merge: main (workflow editor 109 / cli_sessions 110-111 / workflow_settings 112) — renumber PR-entity migration to 113, union core exports, TaskCard prNode + cliSessionState badges, executor PrNodeDeps + CliAgentRuntime options
This commit is contained in:
@@ -50,6 +50,7 @@
|
||||
"build:exe:all": "bun run build.ts --all",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run --silent=passed-only --reporter=dot",
|
||||
"test:ci-shape": "vitest run src/__tests__/ci-workflow.test.ts --silent=passed-only --reporter=dot",
|
||||
"test:slow-cli": "cross-env FUSION_TEST_SLOW_CLI=1 vitest run src/commands/__tests__/agent-export.test.ts --silent=passed-only --reporter=dot",
|
||||
"test:extension-integration": "cross-env FUSION_TEST_EXTENSION_INTEGRATION=1 vitest run src/__tests__/extension-integration.test.ts --silent=passed-only --reporter=dot",
|
||||
"test:build-exe": "cross-env FUSION_TEST_BUILD_EXE=1 vitest run --config vitest.build-exe.config.ts --silent=passed-only --reporter=dot",
|
||||
@@ -68,7 +69,8 @@
|
||||
"multer": "^2.1.1",
|
||||
"node-pty": "npm:@homebridge/node-pty-prebuilt-multiarch@^0.13.1",
|
||||
"react": "^19.2.0",
|
||||
"react-i18next": "^17.0.8"
|
||||
"react-i18next": "^17.0.8",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@earendil-works/pi-ai": "*",
|
||||
@@ -95,6 +97,7 @@
|
||||
"@fusion/pi-llama-cpp": "workspace:*",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/ws": "^8.5.0",
|
||||
"@vitest/coverage-v8": "^3.1.0",
|
||||
"cross-env": "^7.0.0",
|
||||
"esbuild": "^0.25.12",
|
||||
|
||||
@@ -16,13 +16,20 @@ These tools are **not** part of the user-invokable extension surface. They are i
|
||||
| `fn_task_document_write` | triage, executor, heartbeat | Save/update a named task document revision | `key` (string), `content` (string), `author?` (string) |
|
||||
| `fn_task_document_read` | triage, executor, heartbeat | Read one task document or list all | `key?` (string) |
|
||||
| `fn_workflow_list` | executor | List the project's custom workflows (read-only built-ins plus user definitions) | none |
|
||||
| `fn_workflow_get` | executor | Fetch one workflow definition by id — name, description, builtin flag, and the full IR (nodes/edges/columns/artifacts/fields) as JSON | `workflow_id` (string) |
|
||||
| `fn_workflow_get` | executor | Fetch one workflow definition by id — name, description, builtin flag, and the full IR (nodes/edges/columns/artifacts/fields/settings) as JSON | `workflow_id` (string) |
|
||||
| `fn_workflow_select` | executor | Assign a custom workflow to a task (defaults to the current task) | `workflow_id` (string), `task_id?` (string) |
|
||||
| `fn_workflow_create` | executor | Create a custom workflow definition from a graph IR (validated server-side). v2 IR supports step-inversion constructs: `parse-steps`, `foreach` (mode/isolation/concurrency/maxReworkCycles), `step-execute`, `step-review`, `code` nodes, `rework` edges, plus `artifacts` and custom `fields` declarations | `name` (string), `description?` (string), `ir` (object), `layout?` (object) |
|
||||
| `fn_workflow_update` | executor | Update a custom workflow definition's name/description/ir/layout (built-ins cannot be edited; same step-inversion IR constructs as create; editing `fields` orphans rather than destroys existing task values) | `workflow_id` (string), `name?` (string), `description?` (string), `ir?` (object), `layout?` (object), `rehome_to?` (string) |
|
||||
| `fn_workflow_create` | executor | Create a custom workflow definition from a graph IR (validated server-side). v2 IR supports step-inversion constructs: `parse-steps`, `foreach` (mode/isolation/concurrency/maxReworkCycles), `step-execute`, `step-review`, `code` nodes, `rework` edges, plus `artifacts`, custom `fields`, and typed `settings` declarations | `name` (string), `description?` (string), `ir` (object), `layout?` (object) |
|
||||
| `fn_workflow_update` | executor | Update a custom workflow definition's name/description/ir/layout (built-ins cannot be edited; same step-inversion IR constructs as create; editing `fields` orphans rather than destroys existing task values; editing `settings` declarations drops orphaned setting values on resolution) | `workflow_id` (string), `name?` (string), `description?` (string), `ir?` (object), `layout?` (object), `rehome_to?` (string) |
|
||||
| `fn_workflow_delete` | executor | Delete a custom workflow definition (built-ins cannot be deleted); selecting tasks are re-homed to the default workflow's entry column | `workflow_id` (string) |
|
||||
| `fn_workflow_settings` | executor | Read/write a workflow's per-`(workflow, project)` setting **values** (`get` returns `{stored, effective, orphaned}`; `set` writes `values` and returns `{stored, effective, orphaned}`, with `null` clearing an override — including any stored value for an orphaned key). Validated against the named workflow's declared settings; built-in **values** are writable though built-in **declarations** are not; invalid values return a typed rejection list and persist nothing | `action` (`get` \| `set`), `workflow_id` (string), `values?` (object keyed by setting id) |
|
||||
| `fn_workflow_list` | executor, chat, planning | List the project's custom workflows (read-only built-ins plus user definitions) | none |
|
||||
| `fn_workflow_get` | executor, chat, planning | Fetch one workflow definition by id — name, description, builtin flag, and the full IR (nodes/edges/columns/artifacts/fields) as JSON | `workflow_id` (string) |
|
||||
| `fn_workflow_select` | executor, chat, planning | Assign a custom workflow to a task (defaults to the current task) | `workflow_id` (string), `task_id?` (string) |
|
||||
| `fn_workflow_create` | executor, chat, planning | Create a custom workflow definition from a graph IR (validated server-side). v2 IR supports step-inversion constructs: `parse-steps`, `foreach` (mode/isolation/concurrency/maxReworkCycles), `step-execute`, `step-review`, `code` nodes, `rework` edges, plus `artifacts` and custom `fields` declarations | `name` (string), `description?` (string), `ir` (object), `layout?` (object) |
|
||||
| `fn_workflow_update` | executor, chat, planning | Update a custom workflow definition's name/description/ir/layout (built-ins cannot be edited; same step-inversion IR constructs as create; editing `fields` orphans rather than destroys existing task values) | `workflow_id` (string), `name?` (string), `description?` (string), `ir?` (object), `layout?` (object), `rehome_to?` (string) |
|
||||
| `fn_workflow_delete` | executor, chat, planning | Delete a custom workflow definition (built-ins cannot be deleted); selecting tasks are re-homed to the default workflow's entry column | `workflow_id` (string) |
|
||||
| `fn_task_promote` | executor | Promote a held task out of a manual-release hold column (defaults to the current task) | `task_id?` (string) |
|
||||
| `fn_trait_list` | executor | List the registered column trait catalog (built-in and plugin traits) | none |
|
||||
| `fn_trait_list` | executor, chat, planning | List the registered column trait catalog (built-in and plugin traits) | none |
|
||||
| `fn_memory_search` | triage, executor, heartbeat | Search project memory plus per-agent layered memory snippets | `query` (string), `limit?` (number) |
|
||||
| `fn_memory_get` | triage, executor, heartbeat | Read a bounded memory file window (including bounded per-agent layered paths) | `path` (string), `startLine?` (number), `lineCount?` (number) |
|
||||
| `fn_memory_append` | executor, heartbeat (when writable backend enabled) | Append memory notes with explicit scope: `scope="agent"` for private operating context, `scope="project"` for workspace-wide durable knowledge | `scope?` (`project` \| `agent`), `layer` (`long-term` \| `daily`), `content` (string) |
|
||||
@@ -76,3 +83,62 @@ Note: step-session execution (`step-session-executor.ts`) reuses executor coordi
|
||||
| Tool | Purpose | Parameters |
|
||||
|---|---|---|
|
||||
| `fn_heartbeat_done` | Signal end of heartbeat run with optional summary | `summary?` (string) |
|
||||
|
||||
## Workflow settings: declarations vs. values
|
||||
|
||||
Workflow settings split into two surfaces (the same split as custom task fields, one level up):
|
||||
|
||||
- **Declarations** (the typed schema) live in the workflow IR's `settings` array and are authored with `fn_workflow_create` / `fn_workflow_update`. Built-in workflow declarations cannot be edited (the store's built-in guard rejects the IR edit with a `WorkflowIrError`/built-in error surfaced through the tool result).
|
||||
- **Values** (the per-`(workflow, project)` data) are read/written with `fn_workflow_settings`. Built-in workflow **values** are writable so each project can tune `builtin:coding` differently.
|
||||
|
||||
Declare a setting (custom workflow):
|
||||
|
||||
```jsonc
|
||||
// fn_workflow_create
|
||||
{
|
||||
"name": "QA",
|
||||
"ir": {
|
||||
"version": "v2",
|
||||
"name": "QA",
|
||||
"columns": [{ "id": "intake", "name": "Intake", "traits": [] }],
|
||||
"nodes": [],
|
||||
"edges": [],
|
||||
"settings": [
|
||||
{ "id": "reviewHandoffPolicy", "name": "Review handoff", "type": "enum",
|
||||
"default": "disabled",
|
||||
"options": [
|
||||
{ "value": "disabled", "label": "Disabled" },
|
||||
{ "value": "always", "label": "Always" }
|
||||
] }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Write a value (built-in workflow VALUE — accepted even though built-in declarations are read-only):
|
||||
|
||||
```jsonc
|
||||
// fn_workflow_settings
|
||||
{ "action": "set", "workflow_id": "builtin:coding",
|
||||
"values": { "workflowStepTimeoutMs": 600000, "reviewHandoffPolicy": "always" } }
|
||||
```
|
||||
|
||||
An invalid value (e.g. an enum violation) is rejected with a typed list and persists nothing:
|
||||
|
||||
```jsonc
|
||||
// returns isError:true with details.rejections:
|
||||
// [{ "code": "enum-violation", "settingId": "reviewHandoffPolicy", "message": "..." }]
|
||||
```
|
||||
|
||||
Read values — `effective` is what the engine actually consumes (declaration defaults filled in, orphaned values dropped); `stored` is the raw override map; `orphaned` lists stored entries with no current declaration (or a value that no longer validates). `set` returns the same `{stored, effective, orphaned}` shape:
|
||||
|
||||
```jsonc
|
||||
// fn_workflow_settings
|
||||
{ "action": "get", "workflow_id": "builtin:coding" }
|
||||
// → { "workflowId": "builtin:coding",
|
||||
// "stored": { "workflowStepTimeoutMs": 600000 },
|
||||
// "effective": { "workflowStepTimeoutMs": 600000, "reviewHandoffPolicy": "disabled", ... },
|
||||
// "orphaned": [] }
|
||||
```
|
||||
|
||||
Patching a key to `null` clears any stored value for it — including a value left behind under an orphaned key — so `set` doubles as the way to drop orphans. To see the full declaration catalog (every setting id, type, and default) call `fn_workflow_get` on `builtin:coding`, whose IR `settings` array is the canonical catalog.
|
||||
|
||||
@@ -27,12 +27,10 @@ function findCompositeSetupStep(steps: any[]) {
|
||||
return steps.find((step) => step.uses === "./.github/actions/setup-node-pnpm");
|
||||
}
|
||||
|
||||
describe("CI workflow (.github/workflows/ci.yml)", () => {
|
||||
describe("Merge gate (.github/workflows/pr-checks.yml)", () => {
|
||||
let workflow: any;
|
||||
let content: string;
|
||||
let compositeAction: any;
|
||||
let buildSteps: any[];
|
||||
let testShardJob: any;
|
||||
let contributingContent: string;
|
||||
let readmeContent: string;
|
||||
let cliPackageJsonContent: string;
|
||||
@@ -41,12 +39,10 @@ describe("CI workflow (.github/workflows/ci.yml)", () => {
|
||||
let buildExeSuiteContent: string;
|
||||
|
||||
beforeAll(() => {
|
||||
const result = loadWorkflow("ci.yml");
|
||||
const result = loadWorkflow("pr-checks.yml");
|
||||
workflow = result.parsed;
|
||||
content = result.content;
|
||||
compositeAction = loadYamlFile(".github", "actions", "setup-node-pnpm", "action.yml").parsed;
|
||||
buildSteps = workflow.jobs?.build?.steps ?? [];
|
||||
testShardJob = workflow.jobs?.["test-shards"];
|
||||
contributingContent = readFileSync(join(workspaceRoot, "docs", "contributing.md"), "utf-8");
|
||||
readmeContent = readFileSync(join(workspaceRoot, "README.md"), "utf-8");
|
||||
cliPackageJsonContent = readFileSync(join(workspaceRoot, "packages", "cli", "package.json"), "utf-8");
|
||||
@@ -64,134 +60,56 @@ describe("CI workflow (.github/workflows/ci.yml)", () => {
|
||||
);
|
||||
});
|
||||
|
||||
const findBuildStepByRun = (runSnippet: string) =>
|
||||
buildSteps.find((step) => typeof step.run === "string" && step.run.includes(runSnippet));
|
||||
|
||||
it("is valid YAML", () => {
|
||||
expect(workflow).toBeDefined();
|
||||
expect(typeof workflow).toBe("object");
|
||||
});
|
||||
|
||||
it("uses workflow_dispatch trigger (auto CI disabled)", () => {
|
||||
expect(workflow.on).toHaveProperty("workflow_dispatch");
|
||||
it("runs on pull requests targeting main and ONLY there", () => {
|
||||
expect(workflow.on?.pull_request?.branches).toContain("main");
|
||||
// Post-merge signal lives in full-suite.yml; the gate workflow must not
|
||||
// double-run on push (that conflates blocking and non-blocking surfaces).
|
||||
expect(workflow.on?.push).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not auto-trigger on push/pull_request", () => {
|
||||
expect(workflow.on.push).toBeUndefined();
|
||||
expect(workflow.on.pull_request).toBeUndefined();
|
||||
it("blocks PRs on exactly lint, typecheck, build, and gate", () => {
|
||||
expect(Object.keys(workflow.jobs ?? {}).sort()).toEqual(["build", "gate", "lint", "typecheck"]);
|
||||
});
|
||||
|
||||
it("pins dependency bootstrap to frozen lockfile", () => {
|
||||
const jobs = [workflow.jobs?.lint, workflow.jobs?.["test-shards"], workflow.jobs?.build];
|
||||
for (const job of jobs) {
|
||||
expect(findCompositeSetupStep(job?.steps ?? [])).toBeDefined();
|
||||
it("contains no shard matrix or full-suite invocation (demoted to full-suite.yml)", () => {
|
||||
expect(workflow.jobs?.["test-shards"]).toBeUndefined();
|
||||
expect(workflow.jobs?.["test-slow"]).toBeUndefined();
|
||||
expect(workflow.jobs?.["test-inventory-guard"]).toBeUndefined();
|
||||
expect(content).not.toContain("test:ci:shard");
|
||||
expect(content).not.toContain("run: pnpm test\n");
|
||||
expect(content).not.toContain("pnpm verify:workspace");
|
||||
});
|
||||
|
||||
it("gate job runs boot smoke and the dedicated test:gate command", () => {
|
||||
const gateSteps = workflow.jobs?.gate?.steps ?? [];
|
||||
expect(
|
||||
gateSteps.some(
|
||||
(step: any) => typeof step.run === "string" && step.run.includes("node scripts/boot-smoke.mjs"),
|
||||
),
|
||||
).toBe(true);
|
||||
// The gate must use the dedicated command — `pnpm test` routes through
|
||||
// scripts/test-changed.mjs whose selection semantics are for local runs.
|
||||
expect(
|
||||
gateSteps.some(
|
||||
(step: any) => typeof step.run === "string" && step.run.includes("pnpm test:gate"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("pins dependency bootstrap to frozen lockfile in every job", () => {
|
||||
for (const jobName of ["lint", "typecheck", "build", "gate"]) {
|
||||
expect(findCompositeSetupStep(workflow.jobs?.[jobName]?.steps ?? [])).toBeDefined();
|
||||
}
|
||||
expect(content).not.toContain("run: pnpm install\n");
|
||||
expect(content).not.toContain("--no-frozen-lockfile");
|
||||
expect(compositeAction.inputs?.["install-args"]?.default).toBe("--frozen-lockfile");
|
||||
});
|
||||
|
||||
it("uses deterministic test sharding and keeps lint/build as explicit jobs", () => {
|
||||
expect(workflow.jobs?.lint).toBeDefined();
|
||||
expect(testShardJob).toBeDefined();
|
||||
expect(workflow.jobs?.build).toBeDefined();
|
||||
|
||||
expect(testShardJob.strategy?.matrix?.shard).toEqual([1, 2, 3]);
|
||||
expect(content).toContain("pnpm test:ci:shard --shard ${{ matrix.shard }} --total 3");
|
||||
expect(content).not.toContain("pnpm verify:workspace");
|
||||
});
|
||||
|
||||
it("runs build job after lint and sharded tests, then executes slow lane and binary packaging", () => {
|
||||
expect(workflow.jobs?.build?.needs).toEqual(["lint", "test-shards"]);
|
||||
expect(findBuildStepByRun("pnpm build")).toBeDefined();
|
||||
expect(findBuildStepByRun("pnpm test:slow-cli")).toBeDefined();
|
||||
expect(findBuildStepByRun("build:exe")).toBeDefined();
|
||||
});
|
||||
|
||||
it("keeps contributing docs aligned with verification and slow-lane contracts", () => {
|
||||
expect(contributingContent).toContain("pnpm test:full` must be runnable in a clean worktree without requiring a prior `pnpm build`.");
|
||||
expect(contributingContent).toContain("`pnpm verify:workspace` is the canonical pre-merge gate");
|
||||
expect(contributingContent).toContain("1. `pnpm lint`");
|
||||
expect(contributingContent).toContain("2. `pnpm test:full`");
|
||||
expect(contributingContent).toContain("3. `pnpm build`");
|
||||
expect(contributingContent).toContain("`pnpm test` now uses a changed-only entrypoint");
|
||||
|
||||
expect(contributingContent).toContain("pnpm test:slow-cli");
|
||||
expect(contributingContent).toContain("test:pre-release");
|
||||
expect(contributingContent).toContain("test:extension-integration");
|
||||
});
|
||||
|
||||
it("keeps docs aligned with default and explicit build commands", () => {
|
||||
expect(readmeContent).toContain("pnpm build # Build default workspace packages (excludes desktop/mobile)");
|
||||
expect(readmeContent).toContain("pnpm build:all # Build all packages (including desktop/mobile)");
|
||||
|
||||
expect(contributingContent).toContain("pnpm build # default build (excludes desktop/mobile)");
|
||||
expect(contributingContent).toContain("pnpm build:all # full recursive build including desktop/mobile");
|
||||
});
|
||||
|
||||
it("includes binary build step", () => {
|
||||
expect(content).toContain("build:exe");
|
||||
});
|
||||
|
||||
it("keeps explicit gating for audited CLI integration suites", () => {
|
||||
expect(cliPackageJsonContent).toContain('"test:slow-cli"');
|
||||
expect(cliPackageJsonContent).toContain("FUSION_TEST_SLOW_CLI=1");
|
||||
expect(cliPackageJsonContent).toContain('"test:extension-integration"');
|
||||
expect(cliPackageJsonContent).toContain("FUSION_TEST_EXTENSION_INTEGRATION=1");
|
||||
expect(cliPackageJsonContent).toContain("extension-integration.test.ts");
|
||||
expect(cliPackageJsonContent).toContain('"test:build-exe"');
|
||||
expect(cliPackageJsonContent).toContain("FUSION_TEST_BUILD_EXE=1");
|
||||
|
||||
expect(extensionSuiteContent).toContain("describe.skipIf(!SHOULD_RUN_EXTENSION_INTEGRATION)");
|
||||
expect(extensionSuiteContent).toContain("FUSION_TEST_EXTENSION_INTEGRATION");
|
||||
expect(extensionSuiteContent).toContain("dist/extension.js");
|
||||
|
||||
expect(agentExportSuiteContent).toContain("describe.skipIf(!SHOULD_RUN_SLOW_CLI)");
|
||||
expect(agentExportSuiteContent).toContain("FUSION_TEST_SLOW_CLI");
|
||||
|
||||
expect(buildExeSuiteContent).toContain('process.env.FUSION_TEST_BUILD_EXE === "1"');
|
||||
expect(buildExeSuiteContent).toContain('process.env.FUSION_TEST_BUILD_EXE === "true"');
|
||||
expect(buildExeSuiteContent).not.toContain("Boolean(process.env.FUSION_TEST_BUILD_EXE)");
|
||||
});
|
||||
|
||||
it("includes Bun setup", () => {
|
||||
expect(content).toContain("oven-sh/setup-bun");
|
||||
});
|
||||
|
||||
it("verifies binary exists after build", () => {
|
||||
expect(content).toContain("test -f packages/cli/dist/fn");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PR checks workflow (.github/workflows/pr-checks.yml)", () => {
|
||||
let workflow: any;
|
||||
let content: string;
|
||||
|
||||
beforeAll(() => {
|
||||
const result = loadWorkflow("pr-checks.yml");
|
||||
workflow = result.parsed;
|
||||
content = result.content;
|
||||
});
|
||||
|
||||
it("is valid YAML", () => {
|
||||
expect(workflow).toBeDefined();
|
||||
expect(typeof workflow).toBe("object");
|
||||
});
|
||||
|
||||
it("runs on pull requests targeting main", () => {
|
||||
expect(workflow.on?.pull_request?.branches).toContain("main");
|
||||
});
|
||||
|
||||
it("uses the same deterministic test sharding command as manual CI", () => {
|
||||
expect(workflow.jobs?.lint).toBeDefined();
|
||||
expect(workflow.jobs?.typecheck).toBeDefined();
|
||||
expect(workflow.jobs?.build).toBeDefined();
|
||||
expect(workflow.jobs?.["test-shards"]).toBeDefined();
|
||||
expect(workflow.jobs?.["test-shards"]?.strategy?.matrix?.shard).toEqual([1, 2, 3, 4]);
|
||||
expect(content).toContain("pnpm test:ci:shard --shard ${{ matrix.shard }} --total 4");
|
||||
expect(content).not.toContain("run: pnpm test\n");
|
||||
});
|
||||
|
||||
it("keeps lint as install + lint only, without Bun/setup build coupling", () => {
|
||||
const lintSteps = workflow.jobs?.lint?.steps ?? [];
|
||||
expect(
|
||||
@@ -229,7 +147,99 @@ describe("PR checks workflow (.github/workflows/pr-checks.yml)", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not spend PR action minutes on a pre-test workspace build", () => {
|
||||
it("keeps contributing docs aligned with the gate contract", () => {
|
||||
expect(contributingContent).toContain("pnpm test:full` must be runnable in a clean worktree without requiring a prior `pnpm build`.");
|
||||
expect(contributingContent).toContain("`pnpm test:gate` is the merge gate");
|
||||
expect(contributingContent).toContain("`pnpm verify:workspace` is the deep opt-in verification (not the merge gate)");
|
||||
expect(contributingContent).toContain("1. `pnpm lint`");
|
||||
expect(contributingContent).toContain("2. `pnpm test:full`");
|
||||
expect(contributingContent).toContain("3. `pnpm build`");
|
||||
expect(contributingContent).toContain("`pnpm test` now uses a changed-only entrypoint");
|
||||
|
||||
expect(contributingContent).toContain("pnpm test:slow-cli");
|
||||
expect(contributingContent).toContain("test:pre-release");
|
||||
expect(contributingContent).toContain("test:extension-integration");
|
||||
});
|
||||
|
||||
it("keeps docs aligned with default and explicit build commands", () => {
|
||||
expect(readmeContent).toContain("pnpm build # Build default workspace packages (excludes desktop/mobile)");
|
||||
expect(readmeContent).toContain("pnpm build:all # Build all packages (including desktop/mobile)");
|
||||
|
||||
expect(contributingContent).toContain("pnpm build # default build (excludes desktop/mobile)");
|
||||
expect(contributingContent).toContain("pnpm build:all # full recursive build including desktop/mobile");
|
||||
});
|
||||
|
||||
it("keeps explicit gating for audited CLI integration suites", () => {
|
||||
expect(cliPackageJsonContent).toContain('"test:slow-cli"');
|
||||
expect(cliPackageJsonContent).toContain("FUSION_TEST_SLOW_CLI=1");
|
||||
expect(cliPackageJsonContent).toContain('"test:extension-integration"');
|
||||
expect(cliPackageJsonContent).toContain("FUSION_TEST_EXTENSION_INTEGRATION=1");
|
||||
expect(cliPackageJsonContent).toContain("extension-integration.test.ts");
|
||||
expect(cliPackageJsonContent).toContain('"test:build-exe"');
|
||||
expect(cliPackageJsonContent).toContain("FUSION_TEST_BUILD_EXE=1");
|
||||
|
||||
expect(extensionSuiteContent).toContain("describe.skipIf(!SHOULD_RUN_EXTENSION_INTEGRATION)");
|
||||
expect(extensionSuiteContent).toContain("FUSION_TEST_EXTENSION_INTEGRATION");
|
||||
expect(extensionSuiteContent).toContain("dist/extension.js");
|
||||
|
||||
expect(agentExportSuiteContent).toContain("describe.skipIf(!SHOULD_RUN_SLOW_CLI)");
|
||||
expect(agentExportSuiteContent).toContain("FUSION_TEST_SLOW_CLI");
|
||||
|
||||
expect(buildExeSuiteContent).toContain('process.env.FUSION_TEST_BUILD_EXE === "1"');
|
||||
expect(buildExeSuiteContent).toContain('process.env.FUSION_TEST_BUILD_EXE === "true"');
|
||||
expect(buildExeSuiteContent).not.toContain("Boolean(process.env.FUSION_TEST_BUILD_EXE)");
|
||||
});
|
||||
|
||||
it("the deleted manual CI workflow stays deleted", () => {
|
||||
// ci.yml was the trigger-disabled (FN-1541) 3-shard manual workflow; the
|
||||
// merge-gate redesign removed it. Reintroducing it would resurrect a
|
||||
// second, drift-prone definition of the test pipeline.
|
||||
expect(() => loadWorkflow("ci.yml")).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Full suite workflow (.github/workflows/full-suite.yml)", () => {
|
||||
let workflow: any;
|
||||
let content: string;
|
||||
|
||||
beforeAll(() => {
|
||||
const result = loadWorkflow("full-suite.yml");
|
||||
workflow = result.parsed;
|
||||
content = result.content;
|
||||
});
|
||||
|
||||
it("is valid YAML", () => {
|
||||
expect(workflow).toBeDefined();
|
||||
expect(typeof workflow).toBe("object");
|
||||
});
|
||||
|
||||
it("runs ONLY on push to main — never as a PR gate", () => {
|
||||
expect(workflow.on?.push?.branches).toEqual(["main"]);
|
||||
expect(workflow.on?.pull_request).toBeUndefined();
|
||||
});
|
||||
|
||||
it("carries the demoted tier: 4-way shards, engine slow, inventory guard", () => {
|
||||
expect(workflow.jobs?.["test-shards"]?.strategy?.matrix?.shard).toEqual([1, 2, 3, 4]);
|
||||
expect(content).toContain("pnpm test:ci:shard --shard ${{ matrix.shard }} --total 4");
|
||||
expect(workflow.jobs?.["test-slow"]).toBeDefined();
|
||||
expect(workflow.jobs?.["test-inventory-guard"]).toBeDefined();
|
||||
});
|
||||
|
||||
it("keeps full clones where real-git tests need history", () => {
|
||||
const shardSteps = workflow.jobs?.["test-shards"]?.steps ?? [];
|
||||
const slowSteps = workflow.jobs?.["test-slow"]?.steps ?? [];
|
||||
for (const steps of [shardSteps, slowSteps]) {
|
||||
expect(
|
||||
steps.some((step: any) => step.uses?.includes("actions/checkout") && step.with?.["fetch-depth"] === 0),
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("still uploads per-shard timing artifacts for snapshot refresh", () => {
|
||||
expect(content).toContain("test-timings-shard-${{ matrix.shard }}");
|
||||
});
|
||||
|
||||
it("does not spend action minutes on a pre-test workspace build", () => {
|
||||
const testSteps = workflow.jobs?.["test-shards"]?.steps ?? [];
|
||||
expect(
|
||||
testSteps.some(
|
||||
|
||||
@@ -299,10 +299,16 @@ describe("Workspace bootstrap script contract", () => {
|
||||
});
|
||||
|
||||
describe("Workflow YAML validity", () => {
|
||||
it("ci.yml is valid YAML", () => {
|
||||
const parsed = loadWorkflowYaml("ci.yml");
|
||||
it("pr-checks.yml is valid YAML", () => {
|
||||
const parsed = loadWorkflowYaml("pr-checks.yml");
|
||||
expect(parsed).toBeDefined();
|
||||
expect(parsed.name).toBe("CI");
|
||||
expect(parsed.name).toBe("PR Checks");
|
||||
});
|
||||
|
||||
it("full-suite.yml is valid YAML", () => {
|
||||
const parsed = loadWorkflowYaml("full-suite.yml");
|
||||
expect(parsed).toBeDefined();
|
||||
expect(parsed.name).toBe("Full Suite (non-blocking)");
|
||||
});
|
||||
|
||||
it("version.yml is valid YAML", () => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
decideExecutionPlan,
|
||||
normalizeForwardedArgs,
|
||||
resolveAffectedPackages,
|
||||
shouldForceFullSuite,
|
||||
isSharedInfraChange,
|
||||
} from "../../../../scripts/test-changed.mjs";
|
||||
import { computeSplitPlan, parseShardArgs, planShardAssignments, selectShardPackages } from "../../../../scripts/ci-test-shard.mjs";
|
||||
|
||||
@@ -24,7 +24,7 @@ describe("root test command changed-only planning", () => {
|
||||
expect(plan).toEqual({ mode: "changed", packages: ["@fusion/core", "@fusion/engine"] });
|
||||
});
|
||||
|
||||
it("falls back to full suite when shared test infra changes", () => {
|
||||
it("routes to gate mode when shared test infra changes (no implicit full suite)", () => {
|
||||
const plan = decideExecutionPlan({
|
||||
forceFullSuite: false,
|
||||
comparisonBase: "abc123",
|
||||
@@ -32,10 +32,10 @@ describe("root test command changed-only planning", () => {
|
||||
packageNameByDir: new Map([["packages/core", "@fusion/core"]]),
|
||||
});
|
||||
|
||||
expect(plan).toEqual({ mode: "full", reason: "shared-infra-changed" });
|
||||
expect(plan).toEqual({ mode: "gate", reason: "shared-infra-changed" });
|
||||
});
|
||||
|
||||
it("falls back to full suite when comparison base cannot be resolved", () => {
|
||||
it("routes to gate mode when comparison base cannot be resolved", () => {
|
||||
const plan = decideExecutionPlan({
|
||||
forceFullSuite: false,
|
||||
comparisonBase: null,
|
||||
@@ -43,18 +43,18 @@ describe("root test command changed-only planning", () => {
|
||||
packageNameByDir: new Map(),
|
||||
});
|
||||
|
||||
expect(plan).toEqual({ mode: "full", reason: "missing-comparison-base" });
|
||||
expect(plan).toEqual({ mode: "gate", reason: "missing-comparison-base" });
|
||||
});
|
||||
|
||||
it("treats unknown package directories as full-suite fallback", () => {
|
||||
it("treats unknown package directories as gate-mode fallback (resolver returns null)", () => {
|
||||
const resolved = resolveAffectedPackages(["packages/unknown/src/index.ts"], new Map());
|
||||
expect(resolved).toBeNull();
|
||||
});
|
||||
|
||||
it("marks root workflow/config changes as full-suite triggers", () => {
|
||||
expect(shouldForceFullSuite([".github/workflows/ci.yml"])).toBe(true);
|
||||
expect(shouldForceFullSuite(["package.json"])).toBe(true);
|
||||
expect(shouldForceFullSuite(["packages/core/src/store.ts"])).toBe(false);
|
||||
it("marks root workflow/config changes as shared-infra (gate-mode) triggers", () => {
|
||||
expect(isSharedInfraChange([".github/workflows/pr-checks.yml"])).toBe(true);
|
||||
expect(isSharedInfraChange(["package.json"])).toBe(true);
|
||||
expect(isSharedInfraChange(["packages/core/src/store.ts"])).toBe(false);
|
||||
});
|
||||
|
||||
it("strips forwarded silent flags so package vitest scripts do not receive duplicates", () => {
|
||||
|
||||
@@ -495,6 +495,10 @@ vi.mock("@fusion/engine", async (importOriginal) => {
|
||||
return { stop: vi.fn() };
|
||||
}
|
||||
|
||||
getCliAgentRuntime(): undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async onMerge(taskId: string): Promise<unknown> {
|
||||
return aiMergeTask(this.store, this.cwd, taskId, {
|
||||
pool: this.pool,
|
||||
|
||||
@@ -85,6 +85,10 @@ describe("settings commands", () => {
|
||||
expect(VALID_SETTINGS).toContain("worktrunk.enabled");
|
||||
expect(VALID_SETTINGS).toContain("worktrunk.binaryPath");
|
||||
expect(VALID_SETTINGS).toContain("worktrunk.onFailure");
|
||||
// Moved keys are NOT settable via the CLI (they live in workflow settings).
|
||||
expect(VALID_SETTINGS).not.toContain("runStepsInNewSessions");
|
||||
expect(VALID_SETTINGS).not.toContain("maxParallelSteps");
|
||||
expect(VALID_SETTINGS).not.toContain("requirePlanApproval");
|
||||
expect(parseValue("ntfyEnabled", "yes")).toBe(true);
|
||||
expect(parseValue("maxConcurrent", "4")).toBe(4);
|
||||
expect(parseValue("worktreeNaming", "task-id")).toBe("task-id");
|
||||
@@ -212,20 +216,21 @@ describe("settings commands", () => {
|
||||
expect(resolveProject).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runSettingsSet with project updates runStepsInNewSessions", async () => {
|
||||
const updateSettings = vi.fn().mockResolvedValue(makeSettings({ runStepsInNewSessions: true }));
|
||||
const getSettings = vi.fn().mockResolvedValue(makeSettings({ runStepsInNewSessions: true }));
|
||||
it("rejects setting a moved key (runStepsInNewSessions) and prints the workflow-settings redirect hint", async () => {
|
||||
const updateSettings = vi.fn();
|
||||
vi.mocked(resolveProject).mockResolvedValue({
|
||||
projectId: "proj-1",
|
||||
projectName: "demo-project",
|
||||
projectPath: "/projects/demo",
|
||||
isRegistered: true,
|
||||
store: { updateSettings, getSettings } as any,
|
||||
store: { updateSettings, getSettings: vi.fn() } as any,
|
||||
});
|
||||
|
||||
await runSettingsSet("runStepsInNewSessions", "true", "demo-project");
|
||||
await expect(runSettingsSet("runStepsInNewSessions", "true", "demo-project")).rejects.toThrow("process.exit:1");
|
||||
|
||||
expect(updateSettings).toHaveBeenCalledWith({ runStepsInNewSessions: true });
|
||||
expect(updateSettings).not.toHaveBeenCalled();
|
||||
expect(errorSpy).toHaveBeenCalledWith('Error: Unknown setting "runStepsInNewSessions"');
|
||||
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("workflow settings"));
|
||||
});
|
||||
|
||||
it("runSettingsSet with project updates worktreesDir", async () => {
|
||||
@@ -244,19 +249,20 @@ describe("settings commands", () => {
|
||||
expect(updateSettings).toHaveBeenCalledWith({ worktreesDir: "~/.fn-worktrees/{repo}" });
|
||||
});
|
||||
|
||||
it("runSettingsSet with project updates maxParallelSteps", async () => { const updateSettings = vi.fn().mockResolvedValue(makeSettings({ maxParallelSteps: 3 }));
|
||||
const getSettings = vi.fn().mockResolvedValue(makeSettings({ maxParallelSteps: 3 }));
|
||||
it("rejects setting a moved key (maxParallelSteps) — it lives in workflow settings now", async () => {
|
||||
const updateSettings = vi.fn();
|
||||
vi.mocked(resolveProject).mockResolvedValue({
|
||||
projectId: "proj-1",
|
||||
projectName: "demo-project",
|
||||
projectPath: "/projects/demo",
|
||||
isRegistered: true,
|
||||
store: { updateSettings, getSettings } as any,
|
||||
store: { updateSettings, getSettings: vi.fn() } as any,
|
||||
});
|
||||
|
||||
await runSettingsSet("maxParallelSteps", "3", "demo-project");
|
||||
await expect(runSettingsSet("maxParallelSteps", "3", "demo-project")).rejects.toThrow("process.exit:1");
|
||||
|
||||
expect(updateSettings).toHaveBeenCalledWith({ maxParallelSteps: 3 });
|
||||
expect(updateSettings).not.toHaveBeenCalled();
|
||||
expect(errorSpy).toHaveBeenCalledWith('Error: Unknown setting "maxParallelSteps"');
|
||||
});
|
||||
|
||||
it("runSettingsSet updates defaultNodeId and unavailableNodePolicy", async () => {
|
||||
@@ -277,7 +283,7 @@ describe("settings commands", () => {
|
||||
expect(updateSettings).toHaveBeenNthCalledWith(2, { unavailableNodePolicy: "fallback-local" });
|
||||
});
|
||||
|
||||
it("rejects maxParallelSteps values outside range", async () => {
|
||||
it("rejects values outside range for a still-valid numeric setting (maxWorktrees)", async () => {
|
||||
vi.mocked(resolveProject).mockResolvedValue({
|
||||
projectId: "proj-1",
|
||||
projectName: "demo-project",
|
||||
@@ -286,11 +292,11 @@ describe("settings commands", () => {
|
||||
store: { updateSettings: vi.fn(), getSettings: vi.fn() } as any,
|
||||
});
|
||||
|
||||
await expect(runSettingsSet("maxParallelSteps", "5", "demo-project")).rejects.toThrow("process.exit:1");
|
||||
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Value out of range for maxParallelSteps"));
|
||||
await expect(runSettingsSet("maxWorktrees", "99", "demo-project")).rejects.toThrow("process.exit:1");
|
||||
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Value out of range for maxWorktrees"));
|
||||
});
|
||||
|
||||
it("runSettingsShow displays Execution section with step-session settings", async () => {
|
||||
it("runSettingsShow prints the workflow-settings redirect hint and no longer lists moved step settings", async () => {
|
||||
const getSettings = vi.fn().mockResolvedValue(makeSettings({
|
||||
runStepsInNewSessions: true,
|
||||
maxParallelSteps: 3,
|
||||
@@ -306,9 +312,11 @@ describe("settings commands", () => {
|
||||
await runSettingsShow("demo-project");
|
||||
|
||||
const output = logSpy.mock.calls.map((args) => args.join(" ")).join("\n");
|
||||
expect(output).toContain("Execution");
|
||||
expect(output).toContain("Run Steps In New Sessions");
|
||||
expect(output).toContain("Max Parallel Steps");
|
||||
// Moved step settings are no longer listed; the redirect hint points users
|
||||
// to workflow settings.
|
||||
expect(output).not.toContain("Run Steps In New Sessions");
|
||||
expect(output).not.toContain("Max Parallel Steps");
|
||||
expect(output).toContain("workflow settings");
|
||||
});
|
||||
|
||||
it("rejects enabling worktrunk when binary is not verified", async () => {
|
||||
|
||||
@@ -9,7 +9,7 @@ vi.mock("node:child_process", () => ({
|
||||
spawn: mockSpawn,
|
||||
}));
|
||||
|
||||
import { parseOpencodeModelsOutput, refreshOpencodeGoModels, syncStartupModels } from "../startup-model-sync.js";
|
||||
import { normalizeOpencodeGoModel, parseOpencodeModelsOutput, refreshOpencodeGoModels, syncStartupModels } from "../startup-model-sync.js";
|
||||
|
||||
type MockProcess = EventEmitter & {
|
||||
stdout: EventEmitter;
|
||||
@@ -75,8 +75,8 @@ describe("startup-model-sync", () => {
|
||||
expect(registerProvider).toHaveBeenCalledWith("openrouter", expect.objectContaining({ models: expect.any(Array) }));
|
||||
expect(registerProvider).toHaveBeenCalledWith("opencode-go", expect.objectContaining({
|
||||
models: expect.arrayContaining([
|
||||
expect.objectContaining({ id: "opencode-go/gpt-5" }),
|
||||
expect.objectContaining({ id: "opencode-go/custom" }),
|
||||
expect.objectContaining({ id: "gpt-5" }),
|
||||
expect.objectContaining({ id: "custom" }),
|
||||
]),
|
||||
}));
|
||||
expect(log).toHaveBeenCalledWith("openrouter", expect.stringContaining("Synced"));
|
||||
@@ -257,7 +257,7 @@ describe("startup-model-sync", () => {
|
||||
|
||||
expect(result).toEqual({ registeredCount: 1 });
|
||||
expect(registerProvider).toHaveBeenCalledWith("opencode-go", expect.objectContaining({
|
||||
models: [expect.objectContaining({ id: "opencode-go/gpt-5" })],
|
||||
models: [expect.objectContaining({ id: "gpt-5" })],
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -319,4 +319,52 @@ describe("startup-model-sync", () => {
|
||||
"opencode-go/custom",
|
||||
]);
|
||||
});
|
||||
|
||||
it("deduplicates models when CLI emits both prefix forms", async () => {
|
||||
mockSpawn.mockImplementation(() => {
|
||||
const proc = createSpawnProcess();
|
||||
queueMicrotask(() => {
|
||||
proc.stdout.emit("data", Buffer.from("opencode/foo\nopencode-go/foo\nopencode/bar\n"));
|
||||
proc.emit("exit", 0);
|
||||
});
|
||||
return proc;
|
||||
});
|
||||
|
||||
const registerProvider = vi.fn();
|
||||
await refreshOpencodeGoModels({ modelRegistry: { registerProvider }, log: vi.fn() });
|
||||
|
||||
expect(registerProvider).toHaveBeenCalledWith("opencode-go", expect.objectContaining({
|
||||
models: [
|
||||
expect.objectContaining({ id: "foo" }),
|
||||
expect.objectContaining({ id: "bar" }),
|
||||
],
|
||||
}));
|
||||
});
|
||||
|
||||
it("throws on empty model ID after prefix stripping", () => {
|
||||
expect(() => normalizeOpencodeGoModel("opencode/")).toThrow("no model name");
|
||||
expect(() => normalizeOpencodeGoModel("opencode-go/")).toThrow("no model name");
|
||||
});
|
||||
|
||||
it("accepts apiKey and passes it as env var to spawn", async () => {
|
||||
mockSpawn.mockImplementation(() => {
|
||||
const proc = createSpawnProcess();
|
||||
queueMicrotask(() => {
|
||||
proc.stdout.emit("data", Buffer.from("opencode/foo\n"));
|
||||
proc.emit("exit", 0);
|
||||
});
|
||||
return proc;
|
||||
});
|
||||
|
||||
const registerProvider = vi.fn();
|
||||
await refreshOpencodeGoModels({ modelRegistry: { registerProvider }, log: vi.fn(), apiKey: "test-key" });
|
||||
|
||||
expect(mockSpawn).toHaveBeenCalledWith(
|
||||
"opencode",
|
||||
["models", "opencode", "--refresh"],
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({ OPENCODE_API_KEY: "test-key" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -70,12 +70,42 @@ interface MockTask {
|
||||
column: string;
|
||||
}
|
||||
|
||||
// `requirePrApproval` MOVED to workflow settings (U4): the CLI now resolves the
|
||||
// task's EFFECTIVE workflow settings and overlays them onto the project base. So a
|
||||
// mock store must expose `requirePrApproval` (and any moved key) through the
|
||||
// effective-settings resolver store surface (`getWorkflowSettingValues` etc.), not
|
||||
// through `getSettings()`. These stubs make `resolveEffectiveSettings` degrade to
|
||||
// `builtin:coding` and read the moved value from the stored workflow values.
|
||||
const MOVED_TEST_KEYS = new Set(["requirePrApproval"]);
|
||||
|
||||
function splitMovedSettings(settings: Record<string, unknown>) {
|
||||
const projectSettings: Record<string, unknown> = {};
|
||||
const workflowValues: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(settings)) {
|
||||
if (MOVED_TEST_KEYS.has(key)) workflowValues[key] = value;
|
||||
else projectSettings[key] = value;
|
||||
}
|
||||
return { projectSettings, workflowValues };
|
||||
}
|
||||
|
||||
function workflowSettingsResolverStubs(workflowValues: Record<string, unknown>) {
|
||||
return {
|
||||
// No selection → resolver degrades to builtin:coding, whose declarations carry
|
||||
// the moved-key catalog; the stored values below override the declaration default.
|
||||
getTaskWorkflowSelection: vi.fn().mockReturnValue(undefined),
|
||||
getWorkflowDefinition: vi.fn().mockResolvedValue(undefined),
|
||||
getWorkflowSettingValues: vi.fn().mockReturnValue(workflowValues),
|
||||
getWorkflowSettingsProjectId: vi.fn().mockReturnValue("test-project"),
|
||||
};
|
||||
}
|
||||
|
||||
function makeStore(task: MockTask, settings: Record<string, unknown> = {}) {
|
||||
const emitter = new EventEmitter();
|
||||
const updates: Array<{ id: string; patch: Record<string, unknown> }> = [];
|
||||
const { projectSettings, workflowValues } = splitMovedSettings(settings);
|
||||
return Object.assign(emitter, {
|
||||
getTask: vi.fn().mockResolvedValue(task),
|
||||
getSettings: vi.fn().mockResolvedValue({ requirePrApproval: false, ...settings }),
|
||||
getSettings: vi.fn().mockResolvedValue({ ...projectSettings }),
|
||||
updateTask: vi.fn(async (id: string, patch: Record<string, unknown>) => {
|
||||
updates.push({ id, patch });
|
||||
}),
|
||||
@@ -86,6 +116,7 @@ function makeStore(task: MockTask, settings: Record<string, unknown> = {}) {
|
||||
getBranchGroup: vi.fn().mockReturnValue(null),
|
||||
updateBranchGroup: vi.fn(),
|
||||
listTasksByBranchGroup: vi.fn().mockResolvedValue([]),
|
||||
...workflowSettingsResolverStubs(workflowValues),
|
||||
_updates: updates,
|
||||
});
|
||||
}
|
||||
@@ -93,9 +124,11 @@ function makeStore(task: MockTask, settings: Record<string, unknown> = {}) {
|
||||
function makeStatefulStore(task: MockTask, settings: Record<string, unknown> = {}) {
|
||||
const emitter = new EventEmitter();
|
||||
let state = structuredClone(task);
|
||||
const { projectSettings, workflowValues } = splitMovedSettings(settings);
|
||||
return Object.assign(emitter, {
|
||||
getTask: vi.fn(async () => structuredClone(state)),
|
||||
getSettings: vi.fn().mockResolvedValue({ requirePrApproval: false, ...settings }),
|
||||
getSettings: vi.fn().mockResolvedValue({ ...projectSettings }),
|
||||
...workflowSettingsResolverStubs(workflowValues),
|
||||
updateTask: vi.fn(async (_id: string, patch: Record<string, unknown>) => {
|
||||
state = { ...state, ...patch };
|
||||
}),
|
||||
|
||||
@@ -73,7 +73,7 @@ import { createReadOnlyAuthFileStorage, mergeAuthStorageReads, wrapAuthStorageWi
|
||||
import { getClaudeCodeCredentialPaths, getCodexCliAuthPath, getFusionAuthPath, getLegacyAuthPaths, getModelRegistryModelsPath, getPackageManagerAgentDir } from "./auth-paths.js";
|
||||
import { resolveProject } from "../project-context.js";
|
||||
import { ensureBundledDependencyGraphPluginInstalled } from "../plugins/bundled-plugin-install.js";
|
||||
import { refreshOpencodeGoModels, syncStartupModels } from "./startup-model-sync.js";
|
||||
import { handleOpencodeGoApiKeySaved, syncStartupModels } from "./startup-model-sync.js";
|
||||
import { ensureCwdProjectRegistered } from "./ensure-project-registered.js";
|
||||
|
||||
const DIAGNOSTIC_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes
|
||||
@@ -724,14 +724,12 @@ export async function runDaemon(opts: DaemonOptions = {}) {
|
||||
if (providerId !== "opencode" && providerId !== "opencode-go") {
|
||||
return undefined;
|
||||
}
|
||||
const settings = await store.getSettings();
|
||||
if (settings.opencodeGoModelSync === false) {
|
||||
return { registeredCount: 0, reason: "disabled-by-settings" };
|
||||
}
|
||||
return await refreshOpencodeGoModels({
|
||||
return await handleOpencodeGoApiKeySaved(
|
||||
dashboardAuthStorage,
|
||||
store,
|
||||
modelRegistry,
|
||||
log: (scope, message) => console.log(`[${scope}] ${message}`),
|
||||
});
|
||||
(scope, message) => console.log(`[${scope}] ${message}`),
|
||||
);
|
||||
},
|
||||
getClaudeCliExtensionStatus: () => {
|
||||
const r = getCachedClaudeCliResolution();
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
attachTerminalSession,
|
||||
buildWsUrl,
|
||||
fetchAttachTicket,
|
||||
DETACH_CHORD_BYTE,
|
||||
ALT_SCREEN_ENTER,
|
||||
ALT_SCREEN_LEAVE,
|
||||
WS_OPEN,
|
||||
type TerminalWebSocket,
|
||||
type AttachStdin,
|
||||
type AttachStdout,
|
||||
} from "../terminal-attach.js";
|
||||
|
||||
// ── Fakes ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** In-memory WS-like transport. Never opens a real socket / never port 4040. */
|
||||
class FakeWs implements TerminalWebSocket {
|
||||
readyState = WS_OPEN;
|
||||
sent: string[] = [];
|
||||
private handlers = new Map<string, ((...args: unknown[]) => void)[]>();
|
||||
closed = false;
|
||||
|
||||
on(event: string, listener: (...args: unknown[]) => void): void {
|
||||
const list = this.handlers.get(event) ?? [];
|
||||
list.push(listener);
|
||||
this.handlers.set(event, list);
|
||||
}
|
||||
send(data: string): void {
|
||||
this.sent.push(data);
|
||||
}
|
||||
close(): void {
|
||||
if (this.closed) return;
|
||||
this.closed = true;
|
||||
this.readyState = 3; // CLOSED
|
||||
this.emit("close");
|
||||
}
|
||||
emit(event: string, ...args: unknown[]): void {
|
||||
for (const l of this.handlers.get(event) ?? []) l(...args);
|
||||
}
|
||||
/** Simulate the server delivering a (JSON) message frame. */
|
||||
deliver(frame: unknown): void {
|
||||
this.emit("message", Buffer.from(JSON.stringify(frame), "utf8"));
|
||||
}
|
||||
/** Parsed client→server frames. */
|
||||
parsedSent(): Array<Record<string, unknown>> {
|
||||
return this.sent.map((s) => JSON.parse(s));
|
||||
}
|
||||
}
|
||||
|
||||
class FakeStdin implements AttachStdin {
|
||||
isTTY = true;
|
||||
isRaw = false;
|
||||
private listeners: ((chunk: Buffer | string) => void)[] = [];
|
||||
rawCalls: boolean[] = [];
|
||||
resumed = false;
|
||||
on(_event: "data", listener: (chunk: Buffer | string) => void): void {
|
||||
this.listeners.push(listener);
|
||||
}
|
||||
off(_event: "data", listener: (chunk: Buffer | string) => void): void {
|
||||
this.listeners = this.listeners.filter((l) => l !== listener);
|
||||
}
|
||||
setRawMode(mode: boolean): void {
|
||||
this.rawCalls.push(mode);
|
||||
this.isRaw = mode;
|
||||
}
|
||||
resume(): void {
|
||||
this.resumed = true;
|
||||
}
|
||||
/** Simulate a user keystroke chunk. */
|
||||
feed(chunk: Buffer | string): void {
|
||||
for (const l of [...this.listeners]) l(chunk);
|
||||
}
|
||||
listenerCount(): number {
|
||||
return this.listeners.length;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeStdout implements AttachStdout {
|
||||
columns = 80;
|
||||
rows = 24;
|
||||
writes: string[] = [];
|
||||
private resizeListeners: (() => void)[] = [];
|
||||
write(chunk: string): void {
|
||||
this.writes.push(chunk);
|
||||
}
|
||||
on(_event: "resize", listener: () => void): void {
|
||||
this.resizeListeners.push(listener);
|
||||
}
|
||||
off(_event: "resize", listener: () => void): void {
|
||||
this.resizeListeners = this.resizeListeners.filter((l) => l !== listener);
|
||||
}
|
||||
fireResize(cols: number, rows: number): void {
|
||||
this.columns = cols;
|
||||
this.rows = rows;
|
||||
for (const l of [...this.resizeListeners]) l();
|
||||
}
|
||||
resizeListenerCount(): number {
|
||||
return this.resizeListeners.length;
|
||||
}
|
||||
all(): string {
|
||||
return this.writes.join("");
|
||||
}
|
||||
}
|
||||
|
||||
/** A fetchImpl that always returns a ticket. */
|
||||
function okTicketFetch(ticket = "TICKET-1"): typeof fetch {
|
||||
return vi.fn(async () =>
|
||||
new Response(JSON.stringify({ ticket, expiresAt: new Date().toISOString(), readOnly: false }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
function b64(s: string): string {
|
||||
return Buffer.from(s, "utf8").toString("base64");
|
||||
}
|
||||
|
||||
interface Harness {
|
||||
ws: FakeWs;
|
||||
stdin: FakeStdin;
|
||||
stdout: FakeStdout;
|
||||
onDetach: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start an attach, drive the WS `open`, and return the harness + handle.
|
||||
* `await tick()` lets the async ticket fetch resolve.
|
||||
*/
|
||||
async function startAttach(
|
||||
overrides: Partial<Parameters<typeof attachTerminalSession>[0]> = {},
|
||||
): Promise<Harness & { handle: ReturnType<typeof attachTerminalSession> }> {
|
||||
const ws = new FakeWs();
|
||||
const stdin = new FakeStdin();
|
||||
const stdout = new FakeStdout();
|
||||
const onDetach = vi.fn();
|
||||
|
||||
const handle = attachTerminalSession({
|
||||
baseUrl: "http://127.0.0.1:9999",
|
||||
token: "daemon-tok",
|
||||
sessionId: "sess-1",
|
||||
stdin,
|
||||
stdout,
|
||||
onDetach,
|
||||
fetchImpl: okTicketFetch(),
|
||||
wsFactory: () => ws,
|
||||
ackThresholdBytes: 64,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// Let the ticket fetch resolve, then open the socket.
|
||||
await tick();
|
||||
ws.emit("open");
|
||||
|
||||
return { ws, stdin, stdout, onDetach, handle };
|
||||
}
|
||||
|
||||
const tick = () => new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ── URL / ticket helpers ──────────────────────────────────────────────────────
|
||||
|
||||
describe("buildWsUrl", () => {
|
||||
it("derives ws:// from http:// and sets sessionId + ticket", () => {
|
||||
const url = buildWsUrl({ baseUrl: "http://127.0.0.1:4040", sessionId: "s1", ticket: "t1" });
|
||||
expect(url).toBe("ws://127.0.0.1:4040/api/cli-sessions/ws?sessionId=s1&ticket=t1");
|
||||
});
|
||||
it("derives wss:// from https://", () => {
|
||||
const url = buildWsUrl({ baseUrl: "https://host", sessionId: "s", ticket: "t" });
|
||||
expect(url.startsWith("wss://host/api/cli-sessions/ws")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchAttachTicket", () => {
|
||||
it("POSTs to the attach-ticket route with bearer auth and returns the ticket", async () => {
|
||||
const fetchImpl = vi.fn(async () =>
|
||||
new Response(JSON.stringify({ ticket: "TK", readOnly: false }), { status: 200 }),
|
||||
) as unknown as typeof fetch;
|
||||
const res = await fetchAttachTicket({
|
||||
baseUrl: "http://h",
|
||||
token: "tok",
|
||||
sessionId: "s 1",
|
||||
fetchImpl,
|
||||
});
|
||||
expect(res.ticket).toBe("TK");
|
||||
const [url, init] = (fetchImpl as unknown as { mock: { calls: unknown[][] } }).mock.calls[0];
|
||||
expect(url).toBe("http://h/api/cli-sessions/s%201/attach-ticket");
|
||||
expect((init as RequestInit).method).toBe("POST");
|
||||
expect((init as RequestInit).headers).toMatchObject({ authorization: "Bearer tok" });
|
||||
});
|
||||
|
||||
it("throws on non-2xx", async () => {
|
||||
const fetchImpl = vi.fn(async () => new Response("nope", { status: 404, statusText: "Not Found" })) as unknown as typeof fetch;
|
||||
await expect(
|
||||
fetchAttachTicket({ baseUrl: "http://h", sessionId: "s", fetchImpl }),
|
||||
).rejects.toThrow(/HTTP 404/);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Passthrough loop ──────────────────────────────────────────────────────────
|
||||
|
||||
describe("attachTerminalSession passthrough", () => {
|
||||
it("enters alt-screen + raw mode on open and sends an initial resize", async () => {
|
||||
const { stdin, stdout, ws } = await startAttach();
|
||||
expect(stdout.all()).toContain(ALT_SCREEN_ENTER);
|
||||
expect(stdin.rawCalls).toContain(true);
|
||||
expect(stdin.resumed).toBe(true);
|
||||
const resize = ws.parsedSent().find((f) => f.type === "resize");
|
||||
expect(resize).toMatchObject({ type: "resize", cols: 80, rows: 24 });
|
||||
});
|
||||
|
||||
it("frames stdin bytes into input messages (base64)", async () => {
|
||||
const { stdin, ws } = await startAttach();
|
||||
stdin.feed(Buffer.from("ls -la\r", "utf8"));
|
||||
const input = ws.parsedSent().find((f) => f.type === "input");
|
||||
expect(input).toBeDefined();
|
||||
expect(Buffer.from(input!.data as string, "base64").toString("utf8")).toBe("ls -la\r");
|
||||
});
|
||||
|
||||
it("writes data frames to stdout verbatim", async () => {
|
||||
const { stdout, ws } = await startAttach();
|
||||
const payload = "hello \x1b[31mworld\x1b[0m\n";
|
||||
ws.deliver({ type: "data", data: b64(payload) });
|
||||
expect(stdout.all()).toContain(payload);
|
||||
});
|
||||
|
||||
it("passes CJK / double-width bytes through verbatim", async () => {
|
||||
const { stdout, ws } = await startAttach();
|
||||
const payload = "日本語 ❤ 한국어";
|
||||
ws.deliver({ type: "data", data: b64(payload) });
|
||||
expect(stdout.all()).toContain(payload);
|
||||
});
|
||||
|
||||
it("writes scrollback frames to stdout", async () => {
|
||||
const { stdout, ws } = await startAttach();
|
||||
ws.deliver({ type: "scrollback", data: b64("prior output\n") });
|
||||
expect(stdout.all()).toContain("prior output\n");
|
||||
});
|
||||
|
||||
it("propagates host resize as a resize frame", async () => {
|
||||
const { stdout, ws } = await startAttach();
|
||||
stdout.fireResize(120, 40);
|
||||
const resizes = ws.parsedSent().filter((f) => f.type === "resize");
|
||||
expect(resizes.at(-1)).toMatchObject({ cols: 120, rows: 40 });
|
||||
});
|
||||
});
|
||||
|
||||
// ── Detach chord ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe("detach chord (Ctrl-])", () => {
|
||||
it("restores state: leaves alt-screen, restores raw mode, closes WS, calls onDetach", async () => {
|
||||
const { stdin, stdout, ws, onDetach, handle } = await startAttach();
|
||||
stdin.feed(Buffer.from([DETACH_CHORD_BYTE]));
|
||||
await handle.done;
|
||||
|
||||
expect(stdout.all()).toContain(ALT_SCREEN_LEAVE);
|
||||
expect(stdin.rawCalls.at(-1)).toBe(false); // restored to prior (false)
|
||||
expect(ws.closed).toBe(true);
|
||||
expect(onDetach).toHaveBeenCalledTimes(1);
|
||||
expect(onDetach).toHaveBeenCalledWith(undefined);
|
||||
// Listeners removed (refcount back to baseline).
|
||||
expect(stdin.listenerCount()).toBe(0);
|
||||
expect(stdout.resizeListenerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("flushes bytes before the chord, then detaches", async () => {
|
||||
const { stdin, ws, handle } = await startAttach();
|
||||
stdin.feed(Buffer.from([0x61, 0x62, DETACH_CHORD_BYTE, 0x63])); // "ab" Ctrl-] "c"
|
||||
await handle.done;
|
||||
const inputs = ws.parsedSent().filter((f) => f.type === "input");
|
||||
expect(inputs).toHaveLength(1);
|
||||
expect(Buffer.from(inputs[0].data as string, "base64").toString("utf8")).toBe("ab");
|
||||
});
|
||||
|
||||
it("is idempotent — detach() after a chord does not re-fire onDetach", async () => {
|
||||
const { stdin, onDetach, handle } = await startAttach();
|
||||
stdin.feed(Buffer.from([DETACH_CHORD_BYTE]));
|
||||
await handle.done;
|
||||
handle.detach();
|
||||
expect(onDetach).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Error / drop paths ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("WS close mid-attach surfaces error", () => {
|
||||
it("close before exit → onDetach(error) and terminal restored", async () => {
|
||||
const { ws, stdout, stdin, onDetach, handle } = await startAttach();
|
||||
ws.close();
|
||||
await handle.done;
|
||||
expect(onDetach).toHaveBeenCalledTimes(1);
|
||||
expect(onDetach.mock.calls[0][0]).toBeInstanceOf(Error);
|
||||
expect(stdout.all()).toContain(ALT_SCREEN_LEAVE);
|
||||
expect(stdin.rawCalls.at(-1)).toBe(false);
|
||||
});
|
||||
|
||||
it("WS error → onDetach(error) and clean restore", async () => {
|
||||
const { ws, stdout, onDetach, handle } = await startAttach();
|
||||
ws.emit("error", new Error("boom"));
|
||||
await handle.done;
|
||||
expect(onDetach).toHaveBeenCalledTimes(1);
|
||||
expect((onDetach.mock.calls[0][0] as Error).message).toBe("boom");
|
||||
expect(stdout.all()).toContain(ALT_SCREEN_LEAVE);
|
||||
});
|
||||
|
||||
it("server `exit` frame ends the attach cleanly (no error)", async () => {
|
||||
const { ws, onDetach, handle } = await startAttach();
|
||||
ws.deliver({ type: "exit" });
|
||||
await handle.done;
|
||||
expect(onDetach).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
|
||||
it("failed ticket mint surfaces the error without opening the WS", async () => {
|
||||
const onDetach = vi.fn();
|
||||
const wsFactory = vi.fn();
|
||||
const handle = attachTerminalSession({
|
||||
baseUrl: "http://127.0.0.1:9999",
|
||||
sessionId: "s",
|
||||
stdin: new FakeStdin(),
|
||||
stdout: new FakeStdout(),
|
||||
onDetach,
|
||||
fetchImpl: vi.fn(async () => new Response("x", { status: 500, statusText: "Err" })) as unknown as typeof fetch,
|
||||
wsFactory: wsFactory as never,
|
||||
});
|
||||
await handle.done;
|
||||
expect(wsFactory).not.toHaveBeenCalled();
|
||||
expect(onDetach).toHaveBeenCalledTimes(1);
|
||||
expect(onDetach.mock.calls[0][0]).toBeInstanceOf(Error);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Output neutralization (full U10 parity set) ─────────────────────────────────
|
||||
|
||||
describe("output neutralization before stdout", () => {
|
||||
it("strips OSC 52 clipboard-write", async () => {
|
||||
const { stdout, ws } = await startAttach();
|
||||
const hostile = `before\x1b]52;c;${Buffer.from("stolen").toString("base64")}\x07after`;
|
||||
ws.deliver({ type: "data", data: b64(hostile) });
|
||||
const out = stdout.all();
|
||||
expect(out).toContain("before");
|
||||
expect(out).toContain("after");
|
||||
expect(out).not.toContain("52;c;");
|
||||
});
|
||||
|
||||
it("strips a non-http(s) (javascript:) OSC 8 link URI but keeps the text", async () => {
|
||||
const { stdout, ws } = await startAttach();
|
||||
const hostile = `\x1b]8;;javascript:alert(1)\x07click me\x1b]8;;\x07`;
|
||||
ws.deliver({ type: "data", data: b64(hostile) });
|
||||
const out = stdout.all();
|
||||
expect(out).toContain("click me");
|
||||
expect(out).not.toContain("javascript:alert(1)");
|
||||
});
|
||||
|
||||
it("passes an http(s) OSC 8 link through", async () => {
|
||||
const { stdout, ws } = await startAttach();
|
||||
const safe = `\x1b]8;;https://example.com\x07link\x1b]8;;\x07`;
|
||||
ws.deliver({ type: "data", data: b64(safe) });
|
||||
expect(stdout.all()).toContain("https://example.com");
|
||||
});
|
||||
|
||||
it("strips a DSR device-status query (would forge input)", async () => {
|
||||
const { stdout, ws } = await startAttach();
|
||||
const hostile = "x\x1b[6ny"; // DSR cursor-position report
|
||||
ws.deliver({ type: "data", data: b64(hostile) });
|
||||
const out = stdout.all();
|
||||
expect(out).toContain("x");
|
||||
expect(out).toContain("y");
|
||||
expect(out).not.toContain("\x1b[6n");
|
||||
});
|
||||
|
||||
it("neutralizes a sequence split across two frames", async () => {
|
||||
const { stdout, ws } = await startAttach();
|
||||
// Split an OSC 52 across two data frames.
|
||||
const part1 = `safe\x1b]52;c;${Buffer.from("secret").toString("base64")}`;
|
||||
const part2 = `\x07tail`;
|
||||
ws.deliver({ type: "data", data: b64(part1) });
|
||||
ws.deliver({ type: "data", data: b64(part2) });
|
||||
const out = stdout.all();
|
||||
expect(out).toContain("safe");
|
||||
expect(out).toContain("tail");
|
||||
expect(out).not.toContain("52;c;");
|
||||
});
|
||||
});
|
||||
|
||||
// ── ACK flow control ───────────────────────────────────────────────────────────
|
||||
|
||||
describe("ACK flow control", () => {
|
||||
it("emits an ACK after the threshold bytes are written", async () => {
|
||||
const { stdout, ws } = await startAttach({ ackThresholdBytes: 64 });
|
||||
void stdout;
|
||||
// 100 bytes of benign output → crosses the 64-byte threshold once.
|
||||
ws.deliver({ type: "data", data: b64("a".repeat(100)) });
|
||||
const acks = ws.parsedSent().filter((f) => f.type === "ack");
|
||||
expect(acks).toHaveLength(1);
|
||||
expect(acks[0].bytes).toBeGreaterThanOrEqual(64);
|
||||
});
|
||||
|
||||
it("does not ACK below the threshold", async () => {
|
||||
const { ws } = await startAttach({ ackThresholdBytes: 1024 });
|
||||
ws.deliver({ type: "data", data: b64("short") });
|
||||
expect(ws.parsedSent().filter((f) => f.type === "ack")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -134,6 +134,14 @@ export class DashboardTUI {
|
||||
waitUntilExit: () => Promise<unknown>;
|
||||
clear?: () => void;
|
||||
} & Record<string, unknown> | null = null;
|
||||
// Captured at start() so a full-screen terminal attach (U14) can unmount Ink,
|
||||
// hand the TTY to the passthrough loop, then remount the same app on detach.
|
||||
private renderApp: (() => unknown) | null = null;
|
||||
private inkRender: ((node: unknown) => typeof this.inkInstance) | null = null;
|
||||
// True while a full-screen terminal attach owns the TTY; suppresses Ink
|
||||
// re-render/resize work that would corrupt the passthrough surface.
|
||||
private terminalAttachActive = false;
|
||||
|
||||
// Resize listener attached at start(), detached at stop().
|
||||
private resizeListener: (() => void) | null = null;
|
||||
// Debounce timer for resize handling — coalesces tmux/ssh resize bursts.
|
||||
@@ -705,6 +713,12 @@ export class DashboardTUI {
|
||||
process.stdout.write("\x1b[?1049h\x1b[H");
|
||||
}
|
||||
|
||||
// Capture the app element factory + render fn so openTerminalAttach() can
|
||||
// remount the identical tree after a full-screen passthrough detaches.
|
||||
this.renderApp = () =>
|
||||
createElement(I18nextProvider, { i18n }, createElement(DashboardApp, { controller: this }));
|
||||
this.inkRender = (node: unknown) => render(node as Parameters<typeof render>[0]);
|
||||
|
||||
this.inkInstance = render(
|
||||
createElement(I18nextProvider, { i18n }, createElement(DashboardApp, { controller: this })),
|
||||
);
|
||||
@@ -899,6 +913,82 @@ export class DashboardTUI {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a CLI-agent session as a full-screen passthrough (U14). Suspend-and-
|
||||
* handoff: unmount Ink (releasing its raw-mode / stdin grip), let
|
||||
* `attachTerminalSession` own the alt-screen + raw mode for the passthrough
|
||||
* loop, then remount the same Ink app once the user detaches (Ctrl-]) or the
|
||||
* session ends. Resolves after the TUI has been remounted.
|
||||
*
|
||||
* No-op (resolves immediately) when there's no session info / not running.
|
||||
*/
|
||||
async openTerminalAttach(sessionId: string, projectId?: string): Promise<void> {
|
||||
if (!this.isRunning || this.terminalAttachActive) return;
|
||||
if (!this.renderApp || !this.inkRender) return;
|
||||
const baseUrl = this.systemInfo?.baseUrl;
|
||||
if (!baseUrl) return;
|
||||
const token = this.systemInfo?.authToken;
|
||||
|
||||
const { attachTerminalSession } = await import("./terminal-attach.js");
|
||||
|
||||
this.terminalAttachActive = true;
|
||||
|
||||
// Unmount Ink so it relinquishes raw mode + the stdin 'data' grip; the
|
||||
// passthrough loop installs its own listeners on the bare stdin/stdout.
|
||||
// Also drop our mouse listener so wheel reports don't leak into the PTY.
|
||||
this.uninstallMouseListener();
|
||||
if (this.inkInstance) {
|
||||
try {
|
||||
this.inkInstance.unmount();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
this.inkInstance = null;
|
||||
}
|
||||
// Leave Ink's alt-screen; the passthrough enters its own.
|
||||
if (process.stdout?.isTTY && typeof process.stdout.write === "function") {
|
||||
try {
|
||||
process.stdout.write("\x1b[?1049l");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const handle = attachTerminalSession({
|
||||
baseUrl,
|
||||
token,
|
||||
sessionId,
|
||||
projectId,
|
||||
stdin: process.stdin as unknown as import("./terminal-attach.js").AttachStdin,
|
||||
stdout: process.stdout as unknown as import("./terminal-attach.js").AttachStdout,
|
||||
onDetach: (error) => {
|
||||
if (error) {
|
||||
this.error(`Terminal session detached: ${error.message}`, "cli-agent");
|
||||
}
|
||||
},
|
||||
});
|
||||
void handle.done.finally(() => resolve());
|
||||
});
|
||||
|
||||
// Remount Ink on a clean alt-screen.
|
||||
this.terminalAttachActive = false;
|
||||
if (!this.isRunning) return; // stopped while attached — leave the terminal as-is
|
||||
if (process.stdout?.isTTY && typeof process.stdout.write === "function") {
|
||||
try {
|
||||
process.stdout.write("\x1b[?1049h\x1b[H");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
try {
|
||||
this.inkInstance = this.inkRender(this.renderApp());
|
||||
} catch {
|
||||
/* ignore — remount best-effort */
|
||||
}
|
||||
this.notify();
|
||||
}
|
||||
|
||||
// ── Private helpers ────────────────────────────────────────────────────────
|
||||
|
||||
// Attach a parallel `data` listener that decodes xterm SGR mouse
|
||||
|
||||
497
packages/cli/src/commands/dashboard-tui/terminal-attach.ts
Normal file
497
packages/cli/src/commands/dashboard-tui/terminal-attach.ts
Normal file
@@ -0,0 +1,497 @@
|
||||
/**
|
||||
* terminal-attach — full-screen passthrough attach to a CLI agent session
|
||||
* from the Ink TUI (CLI Agent Executor, U14).
|
||||
*
|
||||
* Model: SUSPEND-AND-HANDOFF, not embedding. The caller pauses Ink rendering,
|
||||
* then `attachTerminalSession` takes over the real TTY:
|
||||
* - enter the alternate screen (`\x1b[?1049h`) and put stdin in raw mode,
|
||||
* - WS `scrollback`/`data` frames → neutralize (U10 filter) → write to stdout,
|
||||
* - stdin bytes → WS `input` frames (base64),
|
||||
* - SIGWINCH / stdout resize → WS `resize` frames,
|
||||
* - ACK `bytes` consumed after every ~32KB written, for flow control,
|
||||
* - detach chord Ctrl-] (0x1d) → leave alt-screen, restore raw mode, close WS,
|
||||
* - WS close/error mid-attach → restore the terminal cleanly + surface via
|
||||
* `onDetach(error)`.
|
||||
*
|
||||
* SECURITY (the riskiest leg): the byte stream is UNTRUSTED. The host terminal
|
||||
* honors more escape sequences than xterm.js, so every byte written to the host
|
||||
* TTY is passed through `neutralizeTerminalOutput` FIRST — the identical filter
|
||||
* the dashboard WS bridge uses (re-exported from `@fusion/dashboard`). OSC 52
|
||||
* clipboard writes, OSC 8 non-http(s) links, and device-status queries (whose
|
||||
* auto-responses forge input) are stripped before they ever reach the terminal.
|
||||
*
|
||||
* CJK / double-width: bytes pass through verbatim — no width math is needed in a
|
||||
* passthrough (the host terminal does the width handling).
|
||||
*
|
||||
* The WS transport is injectable (`wsFactory`) so tests drive the loop with an
|
||||
* in-memory WS-like object and NEVER open a real socket (and never touch port
|
||||
* 4040). The default factory uses the `ws` Node client.
|
||||
*/
|
||||
|
||||
import { WebSocket } from "ws";
|
||||
import { neutralizeTerminalOutput, flushTerminalOutput } from "@fusion/dashboard";
|
||||
|
||||
/** Detach chord: Ctrl-] (GS, 0x1d). Documented + shown in the status hint. */
|
||||
export const DETACH_CHORD_BYTE = 0x1d;
|
||||
/** Human-readable label for the detach chord (status hint). */
|
||||
export const DETACH_CHORD_LABEL = "Ctrl-]";
|
||||
|
||||
/** Enter / leave the alternate screen buffer. */
|
||||
export const ALT_SCREEN_ENTER = "\x1b[?1049h";
|
||||
export const ALT_SCREEN_LEAVE = "\x1b[?1049l";
|
||||
|
||||
/** Emit an ACK after roughly this many bytes are written to stdout. */
|
||||
export const DEFAULT_ACK_THRESHOLD_BYTES = 32 * 1024;
|
||||
|
||||
// ── Frame shapes (mirror packages/dashboard/src/cli-session-ws.ts) ──────────
|
||||
|
||||
/** Server → client frames. */
|
||||
type ServerFrame =
|
||||
| { type: "scrollback"; data?: string }
|
||||
| { type: "data"; data?: string }
|
||||
| { type: "state"; [k: string]: unknown }
|
||||
| { type: "error"; message?: string; code?: string }
|
||||
| { type: "exit" };
|
||||
|
||||
/** Client → server frames. */
|
||||
type ClientFrame =
|
||||
| { type: "input"; data: string }
|
||||
| { type: "resize"; cols: number; rows: number }
|
||||
| { type: "ack"; bytes: number };
|
||||
|
||||
/**
|
||||
* The minimal WebSocket surface the passthrough loop uses. The real `ws` client
|
||||
* satisfies this; tests provide an in-memory implementation.
|
||||
*/
|
||||
export interface TerminalWebSocket {
|
||||
/** Register an event handler. */
|
||||
on(event: "open", listener: () => void): void;
|
||||
on(event: "message", listener: (data: unknown) => void): void;
|
||||
on(event: "close", listener: (code?: number, reason?: unknown) => void): void;
|
||||
on(event: "error", listener: (err: Error) => void): void;
|
||||
/** Send a (string) frame. */
|
||||
send(data: string): void;
|
||||
/** Close the socket. */
|
||||
close(code?: number, reason?: string): void;
|
||||
/** Current ready state; OPEN === 1 (matches the ws/WHATWG constant). */
|
||||
readyState: number;
|
||||
}
|
||||
|
||||
/** Ready-state constant matching the `ws` client / WHATWG WebSocket. */
|
||||
export const WS_OPEN = 1;
|
||||
|
||||
/** Factory that opens a WS connection to `url` with the given headers. */
|
||||
export type TerminalWebSocketFactory = (
|
||||
url: string,
|
||||
headers: Record<string, string>,
|
||||
) => TerminalWebSocket;
|
||||
|
||||
/** Minimal readable stdin surface (a TTY ReadStream satisfies this). */
|
||||
export interface AttachStdin {
|
||||
on(event: "data", listener: (chunk: Buffer | string) => void): void;
|
||||
off(event: "data", listener: (chunk: Buffer | string) => void): void;
|
||||
setRawMode?: (mode: boolean) => void;
|
||||
isRaw?: boolean;
|
||||
isTTY?: boolean;
|
||||
resume?: () => void;
|
||||
pause?: () => void;
|
||||
}
|
||||
|
||||
/** Minimal writable stdout surface (a TTY WriteStream satisfies this). */
|
||||
export interface AttachStdout {
|
||||
write(chunk: string): void;
|
||||
columns?: number;
|
||||
rows?: number;
|
||||
on(event: "resize", listener: () => void): void;
|
||||
off(event: "resize", listener: () => void): void;
|
||||
}
|
||||
|
||||
export interface AttachTerminalSessionOptions {
|
||||
/** Dashboard base URL, e.g. `http://127.0.0.1:4040`. */
|
||||
baseUrl: string;
|
||||
/** Daemon token (Authorization: Bearer …). Optional when auth is disabled. */
|
||||
token?: string;
|
||||
/** Session id to attach to. */
|
||||
sessionId: string;
|
||||
/** Project id (scopes the attach-ticket mint), if known. */
|
||||
projectId?: string;
|
||||
stdin: AttachStdin;
|
||||
stdout: AttachStdout;
|
||||
/**
|
||||
* Called exactly once when the attach ends — cleanly (no arg) or with an error
|
||||
* (WS drop / failed ticket). The caller resumes Ink rendering here.
|
||||
*/
|
||||
onDetach: (error?: Error) => void;
|
||||
/** Injectable WS factory (default: the `ws` Node client). */
|
||||
wsFactory?: TerminalWebSocketFactory;
|
||||
/** Injectable fetch (default: global fetch) for the attach-ticket POST. */
|
||||
fetchImpl?: typeof fetch;
|
||||
/** ACK threshold override (bytes). */
|
||||
ackThresholdBytes?: number;
|
||||
/** Print a one-line detach hint before entering the alt-screen. */
|
||||
printHint?: boolean;
|
||||
}
|
||||
|
||||
/** Handle returned by `attachTerminalSession`; lets the caller force-detach. */
|
||||
export interface AttachHandle {
|
||||
/** Resolves when the attach fully ends (after terminal restore + onDetach). */
|
||||
done: Promise<void>;
|
||||
/** Force a clean detach (e.g. the TUI is quitting). Idempotent. */
|
||||
detach(): void;
|
||||
}
|
||||
|
||||
interface AttachTicketResponse {
|
||||
ticket: string;
|
||||
expiresAt?: string;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single-use attach ticket for the session. Throws on non-2xx so the
|
||||
* caller surfaces a clean error and never opens the WS.
|
||||
*/
|
||||
export async function fetchAttachTicket(opts: {
|
||||
baseUrl: string;
|
||||
token?: string;
|
||||
sessionId: string;
|
||||
projectId?: string;
|
||||
fetchImpl?: typeof fetch;
|
||||
}): Promise<AttachTicketResponse> {
|
||||
const fetchFn = opts.fetchImpl ?? fetch;
|
||||
const url = `${opts.baseUrl.replace(/\/$/, "")}/api/cli-sessions/${encodeURIComponent(
|
||||
opts.sessionId,
|
||||
)}/attach-ticket`;
|
||||
const headers: Record<string, string> = { "content-type": "application/json" };
|
||||
if (opts.token) headers.authorization = `Bearer ${opts.token}`;
|
||||
const res = await fetchFn(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(opts.projectId ? { projectId: opts.projectId } : {}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`Failed to mint attach ticket (HTTP ${res.status} ${res.statusText})`,
|
||||
);
|
||||
}
|
||||
const body = (await res.json()) as AttachTicketResponse;
|
||||
if (!body || typeof body.ticket !== "string" || body.ticket.length === 0) {
|
||||
throw new Error("Attach-ticket response missing `ticket`");
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
/** Build the cli-session WS URL with sessionId + ticket query params. */
|
||||
export function buildWsUrl(opts: {
|
||||
baseUrl: string;
|
||||
sessionId: string;
|
||||
ticket: string;
|
||||
}): string {
|
||||
const u = new URL(`${opts.baseUrl.replace(/\/$/, "")}/api/cli-sessions/ws`);
|
||||
// ws(s):// scheme — derive from http(s).
|
||||
u.protocol = u.protocol === "https:" ? "wss:" : "ws:";
|
||||
u.searchParams.set("sessionId", opts.sessionId);
|
||||
u.searchParams.set("ticket", opts.ticket);
|
||||
return u.toString();
|
||||
}
|
||||
|
||||
function defaultWsFactory(): TerminalWebSocketFactory {
|
||||
return (url, headers) => {
|
||||
const ws = new WebSocket(url, { headers });
|
||||
return ws as unknown as TerminalWebSocket;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a server `data`/`scrollback` frame's base64 payload to a UTF-8 string.
|
||||
*/
|
||||
function decodeFrameData(data: string | undefined): string {
|
||||
if (typeof data !== "string" || data.length === 0) return "";
|
||||
return Buffer.from(data, "base64").toString("utf8");
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the full-screen passthrough attach. Returns once the attach has fully
|
||||
* ended and the terminal has been restored (the same point `onDetach` fires).
|
||||
*
|
||||
* Lifecycle is single-shot: every termination path (detach chord, WS close, WS
|
||||
* error, ticket failure, force `detach()`) funnels through one idempotent
|
||||
* teardown that restores raw mode, leaves the alt-screen, closes the WS, and
|
||||
* fires `onDetach` exactly once.
|
||||
*/
|
||||
export function attachTerminalSession(
|
||||
opts: AttachTerminalSessionOptions,
|
||||
): AttachHandle {
|
||||
const {
|
||||
stdin,
|
||||
stdout,
|
||||
onDetach,
|
||||
ackThresholdBytes = DEFAULT_ACK_THRESHOLD_BYTES,
|
||||
} = opts;
|
||||
const wsFactory = opts.wsFactory ?? defaultWsFactory();
|
||||
|
||||
let settled = false;
|
||||
let resolveDone: () => void;
|
||||
const done = new Promise<void>((resolve) => {
|
||||
resolveDone = resolve;
|
||||
});
|
||||
|
||||
// Terminal state we must restore on teardown.
|
||||
const priorRaw = stdin.isRaw ?? false;
|
||||
let enteredAltScreen = false;
|
||||
let rawModeSet = false;
|
||||
|
||||
// Live wiring (set once the WS opens).
|
||||
let ws: TerminalWebSocket | null = null;
|
||||
let onStdinData: ((chunk: Buffer | string) => void) | null = null;
|
||||
let onResize: (() => void) | null = null;
|
||||
|
||||
// Outbound neutralization carry (threaded across data frames so a sequence
|
||||
// split across two frames is still caught).
|
||||
let carry = "";
|
||||
// Flow control: bytes written since the last ACK.
|
||||
let bytesSinceAck = 0;
|
||||
|
||||
const sendFrame = (frame: ClientFrame): void => {
|
||||
if (!ws || ws.readyState !== WS_OPEN) return;
|
||||
try {
|
||||
ws.send(JSON.stringify(frame));
|
||||
} catch {
|
||||
/* socket closing */
|
||||
}
|
||||
};
|
||||
|
||||
const ackConsumed = (n: number): void => {
|
||||
bytesSinceAck += n;
|
||||
if (bytesSinceAck >= ackThresholdBytes) {
|
||||
sendFrame({ type: "ack", bytes: bytesSinceAck });
|
||||
bytesSinceAck = 0;
|
||||
}
|
||||
};
|
||||
|
||||
// Write a (possibly partial) untrusted chunk to the host TTY through the U10
|
||||
// neutralizer. `isSnapshot` flushes the carry (scrollback is a complete unit).
|
||||
const writeNeutralized = (text: string, isSnapshot: boolean): void => {
|
||||
const result = neutralizeTerminalOutput(text, carry);
|
||||
let out = result.output;
|
||||
if (isSnapshot) {
|
||||
out += flushTerminalOutput(result.carry);
|
||||
carry = "";
|
||||
} else {
|
||||
carry = result.carry;
|
||||
}
|
||||
if (out.length === 0) return;
|
||||
try {
|
||||
stdout.write(out);
|
||||
} catch {
|
||||
/* stdout closing */
|
||||
}
|
||||
ackConsumed(Buffer.byteLength(out, "utf8"));
|
||||
};
|
||||
|
||||
const teardown = (error?: Error): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
|
||||
// Detach stdin/resize listeners first so no late bytes race the restore.
|
||||
if (onStdinData) {
|
||||
try {
|
||||
stdin.off("data", onStdinData);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
onStdinData = null;
|
||||
}
|
||||
if (onResize) {
|
||||
try {
|
||||
stdout.off("resize", onResize);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
onResize = null;
|
||||
}
|
||||
|
||||
// Restore raw mode to its prior state (only if we changed it).
|
||||
if (rawModeSet && stdin.setRawMode) {
|
||||
try {
|
||||
stdin.setRawMode(priorRaw);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
// Leave the alt-screen so the caller's shell / Ink scrollback is restored.
|
||||
if (enteredAltScreen) {
|
||||
try {
|
||||
stdout.write(ALT_SCREEN_LEAVE);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
// Close the WS (never throws upward).
|
||||
if (ws) {
|
||||
try {
|
||||
ws.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
onDetach(error);
|
||||
} finally {
|
||||
resolveDone();
|
||||
}
|
||||
};
|
||||
|
||||
const handleServerFrame = (frame: ServerFrame): void => {
|
||||
switch (frame.type) {
|
||||
case "scrollback":
|
||||
writeNeutralized(decodeFrameData(frame.data), true);
|
||||
break;
|
||||
case "data":
|
||||
writeNeutralized(decodeFrameData(frame.data), false);
|
||||
break;
|
||||
case "exit":
|
||||
teardown();
|
||||
break;
|
||||
case "error":
|
||||
// A server error frame (e.g. read-only) is informational; surface it on
|
||||
// stdout but don't tear down — the stream may continue.
|
||||
if (frame.message) {
|
||||
try {
|
||||
stdout.write(`\r\n[session] ${frame.message}\r\n`);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "state":
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// Enter the alt-screen + raw mode, then wire the loop. We do this BEFORE the
|
||||
// WS opens so the first scrollback frame lands on a clean alt-screen.
|
||||
const enterPassthrough = (): void => {
|
||||
if (opts.printHint !== false) {
|
||||
try {
|
||||
stdout.write(
|
||||
`Attached to session ${opts.sessionId}. Press ${DETACH_CHORD_LABEL} to detach.\r\n`,
|
||||
);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
try {
|
||||
stdout.write(ALT_SCREEN_ENTER);
|
||||
enteredAltScreen = true;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
if (stdin.setRawMode) {
|
||||
try {
|
||||
stdin.setRawMode(true);
|
||||
rawModeSet = true;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
stdin.resume?.();
|
||||
|
||||
// stdin → input frames; detach chord intercepted.
|
||||
onStdinData = (chunk: Buffer | string): void => {
|
||||
const buf = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk;
|
||||
// Detach chord: if Ctrl-] appears, send any bytes before it, then detach.
|
||||
const idx = buf.indexOf(DETACH_CHORD_BYTE);
|
||||
if (idx !== -1) {
|
||||
if (idx > 0) {
|
||||
sendFrame({ type: "input", data: buf.subarray(0, idx).toString("base64") });
|
||||
}
|
||||
teardown();
|
||||
return;
|
||||
}
|
||||
sendFrame({ type: "input", data: buf.toString("base64") });
|
||||
};
|
||||
stdin.on("data", onStdinData);
|
||||
|
||||
// stdout resize → resize frames.
|
||||
onResize = (): void => {
|
||||
const cols = stdout.columns;
|
||||
const rows = stdout.rows;
|
||||
if (typeof cols === "number" && typeof rows === "number") {
|
||||
sendFrame({ type: "resize", cols, rows });
|
||||
}
|
||||
};
|
||||
stdout.on("resize", onResize);
|
||||
|
||||
// Send the initial size so the PTY matches the host TTY immediately.
|
||||
onResize();
|
||||
};
|
||||
|
||||
// ── Kick off: mint ticket, open WS, run the loop ──
|
||||
(async () => {
|
||||
let ticket: AttachTicketResponse;
|
||||
try {
|
||||
ticket = await fetchAttachTicket({
|
||||
baseUrl: opts.baseUrl,
|
||||
token: opts.token,
|
||||
sessionId: opts.sessionId,
|
||||
projectId: opts.projectId,
|
||||
fetchImpl: opts.fetchImpl,
|
||||
});
|
||||
} catch (err) {
|
||||
teardown(err instanceof Error ? err : new Error(String(err)));
|
||||
return;
|
||||
}
|
||||
|
||||
const url = buildWsUrl({
|
||||
baseUrl: opts.baseUrl,
|
||||
sessionId: opts.sessionId,
|
||||
ticket: ticket.ticket,
|
||||
});
|
||||
const headers: Record<string, string> = {};
|
||||
if (opts.token) headers.authorization = `Bearer ${opts.token}`;
|
||||
|
||||
try {
|
||||
ws = wsFactory(url, headers);
|
||||
} catch (err) {
|
||||
teardown(err instanceof Error ? err : new Error(String(err)));
|
||||
return;
|
||||
}
|
||||
|
||||
ws.on("open", () => {
|
||||
enterPassthrough();
|
||||
});
|
||||
ws.on("message", (data: unknown) => {
|
||||
let text: string;
|
||||
if (typeof data === "string") text = data;
|
||||
else if (Buffer.isBuffer(data)) text = data.toString("utf8");
|
||||
else if (data instanceof Uint8Array) text = Buffer.from(data).toString("utf8");
|
||||
else text = String(data);
|
||||
let frame: ServerFrame;
|
||||
try {
|
||||
frame = JSON.parse(text) as ServerFrame;
|
||||
} catch {
|
||||
return; // ignore malformed
|
||||
}
|
||||
handleServerFrame(frame);
|
||||
});
|
||||
ws.on("close", () => {
|
||||
// A close before any deliberate detach is treated as a clean end if the
|
||||
// server sent `exit` (already torn down), otherwise as a mid-attach drop.
|
||||
if (!settled) {
|
||||
teardown(new Error("Connection closed"));
|
||||
}
|
||||
});
|
||||
ws.on("error", (err: Error) => {
|
||||
teardown(err instanceof Error ? err : new Error(String(err)));
|
||||
});
|
||||
})();
|
||||
|
||||
return {
|
||||
done,
|
||||
detach: () => teardown(),
|
||||
};
|
||||
}
|
||||
@@ -25,6 +25,9 @@ import {
|
||||
} from "@fusion/core";
|
||||
import {
|
||||
createServer,
|
||||
AttachTicketStore,
|
||||
CliInputAttributionLog,
|
||||
CliConfirmAdvanceRegistry,
|
||||
GitHubClient,
|
||||
createSkillsAdapter,
|
||||
getCliPackageVersion,
|
||||
@@ -86,7 +89,7 @@ import { getCachedUpdateStatus, isUpdateCheckEnabled } from "../update-cache.js"
|
||||
import { resolveSelfExtension } from "./self-extension.js";
|
||||
import { ensureBundledDependencyGraphPluginInstalled, ensureBundledPluginInstalled, isBundledPluginId } from "../plugins/bundled-plugin-install.js";
|
||||
import { registerCustomProviders, reregisterCustomProviders } from "./custom-provider-registry.js";
|
||||
import { refreshOpencodeGoModels, syncStartupModels } from "./startup-model-sync.js";
|
||||
import { handleOpencodeGoApiKeySaved, syncStartupModels } from "./startup-model-sync.js";
|
||||
import { DashboardTUI, DashboardLogSink, isTTYAvailable, type SystemInfo, type GitStatus, type GitCommit, type GitCommitDetail, type GitBranch, type GitWorktree, type FileEntry, type FileReadResult, type TaskStep as TUITaskStep, type TaskLogEntry as TUITaskLogEntry, type TaskDetailData, type TaskEvent } from "./dashboard-tui/index.js";
|
||||
import { DASHBOARD_STARTUP_STATUS, runTuiStartupPrelude } from "./dashboard-startup-chain.js";
|
||||
|
||||
@@ -1745,9 +1748,33 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
// to createServer — routes derived from getPluginRoutes() rely on it.
|
||||
await phaseTime("pluginLoadingPromise (await)", () => pluginLoadingPromise);
|
||||
|
||||
// ── CLI Agent Executor: hub resolver + session transport ─────────────
|
||||
//
|
||||
// The hook route validates a per-session token against the project's live
|
||||
// TelemetryHub; resolve it from that project's engine. The cli-sessions
|
||||
// transport (REST + WS attach) is supplied from the cwd project's runtime
|
||||
// (the canonical single-project surface) when the experimental flag is on.
|
||||
//
|
||||
const cliAgentHubResolver = (projectId: string | undefined, _sessionId: string) => {
|
||||
const engine = projectId ? engineManager.getEngine(projectId) : cwdEngine;
|
||||
return engine?.getCliAgentRuntime()?.bundle.hub;
|
||||
};
|
||||
const cwdCliAgentRuntime = cwdEngine?.getCliAgentRuntime();
|
||||
const cliSessionTransport = cwdCliAgentRuntime
|
||||
? {
|
||||
manager: cwdCliAgentRuntime.bundle.manager,
|
||||
store: cwdCliAgentRuntime.bundle.store,
|
||||
ticketStore: new AttachTicketStore(),
|
||||
attributionLog: new CliInputAttributionLog(),
|
||||
confirmAdvance: new CliConfirmAdvanceRegistry(),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
app = createServer(store, {
|
||||
engine: cwdEngine,
|
||||
engineManager,
|
||||
cliAgentHubResolver,
|
||||
cliSessionTransport,
|
||||
hybridExecutor,
|
||||
centralCore: centralCoreForEngine,
|
||||
authStorage: dashboardAuthStorage,
|
||||
@@ -1765,14 +1792,12 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
if (providerId !== "opencode" && providerId !== "opencode-go") {
|
||||
return undefined;
|
||||
}
|
||||
const settings = await store.getSettings();
|
||||
if (settings.opencodeGoModelSync === false) {
|
||||
return { registeredCount: 0, reason: "disabled-by-settings" };
|
||||
}
|
||||
return await refreshOpencodeGoModels({
|
||||
return await handleOpencodeGoApiKeySaved(
|
||||
dashboardAuthStorage,
|
||||
store,
|
||||
modelRegistry,
|
||||
log: (scope, message) => logSink.log(message, scope),
|
||||
});
|
||||
(scope, message) => logSink.log(message, scope),
|
||||
);
|
||||
},
|
||||
getClaudeCliExtensionStatus: () => {
|
||||
const r = getCachedClaudeCliResolution();
|
||||
@@ -1978,6 +2003,11 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
});
|
||||
},
|
||||
store,
|
||||
// Dev-mode scheduler: no TaskExecutor runs here (engine not started), so
|
||||
// neither `isTaskExecuting` nor the U5 reverse-direction
|
||||
// `isAgentEffectivelyExecuting` guard has a source — both stay unwired (the
|
||||
// guards simply never fire), matching the prior `isTaskExecuting` omission.
|
||||
// The real wiring is the InProcessRuntime construction site.
|
||||
);
|
||||
triggerScheduler.start();
|
||||
|
||||
@@ -2086,14 +2116,12 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
if (providerId !== "opencode" && providerId !== "opencode-go") {
|
||||
return undefined;
|
||||
}
|
||||
const settings = await store.getSettings();
|
||||
if (settings.opencodeGoModelSync === false) {
|
||||
return { registeredCount: 0, reason: "disabled-by-settings" };
|
||||
}
|
||||
return await refreshOpencodeGoModels({
|
||||
return await handleOpencodeGoApiKeySaved(
|
||||
dashboardAuthStorage,
|
||||
store,
|
||||
modelRegistry,
|
||||
log: (scope, message) => logSink.log(message, scope),
|
||||
});
|
||||
(scope, message) => logSink.log(message, scope),
|
||||
);
|
||||
},
|
||||
getClaudeCliExtensionStatus: () => {
|
||||
const r = getCachedClaudeCliResolution();
|
||||
|
||||
@@ -73,7 +73,7 @@ import {
|
||||
} from "./llama-cpp-extension.js";
|
||||
import { resolveSelfExtension } from "./self-extension.js";
|
||||
import { registerCustomProviders, reregisterCustomProviders } from "./custom-provider-registry.js";
|
||||
import { refreshOpencodeGoModels, syncStartupModels } from "./startup-model-sync.js";
|
||||
import { handleOpencodeGoApiKeySaved, syncStartupModels } from "./startup-model-sync.js";
|
||||
import { ensureBundledDependencyGraphPluginInstalled, ensureBundledPluginInstalled, isBundledPluginId } from "../plugins/bundled-plugin-install.js";
|
||||
import { ensureCwdProjectRegistered } from "./ensure-project-registered.js";
|
||||
|
||||
@@ -831,14 +831,12 @@ export async function runServe(
|
||||
if (providerId !== "opencode" && providerId !== "opencode-go") {
|
||||
return undefined;
|
||||
}
|
||||
const settings = await store.getSettings();
|
||||
if (settings.opencodeGoModelSync === false) {
|
||||
return { registeredCount: 0, reason: "disabled-by-settings" };
|
||||
}
|
||||
return await refreshOpencodeGoModels({
|
||||
return await handleOpencodeGoApiKeySaved(
|
||||
dashboardAuthStorage,
|
||||
store,
|
||||
modelRegistry,
|
||||
log: (scope, message) => console.log(`[${scope}] ${message}`),
|
||||
});
|
||||
(scope, message) => console.log(`[${scope}] ${message}`),
|
||||
);
|
||||
},
|
||||
getClaudeCliExtensionStatus: () => {
|
||||
const r = getCachedClaudeCliResolution();
|
||||
|
||||
@@ -110,6 +110,9 @@ export async function runSettingsImport(
|
||||
if (result.projectCount > 0) {
|
||||
console.log(` Imported ${result.projectCount} project setting(s)`);
|
||||
}
|
||||
if (result.workflowSettingsCount > 0) {
|
||||
console.log(` Upgraded ${result.workflowSettingsCount} workflow setting value(s)`);
|
||||
}
|
||||
console.log();
|
||||
|
||||
process.exit(0);
|
||||
|
||||
@@ -9,7 +9,12 @@ import {
|
||||
import { probeWorktrunk, resolveWorktrunkBinary } from "@fusion/engine";
|
||||
import { resolveProject } from "../project-context.js";
|
||||
|
||||
// Settings that can be updated via CLI
|
||||
// Settings that can be updated via CLI.
|
||||
//
|
||||
// NOTE: the step/review/model-lane policy keys (`runStepsInNewSessions`,
|
||||
// `maxParallelSteps`, `requirePlanApproval`, etc.) were MOVED to workflow settings
|
||||
// (U4/KTD-5) and are intentionally ABSENT here — they live as per-workflow values,
|
||||
// not project/global settings. See WORKFLOW_SETTINGS_REDIRECT_HINT below.
|
||||
export const VALID_SETTINGS = [
|
||||
"maxConcurrent",
|
||||
"maxWorktrees",
|
||||
@@ -19,11 +24,8 @@ export const VALID_SETTINGS = [
|
||||
"ntfyTopic",
|
||||
"autoResolveConflicts",
|
||||
"smartConflictResolution",
|
||||
"requirePlanApproval",
|
||||
"ntfyEnabled",
|
||||
"defaultModel",
|
||||
"runStepsInNewSessions",
|
||||
"maxParallelSteps",
|
||||
"defaultNodeId",
|
||||
"unavailableNodePolicy",
|
||||
"worktrunk.enabled",
|
||||
@@ -32,6 +34,11 @@ export const VALID_SETTINGS = [
|
||||
"language",
|
||||
] as const;
|
||||
|
||||
// One-line redirect surfaced wherever the CLI lists/validates settings keys, so
|
||||
// users who reach for a moved key learn where it lives now (U5/KTD-8).
|
||||
export const WORKFLOW_SETTINGS_REDIRECT_HINT =
|
||||
"Note: step, review, and model-lane policy now live in workflow settings — edit them in the workflow editor or via fn_workflow_settings.";
|
||||
|
||||
const GLOBAL_ONLY_SETTINGS = ["ntfyEnabled", "ntfyTopic", "defaultModel", "language"] as const;
|
||||
const PROJECT_ONLY_SETTINGS = [
|
||||
"maxConcurrent",
|
||||
@@ -41,9 +48,6 @@ const PROJECT_ONLY_SETTINGS = [
|
||||
"taskPrefix",
|
||||
"autoResolveConflicts",
|
||||
"smartConflictResolution",
|
||||
"requirePlanApproval",
|
||||
"runStepsInNewSessions",
|
||||
"maxParallelSteps",
|
||||
"defaultNodeId",
|
||||
"unavailableNodePolicy",
|
||||
] as const;
|
||||
@@ -54,13 +58,11 @@ type ValidSettingKey = (typeof VALID_SETTINGS)[number];
|
||||
const BOOLEAN_SETTINGS: readonly string[] = [
|
||||
"autoResolveConflicts",
|
||||
"smartConflictResolution",
|
||||
"requirePlanApproval",
|
||||
"ntfyEnabled",
|
||||
"runStepsInNewSessions",
|
||||
"worktrunk.enabled",
|
||||
];
|
||||
|
||||
const NUMBER_SETTINGS: readonly string[] = ["maxConcurrent", "maxWorktrees", "maxParallelSteps"];
|
||||
const NUMBER_SETTINGS: readonly string[] = ["maxConcurrent", "maxWorktrees"];
|
||||
|
||||
const ENUM_SETTINGS: Record<string, readonly string[]> = {
|
||||
worktreeNaming: ["random", "task-id", "task-title"],
|
||||
@@ -83,7 +85,6 @@ const STRING_SETTINGS: readonly string[] = [
|
||||
const NUMBER_RANGES: Record<string, { min: number; max: number }> = {
|
||||
maxConcurrent: { min: 1, max: 10 },
|
||||
maxWorktrees: { min: 1, max: 20 },
|
||||
maxParallelSteps: { min: 1, max: 4 },
|
||||
};
|
||||
|
||||
async function getGlobalSettingsStore(): Promise<GlobalSettingsStore> {
|
||||
@@ -256,10 +257,6 @@ export async function runSettingsShow(projectName?: string): Promise<void> {
|
||||
title: "Engine",
|
||||
keys: ["maxConcurrent", "maxWorktrees", "autoResolveConflicts", "smartConflictResolution"],
|
||||
},
|
||||
{
|
||||
title: "Execution",
|
||||
keys: ["runStepsInNewSessions", "maxParallelSteps"],
|
||||
},
|
||||
{
|
||||
title: "Worktrees",
|
||||
keys: ["worktreeNaming", "worktreesDir", "recycleWorktrees"],
|
||||
@@ -270,7 +267,7 @@ export async function runSettingsShow(projectName?: string): Promise<void> {
|
||||
},
|
||||
{
|
||||
title: "Tasks",
|
||||
keys: ["taskPrefix", "requirePlanApproval", "includeTaskIdInCommit"],
|
||||
keys: ["taskPrefix", "includeTaskIdInCommit"],
|
||||
},
|
||||
{
|
||||
title: "Node Routing",
|
||||
@@ -302,6 +299,8 @@ export async function runSettingsShow(projectName?: string): Promise<void> {
|
||||
}
|
||||
|
||||
console.log();
|
||||
console.log(` ${WORKFLOW_SETTINGS_REDIRECT_HINT}`);
|
||||
console.log();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -315,6 +314,7 @@ export async function runSettingsSet(key: string, value: string, projectName?: s
|
||||
if (!VALID_SETTINGS.includes(key as ValidSettingKey)) {
|
||||
console.error(`Error: Unknown setting "${key}"`);
|
||||
console.error(`Valid settings: ${VALID_SETTINGS.join(", ")}`);
|
||||
console.error(WORKFLOW_SETTINGS_REDIRECT_HINT);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -205,15 +205,22 @@ async function syncOpenRouterModels(options: StartupSyncOptions, settings: Setti
|
||||
|
||||
export function normalizeOpencodeGoModel(modelId: string): ModelConfig {
|
||||
const trimmed = modelId.trim();
|
||||
const normalizedId = trimmed.startsWith("opencode/")
|
||||
? `opencode-go/${trimmed.slice("opencode/".length)}`
|
||||
: trimmed.startsWith("opencode-go/")
|
||||
? trimmed
|
||||
: `opencode-go/${trimmed}`;
|
||||
// Strip the provider prefix (opencode/ or opencode-go/) — the Pi SDK
|
||||
// already routes requests by provider, and the OpenCode API expects the
|
||||
// bare model name (e.g. "deepseek-v4-flash", not "opencode-go/deepseek-v4-flash").
|
||||
const bareModel = trimmed.startsWith("opencode-go/")
|
||||
? trimmed.slice("opencode-go/".length)
|
||||
: trimmed.startsWith("opencode/")
|
||||
? trimmed.slice("opencode/".length)
|
||||
: trimmed;
|
||||
|
||||
if (!bareModel) {
|
||||
throw new Error(`Invalid opencode-go model ID: "${modelId}" has no model name after provider prefix`);
|
||||
}
|
||||
|
||||
return {
|
||||
id: normalizedId,
|
||||
name: normalizedId,
|
||||
id: bareModel,
|
||||
name: bareModel,
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
@@ -233,10 +240,15 @@ export function parseOpencodeModelsOutput(stdout: string): string[] {
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
export async function discoverOpencodeGoModels(): Promise<string[]> {
|
||||
export async function discoverOpencodeGoModels(apiKey?: string): Promise<string[]> {
|
||||
return await new Promise<string[]>((resolve, reject) => {
|
||||
const env: Record<string, string> = { ...process.env as Record<string, string> };
|
||||
if (apiKey) {
|
||||
env.OPENCODE_API_KEY = apiKey;
|
||||
}
|
||||
const proc = spawn("opencode", ["models", "opencode", "--refresh"], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env,
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
@@ -272,16 +284,25 @@ export async function discoverOpencodeGoModels(): Promise<string[]> {
|
||||
export async function refreshOpencodeGoModels(options: {
|
||||
modelRegistry: ModelRegistryLike;
|
||||
log: (scope: string, message: string) => void;
|
||||
apiKey?: string;
|
||||
}): Promise<OpencodeGoRefreshResult> {
|
||||
try {
|
||||
const { modelRegistry, log } = options;
|
||||
const modelIds = await discoverOpencodeGoModels();
|
||||
const { modelRegistry, log, apiKey } = options;
|
||||
const modelIds = await discoverOpencodeGoModels(apiKey);
|
||||
if (modelIds.length === 0) {
|
||||
log("opencode-go", "No models discovered from opencode CLI refresh");
|
||||
return { registeredCount: 0, reason: "no-models-from-cli" };
|
||||
}
|
||||
|
||||
const models = modelIds.map(normalizeOpencodeGoModel);
|
||||
const normalized = modelIds.map(normalizeOpencodeGoModel);
|
||||
// Deduplicate: CLI can emit both "opencode/foo" and "opencode-go/foo"
|
||||
// which normalize to the same bare ID.
|
||||
const seen = new Set<string>();
|
||||
const models = normalized.filter((m) => {
|
||||
if (seen.has(m.id)) return false;
|
||||
seen.add(m.id);
|
||||
return true;
|
||||
});
|
||||
modelRegistry.registerProvider("opencode-go", {
|
||||
baseUrl: "https://api.opencode.ai/v1",
|
||||
apiKey: "OPENCODE_API_KEY",
|
||||
@@ -310,6 +331,27 @@ export async function syncStartupModels(options: StartupSyncOptions): Promise<vo
|
||||
}
|
||||
|
||||
if (settings.opencodeGoModelSync !== false) {
|
||||
await refreshOpencodeGoModels({ modelRegistry: options.modelRegistry, log: options.log });
|
||||
const opencodeGoApiKey = await options.authStorage.getApiKey("opencode-go") ?? await options.authStorage.getApiKey("opencode");
|
||||
await refreshOpencodeGoModels({ modelRegistry: options.modelRegistry, log: options.log, apiKey: opencodeGoApiKey });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared handler for the onApiKeySaved callback used by serve, daemon, and
|
||||
* dashboard. Resolves the opencode-go API key from auth storage (falling back
|
||||
* to the "opencode" provider ID) and triggers a model refresh, respecting the
|
||||
* opencodeGoModelSync setting.
|
||||
*/
|
||||
export async function handleOpencodeGoApiKeySaved(
|
||||
dashboardAuthStorage: AuthStorageLike,
|
||||
store: { getSettings: () => Promise<SettingsLike> },
|
||||
modelRegistry: ModelRegistryLike,
|
||||
log: (scope: string, message: string) => void,
|
||||
): Promise<OpencodeGoRefreshResult | undefined> {
|
||||
const settings = await store.getSettings();
|
||||
if (settings.opencodeGoModelSync === false) {
|
||||
return { registeredCount: 0, reason: "disabled-by-settings" };
|
||||
}
|
||||
const opencodeGoKey = await dashboardAuthStorage.getApiKey("opencode-go") ?? await dashboardAuthStorage.getApiKey("opencode");
|
||||
return await refreshOpencodeGoModels({ modelRegistry, log, apiKey: opencodeGoKey });
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ const execAsync = promisify(exec);
|
||||
const execFileAsync: (file: string, args: string[], opts?: import("node:child_process").ExecFileOptions) => Promise<{ stdout: string; stderr: string }> = (file, args, opts) =>
|
||||
(promisify(childProcess.execFile) as (f: string, a: string[], o?: object) => Promise<{ stdout: string; stderr: string }>)(file, args, opts);
|
||||
import type { TaskStore } from "@fusion/core";
|
||||
import { resolveTaskMergeTarget, getCurrentRepo, isBranchGroupMemberLanded } from "@fusion/core";
|
||||
import { resolveTaskMergeTarget, getCurrentRepo, isBranchGroupMemberLanded, resolveEffectiveSettings } from "@fusion/core";
|
||||
import type { Settings, TaskDetail, PrInfo, MergeResult, BranchGroup, BranchGroupPrState, Task } from "@fusion/core";
|
||||
import { activeSessionRegistry, resolveIntegrationBranch } from "@fusion/engine";
|
||||
import type {
|
||||
@@ -669,6 +669,17 @@ export async function processPullRequestMergeTask(
|
||||
|
||||
const branch = getTaskBranchName(task.id);
|
||||
const settings = await store.getSettings();
|
||||
// `requirePrApproval` MOVED to workflow settings (U4): resolve the task's
|
||||
// effective workflow settings and overlay them onto the project/global base so
|
||||
// the approval-gate reads the per-(workflow, project) value post-migration. The
|
||||
// resolver never throws — a missing workflow degrades to built-in declaration
|
||||
// defaults (requirePrApproval=false), matching the pre-move default.
|
||||
try {
|
||||
const effective = await resolveEffectiveSettings(store, { id: task.id });
|
||||
Object.assign(settings as Record<string, unknown>, effective);
|
||||
} catch {
|
||||
// Defensive: keep the base settings if effective resolution fails entirely.
|
||||
}
|
||||
const resolvedIntegrationBranch = await resolveIntegrationBranch(cwd, settings);
|
||||
const projectDefaultBranch = resolvedIntegrationBranch;
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Drift guard for the intentionally duplicated resolvePluginEntryPath.
|
||||
*
|
||||
* The CLI keeps a local copy in bundled-plugin-install.ts (so its fs mocks
|
||||
* work in tests) while @fusion/core owns the copy used by the dashboard
|
||||
* install/enable routes. This test runs both against real on-disk layouts and
|
||||
* asserts identical results, so a candidate-list change applied to one copy
|
||||
* but not the other fails CI instead of silently diverging.
|
||||
*
|
||||
* No fs mocks here on purpose — vitest module mocks don't reach the
|
||||
* externalized @fusion/core import, so real temp directories are the only
|
||||
* seam that exercises both implementations equally.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { resolvePluginEntryPath as cliResolve } from "../bundled-plugin-install.js";
|
||||
import { resolvePluginEntryPath as coreResolve } from "@fusion/core";
|
||||
|
||||
describe("resolvePluginEntryPath: CLI copy stays in sync with @fusion/core", () => {
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), "entry-path-sync-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function touch(relative: string) {
|
||||
const full = join(dir, relative);
|
||||
mkdirSync(join(full, ".."), { recursive: true });
|
||||
writeFileSync(full, "// entry\n");
|
||||
}
|
||||
|
||||
const layouts: Array<{ name: string; files: string[]; expected: string | null }> = [
|
||||
{ name: "bundled.js only", files: ["bundled.js"], expected: "bundled.js" },
|
||||
{ name: "dist/index.js only", files: ["dist/index.js"], expected: "dist/index.js" },
|
||||
{ name: "src/index.ts only", files: ["src/index.ts"], expected: "src/index.ts" },
|
||||
{ name: "bundled.js preferred over src", files: ["bundled.js", "src/index.ts"], expected: "bundled.js" },
|
||||
{ name: "dist preferred over src", files: ["dist/index.js", "src/index.ts"], expected: "dist/index.js" },
|
||||
{ name: "all three → bundled.js", files: ["bundled.js", "dist/index.js", "src/index.ts"], expected: "bundled.js" },
|
||||
{ name: "no entry files", files: ["README.md"], expected: null },
|
||||
];
|
||||
|
||||
for (const layout of layouts) {
|
||||
it(`resolves identically for: ${layout.name}`, () => {
|
||||
for (const f of layout.files) touch(f);
|
||||
const expected = layout.expected === null ? null : join(dir, layout.expected);
|
||||
|
||||
expect(cliResolve(dir)).toBe(expected);
|
||||
expect(coreResolve(dir)).toBe(expected);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -78,6 +78,9 @@ function resolveBundledPluginDir(pluginId: string): string | null {
|
||||
* Returns null when the directory exists but none of the loadable entry files
|
||||
* are present. Callers must treat that as a missing bundle rather than
|
||||
* persisting a directory path that Node cannot import.
|
||||
*
|
||||
* Keep in sync with resolvePluginEntryPath in @fusion/core (plugin-loader.ts),
|
||||
* which the dashboard install/enable routes use for the same contract.
|
||||
*/
|
||||
export function resolvePluginEntryPath(pluginDir: string): string | null {
|
||||
const candidates = [
|
||||
|
||||
Reference in New Issue
Block a user