Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Workflow Dev

VS Code extension for uploading and running workflows from a local workspace.

## Development

1. Install dependencies and compile:

```sh
npm ci
npm run compile
```

Use `npm run watch` instead of `compile` if you want TypeScript to rebuild on save.

2. Open this repository in VS Code.
3. Open **Run and Debug** and start the **Extension** launch configuration (or press F5). A second VS Code window opens—the **Extension Development Host**, which has this extension loaded.
4. In that window, open a workflow project folder. The repo includes `test-workspace/` as a minimal example.
5. Add a `.vscode/launch.json` in that folder with a `workflow` debug configuration. Two fields are required:
- `api` — base URL of the Workflow API (e.g. `https://milestones-tst.fnwi.uva.nl/`)
- `version` — version name used when uploading and running the workflow (e.g. `testing-1`)

Then start it from **Run and Debug**. Example configuration:

```json
{
"version": "0.2.0",
"configurations": [
{
"type": "workflow",
"request": "launch",
"name": "Launch workflow",
"api": "https://milestones-tst.fnwi.uva.nl/",
"version": "testing-1"
}
]
}
```

## Authentication

The first workflow launch signs in through SURFconext using the authorization-code flow with PKCE.

- OIDC issuer: `https://connect.test.surfconext.nl/`
- Client ID: `milestones-tst.fnwi.uva.nl`
- Callback: `http://127.0.0.1:53682/callback`
- Scopes: `openid profile`

The extension temporarily listens on port 53682, opens SURFconext in the system browser, validates the callback, and exchanges the authorization code. It then requests UserInfo for the account display name.

Access and ID tokens are stored in VS Code SecretStorage. An access token is reused until it is close to expiry, then the extension starts browser sign-in again. Workflow API requests include the access token as a bearer token.

Port 53682 must be available, and the callback URL must be registered for the client.
These values can be overridden under the `workflow.surfconext` extension settings.

## Sign out

Open the **Accounts/Profile** menu in the bottom-left of VS Code, select the account marked **SURFconext**, and choose **Sign Out**.

This removes the locally stored tokens. It does not end the browser's SURFconext SSO session.

During extension development, logs are written to the parent VS Code window's **Debug Console** with the `[workflow-dev]` prefix.
38 changes: 35 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

