Skip to content

Commit 821031a

Browse files
Generate process containers with Wave (#122)
--------- Co-authored-by: Ben Sherman <[email protected]>
1 parent ac06845 commit 821031a

File tree

20 files changed

+588
-150
lines changed

20 files changed

+588
-150
lines changed

src/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export const SEQERA_PLATFORM_URL = `https://cloud.seqera.io`;
22
export const SEQERA_API_URL = `${SEQERA_PLATFORM_URL}/api`;
3+
export const SEQERA_HUB_API_URL = `https://hub.seqera.io`;
34
export const SEQERA_INTERN_API_URL = `https://intern.seqera.io`;

src/webview/WebviewProvider/index.ts

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ import {
1111
fetchPlatformData,
1212
fetchRuns,
1313
getRepoInfo,
14-
queryWorkspace
14+
queryWorkspace,
15+
getContainer
1516
} from "./lib";
1617
import { AuthProvider, getAccessToken } from "../../auth";
1718
import { jwtExpired } from "../../auth/AuthProvider/utils/jwt";
@@ -32,11 +33,10 @@ class WebviewProvider implements vscode.WebviewViewProvider {
3233
public async resolveWebviewView(view: vscode.WebviewView): Promise<void> {
3334
this._authProvider?.setWebview(view.webview);
3435
this.initHTML(view);
35-
await sleep(100); // Wait for the app to mount
36-
await this.initViewData();
3736

3837
view.webview.onDidReceiveMessage((message) => {
3938
const { command, workspaceId } = message;
39+
console.log("🟠 onDidReceiveMessage", message);
4040
switch (command) {
4141
case "openFile":
4242
this.openFile(message.path, message.line);
@@ -75,13 +75,19 @@ class WebviewProvider implements vscode.WebviewViewProvider {
7575
case "createTest":
7676
this.createTest(message.filePath);
7777
break;
78+
case "getContainer":
79+
this.getContainer(message.filePath);
80+
break;
7881
}
7982
});
8083

8184
view.onDidChangeVisibility(() => {
8285
if (!view.visible) return;
8386
this.initViewData();
8487
});
88+
89+
await sleep(100); // Wait for the app to mount
90+
await this.initViewData();
8591
}
8692

8793
private async login() {
@@ -148,6 +154,7 @@ class WebviewProvider implements vscode.WebviewViewProvider {
148154

149155
public async initViewData(refresh?: boolean) {
150156
const { viewID, _context, _currentView: view } = this;
157+
console.log("🟠 initViewData", viewID);
151158
if (!view) return;
152159
if (viewID === "seqeraCloud") {
153160
this.getRepoInfo();
@@ -172,7 +179,6 @@ class WebviewProvider implements vscode.WebviewViewProvider {
172179

173180
private async createTest(filePath: string) {
174181
const accessToken = await getAccessToken(this._context);
175-
if (!accessToken) return false;
176182

177183
try {
178184
const created = await createTest(filePath, accessToken);
@@ -183,6 +189,27 @@ class WebviewProvider implements vscode.WebviewViewProvider {
183189
}
184190
}
185191

192+
private async emitContainerCreated(filePath: string, successful: boolean) {
193+
this._currentView?.webview.postMessage({
194+
containerCreated: {
195+
filePath,
196+
successful
197+
}
198+
});
199+
}
200+
201+
private async getContainer(filePath: string) {
202+
const accessToken = await getAccessToken(this._context);
203+
204+
try {
205+
const created = await getContainer(filePath, accessToken);
206+
this.emitContainerCreated(filePath, created);
207+
} catch (error) {
208+
console.log("🟠 Container creation failed", error);
209+
this.emitContainerCreated(filePath, false);
210+
}
211+
}
212+
186213
private async openFile(filePath: string, line: number) {
187214
const doc = await vscode.workspace.openTextDocument(filePath);
188215
await vscode.window.showTextDocument(doc, {

src/webview/WebviewProvider/lib/platform/utils/createTest/fetchContent.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import { SEQERA_INTERN_API_URL } from "../../../../../../constants";
2-
import { systemPrompt } from "./prompt";
2+
import { systemPrompt as defaultSystemPrompt } from "./prompt";
33

44
async function fetchContent(
55
prompt: string,
66
token: string,
7-
onChunk?: (chunk: string) => void
7+
onChunk?: (chunk: string) => void,
8+
givenSystemPrompt?: string
89
): Promise<string> {
910
try {
11+
const systemPrompt = givenSystemPrompt || defaultSystemPrompt;
1012
const fullPrompt = `:::details\n\n${systemPrompt}\n\n${prompt}\n\n:::\n\n`;
1113
const url = `${SEQERA_INTERN_API_URL}/internal-ai/query`;
1214

src/webview/WebviewProvider/lib/platform/utils/createTest/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ function getTestPath(filePath: string): string {
1515
return path.join(testDir, baseName.replace(".nf", ".nf.test"));
1616
}
1717

18-
async function createTest(filePath: string, token: string): Promise<boolean> {
18+
async function createTest(filePath: string, token = ""): Promise<boolean> {
1919
return vscode.window.withProgress(
2020
{
2121
location: vscode.ProgressLocation.Notification,
@@ -70,6 +70,7 @@ async function createTest(filePath: string, token: string): Promise<boolean> {
7070
} catch (error: any) {
7171
const isAuthError =
7272
error?.message?.includes("401") ||
73+
error?.message?.includes("403") ||
7374
error?.message?.includes("Unauthorized");
7475

7576
if (isAuthError) {
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import fetchContent from "../createTest/fetchContent";
2+
import { getPrompt } from "./prompt";
3+
4+
async function generateRequirements(
5+
content: string,
6+
token: string,
7+
onChunk?: (chunk: string) => void
8+
): Promise<string> {
9+
const prompt = getPrompt(content);
10+
11+
try {
12+
const response = await fetchContent(prompt, token, onChunk);
13+
return response;
14+
} catch (error) {
15+
console.error("🟠 Error generating requirements:", error);
16+
throw error;
17+
}
18+
}
19+
20+
export default generateRequirements;
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import * as vscode from "vscode";
2+
import * as fs from "fs";
3+
import generateRequirements from "./generateRequirements";
4+
import { startBuild } from "./startBuild";
5+
6+
async function getContainer(filePath: string, token = ""): Promise<boolean> {
7+
return vscode.window.withProgress(
8+
{
9+
location: vscode.ProgressLocation.Notification,
10+
cancellable: false
11+
},
12+
async (progress) => {
13+
try {
14+
// Open the original file
15+
const originalFilePath = vscode.Uri.file(filePath);
16+
await vscode.workspace.openTextDocument(originalFilePath);
17+
18+
const content = fs.readFileSync(filePath, "utf8");
19+
20+
// Find required packages
21+
progress.report({ message: "Seqera AI: Finding required packages" });
22+
const generatedContent = await generateRequirements(content, token);
23+
24+
// Start container build
25+
progress.report({ message: "Wave: Starting container build" });
26+
const buildResult = await startBuild(generatedContent);
27+
28+
if (buildResult.error) {
29+
vscode.window.showErrorMessage(
30+
`Wave: Failed to build container: ${buildResult.error}`
31+
);
32+
return false;
33+
}
34+
35+
const { buildId, containerImage } = buildResult;
36+
37+
if (buildId) {
38+
const buildUrl = `https://wave.seqera.io/view/builds/${buildId}`;
39+
const openBuildAction = "See details";
40+
41+
if (containerImage) {
42+
progress.report({ message: "Wave: Container built" });
43+
44+
// Show URL
45+
await vscode.window.showInputBox({
46+
value: containerImage,
47+
ignoreFocusOut: true,
48+
title: "Wave Image URL"
49+
});
50+
51+
// Copy to clipboard
52+
await vscode.env.clipboard.writeText(containerImage);
53+
54+
// Show success message
55+
vscode.window
56+
.showInformationMessage(
57+
`Wave: Copied to clipboard`,
58+
openBuildAction
59+
)
60+
.then((selection) => {
61+
if (selection === openBuildAction) {
62+
vscode.env.openExternal(vscode.Uri.parse(buildUrl));
63+
}
64+
});
65+
}
66+
} else {
67+
vscode.window.showErrorMessage("Wave: Failed to build container");
68+
return false;
69+
}
70+
71+
return true;
72+
} catch (error: any) {
73+
const isAuthError =
74+
error?.message?.includes("401") ||
75+
error?.message?.includes("403") ||
76+
error?.message?.includes("Unauthorized");
77+
78+
if (isAuthError) {
79+
return handleAuthError();
80+
} else {
81+
vscode.window.showErrorMessage(
82+
`Wave: Failed to build container: ${error?.message}`
83+
);
84+
return false;
85+
}
86+
}
87+
}
88+
);
89+
}
90+
91+
async function handleAuthError(): Promise<boolean> {
92+
const loginAction = "Login to Seqera Cloud";
93+
const result = await vscode.window.showInformationMessage(
94+
"Authentication required to generate nf-test. Please login to continue.",
95+
loginAction
96+
);
97+
98+
if (result === loginAction) {
99+
await vscode.commands.executeCommand("nextflow.seqera.login");
100+
}
101+
return false;
102+
}
103+
104+
export default getContainer;
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
export const systemPrompt = `You ONLY output pure CSV, nothing else. Do not include backticks or code blocks. You do not output any other text such as explanations, or anything else. You output pure CSV.`;
2+
3+
export const getPrompt = (fileContents: string) => {
4+
return `
5+
Analyze the following code, and determine which packages would be required to make a Wave container for it.
6+
Your response should be in the format of "channel::package=version,channel::package=version".
7+
For example: "bioconda::bcftools=1.2,pip:numpy==2.0.0rc1,bioconda::bioconductor-iranges=2.36.0"
8+
9+
Here is the code:
10+
${fileContents}
11+
`;
12+
};
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { SEQERA_HUB_API_URL } from "../../../../../../constants";
2+
import type {
3+
ImageType,
4+
PackageResult,
5+
Platform,
6+
WaveBuild,
7+
WaveResponse
8+
} from "./types";
9+
10+
type StartBuildPayload = {
11+
packages: {
12+
type: "CONDA";
13+
channels: string[];
14+
entries: string[];
15+
};
16+
format?: string;
17+
containerPlatform?: string;
18+
};
19+
20+
function parsePackagesString(packagesString: string): PackageResult[] {
21+
return packagesString.split(",").map((pkg) => {
22+
const [spec, version] = pkg.split("=");
23+
const [channel, name] = spec.split("::");
24+
25+
return {
26+
name,
27+
source: channel === "pip" ? "pip" : "conda",
28+
selected_version: version
29+
};
30+
});
31+
}
32+
33+
/**
34+
* Starts a container build process with the specified packages and configuration
35+
* @param packagesString - String of packages in format "channel::package=version,channel::package=version"
36+
* @param imageType - Type of container image to build (e.g. "singularity")
37+
* @param selectedPlatform - Target platform for the container
38+
* @returns Promise resolving to the build information or error
39+
*/
40+
export async function startBuild(
41+
packagesString: string,
42+
imageType?: ImageType,
43+
selectedPlatform?: Platform
44+
): Promise<WaveBuild> {
45+
const packages = parsePackagesString(packagesString);
46+
47+
if (packages.length === 0) {
48+
throw new Error("No packages added to container");
49+
}
50+
51+
console.log("🟢 Starting container build with packages:", packages);
52+
53+
const url = `${SEQERA_HUB_API_URL}/container`;
54+
55+
// Collect all required channels, including default ones
56+
const channels = new Set(["conda-forge", "bioconda"]);
57+
packages.forEach((p) => {
58+
if (p.channel && !channels.has(p.channel)) {
59+
channels.add(p.channel);
60+
}
61+
});
62+
63+
const payload: StartBuildPayload = {
64+
packages: {
65+
type: "CONDA",
66+
channels: Array.from(channels),
67+
entries: packages.map(
68+
(p) => `${p.name}${p.selected_version ? `=${p.selected_version}` : ""}`
69+
)
70+
}
71+
};
72+
73+
if (imageType === "singularity") {
74+
payload.format = "sif";
75+
}
76+
77+
if (selectedPlatform === "linux/arm64") {
78+
payload.containerPlatform = "arm64";
79+
}
80+
81+
console.log("🟢 Build payload:", payload);
82+
83+
try {
84+
const response = await fetch(url, {
85+
method: "POST",
86+
headers: { "content-type": "application/json" },
87+
body: JSON.stringify(payload)
88+
});
89+
90+
const build = (await response.json()) as WaveResponse;
91+
92+
if (response.ok) {
93+
console.log("🟢 Container built:", build);
94+
return build;
95+
}
96+
97+
console.log(
98+
"🟠 Build request failed:",
99+
response.status,
100+
response.statusText
101+
);
102+
return { buildId: build.buildId, error: response.statusText };
103+
} catch (error) {
104+
console.error("🟠 Error starting build:", error);
105+
throw new Error(
106+
`${error instanceof Error ? error.message : String(error)}`
107+
);
108+
}
109+
}

0 commit comments

Comments
 (0)