From 375b04efca09dfd9bcb02a6c86772255a159ed1f Mon Sep 17 00:00:00 2001
From: Zhong-Chenchen <2021302121369@whu.edu.cn>
Date: Wed, 11 Nov 2026 15:40:17 +0800
Subject: [PATCH 1/6] Add VS Code integration for LoopForge workflows
---
README.md | 14 +
vscode-extension/.gitignore | 2 +
vscode-extension/.vscodeignore | 6 +
vscode-extension/LICENSE | 52 +++
vscode-extension/README.md | 61 +++
.../acceptance/platform-client.js | 36 ++
vscode-extension/acceptance/platform_test.go | 41 ++
vscode-extension/extension.js | 355 ++++++++++++++++++
vscode-extension/lib/client.js | 99 +++++
vscode-extension/package.json | 117 ++++++
vscode-extension/resources/loopforge.svg | 4 +
vscode-extension/test/client.test.js | 62 +++
vscode-extension/test/extension.test.js | 124 ++++++
13 files changed, 973 insertions(+)
create mode 100644 vscode-extension/.gitignore
create mode 100644 vscode-extension/.vscodeignore
create mode 100644 vscode-extension/LICENSE
create mode 100644 vscode-extension/README.md
create mode 100644 vscode-extension/acceptance/platform-client.js
create mode 100644 vscode-extension/acceptance/platform_test.go
create mode 100644 vscode-extension/extension.js
create mode 100644 vscode-extension/lib/client.js
create mode 100644 vscode-extension/package.json
create mode 100644 vscode-extension/resources/loopforge.svg
create mode 100644 vscode-extension/test/client.test.js
create mode 100644 vscode-extension/test/extension.test.js
diff --git a/README.md b/README.md
index d4ec28d9..4bec5cab 100644
--- a/README.md
+++ b/README.md
@@ -193,6 +193,20 @@ See the [local guide](docs/local/usage.md) for each host's entry point and [edit
| Workflows | Chat with Main Bot + custom SOPs on a visual canvas | Built-in Classic / Portable workflows |
| Evidence | Shared run details, approvals and metrics in the web workspace | Artifacts under `artifacts/{task_slug}/` + optional local dashboard |
+## LoopForge for VS Code
+
+The standalone desktop [VS Code extension](vscode-extension/) connects to a LoopForge platform server, starts a workflow with project and selected-code context, and shows run status, logs, approvals, artifacts, files and diffs. It targets VS Code 1.85+ and the platform REST API.
+
+Build and install a local VSIX:
+
+```bash
+cd vscode-extension
+npx --yes @vscode/vsce package --no-dependencies
+code --install-extension loopforge-0.1.4.vsix
+```
+
+See the [extension guide](vscode-extension/README.md) for configuration, API token storage, capability boundaries, upgrades and uninstalling.
+
## FAQ
**Do I need an API key to try it?**
diff --git a/vscode-extension/.gitignore b/vscode-extension/.gitignore
new file mode 100644
index 00000000..e4aa8462
--- /dev/null
+++ b/vscode-extension/.gitignore
@@ -0,0 +1,2 @@
+*.vsix
+node_modules/
diff --git a/vscode-extension/.vscodeignore b/vscode-extension/.vscodeignore
new file mode 100644
index 00000000..9f7acb03
--- /dev/null
+++ b/vscode-extension/.vscodeignore
@@ -0,0 +1,6 @@
+node_modules
+test
+lib/*.test.js
+*.map
+.gitignore
+acceptance
diff --git a/vscode-extension/LICENSE b/vscode-extension/LICENSE
new file mode 100644
index 00000000..3d01ea41
--- /dev/null
+++ b/vscode-extension/LICENSE
@@ -0,0 +1,52 @@
+Tencent is pleased to support the open source community by making LoopForge available.
+
+Copyright (C) 2026 Tencent. All rights reserved.
+
+LoopForge is licensed under MIT, except for the third-party components listed below, which remain licensed under their respective original terms. LoopForge does not impose any additional restrictions beyond those specified in the original licenses of these third-party components. Users are required to comply with all applicable terms and conditions of the original licenses.
+
+In case you believe there have been errors in the attribution below, you may submit the concerns to us for review and correction.
+
+Terms of the MIT License:
+--------------------------------------------------------------------
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice (including the next paragraph) shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+
+Dependencies and Licenses:
+
+The below software in this distribution may have been modified by Tencent ("Tencent Modifications"). All Tencent Modifications are Copyright (C) Tencent.
+
+
+Open Source Software Licensed under the MIT:
+--------------------------------------------------------------------
+1. superpowers
+Copyright (c) 2025 Jesse Vincent
+Please note this software has been modified by Tencent in this distribution.
+You may find the original code here: https://github.com/obra/superpowers
+Terms of the MIT:
+--------------------------------------------------------------------
+MIT License
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+==================================================
+End of the Attribution Notice of this project.
diff --git a/vscode-extension/README.md b/vscode-extension/README.md
new file mode 100644
index 00000000..143f98db
--- /dev/null
+++ b/vscode-extension/README.md
@@ -0,0 +1,61 @@
+# LoopForge for VS Code
+
+This directory contains the first standalone desktop VS Code extension for LoopForge. It connects to an existing LoopForge HTTP server and reuses the platform Runs API; it does not modify the LoopForge server or install a second workflow engine in the editor.
+
+## Supported scope
+
+- VS Code 1.85 or newer on desktop.
+- LoopForge platform API with `POST /api/runs`, the Runs detail/log/approval/artifact endpoints, and the workspace diff endpoint.
+- Platform workflows (SOPs). The extension starts a run through the server; model providers and execution environments remain configured by LoopForge.
+- A server URL and, when authentication is enabled, a LoopForge Personal API Token. The token is stored in VS Code SecretStorage and is never written to workspace settings.
+
+The extension uses the active file's workspace folder (or the first folder if no file is active). `LoopForge: Start Workflow` asks for a task description and appends the project path, active file path and up to 4,000 characters of selected text as input. This sends the selected content to the configured server. Paths are descriptive: the extension does not upload or mount the local project. Configure a repository on the platform if the workflow needs source files. The server remains the source of truth for project access, workflow permissions, execution, and secrets.
+
+## Install from a VSIX
+
+From the repository root:
+
+```bash
+cd vscode-extension
+npx --yes @vscode/vsce package --no-dependencies
+code --install-extension loopforge-0.1.4.vsix
+```
+
+The package can later be published to an extension marketplace with the publisher's normal VSCE credentials. No marketplace account or credential is required to build or install the VSIX locally.
+
+## Configure
+
+Use the command palette:
+
+1. Start or obtain access to a LoopForge platform server. Set `loopforge.serverUrl` in user or remote settings to its base URL (for example `http://127.0.0.1:8090` when the server runs on the extension host). Use HTTPS for a remote server. Workspace settings cannot change the server address, to prevent a project from redirecting the stored token.
+2. Create a Personal API Token in the platform's account settings if authentication is enabled. `LoopForge: Configure API Token` stores or clears it in SecretStorage. A personal token is not a model-provider key.
+3. `LoopForge: Select Project` and `LoopForge: Select Workflow` discover choices from the configured server, or set `loopforge.projectId` and `loopforge.workflowId` in workspace settings. A project owner or authenticated user is required for manual runs.
+
+When switching projects with `LoopForge: Select Project`, the extension clears the workspace's workflow selection so the next run asks for a workflow in the new project. If you edit these settings manually, choose a workflow that belongs to the selected project or is shared with it.
+
+The extension only writes the two explicitly selected LoopForge workspace settings. It does not change unrelated VS Code settings. Uninstalling removes the extension files and leaves the workspace settings and SecretStorage entry under the user's control; clear the token with `LoopForge: Configure API Token` when desired.
+
+## Commands and run view
+
+The LoopForge activity bar view lists recent runs, their status and active stage, waiting human approvals, and artifact metadata. The commands provide:
+
+- Start a workflow with current project/file/selection context.
+- Refresh and inspect status, stage output, and logs.
+- Approve or send back a stage that is waiting for human input. The client includes the node attempt expectation so stale approval cards are rejected by the server.
+- Open an artifact in a VS Code editor.
+- Open a changed file from the run workspace in a VS Code editor.
+- Open the current workspace change as a VS Code diff. If there are multiple changes, the extension asks which path to compare.
+
+Connection, authentication, timeout, HTTP, and server validation errors are shown in the Runs view or as VS Code error messages. Run polling is bounded by `loopforge.pollInterval` and is disabled on deactivation. To upgrade, install the new VSIX through VS Code; to uninstall, use the Extensions view. Existing LoopForge settings may remain after uninstall; remove them manually if desired.
+
+## Capability boundary
+
+The first release is a platform-connected adapter. It does not claim to execute a workflow locally, provide a model/provider setup wizard, or replace the LoopForge Web workspace. Those remain platform responsibilities and are reported by the same API error messages used by the Web client.
+
+Changed files and diffs are available only while the platform retains the run workspace. In the current platform, a successful or cancelled ordinary workflow can have its workspace reclaimed immediately, so open its changes before completing the workflow when they are needed. After reclamation the extension reports the platform's reason; artifacts stored by the platform remain separately accessible. Workspace snapshots and restoration depend on the platform and are not provided by this extension.
+
+If a connection fails, check that the platform is running and that `loopforge.serverUrl` is reachable from the machine hosting the extension. In a Remote SSH session, `127.0.0.1` refers to the remote extension host, not the desktop running the VS Code window.
+
+## Tests
+
+Run `npm test` in this directory for extension interaction and client tests. To exercise the client against the actual platform HTTP handlers and workflow engine using an isolated in-memory test database, build the repository's web assets as described in the [contributing guide](../CONTRIBUTING.md), then run `go test -v ./vscode-extension/acceptance` from the repository root. The acceptance workflow is a single human approval step and does not need Docker, organization credentials, or a model provider.
diff --git a/vscode-extension/acceptance/platform-client.js b/vscode-extension/acceptance/platform-client.js
new file mode 100644
index 00000000..a6506ca5
--- /dev/null
+++ b/vscode-extension/acceptance/platform-client.js
@@ -0,0 +1,36 @@
+'use strict';
+const assert = require('node:assert/strict');
+const { LoopForgeClient } = require('../lib/client');
+(async () => {
+ const api = new LoopForgeClient({ baseUrl: process.argv[2] });
+ const input = 'VS Code acceptance\nfile: src/main.js\nselection:\nline one\nline two';
+ const run = await api.createRun({ workflowId: 'vscode-acceptance', projectId: 'platform', input });
+ assert.ok(run.id);
+ let detail;
+ for (let i = 0; i < 100; i++) {
+ detail = await api.getRun(run.id);
+ if (detail.nodes?.gate?.status === 'waiting_approval') break;
+ await new Promise(resolve => setTimeout(resolve, 100));
+ }
+ assert.equal(detail.nodes.gate.status, 'waiting_approval');
+ assert.equal(detail.input, input);
+ const gate = detail.nodes.gate;
+ await assert.rejects(
+ () => api.approve(run.id, { nodeId: 'gate', expected: { status: gate.status, attempt: gate.attempt + 1, reworkCount: gate.reworkCount } }),
+ error => error.status === 400 && /节点状态已变化/.test(error.message)
+ );
+ assert.equal((await api.getRun(run.id)).nodes.gate.status, 'waiting_approval');
+ await api.approve(run.id, { nodeId: 'gate', context: 'Reviewed', expected: { status: gate.status, attempt: gate.attempt, reworkCount: gate.reworkCount } });
+ for (let i = 0; i < 100; i++) {
+ detail = await api.getRun(run.id);
+ if (detail.status === 'succeeded') break;
+ await new Promise(resolve => setTimeout(resolve, 100));
+ }
+ assert.equal(detail.status, 'succeeded');
+ const artifact = detail.artifacts.find(item => item.name === 'decision');
+ assert.ok(artifact?.id, 'approval decision artifact missing');
+ const content = await api.getArtifact(run.id, artifact.id);
+ assert.ok(content.includes('Reviewed'));
+ assert.ok(Array.isArray((await api.getLogs(run.id)).logs));
+ console.log('Real platform: created run, preserved context, approved, inspected result and retrieved artifact.');
+})().catch(error => { console.error(error); process.exitCode = 1; });
diff --git a/vscode-extension/acceptance/platform_test.go b/vscode-extension/acceptance/platform_test.go
new file mode 100644
index 00000000..457b736a
--- /dev/null
+++ b/vscode-extension/acceptance/platform_test.go
@@ -0,0 +1,41 @@
+package acceptance
+
+import (
+ "os"
+ "os/exec"
+ "path/filepath"
+ "testing"
+
+ "agentflow/internal/domain"
+ "agentflow/internal/engine"
+ "agentflow/internal/httpapi"
+ "agentflow/internal/repo"
+ "agentflow/internal/runtime"
+ "agentflow/internal/tracker"
+ "github.com/gin-gonic/gin"
+ "net/http/httptest"
+)
+
+func TestExtensionClientAgainstRealPlatform(t *testing.T) {
+ t.Setenv("AGENTFLOW_AUTH_REQUIRED", "false")
+ gin.SetMode(gin.ReleaseMode)
+ store, err := repo.OpenTest()
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer store.Close()
+ wf := domain.WorkflowDefinition{ID: "vscode-acceptance", Name: "VS Code acceptance", SandboxProfileID: "local", Nodes: []domain.WorkflowNode{{ID: "gate", Name: "Approval", Type: "approval", MaxAttempts: 1}}}
+ if err := store.PutWorkflow(wf); err != nil {
+ t.Fatal(err)
+ }
+ engine := engine.New(store, runtime.NewManager(filepath.Join(t.TempDir(), "workspaces")))
+ server := httptest.NewServer(httpapi.New(store, engine, tracker.NewService(), "", ""))
+ defer server.Close()
+ cmd := exec.Command("node", "platform-client.js", server.URL)
+ cmd.Env = append(os.Environ(), "NO_PROXY=127.0.0.1,localhost", "no_proxy=127.0.0.1,localhost")
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ t.Fatalf("extension client: %v\n%s", err, out)
+ }
+ t.Log(string(out))
+}
diff --git a/vscode-extension/extension.js b/vscode-extension/extension.js
new file mode 100644
index 00000000..e44db6b1
--- /dev/null
+++ b/vscode-extension/extension.js
@@ -0,0 +1,355 @@
+'use strict';
+
+const vscode = require('vscode');
+const path = require('path');
+const { LoopForgeClient, LoopForgeError } = require('./lib/client');
+
+const CONFIG = 'loopforge';
+const TOKEN_KEY = 'loopforge.apiToken';
+
+function config() { return vscode.workspace.getConfiguration(CONFIG); }
+function configured(name, fallback = '') { return config().get(name, fallback); }
+function workspaceFolder() {
+ const uri = vscode.window.activeTextEditor?.document.uri;
+ return (uri && vscode.workspace.getWorkspaceFolder(uri)) || vscode.workspace.workspaceFolders?.[0];
+}
+function workspaceName() {
+ const folder = workspaceFolder();
+ return folder ? (folder.name || path.basename(folder.uri.fsPath)) : '当前项目';
+}
+function trimText(value, max = 4000) {
+ const text = String(value || '').trim();
+ return text.length > max ? `${text.slice(0, max)}\n…(已截断)` : text;
+}
+function selectedContext() {
+ const editor = vscode.window.activeTextEditor;
+ if (!editor) return { file: '', selection: '' };
+ const file = vscode.workspace.asRelativePath(editor.document.uri, false);
+ const selected = editor.selection && !editor.selection.isEmpty ? editor.document.getText(editor.selection) : '';
+ const selection = selected.length > 4000 ? `${selected.slice(0, 4000)}\n…(已截断)` : selected;
+ return { file, selection };
+}
+function languageFor(name) {
+ const ext = path.extname(String(name || '')).toLowerCase();
+ return { '.md': 'markdown', '.json': 'json', '.js': 'javascript', '.ts': 'typescript', '.py': 'python', '.go': 'go', '.css': 'css', '.html': 'html', '.yaml': 'yaml', '.yml': 'yaml', '.sh': 'shell' }[ext] || 'plaintext';
+}
+function runStatusLabel(run) {
+ const status = run?.status || 'unknown';
+ const nodes = Object.values(run?.nodes || {});
+ const active = nodes.find(node => ['running', 'waiting_approval', 'waiting'].includes(node?.status));
+ const stage = active ? `${active.nodeId || 'stage'} · ${active.status}` : '';
+ return stage ? `${status} · ${stage}` : status;
+}
+function waitingApproval(run) {
+ for (const node of Object.values(run?.nodes || {})) {
+ if (node && node.status === 'waiting_approval') return node;
+ }
+ return null;
+}
+
+class RunTreeItem extends vscode.TreeItem {
+ constructor(run, kind = 'run', parent) {
+ const label = kind === 'run' ? `${run.id} · ${runStatusLabel(run)}` : run;
+ super(label, kind === 'run' ? vscode.TreeItemCollapsibleState.Collapsed : vscode.TreeItemCollapsibleState.None);
+ this.run = kind === 'run' ? run : parent?.run;
+ this.kind = kind;
+ this.parent = parent;
+ this.contextValue = `loopforge.${kind}`;
+ this.tooltip = kind === 'run' ? `${run.workflowId || run.workflowSnapshot?.name || 'workflow'}\n${run.input || ''}` : label;
+ if (kind === 'run') {
+ this.description = run.workflowSnapshot?.name || run.workflowId || '';
+ this.iconPath = new vscode.ThemeIcon(run.status === 'succeeded' ? 'pass' : run.status === 'failed' ? 'error' : 'sync~spin');
+ this.command = { command: 'loopforge.showStatus', title: 'Show Status', arguments: [this] };
+ } else if (kind === 'approval') {
+ this.description = '需要人工确认';
+ this.iconPath = new vscode.ThemeIcon('question');
+ this.command = { command: 'loopforge.approve', title: 'Approve', arguments: [this] };
+ } else {
+ const artifact = parent?.artifact;
+ this.description = `${artifact?.mediaType || 'text'} · ${artifact?.size || 0} bytes`;
+ this.iconPath = new vscode.ThemeIcon('file');
+ this.command = { command: 'loopforge.openArtifact', title: 'Open Artifact', arguments: [this] };
+ }
+ }
+}
+
+class LoopForgeTreeProvider {
+ constructor(state) { this.state = state; this.emitter = new vscode.EventEmitter(); this.onDidChangeTreeData = this.emitter.event; }
+ refresh() { this.emitter.fire(); }
+ getTreeItem(item) { return item; }
+ async getChildren(item) {
+ if (!item) {
+ if (this.state.lastError) return [new vscode.TreeItem(`连接失败:${this.state.lastError}`)];
+ const runs = this.state.runs || [];
+ if (!runs.length) return [new vscode.TreeItem('没有最近任务。运行 “LoopForge: Start Workflow” 开始。')];
+ return runs.map(run => new RunTreeItem(run));
+ }
+ if (item.kind !== 'run') return [];
+ try {
+ item.run = await this.state.detail(item, false);
+ } catch (error) {
+ this.state.error(error);
+ return [new vscode.TreeItem(`读取任务详情失败:${error.message}`)];
+ }
+ const children = [];
+ for (const approval of Object.values(item.run.nodes || {}).filter(node => node?.status === 'waiting_approval')) {
+ const approvalItem = new RunTreeItem(`审批:${approval.nodeId || '当前阶段'}`, 'approval', item);
+ // Keep the attempt displayed in the tree even if the run changes later.
+ approvalItem.approval = { ...approval };
+ children.push(approvalItem);
+ }
+ for (const artifact of item.run.artifacts || []) {
+ const artifactItem = new RunTreeItem(artifact.name || artifact.id, 'artifact', item);
+ artifactItem.artifact = artifact;
+ artifactItem.description = `${artifact.mediaType || 'text'} · ${artifact.size || 0} bytes`;
+ children.push(artifactItem);
+ }
+ if (!children.length) children.push(new vscode.TreeItem('暂无审批或产物'));
+ return children;
+ }
+}
+
+class ExtensionState {
+ constructor(context) {
+ this.context = context;
+ this.runs = [];
+ this.client = null;
+ this.output = vscode.window.createOutputChannel('LoopForge');
+ this.tree = new LoopForgeTreeProvider(this);
+ this.refreshing = false;
+ this.pollTimer = undefined;
+ this.lastError = '';
+ }
+ async connect() {
+ const token = await this.context.secrets.get(TOKEN_KEY) || '';
+ this.client = new LoopForgeClient({ baseUrl: configured('serverUrl', 'http://127.0.0.1:8090'), token });
+ return this.client;
+ }
+ async refresh({ quiet = false } = {}) {
+ if (this.refreshing) return;
+ this.refreshing = true;
+ try {
+ await this.connect();
+ this.runs = await this.client.listRuns(configured('projectId', ''), Number(configured('maxRuns', 20)));
+ this.lastError = '';
+ this.tree.refresh();
+ if (!quiet) vscode.window.setStatusBarMessage(`LoopForge: 已刷新 ${this.runs.length} 个任务`, 3000);
+ } catch (error) {
+ this.lastError = error.message;
+ this.tree.refresh();
+ if (!quiet) this.error(error);
+ else this.output.appendLine(`Refresh failed: ${error.message}`);
+ } finally { this.refreshing = false; }
+ }
+ error(error) {
+ const message = error instanceof LoopForgeError ? error.message : `LoopForge error: ${error?.message || error}`;
+ this.output.appendLine(message);
+ vscode.window.showErrorMessage(message);
+ }
+ async detail(runOrItem, refreshTree = true) {
+ await this.connect();
+ const run = runOrItem?.run || runOrItem;
+ if (!run?.id) throw new LoopForgeError('No LoopForge run is selected.');
+ const detail = await this.client.getRun(run.id);
+ const index = this.runs.findIndex(item => item.id === detail.id);
+ if (index >= 0) this.runs[index] = detail;
+ if (refreshTree) this.tree.refresh();
+ return detail;
+ }
+ dispose() {
+ if (this.pollTimer) clearInterval(this.pollTimer);
+ this.output.dispose();
+ this.tree.emitter.dispose();
+ }
+}
+
+async function chooseProject(state, force = false) {
+ const value = configured('projectId', '');
+ if (value && !force) return value;
+ await state.connect();
+ const projects = await state.client.listProjects();
+ const items = (Array.isArray(projects) ? projects : []).map(project => ({ label: project.name || project.id, description: project.id, project }));
+ if (!items.length) throw new LoopForgeError('No projects found. Create a project in LoopForge first.');
+ const selected = await vscode.window.showQuickPick(items, { placeHolder: '选择 LoopForge 项目' });
+ return selected?.project?.id;
+}
+async function chooseWorkflow(state, projectId, force = false) {
+ const value = configured('workflowId', '');
+ if (value && !force) return value;
+ await state.connect();
+ const workflows = await state.client.listWorkflows();
+ const items = (Array.isArray(workflows) ? workflows : []).filter(workflow => !projectId || !workflow.projectId || workflow.projectId === projectId).map(workflow => ({ label: workflow.name || workflow.id, description: workflow.id, workflow }));
+ if (!items.length) throw new LoopForgeError('No workflows were returned by LoopForge. Configure loopforge.workflowId or publish a workflow first.');
+ const selected = await vscode.window.showQuickPick(items, { placeHolder: '选择 LoopForge 工作流' });
+ return selected?.workflow.id;
+}
+function workflowInput() {
+ const context = selectedContext();
+ const lines = [`在项目“${workspaceName()}”中执行 LoopForge 工作流。`];
+ const folder = workspaceFolder();
+ if (folder) lines.push(`项目路径:${folder.uri.fsPath}`);
+ if (context.file) lines.push(`当前文件:${context.file}`);
+ if (context.selection) lines.push(`选中代码:\n${context.selection}`);
+ return lines.join('\n');
+}
+
+async function startWorkflow(state) {
+ // Capture editor context before opening any UI, including in multi-root workspaces.
+ const context = workflowInput();
+ const projectId = await chooseProject(state);
+ if (!projectId) return;
+ const workflowId = await chooseWorkflow(state, projectId);
+ if (!workflowId) return;
+ const input = await vscode.window.showInputBox({ prompt: '描述要执行的 LoopForge 工作流(当前文件和选中代码将作为上下文附加)', ignoreFocusOut: true, validateInput: value => value.trim() ? undefined : '请输入工作流任务描述' });
+ if (input === undefined) return;
+ await state.connect();
+ const run = await state.client.createRun({ workflowId, input: `${input.trim()}\n\n${context}`, projectId });
+ state.output.appendLine(`Started run ${run.id} (${workflowId})`);
+ await state.refresh({ quiet: true });
+ vscode.window.showInformationMessage(`LoopForge 任务已启动:${run.id}`, '查看状态').then(action => { if (action === '查看状态') showStatus(state, { run }); });
+}
+async function showStatus(state, item) {
+ try {
+ const run = await state.detail(item || state.runs[0]);
+ const lines = [`Run: ${run.id}`, `Workflow: ${run.workflowSnapshot?.name || run.workflowId}`, `Status: ${run.status}`, `Input: ${run.input || ''}`];
+ for (const node of Object.values(run.nodes || {})) lines.push(`Stage ${node.nodeId}: ${node.status}${node.error ? ` — ${node.error}` : ''}`);
+ if (run.status === 'waiting' || waitingApproval(run)) lines.push('Human action is required. Use the approval item in the LoopForge view.');
+ state.output.clear(); state.output.appendLine(lines.join('\n')); state.output.show(true);
+ } catch (error) { state.error(error); }
+}
+async function showResult(state, item) {
+ try {
+ const run = await state.detail(item || state.runs[0]);
+ const logs = await state.client.getLogs(run.id).catch(error => ({ logs: [`Unable to load logs: ${error.message}`] }));
+ const lines = [`Run ${run.id}`, `Status: ${run.status}`, '', 'Stages:'];
+ for (const node of Object.values(run.nodes || {})) lines.push(`- ${node.nodeId}: ${node.status}${node.error ? `\n Error: ${node.error}` : ''}${node.output ? `\n ${trimText(node.output, 1000)}` : ''}`);
+ if (Array.isArray(logs.logs)) lines.push('', 'Logs:', ...logs.logs.map(log => typeof log === 'string' ? log : `[${log.timestamp || ''}] ${log.body || log.message || ''}`));
+ state.output.clear(); state.output.appendLine(lines.join('\n')); state.output.show(true);
+ } catch (error) { state.error(error); }
+}
+async function approve(state, item) {
+ try {
+ const run = await state.detail(item || state.runs[0]);
+ const node = item?.approval || waitingApproval(run);
+ if (!node || node.status !== 'waiting_approval') throw new LoopForgeError('This run has no stage waiting for approval.');
+ const context = await vscode.window.showInputBox({ prompt: `确认 ${node.nodeId}(第 ${node.attempt} 次执行,退回 ${node.reworkCount} 次),可附加说明`, value: '' });
+ if (context === undefined) return;
+ await state.client.approve(run.id, { nodeId: node.nodeId, context, expected: { status: node.status, attempt: node.attempt || 0, reworkCount: node.reworkCount || 0 } });
+ vscode.window.showInformationMessage(`LoopForge 已确认 ${node.nodeId || '当前阶段'}`);
+ await state.refresh({ quiet: true });
+ } catch (error) { state.error(error); }
+}
+async function reject(state, item) {
+ try {
+ const run = await state.detail(item || state.runs[0]);
+ const node = item?.approval || waitingApproval(run);
+ if (!node || node.status !== 'waiting_approval') throw new LoopForgeError('This run has no stage waiting for a human decision.');
+ const reason = await vscode.window.showInputBox({ prompt: `退回 ${node.nodeId}(第 ${node.attempt} 次执行,退回 ${node.reworkCount} 次)的原因`, validateInput: value => value.trim() ? undefined : '请输入原因' });
+ if (reason === undefined) return;
+ const workflowNode = (run.workflowSnapshot?.nodes || []).find(value => value.id === node.nodeId);
+ await state.client.reject(run.id, { nodeId: node.nodeId, reason, targetId: workflowNode?.rejectTo || '', expected: { status: node.status, attempt: node.attempt || 0, reworkCount: node.reworkCount || 0 } });
+ vscode.window.showInformationMessage(`LoopForge 已退回 ${node.nodeId || '当前阶段'}`);
+ await state.refresh({ quiet: true });
+ } catch (error) { state.error(error); }
+}
+async function openArtifact(state, item) {
+ try {
+ const run = await state.detail(item || state.runs[0]);
+ let artifact = item?.artifact;
+ if (!artifact) {
+ if (!run.artifacts?.length) throw new LoopForgeError('This run has no artifacts.');
+ const selected = await vscode.window.showQuickPick(run.artifacts.map(value => ({ label: value.name || value.id, artifact: value })), { placeHolder: '选择要打开的产物' });
+ artifact = selected?.artifact;
+ }
+ if (!artifact) return;
+ if (!artifact.id) throw new LoopForgeError('Artifact ID is missing from the server response.');
+ const content = await state.client.getArtifact(run.id, artifact.id);
+ const document = await vscode.workspace.openTextDocument({ language: languageFor(artifact.name), content: String(content || '') });
+ await vscode.window.showTextDocument(document, { preview: false });
+ } catch (error) { state.error(error); }
+}
+async function openDiff(state, item) {
+ try {
+ const run = await state.detail(item || state.runs[0]);
+ const workspace = await state.client.getWorkspace(run.id);
+ if (!workspace.available) throw new LoopForgeError(workspace.reason || 'Run workspace is unavailable.');
+ const changes = workspace?.changes || [];
+ let selectedPath = changes.length === 1 ? changes[0].path : undefined;
+ if (!selectedPath && changes.length > 1) {
+ const selected = await vscode.window.showQuickPick(changes.map(change => ({ label: change.path, description: change.repository || '', change })), { placeHolder: '选择要查看的变更' });
+ if (!selected) return;
+ selectedPath = selected?.change?.path;
+ }
+ if (!selectedPath) {
+ const result = await state.client.getDiff(run.id, '');
+ state.output.clear(); state.output.appendLine(result.diff || 'No diff available.'); state.output.show(true); return;
+ }
+ const diff = await state.client.getDiff(run.id, selectedPath);
+ const original = await vscode.workspace.openTextDocument({ language: languageFor(selectedPath), content: String(diff.original || '') });
+ const modified = await vscode.workspace.openTextDocument({ language: languageFor(selectedPath), content: String(diff.modified || '') });
+ await vscode.commands.executeCommand('vscode.diff', original.uri, modified.uri, `LoopForge: ${selectedPath}`);
+ } catch (error) { state.error(error); }
+}
+async function openFile(state, item) {
+ try {
+ const run = await state.detail(item || state.runs[0]);
+ const workspace = await state.client.getWorkspace(run.id);
+ if (!workspace.available) throw new LoopForgeError(workspace.reason || 'Run workspace is unavailable.');
+ const changes = workspace?.changes || [];
+ if (!changes.length) throw new LoopForgeError('This run has no readable changed files.');
+ const selected = changes.length === 1
+ ? { change: changes[0] }
+ : await vscode.window.showQuickPick(changes.map(change => ({ label: change.path, description: change.repository || '', change })), { placeHolder: '选择要打开的运行文件' });
+ if (!selected?.change?.path) return;
+ const file = await state.client.getWorkspaceFile(run.id, selected.change.path);
+ const document = await vscode.workspace.openTextDocument({ language: languageFor(selected.change.path), content: String(file.content || '') });
+ await vscode.window.showTextDocument(document, { preview: false });
+ } catch (error) { state.error(error); }
+}
+async function configureToken(state) {
+ const token = await vscode.window.showInputBox({ prompt: '输入 LoopForge Personal API Token(保存在 VS Code SecretStorage)', password: true, ignoreFocusOut: true });
+ if (token === undefined) return;
+ if (!token.trim()) { await state.context.secrets.delete(TOKEN_KEY); vscode.window.showInformationMessage('LoopForge API Token 已清除。'); return; }
+ await state.context.secrets.store(TOKEN_KEY, token.trim());
+ vscode.window.showInformationMessage('LoopForge API Token 已安全保存。');
+}
+async function configureSetting(name, value) {
+ await config().update(name, value, vscode.ConfigurationTarget.Workspace);
+}
+
+function activate(context) {
+ const state = new ExtensionState(context);
+ const view = vscode.window.createTreeView('loopforge.runs', { treeDataProvider: state.tree, showCollapseAll: true });
+ const command = (name, fn) => context.subscriptions.push(vscode.commands.registerCommand(name, async (...args) => {
+ try { return await fn(state, ...args); } catch (error) { state.error(error); }
+ }));
+ command('loopforge.startWorkflow', startWorkflow);
+ command('loopforge.showStatus', showStatus);
+ command('loopforge.showResult', showResult);
+ command('loopforge.approve', approve);
+ command('loopforge.reject', reject);
+ command('loopforge.openArtifact', openArtifact);
+ command('loopforge.openFile', openFile);
+ command('loopforge.openDiff', openDiff);
+ command('loopforge.refresh', state => state.refresh());
+ command('loopforge.configureToken', configureToken);
+ command('loopforge.selectProject', async state => {
+ const project = await chooseProject(state, true);
+ if (!project) return;
+ if (project !== configured('projectId', '')) {
+ // An explicit empty workspace value also masks any user-level default.
+ await configureSetting('workflowId', '');
+ await configureSetting('projectId', project);
+ await state.refresh({ quiet: true });
+ vscode.window.showInformationMessage('项目已切换,请重新选择工作流。');
+ }
+ });
+ command('loopforge.selectWorkflow', async state => { const workflow = await chooseWorkflow(state, configured('projectId', ''), true); if (workflow) await configureSetting('workflowId', workflow); });
+ context.subscriptions.push(view, { dispose: () => state.dispose() });
+ state.refresh({ quiet: true });
+ const interval = Math.max(3, Number(configured('pollInterval', 10))) * 1000;
+ state.pollTimer = setInterval(() => state.refresh({ quiet: true }), interval);
+}
+function deactivate() {}
+
+module.exports = { activate, deactivate, selectedContext, workflowInput, runStatusLabel, waitingApproval };
diff --git a/vscode-extension/lib/client.js b/vscode-extension/lib/client.js
new file mode 100644
index 00000000..15bbb91f
--- /dev/null
+++ b/vscode-extension/lib/client.js
@@ -0,0 +1,99 @@
+'use strict';
+
+class LoopForgeError extends Error {
+ constructor(message, details = {}) {
+ super(message);
+ this.name = 'LoopForgeError';
+ Object.assign(this, details);
+ }
+}
+
+function normalizeBaseUrl(value) {
+ const raw = String(value || '').trim();
+ if (!raw) throw new LoopForgeError('LoopForge server URL is empty. Set loopforge.serverUrl first.');
+ let url;
+ try { url = new URL(raw); } catch (_) { throw new LoopForgeError(`Invalid LoopForge server URL: ${raw}`); }
+ if (!['http:', 'https:'].includes(url.protocol)) {
+ throw new LoopForgeError('LoopForge server URL must use http:// or https://.');
+ }
+ if (url.username || url.password || url.search || url.hash) throw new LoopForgeError('Server URL must not include credentials, a query, or a fragment.');
+ return url.toString().replace(/\/$/, '');
+}
+
+function apiPath(path) {
+ return String(path || '').startsWith('/') ? String(path) : `/${path}`;
+}
+
+class LoopForgeClient {
+ constructor({ baseUrl, token = '', fetchImpl = globalThis.fetch, timeoutMs = 30000 } = {}) {
+ this.baseUrl = normalizeBaseUrl(baseUrl);
+ this.token = String(token || '').trim();
+ this.fetch = fetchImpl;
+ this.timeoutMs = timeoutMs;
+ if (typeof this.fetch !== 'function') throw new LoopForgeError('This VS Code runtime does not provide fetch.');
+ }
+
+ async request(path, { method = 'GET', body, signal, parseJson = true } = {}) {
+ const headers = { Accept: 'application/json' };
+ if (body !== undefined) headers['Content-Type'] = 'application/json';
+ if (this.token) headers.Authorization = `Bearer ${this.token}`;
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
+ if (signal) {
+ if (signal.aborted) controller.abort();
+ signal.addEventListener('abort', () => controller.abort(), { once: true });
+ }
+ let response, raw;
+ try {
+ response = await this.fetch(`${this.baseUrl}${apiPath(path)}`, {
+ method, headers, body: body === undefined ? undefined : JSON.stringify(body), signal: controller.signal, redirect: 'error'
+ });
+ raw = await response.text();
+ } catch (error) {
+ if (error?.name === 'AbortError') throw new LoopForgeError(`LoopForge request timed out after ${this.timeoutMs}ms.`, { cause: error, code: 'timeout' });
+ throw new LoopForgeError(`Cannot connect to LoopForge at ${this.baseUrl}: ${error?.message || error}. Check that the LoopForge server is running and that loopforge.serverUrl is reachable from the VS Code extension host.`, { cause: error, code: 'connection' });
+ } finally { clearTimeout(timer); }
+ const contentType = response.headers?.get?.('content-type') || '';
+ let payload = raw;
+ if ((parseJson || !response.ok) && (contentType.includes('json') || raw.trim().startsWith('{') || raw.trim().startsWith('['))) {
+ try { payload = raw ? JSON.parse(raw) : {}; } catch (_) { payload = raw; }
+ }
+ if (!response.ok) {
+ const message = typeof payload === 'object' && payload?.error ? payload.error : `LoopForge returned HTTP ${response.status}.`;
+ const code = typeof payload === 'object' ? payload.code : undefined;
+ const suffix = response.status === 401 ? ' Configure a valid API token with “LoopForge: Configure API Token”.' : '';
+ throw new LoopForgeError(`${message}${suffix}`, { status: response.status, code, payload });
+ }
+ return payload;
+ }
+
+ listProjects() { return this.request('/api/projects'); }
+ listWorkflows() { return this.request('/api/workflows'); }
+ listRuns(projectId, maxRuns = 20) {
+ const query = new URLSearchParams();
+ if (projectId) query.set('projectId', projectId);
+ const suffix = query.toString() ? `?${query}` : '';
+ return this.request(`/api/runs${suffix}`).then(items => Array.isArray(items) ? items.slice(0, maxRuns) : []);
+ }
+ createRun({ workflowId, input, projectId }) {
+ return this.request('/api/runs', { method: 'POST', body: { workflowId, input, ...(projectId ? { projectId } : {}) } });
+ }
+ getRun(runId) { return this.request(`/api/runs/${encodeURIComponent(runId)}`); }
+ getLogs(runId) { return this.request(`/api/runs/${encodeURIComponent(runId)}/logs?limit=200`); }
+ approve(runId, decision) { return this.request(`/api/runs/${encodeURIComponent(runId)}/approve`, { method: 'POST', body: decision }); }
+ reject(runId, decision) { return this.request(`/api/runs/${encodeURIComponent(runId)}/reject`, { method: 'POST', body: decision }); }
+ getWorkspace(runId) { return this.request(`/api/runs/${encodeURIComponent(runId)}/workspace`); }
+ getWorkspaceFile(runId, path) {
+ return this.request(`/api/runs/${encodeURIComponent(runId)}/workspace/file?path=${encodeURIComponent(path)}`);
+ }
+ getDiff(runId, path) {
+ const query = path ? `?path=${encodeURIComponent(path)}` : '';
+ return this.request(`/api/runs/${encodeURIComponent(runId)}/workspace/diff${query}`);
+ }
+ getArtifact(runId, name) {
+ return this.request(`/api/runs/${encodeURIComponent(runId)}/artifacts/${encodeURIComponent(name)}`, { method: 'GET', parseJson: false });
+ }
+ artifactUrl(runId, name) { return `${this.baseUrl}/api/runs/${encodeURIComponent(runId)}/artifacts/${encodeURIComponent(name)}`; }
+}
+
+module.exports = { LoopForgeClient, LoopForgeError, normalizeBaseUrl };
diff --git a/vscode-extension/package.json b/vscode-extension/package.json
new file mode 100644
index 00000000..6355da94
--- /dev/null
+++ b/vscode-extension/package.json
@@ -0,0 +1,117 @@
+{
+ "name": "loopforge",
+ "displayName": "LoopForge",
+ "description": "Start and inspect LoopForge workflows from VS Code.",
+ "version": "0.1.4",
+ "publisher": "tencent",
+ "license": "MIT",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/Tencent/LoopForge.git"
+ },
+ "engines": {
+ "vscode": ">=1.85.0"
+ },
+ "main": "./extension.js",
+ "categories": ["Other"],
+ "activationEvents": [
+ "onStartupFinished",
+ "onView:loopforge.runs",
+ "onCommand:loopforge.startWorkflow",
+ "onCommand:loopforge.showStatus",
+ "onCommand:loopforge.showResult",
+ "onCommand:loopforge.approve",
+ "onCommand:loopforge.reject",
+ "onCommand:loopforge.openArtifact",
+ "onCommand:loopforge.openDiff"
+ ],
+ "contributes": {
+ "commands": [
+ { "command": "loopforge.startWorkflow", "title": "LoopForge: Start Workflow", "icon": "$(play)" },
+ { "command": "loopforge.showStatus", "title": "LoopForge: Show Status", "icon": "$(pulse)" },
+ { "command": "loopforge.showResult", "title": "LoopForge: Show Result", "icon": "$(output)" },
+ { "command": "loopforge.approve", "title": "LoopForge: Approve Current Stage", "icon": "$(check)" },
+ { "command": "loopforge.reject", "title": "LoopForge: Send Current Stage Back", "icon": "$(discard)" },
+ { "command": "loopforge.openArtifact", "title": "LoopForge: Open Artifact", "icon": "$(file)" },
+ { "command": "loopforge.openFile", "title": "LoopForge: Open Run File", "icon": "$(go-to-file)" },
+ { "command": "loopforge.openDiff", "title": "LoopForge: Open Diff", "icon": "$(diff)" },
+ { "command": "loopforge.refresh", "title": "LoopForge: Refresh", "icon": "$(refresh)" },
+ { "command": "loopforge.configureToken", "title": "LoopForge: Configure API Token" },
+ { "command": "loopforge.selectProject", "title": "LoopForge: Select Project" },
+ { "command": "loopforge.selectWorkflow", "title": "LoopForge: Select Workflow" }
+ ],
+ "viewsContainers": {
+ "activitybar": [
+ { "id": "loopforge", "title": "LoopForge", "icon": "resources/loopforge.svg" }
+ ]
+ },
+ "views": {
+ "loopforge": [
+ {
+ "id": "loopforge.runs",
+ "name": "Runs",
+ "contextualTitle": "LoopForge Runs"
+ }
+ ]
+ },
+ "menus": {
+ "view/title": [
+ { "command": "loopforge.startWorkflow", "when": "view == loopforge.runs", "group": "navigation@1" },
+ { "command": "loopforge.refresh", "when": "view == loopforge.runs", "group": "navigation@2" }
+ ],
+ "view/item/context": [
+ { "command": "loopforge.showStatus", "when": "view == loopforge.runs && viewItem == loopforge.run", "group": "inline@1" },
+ { "command": "loopforge.showResult", "when": "view == loopforge.runs && viewItem == loopforge.run", "group": "inline@2" },
+ { "command": "loopforge.approve", "when": "view == loopforge.runs && viewItem == loopforge.approval", "group": "inline@3" },
+ { "command": "loopforge.reject", "when": "view == loopforge.runs && viewItem == loopforge.approval", "group": "navigation@4" },
+ { "command": "loopforge.openArtifact", "when": "view == loopforge.runs && viewItem == loopforge.artifact", "group": "inline@4" },
+ { "command": "loopforge.openFile", "when": "view == loopforge.runs && viewItem == loopforge.run", "group": "inline@5" },
+ { "command": "loopforge.openDiff", "when": "view == loopforge.runs && viewItem == loopforge.run", "group": "inline@5" }
+ ],
+ "commandPalette": [
+ { "command": "loopforge.configureToken" },
+ { "command": "loopforge.selectProject" },
+ { "command": "loopforge.selectWorkflow" }
+ ]
+ },
+ "configuration": {
+ "title": "LoopForge",
+ "properties": {
+ "loopforge.serverUrl": {
+ "type": "string",
+ "default": "http://127.0.0.1:8090",
+ "scope": "machine",
+ "description": "LoopForge server URL. The extension calls its /api endpoints."
+ },
+ "loopforge.projectId": {
+ "type": "string",
+ "default": "",
+ "description": "Project ID used when starting a workflow. Leave empty to choose from the server."
+ },
+ "loopforge.workflowId": {
+ "type": "string",
+ "default": "",
+ "description": "Workflow ID used as the default when starting a workflow. Leave empty to choose from the server."
+ },
+ "loopforge.pollInterval": {
+ "type": "number",
+ "default": 10,
+ "minimum": 3,
+ "maximum": 300,
+ "description": "Seconds between status refreshes while a run is active."
+ },
+ "loopforge.maxRuns": {
+ "type": "number",
+ "default": 20,
+ "minimum": 1,
+ "maximum": 100,
+ "description": "Number of recent runs shown in the Runs view."
+ }
+ }
+ }
+ },
+ "scripts": {
+ "test": "node --test",
+ "package": "npx --yes @vscode/vsce package"
+ }
+}
diff --git a/vscode-extension/resources/loopforge.svg b/vscode-extension/resources/loopforge.svg
new file mode 100644
index 00000000..00b1951a
--- /dev/null
+++ b/vscode-extension/resources/loopforge.svg
@@ -0,0 +1,4 @@
+
diff --git a/vscode-extension/test/client.test.js b/vscode-extension/test/client.test.js
new file mode 100644
index 00000000..3cc8f063
--- /dev/null
+++ b/vscode-extension/test/client.test.js
@@ -0,0 +1,62 @@
+'use strict';
+
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const { LoopForgeClient, LoopForgeError, normalizeBaseUrl } = require('../lib/client');
+
+function response(status, body, contentType = 'application/json') {
+ return {
+ status,
+ ok: status >= 200 && status < 300,
+ headers: { get(name) { return name.toLowerCase() === 'content-type' ? contentType : ''; } },
+ async text() { return typeof body === 'string' ? body : JSON.stringify(body); }
+ };
+}
+
+test('normalizes and validates server URLs', () => {
+ assert.equal(normalizeBaseUrl('http://localhost:8090/'), 'http://localhost:8090');
+ assert.throws(() => normalizeBaseUrl(''), /server URL is empty/);
+ assert.throws(() => normalizeBaseUrl('file:///tmp/loopforge'), /must use http/);
+});
+
+test('sends bearer token and JSON body to the Runs API', async () => {
+ const calls = [];
+ const client = new LoopForgeClient({
+ baseUrl: 'https://loopforge.example.test/',
+ token: 'secret-token',
+ fetchImpl: async (url, options) => {
+ calls.push({ url, options });
+ return response(201, { id: 'run-1', status: 'running' });
+ }
+ });
+ const run = await client.createRun({ workflowId: 'wf-1', input: 'fix it', projectId: 'p-1' });
+ assert.equal(run.id, 'run-1');
+ assert.equal(calls[0].url, 'https://loopforge.example.test/api/runs');
+ assert.equal(calls[0].options.headers.Authorization, 'Bearer secret-token');
+ assert.deepEqual(JSON.parse(calls[0].options.body), { workflowId: 'wf-1', input: 'fix it', projectId: 'p-1' });
+});
+
+test('converts authentication and connection failures into actionable errors', async () => {
+ const unauthorized = new LoopForgeClient({ baseUrl: 'http://localhost:8090', fetchImpl: async () => response(401, { error: 'authentication required' }) });
+ await assert.rejects(() => unauthorized.listRuns(), error => error instanceof LoopForgeError && /Configure a valid API token/.test(error.message) && error.status === 401);
+ const disconnected = new LoopForgeClient({ baseUrl: 'http://localhost:8090', fetchImpl: async () => { throw new Error('ECONNREFUSED'); } });
+ await assert.rejects(() => disconnected.listRuns(), error => error instanceof LoopForgeError && /Cannot connect/.test(error.message) && /server is running/.test(error.message) && /extension host/.test(error.message) && error.code === 'connection');
+});
+
+test('encodes run, artifact, and diff paths', async () => {
+ const urls = [];
+ const client = new LoopForgeClient({ baseUrl: 'http://localhost:8090', fetchImpl: async url => { urls.push(url); return response(200, { content: 'ok' }, 'text/plain'); } });
+ await client.getRun('run/1');
+ await client.getArtifact('run/1', 'report final.md');
+ await client.getDiff('run/1', 'src/a b.go');
+ assert.deepEqual(urls, [
+ 'http://localhost:8090/api/runs/run%2F1',
+ 'http://localhost:8090/api/runs/run%2F1/artifacts/report%20final.md',
+ 'http://localhost:8090/api/runs/run%2F1/workspace/diff?path=src%2Fa%20b.go'
+ ]);
+});
+
+test('keeps plain-text artifact responses intact', async () => {
+ const client = new LoopForgeClient({ baseUrl: 'http://localhost:8090', fetchImpl: async () => response(200, 'a report: {not json}', 'text/plain') });
+ assert.equal(await client.getArtifact('run-1', 'report.md'), 'a report: {not json}');
+});
diff --git a/vscode-extension/test/extension.test.js b/vscode-extension/test/extension.test.js
new file mode 100644
index 00000000..497cd7b8
--- /dev/null
+++ b/vscode-extension/test/extension.test.js
@@ -0,0 +1,124 @@
+'use strict';
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const Module = require('node:module');
+
+function harness() {
+ const commands = new Map(), errors = [], documents = [], requests = [], updates = [], messages = [];
+ let provider, input = ['Implement this', 'looks good'], picks = [], run;
+ const folder = { name: 'project', uri: { fsPath: '/project', scheme: 'file' } };
+ const values = { serverUrl: 'http://example.test', projectId: 'project-1', workflowId: 'workflow-1', pollInterval: 300 };
+ const vscode = {
+ TreeItem: class { constructor(label, collapsibleState) { this.label = label; this.collapsibleState = collapsibleState; } },
+ TreeItemCollapsibleState: { Collapsed: 1, None: 0 }, ThemeIcon: class { constructor(id) { this.id = id; } },
+ EventEmitter: class { constructor() { this.event = () => {}; } fire() {} dispose() {} },
+ ConfigurationTarget: { Workspace: 2 },
+ workspace: {
+ workspaceFolders: [folder], getWorkspaceFolder: () => folder, asRelativePath: () => 'src/file.js',
+ getConfiguration: () => ({ get: (key, fallback) => values[key] ?? fallback, update: async (...args) => { updates.push(args); values[args[0]] = args[1]; } }),
+ openTextDocument: async options => { documents.push(options); return { uri: `document-${documents.length}` }; }
+ },
+ window: {
+ activeTextEditor: { document: { uri: { fsPath: '/project/src/file.js' }, getText: () => 'first line\nsecond line' }, selection: { isEmpty: false } },
+ createOutputChannel: () => ({ appendLine() {}, clear() {}, show() {}, dispose() {} }),
+ createTreeView: (_id, options) => { provider = options.treeDataProvider; return { dispose() {} }; },
+ showInputBox: async () => input.shift(), showQuickPick: async items => picks.length ? picks.shift() : items[0],
+ showErrorMessage: message => { errors.push(message); }, showInformationMessage: async message => { messages.push(message); },
+ setStatusBarMessage() {}, showTextDocument: async doc => { documents.push(doc); }
+ },
+ commands: { registerCommand: (id, fn) => { commands.set(id, fn); return { dispose() {} }; }, executeCommand: async (...args) => requests.push({ command: args }) }
+ };
+ const originalLoad = Module._load;
+ Module._load = function(request, ...args) { return request === 'vscode' ? vscode : originalLoad.call(this, request, ...args); };
+ let extension;
+ try { delete require.cache[require.resolve('../extension')]; extension = require('../extension'); } finally { Module._load = originalLoad; }
+ const oldFetch = globalThis.fetch;
+ globalThis.fetch = async (url, options) => {
+ const route = new URL(url).pathname;
+ requests.push({ route, method: options.method, body: options.body && JSON.parse(options.body) });
+ let body;
+ if (route === '/api/projects') body = [{ id: 'project-2', name: 'Other' }];
+ else if (route === '/api/workflows') body = [{ id: 'workflow-2', name: 'Other', projectId: 'project-2' }];
+ else if (route === '/api/runs' && options.method === 'POST') {
+ run = { id: 'run-1', status: 'waiting', workflowId: 'workflow-1', nodes: { gate: { nodeId: 'gate', status: 'waiting_approval', attempt: 1, reworkCount: 0 } }, artifacts: [{ id: 'artifact-id', name: 'report.md', mediaType: 'text/markdown' }], workflowSnapshot: { nodes: [{ id: 'gate', rejectTo: 'work' }] } };
+ body = run;
+ } else if (route === '/api/runs') body = run ? [{ ...run, artifacts: undefined }] : [];
+ else if (route === '/api/runs/run-1') body = run;
+ else if (route === '/api/runs/run-1/approve' || route === '/api/runs/run-1/reject') {
+ const expected = JSON.parse(options.body).expected;
+ const current = run.nodes.gate;
+ if (expected.status !== current.status || expected.attempt !== current.attempt || expected.reworkCount !== current.reworkCount) return new Response(JSON.stringify({ error: 'stale decision' }), { status: 409, headers: { 'content-type': 'application/json' } });
+ run = { ...run, status: 'succeeded', nodes: { gate: { ...current, status: 'succeeded' } } }; body = { ok: true };
+ }
+ else if (route === '/api/runs/run-1/artifacts/artifact-id') return new Response('report content', { status: 200, headers: { 'content-type': 'text/plain' } });
+ else if (route === '/api/runs/run-1/logs') body = { logs: [{ body: 'completed' }] };
+ else return new Response(JSON.stringify({ error: 'not found' }), { status: 404, headers: { 'content-type': 'application/json' } });
+ return new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } });
+ };
+ const context = { subscriptions: [], secrets: { get: async () => '', store: async () => {}, delete: async () => {} } };
+ extension.activate(context);
+ return { commands, errors, documents, requests, updates, messages, get provider() { return provider; }, setInput: value => { input = value; }, setPicks: value => { picks = value; }, advanceRun() { run = { ...run, nodes: { gate: { ...run.nodes.gate, attempt: 2, reworkCount: 1 } } }; }, dispose() { context.subscriptions.forEach(item => item.dispose()); globalThis.fetch = oldFetch; } };
+}
+
+test('starts a run with multiline selection, shows approval and opens artifact by ID', async () => {
+ const h = harness();
+ try {
+ await h.commands.get('loopforge.startWorkflow')();
+ const request = h.requests.find(item => item.route === '/api/runs' && item.method === 'POST');
+ assert.equal(request.body.projectId, 'project-1');
+ assert.match(request.body.input, /Implement this/);
+ assert.match(request.body.input, /src\/file\.js/);
+ assert.match(request.body.input, /first line\nsecond line/);
+ const roots = await h.provider.getChildren();
+ assert.equal(roots[0].contextValue, 'loopforge.run');
+ const children = await h.provider.getChildren(roots[0]);
+ assert.deepEqual(children.map(item => item.contextValue), ['loopforge.approval', 'loopforge.artifact']);
+ await h.commands.get('loopforge.openArtifact')(children[1]);
+ assert.ok(h.requests.some(item => item.route === '/api/runs/run-1/artifacts/artifact-id'));
+ assert.ok(h.documents.some(doc => doc.content === 'report content'));
+ await h.commands.get('loopforge.approve')(children[0]);
+ assert.deepEqual(h.requests.find(item => item.route === '/api/runs/run-1/approve').body.expected, { status: 'waiting_approval', attempt: 1, reworkCount: 0 });
+ await h.commands.get('loopforge.showResult')(roots[0]);
+ assert.ok(h.requests.some(item => item.route === '/api/runs/run-1/logs'));
+ assert.deepEqual(h.errors, []);
+ assert.deepEqual(h.updates, []);
+ } finally { h.dispose(); }
+});
+
+test('switching projects clears the old workflow and asks for a workflow in the new project', async () => {
+ const h = harness();
+ try {
+ await h.commands.get('loopforge.selectProject')();
+ assert.deepEqual(h.updates, [['workflowId', '', 2], ['projectId', 'project-2', 2]]);
+ await h.commands.get('loopforge.startWorkflow')();
+ const start = h.requests.find(item => item.route === '/api/runs' && item.method === 'POST');
+ assert.equal(start.body.projectId, 'project-2');
+ assert.equal(start.body.workflowId, 'workflow-2');
+ } finally { h.dispose(); }
+});
+
+for (const action of ['approve', 'reject']) {
+ test(`${action} uses the displayed attempt and reports a stale decision`, async () => {
+ const h = harness();
+ try {
+ await h.commands.get('loopforge.startWorkflow')();
+ const [root] = await h.provider.getChildren();
+ const [approval] = await h.provider.getChildren(root);
+ h.advanceRun();
+ await h.commands.get(`loopforge.${action}`)(approval);
+ const request = h.requests.find(item => item.route === `/api/runs/run-1/${action}`);
+ assert.deepEqual(request.body.expected, { status: 'waiting_approval', attempt: 1, reworkCount: 0 });
+ assert.ok(h.errors.some(error => error.includes('stale decision')));
+ assert.ok(!h.messages.some(message => /已确认|已退回/.test(message)));
+ } finally { h.dispose(); }
+ });
+}
+
+test('start failures are reported to the user', async () => {
+ const h = harness();
+ try {
+ globalThis.fetch = async () => { throw new Error('offline'); };
+ await h.commands.get('loopforge.startWorkflow')();
+ assert.match(h.errors[0], /Cannot connect to LoopForge/);
+ } finally { h.dispose(); }
+});
From daabaec55bb6efc0800050ef35ea1359db730e56 Mon Sep 17 00:00:00 2001
From: Zhong-Chenchen <2021302121369@whu.edu.cn>
Date: Wed, 11 Nov 2026 16:22:52 +0800
Subject: [PATCH 2/6] Update Go to fix HTTP server vulnerability
---
Dockerfile | 2 +-
Makefile | 2 +-
README.md | 2 +-
README.zh-CN.md | 2 +-
go.mod | 2 +-
5 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/Dockerfile b/Dockerfile
index 48591483..41483680 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -9,7 +9,7 @@ RUN --mount=type=cache,target=/root/.npm npm --prefix chatbot ci --include=dev -
COPY chatbot/ chatbot/
COPY web/app/ web/app/
RUN npm --prefix chatbot run build && test -f web/native-chat/index.html
-FROM --platform=$BUILDPLATFORM golang:1.26.10-bookworm AS build
+FROM --platform=$BUILDPLATFORM golang:1.26.11-bookworm AS build
ARG TARGETOS
ARG TARGETARCH
WORKDIR /src
diff --git a/Makefile b/Makefile
index 6caf32f4..44e1d02d 100644
--- a/Makefile
+++ b/Makefile
@@ -1,7 +1,7 @@
.PHONY: run test test-mysql clean-mysql build build-chatbot build-gate-mcp coverage coverage-go coverage-chatbot coverage-web coverage-e2e
GOCACHE ?= /tmp/agentflow-gocache
-# Use the local toolchain; go.mod requires go >= 1.26.10.
+# Use the local toolchain; go.mod requires go >= 1.26.11.
run:
GOCACHE=$(GOCACHE) go run ./cmd/agentflow
diff --git a/README.md b/README.md
index 4bec5cab..fa3e4568 100644
--- a/README.md
+++ b/README.md
@@ -106,7 +106,7 @@ New accounts join the **LoopForge demo project**, which includes an SOP made onl
- **Sandboxes.** In the default Compose installation, SOP runs and conversation workspaces both use Docker sandbox containers started through the host Docker socket. Default sandboxes reuse the installed runtime image, so nothing extra is built after installation; custom Dockerfiles still build their own images. Docker socket access grants control of the host daemon, so this setup is for trusted teams, not untrusted multi-tenant isolation. See [deployment boundaries](docs/deployment.md#accounts). Re-running the installer keeps your data.
- **Accounts and access.** New installations listen on all interfaces (existing bind settings are kept) and are designed for a trusted team: signed-in users can read project data across the instance, and teammates can sign up from the login page (`AGENTFLOW_ALLOW_SIGNUP=false` in `.env` turns that off). Read the [account and access model](docs/deployment.md#accounts) before exposing it beyond your team.
- **Remote access, HTTPS, separate workers.** See [Deployment](docs/deployment.md).
-- **Hacking on LoopForge itself?** `bash scripts/dev.sh` runs it natively (Go 1.26.10+).
+- **Hacking on LoopForge itself?** `bash scripts/dev.sh` runs it natively (Go 1.26.11+).
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 5be02db8..8792c783 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -106,7 +106,7 @@ cd LoopForge
- **沙箱。** 默认 Compose 安装中,SOP 运行和对话工作区都使用 Docker 沙箱容器,由平台通过宿主机 Docker socket 启动;默认沙箱复用已安装的运行镜像,安装后无需再构建;自定义 Dockerfile 仍单独构建。Docker socket 赋予平台控制宿主机 Docker 的权限,因此只适用于可信团队,不构成不可信多租户隔离边界;详见[部署边界](docs/deployment.md#accounts)。重复执行安装脚本会保留已有数据。
- **账号与访问。** 新安装默认监听所有网卡(已有安装保留原配置),面向可信团队:已登录用户可以读取实例内各项目的数据,团队成员可在登录页自助注册(在 `.env` 中设置 `AGENTFLOW_ALLOW_SIGNUP=false` 可关闭)。对团队以外开放前,请先阅读[账号与访问边界](docs/deployment.md#accounts)。
- **远程访问、HTTPS、独立 Worker。** 见[部署指南](docs/deployment.md)。
-- **参与 LoopForge 本身的开发?** 用 `bash scripts/dev.sh` 原生运行(需要 Go 1.26.10+)。
+- **参与 LoopForge 本身的开发?** 用 `bash scripts/dev.sh` 原生运行(需要 Go 1.26.11+)。
diff --git a/go.mod b/go.mod
index 07caa4ce..a267673c 100644
--- a/go.mod
+++ b/go.mod
@@ -1,6 +1,6 @@
module agentflow
-go 1.26.10
+go 1.26.11
require (
github.com/creack/pty v1.1.24
From a4061ed50a22adedb915eee1105a74ad950e35af Mon Sep 17 00:00:00 2001
From: Zhong-Chenchen <2021302121369@whu.edu.cn>
Date: Wed, 11 Nov 2026 22:47:04 +0800
Subject: [PATCH 3/6] Require HTTPS for remote LoopForge servers
---
vscode-extension/README.md | 2 +-
vscode-extension/lib/client.js | 5 +++++
vscode-extension/package.json | 2 +-
vscode-extension/test/client.test.js | 10 ++++++++++
vscode-extension/test/extension.test.js | 2 +-
5 files changed, 18 insertions(+), 3 deletions(-)
diff --git a/vscode-extension/README.md b/vscode-extension/README.md
index 143f98db..357373f6 100644
--- a/vscode-extension/README.md
+++ b/vscode-extension/README.md
@@ -27,7 +27,7 @@ The package can later be published to an extension marketplace with the publishe
Use the command palette:
-1. Start or obtain access to a LoopForge platform server. Set `loopforge.serverUrl` in user or remote settings to its base URL (for example `http://127.0.0.1:8090` when the server runs on the extension host). Use HTTPS for a remote server. Workspace settings cannot change the server address, to prevent a project from redirecting the stored token.
+1. Start or obtain access to a LoopForge platform server. Set `loopforge.serverUrl` in user or remote settings to its base URL (for example `http://127.0.0.1:8090` when the server runs on the extension host). HTTPS is required for non-loopback hosts; HTTP is accepted only for `localhost`, IPv4 addresses in `127.0.0.0/8`, and `::1`. Workspace settings cannot change the server address, to prevent a project from redirecting the stored token.
2. Create a Personal API Token in the platform's account settings if authentication is enabled. `LoopForge: Configure API Token` stores or clears it in SecretStorage. A personal token is not a model-provider key.
3. `LoopForge: Select Project` and `LoopForge: Select Workflow` discover choices from the configured server, or set `loopforge.projectId` and `loopforge.workflowId` in workspace settings. A project owner or authenticated user is required for manual runs.
diff --git a/vscode-extension/lib/client.js b/vscode-extension/lib/client.js
index 15bbb91f..410327f9 100644
--- a/vscode-extension/lib/client.js
+++ b/vscode-extension/lib/client.js
@@ -17,6 +17,11 @@ function normalizeBaseUrl(value) {
throw new LoopForgeError('LoopForge server URL must use http:// or https://.');
}
if (url.username || url.password || url.search || url.hash) throw new LoopForgeError('Server URL must not include credentials, a query, or a fragment.');
+ // WHATWG URL parsing canonicalizes IPv4 addresses before this comparison.
+ const loopback = url.hostname === 'localhost' || url.hostname === '[::1]' || /^127\.\d+\.\d+\.\d+$/.test(url.hostname);
+ if (url.protocol === 'http:' && !loopback) {
+ throw new LoopForgeError('LoopForge server URL must use HTTPS for non-loopback hosts. HTTP is allowed only for localhost, 127.0.0.0/8, or ::1.');
+ }
return url.toString().replace(/\/$/, '');
}
diff --git a/vscode-extension/package.json b/vscode-extension/package.json
index 6355da94..6a521740 100644
--- a/vscode-extension/package.json
+++ b/vscode-extension/package.json
@@ -81,7 +81,7 @@
"type": "string",
"default": "http://127.0.0.1:8090",
"scope": "machine",
- "description": "LoopForge server URL. The extension calls its /api endpoints."
+ "description": "LoopForge server URL. Remote hosts require HTTPS; HTTP is allowed only for loopback addresses."
},
"loopforge.projectId": {
"type": "string",
diff --git a/vscode-extension/test/client.test.js b/vscode-extension/test/client.test.js
index 3cc8f063..81f8f7b0 100644
--- a/vscode-extension/test/client.test.js
+++ b/vscode-extension/test/client.test.js
@@ -19,6 +19,16 @@ test('normalizes and validates server URLs', () => {
assert.throws(() => normalizeBaseUrl('file:///tmp/loopforge'), /must use http/);
});
+test('allows HTTP only for loopback addresses and requires HTTPS elsewhere', () => {
+ for (const url of ['http://localhost:8090', 'http://LOCALHOST:8090', 'http://127.0.0.1:8090', 'http://127.255.255.255:8090', 'http://[::1]:8090', 'http://127.1:8090']) {
+ assert.doesNotThrow(() => normalizeBaseUrl(url), url);
+ }
+ for (const url of ['http://remote.example:8090', 'http://10.0.0.5:8090', 'http://localhost.example:8090', 'http://127.0.0.1.example:8090', 'http://[::ffff:127.0.0.1]:8090']) {
+ assert.throws(() => new LoopForgeClient({ baseUrl: url, token: 'secret-token' }), /must use HTTPS for non-loopback hosts/, url);
+ }
+ assert.equal(normalizeBaseUrl('https://remote.example:8090/'), 'https://remote.example:8090');
+});
+
test('sends bearer token and JSON body to the Runs API', async () => {
const calls = [];
const client = new LoopForgeClient({
diff --git a/vscode-extension/test/extension.test.js b/vscode-extension/test/extension.test.js
index 497cd7b8..aa8c9179 100644
--- a/vscode-extension/test/extension.test.js
+++ b/vscode-extension/test/extension.test.js
@@ -7,7 +7,7 @@ function harness() {
const commands = new Map(), errors = [], documents = [], requests = [], updates = [], messages = [];
let provider, input = ['Implement this', 'looks good'], picks = [], run;
const folder = { name: 'project', uri: { fsPath: '/project', scheme: 'file' } };
- const values = { serverUrl: 'http://example.test', projectId: 'project-1', workflowId: 'workflow-1', pollInterval: 300 };
+ const values = { serverUrl: 'https://example.test', projectId: 'project-1', workflowId: 'workflow-1', pollInterval: 300 };
const vscode = {
TreeItem: class { constructor(label, collapsibleState) { this.label = label; this.collapsibleState = collapsibleState; } },
TreeItemCollapsibleState: { Collapsed: 1, None: 0 }, ThemeIcon: class { constructor(id) { this.id = id; } },
From 8f35a709197bea92922b7bb53818ebeb1dd01b7f Mon Sep 17 00:00:00 2001
From: Zhong-Chenchen <2021302121369@whu.edu.cn>
Date: Thu, 12 Nov 2026 00:22:43 +0800
Subject: [PATCH 4/6] Retry CI after runner DNS failure
From 104764e75881c96502060348ae91587063ad5074 Mon Sep 17 00:00:00 2001
From: Zhong-Chenchen <2021302121369@whu.edu.cn>
Date: Thu, 12 Nov 2026 19:07:11 +0800
Subject: [PATCH 5/6] Address VS Code extension review feedback
---
.github/workflows/validate.yml | 12 +++
vscode-extension/README.md | 6 +-
vscode-extension/acceptance/platform_test.go | 3 +
vscode-extension/extension.js | 76 ++++++++++++-----
vscode-extension/lib/client.js | 5 +-
vscode-extension/package.json | 18 ++--
vscode-extension/test/client.test.js | 4 +-
vscode-extension/test/extension.test.js | 89 +++++++++++++++++++-
8 files changed, 169 insertions(+), 44 deletions(-)
diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml
index 8dc144c3..1e1bf82a 100644
--- a/.github/workflows/validate.yml
+++ b/.github/workflows/validate.yml
@@ -13,6 +13,18 @@ defaults:
working-directory: local
jobs:
+ vscode-extension:
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: vscode-extension
+ steps:
+ - uses: actions/checkout@v7.0.1
+ - uses: actions/setup-node@v7.1.0
+ with:
+ node-version: "22"
+ - run: npm test
+
validate:
runs-on: ubuntu-latest
strategy:
diff --git a/vscode-extension/README.md b/vscode-extension/README.md
index 357373f6..eaf61fe3 100644
--- a/vscode-extension/README.md
+++ b/vscode-extension/README.md
@@ -9,7 +9,7 @@ This directory contains the first standalone desktop VS Code extension for LoopF
- Platform workflows (SOPs). The extension starts a run through the server; model providers and execution environments remain configured by LoopForge.
- A server URL and, when authentication is enabled, a LoopForge Personal API Token. The token is stored in VS Code SecretStorage and is never written to workspace settings.
-The extension uses the active file's workspace folder (or the first folder if no file is active). `LoopForge: Start Workflow` asks for a task description and appends the project path, active file path and up to 4,000 characters of selected text as input. This sends the selected content to the configured server. Paths are descriptive: the extension does not upload or mount the local project. Configure a repository on the platform if the workflow needs source files. The server remains the source of truth for project access, workflow permissions, execution, and secrets.
+The extension uses the active file's workspace folder (or the first folder if no file is active). `LoopForge: Start Workflow` asks for a task description and appends the workspace name, relative file path and up to 4,000 characters of selected text as input. Without a workspace folder, it sends only the file name. This sends the selected content to the configured server. Paths are descriptive: the extension does not upload or mount the local project. Configure a repository on the platform if the workflow needs source files. The server remains the source of truth for project access, workflow permissions, execution, and secrets.
## Install from a VSIX
@@ -33,7 +33,7 @@ Use the command palette:
When switching projects with `LoopForge: Select Project`, the extension clears the workspace's workflow selection so the next run asks for a workflow in the new project. If you edit these settings manually, choose a workflow that belongs to the selected project or is shared with it.
-The extension only writes the two explicitly selected LoopForge workspace settings. It does not change unrelated VS Code settings. Uninstalling removes the extension files and leaves the workspace settings and SecretStorage entry under the user's control; clear the token with `LoopForge: Configure API Token` when desired.
+The extension only writes the two explicitly selected LoopForge settings (workspace settings when a folder is open, otherwise user settings). In untrusted workspaces, project and workflow settings from that workspace are restricted. It does not change unrelated VS Code settings. Uninstalling removes the extension files and leaves the settings and SecretStorage entry under the user's control; clear the token with `LoopForge: Configure API Token` when desired.
## Commands and run view
@@ -46,7 +46,7 @@ The LoopForge activity bar view lists recent runs, their status and active stage
- Open a changed file from the run workspace in a VS Code editor.
- Open the current workspace change as a VS Code diff. If there are multiple changes, the extension asks which path to compare.
-Connection, authentication, timeout, HTTP, and server validation errors are shown in the Runs view or as VS Code error messages. Run polling is bounded by `loopforge.pollInterval` and is disabled on deactivation. To upgrade, install the new VSIX through VS Code; to uninstall, use the Extensions view. Existing LoopForge settings may remain after uninstall; remove them manually if desired.
+Connection, authentication, timeout, HTTP, and server validation errors are shown in the Runs view or as VS Code error messages. The extension refreshes when the Runs view becomes visible and polls only while the view is visible and a listed run is non-terminal. Polling backs off on failures and stops on deactivation. The platform currently returns all matching runs before the extension applies `loopforge.maxRuns`; use a project selection to limit the scope. To upgrade, install the new VSIX through VS Code; to uninstall, use the Extensions view. Existing LoopForge settings may remain after uninstall; remove them manually if desired.
## Capability boundary
diff --git a/vscode-extension/acceptance/platform_test.go b/vscode-extension/acceptance/platform_test.go
index 457b736a..d0e8ea27 100644
--- a/vscode-extension/acceptance/platform_test.go
+++ b/vscode-extension/acceptance/platform_test.go
@@ -17,6 +17,9 @@ import (
)
func TestExtensionClientAgainstRealPlatform(t *testing.T) {
+ if _, err := exec.LookPath("node"); err != nil {
+ t.Skip("node is required for the VS Code extension acceptance test")
+ }
t.Setenv("AGENTFLOW_AUTH_REQUIRED", "false")
gin.SetMode(gin.ReleaseMode)
store, err := repo.OpenTest()
diff --git a/vscode-extension/extension.js b/vscode-extension/extension.js
index e44db6b1..6768c9c9 100644
--- a/vscode-extension/extension.js
+++ b/vscode-extension/extension.js
@@ -24,7 +24,8 @@ function trimText(value, max = 4000) {
function selectedContext() {
const editor = vscode.window.activeTextEditor;
if (!editor) return { file: '', selection: '' };
- const file = vscode.workspace.asRelativePath(editor.document.uri, false);
+ const folder = vscode.workspace.getWorkspaceFolder(editor.document.uri);
+ const file = folder ? vscode.workspace.asRelativePath(editor.document.uri, false) : path.basename(editor.document.uri.fsPath || '');
const selected = editor.selection && !editor.selection.isEmpty ? editor.document.getText(editor.selection) : '';
const selection = selected.length > 4000 ? `${selected.slice(0, 4000)}\n…(已截断)` : selected;
return { file, selection };
@@ -58,15 +59,14 @@ class RunTreeItem extends vscode.TreeItem {
this.tooltip = kind === 'run' ? `${run.workflowId || run.workflowSnapshot?.name || 'workflow'}\n${run.input || ''}` : label;
if (kind === 'run') {
this.description = run.workflowSnapshot?.name || run.workflowId || '';
- this.iconPath = new vscode.ThemeIcon(run.status === 'succeeded' ? 'pass' : run.status === 'failed' ? 'error' : 'sync~spin');
+ const icons = { succeeded: 'pass', failed: 'error', cancelled: 'circle-slash', waiting: 'clock', paused: 'debug-pause', queued: 'clock', running: 'sync~spin' };
+ this.iconPath = new vscode.ThemeIcon(icons[run.status] || 'circle-outline');
this.command = { command: 'loopforge.showStatus', title: 'Show Status', arguments: [this] };
} else if (kind === 'approval') {
this.description = '需要人工确认';
this.iconPath = new vscode.ThemeIcon('question');
this.command = { command: 'loopforge.approve', title: 'Approve', arguments: [this] };
} else {
- const artifact = parent?.artifact;
- this.description = `${artifact?.mediaType || 'text'} · ${artifact?.size || 0} bytes`;
this.iconPath = new vscode.ThemeIcon('file');
this.command = { command: 'loopforge.openArtifact', title: 'Open Artifact', arguments: [this] };
}
@@ -116,8 +116,11 @@ class ExtensionState {
this.client = null;
this.output = vscode.window.createOutputChannel('LoopForge');
this.tree = new LoopForgeTreeProvider(this);
- this.refreshing = false;
+ this.refreshing = null;
this.pollTimer = undefined;
+ this.visible = false;
+ this.disposed = false;
+ this.failures = 0;
this.lastError = '';
}
async connect() {
@@ -126,20 +129,36 @@ class ExtensionState {
return this.client;
}
async refresh({ quiet = false } = {}) {
- if (this.refreshing) return;
- this.refreshing = true;
+ if (this.refreshing) {
+ await this.refreshing;
+ if (this.disposed) return;
+ return this.refresh({ quiet });
+ }
+ this.refreshing = this.doRefresh(quiet);
+ try { await this.refreshing; } finally { this.refreshing = null; this.schedulePoll(); }
+ }
+ async doRefresh(quiet) {
try {
await this.connect();
this.runs = await this.client.listRuns(configured('projectId', ''), Number(configured('maxRuns', 20)));
this.lastError = '';
+ this.failures = 0;
this.tree.refresh();
if (!quiet) vscode.window.setStatusBarMessage(`LoopForge: 已刷新 ${this.runs.length} 个任务`, 3000);
} catch (error) {
+ this.failures++;
this.lastError = error.message;
this.tree.refresh();
if (!quiet) this.error(error);
else this.output.appendLine(`Refresh failed: ${error.message}`);
- } finally { this.refreshing = false; }
+ }
+ }
+ schedulePoll() {
+ clearTimeout(this.pollTimer);
+ this.pollTimer = undefined;
+ if (this.disposed || !this.visible || !this.runs.some(run => !['succeeded', 'failed', 'cancelled', 'canceled'].includes(run.status))) return;
+ const seconds = Math.min(300, Math.max(3, Number(configured('pollInterval', 10)) || 10) * 2 ** Math.min(this.failures, 5));
+ this.pollTimer = setTimeout(() => this.refresh({ quiet: true }), seconds * 1000);
}
error(error) {
const message = error instanceof LoopForgeError ? error.message : `LoopForge error: ${error?.message || error}`;
@@ -157,7 +176,8 @@ class ExtensionState {
return detail;
}
dispose() {
- if (this.pollTimer) clearInterval(this.pollTimer);
+ this.disposed = true;
+ clearTimeout(this.pollTimer);
this.output.dispose();
this.tree.emitter.dispose();
}
@@ -186,8 +206,6 @@ async function chooseWorkflow(state, projectId, force = false) {
function workflowInput() {
const context = selectedContext();
const lines = [`在项目“${workspaceName()}”中执行 LoopForge 工作流。`];
- const folder = workspaceFolder();
- if (folder) lines.push(`项目路径:${folder.uri.fsPath}`);
if (context.file) lines.push(`当前文件:${context.file}`);
if (context.selection) lines.push(`选中代码:\n${context.selection}`);
return lines.join('\n');
@@ -232,7 +250,7 @@ async function approve(state, item) {
const run = await state.detail(item || state.runs[0]);
const node = item?.approval || waitingApproval(run);
if (!node || node.status !== 'waiting_approval') throw new LoopForgeError('This run has no stage waiting for approval.');
- const context = await vscode.window.showInputBox({ prompt: `确认 ${node.nodeId}(第 ${node.attempt} 次执行,退回 ${node.reworkCount} 次),可附加说明`, value: '' });
+ const context = await vscode.window.showInputBox({ prompt: `确认 ${node.nodeId}(第 ${node.attempt ?? 0} 次执行,退回 ${node.reworkCount ?? 0} 次),可附加说明`, value: '' });
if (context === undefined) return;
await state.client.approve(run.id, { nodeId: node.nodeId, context, expected: { status: node.status, attempt: node.attempt || 0, reworkCount: node.reworkCount || 0 } });
vscode.window.showInformationMessage(`LoopForge 已确认 ${node.nodeId || '当前阶段'}`);
@@ -244,7 +262,7 @@ async function reject(state, item) {
const run = await state.detail(item || state.runs[0]);
const node = item?.approval || waitingApproval(run);
if (!node || node.status !== 'waiting_approval') throw new LoopForgeError('This run has no stage waiting for a human decision.');
- const reason = await vscode.window.showInputBox({ prompt: `退回 ${node.nodeId}(第 ${node.attempt} 次执行,退回 ${node.reworkCount} 次)的原因`, validateInput: value => value.trim() ? undefined : '请输入原因' });
+ const reason = await vscode.window.showInputBox({ prompt: `退回 ${node.nodeId}(第 ${node.attempt ?? 0} 次执行,退回 ${node.reworkCount ?? 0} 次)的原因`, validateInput: value => value.trim() ? undefined : '请输入原因' });
if (reason === undefined) return;
const workflowNode = (run.workflowSnapshot?.nodes || []).find(value => value.id === node.nodeId);
await state.client.reject(run.id, { nodeId: node.nodeId, reason, targetId: workflowNode?.rejectTo || '', expected: { status: node.status, attempt: node.attempt || 0, reworkCount: node.reworkCount || 0 } });
@@ -273,6 +291,8 @@ async function openDiff(state, item) {
const run = await state.detail(item || state.runs[0]);
const workspace = await state.client.getWorkspace(run.id);
if (!workspace.available) throw new LoopForgeError(workspace.reason || 'Run workspace is unavailable.');
+ // Paths returned by the platform are relative to the run workspace and
+ // include the repository directory for multi-repository runs.
const changes = workspace?.changes || [];
let selectedPath = changes.length === 1 ? changes[0].path : undefined;
if (!selectedPath && changes.length > 1) {
@@ -309,12 +329,17 @@ async function openFile(state, item) {
async function configureToken(state) {
const token = await vscode.window.showInputBox({ prompt: '输入 LoopForge Personal API Token(保存在 VS Code SecretStorage)', password: true, ignoreFocusOut: true });
if (token === undefined) return;
- if (!token.trim()) { await state.context.secrets.delete(TOKEN_KEY); vscode.window.showInformationMessage('LoopForge API Token 已清除。'); return; }
- await state.context.secrets.store(TOKEN_KEY, token.trim());
- vscode.window.showInformationMessage('LoopForge API Token 已安全保存。');
+ if (!token.trim()) {
+ await state.context.secrets.delete(TOKEN_KEY);
+ vscode.window.showInformationMessage('LoopForge API Token 已清除。');
+ } else {
+ await state.context.secrets.store(TOKEN_KEY, token.trim());
+ vscode.window.showInformationMessage('LoopForge API Token 已安全保存。');
+ }
+ if (state.visible) await state.refresh({ quiet: true });
}
async function configureSetting(name, value) {
- await config().update(name, value, vscode.ConfigurationTarget.Workspace);
+ await config().update(name, value, vscode.workspace.workspaceFolders?.length ? vscode.ConfigurationTarget.Workspace : vscode.ConfigurationTarget.Global);
}
function activate(context) {
@@ -337,7 +362,7 @@ function activate(context) {
const project = await chooseProject(state, true);
if (!project) return;
if (project !== configured('projectId', '')) {
- // An explicit empty workspace value also masks any user-level default.
+ // An explicit empty value also masks any user-level default in a workspace.
await configureSetting('workflowId', '');
await configureSetting('projectId', project);
await state.refresh({ quiet: true });
@@ -346,9 +371,18 @@ function activate(context) {
});
command('loopforge.selectWorkflow', async state => { const workflow = await chooseWorkflow(state, configured('projectId', ''), true); if (workflow) await configureSetting('workflowId', workflow); });
context.subscriptions.push(view, { dispose: () => state.dispose() });
- state.refresh({ quiet: true });
- const interval = Math.max(3, Number(configured('pollInterval', 10))) * 1000;
- state.pollTimer = setInterval(() => state.refresh({ quiet: true }), interval);
+ state.visible = view.visible;
+ context.subscriptions.push(view.onDidChangeVisibility(({ visible }) => {
+ state.visible = visible;
+ if (visible) state.refresh({ quiet: true });
+ else state.schedulePoll();
+ }));
+ context.subscriptions.push(vscode.workspace.onDidChangeConfiguration(event => {
+ if (event.affectsConfiguration('loopforge.serverUrl') || event.affectsConfiguration('loopforge.projectId') || event.affectsConfiguration('loopforge.maxRuns')) {
+ if (state.visible) state.refresh();
+ } else if (event.affectsConfiguration('loopforge.pollInterval')) state.schedulePoll();
+ }));
+ if (state.visible) state.refresh({ quiet: true });
}
function deactivate() {}
diff --git a/vscode-extension/lib/client.js b/vscode-extension/lib/client.js
index 410327f9..ea139440 100644
--- a/vscode-extension/lib/client.js
+++ b/vscode-extension/lib/client.js
@@ -95,10 +95,9 @@ class LoopForgeClient {
const query = path ? `?path=${encodeURIComponent(path)}` : '';
return this.request(`/api/runs/${encodeURIComponent(runId)}/workspace/diff${query}`);
}
- getArtifact(runId, name) {
- return this.request(`/api/runs/${encodeURIComponent(runId)}/artifacts/${encodeURIComponent(name)}`, { method: 'GET', parseJson: false });
+ getArtifact(runId, artifactId) {
+ return this.request(`/api/runs/${encodeURIComponent(runId)}/artifacts/${encodeURIComponent(artifactId)}`, { method: 'GET', parseJson: false });
}
- artifactUrl(runId, name) { return `${this.baseUrl}/api/runs/${encodeURIComponent(runId)}/artifacts/${encodeURIComponent(name)}`; }
}
module.exports = { LoopForgeClient, LoopForgeError, normalizeBaseUrl };
diff --git a/vscode-extension/package.json b/vscode-extension/package.json
index 6a521740..1a25f567 100644
--- a/vscode-extension/package.json
+++ b/vscode-extension/package.json
@@ -14,17 +14,13 @@
},
"main": "./extension.js",
"categories": ["Other"],
- "activationEvents": [
- "onStartupFinished",
- "onView:loopforge.runs",
- "onCommand:loopforge.startWorkflow",
- "onCommand:loopforge.showStatus",
- "onCommand:loopforge.showResult",
- "onCommand:loopforge.approve",
- "onCommand:loopforge.reject",
- "onCommand:loopforge.openArtifact",
- "onCommand:loopforge.openDiff"
- ],
+ "capabilities": {
+ "untrustedWorkspaces": {
+ "supported": "limited",
+ "description": "Project and workflow settings are ignored in untrusted workspaces.",
+ "restrictedConfigurations": ["loopforge.projectId", "loopforge.workflowId"]
+ }
+ },
"contributes": {
"commands": [
{ "command": "loopforge.startWorkflow", "title": "LoopForge: Start Workflow", "icon": "$(play)" },
diff --git a/vscode-extension/test/client.test.js b/vscode-extension/test/client.test.js
index 81f8f7b0..943eda46 100644
--- a/vscode-extension/test/client.test.js
+++ b/vscode-extension/test/client.test.js
@@ -57,11 +57,11 @@ test('encodes run, artifact, and diff paths', async () => {
const urls = [];
const client = new LoopForgeClient({ baseUrl: 'http://localhost:8090', fetchImpl: async url => { urls.push(url); return response(200, { content: 'ok' }, 'text/plain'); } });
await client.getRun('run/1');
- await client.getArtifact('run/1', 'report final.md');
+ await client.getArtifact('run/1', 'artifact id');
await client.getDiff('run/1', 'src/a b.go');
assert.deepEqual(urls, [
'http://localhost:8090/api/runs/run%2F1',
- 'http://localhost:8090/api/runs/run%2F1/artifacts/report%20final.md',
+ 'http://localhost:8090/api/runs/run%2F1/artifacts/artifact%20id',
'http://localhost:8090/api/runs/run%2F1/workspace/diff?path=src%2Fa%20b.go'
]);
});
diff --git a/vscode-extension/test/extension.test.js b/vscode-extension/test/extension.test.js
index aa8c9179..3f306310 100644
--- a/vscode-extension/test/extension.test.js
+++ b/vscode-extension/test/extension.test.js
@@ -5,23 +5,24 @@ const Module = require('node:module');
function harness() {
const commands = new Map(), errors = [], documents = [], requests = [], updates = [], messages = [];
- let provider, input = ['Implement this', 'looks good'], picks = [], run;
+ let provider, visibilityListener, configurationListener, input = ['Implement this', 'looks good'], picks = [], run;
const folder = { name: 'project', uri: { fsPath: '/project', scheme: 'file' } };
const values = { serverUrl: 'https://example.test', projectId: 'project-1', workflowId: 'workflow-1', pollInterval: 300 };
const vscode = {
TreeItem: class { constructor(label, collapsibleState) { this.label = label; this.collapsibleState = collapsibleState; } },
TreeItemCollapsibleState: { Collapsed: 1, None: 0 }, ThemeIcon: class { constructor(id) { this.id = id; } },
EventEmitter: class { constructor() { this.event = () => {}; } fire() {} dispose() {} },
- ConfigurationTarget: { Workspace: 2 },
+ ConfigurationTarget: { Global: 1, Workspace: 2 },
workspace: {
workspaceFolders: [folder], getWorkspaceFolder: () => folder, asRelativePath: () => 'src/file.js',
getConfiguration: () => ({ get: (key, fallback) => values[key] ?? fallback, update: async (...args) => { updates.push(args); values[args[0]] = args[1]; } }),
+ onDidChangeConfiguration: fn => { configurationListener = fn; return { dispose() {} }; },
openTextDocument: async options => { documents.push(options); return { uri: `document-${documents.length}` }; }
},
window: {
activeTextEditor: { document: { uri: { fsPath: '/project/src/file.js' }, getText: () => 'first line\nsecond line' }, selection: { isEmpty: false } },
createOutputChannel: () => ({ appendLine() {}, clear() {}, show() {}, dispose() {} }),
- createTreeView: (_id, options) => { provider = options.treeDataProvider; return { dispose() {} }; },
+ createTreeView: (_id, options) => { provider = options.treeDataProvider; return { visible: false, onDidChangeVisibility: fn => { visibilityListener = fn; return { dispose() {} }; }, dispose() {} }; },
showInputBox: async () => input.shift(), showQuickPick: async items => picks.length ? picks.shift() : items[0],
showErrorMessage: message => { errors.push(message); }, showInformationMessage: async message => { messages.push(message); },
setStatusBarMessage() {}, showTextDocument: async doc => { documents.push(doc); }
@@ -57,7 +58,7 @@ function harness() {
};
const context = { subscriptions: [], secrets: { get: async () => '', store: async () => {}, delete: async () => {} } };
extension.activate(context);
- return { commands, errors, documents, requests, updates, messages, get provider() { return provider; }, setInput: value => { input = value; }, setPicks: value => { picks = value; }, advanceRun() { run = { ...run, nodes: { gate: { ...run.nodes.gate, attempt: 2, reworkCount: 1 } } }; }, dispose() { context.subscriptions.forEach(item => item.dispose()); globalThis.fetch = oldFetch; } };
+ return { commands, errors, documents, requests, updates, messages, values, vscode, get provider() { return provider; }, setVisible: visible => visibilityListener({ visible }), changeConfig: name => configurationListener({ affectsConfiguration: key => key === name }), setInput: value => { input = value; }, setPicks: value => { picks = value; }, advanceRun() { run = { ...run, nodes: { gate: { ...run.nodes.gate, attempt: 2, reworkCount: 1 } } }; }, dispose() { context.subscriptions.forEach(item => item.dispose()); globalThis.fetch = oldFetch; } };
}
test('starts a run with multiline selection, shows approval and opens artifact by ID', async () => {
@@ -69,6 +70,7 @@ test('starts a run with multiline selection, shows approval and opens artifact b
assert.match(request.body.input, /Implement this/);
assert.match(request.body.input, /src\/file\.js/);
assert.match(request.body.input, /first line\nsecond line/);
+ assert.doesNotMatch(request.body.input, /\/project/);
const roots = await h.provider.getChildren();
assert.equal(roots[0].contextValue, 'loopforge.run');
const children = await h.provider.getChildren(roots[0]);
@@ -85,6 +87,85 @@ test('starts a run with multiline selection, shows approval and opens artifact b
} finally { h.dispose(); }
});
+test('polls only for visible non-terminal runs and responds to configuration changes', async () => {
+ const h = harness();
+ try {
+ const state = h.provider.state;
+ assert.equal(h.requests.length, 0);
+ assert.equal(state.pollTimer, undefined);
+ await h.commands.get('loopforge.startWorkflow')();
+ assert.equal(state.pollTimer, undefined);
+ h.setVisible(true);
+ await new Promise(resolve => setImmediate(resolve));
+ assert.ok(state.pollTimer);
+ assert.equal(state.pollTimer._idleTimeout, 300000);
+ h.values.pollInterval = 12; h.changeConfig('loopforge.pollInterval');
+ assert.equal(state.pollTimer._idleTimeout, 12000);
+ h.setVisible(false); assert.equal(state.pollTimer, undefined);
+ h.setVisible(true); await new Promise(resolve => setImmediate(resolve));
+ await h.commands.get('loopforge.approve')();
+ assert.equal(state.pollTimer, undefined);
+ } finally { h.dispose(); }
+});
+
+test('selecting a project without an open folder uses user settings', async () => {
+ const h = harness();
+ try {
+ h.vscode.workspace.workspaceFolders = [];
+ await h.commands.get('loopforge.selectProject')();
+ assert.deepEqual(h.updates, [['workflowId', '', 1], ['projectId', 'project-2', 1]]);
+ } finally { h.dispose(); }
+});
+
+test('manual refresh runs again after an in-flight refresh', async () => {
+ const h = harness();
+ const originalFetch = globalThis.fetch;
+ try {
+ let release;
+ globalThis.fetch = async (...args) => { await new Promise(resolve => { release = resolve; }); return originalFetch(...args); };
+ const initial = h.provider.state.refresh({ quiet: true });
+ await new Promise(resolve => setImmediate(resolve));
+ const manual = h.commands.get('loopforge.refresh')();
+ globalThis.fetch = originalFetch;
+ release();
+ await Promise.all([initial, manual]);
+ assert.equal(h.requests.filter(r => r.route === '/api/runs').length, 2);
+ } finally { h.dispose(); }
+});
+
+test('backs off polling on failures and resumes at the configured interval', async () => {
+ const h = harness();
+ const originalFetch = globalThis.fetch;
+ try {
+ h.values.pollInterval = 10;
+ await h.commands.get('loopforge.startWorkflow')();
+ h.setVisible(true);
+ await new Promise(resolve => setImmediate(resolve));
+ globalThis.fetch = async () => { throw new Error('offline'); };
+ await h.provider.state.refresh({ quiet: true });
+ assert.equal(h.provider.state.pollTimer._idleTimeout, 20000);
+ await h.provider.state.refresh({ quiet: true });
+ assert.equal(h.provider.state.pollTimer._idleTimeout, 40000);
+ globalThis.fetch = originalFetch;
+ await h.provider.state.refresh({ quiet: true });
+ assert.equal(h.provider.state.pollTimer._idleTimeout, 10000);
+ } finally { h.dispose(); }
+});
+
+test('refreshes a visible view on server changes and stays idle while hidden', async () => {
+ const h = harness();
+ try {
+ h.changeConfig('loopforge.serverUrl');
+ assert.equal(h.requests.length, 0);
+ h.setVisible(true);
+ await new Promise(resolve => setImmediate(resolve));
+ const before = h.requests.length;
+ h.changeConfig('loopforge.serverUrl');
+ await new Promise(resolve => setImmediate(resolve));
+ assert.equal(h.requests.length, before + 1);
+ } finally { h.dispose(); }
+});
+
test('switching projects clears the old workflow and asks for a workflow in the new project', async () => {
const h = harness();
try {
From 4f9d4f512ba5e5a0b1fde4aa00c3775c1b0768d8 Mon Sep 17 00:00:00 2001
From: cocofhu
Date: Thu, 12 Nov 2026 17:57:41 +0000
Subject: [PATCH 6/6] test(vscode): cover run workspace diff/file commands and
token setup
openDiff, openFile and configureToken had no tests. Cover single and
multiple changed files, a cancelled pick, the whole-workspace diff
fallback, unavailable workspaces (with and without a server reason),
runs without changes, and storing/clearing/cancelling the API token.
Co-Authored-By: Claude Opus 5.5
---
vscode-extension/test/extension.test.js | 127 +++++++++++++++++++++++-
1 file changed, 124 insertions(+), 3 deletions(-)
diff --git a/vscode-extension/test/extension.test.js b/vscode-extension/test/extension.test.js
index 3f306310..e8b115d9 100644
--- a/vscode-extension/test/extension.test.js
+++ b/vscode-extension/test/extension.test.js
@@ -4,7 +4,7 @@ const assert = require('node:assert/strict');
const Module = require('node:module');
function harness() {
- const commands = new Map(), errors = [], documents = [], requests = [], updates = [], messages = [];
+ const commands = new Map(), errors = [], documents = [], requests = [], updates = [], messages = [], secretOps = [];
let provider, visibilityListener, configurationListener, input = ['Implement this', 'looks good'], picks = [], run;
const folder = { name: 'project', uri: { fsPath: '/project', scheme: 'file' } };
const values = { serverUrl: 'https://example.test', projectId: 'project-1', workflowId: 'workflow-1', pollInterval: 300 };
@@ -56,9 +56,9 @@ function harness() {
else return new Response(JSON.stringify({ error: 'not found' }), { status: 404, headers: { 'content-type': 'application/json' } });
return new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } });
};
- const context = { subscriptions: [], secrets: { get: async () => '', store: async () => {}, delete: async () => {} } };
+ const context = { subscriptions: [], secrets: { get: async () => '', store: async (key, value) => { secretOps.push(['store', key, value]); }, delete: async key => { secretOps.push(['delete', key]); } } };
extension.activate(context);
- return { commands, errors, documents, requests, updates, messages, values, vscode, get provider() { return provider; }, setVisible: visible => visibilityListener({ visible }), changeConfig: name => configurationListener({ affectsConfiguration: key => key === name }), setInput: value => { input = value; }, setPicks: value => { picks = value; }, advanceRun() { run = { ...run, nodes: { gate: { ...run.nodes.gate, attempt: 2, reworkCount: 1 } } }; }, dispose() { context.subscriptions.forEach(item => item.dispose()); globalThis.fetch = oldFetch; } };
+ return { commands, errors, documents, requests, updates, messages, values, secretOps, vscode, get provider() { return provider; }, setVisible: visible => visibilityListener({ visible }), changeConfig: name => configurationListener({ affectsConfiguration: key => key === name }), setInput: value => { input = value; }, setPicks: value => { picks = value; }, advanceRun() { run = { ...run, nodes: { gate: { ...run.nodes.gate, attempt: 2, reworkCount: 1 } } }; }, dispose() { context.subscriptions.forEach(item => item.dispose()); globalThis.fetch = oldFetch; } };
}
test('starts a run with multiline selection, shows approval and opens artifact by ID', async () => {
@@ -203,3 +203,124 @@ test('start failures are reported to the user', async () => {
assert.match(h.errors[0], /Cannot connect to LoopForge/);
} finally { h.dispose(); }
});
+
+// Serves the run-workspace endpoints on top of the harness server so the
+// diff/file commands can be exercised; full URLs are kept to check queries.
+function withWorkspace(h, workspace, { diff = {}, files = {} } = {}) {
+ const base = globalThis.fetch, urls = [];
+ globalThis.fetch = async (url, options) => {
+ const parsed = new URL(url);
+ urls.push(parsed.pathname + parsed.search);
+ const json = body => new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } });
+ if (parsed.pathname === '/api/runs/run-1/workspace') return json(workspace);
+ if (parsed.pathname === '/api/runs/run-1/workspace/diff') return json(diff[parsed.searchParams.get('path') || ''] || { diff: '' });
+ if (parsed.pathname === '/api/runs/run-1/workspace/file') return json(files[parsed.searchParams.get('path')] || {});
+ return base(url, options);
+ };
+ return urls;
+}
+
+test('openDiff shows a side-by-side diff for a single changed file', async () => {
+ const h = harness();
+ try {
+ await h.commands.get('loopforge.startWorkflow')();
+ const urls = withWorkspace(h, { available: true, changes: [{ path: 'repo/src/a.go', repository: 'repo' }] }, { diff: { 'repo/src/a.go': { original: 'old', modified: 'new' } } });
+ await h.commands.get('loopforge.openDiff')();
+ assert.ok(urls.includes('/api/runs/run-1/workspace/diff?path=repo%2Fsrc%2Fa.go'));
+ assert.deepEqual(h.documents.slice(-2), [{ language: 'go', content: 'old' }, { language: 'go', content: 'new' }]);
+ const diffCommand = h.requests.find(item => item.command?.[0] === 'vscode.diff');
+ assert.equal(diffCommand.command[3], 'LoopForge: repo/src/a.go');
+ assert.deepEqual(h.errors, []);
+ } finally { h.dispose(); }
+});
+
+test('openDiff asks which change to show and stops when the pick is cancelled', async () => {
+ const h = harness();
+ try {
+ await h.commands.get('loopforge.startWorkflow')();
+ const changes = [{ path: 'a/x.md', repository: 'a' }, { path: 'b/y.json', repository: 'b' }];
+ let urls = withWorkspace(h, { available: true, changes }, { diff: { 'b/y.json': { original: '{}', modified: '{"a":1}' } } });
+ h.setPicks([{ change: changes[1] }]);
+ await h.commands.get('loopforge.openDiff')();
+ assert.ok(urls.includes('/api/runs/run-1/workspace/diff?path=b%2Fy.json'));
+ assert.equal(h.documents.at(-1).language, 'json');
+
+ const before = h.documents.length;
+ urls = withWorkspace(h, { available: true, changes });
+ // showQuickPick resolves undefined when the user dismisses the picker.
+ const pick = h.vscode.window.showQuickPick;
+ h.vscode.window.showQuickPick = async () => undefined;
+ try { await h.commands.get('loopforge.openDiff')(); } finally { h.vscode.window.showQuickPick = pick; }
+ assert.ok(!urls.some(url => url.includes('/workspace/diff')));
+ assert.equal(h.documents.length, before);
+ } finally { h.dispose(); }
+});
+
+test('openDiff falls back to the whole-workspace diff when no file is listed', async () => {
+ const h = harness();
+ try {
+ await h.commands.get('loopforge.startWorkflow')();
+ const urls = withWorkspace(h, { available: true, changes: [] }, { diff: { '': { diff: 'diff --git a b' } } });
+ const before = h.documents.length;
+ await h.commands.get('loopforge.openDiff')();
+ assert.ok(urls.includes('/api/runs/run-1/workspace/diff'));
+ assert.equal(h.documents.length, before);
+ assert.deepEqual(h.errors, []);
+ } finally { h.dispose(); }
+});
+
+for (const command of ['openDiff', 'openFile']) {
+ test(`${command} reports an unavailable workspace with the server reason`, async () => {
+ const h = harness();
+ try {
+ await h.commands.get('loopforge.startWorkflow')();
+ withWorkspace(h, { available: false, reason: '工作区已清理' });
+ await h.commands.get(`loopforge.${command}`)();
+ assert.deepEqual(h.errors, ['工作区已清理']);
+ withWorkspace(h, { available: false });
+ await h.commands.get(`loopforge.${command}`)();
+ assert.equal(h.errors[1], 'Run workspace is unavailable.');
+ } finally { h.dispose(); }
+ });
+}
+
+test('openFile opens the only changed file and rejects runs without changes', async () => {
+ const h = harness();
+ try {
+ await h.commands.get('loopforge.startWorkflow')();
+ let urls = withWorkspace(h, { available: true, changes: [{ path: 'repo/main.py' }] }, { files: { 'repo/main.py': { content: 'print(1)' } } });
+ await h.commands.get('loopforge.openFile')();
+ assert.ok(urls.includes('/api/runs/run-1/workspace/file?path=repo%2Fmain.py'));
+ assert.ok(h.documents.some(doc => doc.language === 'python' && doc.content === 'print(1)'));
+
+ urls = withWorkspace(h, { available: true, changes: [] });
+ await h.commands.get('loopforge.openFile')();
+ assert.deepEqual(h.errors, ['This run has no readable changed files.']);
+ assert.ok(!urls.some(url => url.includes('/workspace/file')));
+ } finally { h.dispose(); }
+});
+
+test('openFile lets the user choose among several changed files', async () => {
+ const h = harness();
+ try {
+ await h.commands.get('loopforge.startWorkflow')();
+ const changes = [{ path: 'a/one.sh' }, { path: 'b/two.yml' }];
+ const urls = withWorkspace(h, { available: true, changes }, { files: { 'b/two.yml': { content: 'k: v' } } });
+ h.setPicks([{ change: changes[1] }]);
+ await h.commands.get('loopforge.openFile')();
+ assert.ok(urls.includes('/api/runs/run-1/workspace/file?path=b%2Ftwo.yml'));
+ assert.ok(h.documents.some(doc => doc.language === 'yaml' && doc.content === 'k: v'));
+ } finally { h.dispose(); }
+});
+
+test('configureToken stores a trimmed token, clears on empty input and ignores cancel', async () => {
+ const h = harness();
+ try {
+ h.setInput([' lf_token ', ' ', undefined]);
+ await h.commands.get('loopforge.configureToken')();
+ await h.commands.get('loopforge.configureToken')();
+ await h.commands.get('loopforge.configureToken')();
+ assert.deepEqual(h.secretOps, [['store', 'loopforge.apiToken', 'lf_token'], ['delete', 'loopforge.apiToken']]);
+ assert.deepEqual(h.messages, ['LoopForge API Token 已安全保存。', 'LoopForge API Token 已清除。']);
+ } finally { h.dispose(); }
+});