43 changes: 41 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,48 @@
"activationEvents": [
"onDebugResolve:workflow",
"onDebugDynamicConfigurations:workflow",
"onLanguage:yaml"
"onLanguage:yaml",
"onAuthenticationRequest:workflow.surfconext"
],
"main": "./out/extension.js",
"contributes": {
"authentication": [
{
"id": "workflow.surfconext",
"label": "SURFconext"
}
],
"configuration": {
"title": "Workflow authentication",
"properties": {
"workflow.surfconext.authority": {
"type": "string",
"default": "https://connect.test.surfconext.nl/",
"description": "SURFconext OIDC issuer URL. Reload the window after changing this setting."
},
"workflow.surfconext.clientId": {
"type": "string",
"default": "milestones-tst.fnwi.uva.nl",
"description": "SURFconext OIDC client ID. Reload the window after changing this setting."
},
"workflow.surfconext.redirectUri": {
"type": "string",
"default": "http://127.0.0.1:53682/callback",
"description": "Registered loopback callback URL. Reload the window after changing this setting."
},
"workflow.surfconext.scopes": {
"type": "array",
"items": {
"type": "string"
},
"default": [
"openid",
"profile"
],
"description": "OIDC scopes requested during sign-in. Reload the window after changing this setting."
}
}
},
"debuggers": [
{
"type": "workflow",
Expand Down Expand Up @@ -117,7 +155,8 @@
"typescript": "^5.9.3"
},
"dependencies": {
"@vscode/debugadapter": "^1.68.0"
"@vscode/debugadapter": "^1.68.0",
"openid-client": "^6.8.2"
},
"extensionDependencies": [
"redhat.vscode-yaml"
Expand Down
87 changes: 56 additions & 31 deletions src/WorkflowDebugSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { InitializedEvent, LoggingDebugSession, OutputEvent } from "@vscode/debu
import { DebugProtocol } from "@vscode/debugprotocol";
import path from "path";
import * as vscode from 'vscode';
import { AccessTokenProvider, uploadWorkflowAuthenticated, workflowLaunchOutput } from './workflowApi.js';
import { logger } from './logger.js';

interface WorkflowLaunchRequestArguments extends DebugProtocol.LaunchRequestArguments {
version: string;
Expand All @@ -11,7 +13,7 @@ interface WorkflowLaunchRequestArguments extends DebugProtocol.LaunchRequestArgu
export class WorkflowDebugSession extends LoggingDebugSession {
private _configurationDone = false;

public constructor() {
public constructor(private readonly tokenProvider: AccessTokenProvider) {
super();
}

Expand All @@ -24,59 +26,82 @@ export class WorkflowDebugSession extends LoggingDebugSession {
protected configurationDoneRequest(response: DebugProtocol.ConfigurationDoneResponse, args: DebugProtocol.ConfigurationDoneArguments): void {
super.configurationDoneRequest(response, args);

console.log('configuration done');
logger.info('Debug session configuration completed.');
this._configurationDone = true;
}

protected async attachRequest(response: DebugProtocol.AttachResponse, args: WorkflowLaunchRequestArguments) {
console.log('attach request not implemented');
logger.warn('Attach request is not implemented.');
}

protected async launchRequest(launchResponse: DebugProtocol.LaunchResponse, args: WorkflowLaunchRequestArguments, request?: DebugProtocol.Request) {
console.log("launching debug session");
const fileMap: { [filename: string]: string } = {};

logger.info(`Starting workflow launch for version "${args.version}" against ${args.api}.`);
try {
// Gather the workspace files and authenticate concurrently. The upload below still awaits
// the token, so a failed or abandoned login never starts an upload.
const [accessToken, fileMap] = await Promise.all([
this.tokenProvider.getAccessToken(),
this.collectWorkflowFiles(),
]);

const response = await uploadWorkflowAuthenticated(
{ api: args.api, version: args.version, files: fileMap },
this.tokenProvider,
accessToken,
);

if (!response.ok) {
const details = (await response.text()).trim();
logger.warn(`Workflow launch stopped because the API returned HTTP ${response.status}.`);
this.sendErrorResponse(launchResponse, {
id: response.status,
format: `Workflow API rejected the upload (${response.status})${details ? `: ${details}` : '.'}`
});
return;
}

this.sendResponse(launchResponse);
logger.info('Workflow upload completed successfully.');
this.sendEvent(new OutputEvent(workflowLaunchOutput(args.api, args.version)));
} catch (error) {
logger.error('Workflow launch failed.');
this.sendErrorResponse(launchResponse, {
id: 1001,
format: error instanceof Error ? error.message : 'Could not authenticate or upload the workflow.',
});
}
}

private async collectWorkflowFiles(): Promise<Record<string, string>> {
const fileMap: Record<string, string> = {};

// Find all files in the workspace
const files = await vscode.workspace.findFiles("**/*.yaml");

logger.info(`Found ${files.length} YAML file(s) in the workspace.`);

// Read each file
for (const fileUri of files) {
try {
// Read file content as Uint8Array
const content = await vscode.workspace.fs.readFile(fileUri);

// Convert to string (assuming UTF-8 encoding)
const textContent = Buffer.from(content).toString('utf-8');

// Get relative path from workspace root
const workspaceFolder = vscode.workspace.getWorkspaceFolder(fileUri);
const relativePath = workspaceFolder
const relativePath = workspaceFolder
? path.relative(workspaceFolder.uri.fsPath, fileUri.fsPath)
: fileUri.fsPath;

fileMap[relativePath.replaceAll("\\", "/")] = textContent;
} catch (error) {
console.error(`Error reading file ${fileUri.fsPath}:`, error);
logger.error(`Error reading file ${fileUri.fsPath}:`, error);
// Continue with other files even if one fails
}
}

const response = await fetch(`${args.api}/Versions/${args.version}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(fileMap)
});

if (!response.ok) {
this.sendErrorResponse(launchResponse, {
id: response.status,
format: `Error connecting to backend (${response.status}): ${await response.text()}`
});
} else {
this.sendResponse(launchResponse);
this.sendEvent(new OutputEvent(`Running. View the workflow at https://workflow-dummy-ui.datanose.nl/instances?version=${args.version}&api=${args.api}`));
}
logger.info(`Prepared ${Object.keys(fileMap).length} workflow file(s) for upload.`);
return fileMap;
}
}

}
71 changes: 71 additions & 0 deletions src/auth/LoopbackCallbackServer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { createServer, Server } from 'node:http';
import { logger } from '../logger.js';

export class LoopbackCallbackServer {
private server: Server | undefined;
private readonly redirectUri: URL;

public constructor(redirectUri: string) {
this.redirectUri = new URL(redirectUri);
}

public start(onCallback: (callbackUrl: URL) => void): Promise<{ dispose(): void }> {
if (this.server) {
throw new Error('The SURFconext callback listener is already running.');
}

return new Promise((resolve, reject) => {
let handled = false;
const server = createServer((request, response) => {
const callbackUrl = new URL(request.url ?? '/', this.redirectUri);
if (request.method !== 'GET' || callbackUrl.pathname !== this.redirectUri.pathname) {
logger.warn('Rejected an unexpected request to the loopback callback listener.');
response.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
response.end('Not found.');
return;
}
if (handled) {
logger.warn('Rejected a duplicate loopback callback.');
response.writeHead(409, { 'Content-Type': 'text/plain; charset=utf-8' });
response.end('This sign-in callback has already been handled.');
return;
}

handled = true;
logger.info('Received the SURFconext loopback callback.');
response.writeHead(200, {
'Cache-Control': 'no-store',
'Content-Type': 'text/plain; charset=utf-8',
});
response.end('Sign-in returned to VS Code. You can close this browser tab.');
onCallback(callbackUrl);
});

const fail = (error: Error): void => {
logger.error('Could not start the loopback callback listener.');
this.server = undefined;
reject(error);
};
server.once('error', fail);
server.listen(this.port(), this.redirectUri.hostname, () => {
server.off('error', fail);
server.on('error', () => undefined);
this.server = server;
logger.info(`Listening for the SURFconext callback at ${this.redirectUri.toString()}.`);
resolve({
dispose: () => {
if (this.server === server) {
this.server = undefined;
}
server.close();
logger.info('Stopped the SURFconext callback listener.');
},
});
});
});
}

private port(): number {
return this.redirectUri.port ? Number(this.redirectUri.port) : 80;
}
}
Loading