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:
gsxdsm
2026-06-05 23:05:01 -07:00
389 changed files with 57601 additions and 11062 deletions

View File

@@ -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",

View File

@@ -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.

View File

@@ -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(

View File

@@ -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", () => {

View File

@@ -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", () => {

View File

@@ -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,

View File

@@ -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 () => {

View File

@@ -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" }),
}),
);
});
});

View File

@@ -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 };
}),

View File

@@ -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();

View File

@@ -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);
});
});

View File

@@ -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

View 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(),
};
}

View File

@@ -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();

View File

@@ -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();

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -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 });
}

View File

@@ -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;

View File

@@ -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);
});
}
});

View File

@@ -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 = [

View File

@@ -0,0 +1,211 @@
import { describe, it, expect, beforeAll, beforeEach, afterAll } from "vitest";
import { CliSessionStore } from "../cli-session-store.js";
import { Database } from "../db.js";
import { mkdtempSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { rm } from "node:fs/promises";
function makeTmpDir(): string {
return mkdtempSync(join(tmpdir(), "kb-cli-session-store-test-"));
}
describe("CliSessionStore", () => {
let tmpDir: string;
let fusionDir: string;
let db: Database;
let store: CliSessionStore;
beforeAll(() => {
tmpDir = makeTmpDir();
fusionDir = join(tmpDir, ".fusion");
db = new Database(fusionDir, { inMemory: true });
db.init();
store = new CliSessionStore(fusionDir, db);
});
beforeEach(() => {
db.exec("DELETE FROM cli_sessions");
store.removeAllListeners();
});
afterAll(async () => {
db.close();
await rm(tmpDir, { recursive: true, force: true });
});
it("creates and reads a session record", () => {
const created = store.createSession({
taskId: "FN-100",
purpose: "execute",
projectId: "proj-1",
adapterId: "claude-local",
worktreePath: "/tmp/wt/FN-100",
autonomyPosture: { autoApprove: true, maxResumeAttempts: 3 },
});
expect(created.id).toMatch(/^cli-/);
expect(created.agentState).toBe("starting");
expect(created.terminationReason).toBeNull();
expect(created.resumeAttempts).toBe(0);
expect(created.chatSessionId).toBeNull();
expect(created.autonomyPosture).toEqual({ autoApprove: true, maxResumeAttempts: 3 });
const fetched = store.getSession(created.id);
expect(fetched).toEqual(created);
});
it("persists state transitions", () => {
const s = store.createSession({
taskId: "FN-101",
purpose: "planning",
projectId: "proj-1",
adapterId: "codex-local",
});
const states = ["ready", "busy", "waitingOnInput", "busy", "done"] as const;
for (const state of states) {
const updated = store.updateSession(s.id, { agentState: state });
expect(updated?.agentState).toBe(state);
// Persisted, not just returned.
expect(store.getSession(s.id)?.agentState).toBe(state);
}
});
it("round-trips the native session id", () => {
const s = store.createSession({
taskId: "FN-102",
purpose: "execute",
projectId: "proj-1",
adapterId: "claude-local",
});
expect(s.nativeSessionId).toBeNull();
store.updateSession(s.id, { nativeSessionId: "native-abc-123" });
expect(store.getSession(s.id)?.nativeSessionId).toBe("native-abc-123");
// Reopen via a fresh store instance on the same DB to prove durability.
const reopened = new CliSessionStore(fusionDir, db);
expect(reopened.getSession(s.id)?.nativeSessionId).toBe("native-abc-123");
});
it("updates terminationReason and resumeAttempts atomically with state", () => {
const s = store.createSession({
taskId: "FN-103",
purpose: "validator",
projectId: "proj-1",
adapterId: "claude-local",
});
const updated = store.updateSession(s.id, {
agentState: "dead",
terminationReason: "crashed",
resumeAttempts: 2,
});
expect(updated?.agentState).toBe("dead");
expect(updated?.terminationReason).toBe("crashed");
expect(updated?.resumeAttempts).toBe(2);
const persisted = store.getSession(s.id)!;
expect(persisted.agentState).toBe("dead");
expect(persisted.terminationReason).toBe("crashed");
expect(persisted.resumeAttempts).toBe(2);
});
it("clears terminationReason when set back to null", () => {
const s = store.createSession({
taskId: "FN-104",
purpose: "execute",
projectId: "proj-1",
adapterId: "claude-local",
agentState: "dead",
terminationReason: "killed",
});
expect(s.terminationReason).toBe("killed");
store.updateSession(s.id, { agentState: "starting", terminationReason: null });
const persisted = store.getSession(s.id)!;
expect(persisted.terminationReason).toBeNull();
expect(persisted.agentState).toBe("starting");
});
it("queries sessions by task and by chat entity", () => {
store.createSession({ taskId: "FN-200", purpose: "execute", projectId: "p", adapterId: "a" });
store.createSession({ taskId: "FN-200", purpose: "validator", projectId: "p", adapterId: "a" });
store.createSession({ taskId: "FN-201", purpose: "execute", projectId: "p", adapterId: "a" });
store.createSession({ chatSessionId: "chat-xyz", purpose: "chat", projectId: "p", adapterId: "a" });
expect(store.listByTask("FN-200")).toHaveLength(2);
expect(store.listByTask("FN-201")).toHaveLength(1);
expect(store.listByTask("FN-999")).toHaveLength(0);
const chatSessions = store.listByChatSession("chat-xyz");
expect(chatSessions).toHaveLength(1);
expect(chatSessions[0].purpose).toBe("chat");
});
it("filters by projectId and agentState", () => {
store.createSession({ taskId: "FN-300", purpose: "execute", projectId: "pA", adapterId: "a", agentState: "busy" });
store.createSession({ taskId: "FN-301", purpose: "execute", projectId: "pA", adapterId: "a", agentState: "done" });
store.createSession({ taskId: "FN-302", purpose: "execute", projectId: "pB", adapterId: "a", agentState: "busy" });
expect(store.listSessions({ projectId: "pA" })).toHaveLength(2);
expect(store.listSessions({ projectId: "pA", agentState: "busy" })).toHaveLength(1);
expect(store.listSessions({ agentState: "busy" })).toHaveLength(2);
});
it("rejects an invalid agent state at the store boundary", () => {
const s = store.createSession({
taskId: "FN-400",
purpose: "execute",
projectId: "p",
adapterId: "a",
});
expect(() =>
// @ts-expect-error invalid state value rejected at runtime
store.updateSession(s.id, { agentState: "bogus" }),
).toThrow(/Invalid CLI agent state/);
expect(() =>
// @ts-expect-error invalid state value rejected at runtime
store.createSession({ purpose: "execute", projectId: "p", adapterId: "a", agentState: "nope" }),
).toThrow(/Invalid CLI agent state/);
// The original record was untouched by the failed update.
expect(store.getSession(s.id)?.agentState).toBe("starting");
});
it("rejects an invalid purpose and termination reason at the store boundary", () => {
expect(() =>
// @ts-expect-error invalid purpose rejected at runtime
store.createSession({ purpose: "wat", projectId: "p", adapterId: "a" }),
).toThrow(/Invalid CLI session purpose/);
const s = store.createSession({ taskId: "FN-401", purpose: "execute", projectId: "p", adapterId: "a" });
expect(() =>
// @ts-expect-error invalid termination reason rejected at runtime
store.updateSession(s.id, { terminationReason: "exploded" }),
).toThrow(/Invalid CLI termination reason/);
});
it("emits create/update/delete events", () => {
const events: string[] = [];
store.on("cli-session:created", () => events.push("created"));
store.on("cli-session:updated", () => events.push("updated"));
store.on("cli-session:deleted", () => events.push("deleted"));
const s = store.createSession({ taskId: "FN-500", purpose: "ce", projectId: "p", adapterId: "a" });
store.updateSession(s.id, { agentState: "ready" });
expect(store.deleteSession(s.id)).toBe(true);
expect(store.getSession(s.id)).toBeUndefined();
expect(events).toEqual(["created", "updated", "deleted"]);
});
it("returns undefined when updating a missing session and false when deleting one", () => {
expect(store.updateSession("cli-missing", { agentState: "ready" })).toBeUndefined();
expect(store.deleteSession("cli-missing")).toBe(false);
});
});

View File

@@ -0,0 +1,288 @@
// @vitest-environment node
//
// column-agent plan U2 — the shared effective-agent resolver.
//
// Proves the full mode × own-settings matrix (KTD-2/KTD-5):
// - override × own-settings present → column agent; override × bare → column.
// - defer × own agentId → own; defer × complete model pair → own;
// defer × lone provider (incomplete pair, no agentId) → column agent wins.
// - no node.column / column without binding → own-settings or none.
// - foreach instance inheritance + template-node own column wins.
// - parseInstanceNodeId round-trip incl. templateNodeId containing ':'.
// - two graphs differing only in binding diverge.
import { describe, expect, it } from "vitest";
import {
instanceNodeId,
parseInstanceNodeId,
resolveColumnAgentBinding,
resolveEffectiveAgent,
} from "../column-agent-resolver.js";
import type {
WorkflowColumnAgent,
WorkflowIrEdge,
WorkflowIrNode,
WorkflowIrV2,
} from "../workflow-ir-types.js";
function v2(
columns: WorkflowIrV2["columns"],
nodes: WorkflowIrNode[],
edges: WorkflowIrEdge[] = [],
): WorkflowIrV2 {
return { version: "v2", name: "test", columns, nodes, edges };
}
const overrideBinding: WorkflowColumnAgent = { agentId: "col-agent", mode: "override" };
const deferBinding: WorkflowColumnAgent = { agentId: "col-agent", mode: "defer" };
describe("resolveEffectiveAgent — precedence matrix (U2)", () => {
it("override × own settings present → column agent", () => {
expect(
resolveEffectiveAgent({
binding: overrideBinding,
ownAgentId: "own-agent",
ownModelProvider: "anthropic",
ownModelId: "claude-x",
}),
).toEqual({ source: "column-agent", agentId: "col-agent" });
});
it("override × bare → column agent", () => {
expect(resolveEffectiveAgent({ binding: overrideBinding })).toEqual({
source: "column-agent",
agentId: "col-agent",
});
});
it("defer × own agentId only → own settings win", () => {
expect(resolveEffectiveAgent({ binding: deferBinding, ownAgentId: "own-agent" })).toEqual({
source: "own-settings",
});
});
it("defer × complete own model pair only → own settings win", () => {
expect(
resolveEffectiveAgent({
binding: deferBinding,
ownModelProvider: "anthropic",
ownModelId: "claude-x",
}),
).toEqual({ source: "own-settings" });
});
it("defer × lone provider (incomplete pair, no agentId) → column agent wins", () => {
// An incomplete pair does NOT count as own settings (KTD-5; matches
// resolveExecutorSessionModel's both-present rule).
expect(
resolveEffectiveAgent({ binding: deferBinding, ownModelProvider: "anthropic" }),
).toEqual({ source: "column-agent", agentId: "col-agent" });
});
it("defer × lone modelId (incomplete pair, no agentId) → column agent wins", () => {
// Symmetric incomplete-pair surface (FN-5893: assert the invariant across
// ALL known surfaces, not only the provider-only reproduction).
expect(resolveEffectiveAgent({ binding: deferBinding, ownModelId: "claude-x" })).toEqual({
source: "column-agent",
agentId: "col-agent",
});
});
it("defer × bare → column agent wins", () => {
expect(resolveEffectiveAgent({ binding: deferBinding })).toEqual({
source: "column-agent",
agentId: "col-agent",
});
});
it("no binding × own settings → own-settings", () => {
expect(resolveEffectiveAgent({ binding: undefined, ownAgentId: "own-agent" })).toEqual({
source: "own-settings",
});
});
it("no binding × bare → none", () => {
expect(resolveEffectiveAgent({ binding: undefined })).toEqual({ source: "none" });
});
});
describe("resolveColumnAgentBinding — lookup (U2)", () => {
const ir = v2(
[
{ id: "todo", name: "todo", traits: [] },
{ id: "review", name: "review", traits: [], agent: overrideBinding },
],
[
{ id: "start", kind: "start", column: "todo" },
{ id: "work", kind: "prompt", column: "review", config: { prompt: "do" } },
{ id: "plain", kind: "prompt", column: "todo", config: { prompt: "do" } },
{ id: "nocol", kind: "prompt", config: { prompt: "do" } },
{ id: "end", kind: "end", column: "review" },
],
);
it("resolves the bound column's agent for a node declared in it", () => {
expect(resolveColumnAgentBinding(ir, "work")).toEqual(overrideBinding);
});
it("returns undefined for a node in a column without a binding", () => {
expect(resolveColumnAgentBinding(ir, "plain")).toBeUndefined();
});
it("returns undefined for a node with no declared column, even when other columns bind", () => {
expect(resolveColumnAgentBinding(ir, "nocol")).toBeUndefined();
});
it("returns undefined for an unknown node id", () => {
expect(resolveColumnAgentBinding(ir, "ghost")).toBeUndefined();
});
});
describe("resolveColumnAgentBinding — foreach instance inheritance (U2)", () => {
function foreachIr(opts: {
foreachColumn?: string;
templateNodeColumn?: string;
reviewAgent?: WorkflowColumnAgent;
todoAgent?: WorkflowColumnAgent;
}): WorkflowIrV2 {
return v2(
[
{ id: "todo", name: "todo", traits: [], ...(opts.todoAgent ? { agent: opts.todoAgent } : {}) },
{ id: "review", name: "review", traits: [], ...(opts.reviewAgent ? { agent: opts.reviewAgent } : {}) },
],
[
{ id: "start", kind: "start" },
{
id: "fe",
kind: "foreach",
...(opts.foreachColumn ? { column: opts.foreachColumn } : {}),
config: {
source: "task-steps",
template: {
nodes: [
{
id: "se",
kind: "prompt",
...(opts.templateNodeColumn ? { column: opts.templateNodeColumn } : {}),
config: { seam: "step-execute" },
},
{ id: "rev", kind: "step-review", config: { type: "code" } },
{ id: "exit", kind: "prompt" },
],
edges: [],
},
},
},
{ id: "end", kind: "end" },
],
);
}
it("instance node inherits the enclosing foreach node's column binding", () => {
const ir = foreachIr({ foreachColumn: "review", reviewAgent: overrideBinding });
const nodeId = instanceNodeId("fe", 0, "se");
expect(resolveColumnAgentBinding(ir, nodeId)).toEqual(overrideBinding);
});
it("template node's own declared column wins over inheritance", () => {
const ir = foreachIr({
foreachColumn: "review",
reviewAgent: overrideBinding,
templateNodeColumn: "todo",
todoAgent: deferBinding,
});
const nodeId = instanceNodeId("fe", 1, "se");
expect(resolveColumnAgentBinding(ir, nodeId)).toEqual(deferBinding);
});
it("instance node with no foreach column and no template column → no binding", () => {
const ir = foreachIr({ reviewAgent: overrideBinding });
const nodeId = instanceNodeId("fe", 0, "se");
expect(resolveColumnAgentBinding(ir, nodeId)).toBeUndefined();
});
it("skips a candidate whose templateNodeId doesn't exist under the foreach", () => {
// PR #1432 review: a bogus prefix candidate can name a real foreach while its
// parsed templateNodeId resolves to nothing — it must be skipped, not treated
// as inheriting the foreach's column.
const ir = foreachIr({ foreachColumn: "review", reviewAgent: overrideBinding });
expect(resolveColumnAgentBinding(ir, instanceNodeId("fe", 0, "nope"))).toBeUndefined();
});
it("resolves bindings when the foreach node id itself contains '#'", () => {
// The instance-id format is delimiter-ambiguous; the resolver validates each
// candidate split against real foreach nodes instead of trusting the first '#'
// (PR #1432 review).
const ir = foreachIr({ foreachColumn: "review", reviewAgent: overrideBinding });
const fe = ir.nodes.find((n) => n.id === "fe");
if (!fe) throw new Error("fixture foreach missing");
fe.id = "fe#a";
const nodeId = instanceNodeId("fe#a", 0, "se");
expect(nodeId).toBe("fe#a#0:se");
expect(resolveColumnAgentBinding(ir, nodeId)).toEqual(overrideBinding);
});
});
describe("instanceNodeId / parseInstanceNodeId round-trip (U2)", () => {
it("round-trips a simple instance id", () => {
const id = instanceNodeId("fe", 3, "se");
expect(id).toBe("fe#3:se");
expect(parseInstanceNodeId(id)).toEqual({
foreachNodeId: "fe",
stepIndex: 3,
templateNodeId: "se",
});
});
it("round-trips when the templateNodeId itself contains ':'", () => {
// Defensive: split on the FIRST ':' of the remainder, keep the rest.
const id = instanceNodeId("fe", 2, "ns:inner:node");
expect(id).toBe("fe#2:ns:inner:node");
expect(parseInstanceNodeId(id)).toEqual({
foreachNodeId: "fe",
stepIndex: 2,
templateNodeId: "ns:inner:node",
});
});
it("returns undefined for non-instance ids", () => {
expect(parseInstanceNodeId("plain")).toBeUndefined();
expect(parseInstanceNodeId("fe#3")).toBeUndefined();
expect(parseInstanceNodeId("fe#:se")).toBeUndefined();
expect(parseInstanceNodeId("fe#x:se")).toBeUndefined();
});
});
describe("two graphs differing only in binding diverge (U2)", () => {
function graph(reviewAgent?: WorkflowColumnAgent): WorkflowIrV2 {
return v2(
[
{ id: "todo", name: "todo", traits: [] },
{ id: "review", name: "review", traits: [], ...(reviewAgent ? { agent: reviewAgent } : {}) },
],
[
{ id: "start", kind: "start", column: "todo" },
{ id: "work", kind: "prompt", column: "review", config: { prompt: "do" } },
{ id: "end", kind: "end", column: "review" },
],
);
}
it("the effective agent diverges when only the binding differs", () => {
const bound = graph(overrideBinding);
const unbound = graph();
// Same node, same own settings, different graph binding → different verdict.
const own = { ownAgentId: "task-agent" } as const;
const boundResult = resolveEffectiveAgent({
binding: resolveColumnAgentBinding(bound, "work"),
...own,
});
const unboundResult = resolveEffectiveAgent({
binding: resolveColumnAgentBinding(unbound, "work"),
...own,
});
expect(boundResult).toEqual({ source: "column-agent", agentId: "col-agent" });
expect(unboundResult).toEqual({ source: "own-settings" });
expect(boundResult).not.toEqual(unboundResult);
});
});

View File

@@ -715,7 +715,8 @@ describe("schema migration", () => {
const row = db.prepare("SELECT deletedAt FROM tasks WHERE id = 'FN-legacy'").get() as { deletedAt: string | null };
expect(row.deletedAt).toBeNull();
expect(db.getSchemaVersion()).toBe(108);
expect(db.getSchemaVersion()).toBe(113);
expect(db.getSchemaVersion()).toBe(113);
db.close();
});
@@ -748,7 +749,8 @@ describe("schema migration", () => {
{ id: "WS-001", mode: "prompt", gateMode: "advisory" },
{ id: "WS-002", mode: "script", gateMode: "advisory" },
]);
expect(db.getSchemaVersion()).toBe(108);
expect(db.getSchemaVersion()).toBe(113);
expect(db.getSchemaVersion()).toBe(113);
db.close();
});
@@ -798,7 +800,8 @@ describe("schema migration", () => {
reviewerContextRetryCount: 0,
reviewerFallbackRetryCount: 0,
});
expect(db.getSchemaVersion()).toBe(108);
expect(db.getSchemaVersion()).toBe(113);
expect(db.getSchemaVersion()).toBe(113);
db.close();
});
@@ -827,7 +830,8 @@ describe("schema migration", () => {
const columns = db.prepare("PRAGMA table_info(milestones)").all() as Array<{ name: string }>;
expect(columns.map((column) => column.name)).toContain("acceptanceCriteria");
expect(db.getSchemaVersion()).toBe(108);
expect(db.getSchemaVersion()).toBe(113);
expect(db.getSchemaVersion()).toBe(113);
db.close();
});
@@ -868,7 +872,8 @@ describe("schema migration", () => {
const missionColumns = db.prepare("PRAGMA table_info(missions)").all() as Array<{ name: string }>;
expect(missionColumns.map((column) => column.name)).toContain("autoMerge");
expect(db.getSchemaVersion()).toBe(108);
expect(db.getSchemaVersion()).toBe(113);
expect(db.getSchemaVersion()).toBe(113);
db.close();
});
@@ -902,7 +907,8 @@ describe("schema migration", () => {
{ id: "WS-002", mode: "script", enabled: 1, gateMode: "advisory" },
{ id: "WS-003", mode: "prompt", enabled: 0, gateMode: "advisory" },
]);
expect(db.getSchemaVersion()).toBe(108);
expect(db.getSchemaVersion()).toBe(113);
expect(db.getSchemaVersion()).toBe(113);
db.close();
});
@@ -939,7 +945,8 @@ describe("schema migration", () => {
const indexes = db.prepare("PRAGMA index_list(mission_goals)").all() as Array<{ name: string }>;
expect(indexes.some((index) => index.name === "idxMissionGoalsGoalId")).toBe(true);
expect(db.getSchemaVersion()).toBe(108);
expect(db.getSchemaVersion()).toBe(113);
expect(db.getSchemaVersion()).toBe(113);
db.close();
});
@@ -1000,7 +1007,246 @@ describe("schema migration", () => {
expect(customFieldsColumn).toBeDefined();
expect(customFieldsColumn?.dflt_value).toBe("'{}'");
expect(db.getSchemaVersion()).toBe(108);
expect(db.getSchemaVersion()).toBe(113);
db.close();
});
it("adds workflow_settings table when migrating from schema version 108", () => {
const db = new Database(fusionDir);
db.exec("CREATE TABLE IF NOT EXISTS __meta (key TEXT PRIMARY KEY, value TEXT)");
db.exec("INSERT INTO __meta (key, value) VALUES ('schemaVersion', '108')");
db.exec("INSERT INTO __meta (key, value) VALUES ('lastModified', '1000')");
db.exec(`
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
description TEXT NOT NULL,
"column" TEXT NOT NULL,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL
)
`);
db.init();
// The new per-(workflowId, projectId) setting-value table exists.
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as Array<{ name: string }>;
expect(tables.map((row) => row.name)).toContain("workflow_settings");
const columns = db.prepare("PRAGMA table_info(workflow_settings)").all() as Array<{
name: string;
pk: number;
dflt_value: string | null;
}>;
expect(columns.map((column) => column.name)).toEqual(["workflowId", "projectId", "values", "updatedAt"]);
expect(columns.filter((column) => column.pk > 0).map((column) => column.name).sort()).toEqual(["projectId", "workflowId"]);
const valuesColumn = columns.find((column) => column.name === "values");
expect(valuesColumn?.dflt_value).toBe("'{}'");
const indexes = db.prepare("PRAGMA index_list(workflow_settings)").all() as Array<{ name: string }>;
expect(indexes.some((index) => index.name === "idx_workflow_settings_project")).toBe(true);
expect(db.getSchemaVersion()).toBe(113);
db.close();
});
it("adds cli_sessions table + indexes when migrating from schema version 108", () => {
const db = new Database(fusionDir);
db.exec("CREATE TABLE IF NOT EXISTS __meta (key TEXT PRIMARY KEY, value TEXT)");
db.exec("INSERT INTO __meta (key, value) VALUES ('schemaVersion', '108')");
db.exec("INSERT INTO __meta (key, value) VALUES ('lastModified', '1000')");
db.exec(`
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
description TEXT NOT NULL,
"column" TEXT NOT NULL,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL
)
`);
db.init();
// The new per-(workflowId, projectId) setting-value table exists.
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as Array<{ name: string }>;
expect(tables.map((row) => row.name)).toContain("workflow_settings");
const columns = db.prepare("PRAGMA table_info(workflow_settings)").all() as Array<{
name: string;
pk: number;
dflt_value: string | null;
}>;
expect(columns.map((column) => column.name)).toEqual([
"workflowId",
"projectId",
"values",
"updatedAt",
]);
// Composite primary key over (workflowId, projectId).
expect(columns.filter((column) => column.pk > 0).map((column) => column.name).sort()).toEqual([
"projectId",
"workflowId",
]);
// `values` defaults to an empty JSON object.
const valuesColumn = columns.find((column) => column.name === "values");
expect(valuesColumn?.dflt_value).toBe("'{}'");
// The per-projectId lookup index is created alongside the table so migrated
// DBs match the fresh schema.
const indexes = db.prepare("PRAGMA index_list(workflow_settings)").all() as Array<{ name: string }>;
expect(indexes.some((index) => index.name === "idx_workflow_settings_project")).toBe(true);
// The durable CLI-session record table exists.
const cliTables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as Array<{ name: string }>;
expect(cliTables.map((row) => row.name)).toContain("cli_sessions");
const cliSessionColumns = db
.prepare("PRAGMA table_info(cli_sessions)")
.all() as Array<{ name: string }>;
expect(cliSessionColumns.map((column) => column.name)).toEqual([
"id",
"taskId",
"chatSessionId",
"purpose",
"projectId",
"adapterId",
"agentState",
"terminationReason",
"nativeSessionId",
"resumeAttempts",
"autonomyPosture",
"worktreePath",
"createdAt",
"updatedAt",
]);
const cliSessionIndexes = db
.prepare("PRAGMA index_list(cli_sessions)")
.all() as Array<{ name: string }>;
const indexNames = cliSessionIndexes.map((index) => index.name);
expect(indexNames).toContain("idx_cli_sessions_taskId");
expect(indexNames).toContain("idx_cli_sessions_chatSessionId");
expect(indexNames).toContain("idx_cli_sessions_project_state");
expect(db.getSchemaVersion()).toBe(113);
db.close();
});
it("adds cliExecutorAdapterId to chat_sessions when migrating from schema version 109", () => {
const db = new Database(fusionDir);
db.exec("CREATE TABLE IF NOT EXISTS __meta (key TEXT PRIMARY KEY, value TEXT)");
db.exec("INSERT INTO __meta (key, value) VALUES ('schemaVersion', '109')");
db.exec("INSERT INTO __meta (key, value) VALUES ('lastModified', '1000')");
db.exec(`
CREATE TABLE IF NOT EXISTS chat_sessions (
id TEXT PRIMARY KEY,
agentId TEXT NOT NULL,
title TEXT,
status TEXT NOT NULL DEFAULT 'active',
projectId TEXT,
modelProvider TEXT,
modelId TEXT,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL,
cliSessionFile TEXT,
inFlightGeneration TEXT
)
`);
db.init();
const columns = db
.prepare("PRAGMA table_info(chat_sessions)")
.all() as Array<{ name: string }>;
expect(columns.map((column) => column.name)).toContain("cliExecutorAdapterId");
expect(db.getSchemaVersion()).toBe(113);
db.close();
});
it("creates cli_sessions on a fresh database (fresh-create path)", () => {
const db = new Database(fusionDir);
db.init();
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as Array<{ name: string }>;
expect(tables.map((row) => row.name)).toContain("cli_sessions");
expect(db.getSchemaVersion()).toBe(113);
db.close();
});
it("adds workflows.kind + workflow_steps.migrated_fragment_id when migrating from schema version 108", () => {
const db = new Database(fusionDir);
db.exec("CREATE TABLE IF NOT EXISTS __meta (key TEXT PRIMARY KEY, value TEXT)");
db.exec("INSERT INTO __meta (key, value) VALUES ('schemaVersion', '108')");
db.exec("INSERT INTO __meta (key, value) VALUES ('lastModified', '1000')");
db.exec(`
CREATE TABLE IF NOT EXISTS workflows (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
ir TEXT NOT NULL,
layout TEXT NOT NULL DEFAULT '{}',
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL
)
`);
db.exec(`
CREATE TABLE IF NOT EXISTS workflow_steps (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL,
mode TEXT NOT NULL DEFAULT 'prompt',
phase TEXT NOT NULL DEFAULT 'pre-merge',
prompt TEXT NOT NULL DEFAULT '',
enabled INTEGER NOT NULL DEFAULT 1,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL
)
`);
db.exec(
`INSERT INTO workflows (id, name, ir, createdAt, updatedAt) VALUES ('WF-legacy', 'Legacy', '{"version":"v1","name":"x","nodes":[],"edges":[]}', '2025-01-01T00:00:00.000Z', '2025-01-01T00:00:00.000Z')`,
);
db.exec(
"INSERT INTO workflow_steps (id, name, description, createdAt, updatedAt) VALUES ('WS-legacy', 'Legacy', 'desc', '2025-01-01T00:00:00.000Z', '2025-01-01T00:00:00.000Z')",
);
db.init();
const workflowColumns = db.prepare("PRAGMA table_info(workflows)").all() as Array<{
name: string;
}>;
expect(workflowColumns.map((c) => c.name)).toContain("kind");
// Existing rows default to 'workflow'.
const wfRow = db.prepare("SELECT kind FROM workflows WHERE id = 'WF-legacy'").get() as { kind: string };
expect(wfRow.kind).toBe("workflow");
const stepColumns = db.prepare("PRAGMA table_info(workflow_steps)").all() as Array<{ name: string }>;
expect(stepColumns.map((c) => c.name)).toContain("migrated_fragment_id");
const stepRow = db
.prepare("SELECT migrated_fragment_id FROM workflow_steps WHERE id = 'WS-legacy'")
.get() as { migrated_fragment_id: string | null };
expect(stepRow.migrated_fragment_id).toBeNull();
expect(db.getSchemaVersion()).toBe(113);
expect(db.getSchemaVersion()).toBe(113);
db.close();
});
it("migration 109 is idempotent on re-init", () => {
const db = new Database(fusionDir);
db.init();
expect(db.getSchemaVersion()).toBe(113);
expect(db.getSchemaVersion()).toBe(113);
db.close();
// Re-open the same on-disk DB: already at 109, the 109 block must be a no-op.
const reopened = new Database(fusionDir);
reopened.init();
expect(reopened.getSchemaVersion()).toBe(113);
expect(reopened.getSchemaVersion()).toBe(113);
const workflowColumns = reopened.prepare("PRAGMA table_info(workflows)").all() as Array<{ name: string }>;
expect(workflowColumns.filter((c) => c.name === "kind")).toHaveLength(1);
const stepColumns = reopened.prepare("PRAGMA table_info(workflow_steps)").all() as Array<{ name: string }>;
expect(stepColumns.filter((c) => c.name === "migrated_fragment_id")).toHaveLength(1);
reopened.close();
});
});

View File

@@ -334,7 +334,8 @@ describe("Database", () => {
});
it("seeds schema version", () => {
expect(db.getSchemaVersion()).toBe(108);
expect(db.getSchemaVersion()).toBe(113);
expect(db.getSchemaVersion()).toBe(113);
});
it("includes tokenUsageCacheWriteTokens on freshly initialized tasks table", () => {
@@ -393,7 +394,8 @@ describe("Database", () => {
it("is idempotent - calling init() twice does not fail", () => {
expect(() => db.init()).not.toThrow();
expect(db.getSchemaVersion()).toBe(108);
expect(db.getSchemaVersion()).toBe(113);
expect(db.getSchemaVersion()).toBe(113);
});
it("does not overwrite existing config on re-init", () => {
// Update the config
@@ -1463,7 +1465,8 @@ describe("schema migrations", () => {
db.init();
// Verify version bumped to 29 (includes v1→v2 through v26→v29)
expect(db.getSchemaVersion()).toBe(108);
expect(db.getSchemaVersion()).toBe(113);
expect(db.getSchemaVersion()).toBe(113);
// Verify new columns exist and existing data is intact
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
@@ -1488,11 +1491,16 @@ describe("schema migrations", () => {
const db = new Database(fusionDir);
db.init();
expect(db.getSchemaVersion()).toBe(108);
expect(db.getSchemaVersion()).toBe(113);
// Re-init should not fail
db.init();
expect(db.getSchemaVersion()).toBe(108);
expect(db.getSchemaVersion()).toBe(113);
expect(db.getSchemaVersion()).toBe(113);
// Re-init should not fail
db.init();
expect(db.getSchemaVersion()).toBe(113);
db.close();
});
@@ -1527,7 +1535,8 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(108);
expect(db.getSchemaVersion()).toBe(113);
expect(db.getSchemaVersion()).toBe(113);
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
expect(cols.map((col) => col.name)).toContain("priority");
@@ -1568,7 +1577,8 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(108);
expect(db.getSchemaVersion()).toBe(113);
expect(db.getSchemaVersion()).toBe(113);
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
const colNames = cols.map((col) => col.name);
@@ -1640,7 +1650,8 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(108);
expect(db.getSchemaVersion()).toBe(113);
expect(db.getSchemaVersion()).toBe(113);
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
const colNames = cols.map((col) => col.name);
@@ -1722,13 +1733,13 @@ describe("schema migrations", () => {
const kept = entries.filter(([name]) => !dropped.has(name));
const chosen = kept.length > 0 ? kept : entries.slice(0, 1);
const columnSql = chosen.map(([name, def]) => ` ${name} ${def}`).join(",\n");
const columnSql = chosen.map(([name, def]) => ` "${name}" ${def}`).join(",\n");
legacyDb.exec(`CREATE TABLE IF NOT EXISTS ${tableName} (\n${columnSql}\n)`);
}
const validatorColumns = Object.entries(MIGRATION_ONLY_TABLE_SCHEMAS.mission_validator_runs)
.filter(([name, definition], index) => name === "id" || (name !== "taskId" && (index % 4 !== 0 || !isSafeToDrop(definition))))
.map(([name, def]) => ` ${name} ${def}`)
.map(([name, def]) => ` "${name}" ${def}`)
.join(",\n");
legacyDb.exec(`CREATE TABLE IF NOT EXISTS mission_validator_runs (\n${validatorColumns}\n)`);
@@ -1880,7 +1891,8 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(108);
expect(db.getSchemaVersion()).toBe(113);
expect(db.getSchemaVersion()).toBe(113);
const cols = db.prepare("PRAGMA table_info(chat_messages)").all() as Array<{ name: string }>;
expect(cols.map((col) => col.name)).toContain("attachments");
@@ -1954,7 +1966,8 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(108);
expect(db.getSchemaVersion()).toBe(113);
expect(db.getSchemaVersion()).toBe(113);
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'agentRatings'").all() as Array<{ name: string }>;
expect(tables).toEqual([{ name: "agentRatings" }]);
@@ -1978,7 +1991,8 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(108);
expect(db.getSchemaVersion()).toBe(113);
expect(db.getSchemaVersion()).toBe(113);
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'mission_events'").all() as Array<{ name: string }>;
expect(tables).toEqual([{ name: "mission_events" }]);
@@ -2082,7 +2096,8 @@ describe("schema migrations", () => {
db.init();
// Verify version bumped to 29
expect(db.getSchemaVersion()).toBe(108);
expect(db.getSchemaVersion()).toBe(113);
expect(db.getSchemaVersion()).toBe(113);
// Verify new columns exist and existing data is intact
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
@@ -2301,7 +2316,8 @@ describe("schema migrations", () => {
localDb.init();
expect(localDb.getSchemaVersion()).toBe(108);
expect(localDb.getSchemaVersion()).toBe(113);
expect(localDb.getSchemaVersion()).toBe(113);
const columns = localDb.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
expect(columns.map((column) => column.name)).toContain("tokenUsageCacheWriteTokens");
@@ -2612,7 +2628,8 @@ describe("createDatabase factory", () => {
const db = createDatabase(fusionDir);
db.init();
expect(db.getSchemaVersion()).toBe(108);
expect(db.getSchemaVersion()).toBe(113);
expect(db.getSchemaVersion()).toBe(113);
expect(db.getLastModified()).toBeGreaterThan(0);
db.close();
@@ -2766,7 +2783,8 @@ describe("migration v77 task token budget columns", () => {
migrated = new Database(fusion);
migrated.init();
expect(migrated.getSchemaVersion()).toBe(108);
expect(migrated.getSchemaVersion()).toBe(113);
expect(migrated.getSchemaVersion()).toBe(113);
const rows = migrated.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
const names = new Set(rows.map((row) => row.name));
expect(names.has("tokenBudgetSoftAlertedAt")).toBe(true);
@@ -2797,7 +2815,8 @@ describe("migration v106 adds tasks.transitionPending (FN-1417)", () => {
const fresh = new Database(fusion);
try {
fresh.init();
expect(fresh.getSchemaVersion()).toBe(108);
expect(fresh.getSchemaVersion()).toBe(113);
expect(fresh.getSchemaVersion()).toBe(113);
const names = new Set(
(fresh.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>).map((r) => r.name),
);
@@ -2825,7 +2844,8 @@ describe("migration v106 adds tasks.transitionPending (FN-1417)", () => {
migrated = new Database(fusion);
migrated.init();
expect(migrated.getSchemaVersion()).toBe(108);
expect(migrated.getSchemaVersion()).toBe(113);
expect(migrated.getSchemaVersion()).toBe(113);
const names = new Set(
(migrated.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>).map((r) => r.name),
);
@@ -2851,7 +2871,8 @@ describe("migration v107 adds workflow_run_branches + index (FN-1417)", () => {
const fresh = new Database(fusion);
try {
fresh.init();
expect(fresh.getSchemaVersion()).toBe(108);
expect(fresh.getSchemaVersion()).toBe(113);
expect(fresh.getSchemaVersion()).toBe(113);
const table = fresh
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'workflow_run_branches'")
.get() as { name: string } | undefined;
@@ -2885,7 +2906,8 @@ describe("migration v107 adds workflow_run_branches + index (FN-1417)", () => {
migrated = new Database(fusion);
migrated.init();
expect(migrated.getSchemaVersion()).toBe(108);
expect(migrated.getSchemaVersion()).toBe(113);
expect(migrated.getSchemaVersion()).toBe(113);
const table = migrated
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'workflow_run_branches'")
.get() as { name: string } | undefined;
@@ -2926,7 +2948,8 @@ describe("migration v67 drops orphan project auth tables", () => {
migrated = new Database(fusion);
migrated.init();
expect(migrated.getSchemaVersion()).toBe(108);
expect(migrated.getSchemaVersion()).toBe(113);
expect(migrated.getSchemaVersion()).toBe(113);
const tables = migrated
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'")
.all() as Array<{ name: string }>;
@@ -2953,7 +2976,8 @@ describe("migration v67 drops orphan project auth tables", () => {
try {
fresh.init();
expect(fresh.getSchemaVersion()).toBe(108);
expect(fresh.getSchemaVersion()).toBe(113);
expect(fresh.getSchemaVersion()).toBe(113);
const tables = fresh
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'")
.all() as Array<{ name: string }>;

View File

@@ -0,0 +1,134 @@
/**
* cliAgents global-settings slice (U15): round-trip with defaults merge +
* invalid-dropped-at-the-write-boundary behavior.
*/
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { GlobalSettingsStore } from "../global-settings.js";
import { sanitizeCliAgentsSettings, sanitizeCliAgentSettings } from "../settings-schema.js";
describe("sanitizeCliAgentSettings (write-boundary validation)", () => {
it("keeps valid fields and trims strings", () => {
expect(
sanitizeCliAgentSettings({
commandOverride: " /opt/claude ",
extraArgs: [" --foo ", "", "bar"],
envAdditions: ["MY_VAR", " ", "OTHER"],
autonomyMode: "elevated",
}),
).toEqual({
commandOverride: "/opt/claude",
extraArgs: ["--foo", "bar"],
envAdditions: ["MY_VAR", "OTHER"],
autonomyMode: "elevated",
});
});
it("drops unknown fields and invalid values", () => {
expect(
sanitizeCliAgentSettings({
commandOverride: 42,
extraArgs: "not-an-array",
envAdditions: [1, 2, 3],
autonomyMode: "godmode",
bogus: "x",
}),
).toBeUndefined();
});
it("drops empty-after-trim command override", () => {
expect(sanitizeCliAgentSettings({ commandOverride: " " })).toBeUndefined();
});
});
describe("sanitizeCliAgentsSettings", () => {
it("drops unknown adapter ids", () => {
const out = sanitizeCliAgentsSettings({
"claude-code": { autonomyMode: "elevated" },
"totally-made-up": { autonomyMode: "elevated" },
});
expect(Object.keys(out)).toEqual(["claude-code"]);
});
it("returns empty object for non-objects", () => {
expect(sanitizeCliAgentsSettings(null)).toEqual({});
expect(sanitizeCliAgentsSettings([1, 2])).toEqual({});
expect(sanitizeCliAgentsSettings("x")).toEqual({});
});
it("omits adapter entries that sanitize to nothing", () => {
const out = sanitizeCliAgentsSettings({
codex: { autonomyMode: "garbage" },
pi: { extraArgs: ["--ok"] },
});
expect(out).toEqual({ pi: { extraArgs: ["--ok"] } });
});
});
describe("GlobalSettingsStore cliAgents round-trip", () => {
let dir: string;
let store: GlobalSettingsStore;
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), "fusion-cli-agents-"));
store = new GlobalSettingsStore(dir);
await store.init();
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});
it("defaults cliAgents to an empty object", async () => {
const settings = await store.getSettings();
expect(settings.cliAgents).toEqual({});
});
it("persists a valid adapter config across a fresh read", async () => {
await store.updateSettings({
cliAgents: {
"claude-code": {
commandOverride: "/usr/local/bin/claude",
extraArgs: ["--verbose"],
autonomyMode: "elevated",
envAdditions: ["HTTP_PROXY"],
},
},
});
store.invalidateCache();
const reread = await store.getSettings();
expect(reread.cliAgents).toEqual({
"claude-code": {
commandOverride: "/usr/local/bin/claude",
extraArgs: ["--verbose"],
autonomyMode: "elevated",
envAdditions: ["HTTP_PROXY"],
},
});
});
it("drops invalid adapter ids and fields at the write boundary", async () => {
await store.updateSettings({
cliAgents: {
// unknown adapter id → dropped
"evil-adapter": { autonomyMode: "elevated" },
// valid adapter, junk autonomyMode dropped, valid extraArgs kept
codex: { autonomyMode: "yolo", extraArgs: ["--model=gpt"] },
} as never,
});
store.invalidateCache();
const reread = await store.getSettings();
expect(reread.cliAgents).toEqual({ codex: { extraArgs: ["--model=gpt"] } });
});
it("merges per-adapter without dropping unrelated global keys", async () => {
await store.updateSettings({ themeMode: "light" });
await store.updateSettings({ cliAgents: { pi: { extraArgs: ["--tools=read"] } } });
store.invalidateCache();
const reread = await store.getSettings();
expect(reread.themeMode).toBe("light");
expect(reread.cliAgents).toEqual({ pi: { extraArgs: ["--tools=read"] } });
});
});

View File

@@ -91,6 +91,6 @@ describe("goals schema", () => {
});
it("reports schema version 101", () => {
expect(db.getSchemaVersion()).toBe(108);
expect(db.getSchemaVersion()).toBe(113);
});
});

View File

@@ -1000,7 +1000,7 @@ describe("Migration: pre-33 DB upgrade", () => {
// Step 1: Create a fresh database at v33 (runs all migrations up to 33)
const db1 = createDatabase(legacyDir);
db1.init();
expect(db1.getSchemaVersion()).toBe(108);
expect(db1.getSchemaVersion()).toBe(113);
db1.close();
// Step 2: Manually downgrade to version 32 and drop insight tables
@@ -1035,7 +1035,7 @@ describe("Migration: pre-33 DB upgrade", () => {
expect(tableNamesBefore).not.toContain("project_insight_runs");
// Now run init — this triggers the v32→v33 migration
db3.init();
expect(db3.getSchemaVersion()).toBe(108);
expect(db3.getSchemaVersion()).toBe(113);
// Step 4: Verify insight tables exist after migration
const tablesAfter = db3.prepare(
@@ -1066,12 +1066,12 @@ describe("Migration: pre-33 DB upgrade", () => {
try {
const db1 = createDatabase(testDir);
db1.init();
expect(db1.getSchemaVersion()).toBe(108);
expect(db1.getSchemaVersion()).toBe(113);
db1.close();
const db2 = createDatabase(testDir);
expect(() => db2.init()).not.toThrow();
expect(db2.getSchemaVersion()).toBe(108);
expect(db2.getSchemaVersion()).toBe(113);
db2.close();
} finally {
rmSync(testDir, { recursive: true, force: true });
@@ -1085,7 +1085,7 @@ describe("Migration: pre-33 DB upgrade", () => {
// Step 1: Create a fresh DB and run migrations
const db1 = createDatabase(compatDir);
db1.init();
expect(db1.getSchemaVersion()).toBe(108);
expect(db1.getSchemaVersion()).toBe(113);
// Step 2: Strip lifecycle and cancelledAt columns by recreating the
// table without them. This simulates a DB that was created before the

View File

@@ -38,7 +38,7 @@ describe("TaskStore merge request record + completion handoff marker", () => {
.all() as Array<{ name: string }>;
expect(tableRows).toEqual([{ name: "completion_handoff_markers" }, { name: "merge_requests" }]);
expect(db.getSchemaVersion()).toBe(108);
expect(db.getSchemaVersion()).toBe(113);
});
it("upserts merge request records", async () => {

View File

@@ -3746,7 +3746,7 @@ describe("MissionStore", () => {
describe("Loop State & Validator Run Schema (v31)", () => {
it("schema version is 101 after migration", () => {
expect(db.getSchemaVersion()).toBe(108);
expect(db.getSchemaVersion()).toBe(113);
});
it("mission_features table has loop state columns", () => {

View File

@@ -0,0 +1,38 @@
import { describe, it, expect } from "vitest";
import { redactSecrets } from "../redact-secrets.js";
// Parity fixtures mirror the original ACP plugin's process-manager tests so the
// shared implementation produces identical behavior (Risk S8).
describe("redactSecrets (shared @fusion/core)", () => {
it("redacts bearer tokens", () => {
const out = redactSecrets("Authorization: Bearer sk-live-ABCDEFG1234567890abcdef");
expect(out).not.toContain("sk-live-ABCDEFG1234567890abcdef");
expect(out).toContain("[REDACTED]");
});
it("redacts key=/token= assignments", () => {
const out = redactSecrets("api_key=abcdef0123456789 token=ZZZ987654321");
expect(out).not.toContain("abcdef0123456789");
expect(out).not.toContain("ZZZ987654321");
});
it("redacts long opaque hex/base64 secrets", () => {
const out = redactSecrets("value 0123456789abcdef0123456789abcdef done");
expect(out).not.toContain("0123456789abcdef0123456789abcdef");
});
it("leaves benign text intact", () => {
expect(redactSecrets("hello world")).toBe("hello world");
});
it("redacts standalone sk-/ghp_/AKIA opaque tokens", () => {
const out = redactSecrets("ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789");
expect(out).toBe("[REDACTED]");
});
it("redacts quoted secret assignments", () => {
const out = redactSecrets('client_secret="topsecretvalue123"');
expect(out).not.toContain("topsecretvalue123");
expect(out).toContain("[REDACTED]");
});
});

View File

@@ -584,7 +584,7 @@ describe("Run Audit", () => {
});
it("schema version is bumped to 40", () => {
expect(db.getSchemaVersion()).toBe(108);
expect(db.getSchemaVersion()).toBe(113);
});
});
});

View File

@@ -0,0 +1,104 @@
/**
* U5 — Permanent settings-regime consistency guard (registration-drift lesson).
*
* Every settings key must live in EXACTLY ONE regime: either a project/global
* SCHEMA key, or a MOVED (tombstoned) workflow-setting key. This test fails fast
* if the schema key lists, the tombstone list, and the built-in workflow setting
* declarations ever drift apart — the exact class of bug the U4/U5 work exists to
* prevent (a moved key re-materializing in project settings, or a tombstone with
* no backing declaration).
*/
import { describe, it, expect } from "vitest";
import { MOVED_SETTINGS_KEYS } from "../moved-settings.js";
import { BUILTIN_WORKFLOW_SETTINGS } from "../builtin-workflow-settings.js";
import {
DEFAULT_GLOBAL_SETTINGS,
DEFAULT_PROJECT_SETTINGS,
GLOBAL_SETTINGS_KEYS,
PROJECT_SETTINGS_KEYS,
isGlobalSettingsKey,
isProjectSettingsKey,
} from "../settings-schema.js";
import {
SETTINGS_EXPORT_VERSION,
exportSettings,
} from "../settings-export.js";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const movedKeys = MOVED_SETTINGS_KEYS as readonly string[];
describe("settings consistency (U5)", () => {
it("(a) no moved key is also a DEFAULT_PROJECT_SETTINGS or DEFAULT_GLOBAL_SETTINGS key", () => {
const projectDefaultKeys = Object.keys(DEFAULT_PROJECT_SETTINGS);
const globalDefaultKeys = Object.keys(DEFAULT_GLOBAL_SETTINGS);
for (const key of movedKeys) {
expect(projectDefaultKeys, `moved key '${key}' must not be in DEFAULT_PROJECT_SETTINGS`).not.toContain(key);
expect(globalDefaultKeys, `moved key '${key}' must not be in DEFAULT_GLOBAL_SETTINGS`).not.toContain(key);
}
});
it("(b) MOVED_SETTINGS_KEYS and BUILTIN_WORKFLOW_SETTINGS declaration ids are exactly equal sets", () => {
const declIds = new Set(BUILTIN_WORKFLOW_SETTINGS.map((s) => s.id));
const moved = new Set(movedKeys);
// Every moved key has a declaration.
for (const key of moved) {
expect(declIds.has(key), `moved key '${key}' has no BUILTIN_WORKFLOW_SETTINGS declaration`).toBe(true);
}
// Every declaration is a moved key.
for (const id of declIds) {
expect(moved.has(id), `declaration '${id}' is missing from MOVED_SETTINGS_KEYS`).toBe(true);
}
expect(moved.size).toBe(declIds.size);
});
it("(c) every moved key is absent from GLOBAL_SETTINGS_KEYS / PROJECT_SETTINGS_KEYS and their predicates", () => {
const globalKeys = GLOBAL_SETTINGS_KEYS as readonly string[];
const projectKeys = PROJECT_SETTINGS_KEYS as readonly string[];
for (const key of movedKeys) {
expect(globalKeys, `moved key '${key}' must not be in GLOBAL_SETTINGS_KEYS`).not.toContain(key);
expect(projectKeys, `moved key '${key}' must not be in PROJECT_SETTINGS_KEYS`).not.toContain(key);
expect(isGlobalSettingsKey(key), `isGlobalSettingsKey('${key}') must be false`).toBe(false);
expect(isProjectSettingsKey(key), `isProjectSettingsKey('${key}') must be false`).toBe(false);
}
});
it("(d) settings-export v2 global/project section keys never overlap moved keys", async () => {
expect(SETTINGS_EXPORT_VERSION).toBe(2);
const tempDir = mkdtempSync(join(tmpdir(), "fn-settings-consistency-"));
const fusionDir = join(tempDir, ".fusion");
const globalSettingsDir = join(tempDir, "global-settings");
mkdirSync(join(fusionDir, "tasks"), { recursive: true });
mkdirSync(globalSettingsDir, { recursive: true });
writeFileSync(join(fusionDir, "config.json"), JSON.stringify({ nextId: 1, settings: {} }));
writeFileSync(join(globalSettingsDir, "settings.json"), JSON.stringify({}));
const { TaskStore } = await import("../store.js");
const store = new TaskStore(tempDir, globalSettingsDir, { inMemoryDb: true });
await store.init();
try {
// Even with a moved key written as a workflow value, it must surface ONLY in
// the workflowSettings section, never under global/project.
await store.updateWorkflowSettingValues(
"builtin:coding",
store.getWorkflowSettingsProjectId(),
{ requirePrApproval: true },
);
const exported = await exportSettings(store, { scope: "both" });
const globalSectionKeys = Object.keys(exported.global ?? {});
const projectSectionKeys = Object.keys(exported.project ?? {});
for (const key of movedKeys) {
expect(globalSectionKeys, `moved key '${key}' must not appear in export global section`).not.toContain(key);
expect(projectSectionKeys, `moved key '${key}' must not appear in export project section`).not.toContain(key);
}
// It IS present in the workflowSettings section.
expect(exported.workflowSettings?.["builtin:coding"]?.requirePrApproval).toBe(true);
} finally {
store.close();
rmSync(tempDir, { recursive: true, force: true });
}
});
});

View File

@@ -139,14 +139,23 @@ describe("settings-export", () => {
]);
});
it("should return error for wrong version", () => {
it("should accept v2 data", () => {
const data = {
version: 2,
exportedAt: new Date().toISOString(),
global: {},
};
expect(validateImportData(data)).toEqual([]);
});
it("should return error for wrong version", () => {
const data = {
version: 3,
exportedAt: new Date().toISOString(),
global: {},
};
expect(validateImportData(data)).toContain(
"Unsupported export version: 2. Expected: 1"
"Unsupported export version: 3. Expected: 1 or 2"
);
});
@@ -166,7 +175,7 @@ describe("settings-export", () => {
exportedAt: new Date().toISOString(),
};
expect(validateImportData(data)).toContain(
"Export data must contain at least one of 'global' or 'project' settings"
"Export data must contain at least one of 'global', 'project', or 'workflowSettings' settings"
);
});
@@ -201,7 +210,7 @@ describe("settings-export", () => {
const result = await exportSettings(store);
expect(result.version).toBe(1);
expect(result.version).toBe(2);
expect(result.exportedAt).toBeDefined();
expect(result.global).toBeDefined();
expect(result.global?.themeMode).toBe("dark");
@@ -365,7 +374,7 @@ describe("settings-export", () => {
it("should fail with validation errors for invalid data", async () => {
const importData = {
version: 2,
version: 3,
exportedAt: new Date().toISOString(),
global: {},
} as unknown as SettingsExportData;
@@ -373,7 +382,7 @@ describe("settings-export", () => {
const result = await importSettings(store, importData);
expect(result.success).toBe(false);
expect(result.error).toContain("Unsupported export version: 2");
expect(result.error).toContain("Unsupported export version: 3");
});
it("should handle import errors gracefully", async () => {
@@ -513,6 +522,203 @@ describe("settings-export", () => {
});
});
// ── U5: workflow settings (v2) export/import + v1 upgrade (KTD-8) ──────────
describe("workflow settings export/import (U5/KTD-8)", () => {
function rawDb(s: TaskStore): {
prepare: (sql: string) => { run: (...a: unknown[]) => unknown };
} {
return (s as unknown as { db: { prepare: (sql: string) => { run: (...a: unknown[]) => unknown } } }).db;
}
it("export post-migration carries workflow setting values; no moved key under project", async () => {
const projectId = store.getWorkflowSettingsProjectId();
// A normal unrelated project key + a workflow setting value on builtin:coding.
await store.updateSettings({ maxConcurrent: 3 });
await store.updateWorkflowSettingValues("builtin:coding", projectId, {
workflowStepTimeoutMs: 120_000,
requirePrApproval: true,
});
const result = await exportSettings(store, { scope: "project" });
expect(result.version).toBe(2);
// Project section: the unrelated key survives, NO moved key present.
expect(result.project?.maxConcurrent).toBe(3);
expect((result.project as Record<string, unknown>)?.workflowStepTimeoutMs).toBeUndefined();
expect((result.project as Record<string, unknown>)?.requirePrApproval).toBeUndefined();
// workflowSettings section carries the value-table row.
expect(result.workflowSettings?.["builtin:coding"]).toEqual({
workflowStepTimeoutMs: 120_000,
requirePrApproval: true,
});
});
it("import v1 payload containing workflowStepTimeoutMs → value lands per target rule, not project settings", async () => {
const projectId = store.getWorkflowSettingsProjectId();
const importData = {
version: 1 as const,
exportedAt: new Date().toISOString(),
project: {
// unrelated key — imports normally
maxConcurrent: 5,
// moved key — must be UPGRADED into workflow setting values
workflowStepTimeoutMs: 90_000,
} as Record<string, unknown>,
};
const result = await importSettings(store, importData as unknown as SettingsExportData, {
scope: "project",
merge: true,
});
expect(result.success).toBe(true);
expect(result.projectCount).toBe(1); // only maxConcurrent
expect(result.workflowSettingsCount).toBeGreaterThanOrEqual(1);
// Project settings: moved key never written into raw project settings.
const settings = await store.getSettings();
expect(settings.maxConcurrent).toBe(5);
const db = (store as unknown as { db: { prepare: (s: string) => { get: (...a: unknown[]) => unknown } } }).db;
const rawProject = JSON.parse(
(db.prepare("SELECT settings FROM config WHERE id = 1").get() as { settings: string }).settings,
) as Record<string, unknown>;
expect(rawProject.workflowStepTimeoutMs).toBeUndefined();
// Value landed on the resolved default workflow (builtin:coding, unset default).
expect(store.getWorkflowSettingValues("builtin:coding", projectId).workflowStepTimeoutMs).toBe(90_000);
});
it("import v1 upgrade targets every in-use selection workflow ∪ default", async () => {
const projectId = store.getWorkflowSettingsProjectId();
// Seed an in-use selection on a builtin workflow distinct from the default.
rawDb(store)
.prepare(
`INSERT INTO task_workflow_selection (taskId, workflowId, stepIds, updatedAt)
VALUES (?, ?, '[]', ?)
ON CONFLICT(taskId) DO UPDATE SET workflowId = excluded.workflowId`,
)
.run("task-1", "builtin:quick-fix", new Date().toISOString());
const importData = {
version: 1 as const,
exportedAt: new Date().toISOString(),
project: { requirePrApproval: true } as Record<string, unknown>,
};
await importSettings(store, importData as unknown as SettingsExportData, { scope: "project" });
// Both the in-use selection workflow and the default lane received the value.
expect(store.getWorkflowSettingValues("builtin:quick-fix", projectId).requirePrApproval).toBe(true);
expect(store.getWorkflowSettingValues("builtin:coding", projectId).requirePrApproval).toBe(true);
});
it("import v2 round-trips workflow setting values", async () => {
const projectId = store.getWorkflowSettingsProjectId();
const importData: SettingsExportData = {
version: 2,
exportedAt: new Date().toISOString(),
workflowSettings: {
"builtin:coding": { workflowStepTimeoutMs: 45_000, requirePrApproval: true },
},
};
const result = await importSettings(store, importData, { scope: "project", merge: true });
expect(result.success).toBe(true);
expect(result.workflowSettingsCount).toBe(2);
expect(store.getWorkflowSettingValues("builtin:coding", projectId)).toEqual({
workflowStepTimeoutMs: 45_000,
requirePrApproval: true,
});
});
it("import v2 drops-and-logs invalid values without aborting", async () => {
const projectId = store.getWorkflowSettingsProjectId();
const importData: SettingsExportData = {
version: 2,
exportedAt: new Date().toISOString(),
workflowSettings: {
// workflowStepTimeoutMs expects a number; the bad string is dropped, the
// valid requirePrApproval still lands.
"builtin:coding": {
workflowStepTimeoutMs: "not-a-number" as unknown as number,
requirePrApproval: true,
},
},
};
const result = await importSettings(store, importData, { scope: "project", merge: true });
expect(result.success).toBe(true);
const stored = store.getWorkflowSettingValues("builtin:coding", projectId);
expect(stored.workflowStepTimeoutMs).toBeUndefined();
expect(stored.requirePrApproval).toBe(true);
});
it("merge mode merges into existing rows; replace mode replaces the workflow's row", async () => {
const projectId = store.getWorkflowSettingsProjectId();
await store.updateWorkflowSettingValues("builtin:coding", projectId, {
workflowStepTimeoutMs: 10_000,
requirePrApproval: true,
});
// merge: only requirePrApproval changes; the timeout survives.
await importSettings(
store,
{
version: 2,
exportedAt: new Date().toISOString(),
workflowSettings: { "builtin:coding": { requirePrApproval: false } },
},
{ scope: "project", merge: true },
);
expect(store.getWorkflowSettingValues("builtin:coding", projectId)).toEqual({
workflowStepTimeoutMs: 10_000,
requirePrApproval: false,
});
// replace: the row becomes exactly the imported values (timeout dropped).
await importSettings(
store,
{
version: 2,
exportedAt: new Date().toISOString(),
workflowSettings: { "builtin:coding": { requirePrApproval: true } },
},
{ scope: "project", merge: false },
);
expect(store.getWorkflowSettingValues("builtin:coding", projectId)).toEqual({
requirePrApproval: true,
});
});
it("export → import round-trips the full payload", async () => {
const projectId = store.getWorkflowSettingsProjectId();
await store.updateSettings({ maxConcurrent: 4 });
await store.updateWorkflowSettingValues("builtin:coding", projectId, {
workflowStepTimeoutMs: 77_000,
});
const exported = await exportSettings(store, { scope: "project" });
// Fresh store, import the exported payload.
const env2 = createTestEnv();
const { TaskStore: TS } = await import("../store.js");
const store2 = new TS(env2.tempDir, env2.globalSettingsDir, { inMemoryDb: true });
await store2.init();
try {
const r = await importSettings(store2, exported, { scope: "project", merge: true });
expect(r.success).toBe(true);
const settings2 = await store2.getSettings();
expect(settings2.maxConcurrent).toBe(4);
expect(store2.getWorkflowSettingValues("builtin:coding", store2.getWorkflowSettingsProjectId()).workflowStepTimeoutMs).toBe(77_000);
} finally {
store2.close();
cleanupTestEnv(env2.tempDir);
}
});
});
describe("readExportFile", () => {
it("should read and parse valid export file", async () => {
const filePath = join(env.tempDir, "test-export.json");

View File

@@ -0,0 +1,362 @@
/**
* U4 — One-time hard-move migration of MOVED_SETTINGS_KEYS into workflow setting
* values (R6, R8, KTD-5). The load-bearing gate is the default re-injection
* regression: post-migration, saving an unrelated setting must NOT re-materialize
* any moved key in raw storage.
*
* Strategy: the migration runs at store init. To exercise a *pre-migration
* customized project* deterministically, we (a) init a store, (b) seed the RAW
* `config.settings` row + global settings file with customized moved keys and
* clear the `__meta` marker (simulating a project written by an older binary),
* then (c) invoke the migration directly and assert the end state. This mirrors
* the real flow (a fresh `init()` on a legacy DB) without depending on a binary
* downgrade.
*/
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync, existsSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { TaskStore } from "../store.js";
import {
MOVED_SETTINGS_KEYS,
SETTINGS_MIGRATION_VERSION,
SETTINGS_MIGRATION_MARKER_KEY,
} from "../moved-settings.js";
import { resolveEffectiveSettingsById, type WorkflowSettingsResolverStore } from "../workflow-settings-resolver.js";
import { PROJECT_SETTINGS_KEYS } from "../settings-schema.js";
// ── Test harness ────────────────────────────────────────────────────────────
interface Env {
tempDir: string;
fusionDir: string;
globalSettingsDir: string;
}
function createEnv(): Env {
const tempDir = mkdtempSync(join(tmpdir(), "fn-settings-migration-"));
const fusionDir = join(tempDir, ".fusion");
const tasksDir = join(fusionDir, "tasks");
const globalSettingsDir = join(tempDir, "global-settings");
mkdirSync(tasksDir, { recursive: true });
mkdirSync(globalSettingsDir, { recursive: true });
writeFileSync(join(globalSettingsDir, "settings.json"), JSON.stringify({}));
return { tempDir, fusionDir, globalSettingsDir };
}
async function openStore(env: Env): Promise<TaskStore> {
const { TaskStore } = await import("../store.js");
// Disk-backed DB so the global readRaw + config row paths are realistic and the
// raw settings survive across the seeding/migration steps.
const store = new TaskStore(env.tempDir, env.globalSettingsDir, { inMemoryDb: false });
await store.init();
return store;
}
/** Low-level raw db handle (tests routinely reach for `store["db"]`). */
function rawDb(store: TaskStore): {
prepare: (sql: string) => { run: (...a: unknown[]) => unknown; get: (...a: unknown[]) => unknown; all: (...a: unknown[]) => unknown };
} {
return (store as unknown as { db: ReturnType<typeof rawDb> }).db;
}
/** Overwrite the RAW persisted project `config.settings` JSON with `settings`. */
function seedRawProjectSettings(store: TaskStore, settings: Record<string, unknown>): void {
const db = rawDb(store);
const now = new Date().toISOString();
// Ensure a config row exists, then set its settings JSON directly.
db.prepare(
`INSERT INTO config (id, nextWorkflowStepId, settings, workflowSteps, updatedAt)
VALUES (1, 1, ?, '[]', ?)
ON CONFLICT(id) DO UPDATE SET settings = excluded.settings, updatedAt = excluded.updatedAt`,
).run(JSON.stringify(settings), now);
}
/** Read the RAW persisted project settings JSON back. */
function readRawProjectSettings(store: TaskStore): Record<string, unknown> {
const row = rawDb(store).prepare("SELECT settings FROM config WHERE id = 1").get() as
| { settings: string }
| undefined;
if (!row) return {};
return JSON.parse(row.settings) as Record<string, unknown>;
}
/** Clear the migration marker so the next migration run executes. */
function clearMarker(store: TaskStore): void {
rawDb(store).prepare("DELETE FROM __meta WHERE key = ?").run(SETTINGS_MIGRATION_MARKER_KEY);
}
function readMarker(store: TaskStore): number | undefined {
const row = rawDb(store).prepare("SELECT value FROM __meta WHERE key = ?").get(SETTINGS_MIGRATION_MARKER_KEY) as
| { value: string }
| undefined;
return row ? Number(row.value) : undefined;
}
/** Insert a `task_workflow_selection` row directly (deterministic; no flag deps). */
function seedSelection(store: TaskStore, taskId: string, workflowId: string): void {
rawDb(store)
.prepare(
`INSERT INTO task_workflow_selection (taskId, workflowId, stepIds, updatedAt)
VALUES (?, ?, '[]', ?)
ON CONFLICT(taskId) DO UPDATE SET workflowId = excluded.workflowId`,
)
.run(taskId, workflowId, new Date().toISOString());
}
/** Run the (private) migration directly. */
async function runMigration(store: TaskStore): Promise<void> {
await (store as unknown as { migrateMovedSettingsToWorkflowValuesOnce(): Promise<void> }).migrateMovedSettingsToWorkflowValuesOnce();
}
const resolverStore = (store: TaskStore) => store as unknown as WorkflowSettingsResolverStore;
// ── Tests ────────────────────────────────────────────────────────────────────
describe("settings hard-move migration (U4)", () => {
let env: Env;
let store: TaskStore;
beforeEach(async () => {
env = createEnv();
store = await openStore(env);
});
afterEach(async () => {
try {
await store.close();
} catch {
/* ignore */
}
try {
rmSync(env.tempDir, { recursive: true, force: true });
} catch {
/* ignore */
}
});
it("MOVED_SETTINGS_KEYS excludes buildTimeoutMs and the reflection interval/after keys", () => {
expect(MOVED_SETTINGS_KEYS).not.toContain("buildTimeoutMs");
expect(MOVED_SETTINGS_KEYS).not.toContain("reflectionIntervalMs");
expect(MOVED_SETTINGS_KEYS).not.toContain("reflectionAfterTask");
expect(MOVED_SETTINGS_KEYS).not.toContain("completionDocumentationMode");
expect(MOVED_SETTINGS_KEYS).toContain("workflowStepTimeoutMs");
expect(MOVED_SETTINGS_KEYS).toContain("requirePrApproval");
expect(MOVED_SETTINGS_KEYS).toContain("executionProvider");
// 30 keys after removing buildTimeoutMs from the catalog.
expect(MOVED_SETTINGS_KEYS.length).toBe(30);
});
it("fresh project post-init: marker set, effective values equal declaration defaults, no moved key in PROJECT_SETTINGS_KEYS", async () => {
// The store's own init() already ran the migration on a fresh DB.
expect(readMarker(store)).toBe(SETTINGS_MIGRATION_VERSION);
for (const key of MOVED_SETTINGS_KEYS) {
expect((PROJECT_SETTINGS_KEYS as readonly string[]).includes(key)).toBe(false);
}
const effective = await resolveEffectiveSettingsById(resolverStore(store), "builtin:coding", store.getWorkflowSettingsProjectId());
// Declaration defaults: workflowStepTimeoutMs=360000, requirePrApproval=false.
expect(effective.workflowStepTimeoutMs).toBe(360_000);
expect(effective.requirePrApproval).toBe(false);
});
it("customized project: moved values land under the in-use (workflowId, projectId); raw settings lose the keys; effective values identical pre/post", async () => {
const projectId = store.getWorkflowSettingsProjectId();
// Capture the PRE-migration effective values (the migration hasn't run on the
// seeded state yet). We resolve them from the legacy raw values by simulating
// them as builtin:coding effective inputs: pre-move these lived in project
// settings, so the "effective" engine value WAS the customized value.
const customized = {
// unrelated, non-moved project key — must survive untouched
maxConcurrent: 3,
// moved keys, customized:
workflowStepTimeoutMs: 120_000,
requirePrApproval: true,
executionProvider: "anthropic",
};
seedRawProjectSettings(store, customized);
clearMarker(store);
await runMigration(store);
// Marker set.
expect(readMarker(store)).toBe(SETTINGS_MIGRATION_VERSION);
// Raw project settings no longer contain the moved keys; the unrelated key stays.
const raw = readRawProjectSettings(store);
expect(raw.workflowStepTimeoutMs).toBeUndefined();
expect(raw.requirePrApproval).toBeUndefined();
expect(raw.executionProvider).toBeUndefined();
expect(raw.maxConcurrent).toBe(3);
// Values land on the resolved default (builtin:coding) for this project.
const effective = await resolveEffectiveSettingsById(resolverStore(store), "builtin:coding", projectId);
expect(effective.workflowStepTimeoutMs).toBe(120_000);
expect(effective.requirePrApproval).toBe(true);
expect(effective.executionProvider).toBe("anthropic");
});
it("mixed-pinning: one builtin task + one custom-pinned task, defaultWorkflowId unset → both read identical customized effective values", async () => {
const projectId = store.getWorkflowSettingsProjectId();
// A custom workflow declaring the moved keys (so values validate against it).
const custom = await store.createWorkflowDefinition({
name: "Custom WF",
ir: {
version: "v2",
name: "custom-wf",
columns: [{ id: "todo", name: "Todo", traits: [] }],
nodes: [
{ id: "start", kind: "start" },
{ id: "end", kind: "end" },
],
edges: [{ from: "start", to: "end" }],
settings: [
{ id: "workflowStepTimeoutMs", name: "Step timeout", type: "number", default: 360_000 },
{ id: "requirePrApproval", name: "Require PR approval", type: "boolean", default: false },
],
},
});
seedSelection(store, "FN-1", custom.id); // task pinned to custom
// FN-2 has NO selection row → resolves builtin:coding.
seedRawProjectSettings(store, {
workflowStepTimeoutMs: 200_000,
requirePrApproval: true,
});
clearMarker(store);
await runMigration(store);
const builtinEffective = await resolveEffectiveSettingsById(resolverStore(store), "builtin:coding", projectId);
const customEffective = await resolveEffectiveSettingsById(resolverStore(store), custom.id, projectId);
expect(builtinEffective.workflowStepTimeoutMs).toBe(200_000);
expect(builtinEffective.requirePrApproval).toBe(true);
expect(customEffective.workflowStepTimeoutMs).toBe(200_000);
expect(customEffective.requirePrApproval).toBe(true);
});
it("defaultWorkflowId unset, no selections → snapshot lands on (builtin:coding, projectId)", async () => {
const projectId = store.getWorkflowSettingsProjectId();
seedRawProjectSettings(store, { workflowStepTimeoutMs: 90_000 });
clearMarker(store);
await runMigration(store);
const effective = await resolveEffectiveSettingsById(resolverStore(store), "builtin:coding", projectId);
expect(effective.workflowStepTimeoutMs).toBe(90_000);
});
it("migration runs twice → second run is a no-op (idempotent via marker)", async () => {
const projectId = store.getWorkflowSettingsProjectId();
seedRawProjectSettings(store, { workflowStepTimeoutMs: 111_000 });
clearMarker(store);
await runMigration(store);
const valuesAfterFirst = store.getWorkflowSettingValues("builtin:coding", projectId);
// Second run: marker is set, so it no-ops. Mutating raw settings afterward must
// not be re-snapshotted.
await runMigration(store);
const valuesAfterSecond = store.getWorkflowSettingValues("builtin:coding", projectId);
expect(valuesAfterSecond).toEqual(valuesAfterFirst);
expect(valuesAfterSecond.workflowStepTimeoutMs).toBe(111_000);
});
it("crash simulation: value-writes then full re-run converges (write-then-null re-runnable)", async () => {
const projectId = store.getWorkflowSettingsProjectId();
seedRawProjectSettings(store, { workflowStepTimeoutMs: 150_000, requirePrApproval: true });
clearMarker(store);
// First (completing) run.
await runMigration(store);
const first = store.getWorkflowSettingValues("builtin:coding", projectId);
// Simulate a crash that left the marker UNSET but values written: clear marker,
// restore the raw keys (as if the null-out had not committed), re-run.
clearMarker(store);
seedRawProjectSettings(store, { workflowStepTimeoutMs: 150_000, requirePrApproval: true });
await runMigration(store);
const second = store.getWorkflowSettingValues("builtin:coding", projectId);
expect(second.workflowStepTimeoutMs).toBe(first.workflowStepTimeoutMs);
expect(second.requirePrApproval).toBe(first.requirePrApproval);
expect(readRawProjectSettings(store).workflowStepTimeoutMs).toBeUndefined();
expect(readMarker(store)).toBe(SETTINGS_MIGRATION_VERSION);
});
it("LOAD-BEARING: post-migration save of an unrelated setting does NOT re-materialize any moved key; effective values unchanged", async () => {
const projectId = store.getWorkflowSettingsProjectId();
seedRawProjectSettings(store, { workflowStepTimeoutMs: 130_000, requirePrApproval: true, maxConcurrent: 2 });
clearMarker(store);
await runMigration(store);
const before = await resolveEffectiveSettingsById(resolverStore(store), "builtin:coding", projectId);
// Save an UNRELATED project setting through the normal API.
await store.updateSettings({ maxConcurrent: 7 });
// No moved key re-materialized in raw storage (the default re-injection trap).
const raw = readRawProjectSettings(store);
for (const key of MOVED_SETTINGS_KEYS) {
expect(raw[key]).toBeUndefined();
}
expect(raw.maxConcurrent).toBe(7);
// Effective values unchanged.
const after = await resolveEffectiveSettingsById(resolverStore(store), "builtin:coding", projectId);
expect(after.workflowStepTimeoutMs).toBe(before.workflowStepTimeoutMs);
expect(after.requirePrApproval).toBe(before.requirePrApproval);
});
it("defaultWorkflowId points at a deleted/missing workflow → values land on builtin:coding", async () => {
const projectId = store.getWorkflowSettingsProjectId();
// Seed a default pointing at a non-existent workflow + the customized value.
seedRawProjectSettings(store, {
defaultWorkflowId: "missing-workflow-id",
workflowStepTimeoutMs: 175_000,
});
clearMarker(store);
await runMigration(store);
const effective = await resolveEffectiveSettingsById(resolverStore(store), "builtin:coding", projectId);
expect(effective.workflowStepTimeoutMs).toBe(175_000);
// The missing workflow id received nothing.
const missingValues = store.getWorkflowSettingValues("missing-workflow-id", projectId);
expect(missingValues.workflowStepTimeoutMs).toBeUndefined();
});
it("stale writer: updateSettings patch containing a moved key post-migration is dropped, not persisted", async () => {
clearMarker(store);
await runMigration(store);
await store.updateSettings({
// unrelated key
maxConcurrent: 5,
// stale moved key — must be dropped
workflowStepTimeoutMs: 999_999,
} as unknown as Parameters<TaskStore["updateSettings"]>[0]);
const raw = readRawProjectSettings(store);
expect(raw.maxConcurrent).toBe(5);
expect(raw.workflowStepTimeoutMs).toBeUndefined();
});
it("global settings file moved keys are nulled out by the migration (defensive belt)", async () => {
// Seed a moved key into the global settings file (legacy/defensive case).
const globalPath = join(env.globalSettingsDir, "settings.json");
writeFileSync(globalPath, JSON.stringify({ requirePrApproval: true, themeMode: "dark" }));
// Also seed the project raw with the same key (project wins).
seedRawProjectSettings(store, { requirePrApproval: true });
clearMarker(store);
await runMigration(store);
const globalRaw = existsSync(globalPath)
? (JSON.parse(readFileSync(globalPath, "utf-8")) as Record<string, unknown>)
: {};
expect(globalRaw.requirePrApproval).toBeUndefined();
expect(globalRaw.themeMode).toBe("dark");
});
});

View File

@@ -182,7 +182,56 @@ describe("settings key parity", () => {
it("keeps task stuck timeout active by default without coupling to workflow step timeout", () => {
expect(DEFAULT_PROJECT_SETTINGS.taskStuckTimeoutMs).toBe(600_000);
expect(DEFAULT_PROJECT_SETTINGS.runtimeStopDrainMs).toBe(2_000);
expect(DEFAULT_PROJECT_SETTINGS.workflowStepTimeoutMs).toBe(360_000);
// workflowStepTimeoutMs MOVED to workflow settings (U4) — no longer a project key.
expect(isProjectSettingsKey("workflowStepTimeoutMs")).toBe(false);
expect(PROJECT_SETTINGS_KEYS).not.toContain("workflowStepTimeoutMs");
});
it("removes the moved settings keys (U4 hard-move) from the project scope", () => {
const movedKeys = [
"workflowStepTimeoutMs",
"workflowStepScopeEnforcement",
"planOnlyScopeLeakEnforcement",
"workflowRevisionForkOnScopeMismatch",
"strictScopeEnforcement",
"runStepsInNewSessions",
"maxParallelSteps",
"buildRetryCount",
"verificationFixRetries",
"maxPostReviewFixes",
"requirePrApproval",
"requirePlanApproval",
"reviewHandoffPolicy",
"maxReviewerContextRetries",
"maxReviewerFallbackRetries",
"reflectionEnabled",
"executionProvider",
"executionModelId",
"planningProvider",
"planningModelId",
"planningFallbackProvider",
"planningFallbackModelId",
"validatorProvider",
"validatorModelId",
"validatorFallbackProvider",
"validatorFallbackModelId",
"titleSummarizerProvider",
"titleSummarizerModelId",
"titleSummarizerFallbackProvider",
"titleSummarizerFallbackModelId",
];
for (const key of movedKeys) {
expect(isProjectSettingsKey(key)).toBe(false);
expect(PROJECT_SETTINGS_KEYS).not.toContain(key);
expect(isGlobalSettingsKey(key)).toBe(false);
}
});
it("keeps buildTimeoutMs / reflectionIntervalMs / reflectionAfterTask project-scoped (NOT moved)", () => {
expect(isProjectSettingsKey("buildTimeoutMs")).toBe(true);
expect(isProjectSettingsKey("reflectionIntervalMs")).toBe(true);
expect(isProjectSettingsKey("reflectionAfterTask")).toBe(true);
expect(DEFAULT_PROJECT_SETTINGS.buildTimeoutMs).toBe(300_000);
});
it("defaults engine activation grace and leaves engine active clock undefined", () => {
@@ -367,27 +416,33 @@ describe("eval settings parity regression (FN-3393)", () => {
});
describe("model lane key parity regression (FN-1729)", () => {
// All model lane provider/modelId pairs that should exist
// All model lane provider/modelId pairs that should exist.
//
// U4 hard-move: the per-PHASE project lanes (execution/planning/validator/
// titleSummarizer provider+model, plus their fallbacks) MOVED to workflow
// settings and are no longer in either scope key list ("workflow" scope). The
// GLOBAL baseline lanes (`*GlobalProvider`) and the default/fallback baseline
// stay global.
const allModelLanePairs = [
// Default baseline (global only)
{ provider: "defaultProvider", modelId: "defaultModelId", expectedScope: "global" },
// Fallback baseline (global only)
{ provider: "fallbackProvider", modelId: "fallbackModelId", expectedScope: "global" },
// Execution lane
{ provider: "executionProvider", modelId: "executionModelId", expectedScope: "project" },
{ provider: "executionProvider", modelId: "executionModelId", expectedScope: "workflow" },
{ provider: "executionGlobalProvider", modelId: "executionGlobalModelId", expectedScope: "global" },
// Planning lane
{ provider: "planningProvider", modelId: "planningModelId", expectedScope: "project" },
{ provider: "planningProvider", modelId: "planningModelId", expectedScope: "workflow" },
{ provider: "planningGlobalProvider", modelId: "planningGlobalModelId", expectedScope: "global" },
{ provider: "planningFallbackProvider", modelId: "planningFallbackModelId", expectedScope: "project" },
{ provider: "planningFallbackProvider", modelId: "planningFallbackModelId", expectedScope: "workflow" },
// Validator lane
{ provider: "validatorProvider", modelId: "validatorModelId", expectedScope: "project" },
{ provider: "validatorProvider", modelId: "validatorModelId", expectedScope: "workflow" },
{ provider: "validatorGlobalProvider", modelId: "validatorGlobalModelId", expectedScope: "global" },
{ provider: "validatorFallbackProvider", modelId: "validatorFallbackModelId", expectedScope: "project" },
{ provider: "validatorFallbackProvider", modelId: "validatorFallbackModelId", expectedScope: "workflow" },
// Summarizer lane
{ provider: "titleSummarizerProvider", modelId: "titleSummarizerModelId", expectedScope: "project" },
{ provider: "titleSummarizerProvider", modelId: "titleSummarizerModelId", expectedScope: "workflow" },
{ provider: "titleSummarizerGlobalProvider", modelId: "titleSummarizerGlobalModelId", expectedScope: "global" },
{ provider: "titleSummarizerFallbackProvider", modelId: "titleSummarizerFallbackModelId", expectedScope: "project" },
{ provider: "titleSummarizerFallbackProvider", modelId: "titleSummarizerFallbackModelId", expectedScope: "workflow" },
] as const;
it.each(allModelLanePairs)(
@@ -398,6 +453,12 @@ describe("model lane key parity regression (FN-1729)", () => {
expect(isGlobalSettingsKey(modelId)).toBe(true);
expect(isProjectSettingsKey(provider)).toBe(false);
expect(isProjectSettingsKey(modelId)).toBe(false);
} else if (expectedScope === "workflow") {
// Moved to workflow settings — absent from BOTH scope key lists.
expect(isGlobalSettingsKey(provider)).toBe(false);
expect(isGlobalSettingsKey(modelId)).toBe(false);
expect(isProjectSettingsKey(provider)).toBe(false);
expect(isProjectSettingsKey(modelId)).toBe(false);
} else {
expect(isProjectSettingsKey(provider)).toBe(true);
expect(isProjectSettingsKey(modelId)).toBe(true);
@@ -407,15 +468,19 @@ describe("model lane key parity regression (FN-1729)", () => {
},
);
it("model lane keys appear in exactly one scope key list", () => {
it("scoped (non-workflow) model lane keys appear in exactly one scope key list", () => {
const globalKeys = new Set(GLOBAL_SETTINGS_KEYS as readonly string[]);
const projectKeys = new Set(PROJECT_SETTINGS_KEYS as readonly string[]);
for (const { provider, modelId } of allModelLanePairs) {
for (const { provider, modelId, expectedScope } of allModelLanePairs) {
if (expectedScope === "workflow") {
// Workflow-scoped lanes are in neither list.
expect(globalKeys.has(provider) || projectKeys.has(provider)).toBe(false);
expect(globalKeys.has(modelId) || projectKeys.has(modelId)).toBe(false);
continue;
}
const inGlobal = globalKeys.has(provider) && globalKeys.has(modelId);
const inProject = projectKeys.has(provider) && projectKeys.has(modelId);
// Each pair must appear in exactly one scope
expect(inGlobal || inProject).toBe(true);
expect(inGlobal && inProject).toBe(false);
}
@@ -433,15 +498,14 @@ describe("model lane key parity regression (FN-1729)", () => {
}
});
it("all project model lane keys are in PROJECT_SETTINGS_KEYS", () => {
const projectKeys = new Set(PROJECT_SETTINGS_KEYS as readonly string[]);
const projectLanes = allModelLanePairs
.filter((p) => p.expectedScope === "project")
it("moved (workflow) model lane keys are in NEITHER scope key list", () => {
const allKeys = new Set([...GLOBAL_SETTINGS_KEYS, ...PROJECT_SETTINGS_KEYS] as readonly string[]);
const workflowLanes = allModelLanePairs
.filter((p) => p.expectedScope === "workflow")
.flatMap((p) => [p.provider, p.modelId]);
for (const key of projectLanes) {
expect(projectKeys.has(key)).toBe(true);
for (const key of workflowLanes) {
expect(allKeys.has(key)).toBe(false);
}
});

View File

@@ -60,7 +60,7 @@ describe("TaskStore merge queue", () => {
expect.arrayContaining(["idx_mergeQueue_lease_ready", "idx_mergeQueue_leaseExpiresAt"]),
);
expect(store.getDatabase().getSchemaVersion()).toBe(108);
expect(store.getDatabase().getSchemaVersion()).toBe(113);
});
it("migrates a legacy v88 database and preserves task rows", async () => {

View File

@@ -124,150 +124,71 @@ describe("TaskStore", () => {
// ── Planning/Validator Model Settings ────────────────────────────
describe("planning/validator model settings", () => {
it("saves and restores planning model settings via updateSettings", async () => {
// U4 hard-move: planning/validator (and execution/titleSummarizer) PROJECT model
// lanes MOVED to workflow settings. `updateSettings` now DROPS them (R8); their
// persistence/precedence is covered by the workflow-settings + settings-migration
// suites. This block asserts the new drop behavior at the project-settings layer.
describe("planning/validator model settings (moved to workflow settings)", () => {
it("drops planning model settings from project settings (not persisted)", async () => {
await harness.store().updateSettings({
planningProvider: "anthropic",
planningModelId: "claude-sonnet-4-5",
});
const settings = await harness.store().getSettings();
expect(settings.planningProvider).toBe("anthropic");
expect(settings.planningModelId).toBe("claude-sonnet-4-5");
});
it("saves and restores validator model settings via updateSettings", async () => {
await harness.store().updateSettings({
validatorProvider: "openai",
validatorModelId: "gpt-4o",
});
const settings = await harness.store().getSettings();
expect(settings.validatorProvider).toBe("openai");
expect(settings.validatorModelId).toBe("gpt-4o");
});
it("saves and restores both planning and validator model settings via updateSettings", async () => {
await harness.store().updateSettings({
planningProvider: "anthropic",
planningModelId: "claude-sonnet-4-5",
validatorProvider: "openai",
validatorModelId: "gpt-4o",
});
const settings = await harness.store().getSettings();
expect(settings.planningProvider).toBe("anthropic");
expect(settings.planningModelId).toBe("claude-sonnet-4-5");
expect(settings.validatorProvider).toBe("openai");
expect(settings.validatorModelId).toBe("gpt-4o");
});
it("clears planning model settings when set to undefined", async () => {
await harness.store().updateSettings({
planningProvider: "anthropic",
planningModelId: "claude-sonnet-4-5",
});
await harness.store().updateSettings({
planningProvider: undefined,
planningModelId: undefined,
});
const settings = await harness.store().getSettings();
expect(settings.planningProvider).toBeUndefined();
expect(settings.planningModelId).toBeUndefined();
const config = JSON.parse(await readFile(join(harness.rootDir(), ".fusion", "config.json"), "utf-8"));
expect((config.settings as any).planningProvider).toBeUndefined();
expect((config.settings as any).planningModelId).toBeUndefined();
});
it("clears validator model settings when set to undefined", async () => {
it("drops validator model settings from project settings (not persisted)", async () => {
await harness.store().updateSettings({
validatorProvider: "openai",
validatorModelId: "gpt-4o",
});
await harness.store().updateSettings({
validatorProvider: undefined,
validatorModelId: undefined,
});
const settings = await harness.store().getSettings();
expect(settings.validatorProvider).toBeUndefined();
expect(settings.validatorModelId).toBeUndefined();
});
it("persists planning/validator settings in project config", async () => {
it("drops both planning and validator model settings together", async () => {
await harness.store().updateSettings({
planningProvider: "anthropic",
planningModelId: "claude-opus-4",
planningModelId: "claude-sonnet-4-5",
validatorProvider: "openai",
validatorModelId: "gpt-4-turbo",
validatorModelId: "gpt-4o",
});
// Verify the settings are in the project config file
const configRaw = await readFile(join(harness.rootDir(), ".fusion", "config.json"), "utf-8");
const config = JSON.parse(configRaw);
expect(config.settings.planningProvider).toBe("anthropic");
expect(config.settings.planningModelId).toBe("claude-opus-4");
expect(config.settings.validatorProvider).toBe("openai");
expect(config.settings.validatorModelId).toBe("gpt-4-turbo");
const settings = await harness.store().getSettings();
expect(settings.planningProvider).toBeUndefined();
expect(settings.validatorProvider).toBeUndefined();
});
});
// ── Dual-Scope Lane Model Settings (FN-1710) ─────────────────────
describe("dual-scope lane model settings", () => {
// Legacy backward compatibility tests
it("legacy: project config with only planningProvider/planningModelId round-trips unchanged", async () => {
// U4 hard-move: the per-phase PROJECT lanes are dropped by updateSettings.
it("moved project lanes are dropped, not round-tripped through project config", async () => {
await harness.store().updateSettings({
planningProvider: "anthropic",
planningModelId: "claude-sonnet-4-5",
});
const settings = await harness.store().getSettings();
expect(settings.planningProvider).toBe("anthropic");
expect(settings.planningModelId).toBe("claude-sonnet-4-5");
// Verify it's persisted correctly
const configRaw = await readFile(join(harness.rootDir(), ".fusion", "config.json"), "utf-8");
const config = JSON.parse(configRaw);
expect(config.settings.planningProvider).toBe("anthropic");
expect(config.settings.planningModelId).toBe("claude-sonnet-4-5");
});
it("legacy: project config with only validatorProvider/validatorModelId round-trips unchanged", async () => {
await harness.store().updateSettings({
validatorProvider: "openai",
validatorModelId: "gpt-4o",
});
const settings = await harness.store().getSettings();
expect(settings.validatorProvider).toBe("openai");
expect(settings.validatorModelId).toBe("gpt-4o");
const configRaw = await readFile(join(harness.rootDir(), ".fusion", "config.json"), "utf-8");
const config = JSON.parse(configRaw);
expect(config.settings.validatorProvider).toBe("openai");
expect(config.settings.validatorModelId).toBe("gpt-4o");
});
it("legacy: project config with only titleSummarizerProvider/titleSummarizerModelId round-trips unchanged", async () => {
await harness.store().updateSettings({
titleSummarizerProvider: "google",
titleSummarizerModelId: "gemini-2.5-pro",
});
const settings = await harness.store().getSettings();
expect(settings.titleSummarizerProvider).toBe("google");
expect(settings.titleSummarizerModelId).toBe("gemini-2.5-pro");
expect(settings.planningProvider).toBeUndefined();
expect(settings.validatorProvider).toBeUndefined();
expect(settings.titleSummarizerProvider).toBeUndefined();
const configRaw = await readFile(join(harness.rootDir(), ".fusion", "config.json"), "utf-8");
const config = JSON.parse(configRaw);
expect(config.settings.titleSummarizerProvider).toBe("google");
expect(config.settings.titleSummarizerModelId).toBe("gemini-2.5-pro");
});
it("legacy: partial provider without modelId behaves correctly", async () => {
// Set provider only without modelId (partial legacy pair)
await harness.store().updateSettings({
planningProvider: "anthropic",
// No planningModelId
});
const settings = await harness.store().getSettings();
expect(settings.planningProvider).toBe("anthropic");
expect(settings.planningModelId).toBeUndefined();
const config = JSON.parse(await readFile(join(harness.rootDir(), ".fusion", "config.json"), "utf-8"));
expect((config.settings as any).planningProvider).toBeUndefined();
expect((config.settings as any).validatorProvider).toBeUndefined();
expect((config.settings as any).titleSummarizerProvider).toBeUndefined();
});
// New default override fields
@@ -300,26 +221,26 @@ describe("TaskStore", () => {
});
// New execution lane fields
it("persists executionProvider/executionModelId via updateSettings", async () => {
it("executionProvider/executionModelId are DROPPED from project settings (moved)", async () => {
await harness.store().updateSettings({
executionProvider: "anthropic",
executionModelId: "claude-opus-4",
});
const settings = await harness.store().getSettings();
expect(settings.executionProvider).toBe("anthropic");
expect(settings.executionModelId).toBe("claude-opus-4");
expect(settings.executionProvider).toBeUndefined();
expect(settings.executionModelId).toBeUndefined();
});
it("executionProvider/executionModelId appear in project scope", async () => {
it("executionProvider/executionModelId never appear in project scope (moved)", async () => {
await harness.store().updateSettings({
executionProvider: "openai",
executionModelId: "gpt-4-turbo",
});
const { project } = await harness.store().getSettingsByScope();
expect(project.executionProvider).toBe("openai");
expect(project.executionModelId).toBe("gpt-4-turbo");
expect((project as any).executionProvider).toBeUndefined();
expect((project as any).executionModelId).toBeUndefined();
});
it("executionProvider/executionModelId default to undefined", async () => {
@@ -399,12 +320,12 @@ describe("TaskStore", () => {
planningModelId: "gpt-4o",
});
// Both should be readable with no crashes
// Global lane stays; project lane is MOVED → dropped.
const settings = await harness.store().getSettings();
expect(settings.planningGlobalProvider).toBe("anthropic");
expect(settings.planningGlobalModelId).toBe("claude-sonnet-4-5");
expect(settings.planningProvider).toBe("openai");
expect(settings.planningModelId).toBe("gpt-4o");
expect(settings.planningProvider).toBeUndefined();
expect(settings.planningModelId).toBeUndefined();
});
it("mixed shape: project validatorProvider + global validatorGlobalProvider is stable", async () => {
@@ -421,8 +342,8 @@ describe("TaskStore", () => {
const settings = await harness.store().getSettings();
expect(settings.validatorGlobalProvider).toBe("google");
expect(settings.validatorGlobalModelId).toBe("gemini-2.5-pro");
expect(settings.validatorProvider).toBe("anthropic");
expect(settings.validatorModelId).toBe("claude-opus-4");
expect(settings.validatorProvider).toBeUndefined();
expect(settings.validatorModelId).toBeUndefined();
});
it("mixed shape: project titleSummarizerProvider + global titleSummarizerGlobalProvider is stable", async () => {
@@ -439,8 +360,8 @@ describe("TaskStore", () => {
const settings = await harness.store().getSettings();
expect(settings.titleSummarizerGlobalProvider).toBe("openai");
expect(settings.titleSummarizerGlobalModelId).toBe("gpt-4o-mini");
expect(settings.titleSummarizerProvider).toBe("anthropic");
expect(settings.titleSummarizerModelId).toBe("claude-haiku");
expect(settings.titleSummarizerProvider).toBeUndefined();
expect(settings.titleSummarizerModelId).toBeUndefined();
});
// Global-only key filtering tests
@@ -496,22 +417,46 @@ describe("TaskStore", () => {
describe("model lane persistence regression", () => {
// Table-driven test matrix: verifies all model lane fields persist correctly
// Fields are split by their correct scope (global or project)
// U4 hard-move: the per-PHASE project lanes (execution/planning/validator/
// titleSummarizer + fallbacks) MOVED to workflow settings and no longer
// persist through `updateSettings` (the stale-writer guard drops them). They
// are covered by the workflow-settings store + settings-migration suites.
// Only `defaultProviderOverride`/`defaultModelIdOverride` remain project-scoped.
const projectModelLanePairs = [
// Execution lane (project override)
{ provider: "executionProvider", modelId: "executionModelId" },
// Planning lane (project override + fallback)
{ provider: "planningProvider", modelId: "planningModelId" },
{ provider: "planningFallbackProvider", modelId: "planningFallbackModelId" },
// Validator lane (project override + fallback)
{ provider: "validatorProvider", modelId: "validatorModelId" },
{ provider: "validatorFallbackProvider", modelId: "validatorFallbackModelId" },
// Summarizer lane (project override + fallback)
{ provider: "titleSummarizerProvider", modelId: "titleSummarizerModelId" },
{ provider: "titleSummarizerFallbackProvider", modelId: "titleSummarizerFallbackModelId" },
// Default override (project-level override of global defaults)
// Default override (project-level override of global defaults) — NOT moved.
{ provider: "defaultProviderOverride", modelId: "defaultModelIdOverride" },
] as const;
// The moved lanes, asserted to be DROPPED from project settings (R8).
const movedProjectModelLanePairs = [
{ provider: "executionProvider", modelId: "executionModelId" },
{ provider: "planningProvider", modelId: "planningModelId" },
{ provider: "planningFallbackProvider", modelId: "planningFallbackModelId" },
{ provider: "validatorProvider", modelId: "validatorModelId" },
{ provider: "validatorFallbackProvider", modelId: "validatorFallbackModelId" },
{ provider: "titleSummarizerProvider", modelId: "titleSummarizerModelId" },
{ provider: "titleSummarizerFallbackProvider", modelId: "titleSummarizerFallbackModelId" },
] as const;
it.each(movedProjectModelLanePairs)(
"moved lane $provider/$modelId is DROPPED from project settings (U4 hard-move)",
async ({ provider, modelId }) => {
const patch: Record<string, string> = {};
patch[provider] = "anthropic";
patch[modelId] = "claude-opus-4";
await harness.store().updateSettings(patch);
const settings = await harness.store().getSettings();
expect((settings as any)[provider]).toBeUndefined();
expect((settings as any)[modelId]).toBeUndefined();
const configRaw = await readFile(join(harness.rootDir(), ".fusion", "config.json"), "utf-8");
const config = JSON.parse(configRaw);
expect((config.settings as any)[provider]).toBeUndefined();
expect((config.settings as any)[modelId]).toBeUndefined();
},
);
const globalModelLanePairs = [
// Default baseline
{ provider: "defaultProvider", modelId: "defaultModelId" },
@@ -740,13 +685,11 @@ describe("TaskStore", () => {
planningGlobalModelId: "claude-sonnet-4-5",
});
// U4 hard-move: the per-phase project lanes are dropped; use a remaining
// project-scoped key (defaultProviderOverride) for the project-scope side.
await harness.store().updateSettings({
planningProvider: "anthropic",
planningModelId: "claude-opus-4",
planningFallbackProvider: "openai",
planningFallbackModelId: "gpt-4o-mini",
executionProvider: "google",
executionModelId: "gemini-2.5-pro",
defaultProviderOverride: "anthropic",
defaultModelIdOverride: "claude-opus-4",
});
const { global, project } = await harness.store().getSettingsByScope();
@@ -759,20 +702,15 @@ describe("TaskStore", () => {
expect(global.planningGlobalProvider).toBe("anthropic");
expect(global.planningGlobalModelId).toBe("claude-sonnet-4-5");
// Project scope
expect(project.planningProvider).toBe("anthropic");
expect(project.planningModelId).toBe("claude-opus-4");
expect(project.planningFallbackProvider).toBe("openai");
expect(project.planningFallbackModelId).toBe("gpt-4o-mini");
expect(project.executionProvider).toBe("google");
expect(project.executionModelId).toBe("gemini-2.5-pro");
// Project scope (remaining, non-moved keys)
expect(project.defaultProviderOverride).toBe("anthropic");
expect(project.defaultModelIdOverride).toBe("claude-opus-4");
// Verify no cross-contamination
expect((global as any).planningProvider).toBeUndefined();
expect((global as any).planningFallbackProvider).toBeUndefined();
expect((global as any).executionProvider).toBeUndefined();
// Verify no cross-contamination + moved lanes never resurface in project scope
expect((project as any).planningGlobalProvider).toBeUndefined();
expect((project as any).defaultProvider).toBeUndefined();
expect((project as any).planningProvider).toBeUndefined();
expect((project as any).executionProvider).toBeUndefined();
});
});
@@ -893,24 +831,25 @@ describe("TaskStore", () => {
expect(settings.fallbackModelId).toBe("gpt-4o");
expect(settings.planningGlobalProvider).toBe("google");
expect(settings.planningGlobalModelId).toBe("gemini-2.5-pro");
expect(settings.planningProvider).toBe("anthropic");
expect(settings.planningModelId).toBe("claude-sonnet-4-5");
expect(settings.planningFallbackProvider).toBe("openai");
expect(settings.planningFallbackModelId).toBe("gpt-4o-mini");
expect(settings.executionGlobalProvider).toBe("anthropic");
expect(settings.executionGlobalModelId).toBe("claude-opus-4");
expect(settings.executionProvider).toBe("google");
expect(settings.executionModelId).toBe("gemini-2.5-pro");
expect(settings.validatorProvider).toBe("anthropic");
expect(settings.validatorModelId).toBe("claude-opus-4");
expect(settings.validatorFallbackProvider).toBe("openai");
expect(settings.validatorFallbackModelId).toBe("gpt-4o");
expect(settings.titleSummarizerProvider).toBe("google");
expect(settings.titleSummarizerModelId).toBe("gemini-2.5-pro");
expect(settings.titleSummarizerGlobalProvider).toBe("anthropic");
expect(settings.titleSummarizerGlobalModelId).toBe("claude-haiku");
expect(settings.titleSummarizerFallbackProvider).toBe("anthropic");
expect(settings.titleSummarizerFallbackModelId).toBe("claude-haiku");
// U4 hard-move: the per-phase PROJECT lanes are dropped by updateSettings.
expect(settings.planningProvider).toBeUndefined();
expect(settings.planningModelId).toBeUndefined();
expect(settings.planningFallbackProvider).toBeUndefined();
expect(settings.planningFallbackModelId).toBeUndefined();
expect(settings.executionProvider).toBeUndefined();
expect(settings.executionModelId).toBeUndefined();
expect(settings.validatorProvider).toBeUndefined();
expect(settings.validatorModelId).toBeUndefined();
expect(settings.validatorFallbackProvider).toBeUndefined();
expect(settings.validatorFallbackModelId).toBeUndefined();
expect(settings.titleSummarizerProvider).toBeUndefined();
expect(settings.titleSummarizerModelId).toBeUndefined();
expect(settings.titleSummarizerFallbackProvider).toBeUndefined();
expect(settings.titleSummarizerFallbackModelId).toBeUndefined();
});
});
@@ -932,9 +871,11 @@ describe("TaskStore", () => {
const settings = await harness.store().getSettings();
// Project pair should win
expect(settings.planningProvider).toBe("openai");
expect(settings.planningModelId).toBe("gpt-4o");
// U4 hard-move: project lane no longer persists in project settings; the
// project-vs-global precedence now resolves through workflow effective
// settings (covered by the workflow-settings/migration suites).
expect(settings.planningProvider).toBeUndefined();
expect(settings.planningModelId).toBeUndefined();
// Global should still be readable
expect(settings.planningGlobalProvider).toBe("anthropic");
@@ -996,9 +937,9 @@ describe("TaskStore", () => {
const settings = await harness.store().getSettings();
// Project override should win
expect(settings.executionProvider).toBe("openai");
expect(settings.executionModelId).toBe("gpt-4o");
// U4 hard-move: execution project lane dropped from project settings.
expect(settings.executionProvider).toBeUndefined();
expect(settings.executionModelId).toBeUndefined();
// Global should still be accessible
expect(settings.executionGlobalProvider).toBe("google");
@@ -1050,31 +991,23 @@ describe("TaskStore", () => {
expect(settings.fallbackProvider).toBe("openai");
expect(settings.fallbackModelId).toBe("gpt-4o");
expect(settings.executionProvider).toBe("openai");
expect(settings.executionModelId).toBe("gpt-4o-mini");
// Global lanes stay; U4 hard-move drops every per-phase PROJECT lane.
expect(settings.executionGlobalProvider).toBe("google");
expect(settings.executionGlobalModelId).toBe("gemini-2.5-pro");
expect(settings.planningProvider).toBe("google");
expect(settings.planningModelId).toBe("gemini-2.5-flash");
expect(settings.planningGlobalProvider).toBe("anthropic");
expect(settings.planningGlobalModelId).toBe("claude-opus-4");
expect(settings.planningFallbackProvider).toBe("anthropic");
expect(settings.planningFallbackModelId).toBe("claude-sonnet-4-5");
expect(settings.validatorProvider).toBe("google");
expect(settings.validatorModelId).toBe("gemini-2.5-pro");
expect(settings.validatorGlobalProvider).toBe("openai");
expect(settings.validatorGlobalModelId).toBe("gpt-4-turbo");
expect(settings.validatorFallbackProvider).toBe("anthropic");
expect(settings.validatorFallbackModelId).toBe("claude-opus-4");
expect(settings.titleSummarizerProvider).toBe("openai");
expect(settings.titleSummarizerModelId).toBe("gpt-4o");
expect(settings.titleSummarizerGlobalProvider).toBe("anthropic");
expect(settings.titleSummarizerGlobalModelId).toBe("claude-haiku");
expect(settings.titleSummarizerFallbackProvider).toBe("google");
expect(settings.titleSummarizerFallbackModelId).toBe("gemini-2.5-flash");
expect(settings.executionProvider).toBeUndefined();
expect(settings.planningProvider).toBeUndefined();
expect(settings.planningFallbackProvider).toBeUndefined();
expect(settings.validatorProvider).toBeUndefined();
expect(settings.validatorFallbackProvider).toBeUndefined();
expect(settings.titleSummarizerProvider).toBeUndefined();
expect(settings.titleSummarizerFallbackProvider).toBeUndefined();
});
});
@@ -1096,11 +1029,11 @@ describe("TaskStore", () => {
const settings = await harness.store().getSettings();
// Both should coexist
// Global lane stays; U4 drops the project lane.
expect(settings.executionGlobalProvider).toBe("anthropic");
expect(settings.executionGlobalModelId).toBe("claude-sonnet-4-5");
expect(settings.planningProvider).toBe("openai");
expect(settings.planningModelId).toBe("gpt-4o");
expect(settings.planningProvider).toBeUndefined();
expect(settings.planningModelId).toBeUndefined();
});
it("mixed legacy canonical shapes resolve deterministically", async () => {
@@ -1132,17 +1065,12 @@ describe("TaskStore", () => {
const settings = await harness.store().getSettings();
// Legacy shapes preserved
expect(settings.planningProvider).toBe("anthropic");
expect(settings.planningModelId).toBe("claude-sonnet-4-5");
expect(settings.validatorProvider).toBe("openai");
expect(settings.validatorModelId).toBe("gpt-4o");
expect(settings.titleSummarizerProvider).toBe("google");
expect(settings.titleSummarizerModelId).toBe("gemini-2.5-pro");
// Canonical shapes preserved
expect(settings.executionProvider).toBe("anthropic");
expect(settings.executionModelId).toBe("claude-opus-4");
// U4 hard-move: all per-phase PROJECT lanes are dropped from project settings.
expect(settings.planningProvider).toBeUndefined();
expect(settings.validatorProvider).toBeUndefined();
expect(settings.titleSummarizerProvider).toBeUndefined();
expect(settings.executionProvider).toBeUndefined();
expect(settings.executionModelId).toBeUndefined();
// Global canonical shapes preserved
expect(settings.planningGlobalProvider).toBe("anthropic");
@@ -1151,66 +1079,43 @@ describe("TaskStore", () => {
expect(settings.validatorGlobalModelId).toBe("gpt-4o-mini");
});
it("legacy format: planningProvider without planningModelId is valid partial pair", async () => {
await harness.store().updateSettings({
planningProvider: "anthropic",
// planningModelId intentionally omitted
});
// U4 hard-move: partial/full PROJECT lane writes are dropped — they no longer
// persist in project settings. (Workflow-setting partial-pair semantics are
// covered by the workflow-settings suite.)
it("moved project lane: planningProvider without planningModelId is dropped", async () => {
await harness.store().updateSettings({ planningProvider: "anthropic" });
const settings = await harness.store().getSettings();
expect(settings.planningProvider).toBe("anthropic");
expect(settings.planningProvider).toBeUndefined();
expect(settings.planningModelId).toBeUndefined();
});
it("legacy format: validatorProvider without validatorModelId is valid partial pair", async () => {
await harness.store().updateSettings({
validatorProvider: "openai",
// validatorModelId intentionally omitted
});
it("moved project lane: validatorProvider without validatorModelId is dropped", async () => {
await harness.store().updateSettings({ validatorProvider: "openai" });
const settings = await harness.store().getSettings();
expect(settings.validatorProvider).toBe("openai");
expect(settings.validatorProvider).toBeUndefined();
expect(settings.validatorModelId).toBeUndefined();
});
it("canonical format: executionProvider without executionModelId is valid partial pair", async () => {
await harness.store().updateSettings({
executionProvider: "google",
// executionModelId intentionally omitted
});
it("moved project lane: executionProvider without executionModelId is dropped", async () => {
await harness.store().updateSettings({ executionProvider: "google" });
const settings = await harness.store().getSettings();
expect(settings.executionProvider).toBe("google");
expect(settings.executionProvider).toBeUndefined();
expect(settings.executionModelId).toBeUndefined();
});
it("mixed: full pair + partial pair coexist in same lane", async () => {
// Set full planning pair
it("moved project lanes: full + partial writes all drop from project settings", async () => {
await harness.store().updateSettings({
planningProvider: "anthropic",
planningModelId: "claude-sonnet-4-5",
});
// Set partial validator pair (only provider)
await harness.store().updateSettings({
validatorProvider: "openai",
// validatorModelId intentionally omitted
});
// Set full execution pair
await harness.store().updateSettings({
executionProvider: "google",
executionModelId: "gemini-2.5-pro",
});
const settings = await harness.store().getSettings();
expect(settings.planningProvider).toBe("anthropic");
expect(settings.planningModelId).toBe("claude-sonnet-4-5");
expect(settings.validatorProvider).toBe("openai");
expect(settings.validatorModelId).toBeUndefined();
expect(settings.executionProvider).toBe("google");
expect(settings.executionModelId).toBe("gemini-2.5-pro");
expect(settings.planningProvider).toBeUndefined();
expect(settings.validatorProvider).toBeUndefined();
expect(settings.executionProvider).toBeUndefined();
});
});
@@ -1222,9 +1127,11 @@ describe("TaskStore", () => {
planningModelId: "claude-sonnet-4-5",
});
// U4 hard-move: the project lane never persists (dropped on write), so it is
// already undefined; a subsequent null-clear is a harmless no-op.
let settings = await harness.store().getSettings();
expect(settings.planningProvider).toBe("anthropic");
expect(settings.planningModelId).toBe("claude-sonnet-4-5");
expect(settings.planningProvider).toBeUndefined();
expect(settings.planningModelId).toBeUndefined();
// Clear with null
// @ts-expect-error - null is intentionally used to clear field (null-as-delete)
@@ -1392,8 +1299,10 @@ describe("TaskStore", () => {
await harness.store().updateSettings({ planningProvider: null });
const settings = await harness.store().getSettings();
// U4 hard-move: both moved-lane fields are dropped on the initial write, so
// neither persists in project settings.
expect(settings.planningProvider).toBeUndefined();
expect(settings.planningModelId).toBe("claude-sonnet-4-5"); // Preserved
expect(settings.planningModelId).toBeUndefined();
});
it("cleared model settings fall back to undefined (not default values)", async () => {
@@ -1426,26 +1335,23 @@ describe("TaskStore", () => {
expect(settings.defaultModelId).toBe("claude-sonnet-4-5");
});
it("cleared model settings removed from persisted config", async () => {
it("moved model settings are never persisted to config (dropped on write)", async () => {
await harness.store().updateSettings({
planningProvider: "anthropic",
planningModelId: "claude-sonnet-4-5",
});
// Verify persisted
let configRaw = await readFile(join(harness.rootDir(), ".fusion", "config.json"), "utf-8");
let config = JSON.parse(configRaw);
expect((config.settings as any).planningProvider).toBe("anthropic");
// U4 hard-move: never persisted to project config in the first place.
let config = JSON.parse(await readFile(join(harness.rootDir(), ".fusion", "config.json"), "utf-8"));
expect((config.settings as any).planningProvider).toBeUndefined();
// Clear with null
// Null-clear is a harmless no-op; still absent.
// @ts-expect-error - null is intentionally used to clear field (null-as-delete)
await harness.store().updateSettings({ planningProvider: null });
// @ts-expect-error - null is intentionally used to clear field (null-as-delete)
await harness.store().updateSettings({ planningModelId: null });
// Verify removed from persisted config
configRaw = await readFile(join(harness.rootDir(), ".fusion", "config.json"), "utf-8");
config = JSON.parse(configRaw);
config = JSON.parse(await readFile(join(harness.rootDir(), ".fusion", "config.json"), "utf-8"));
expect((config.settings as any).planningProvider).toBeUndefined();
expect((config.settings as any).planningModelId).toBeUndefined();
});

View File

@@ -0,0 +1,111 @@
import { describe, it, expect } from "vitest";
import { stripApprovalBypassFlags } from "../workflow-ir.js";
import type { WorkflowIr } from "../workflow-ir-types.js";
/**
* P0 security helper: removes the CLI-approval-bypass flags
* (`cliSkipApproval`/`autoApprove`) from every node config, recursing into
* foreach `config.template.nodes` at any nesting depth.
*/
describe("stripApprovalBypassFlags", () => {
it("removes both flags from a top-level node config and reports stripped:true", () => {
const ir = {
version: "v1",
name: "wf",
nodes: [{ id: "n1", kind: "prompt", config: { cliSkipApproval: true, autoApprove: true, name: "x" } }],
edges: [],
} as unknown as WorkflowIr;
const { ir: out, stripped } = stripApprovalBypassFlags(ir);
expect(stripped).toBe(true);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const cfg = (out as any).nodes[0].config;
expect(cfg.cliSkipApproval).toBeUndefined();
expect(cfg.autoApprove).toBeUndefined();
expect(cfg.name).toBe("x"); // unrelated config preserved
});
it("strips nested foreach-in-foreach template nodes (arbitrary depth)", () => {
const ir = {
version: "v1",
name: "wf",
nodes: [
{
id: "outer",
kind: "foreach",
config: {
template: {
nodes: [
{
id: "inner-foreach",
kind: "foreach",
config: {
template: {
nodes: [
{ id: "deep", kind: "step-execute", config: { autoApprove: true } },
],
edges: [],
},
},
},
],
edges: [],
},
},
},
],
edges: [],
} as unknown as WorkflowIr;
const { stripped } = stripApprovalBypassFlags(ir);
expect(stripped).toBe(true);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const deep = (ir as any).nodes[0].config.template.nodes[0].config.template.nodes[0];
expect(deep.config.autoApprove).toBeUndefined();
});
it("returns stripped:false when no flags present", () => {
const ir = {
version: "v1",
name: "wf",
nodes: [{ id: "n1", kind: "prompt", config: { name: "x" } }],
edges: [],
} as unknown as WorkflowIr;
expect(stripApprovalBypassFlags(ir).stripped).toBe(false);
});
it("tolerates a non-array nodes field", () => {
const ir = { version: "v1", name: "wf" } as unknown as WorkflowIr;
expect(stripApprovalBypassFlags(ir).stripped).toBe(false);
});
it("tolerates non-object entries in nodes (untrusted input)", () => {
const ir = {
version: "v1",
name: "wf",
nodes: [null, "bogus", 42, { id: "n1", kind: "prompt", config: { cliSkipApproval: true } }],
edges: [],
} as unknown as WorkflowIr;
const { ir: out, stripped } = stripApprovalBypassFlags(ir);
expect(stripped).toBe(true);
expect((out as any).nodes[3].config.cliSkipApproval).toBeUndefined();
});
it("tolerates non-object entries in nested template.nodes", () => {
const ir = {
version: "v1",
name: "wf",
nodes: [
{
id: "fe",
kind: "foreach",
config: {
template: { nodes: [null, 0, "x", { id: "inner", kind: "prompt", config: { autoApprove: true } }] },
},
},
],
edges: [],
} as unknown as WorkflowIr;
const { ir: out, stripped } = stripApprovalBypassFlags(ir);
expect(stripped).toBe(true);
expect((out as any).nodes[0].config.template.nodes[3].config.autoApprove).toBeUndefined();
});
});

View File

@@ -247,8 +247,11 @@ describe("task creation hook", () => {
summarizeTitleMock.mockResolvedValue("Auto Generated Title");
setTaskCreatedHook(hook);
await store.updateSettings({
autoSummarizeTitles: true,
// autoSummarizeTitles stays a project setting; the summarizer model lanes
// MOVED to workflow settings (U4/KTD-7), so write them to the project's
// default workflow (builtin:coding) value store.
await store.updateSettings({ autoSummarizeTitles: true });
await store.updateWorkflowSettingValues("builtin:coding", store.getWorkflowSettingsProjectId(), {
titleSummarizerProvider: "openai",
titleSummarizerModelId: "gpt-5-mini",
});

View File

@@ -51,7 +51,7 @@ describe("TaskStore task documents", () => {
expect(tableNames.has("task_documents")).toBe(true);
expect(tableNames.has("task_document_revisions")).toBe(true);
expect(db.getSchemaVersion()).toBe(108);
expect(db.getSchemaVersion()).toBe(113);
const index = db
.prepare(

View File

@@ -180,4 +180,132 @@ describe("TaskStore workflow definitions (U1)", () => {
const c = await store.createWorkflowDefinition({ name: "C", ir: makeIr() });
expect(c.id).toBe("WF-003");
});
// ── kind discriminator (U1, R6/KTD-1) ────────────────────────────────
// A pure-v1 start→node→end fragment IR.
function fragmentIr(): WorkflowIr {
return {
version: "v1",
name: "frag",
nodes: [
{ id: "start", kind: "start" },
{ id: "step-1", kind: "prompt", config: { name: "Doc", gateMode: "advisory", prompt: "doc it" } },
{ id: "end", kind: "end" },
],
edges: [
{ from: "start", to: "step-1", condition: "success" },
{ from: "step-1", to: "end", condition: "success" },
],
};
}
it("defaults a created workflow to kind 'workflow'", async () => {
const created = await store.createWorkflowDefinition({ name: "W", ir: makeIr() });
expect(created.kind).toBe("workflow");
expect((await store.getWorkflowDefinition(created.id))?.kind).toBe("workflow");
});
it("persists and round-trips kind 'fragment' (INSERT includes kind)", async () => {
const created = await store.createWorkflowDefinition({ name: "Frag", ir: fragmentIr(), kind: "fragment" });
expect(created.kind).toBe("fragment");
// Raw column persisted.
const raw = (store as any).db.prepare("SELECT kind FROM workflows WHERE id = ?").get(created.id) as { kind: string };
expect(raw.kind).toBe("fragment");
// Reload.
expect((await store.getWorkflowDefinition(created.id))?.kind).toBe("fragment");
});
it("preserves kind across updateWorkflowDefinition", async () => {
const created = await store.createWorkflowDefinition({ name: "Frag", ir: fragmentIr(), kind: "fragment" });
const updated = await store.updateWorkflowDefinition(created.id, { description: "edited" });
expect(updated.kind).toBe("fragment");
expect((await store.getWorkflowDefinition(created.id))?.kind).toBe("fragment");
});
it("listWorkflowDefinitions({kind:'fragment'}) returns only fragments", async () => {
await store.createWorkflowDefinition({ name: "W1", ir: makeIr() });
const frag = await store.createWorkflowDefinition({ name: "F1", ir: fragmentIr(), kind: "fragment" });
const fragments = await store.listWorkflowDefinitions({ kind: "fragment" });
expect(fragments.map((w) => w.id)).toEqual([frag.id]);
expect(fragments.every((w) => w.kind === "fragment")).toBe(true);
});
it("built-in list entries are kind 'workflow'", async () => {
const all = await store.listWorkflowDefinitions();
const builtins = all.filter((w) => isBuiltinWorkflowId(w.id));
expect(builtins.length).toBeGreaterThan(0);
expect(builtins.every((w) => w.kind === "workflow")).toBe(true);
// The workflow filter includes built-ins; the fragment filter excludes them.
expect((await store.listWorkflowDefinitions({ kind: "workflow" })).some((w) => isBuiltinWorkflowId(w.id))).toBe(true);
expect((await store.listWorkflowDefinitions({ kind: "fragment" })).some((w) => isBuiltinWorkflowId(w.id))).toBe(false);
});
it("cache regression: filtered then unfiltered (and reverse) are both correct", async () => {
await store.createWorkflowDefinition({ name: "W1", ir: makeIr() });
const frag = await store.createWorkflowDefinition({ name: "F1", ir: fragmentIr(), kind: "fragment" });
// filtered → unfiltered
const f1 = await store.listWorkflowDefinitions({ kind: "fragment" });
expect(f1.map((w) => w.id)).toEqual([frag.id]);
const allAfterFiltered = await store.listWorkflowDefinitions();
expect(allAfterFiltered.filter((w) => !isBuiltinWorkflowId(w.id)).map((w) => w.kind).sort()).toEqual([
"fragment",
"workflow",
]);
// unfiltered → filtered (cache already populated by the unfiltered call)
const f2 = await store.listWorkflowDefinitions({ kind: "fragment" });
expect(f2.map((w) => w.id)).toEqual([frag.id]);
const w2 = await store.listWorkflowDefinitions({ kind: "workflow" });
expect(w2.filter((w) => !isBuiltinWorkflowId(w.id)).every((w) => w.kind === "workflow")).toBe(true);
});
it("a fragment IR survives downgradeIrToV1IfPure unchanged (persists as v1)", async () => {
const created = await store.createWorkflowDefinition({ name: "Frag", ir: fragmentIr(), kind: "fragment" });
const raw = (store as any).db.prepare("SELECT ir FROM workflows WHERE id = ?").get(created.id) as { ir: string };
expect(JSON.parse(raw.ir).version).toBe("v1");
});
it("selectTaskWorkflow rejects a fragment id with a clear error", async () => {
const frag = await store.createWorkflowDefinition({ name: "Frag", ir: fragmentIr(), kind: "fragment" });
// Create a task to select against.
const task = await store.createTask({ description: "t" });
await expect(store.selectTaskWorkflow(task.id, frag.id)).rejects.toThrow(/fragment/i);
});
it("setDefaultWorkflowId rejects a fragment id at the write boundary", async () => {
const frag = await store.createWorkflowDefinition({ name: "Frag", ir: fragmentIr(), kind: "fragment" });
await expect(store.setDefaultWorkflowId(frag.id)).rejects.toThrow(/fragment/i);
expect(await store.getDefaultWorkflowId()).toBeUndefined();
});
it("setDefaultWorkflowId accepts a real workflow and clears with null", async () => {
const wf = await store.createWorkflowDefinition({ name: "W", ir: makeIr() });
await store.setDefaultWorkflowId(wf.id);
expect(await store.getDefaultWorkflowId()).toBe(wf.id);
await store.setDefaultWorkflowId(null);
expect(await store.getDefaultWorkflowId()).toBeUndefined();
});
it("createTaskWithReservedId honors an explicit workflowId (precedence over default)", async () => {
const def = await store.createWorkflowDefinition({ name: "Explicit", ir: makeIr() });
const task = await store.createTaskWithReservedId(
{ description: "t", workflowId: def.id },
{ taskId: "task-explicit-wf" },
);
const sel = store.getTaskWorkflowSelection(task.id);
expect(sel?.workflowId).toBe(def.id);
});
it("createTaskWithReservedId treats workflowId:null as explicit opt-out", async () => {
const def = await store.createWorkflowDefinition({ name: "Def", ir: makeIr() });
await store.setDefaultWorkflowId(def.id);
const task = await store.createTaskWithReservedId(
{ description: "t", workflowId: null },
{ taskId: "task-optout-wf" },
);
const sel = store.getTaskWorkflowSelection(task.id);
expect(sel?.workflowId ?? undefined).toBeUndefined();
});
});

View File

@@ -0,0 +1,224 @@
// @vitest-environment node
//
// column-agent plan U1 — IR schema, validation, and parity registration for the
// per-column permanent-agent binding (`WorkflowIrColumn.agent`).
//
// Proves:
// - a column `agent` binding parses + round-trips; absent field parses as today.
// - typed validation errors for empty agentId / missing mode / unknown mode.
// - v1 upgrade synthesizes columns with NO `agent` field (absent, not null).
// - a template-subgraph node with a dangling `column` is a typed error.
// - the default workflow IR round-trips byte-identically; a graph carrying a
// column agent is flagged non-default (forces v2 — KTD-1/R9).
// - a removed binding omits the `agent` key entirely on serialization.
import { describe, expect, it } from "vitest";
import {
parseWorkflowIr,
serializeWorkflowIr,
downgradeIrToV1IfPure,
WorkflowIrError,
} from "../workflow-ir.js";
import { BUILTIN_CODING_WORKFLOW_IR } from "../builtin-coding-workflow-ir.js";
import type {
WorkflowColumnAgent,
WorkflowIrEdge,
WorkflowIrNode,
WorkflowIrV1,
WorkflowIrV2,
} from "../workflow-ir-types.js";
const baseColumns: WorkflowIrV2["columns"] = [
{ id: "todo", name: "todo", traits: [] },
{ id: "review", name: "review", traits: [] },
];
function v2(
columns: WorkflowIrV2["columns"],
nodes: WorkflowIrNode[],
edges: WorkflowIrEdge[],
extra: Partial<WorkflowIrV2> = {},
): WorkflowIrV2 {
return { version: "v2", name: "test", columns, nodes, edges, ...extra };
}
/** start → work → end, work in the second column. */
function simpleGraph(reviewAgent?: WorkflowColumnAgent): WorkflowIrV2 {
const columns: WorkflowIrV2["columns"] = [
{ id: "todo", name: "todo", traits: [] },
{ id: "review", name: "review", traits: [], ...(reviewAgent ? { agent: reviewAgent } : {}) },
];
return v2(
columns,
[
{ id: "start", kind: "start", column: "todo" },
{ id: "work", kind: "prompt", column: "review", config: { prompt: "do" } },
{ id: "end", kind: "end", column: "review" },
],
[
{ from: "start", to: "work" },
{ from: "work", to: "end" },
],
);
}
describe("column-agent IR schema + validation (U1)", () => {
it("parses and round-trips a column with a defer agent binding", () => {
const ir = simpleGraph({ agentId: "agent-001", mode: "defer" });
const parsed = parseWorkflowIr(serializeWorkflowIr(ir)) as WorkflowIrV2;
const col = parsed.columns.find((c) => c.id === "review")!;
expect(col.agent).toEqual({ agentId: "agent-001", mode: "defer" });
});
it("parses identically to today when no agent field is present", () => {
const ir = simpleGraph();
const parsed = parseWorkflowIr(serializeWorkflowIr(ir)) as WorkflowIrV2;
const col = parsed.columns.find((c) => c.id === "review")!;
expect("agent" in col).toBe(false);
});
it("rejects an empty agentId (typed error naming the column)", () => {
const ir = simpleGraph({ agentId: "", mode: "defer" });
expect(() => parseWorkflowIr(ir)).toThrow(WorkflowIrError);
expect(() => parseWorkflowIr(ir)).toThrow(/column 'review'.*non-empty agentId/);
});
it("rejects a missing mode", () => {
const ir = simpleGraph({ agentId: "agent-001" } as unknown as WorkflowColumnAgent);
expect(() => parseWorkflowIr(ir)).toThrow(/column 'review'.*mode must be/);
});
it("rejects an unknown mode value", () => {
const ir = simpleGraph({ agentId: "agent-001", mode: "always" as "defer" });
expect(() => parseWorkflowIr(ir)).toThrow(/column 'review'.*mode must be/);
});
it("v1 upgrade synthesizes columns with no agent field (absent, not null)", () => {
const v1: WorkflowIrV1 = {
version: "v1",
name: "legacy",
nodes: [
{ id: "start", kind: "start" },
{ id: "p", kind: "prompt", config: { prompt: "hi" } },
{ id: "end", kind: "end" },
],
edges: [
{ from: "start", to: "p" },
{ from: "p", to: "end" },
],
};
const upgraded = parseWorkflowIr(v1) as WorkflowIrV2;
for (const col of upgraded.columns) {
expect("agent" in col).toBe(false);
}
// And serialization carries no `agent` key at all.
expect(serializeWorkflowIr(upgraded)).not.toContain('"agent"');
});
it("rejects a foreach template node whose column does not resolve (typed, names node)", () => {
const ir = v2(
baseColumns,
[
{ id: "start", kind: "start" },
{
id: "ps",
kind: "parse-steps",
config: { artifact: "PROMPT.md", parser: "step-headings" },
},
{
id: "fe",
kind: "foreach",
config: {
source: "task-steps",
template: {
nodes: [
// Dangling column reference on a template node.
{ id: "se", kind: "prompt", column: "nope", config: { seam: "step-execute" } },
{ id: "rev", kind: "step-review", config: { type: "code" } },
{ id: "exit", kind: "prompt" },
],
edges: [
{ from: "se", to: "rev" },
{ from: "rev", to: "exit", condition: "outcome:approve" },
{ from: "rev", to: "se", condition: "outcome:revise", kind: "rework" },
],
},
},
},
{ id: "end", kind: "end" },
],
[
{ from: "start", to: "ps" },
{ from: "ps", to: "fe" },
{ from: "fe", to: "end" },
],
);
expect(() => parseWorkflowIr(ir)).toThrow(/node 'se' references undefined column 'nope'/);
});
it("accepts a foreach template node whose column resolves to a declared column", () => {
const ir = v2(
baseColumns,
[
{ id: "start", kind: "start" },
{
id: "ps",
kind: "parse-steps",
config: { artifact: "PROMPT.md", parser: "step-headings" },
},
{
id: "fe",
kind: "foreach",
column: "review",
config: {
source: "task-steps",
template: {
nodes: [
{ id: "se", kind: "prompt", column: "todo", config: { seam: "step-execute" } },
{ id: "rev", kind: "step-review", config: { type: "code" } },
{ id: "exit", kind: "prompt" },
],
edges: [
{ from: "se", to: "rev" },
{ from: "rev", to: "exit", condition: "outcome:approve" },
{ from: "rev", to: "se", condition: "outcome:revise", kind: "rework" },
],
},
},
},
{ id: "end", kind: "end" },
],
[
{ from: "start", to: "ps" },
{ from: "ps", to: "fe" },
{ from: "fe", to: "end" },
],
);
expect(() => parseWorkflowIr(ir)).not.toThrow();
});
});
describe("column-agent parity registration (U1, R9)", () => {
it("default workflow IR round-trips byte-identically", () => {
const serialized = serializeWorkflowIr(BUILTIN_CODING_WORKFLOW_IR);
const reparsed = parseWorkflowIr(serialized);
expect(serializeWorkflowIr(reparsed)).toBe(serialized);
});
it("a graph carrying a column agent is flagged non-default (forces v2)", () => {
// A pure default-shaped graph downgrades to v1; adding an agent binding must
// keep it v2 (the v2-only-feature gate registers the field).
const bound = simpleGraph({ agentId: "agent-001", mode: "override" });
expect(downgradeIrToV1IfPure(bound).version).toBe("v2");
});
it("serialization of a column whose binding was removed omits the key entirely", () => {
const bound = simpleGraph({ agentId: "agent-001", mode: "defer" });
const col = bound.columns.find((c) => c.id === "review")!;
delete col.agent;
const serialized = serializeWorkflowIr(bound);
expect(serialized).not.toContain('"agent"');
const reparsed = parseWorkflowIr(serialized) as WorkflowIrV2;
expect("agent" in reparsed.columns.find((c) => c.id === "review")!).toBe(false);
});
});

View File

@@ -0,0 +1,264 @@
import { describe, expect, it } from "vitest";
import {
parseWorkflowIr,
serializeWorkflowIr,
downgradeIrToV1IfPure,
WorkflowIrError,
} from "../workflow-ir.js";
import { BUILTIN_CODING_WORKFLOW_IR } from "../builtin-coding-workflow-ir.js";
import { BUILTIN_WORKFLOW_SETTINGS } from "../builtin-workflow-settings.js";
import { DEFAULT_PROJECT_SETTINGS } from "../types.js";
import type {
WorkflowIrV2,
WorkflowIrNode,
WorkflowSettingDefinition,
} from "../workflow-ir-types.js";
const startEnd: WorkflowIrNode[] = [
{ id: "start", kind: "start" },
{ id: "end", kind: "end" },
];
function withSettings(settings: WorkflowSettingDefinition[]): WorkflowIrV2 {
return {
version: "v2",
name: "test",
columns: [],
nodes: startEnd,
edges: [{ from: "start", to: "end" }],
settings,
};
}
describe("parseWorkflowIr — workflow settings declarations (U1)", () => {
it("parses and round-trips a valid declaration of each type", () => {
const settings: WorkflowSettingDefinition[] = [
{ id: "s-string", name: "S", type: "string", default: "x" },
{ id: "s-text", name: "T", type: "text", default: "long" },
{ id: "s-number", name: "N", type: "number", default: 42 },
{ id: "s-boolean", name: "B", type: "boolean", default: true },
{
id: "s-enum",
name: "E",
type: "enum",
default: "a",
options: [
{ value: "a", label: "A" },
{ value: "b", label: "B" },
],
},
{
id: "s-multi",
name: "M",
type: "multi-enum",
default: ["a"],
options: [
{ value: "a", label: "A" },
{ value: "b", label: "B" },
],
render: { widget: "chips" },
},
];
const parsed = parseWorkflowIr(withSettings(settings)) as WorkflowIrV2;
expect(parsed.settings).toEqual(settings);
const reparsed = parseWorkflowIr(serializeWorkflowIr(parsed));
expect(reparsed).toEqual(parsed);
});
it("allows a declaration with no default and a description", () => {
const parsed = parseWorkflowIr(
withSettings([
{ id: "lane", name: "Lane", type: "string", description: "a model lane" },
]),
) as WorkflowIrV2;
expect(parsed.settings?.[0].default).toBeUndefined();
});
it("rejects duplicate setting ids", () => {
expect(() =>
parseWorkflowIr(
withSettings([
{ id: "dup", name: "A", type: "string" },
{ id: "dup", name: "B", type: "string" },
]),
),
).toThrow(WorkflowIrError);
});
it("rejects an empty id", () => {
expect(() =>
parseWorkflowIr(withSettings([{ id: "", name: "A", type: "string" }])),
).toThrow(WorkflowIrError);
});
it("rejects an unknown type", () => {
expect(() =>
parseWorkflowIr(
withSettings([{ id: "x", name: "A", type: "date" as never }]),
),
).toThrow(WorkflowIrError);
});
it("rejects an enum without options", () => {
expect(() =>
parseWorkflowIr(withSettings([{ id: "x", name: "A", type: "enum" }])),
).toThrow(WorkflowIrError);
});
it("rejects options on a non-enum type", () => {
expect(() =>
parseWorkflowIr(
withSettings([
{ id: "x", name: "A", type: "number", options: [{ value: "a", label: "A" }] },
]),
),
).toThrow(WorkflowIrError);
});
it("rejects duplicate option values", () => {
expect(() =>
parseWorkflowIr(
withSettings([
{
id: "x",
name: "A",
type: "enum",
options: [
{ value: "a", label: "A" },
{ value: "a", label: "A2" },
],
},
]),
),
).toThrow(WorkflowIrError);
});
it("rejects a disallowed render widget", () => {
expect(() =>
parseWorkflowIr(
withSettings([
{ id: "x", name: "A", type: "string", render: { widget: "slider" as never } },
]),
),
).toThrow(WorkflowIrError);
});
it("rejects a default violating its own type (number with string)", () => {
expect(() =>
parseWorkflowIr(
withSettings([{ id: "x", name: "A", type: "number", default: "x" }]),
),
).toThrow(WorkflowIrError);
});
it("rejects a default violating boolean type", () => {
expect(() =>
parseWorkflowIr(
withSettings([{ id: "x", name: "A", type: "boolean", default: "true" }]),
),
).toThrow(WorkflowIrError);
});
it("rejects an enum default not among options", () => {
expect(() =>
parseWorkflowIr(
withSettings([
{
id: "x",
name: "A",
type: "enum",
default: "c",
options: [
{ value: "a", label: "A" },
{ value: "b", label: "B" },
],
},
]),
),
).toThrow(WorkflowIrError);
});
it("rejects a multi-enum default containing an unknown option", () => {
expect(() =>
parseWorkflowIr(
withSettings([
{
id: "x",
name: "A",
type: "multi-enum",
default: ["a", "c"],
options: [
{ value: "a", label: "A" },
{ value: "b", label: "B" },
],
},
]),
),
).toThrow(WorkflowIrError);
});
it("does not downgrade an IR with settings present to v1", () => {
const parsed = parseWorkflowIr(
withSettings([{ id: "x", name: "A", type: "string", default: "v" }]),
);
const down = downgradeIrToV1IfPure(parsed);
expect(down.version).toBe("v2");
});
});
describe("built-in workflow settings parity anchor (U1, R4)", () => {
it("the built-in coding workflow declares the full moved-key catalog", () => {
const builtin = BUILTIN_CODING_WORKFLOW_IR as WorkflowIrV2;
const declaredIds = new Set((builtin.settings ?? []).map((s) => s.id));
for (const setting of BUILTIN_WORKFLOW_SETTINGS) {
expect(declaredIds.has(setting.id)).toBe(true);
}
expect(builtin.settings).toEqual(BUILTIN_WORKFLOW_SETTINGS);
});
it("the moved-key catalog has left DEFAULT_PROJECT_SETTINGS (U4 hard-move) and pins its legacy defaults", () => {
const legacy = DEFAULT_PROJECT_SETTINGS as Record<string, unknown>;
// Post-U4 hard-move: every catalog key has been REMOVED from
// DEFAULT_PROJECT_SETTINGS (the type-vs-schema split keeps the type field but
// drops the default literal), so the legacy object no longer carries them.
for (const setting of BUILTIN_WORKFLOW_SETTINGS) {
expect(Object.prototype.hasOwnProperty.call(legacy, setting.id)).toBe(false);
}
// The declaration defaults are now the single source of truth; pin the legacy
// values explicitly so they can never silently drift from what they were when
// they lived in DEFAULT_PROJECT_SETTINGS.
const expectedDefaults: Record<string, unknown> = {
workflowStepTimeoutMs: 360_000,
workflowStepScopeEnforcement: "block",
planOnlyScopeLeakEnforcement: "warn",
workflowRevisionForkOnScopeMismatch: true,
strictScopeEnforcement: false,
runStepsInNewSessions: false,
maxParallelSteps: 2,
buildRetryCount: 0,
verificationFixRetries: 3,
maxPostReviewFixes: 1,
requirePrApproval: false,
requirePlanApproval: false,
reviewHandoffPolicy: "disabled",
maxReviewerContextRetries: 2,
maxReviewerFallbackRetries: 2,
reflectionEnabled: false,
// Per-phase model lanes have undefined legacy defaults → declaration omits default.
};
for (const setting of BUILTIN_WORKFLOW_SETTINGS) {
if (Object.prototype.hasOwnProperty.call(expectedDefaults, setting.id)) {
expect(setting.default).toStrictEqual(expectedDefaults[setting.id]);
} else {
// Model-lane keys: no default.
expect(setting.default).toBeUndefined();
}
}
});
it("buildTimeoutMs is NOT in the catalog and stays a plain project setting", () => {
const declaredIds = new Set(BUILTIN_WORKFLOW_SETTINGS.map((s) => s.id));
expect(declaredIds.has("buildTimeoutMs")).toBe(false);
expect((DEFAULT_PROJECT_SETTINGS as Record<string, unknown>).buildTimeoutMs).toBe(300_000);
});
});

View File

@@ -23,6 +23,23 @@ function linearIr(): WorkflowIr {
};
}
/** A single-node fragment IR (start → one node → end). */
function fragmentIr(): WorkflowIr {
return {
version: "v1",
name: "frag",
nodes: [
{ id: "start", kind: "start" },
{ id: "step-1", kind: "prompt", config: { name: "Doc", prompt: "doc it" } },
{ id: "end", kind: "end" },
],
edges: [
{ from: "start", to: "step-1", condition: "success" },
{ from: "step-1", to: "end", condition: "success" },
],
};
}
function branchingIr(): WorkflowIr {
return {
version: "v1",
@@ -172,4 +189,71 @@ describe("TaskStore workflow selection (U3)", () => {
await store.setDefaultWorkflowId(null);
expect(await store.getDefaultWorkflowId()).toBeUndefined();
});
// U6/R3/KTD-4: create-time `workflowId` materializes the selection atomically.
describe("create-time workflowId (U6/R3)", () => {
it("materializes enabledWorkflowSteps atomically when workflowId is given", async () => {
const wf = await store.createWorkflowDefinition({ name: "Pick", ir: linearIr() });
const task = await store.createTask({ description: "with workflow", workflowId: wf.id });
// Reading the task right after create observes the populated steps — no
// intermediate empty state visible to the executor.
const detail = await store.getTask(task.id);
expect(detail.enabledWorkflowSteps).toHaveLength(2);
expect(store.getTaskWorkflowSelection(task.id)?.workflowId).toBe(wf.id);
expect(store.getTaskWorkflowSelection(task.id)?.stepIds).toEqual(detail.enabledWorkflowSteps);
});
it("explicit workflowId overrides the project default", async () => {
const def = await store.createWorkflowDefinition({ name: "Default", ir: linearIr() });
const chosen = await store.createWorkflowDefinition({ name: "Chosen", ir: linearIr() });
await store.setDefaultWorkflowId(def.id);
const task = await store.createTask({ description: "override default", workflowId: chosen.id });
expect(store.getTaskWorkflowSelection(task.id)?.workflowId).toBe(chosen.id);
});
it("workflowId: null skips default materialization (explicit No workflow)", async () => {
const def = await store.createWorkflowDefinition({ name: "Default", ir: linearIr() });
await store.setDefaultWorkflowId(def.id);
const task = await store.createTask({ description: "no workflow", workflowId: null });
const detail = await store.getTask(task.id);
expect(detail.enabledWorkflowSteps ?? []).toHaveLength(0);
expect(store.getTaskWorkflowSelection(task.id)).toBeUndefined();
});
it("undefined workflowId still inherits the project default (unchanged)", async () => {
const def = await store.createWorkflowDefinition({ name: "Default", ir: linearIr() });
await store.setDefaultWorkflowId(def.id);
const task = await store.createTask({ description: "inherit" });
const detail = await store.getTask(task.id);
expect(detail.enabledWorkflowSteps).toHaveLength(2);
expect(store.getTaskWorkflowSelection(task.id)?.workflowId).toBe(def.id);
});
it("rejects a fragment id before creating the task row", async () => {
const frag = await store.createWorkflowDefinition({ name: "Frag", ir: fragmentIr(), kind: "fragment" });
const before = (await store.listTasks({ includeArchived: true })).length;
await expect(
store.createTask({ description: "frag pick", workflowId: frag.id }),
).rejects.toThrow(/fragment/i);
const after = (await store.listTasks({ includeArchived: true })).length;
expect(after).toBe(before);
});
it("rejects an unknown workflow id before creating the task row", async () => {
const before = (await store.listTasks({ includeArchived: true })).length;
await expect(
store.createTask({ description: "bad pick", workflowId: "WF-404" }),
).rejects.toThrow(/not found/i);
const after = (await store.listTasks({ includeArchived: true })).length;
expect(after).toBe(before);
});
});
});

View File

@@ -0,0 +1,336 @@
/**
* U10 — End-to-end characterization of the workflow-settings hard-move (R3, R6, R7).
*
* This is the parity-closure suite: it proves the whole move is behavior-preserving
* across one deterministic journey, with NO real polling and NO slow work (in-memory
* timers are unnecessary — every step is synchronous store/resolver work; the store
* is opened on a temp dir with a disk-backed DB so the raw `config.settings` row and
* the global settings file survive across the seeding/migration steps, exactly as the
* settings-migration suite does).
*
* The journey (single test):
* a. Build a PRE-migration store state: a project with customized MOVED keys
* (`workflowStepTimeoutMs`, `requirePrApproval`, `executionProvider`) written
* into the RAW `config.settings` row the way a v108-era store would hold them —
* BEFORE the migration runner fires (marker cleared, raw seeded). Pattern reused
* from settings-migration.test.ts (`seedRawProjectSettings` + `clearMarker`).
* b. Run the migration → assert effective values via `resolveEffectiveSettingsById`
* equal the customized values (engine-parity anchor).
* c. Edit a value via `store.updateWorkflowSettingValues` (the panel/tool write
* path) → assert `resolveEffectiveSettingsById` reflects it.
* d. Export via `exportSettings` (v2) → wipe (fresh store/project) → `importSettings`
* → assert identical effective values, including the `workflowSettings` section
* round-trip.
* e. Assert NO moved key exists in raw project settings at any point post-migration,
* and an unrelated settings save does not resurrect them.
*
* ── Surface-enumeration checklist (FN-5893 discipline) ────────────────────────────
* Every surface that touches workflow settings carries at least one assertion in a
* dedicated suite. The `surface-enumeration` describe block below asserts each of
* these files exists (cheap meta-test) so the parity coverage cannot silently rot:
*
* - engine (effective-settings):
* packages/engine/src/__tests__/effective-settings-merge.test.ts
* packages/engine/src/__tests__/effective-settings-model-lane.test.ts
* packages/engine/src/__tests__/workflow-settings-fallback-alignment.test.ts
* - dashboard settings modal (moved-keys sweep):
* packages/dashboard/app/__tests__/settings-moved-keys.test.ts
* - workflow editor (WorkflowSettingsPanel):
* packages/dashboard/app/components/__tests__/WorkflowSettingsPanel.test.tsx
* - CLI (settings commands):
* packages/cli/src/commands/__tests__/settings.test.ts
* - agent tools:
* packages/engine/src/__tests__/agent-tools-workflow-settings.test.ts
* - export/import:
* packages/core/src/__tests__/settings-export.test.ts
* - cross-node sync:
* packages/dashboard/src/__tests__/routes-nodes-sync.test.ts
* - consistency drift guard:
* packages/core/src/__tests__/settings-consistency.test.ts
*/
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, existsSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import type { TaskStore } from "../store.js";
import {
MOVED_SETTINGS_KEYS,
SETTINGS_MIGRATION_VERSION,
SETTINGS_MIGRATION_MARKER_KEY,
} from "../moved-settings.js";
import {
resolveEffectiveSettingsById,
type WorkflowSettingsResolverStore,
} from "../workflow-settings-resolver.js";
import { PROJECT_SETTINGS_KEYS } from "../settings-schema.js";
import { exportSettings, importSettings } from "../settings-export.js";
// ── Test harness (mirrors settings-migration.test.ts) ─────────────────────────
interface Env {
tempDir: string;
fusionDir: string;
globalSettingsDir: string;
}
function createEnv(prefix: string): Env {
const tempDir = mkdtempSync(join(tmpdir(), prefix));
const fusionDir = join(tempDir, ".fusion");
const tasksDir = join(fusionDir, "tasks");
const globalSettingsDir = join(tempDir, "global-settings");
mkdirSync(tasksDir, { recursive: true });
mkdirSync(globalSettingsDir, { recursive: true });
writeFileSync(join(globalSettingsDir, "settings.json"), JSON.stringify({}));
return { tempDir, fusionDir, globalSettingsDir };
}
async function openStore(env: Env): Promise<TaskStore> {
const { TaskStore } = await import("../store.js");
// Disk-backed DB so the raw config row + global settings file survive the
// seed → migrate steps (an in-memory DB would not retain the seeded raw row).
const store = new TaskStore(env.tempDir, env.globalSettingsDir, { inMemoryDb: false });
await store.init();
return store;
}
/** Low-level raw db handle. */
function rawDb(store: TaskStore): {
prepare: (sql: string) => { run: (...a: unknown[]) => unknown; get: (...a: unknown[]) => unknown; all: (...a: unknown[]) => unknown };
} {
return (store as unknown as { db: ReturnType<typeof rawDb> }).db;
}
/** Overwrite the RAW persisted project `config.settings` JSON. */
function seedRawProjectSettings(store: TaskStore, settings: Record<string, unknown>): void {
const db = rawDb(store);
const now = new Date().toISOString();
db.prepare(
`INSERT INTO config (id, nextWorkflowStepId, settings, workflowSteps, updatedAt)
VALUES (1, 1, ?, '[]', ?)
ON CONFLICT(id) DO UPDATE SET settings = excluded.settings, updatedAt = excluded.updatedAt`,
).run(JSON.stringify(settings), now);
}
/** Read the RAW persisted project settings JSON back. */
function readRawProjectSettings(store: TaskStore): Record<string, unknown> {
const row = rawDb(store).prepare("SELECT settings FROM config WHERE id = 1").get() as
| { settings: string }
| undefined;
if (!row) return {};
return JSON.parse(row.settings) as Record<string, unknown>;
}
function clearMarker(store: TaskStore): void {
rawDb(store).prepare("DELETE FROM __meta WHERE key = ?").run(SETTINGS_MIGRATION_MARKER_KEY);
}
function readMarker(store: TaskStore): number | undefined {
const row = rawDb(store).prepare("SELECT value FROM __meta WHERE key = ?").get(SETTINGS_MIGRATION_MARKER_KEY) as
| { value: string }
| undefined;
return row ? Number(row.value) : undefined;
}
async function runMigration(store: TaskStore): Promise<void> {
await (store as unknown as { migrateMovedSettingsToWorkflowValuesOnce(): Promise<void> }).migrateMovedSettingsToWorkflowValuesOnce();
}
const resolverStore = (store: TaskStore) => store as unknown as WorkflowSettingsResolverStore;
/** Assert no moved key is present in the raw project settings JSON. */
function expectNoMovedKeysInRaw(store: TaskStore): void {
const raw = readRawProjectSettings(store);
for (const key of MOVED_SETTINGS_KEYS) {
expect(raw[key]).toBeUndefined();
}
}
// ── The canonical end-to-end journey ──────────────────────────────────────────
describe("workflow-settings end-to-end journey (U10)", () => {
let env: Env;
let store: TaskStore;
beforeEach(async () => {
env = createEnv("fn-wf-settings-e2e-");
store = await openStore(env);
});
afterEach(async () => {
try {
await store.close();
} catch {
/* ignore */
}
try {
rmSync(env.tempDir, { recursive: true, force: true });
} catch {
/* ignore */
}
});
it("pre-migration customized project → migrate → edit → export v2 → wipe → import → identical effective values; moved keys never resurrect", async () => {
const projectId = store.getWorkflowSettingsProjectId();
// ── (a) PRE-migration state: a v108-era project with customized MOVED keys
// written into the RAW config.settings row, marker cleared so the runner fires.
const customized = {
// Unrelated, non-moved project key — must survive the whole journey untouched.
maxConcurrent: 3,
// Customized moved keys (step execution, review/approval, model lane).
workflowStepTimeoutMs: 120_000,
requirePrApproval: true,
executionProvider: "openai",
};
seedRawProjectSettings(store, customized);
clearMarker(store);
// Sanity: pre-migration, the raw row holds the moved keys (legacy shape).
expect(readRawProjectSettings(store).workflowStepTimeoutMs).toBe(120_000);
// ── (b) Migration fires → effective values equal the customized values.
await runMigration(store);
expect(readMarker(store)).toBe(SETTINGS_MIGRATION_VERSION);
// No moved key remains in the settings SCHEMA after the hard-move.
for (const key of MOVED_SETTINGS_KEYS) {
expect((PROJECT_SETTINGS_KEYS as readonly string[]).includes(key)).toBe(false);
}
// (e, part 1) Raw project settings lost the moved keys; unrelated key stayed.
expectNoMovedKeysInRaw(store);
expect(readRawProjectSettings(store).maxConcurrent).toBe(3);
// Engine-parity: resolved effective values equal the pre-migration customized
// values for the project's default-resolved workflow (builtin:coding).
const postMigration = await resolveEffectiveSettingsById(resolverStore(store), "builtin:coding", projectId);
expect(postMigration.workflowStepTimeoutMs).toBe(120_000);
expect(postMigration.requirePrApproval).toBe(true);
expect(postMigration.executionProvider).toBe("openai");
// ── (c) Edit a value via the panel/tool write path → resolution reflects it.
await store.updateWorkflowSettingValues("builtin:coding", projectId, {
workflowStepTimeoutMs: 222_000,
});
const afterEdit = await resolveEffectiveSettingsById(resolverStore(store), "builtin:coding", projectId);
expect(afterEdit.workflowStepTimeoutMs).toBe(222_000);
// The other migrated values are unchanged by the single-key edit.
expect(afterEdit.requirePrApproval).toBe(true);
expect(afterEdit.executionProvider).toBe("openai");
// (e, part 2) An UNRELATED settings save must NOT resurrect any moved key
// (the default re-injection trap) and must not disturb effective values.
await store.updateSettings({ maxConcurrent: 9 });
expectNoMovedKeysInRaw(store);
expect(readRawProjectSettings(store).maxConcurrent).toBe(9);
const afterUnrelatedSave = await resolveEffectiveSettingsById(resolverStore(store), "builtin:coding", projectId);
expect(afterUnrelatedSave.workflowStepTimeoutMs).toBe(222_000);
expect(afterUnrelatedSave.requirePrApproval).toBe(true);
// ── (d) Export v2 → carries the workflowSettings value section, no moved keys
// under `project`.
const exported = await exportSettings(store, { scope: "both" });
expect(exported.version).toBe(2);
expect(exported.workflowSettings).toBeDefined();
const exportedBuiltin = exported.workflowSettings?.["builtin:coding"];
expect(exportedBuiltin).toBeDefined();
expect(exportedBuiltin?.workflowStepTimeoutMs).toBe(222_000);
expect(exportedBuiltin?.requirePrApproval).toBe(true);
expect(exportedBuiltin?.executionProvider).toBe("openai");
// Moved keys never appear under `project` in a v2 export.
for (const key of MOVED_SETTINGS_KEYS) {
expect((exported.project as Record<string, unknown> | undefined)?.[key]).toBeUndefined();
}
// The unrelated project key is carried under `project`.
expect((exported.project as Record<string, unknown> | undefined)?.maxConcurrent).toBe(9);
// ── Wipe: a brand-new store/project (fresh temp dir, fresh DB).
const env2 = createEnv("fn-wf-settings-e2e-import-");
const store2 = await openStore(env2);
try {
const projectId2 = store2.getWorkflowSettingsProjectId();
// The fresh project has declaration defaults (NOT the source project's values).
const freshBefore = await resolveEffectiveSettingsById(resolverStore(store2), "builtin:coding", projectId2);
expect(freshBefore.workflowStepTimeoutMs).toBe(360_000); // legacy/declaration default
expect(freshBefore.requirePrApproval).toBe(false);
// ── Import the v2 export → effective values match the exported project,
// INCLUDING the workflowSettings section round-trip.
const importResult = await importSettings(store2, exported, { scope: "both" });
expect(importResult.success).toBe(true);
expect(importResult.workflowSettingsCount).toBeGreaterThan(0);
const imported = await resolveEffectiveSettingsById(resolverStore(store2), "builtin:coding", projectId2);
expect(imported.workflowStepTimeoutMs).toBe(222_000);
expect(imported.requirePrApproval).toBe(true);
expect(imported.executionProvider).toBe("openai");
// The imported project carries the unrelated key but never a moved key in raw.
expect(readRawProjectSettings(store2).maxConcurrent).toBe(9);
expectNoMovedKeysInRaw(store2);
// (e, part 3) A post-import unrelated save on the destination store also does
// not resurrect moved keys.
await store2.updateSettings({ maxConcurrent: 4 });
expectNoMovedKeysInRaw(store2);
} finally {
try {
await store2.close();
} catch {
/* ignore */
}
rmSync(env2.tempDir, { recursive: true, force: true });
}
});
});
// ── Surface-enumeration meta-test (FN-5893 discipline) ────────────────────────
//
// A cheap structural guard: every surface that consumes/manages workflow settings
// must keep at least one dedicated test suite. If any surface's suite is renamed or
// deleted without a replacement, this fails loudly so parity coverage can't rot.
describe("workflow-settings surface enumeration (FN-5893)", () => {
// Resolve the monorepo `packages/` root from this file's location:
// .../packages/core/src/__tests__/<this file> → up 4 → packages/
const packagesRoot = resolve(fileURLToPath(import.meta.url), "../../../..");
const surfaceSuites: Record<string, string[]> = {
"engine (effective-settings)": [
"engine/src/__tests__/effective-settings-merge.test.ts",
"engine/src/__tests__/effective-settings-model-lane.test.ts",
"engine/src/__tests__/workflow-settings-fallback-alignment.test.ts",
],
"dashboard settings modal (moved-keys sweep)": [
"dashboard/app/__tests__/settings-moved-keys.test.ts",
],
"workflow editor (WorkflowSettingsPanel)": [
"dashboard/app/components/__tests__/WorkflowSettingsPanel.test.tsx",
],
"CLI (settings command)": [
"cli/src/commands/__tests__/settings.test.ts",
],
"agent tools": [
"engine/src/__tests__/agent-tools-workflow-settings.test.ts",
],
"export / import": [
"core/src/__tests__/settings-export.test.ts",
],
"cross-node sync": [
"dashboard/src/__tests__/routes-nodes-sync.test.ts",
],
"consistency drift guard": [
"core/src/__tests__/settings-consistency.test.ts",
],
};
for (const [surface, files] of Object.entries(surfaceSuites)) {
it(`${surface} has a dedicated workflow-settings suite`, () => {
for (const rel of files) {
const abs = join(packagesRoot, rel);
expect(existsSync(abs), `expected surface test to exist: ${rel}`).toBe(true);
}
});
}
});

View File

@@ -0,0 +1,212 @@
import { describe, it, expect, vi } from "vitest";
import { BUILTIN_WORKFLOW_SETTINGS } from "../builtin-workflow-settings.js";
import type { WorkflowIr } from "../workflow-ir-types.js";
import {
resolveEffectiveSettings,
resolveEffectiveSettingsById,
type WorkflowSettingsResolverStore,
} from "../workflow-settings-resolver.js";
const PROJECT = "proj-1";
/** A custom workflow IR with NO settings declarations (declaration-absent path). */
const CUSTOM_NO_SETTINGS: WorkflowIr = {
version: "v2",
name: "custom-no-settings",
columns: [{ id: "todo", name: "Todo", traits: [] }],
nodes: [
{ id: "start", kind: "start" },
{ id: "end", kind: "end" },
],
edges: [{ from: "start", to: "end" }],
};
/** A custom workflow IR declaring a single setting (workflowStepTimeoutMs). */
const CUSTOM_WITH_SETTING: WorkflowIr = {
...CUSTOM_NO_SETTINGS,
name: "custom-with-setting",
settings: [
{ id: "workflowStepTimeoutMs", name: "Step timeout", type: "number", default: 99_000 },
],
};
function makeStore(opts: {
selection?: Record<string, { workflowId: string; stepIds: string[] }>;
selectionThrows?: boolean;
defs?: Record<string, { ir: string | WorkflowIr } | undefined>;
values?: Record<string, Record<string, unknown>>; // key: `${workflowId}::${projectId}`
valuesThrows?: boolean;
projectId?: string;
projectIdThrows?: boolean;
}): WorkflowSettingsResolverStore {
return {
getTaskWorkflowSelection: vi.fn((taskId: string) => {
if (opts.selectionThrows) throw new Error("boom");
return opts.selection?.[taskId];
}),
getWorkflowDefinition: vi.fn(async (id: string) => opts.defs?.[id]),
getWorkflowSettingValues: vi.fn((workflowId: string, projectId: string) => {
if (opts.valuesThrows) throw new Error("values boom");
return opts.values?.[`${workflowId}::${projectId}`] ?? {};
}),
getWorkflowSettingsProjectId: vi.fn(() => {
if (opts.projectIdThrows) throw new Error("identity boom");
return opts.projectId ?? PROJECT;
}),
};
}
describe("resolveEffectiveSettings (per-task)", () => {
it("parity anchor: builtin:coding with no stored values → effective equals declaration defaults", async () => {
const store = makeStore({
selection: { t1: { workflowId: "builtin:coding", stepIds: [] } },
});
const eff = await resolveEffectiveSettings(store, { id: "t1" });
// Every catalog key with a default contributes its declaration default to the
// effective map. (Post-U4 hard-move the legacy DEFAULT_PROJECT_SETTINGS literals
// for these keys are GONE — the declaration default is now the single source of
// truth, byte-equal to what the legacy literal used to be.)
for (const s of BUILTIN_WORKFLOW_SETTINGS) {
if (s.default === undefined) {
// Absent-default lanes contribute nothing to the effective map.
expect(Object.prototype.hasOwnProperty.call(eff, s.id)).toBe(false);
} else {
expect(eff[s.id]).toStrictEqual(s.default);
}
}
});
it("a stored value for (workflow, project) is returned over the default", async () => {
const store = makeStore({
selection: { t1: { workflowId: "builtin:coding", stepIds: [] } },
values: { "builtin:coding::proj-1": { workflowStepTimeoutMs: 5_000, requirePrApproval: true } },
});
const eff = await resolveEffectiveSettings(store, { id: "t1" });
expect(eff.workflowStepTimeoutMs).toBe(5_000);
expect(eff.requirePrApproval).toBe(true);
// Untouched key falls to the declaration default.
expect(eff.runStepsInNewSessions).toBe(false);
});
it("two tasks resolving different workflows each get their own effective values", async () => {
const store = makeStore({
selection: {
t1: { workflowId: "builtin:coding", stepIds: [] },
t2: { workflowId: "wf-custom", stepIds: [] },
},
defs: { "wf-custom": { ir: CUSTOM_WITH_SETTING } },
values: {
"builtin:coding::proj-1": { workflowStepTimeoutMs: 5_000 },
"wf-custom::proj-1": { workflowStepTimeoutMs: 12_000 },
},
});
const a = await resolveEffectiveSettings(store, { id: "t1" });
const b = await resolveEffectiveSettings(store, { id: "t2" });
expect(a.workflowStepTimeoutMs).toBe(5_000);
expect(b.workflowStepTimeoutMs).toBe(12_000);
// The custom workflow declares ONLY workflowStepTimeoutMs, so nothing else is in its map.
expect(Object.prototype.hasOwnProperty.call(b, "requirePrApproval")).toBe(false);
});
it("custom workflow with empty settings → declaration-absent map (read-site fallback applies)", async () => {
const store = makeStore({
selection: { t1: { workflowId: "wf-empty", stepIds: [] } },
defs: { "wf-empty": { ir: CUSTOM_NO_SETTINGS } },
});
const eff = await resolveEffectiveSettings(store, { id: "t1" });
// No declarations → no moved key in the effective map → engine read site keeps
// its `?? <literal>` fallback (= the legacy default; asserted by the alignment test).
expect(Object.keys(eff)).toHaveLength(0);
});
it("new custom workflow with empty settings does NOT inherit another workflow's values", async () => {
const store = makeStore({
selection: { t1: { workflowId: "wf-new", stepIds: [] } },
defs: { "wf-new": { ir: CUSTOM_NO_SETTINGS } },
// A different workflow has a customized value; the new one must not see it.
values: { "builtin:coding::proj-1": { workflowStepTimeoutMs: 5_000 } },
});
const eff = await resolveEffectiveSettings(store, { id: "t1" });
expect(Object.prototype.hasOwnProperty.call(eff, "workflowStepTimeoutMs")).toBe(false);
});
it("absent-default model lanes are omitted (never undefined) so the merge can't clobber", async () => {
const store = makeStore({
selection: { t1: { workflowId: "builtin:coding", stepIds: [] } },
});
const eff = await resolveEffectiveSettings(store, { id: "t1" });
for (const lane of ["executionProvider", "executionModelId", "planningProvider", "validatorProvider"]) {
expect(Object.prototype.hasOwnProperty.call(eff, lane)).toBe(false);
}
});
it("a set model lane wins; unset lanes stay absent", async () => {
const store = makeStore({
selection: { t1: { workflowId: "builtin:coding", stepIds: [] } },
values: { "builtin:coding::proj-1": { executionProvider: "anthropic" } },
});
const eff = await resolveEffectiveSettings(store, { id: "t1" });
expect(eff.executionProvider).toBe("anthropic");
expect(Object.prototype.hasOwnProperty.call(eff, "executionModelId")).toBe(false);
});
it("no selection → builtin:coding declaration defaults (never throws)", async () => {
const store = makeStore({ selection: {} });
const eff = await resolveEffectiveSettings(store, { id: "t-none" });
expect(eff.workflowStepTimeoutMs).toBe(360_000);
});
it("missing custom definition degrades to builtin declarations (never throws)", async () => {
const store = makeStore({
selection: { t1: { workflowId: "wf-gone", stepIds: [] } },
defs: { "wf-gone": undefined },
});
const eff = await resolveEffectiveSettings(store, { id: "t1" });
// Degrades to BUILTIN_CODING_WORKFLOW_IR declarations.
expect(eff.workflowStepTimeoutMs).toBe(360_000);
});
it("selection lookup throwing degrades to builtin declarations", async () => {
const store = makeStore({ selectionThrows: true });
const eff = await resolveEffectiveSettings(store, { id: "t1" });
expect(eff.workflowStepTimeoutMs).toBe(360_000);
});
it("store value read throwing degrades to declaration defaults", async () => {
const store = makeStore({
selection: { t1: { workflowId: "builtin:coding", stepIds: [] } },
valuesThrows: true,
});
const eff = await resolveEffectiveSettings(store, { id: "t1" });
expect(eff.workflowStepTimeoutMs).toBe(360_000);
});
it("project-id lookup throwing degrades to declaration defaults (empty stored map)", async () => {
const store = makeStore({
selection: { t1: { workflowId: "builtin:coding", stepIds: [] } },
projectIdThrows: true,
values: { "builtin:coding::proj-1": { workflowStepTimeoutMs: 5_000 } },
});
const eff = await resolveEffectiveSettings(store, { id: "t1" });
// The stored 5_000 is unreachable because the project key couldn't be resolved.
expect(eff.workflowStepTimeoutMs).toBe(360_000);
});
});
describe("resolveEffectiveSettingsById", () => {
it("resolves declarations + stored values for an explicit (workflowId, projectId)", async () => {
const store = makeStore({
defs: { "wf-custom": { ir: CUSTOM_WITH_SETTING } },
values: { "wf-custom::proj-9": { workflowStepTimeoutMs: 7_000 } },
});
const eff = await resolveEffectiveSettingsById(store, "wf-custom", "proj-9");
expect(eff.workflowStepTimeoutMs).toBe(7_000);
});
it("builtin id with no stored values → catalog defaults", async () => {
const store = makeStore({});
const eff = await resolveEffectiveSettingsById(store, "builtin:coding", "proj-9");
expect(eff.requirePrApproval).toBe(false);
});
});

View File

@@ -0,0 +1,307 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import {
validateSettingValuePatch,
resolveEffectiveSettingValues,
findOrphanedSettingValues,
WorkflowSettingRejectionError,
} from "../workflow-settings.js";
import type { WorkflowSettingDefinition, WorkflowIrV2 } from "../workflow-ir-types.js";
import { BUILTIN_WORKFLOW_SETTINGS } from "../builtin-workflow-settings.js";
import { createTaskStoreTestHarness } from "./store-test-helpers.js";
const BUILTIN_CODING = "builtin:coding";
const PROJECT = "proj-1";
/** A minimal valid v2 IR carrying `settings` declarations — enough to round-trip
* through `parseWorkflowIr` / `createWorkflowDefinition`. */
function makeIrWithSettings(settings: WorkflowSettingDefinition[]): WorkflowIrV2 {
return {
version: "v2",
name: "Custom WF",
columns: [],
nodes: [
{ id: "start", kind: "start" },
{ id: "end", kind: "end" },
],
edges: [{ from: "start", to: "end" }],
settings,
};
}
const TIMEOUT_DECL: WorkflowSettingDefinition = {
id: "workflowStepTimeoutMs",
name: "Step timeout (ms)",
type: "number",
default: 360_000,
};
const FLAG_DECL: WorkflowSettingDefinition = {
id: "runStepsInNewSessions",
name: "Run steps in new sessions",
type: "boolean",
default: false,
};
const ENUM_DECL: WorkflowSettingDefinition = {
id: "reviewHandoffPolicy",
name: "Review handoff policy",
type: "enum",
default: "disabled",
options: [
{ value: "disabled", label: "Disabled" },
{ value: "always", label: "Always" },
],
};
// ───────────────────────────────────────────────────────────────────────────
// Validation core (side-effect-free)
// ───────────────────────────────────────────────────────────────────────────
describe("validateSettingValuePatch", () => {
const decls = [TIMEOUT_DECL, FLAG_DECL, ENUM_DECL];
it("accepts and normalizes valid values of each type", () => {
const res = validateSettingValuePatch(decls, {
workflowStepTimeoutMs: 1000,
runStepsInNewSessions: true,
reviewHandoffPolicy: "always",
});
expect(res.rejections).toEqual([]);
expect(res.accepted).toEqual({
workflowStepTimeoutMs: 1000,
runStepsInNewSessions: true,
reviewHandoffPolicy: "always",
});
});
it("accepts null as a delete sentinel (null-as-delete)", () => {
const res = validateSettingValuePatch(decls, { workflowStepTimeoutMs: null });
expect(res.rejections).toEqual([]);
expect(res.accepted).toEqual({ workflowStepTimeoutMs: null });
});
it("rejects an unknown setting", () => {
const res = validateSettingValuePatch(decls, { nope: 1 });
expect(res.accepted).toEqual({});
expect(res.rejections).toHaveLength(1);
expect(res.rejections[0]).toMatchObject({ code: "unknown-setting", settingId: "nope" });
});
it("rejects a type mismatch", () => {
const res = validateSettingValuePatch(decls, { workflowStepTimeoutMs: "fast" });
expect(res.accepted).toEqual({});
expect(res.rejections[0]).toMatchObject({ code: "type-mismatch", settingId: "workflowStepTimeoutMs" });
});
it("rejects an enum violation", () => {
const res = validateSettingValuePatch(decls, { reviewHandoffPolicy: "sometimes" });
expect(res.accepted).toEqual({});
expect(res.rejections[0]).toMatchObject({ code: "enum-violation", settingId: "reviewHandoffPolicy" });
});
it("reports no-settings-defined for a non-null write against empty declarations", () => {
const res = validateSettingValuePatch([], { workflowStepTimeoutMs: 1 });
expect(res.accepted).toEqual({});
expect(res.rejections[0]).toMatchObject({ code: "no-settings-defined" });
});
it("accepts a delete even against empty declarations (clears stale rows)", () => {
const res = validateSettingValuePatch([], { workflowStepTimeoutMs: null });
expect(res.rejections).toEqual([]);
expect(res.accepted).toEqual({ workflowStepTimeoutMs: null });
});
it("reports every offending key (not fail-fast)", () => {
const res = validateSettingValuePatch(decls, {
workflowStepTimeoutMs: "x",
reviewHandoffPolicy: "x",
});
expect(res.rejections).toHaveLength(2);
});
});
// ───────────────────────────────────────────────────────────────────────────
// Effective resolution (drop-on-orphan, KTD-6)
// ───────────────────────────────────────────────────────────────────────────
describe("resolveEffectiveSettingValues", () => {
it("uses the stored value when it still validates", () => {
const eff = resolveEffectiveSettingValues([TIMEOUT_DECL], { workflowStepTimeoutMs: 1000 });
expect(eff).toEqual({ workflowStepTimeoutMs: 1000 });
});
it("falls to the declaration default when unset", () => {
const eff = resolveEffectiveSettingValues([TIMEOUT_DECL], {});
expect(eff).toEqual({ workflowStepTimeoutMs: 360_000 });
});
it("drops a stored value that no longer validates (enum→number retype) and uses the default", () => {
// Stored a string under what is now a number declaration.
const retyped: WorkflowSettingDefinition = { id: "x", name: "X", type: "number", default: 42 };
const eff = resolveEffectiveSettingValues([retyped], { x: "stale-string" });
expect(eff).toEqual({ x: 42 });
});
it("drops stored values for ids with no current declaration", () => {
const eff = resolveEffectiveSettingValues([TIMEOUT_DECL], { removedSetting: 7 });
expect(eff).toEqual({ workflowStepTimeoutMs: 360_000 });
});
it("omits a setting with neither a valid value nor a default", () => {
const noDefault: WorkflowSettingDefinition = { id: "y", name: "Y", type: "number" };
const eff = resolveEffectiveSettingValues([noDefault], {});
expect(eff).toEqual({});
});
});
describe("findOrphanedSettingValues", () => {
it("surfaces values dropped by resolution (id + raw value) for the editor disclosure", () => {
const retyped: WorkflowSettingDefinition = { id: "x", name: "X", type: "number", default: 42 };
const orphans = findOrphanedSettingValues([retyped], { x: "stale-string", removed: 9 });
expect(orphans).toEqual([
{ id: "x", value: "stale-string" },
{ id: "removed", value: 9 },
]);
});
it("ignores null/undefined stored entries", () => {
const orphans = findOrphanedSettingValues([TIMEOUT_DECL], { workflowStepTimeoutMs: null });
expect(orphans).toEqual([]);
});
});
// ───────────────────────────────────────────────────────────────────────────
// Store write authority (U2 scenarios)
// ───────────────────────────────────────────────────────────────────────────
describe("TaskStore.updateWorkflowSettingValues", () => {
const harness = createTaskStoreTestHarness();
beforeEach(harness.beforeEach);
afterEach(harness.afterEach);
async function createCustomWorkflow(settings: WorkflowSettingDefinition[]): Promise<string> {
const def = await harness.store().createWorkflowDefinition({
name: "Custom WF",
ir: makeIrWithSettings(settings),
});
return def.id;
}
it("persists a valid value for a custom workflow and reads it back typed", async () => {
const store = harness.store();
const wfId = await createCustomWorkflow([TIMEOUT_DECL, FLAG_DECL]);
await store.updateWorkflowSettingValues(wfId, PROJECT, {
workflowStepTimeoutMs: 5000,
runStepsInNewSessions: true,
});
const stored = store.getWorkflowSettingValues(wfId, PROJECT);
expect(stored).toEqual({ workflowStepTimeoutMs: 5000, runStepsInNewSessions: true });
expect(typeof stored.workflowStepTimeoutMs).toBe("number");
expect(typeof stored.runStepsInNewSessions).toBe("boolean");
});
it("accepts value writes for (builtin:coding, project) while builtin declaration edits stay rejected", async () => {
const store = harness.store();
// R4: value write for a built-in workflow succeeds.
await store.updateWorkflowSettingValues(BUILTIN_CODING, PROJECT, { requirePrApproval: true });
expect(store.getWorkflowSettingValues(BUILTIN_CODING, PROJECT)).toEqual({ requirePrApproval: true });
// Built-in DECLARATION edits remain rejected on the separate error path (KTD-2).
await expect(
store.updateWorkflowDefinition(BUILTIN_CODING, { ir: makeIrWithSettings([TIMEOUT_DECL]) }),
).rejects.toThrow(/Built-in workflows cannot be edited/);
});
it("rejects type-mismatch / unknown-setting / enum-violation and persists nothing", async () => {
const store = harness.store();
const wfId = await createCustomWorkflow([TIMEOUT_DECL, ENUM_DECL]);
await expect(
store.updateWorkflowSettingValues(wfId, PROJECT, { workflowStepTimeoutMs: "fast" }),
).rejects.toBeInstanceOf(WorkflowSettingRejectionError);
await expect(
store.updateWorkflowSettingValues(wfId, PROJECT, { unknownKey: 1 }),
).rejects.toBeInstanceOf(WorkflowSettingRejectionError);
await expect(
store.updateWorkflowSettingValues(wfId, PROJECT, { reviewHandoffPolicy: "nope" }),
).rejects.toBeInstanceOf(WorkflowSettingRejectionError);
// Nothing was persisted by any rejected write.
expect(store.getWorkflowSettingValues(wfId, PROJECT)).toEqual({});
});
it("treats null as delete and effective resolution falls to the declaration default", async () => {
const store = harness.store();
const wfId = await createCustomWorkflow([TIMEOUT_DECL]);
await store.updateWorkflowSettingValues(wfId, PROJECT, { workflowStepTimeoutMs: 5000 });
expect(store.getWorkflowSettingValues(wfId, PROJECT)).toEqual({ workflowStepTimeoutMs: 5000 });
await store.updateWorkflowSettingValues(wfId, PROJECT, { workflowStepTimeoutMs: null });
const stored = store.getWorkflowSettingValues(wfId, PROJECT);
expect(stored).toEqual({});
const def = await store.getWorkflowDefinition(wfId);
const decls = def!.ir.version === "v2" ? def!.ir.settings : undefined;
expect(resolveEffectiveSettingValues(decls, stored)).toEqual({ workflowStepTimeoutMs: 360_000 });
});
it("retype enum→number with a stale stored string: effective resolution drops it, returns default, stored row untouched", async () => {
const store = harness.store();
// Declare an enum setting and store a valid enum value.
const wfId = await createCustomWorkflow([ENUM_DECL]);
await store.updateWorkflowSettingValues(wfId, PROJECT, { reviewHandoffPolicy: "always" });
expect(store.getWorkflowSettingValues(wfId, PROJECT)).toEqual({ reviewHandoffPolicy: "always" });
// Retype the same id to a number (declaration edit via the IR save path).
const retyped: WorkflowSettingDefinition = {
id: "reviewHandoffPolicy",
name: "Review handoff policy",
type: "number",
default: 99,
};
await store.updateWorkflowDefinition(wfId, { ir: makeIrWithSettings([retyped]) });
// Stored row is UNTOUCHED — the stale string survives in storage.
const stored = store.getWorkflowSettingValues(wfId, PROJECT);
expect(stored).toEqual({ reviewHandoffPolicy: "always" });
// Effective resolution drops the stale string and returns the new default.
expect(resolveEffectiveSettingValues([retyped], stored)).toEqual({ reviewHandoffPolicy: 99 });
});
it("cascade-deletes value rows when the custom workflow is deleted", async () => {
const store = harness.store();
const wfId = await createCustomWorkflow([TIMEOUT_DECL]);
await store.updateWorkflowSettingValues(wfId, PROJECT, { workflowStepTimeoutMs: 5000 });
await store.updateWorkflowSettingValues(wfId, "proj-2", { workflowStepTimeoutMs: 7000 });
await store.deleteWorkflowDefinition(wfId);
expect(store.getWorkflowSettingValues(wfId, PROJECT)).toEqual({});
expect(store.getWorkflowSettingValues(wfId, "proj-2")).toEqual({});
});
it("a task pinned to a deleted workflow resolves built-in values", async () => {
const store = harness.store();
// Built-in values for the project (these survive a custom-workflow delete).
await store.updateWorkflowSettingValues(BUILTIN_CODING, PROJECT, { requirePrApproval: true });
const wfId = await createCustomWorkflow([TIMEOUT_DECL]);
await store.updateWorkflowSettingValues(wfId, PROJECT, { workflowStepTimeoutMs: 5000 });
await store.deleteWorkflowDefinition(wfId);
// The deleted workflow's rows are gone; a task pinned to it degrades to
// builtin:coding (resolver) and reads built-in declarations + built-in values.
expect(store.getWorkflowSettingValues(wfId, PROJECT)).toEqual({});
const effective = resolveEffectiveSettingValues(
BUILTIN_WORKFLOW_SETTINGS,
store.getWorkflowSettingValues(BUILTIN_CODING, PROJECT),
);
expect(effective.requirePrApproval).toBe(true);
// Untouched built-in keys resolve to their declaration defaults.
expect(effective.workflowStepTimeoutMs).toBe(360_000);
});
});

View File

@@ -0,0 +1,194 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { isBuiltinWorkflowId } from "../builtin-workflows.js";
import { createTaskStoreTestHarness } from "./store-test-helpers.js";
/**
* U2 / R5 / KTD-3 — lazy idempotent migration of legacy user-authored workflow
* steps into the dual fragment + combined-workflow representation.
*/
describe("TaskStore.migrateLegacyWorkflowSteps (U2/R5)", () => {
const harness = createTaskStoreTestHarness();
let store: ReturnType<typeof harness.store>;
beforeEach(async () => {
await harness.beforeEach();
store = harness.store();
});
afterEach(async () => {
await harness.afterEach();
});
/** User-owned (non-builtin) workflow definitions only. */
async function userDefs() {
return (await store.listWorkflowDefinitions()).filter((w) => !isBuiltinWorkflowId(w.id));
}
it("converts defaultOn + optional + disabled user steps to fragments, builds the combined workflow from defaultOn only, sets the project default, and leaves the compiled row untouched", async () => {
// defaultOn (ran automatically on new tasks) → fragment + joins combined workflow.
const on = await store.createWorkflowStep({
name: "Default On",
description: "ran by default",
prompt: "do the default thing",
defaultOn: true,
enabled: true,
});
// enabled-but-optional → fragment only (NOT in combined workflow).
const optional = await store.createWorkflowStep({
name: "Optional",
description: "opt-in",
prompt: "optional work",
defaultOn: false,
enabled: true,
});
// disabled → still gets a fragment (every user step does).
const disabled = await store.createWorkflowStep({
name: "Disabled",
description: "off",
prompt: "disabled work",
defaultOn: false,
enabled: false,
});
// compiled-materialized row (execution detail) → must be ignored entirely.
const compiled = await store.createWorkflowStep({
name: "Compiled",
description: "materialized",
templateId: "workflow:WF-999",
defaultOn: true,
enabled: true,
});
const result = await store.migrateLegacyWorkflowSteps();
// 3 user steps converted; nothing previously migrated.
expect(result.migrated).toBe(3);
expect(result.skipped).toBe(0);
expect(result.combinedWorkflowId).toBeTruthy();
const defs = await userDefs();
const fragments = defs.filter((d) => d.kind === "fragment");
const workflows = defs.filter((d) => d.kind === "workflow");
// Exactly 3 fragments (one per user step), exactly 1 combined workflow.
expect(fragments).toHaveLength(3);
expect(workflows).toHaveLength(1);
expect(fragments.map((f) => f.name).sort()).toEqual(["Default On", "Disabled", "Optional"]);
// Combined workflow: named "Migrated steps", carries the system description,
// and contains ONLY the defaultOn step's user node (plus start/end + seams).
const combined = workflows[0];
expect(combined.id).toBe(result.combinedWorkflowId);
expect(combined.name).toBe("Migrated steps");
expect(combined.description).toBe("Converted from your legacy workflow steps");
const userNodes = combined.ir.nodes.filter(
(n) => n.kind !== "start" && n.kind !== "end" && typeof n.config?.seam !== "string",
);
expect(userNodes).toHaveLength(1);
expect(userNodes[0].config?.name).toBe("Default On");
// Project default points at the combined workflow.
expect(await store.getDefaultWorkflowId()).toBe(combined.id);
// All 3 user source rows are stamped; the compiled row is untouched.
expect((await store.getWorkflowStep(on.id))?.migratedFragmentId).toBeTruthy();
expect((await store.getWorkflowStep(optional.id))?.migratedFragmentId).toBeTruthy();
expect((await store.getWorkflowStep(disabled.id))?.migratedFragmentId).toBeTruthy();
expect((await store.getWorkflowStep(compiled.id))?.migratedFragmentId).toBeUndefined();
// No source records were deleted.
const steps = await store.listWorkflowSteps();
expect(steps.map((s) => s.id)).toEqual(expect.arrayContaining([on.id, optional.id, disabled.id]));
});
it("creates fragments but NO combined workflow and leaves the default unchanged when no step is defaultOn", async () => {
await store.createWorkflowStep({ name: "A", description: "a", prompt: "a", defaultOn: false });
await store.createWorkflowStep({ name: "B", description: "b", prompt: "b", enabled: false });
const result = await store.migrateLegacyWorkflowSteps();
expect(result.migrated).toBe(2);
expect(result.combinedWorkflowId).toBeUndefined();
const defs = await userDefs();
expect(defs.filter((d) => d.kind === "fragment")).toHaveLength(2);
expect(defs.filter((d) => d.kind === "workflow")).toHaveLength(0);
expect(await store.getDefaultWorkflowId()).toBeUndefined();
});
it("is idempotent: a second run converts nothing and creates no new definitions", async () => {
await store.createWorkflowStep({ name: "A", description: "a", prompt: "a", defaultOn: true });
const first = await store.migrateLegacyWorkflowSteps();
expect(first.migrated).toBe(1);
const afterFirst = (await userDefs()).length;
const second = await store.migrateLegacyWorkflowSteps();
expect(second.migrated).toBe(0);
expect(second.skipped).toBe(1);
expect(second.combinedWorkflowId).toBeUndefined();
expect((await userDefs()).length).toBe(afterFirst);
});
it("does not clobber a pre-existing project default", async () => {
// A user-chosen default workflow exists before migration.
const existing = await store.createWorkflowDefinition({
name: "My choice",
ir: {
version: "v1",
name: "My choice",
nodes: [
{ id: "start", kind: "start" },
{ id: "end", kind: "end" },
],
edges: [{ from: "start", to: "end", condition: "success" }],
},
kind: "workflow",
});
await store.setDefaultWorkflowId(existing.id);
await store.createWorkflowStep({ name: "A", description: "a", prompt: "a", defaultOn: true });
const result = await store.migrateLegacyWorkflowSteps();
// The combined workflow is still created, but the explicit default is kept.
expect(result.combinedWorkflowId).toBeTruthy();
expect(await store.getDefaultWorkflowId()).toBe(existing.id);
});
it("compare-and-set: re-reads the default after the transaction and skips when a concurrent writer set one", async () => {
await store.createWorkflowStep({ name: "A", description: "a", prompt: "a", defaultOn: true });
const concurrent = await store.createWorkflowDefinition({
name: "Concurrent",
ir: {
version: "v1",
name: "Concurrent",
nodes: [
{ id: "start", kind: "start" },
{ id: "end", kind: "end" },
],
edges: [{ from: "start", to: "end", condition: "success" }],
},
kind: "workflow",
});
// A project default exists when migration's post-transaction compare-and-set
// re-reads it. Because the set is gated on the re-read (not a pre-transaction
// snapshot), an existing default is observed and never clobbered.
await store.setDefaultWorkflowId(concurrent.id);
const result = await store.migrateLegacyWorkflowSteps();
expect(result.combinedWorkflowId).toBeTruthy();
expect(result.combinedWorkflowId).not.toBe(concurrent.id);
// The compare-and-set re-read observed the existing default and did NOT clobber it.
expect(await store.getDefaultWorkflowId()).toBe(concurrent.id);
});
it("is a no-op with zero user steps", async () => {
const result = await store.migrateLegacyWorkflowSteps();
expect(result).toEqual({ migrated: 0, skipped: 0, combinedWorkflowId: undefined });
expect(await userDefs()).toHaveLength(0);
expect(await store.getDefaultWorkflowId()).toBeUndefined();
});
});

View File

@@ -0,0 +1,226 @@
import { describe, it, expect } from "vitest";
import { stepsToWorkflowIr, stepToFragmentIr, layoutForIr } from "../workflow-steps-to-ir.js";
import { compileWorkflowToSteps } from "../workflow-compiler.js";
import { parseWorkflowIr } from "../workflow-ir.js";
import type { WorkflowStep, WorkflowStepInput } from "../types.js";
/** Build a fully-specified WorkflowStep fixture. */
function step(overrides: Partial<WorkflowStep>): WorkflowStep {
return {
id: overrides.id ?? "WS-000",
name: overrides.name ?? "Step",
description: overrides.description ?? "",
mode: overrides.mode ?? "prompt",
phase: overrides.phase,
gateMode: overrides.gateMode ?? "advisory",
prompt: overrides.prompt ?? "",
toolMode: overrides.toolMode,
scriptName: overrides.scriptName,
enabled: overrides.enabled ?? true,
defaultOn: overrides.defaultOn,
modelProvider: overrides.modelProvider,
modelId: overrides.modelId,
migratedFragmentId: overrides.migratedFragmentId,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
}
/** Project a compiled step input down to exactly the compiler-visible fields the
* round-trip contract pins (KTD-2). Normalizes optional fields for comparison. */
function visible(input: WorkflowStepInput) {
return {
name: input.name,
mode: input.mode,
phase: input.phase,
gateMode: input.gateMode,
prompt: input.mode === "script" ? undefined : (input.prompt ?? ""),
scriptName: input.scriptName,
toolMode: input.mode === "script" ? undefined : input.toolMode,
modelProvider: input.modelProvider,
modelId: input.modelId,
};
}
function visibleStep(s: WorkflowStep) {
return {
name: s.name,
mode: s.mode,
phase: s.phase ?? "pre-merge",
gateMode: s.gateMode,
prompt: s.mode === "script" ? undefined : (s.prompt ?? ""),
scriptName: s.mode === "script" ? s.scriptName : undefined,
toolMode: s.mode === "script" ? undefined : (s.toolMode ?? "readonly"),
modelProvider: s.mode === "prompt" ? s.modelProvider : undefined,
modelId: s.mode === "prompt" ? s.modelId : undefined,
};
}
describe("stepsToWorkflowIr — round-trip parity (R4/KTD-2)", () => {
it("reproduces every compiler-visible field for a mixed step set", () => {
const steps: WorkflowStep[] = [
step({
id: "WS-1",
name: "Implement",
description: "do the work",
mode: "prompt",
gateMode: "advisory",
prompt: "Implement the change",
toolMode: "coding",
phase: "pre-merge",
}),
step({
id: "WS-2",
name: "Lint",
mode: "script",
gateMode: "gate",
scriptName: "lint",
phase: "pre-merge",
}),
step({
id: "WS-3",
name: "Security gate",
mode: "prompt",
gateMode: "gate",
prompt: "Block on exploitable findings",
toolMode: "readonly",
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
phase: "pre-merge",
}),
step({
id: "WS-4",
name: "Document",
mode: "prompt",
gateMode: "advisory",
prompt: "Write docs",
phase: "post-merge",
}),
step({
id: "WS-5",
name: "Deploy script",
mode: "script",
gateMode: "advisory",
scriptName: "deploy",
phase: "post-merge",
}),
];
const ir = stepsToWorkflowIr(steps, "Migrated");
const compiled = compileWorkflowToSteps(ir);
expect(compiled.map(visible)).toEqual(steps.map(visibleStep));
});
it("undefined phase maps to pre-merge and round-trips", () => {
const steps: WorkflowStep[] = [
step({ id: "WS-1", name: "A", mode: "prompt", gateMode: "advisory", prompt: "a" }),
step({ id: "WS-2", name: "B", mode: "prompt", gateMode: "advisory", prompt: "b" }),
];
const ir = stepsToWorkflowIr(steps, "AllUndefined");
// parseable
expect(() => parseWorkflowIr(ir)).not.toThrow();
const compiled = compileWorkflowToSteps(ir);
expect(compiled.map((c) => c.phase)).toEqual(["pre-merge", "pre-merge"]);
expect(compiled.map(visible)).toEqual(steps.map(visibleStep));
});
it("empty step list yields a minimal valid IR that compiles to []", () => {
const ir = stepsToWorkflowIr([], "Empty");
expect(() => parseWorkflowIr(ir)).not.toThrow();
expect(compileWorkflowToSteps(ir)).toEqual([]);
// start + 3 seams + end.
expect(ir.nodes.map((n) => n.id)).toEqual(["start", "execute", "review", "merge", "end"]);
});
it("post-merge-only set places nodes after the merge seam", () => {
const steps: WorkflowStep[] = [
step({ id: "WS-1", name: "After", mode: "prompt", gateMode: "advisory", prompt: "x", phase: "post-merge" }),
];
const ir = stepsToWorkflowIr(steps, "PostOnly");
const ids = ir.nodes.map((n) => n.id);
expect(ids.indexOf("merge")).toBeLessThan(ids.indexOf("step-1"));
const compiled = compileWorkflowToSteps(ir);
expect(compiled).toHaveLength(1);
expect(compiled[0].phase).toBe("post-merge");
});
it("produced IR passes parseWorkflowIr and encodes seams exactly per linear()", () => {
const steps: WorkflowStep[] = [
step({ id: "WS-1", name: "A", mode: "prompt", gateMode: "advisory", prompt: "a" }),
];
const ir = stepsToWorkflowIr(steps, "Seams");
expect(() => parseWorkflowIr(ir)).not.toThrow();
// Each seam appears exactly once, in execute → review → merge order.
const seamNodes = ir.nodes.filter((n) => typeof n.config?.seam === "string");
expect(seamNodes.map((n) => n.config!.seam)).toEqual(["execute", "review", "merge"]);
// Each seam has a failure → end edge.
for (const seam of ["execute", "review", "merge"]) {
const failEdge = ir.edges.find((e) => e.from === seam && e.condition === "failure");
expect(failEdge?.to).toBe("end");
}
// No duplicate failure edges per seam.
const failureEdges = ir.edges.filter((e) => e.condition === "failure");
expect(failureEdges).toHaveLength(3);
});
it("gate vs advisory both round-trip for prompt and script modes", () => {
const steps: WorkflowStep[] = [
step({ id: "WS-1", name: "PG", mode: "prompt", gateMode: "gate", prompt: "p" }),
step({ id: "WS-2", name: "PA", mode: "prompt", gateMode: "advisory", prompt: "p" }),
step({ id: "WS-3", name: "SG", mode: "script", gateMode: "gate", scriptName: "s" }),
step({ id: "WS-4", name: "SA", mode: "script", gateMode: "advisory", scriptName: "s" }),
];
const compiled = compileWorkflowToSteps(stepsToWorkflowIr(steps, "Gates"));
expect(compiled.map((c) => c.gateMode)).toEqual(["gate", "advisory", "gate", "advisory"]);
expect(compiled.map(visible)).toEqual(steps.map(visibleStep));
});
});
describe("stepToFragmentIr (R6/KTD-1)", () => {
it("produces a parseable start → node → end fragment mirroring the step", () => {
const s = step({
id: "WS-1",
name: "Doc",
description: "doc it",
mode: "prompt",
gateMode: "advisory",
prompt: "Document the change",
toolMode: "readonly",
});
const ir = stepToFragmentIr(s);
expect(() => parseWorkflowIr(ir)).not.toThrow();
expect(ir.nodes.map((n) => n.id)).toEqual(["start", "step-1", "end"]);
expect(ir.nodes.map((n) => n.kind)).toEqual(["start", "prompt", "end"]);
// The single node compiles back to a step mirroring the source.
const compiled = compileWorkflowToSteps(ir);
expect(compiled).toHaveLength(1);
expect(visible(compiled[0])).toEqual(visibleStep(s));
});
it("fragment IR is pure v1 (no v2-only features)", () => {
const ir = stepToFragmentIr(step({ id: "WS-1", name: "S", mode: "script", gateMode: "gate", scriptName: "lint" }));
// parseWorkflowIr upgrades to v2 in-memory; the SOURCE we built is v1-shaped.
const compiled = compileWorkflowToSteps(ir);
expect(compiled[0].mode).toBe("script");
expect(compiled[0].scriptName).toBe("lint");
});
});
describe("layoutForIr", () => {
it("produces x-spaced positions for every node", () => {
const ir = stepsToWorkflowIr(
[step({ id: "WS-1", name: "A", mode: "prompt", gateMode: "advisory", prompt: "a" })],
"L",
);
const layout = layoutForIr(ir);
expect(Object.keys(layout).sort()).toEqual(ir.nodes.map((n) => n.id).sort());
expect(layout.start).toEqual({ x: 60, y: 160 });
// Second node is one column over.
expect(layout[ir.nodes[1].id].x).toBe(60 + 170);
});
});

View File

@@ -156,3 +156,62 @@ export function resolveEffectiveAgentPermissionPolicy(
rules: policy.rules,
});
}
/**
* Disposition strictness rank for column-agent policy-escalation comparison
* (R13). A LOWER rank is *broader* (more privileged): `allow` lets an action
* through unconditionally, `require-approval` gates it, `block` denies it. An
* agent whose policy is broader than the project default on ANY action category
* is an escalation that must be explicitly confirmed at save time.
*/
const DISPOSITION_BREADTH_RANK: Record<AgentPermissionPolicyDisposition, number> = {
allow: 0,
"require-approval": 1,
block: 2,
};
/**
* The broadest (most-privileged) rank — used as the fallback when a category is
* absent from a policy's rules map. Treating a missing category as the broadest
* possible disposition (`allow`) ensures an absent key can never silently
* *suppress* a genuine escalation: the comparison only flags when the agent is
* at least as broad as the default, so an unknown agent-side category errs
* toward flagging, and an unknown default-side category errs toward the most
* permissive default (the conservative direction for escalation detection).
*/
const BROADEST_RANK = DISPOSITION_BREADTH_RANK.allow;
function dispositionRank(
rules: AgentPermissionPolicyRules,
category: (typeof AGENT_PERMISSION_POLICY_ACTION_CATEGORIES)[number],
): number {
const disposition = rules[category];
if (disposition === undefined) {
// An absent category must not suppress escalation. Treat the agent side as
// broadest (most privileged) so a missing key never narrows the comparison.
return BROADEST_RANK;
}
return DISPOSITION_BREADTH_RANK[disposition];
}
/**
* True when `agentPolicy`'s effective policy is broader (more privileged) than
* the project `defaultPolicy` on at least one action category (R13).
*
* Both arguments should already be resolved via
* {@link resolveEffectiveAgentPermissionPolicy}, which fills every category. The
* defensive per-category handling here guards against a partial/custom rules
* map slipping through with a missing category key — an absent key must never
* silently suppress a genuine escalation.
*/
export function isPolicyBroaderThanDefault(
agentPolicy: AgentPermissionPolicy,
defaultPolicy: AgentPermissionPolicy,
): boolean {
for (const category of AGENT_PERMISSION_POLICY_ACTION_CATEGORIES) {
const agentRank = dispositionRank(agentPolicy.rules, category);
const defaultRank = dispositionRank(defaultPolicy.rules, category);
if (agentRank < defaultRank) return true;
}
return false;
}

View File

@@ -1,5 +1,6 @@
import type { WorkflowIr } from "./workflow-ir-types.js";
import { parseWorkflowIr } from "./workflow-ir.js";
import { BUILTIN_WORKFLOW_SETTINGS } from "./builtin-workflow-settings.js";
/**
* The built-in default workflow as a v2 IR. Its six columns have ids that are
@@ -59,6 +60,9 @@ const RAW_BUILTIN_CODING_WORKFLOW_IR: WorkflowIr = {
{ from: "review", to: "end", condition: "failure" },
{ from: "merge", to: "end", condition: "failure" },
],
// Workflow-settings (U1, R4): declare the full moved-key catalog with defaults
// byte-equal to today's DEFAULT_PROJECT_SETTINGS literals. Inert until U3.
settings: BUILTIN_WORKFLOW_SETTINGS,
};
export const BUILTIN_CODING_WORKFLOW_IR = parseWorkflowIr(RAW_BUILTIN_CODING_WORKFLOW_IR);

View File

@@ -1,5 +1,6 @@
import type { WorkflowIr } from "./workflow-ir-types.js";
import { parseWorkflowIr } from "./workflow-ir.js";
import { BUILTIN_WORKFLOW_SETTINGS } from "./builtin-workflow-settings.js";
/**
* The built-in **stepwise** coding workflow (KTD-9) — the demonstration of step
@@ -144,6 +145,8 @@ const RAW_BUILTIN_STEPWISE_CODING_WORKFLOW_IR: WorkflowIr = {
{ from: "merge", to: "end", condition: "success" },
{ from: "merge", to: "end", condition: "failure" },
],
// Workflow-settings (U1, R4): same moved-key catalog as the default builtin.
settings: BUILTIN_WORKFLOW_SETTINGS,
};
export const BUILTIN_STEPWISE_CODING_WORKFLOW_IR = parseWorkflowIr(

View File

@@ -0,0 +1,256 @@
import type { WorkflowSettingDefinition } from "./workflow-ir-types.js";
/**
* The moved-key catalog declared as workflow settings (U1, R4).
*
* Single source of truth, imported by both built-in workflow IR files
* (`builtin-coding-workflow-ir.ts`, `builtin-stepwise-coding-workflow-ir.ts`) so
* the catalog has exactly one definition.
*
* Each `default` here MUST be byte-equal to the corresponding literal in
* `DEFAULT_PROJECT_SETTINGS` (`settings-schema.ts`) — this is the parity anchor
* for the U4 hard-move migration. The U1 test
* (`workflow-ir-settings.test.ts`) asserts strict equality against the legacy
* literals. Keys with `undefined` legacy defaults (the per-phase model lanes)
* omit `default` entirely, which round-trips to the same effective value.
*
* NOTE: these declarations are inert in U1 — nothing reads them until the
* effective-settings resolver and engine integration land (U3). Adding them does
* not change any built-in workflow's behavior.
*
* Keys deliberately NOT in this catalog (per KTD-4 / the catalog-shrink rule):
* - `completionDocumentationMode` — read outside per-task scope (triage), stays
* in project settings.
* - merge-cluster keys + `maxConcurrent` — owned by the columns/traits track.
*/
export const BUILTIN_WORKFLOW_SETTINGS: WorkflowSettingDefinition[] = [
// ── Step execution ─────────────────────────────────────────────────────
{
id: "workflowStepTimeoutMs",
name: "Step timeout (ms)",
type: "number",
default: 360_000,
description: "Maximum time a single workflow step may run before it is timed out.",
},
{
id: "workflowStepScopeEnforcement",
name: "Step scope enforcement",
type: "enum",
default: "block",
options: [
{ value: "block", label: "Block" },
{ value: "warn", label: "Warn" },
{ value: "off", label: "Off" },
],
description: "How to handle a step that writes outside its declared file scope.",
},
{
id: "planOnlyScopeLeakEnforcement",
name: "Plan-only scope leak enforcement",
type: "enum",
default: "warn",
options: [
{ value: "off", label: "Off" },
{ value: "warn", label: "Warn" },
{ value: "block", label: "Block" },
],
description: "How to handle code changes during a plan-only step.",
},
{
id: "workflowRevisionForkOnScopeMismatch",
name: "Fork workflow revision on scope mismatch",
type: "boolean",
default: true,
description: "Fork a new workflow revision when a step's actual scope diverges from its plan.",
},
{
id: "strictScopeEnforcement",
name: "Strict scope enforcement",
type: "boolean",
default: false,
description: "Enforce declared step scope strictly, rejecting any out-of-scope change.",
},
{
id: "runStepsInNewSessions",
name: "Run steps in new sessions",
type: "boolean",
default: false,
description: "Run each workflow step in its own agent session instead of a shared one.",
},
{
id: "maxParallelSteps",
name: "Max parallel steps",
type: "number",
default: 2,
description: "Maximum number of steps to run in parallel when running steps in new sessions.",
},
{
id: "buildRetryCount",
name: "Build retry count",
type: "number",
default: 0,
description: "Number of times to retry a failing build before giving up.",
},
// NOTE (U4 catalog-shrink): `buildTimeoutMs` was REMOVED from this catalog —
// it has NO reader anywhere in the engine, so per the per-task-reader rule
// (KTD-5) it stays a plain project setting and is NOT moved to workflow
// settings. It is therefore absent from `MOVED_SETTINGS_KEYS` and remains in
// `DEFAULT_PROJECT_SETTINGS`.
{
id: "verificationFixRetries",
name: "Verification fix retries",
type: "number",
default: 3,
description: "Number of automatic fix attempts after a failed verification.",
},
{
id: "maxPostReviewFixes",
name: "Max post-review fixes",
type: "number",
default: 1,
description: "Maximum number of automatic fix passes after review feedback.",
},
// ── Review / approval ──────────────────────────────────────────────────
{
id: "requirePrApproval",
name: "Require PR approval",
type: "boolean",
default: false,
description: "Require explicit approval before a pull request can be merged.",
},
{
id: "requirePlanApproval",
name: "Require plan approval",
type: "boolean",
default: false,
description: "Require explicit approval of the plan before execution begins.",
},
{
id: "reviewHandoffPolicy",
name: "Review handoff policy",
type: "enum",
default: "disabled",
options: [
{ value: "disabled", label: "Disabled" },
{ value: "comment-triggered", label: "Comment-triggered" },
{ value: "always", label: "Always" },
],
description: "When to hand off a task to a human reviewer.",
},
{
id: "maxReviewerContextRetries",
name: "Max reviewer context retries",
type: "number",
default: 2,
description: "Maximum reviewer retries due to insufficient context before falling back.",
},
{
id: "maxReviewerFallbackRetries",
name: "Max reviewer fallback retries",
type: "number",
default: 2,
description: "Maximum reviewer retries on the fallback model before failing.",
},
{
id: "reflectionEnabled",
name: "Reflection enabled",
type: "boolean",
default: false,
description: "Enable periodic reflection passes over completed work.",
},
// NOTE (U3 catalog-shrink, item 5): `reflectionIntervalMs` and
// `reflectionAfterTask` were REMOVED from this catalog — neither has any engine
// read site (verified by grep across packages/engine/src), so per the plan's
// catalog-shrink rule they stay plain project settings and are NOT moved to
// workflow settings. `reflectionEnabled` is kept because executor.ts reads it
// (gate for reflection tools).
// ── Per-phase model lanes ──────────────────────────────────────────────
// Legacy defaults are all `undefined`; `default` is omitted so resolution
// falls through to the global lane / project default (KTD-7).
{
id: "executionProvider",
name: "Execution provider",
type: "string",
description: "Provider for the execution phase. Empty falls through to the global lane.",
},
{
id: "executionModelId",
name: "Execution model",
type: "string",
description: "Model id for the execution phase. Empty falls through to the global lane.",
},
{
id: "planningProvider",
name: "Planning provider",
type: "string",
description: "Provider for the planning phase. Empty falls through to the global lane.",
},
{
id: "planningModelId",
name: "Planning model",
type: "string",
description: "Model id for the planning phase. Empty falls through to the global lane.",
},
{
id: "planningFallbackProvider",
name: "Planning fallback provider",
type: "string",
description: "Fallback provider for the planning phase.",
},
{
id: "planningFallbackModelId",
name: "Planning fallback model",
type: "string",
description: "Fallback model id for the planning phase.",
},
{
id: "validatorProvider",
name: "Validator provider",
type: "string",
description: "Provider for the validation phase. Empty falls through to the global lane.",
},
{
id: "validatorModelId",
name: "Validator model",
type: "string",
description: "Model id for the validation phase. Empty falls through to the global lane.",
},
{
id: "validatorFallbackProvider",
name: "Validator fallback provider",
type: "string",
description: "Fallback provider for the validation phase.",
},
{
id: "validatorFallbackModelId",
name: "Validator fallback model",
type: "string",
description: "Fallback model id for the validation phase.",
},
{
id: "titleSummarizerProvider",
name: "Title summarizer provider",
type: "string",
description: "Provider for summarizing task titles.",
},
{
id: "titleSummarizerModelId",
name: "Title summarizer model",
type: "string",
description: "Model id for summarizing task titles.",
},
{
id: "titleSummarizerFallbackProvider",
name: "Title summarizer fallback provider",
type: "string",
description: "Fallback provider for summarizing task titles.",
},
{
id: "titleSummarizerFallbackModelId",
name: "Title summarizer fallback model",
type: "string",
description: "Fallback model id for summarizing task titles.",
},
];

View File

@@ -1,5 +1,6 @@
import { BUILTIN_PR_WORKFLOW_IR } from "./builtin-pr-workflow-ir.js";
import { BUILTIN_STEPWISE_CODING_WORKFLOW_IR } from "./builtin-stepwise-coding-workflow-ir.js";
import { BUILTIN_WORKFLOW_SETTINGS } from "./builtin-workflow-settings.js";
import type { WorkflowDefinition } from "./workflow-definition-types.js";
import type { WorkflowIr } from "./workflow-ir-types.js";
import { parseWorkflowIr } from "./workflow-ir.js";
@@ -44,10 +45,20 @@ function linear(spec: BuiltinSpec): WorkflowDefinition {
layout[node.id] = { x: 60 + i * 170, y: 160 };
});
const ir = parseWorkflowIr({ version: "v1", name: spec.name, nodes, edges });
// Attach the moved-key settings catalog (U1/U3, R4) so every built-in workflow
// carries its declarations through the resolver path (resolveWorkflowIrById →
// resolveEffectiveSettings). v1 graphs upgrade to v2 on parse, so the parsed IR
// is v2 and can carry `settings`. Defaults are byte-equal to legacy
// DEFAULT_PROJECT_SETTINGS literals, so this is behavior-inert.
if (ir.version === "v2") {
ir.settings = BUILTIN_WORKFLOW_SETTINGS;
}
return {
id: spec.id,
name: spec.name,
description: spec.description,
// Built-ins are always selectable workflows, never fragments (KTD-1).
kind: "workflow",
ir,
layout,
createdAt: BUILTIN_TS,
@@ -153,6 +164,7 @@ export const BUILTIN_WORKFLOWS: WorkflowDefinition[] = [
name: "Stepwise coding (built-in)",
description:
"Per-step plan, execute, and review modeled as graph structure: each planned step runs and is reviewed (approve / revise / rethink) before the next, with bounded rework. Requires the workflow graph executor.",
kind: "workflow",
ir: BUILTIN_STEPWISE_CODING_WORKFLOW_IR,
layout: {
start: { x: 60, y: 160 },
@@ -185,6 +197,7 @@ export const BUILTIN_WORKFLOWS: WorkflowDefinition[] = [
name: "PR lifecycle (built-in)",
description:
"The unified PR lifecycle as graph nodes: create the PR, await review, respond to changes (bounded rework loop), gate on auto-merge, then merge — with GitHub reconciliation advancing the await holds. Requires the workflow graph executor.",
kind: "workflow",
ir: BUILTIN_PR_WORKFLOW_IR,
layout: {
start: { x: 60, y: 160 },

View File

@@ -83,6 +83,7 @@ import { getAppVersion, parseSemver } from "./app-version.js";
import { validateDockerNodeConfig } from "./types.js";
import { CentralDatabase, toJson, toJsonNullable, fromJson } from "./central-db.js";
import { resolveGlobalDir } from "./global-settings.js";
import { stripMovedSettingsKeys } from "./moved-settings.js";
import { NodeConnection } from "./node-connection.js";
import { NodeDiscovery } from "./node-discovery.js";
import { collectSystemMetrics } from "./system-metrics.js";
@@ -3659,12 +3660,18 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
let projectCount = 0;
const authCount = payload.providerAuth ? Object.keys(payload.providerAuth).length : 0;
// Apply global settings (shallow merge, local-wins)
// Apply global settings (shallow merge, local-wins).
// Moved (tombstoned) keys are dropped here as a second line of defense — a
// mid-migration peer must never resurrect a moved key cross-node (KTD-8). The
// count reflects only the keys that survive the strip.
if (payload.global) {
// The actual application of global settings is handled by the caller (dashboard route)
// since CentralCore doesn't have access to GlobalSettingsStore.
// We simply count the number of global settings entries for reporting.
globalCount = Object.keys(payload.global).length;
// since CentralCore doesn't have access to GlobalSettingsStore. Mutate the payload
// in place so the caller applies the stripped version — otherwise moved keys survive
// in payload.global and get resurrected cross-node (KTD-8).
const cleanGlobal = stripMovedSettingsKeys(payload.global as Record<string, unknown>);
payload.global = cleanGlobal as typeof payload.global;
globalCount = Object.keys(cleanGlobal).length;
}
// Apply project settings (match by name, local-wins merge)
@@ -3675,11 +3682,17 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
for (const [projectName, remoteSettings] of Object.entries(payload.projects)) {
const localProject = projectsByName.get(projectName);
if (localProject) {
// Strip moved keys from the inbound remote settings before merging —
// defense beyond the store guard so they can never be persisted into a
// project's raw config via the cross-node path (KTD-8).
const cleanRemote = stripMovedSettingsKeys(
(remoteSettings ?? {}) as unknown as Record<string, unknown>,
) as Partial<ProjectSettings>;
// Merge settings: local values take precedence
const mergedSettings: ProjectSettings = {
...remoteSettings,
...cleanRemote,
...localProject.settings,
};
} as ProjectSettings;
await this.updateProject(localProject.id, { settings: mergedSettings });
projectCount++;
}

View File

@@ -86,6 +86,7 @@ interface ChatSessionRow {
updatedAt: string;
cliSessionFile: string | null;
inFlightGeneration: string | null;
cliExecutorAdapterId: string | null;
}
/** Database row shape for chat_messages. */
@@ -161,6 +162,7 @@ export class ChatStore extends EventEmitter<ChatStoreEvents> {
updatedAt: row.updatedAt,
cliSessionFile: row.cliSessionFile ?? null,
inFlightGeneration: fromJson<ChatInFlightGenerationState>(row.inFlightGeneration) ?? null,
cliExecutorAdapterId: row.cliExecutorAdapterId ?? null,
};
}
@@ -254,11 +256,12 @@ export class ChatStore extends EventEmitter<ChatStoreEvents> {
updatedAt: now,
cliSessionFile: null,
inFlightGeneration: null,
cliExecutorAdapterId: input.cliExecutorAdapterId ?? null,
};
this.db.prepare(`
INSERT INTO chat_sessions (id, agentId, title, status, projectId, modelProvider, modelId, createdAt, updatedAt, inFlightGeneration)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO chat_sessions (id, agentId, title, status, projectId, modelProvider, modelId, createdAt, updatedAt, inFlightGeneration, cliExecutorAdapterId)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
session.id,
session.agentId,
@@ -270,6 +273,7 @@ export class ChatStore extends EventEmitter<ChatStoreEvents> {
session.createdAt,
session.updatedAt,
null,
session.cliExecutorAdapterId,
);
this.db.bumpLastModified();
@@ -466,6 +470,27 @@ export class ChatStore extends EventEmitter<ChatStoreEvents> {
this.db.bumpLastModified();
}
/**
* Set (or clear) the cli-agent adapter that backs this chat session (U12).
* When set, the chat is CLI-backed: composer sends route through the inject
* path and adapter transcript events map to chat_messages rows. Emits a
* session update so the client can switch to the CLI-backed rendering path.
*
* @param id - Session ID
* @param adapterId - cli-agent adapter id, or null to revert to the provider path
*/
setCliExecutorAdapterId(id: string, adapterId: string | null): ChatSession | undefined {
const existing = this.getSession(id);
if (!existing) return undefined;
this.db
.prepare("UPDATE chat_sessions SET cliExecutorAdapterId = ?, updatedAt = ? WHERE id = ?")
.run(adapterId, new Date().toISOString(), id);
this.db.bumpLastModified();
const updated = this.getSession(id)!;
this.emit("chat:session:updated", updated);
return updated;
}
setInFlightGeneration(id: string, inFlightGeneration: ChatInFlightGenerationState | null): ChatSession | undefined {
const existing = this.getSession(id);
if (!existing) return undefined;

View File

@@ -68,6 +68,13 @@ export interface ChatSession {
* for sessions that have never produced an assistant reply.
*/
cliSessionFile: string | null;
/**
* cli-agent adapter id backing this chat session (CLI Agent Executor, U12).
* When non-null the chat is CLI-backed: composer sends inject into a live
* CLI session and adapter transcript events map to chat_messages rows. Null
* means the chat uses the standard model-provider path.
*/
cliExecutorAdapterId: string | null;
/** Durable in-flight assistant snapshot used to recover streaming UI after refresh. */
inFlightGeneration: ChatInFlightGenerationState | null;
}
@@ -160,6 +167,8 @@ export interface ChatSessionCreateInput {
modelProvider?: string | null;
/** Optional model ID override */
modelId?: string | null;
/** Optional cli-agent adapter id; when set the chat is CLI-backed (U12) */
cliExecutorAdapterId?: string | null;
}
/**

View File

@@ -0,0 +1,336 @@
/**
* CliSessionStore - Data layer for durable CLI agent session records
* (CLI Agent Executor, U1).
*
* Manages CRUD for the `cli_sessions` table: the long-lived record that
* survives executor restarts so a session can be reasoned about, resumed,
* or reaped from its persisted state.
*
* Follows the same patterns as ChatStore:
* - EventEmitter for change notifications.
* - SQLite for structured data storage.
* - JSON columns for nested data (autonomyPosture).
* - Validation at the store boundary: invalid enum values are rejected.
*/
import { EventEmitter } from "node:events";
import { randomUUID } from "node:crypto";
import type { Database } from "./db.js";
import { fromJson, toJsonNullable } from "./db.js";
import {
isCliAgentState,
isCliSessionPurpose,
isCliTerminationReason,
type CliAgentState,
type CliAutonomyPosture,
type CliSession,
type CliSessionCreateInput,
type CliSessionPurpose,
type CliSessionUpdateInput,
type CliTerminationReason,
} from "./cli-session-types.js";
// ── Event Types ─────────────────────────────────────────────────────────
export interface CliSessionStoreEvents {
/** Emitted when a CLI session record is created. */
"cli-session:created": [session: CliSession];
/** Emitted when a CLI session record is updated. */
"cli-session:updated": [session: CliSession];
/** Emitted when a CLI session record is deleted. */
"cli-session:deleted": [sessionId: string];
}
// ── Row Interface ────────────────────────────────────────────────────────
/** Database row shape for cli_sessions. */
interface CliSessionRow {
id: string;
taskId: string | null;
chatSessionId: string | null;
purpose: string;
projectId: string;
adapterId: string;
agentState: string;
terminationReason: string | null;
nativeSessionId: string | null;
resumeAttempts: number;
autonomyPosture: string | null;
worktreePath: string | null;
createdAt: string;
updatedAt: string;
}
// ── CliSessionStore Class ────────────────────────────────────────────────
export class CliSessionStore extends EventEmitter<CliSessionStoreEvents> {
constructor(
private fusionDir: string,
private db: Database,
) {
super();
this.setMaxListeners(100);
}
// ── Row-to-Object Converter ──────────────────────────────────────────
private rowToSession(row: CliSessionRow): CliSession {
return {
id: row.id,
taskId: row.taskId ?? null,
chatSessionId: row.chatSessionId ?? null,
purpose: row.purpose as CliSessionPurpose,
projectId: row.projectId,
adapterId: row.adapterId,
agentState: row.agentState as CliAgentState,
terminationReason: (row.terminationReason as CliTerminationReason | null) ?? null,
nativeSessionId: row.nativeSessionId ?? null,
resumeAttempts: row.resumeAttempts ?? 0,
autonomyPosture: fromJson<CliAutonomyPosture>(row.autonomyPosture) ?? null,
worktreePath: row.worktreePath ?? null,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
// ── Boundary validation ──────────────────────────────────────────────
private assertAgentState(value: unknown): asserts value is CliAgentState {
if (!isCliAgentState(value)) {
throw new Error(`Invalid CLI agent state: ${JSON.stringify(value)}`);
}
}
private assertPurpose(value: unknown): asserts value is CliSessionPurpose {
if (!isCliSessionPurpose(value)) {
throw new Error(`Invalid CLI session purpose: ${JSON.stringify(value)}`);
}
}
private assertTerminationReason(
value: unknown,
): asserts value is CliTerminationReason | null {
if (value === null || value === undefined) return;
if (!isCliTerminationReason(value)) {
throw new Error(`Invalid CLI termination reason: ${JSON.stringify(value)}`);
}
}
// ── CRUD Operations ──────────────────────────────────────────────────
/**
* Create a new CLI session record.
*
* @throws Error if any enum value (purpose / agentState / terminationReason)
* is invalid, or required fields are missing.
*/
createSession(input: CliSessionCreateInput): CliSession {
this.assertPurpose(input.purpose);
const agentState: CliAgentState = input.agentState ?? "starting";
this.assertAgentState(agentState);
this.assertTerminationReason(input.terminationReason ?? null);
if (!input.projectId) {
throw new Error("CLI session requires a projectId");
}
if (!input.adapterId) {
throw new Error("CLI session requires an adapterId");
}
const now = new Date().toISOString();
const id = input.id ?? `cli-${randomUUID().slice(0, 8)}`;
const resumeAttempts = input.resumeAttempts ?? 0;
const session: CliSession = {
id,
taskId: input.taskId ?? null,
chatSessionId: input.chatSessionId ?? null,
purpose: input.purpose,
projectId: input.projectId,
adapterId: input.adapterId,
agentState,
terminationReason: input.terminationReason ?? null,
nativeSessionId: input.nativeSessionId ?? null,
resumeAttempts,
autonomyPosture: input.autonomyPosture ?? null,
worktreePath: input.worktreePath ?? null,
createdAt: now,
updatedAt: now,
};
this.db
.prepare(
`INSERT INTO cli_sessions (
id, taskId, chatSessionId, purpose, projectId, adapterId,
agentState, terminationReason, nativeSessionId, resumeAttempts,
autonomyPosture, worktreePath, createdAt, updatedAt
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.run(
session.id,
session.taskId,
session.chatSessionId,
session.purpose,
session.projectId,
session.adapterId,
session.agentState,
session.terminationReason,
session.nativeSessionId,
session.resumeAttempts,
toJsonNullable(session.autonomyPosture),
session.worktreePath,
session.createdAt,
session.updatedAt,
);
this.db.bumpLastModified();
this.emit("cli-session:created", session);
return session;
}
/** Get a CLI session record by ID. */
getSession(id: string): CliSession | undefined {
const row = this.db
.prepare("SELECT * FROM cli_sessions WHERE id = ?")
.get(id) as unknown as CliSessionRow | undefined;
if (!row) return undefined;
return this.rowToSession(row);
}
/**
* List CLI session records with optional filtering.
*
* @returns Array of sessions ordered by updatedAt DESC.
*/
listSessions(options?: {
taskId?: string;
chatSessionId?: string;
projectId?: string;
agentState?: CliAgentState;
purpose?: CliSessionPurpose;
}): CliSession[] {
const whereClauses: string[] = [];
const params: string[] = [];
if (options?.taskId !== undefined) {
whereClauses.push("taskId = ?");
params.push(options.taskId);
}
if (options?.chatSessionId !== undefined) {
whereClauses.push("chatSessionId = ?");
params.push(options.chatSessionId);
}
if (options?.projectId !== undefined) {
whereClauses.push("projectId = ?");
params.push(options.projectId);
}
if (options?.agentState !== undefined) {
this.assertAgentState(options.agentState);
whereClauses.push("agentState = ?");
params.push(options.agentState);
}
if (options?.purpose !== undefined) {
this.assertPurpose(options.purpose);
whereClauses.push("purpose = ?");
params.push(options.purpose);
}
const whereSql = whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : "";
const rows = this.db
.prepare(`SELECT * FROM cli_sessions ${whereSql} ORDER BY updatedAt DESC`)
.all(...params);
return (rows as unknown as CliSessionRow[]).map((row) => this.rowToSession(row));
}
/** List CLI session records owned by a task. */
listByTask(taskId: string): CliSession[] {
return this.listSessions({ taskId });
}
/** List CLI session records owned by a chat session. */
listByChatSession(chatSessionId: string): CliSession[] {
return this.listSessions({ chatSessionId });
}
/**
* Update a CLI session record.
*
* State, terminationReason, and resumeAttempts are written atomically in a
* single UPDATE statement, so a state transition that also records why the
* session ended and how many resumes were attempted cannot tear.
*
* @throws Error if any provided enum value is invalid.
* @returns The updated session, or undefined if not found.
*/
updateSession(id: string, input: CliSessionUpdateInput): CliSession | undefined {
const existing = this.getSession(id);
if (!existing) return undefined;
if (input.agentState !== undefined) {
this.assertAgentState(input.agentState);
}
if (input.terminationReason !== undefined) {
this.assertTerminationReason(input.terminationReason);
}
const now = new Date().toISOString();
const setClauses: string[] = ["updatedAt = ?"];
const params: (string | number | null)[] = [now];
if (input.taskId !== undefined) {
setClauses.push("taskId = ?");
params.push(input.taskId);
}
if (input.chatSessionId !== undefined) {
setClauses.push("chatSessionId = ?");
params.push(input.chatSessionId);
}
if (input.agentState !== undefined) {
setClauses.push("agentState = ?");
params.push(input.agentState);
}
if (input.terminationReason !== undefined) {
setClauses.push("terminationReason = ?");
params.push(input.terminationReason);
}
if (input.nativeSessionId !== undefined) {
setClauses.push("nativeSessionId = ?");
params.push(input.nativeSessionId);
}
if (input.resumeAttempts !== undefined) {
setClauses.push("resumeAttempts = ?");
params.push(input.resumeAttempts);
}
if (input.autonomyPosture !== undefined) {
setClauses.push("autonomyPosture = ?");
params.push(toJsonNullable(input.autonomyPosture));
}
if (input.worktreePath !== undefined) {
setClauses.push("worktreePath = ?");
params.push(input.worktreePath);
}
params.push(id);
this.db
.prepare(`UPDATE cli_sessions SET ${setClauses.join(", ")} WHERE id = ?`)
.run(...params);
const updated = this.getSession(id)!;
this.db.bumpLastModified();
this.emit("cli-session:updated", updated);
return updated;
}
/** Delete a CLI session record. */
deleteSession(id: string): boolean {
const existing = this.getSession(id);
if (!existing) return false;
this.db.prepare("DELETE FROM cli_sessions WHERE id = ?").run(id);
this.db.bumpLastModified();
this.emit("cli-session:deleted", id);
return true;
}
}

View File

@@ -0,0 +1,196 @@
/**
* CLI agent session type definitions (CLI Agent Executor, U1).
*
* Defines the durable record shape for a CLI agent session — the long-lived
* process that drives a single autonomy unit (a task execution, a planning
* pass, a validator run, a CE run, or an interactive chat). These records
* outlive the in-memory executor so a crashed/restarted Fusion instance can
* reason about, resume, or reap sessions from their persisted state.
*
* Follows the same conventions as chat-types.ts:
* - String-literal unions for enums.
* - Nullable owning-entity references (taskId / chatSessionId).
* - JSON-serialized structured columns (autonomyPosture).
*/
// ── Enums / String Literals ─────────────────────────────────────────────
/**
* Lifecycle state of a CLI agent session.
*
* Transitions (typical): starting → ready → busy ↔ waitingOnInput → done,
* with dead / needsAttention reachable from any active state on failure or
* a condition requiring operator intervention.
*/
export type CliAgentState =
| "starting"
| "ready"
| "busy"
| "waitingOnInput"
| "done"
| "dead"
| "needsAttention";
/** All valid agent states, for runtime validation at the store boundary. */
export const CLI_AGENT_STATES: readonly CliAgentState[] = [
"starting",
"ready",
"busy",
"waitingOnInput",
"done",
"dead",
"needsAttention",
] as const;
/**
* Why a CLI agent session terminated. Null while the session is still live.
*
* Termination taxonomy (KTD):
* - completed — the agent finished its unit of work successfully.
* - userExited — the user/operator deliberately stopped the session.
* - killed — the session was force-terminated (e.g. supervisor reap).
* - crashed — the underlying process exited abnormally / unexpectedly.
* - authFailed — the session ended because credentials/auth were rejected.
* - engineDeath — the owning Fusion engine/process died, orphaning the session.
*/
export type CliTerminationReason =
| "completed"
| "userExited"
| "killed"
| "crashed"
| "authFailed"
| "engineDeath";
/** All valid termination reasons, for runtime validation at the store boundary. */
export const CLI_TERMINATION_REASONS: readonly CliTerminationReason[] = [
"completed",
"userExited",
"killed",
"crashed",
"authFailed",
"engineDeath",
] as const;
/**
* The purpose a CLI agent session serves — which autonomy unit it drives.
*
* - execute — a task execution run.
* - planning — a planning / triage pass.
* - validator — a validator / acceptance run.
* - ce — a compound-engineering run.
* - chat — an interactive chat session.
*/
export type CliSessionPurpose = "execute" | "planning" | "validator" | "ce" | "chat";
/** All valid session purposes, for runtime validation at the store boundary. */
export const CLI_SESSION_PURPOSES: readonly CliSessionPurpose[] = [
"execute",
"planning",
"validator",
"ce",
"chat",
] as const;
// ── Core Types ──────────────────────────────────────────────────────────
/**
* Operator-configured autonomy posture for a session. Stored as JSON.
*
* Kept intentionally open-ended (structured but extensible) so posture
* controls can evolve without a schema migration. Persisted verbatim.
*/
export interface CliAutonomyPosture {
/** Whether the session may proceed without per-step approval. */
autoApprove?: boolean;
/** Maximum number of resume attempts permitted before giving up. */
maxResumeAttempts?: number;
/** Free-form, forward-compatible posture fields. */
[key: string]: unknown;
}
/**
* A durable CLI agent session record.
*
* Exactly one of `taskId` / `chatSessionId` is typically set, matching the
* owning entity for the session's `purpose` (chat → chatSessionId; the rest →
* taskId). Both may be null for sessions not yet attached to an entity.
*/
export interface CliSession {
/** Stable primary key. */
id: string;
/** Owning task ID, when this session drives task work. Null otherwise. */
taskId: string | null;
/** Owning chat session ID, when purpose is "chat". Null otherwise. */
chatSessionId: string | null;
/** What autonomy unit this session drives. */
purpose: CliSessionPurpose;
/** Project this session belongs to. */
projectId: string;
/** Adapter (CLI agent integration) backing the session. */
adapterId: string;
/** Current lifecycle state. */
agentState: CliAgentState;
/** Why the session terminated, or null while live. */
terminationReason: CliTerminationReason | null;
/** Native (adapter/process) session identifier, for resume. Null until known. */
nativeSessionId: string | null;
/** Number of resume attempts made so far. */
resumeAttempts: number;
/** Operator-configured autonomy posture. */
autonomyPosture: CliAutonomyPosture | null;
/** Worktree path the session operates in. */
worktreePath: string | null;
/** When the record was created (ISO 8601). */
createdAt: string;
/** When the record was last updated (ISO 8601). */
updatedAt: string;
}
/** Input for creating a CLI session record. */
export interface CliSessionCreateInput {
/** Optional explicit ID; generated when omitted. */
id?: string;
taskId?: string | null;
chatSessionId?: string | null;
purpose: CliSessionPurpose;
projectId: string;
adapterId: string;
/** Initial state; defaults to "starting" when omitted. */
agentState?: CliAgentState;
terminationReason?: CliTerminationReason | null;
nativeSessionId?: string | null;
resumeAttempts?: number;
autonomyPosture?: CliAutonomyPosture | null;
worktreePath?: string | null;
}
/** Partial updates to a CLI session record. */
export interface CliSessionUpdateInput {
taskId?: string | null;
chatSessionId?: string | null;
agentState?: CliAgentState;
terminationReason?: CliTerminationReason | null;
nativeSessionId?: string | null;
resumeAttempts?: number;
autonomyPosture?: CliAutonomyPosture | null;
worktreePath?: string | null;
}
// ── Validation helpers ───────────────────────────────────────────────────
/** Narrow an unknown value to a valid CliAgentState. */
export function isCliAgentState(value: unknown): value is CliAgentState {
return typeof value === "string" && (CLI_AGENT_STATES as readonly string[]).includes(value);
}
/** Narrow an unknown value to a valid CliTerminationReason. */
export function isCliTerminationReason(value: unknown): value is CliTerminationReason {
return (
typeof value === "string" && (CLI_TERMINATION_REASONS as readonly string[]).includes(value)
);
}
/** Narrow an unknown value to a valid CliSessionPurpose. */
export function isCliSessionPurpose(value: unknown): value is CliSessionPurpose {
return typeof value === "string" && (CLI_SESSION_PURPOSES as readonly string[]).includes(value);
}

View File

@@ -0,0 +1,104 @@
import type { AgentStore } from "./agent-store.js";
import type { Settings } from "./types.js";
import type { WorkflowIr, WorkflowIrColumn } from "./workflow-ir-types.js";
import {
isPolicyBroaderThanDefault,
resolveEffectiveAgentPermissionPolicy,
} from "./agent-permission-policy.js";
/**
* Typed error raised when a workflow IR binds a column to an agent that fails a
* write-time check (existence or policy escalation, R11/R13). Carries the
* offending column id and a `reason` discriminant so each write surface can map
* it to its own transport (the dashboard route → an HTTP 400; the agent tools →
* a structured tool error) without re-deriving the message.
*
* Shared between the dashboard workflow route and the `fn_workflow_create` /
* `fn_workflow_update` agent tools so both write paths enforce the SAME gate —
* an agent must not be able to persist a binding the UI would reject.
*/
export class ColumnAgentBindingError extends Error {
readonly columnId: string;
readonly agentId: string;
readonly reason: "unknown-agent" | "policy-escalation";
constructor(args: {
message: string;
columnId: string;
agentId: string;
reason: "unknown-agent" | "policy-escalation";
}) {
super(args.message);
this.name = "ColumnAgentBindingError";
this.columnId = args.columnId;
this.agentId = args.agentId;
this.reason = args.reason;
}
}
/**
* Write-time column-agent validation (U6, R11/R13), shared by every write
* surface. Inspects an IR's columns BEFORE it is persisted and throws a typed
* {@link ColumnAgentBindingError} naming the offending column. Never mutates the
* IR and never touches the store/scheduler.
*
* Two checks per bound column:
* 1. Existence — every `column.agent.agentId` must resolve in the agent
* registry; an unknown id throws (`reason: "unknown-agent"`) so the binding
* can't be saved and silently fall back at execution time.
* 2. Policy escalation (R13) — if the bound agent's effective permission policy
* is broader (more privileged) than the project default on any action
* category, the write requires an explicit `confirmPolicyEscalation` flag,
* else it throws (`reason: "policy-escalation"`). Override must never
* silently re-key action gates to a more-privileged agent.
*
* Config is data: bindings are accepted regardless of feature flags — flags gate
* execution, not storage. A null/non-object IR or columns array is left to the
* store's own validator (this only inspects shapes it can read).
*/
export async function validateColumnAgentBindings(args: {
ir: WorkflowIr | unknown;
agentStore: AgentStore;
settings: Pick<Settings, "defaultAgentPermissionPolicy">;
confirmPolicyEscalation: boolean;
}): Promise<void> {
const { ir, agentStore, settings, confirmPolicyEscalation } = args;
const columns = (ir as { columns?: unknown })?.columns;
if (!Array.isArray(columns)) return;
const bound = (columns as WorkflowIrColumn[]).filter(
(col) => col && typeof col === "object" && col.agent && typeof col.agent.agentId === "string",
);
if (bound.length === 0) return;
const defaultPolicy = resolveEffectiveAgentPermissionPolicy(
undefined,
settings.defaultAgentPermissionPolicy,
);
for (const col of bound) {
const agentId = col.agent!.agentId;
const agent = await agentStore.getAgent(agentId);
if (!agent) {
throw new ColumnAgentBindingError({
message: `Column '${col.id}' binds unknown agent '${agentId}'`,
columnId: col.id,
agentId,
reason: "unknown-agent",
});
}
const agentPolicy = resolveEffectiveAgentPermissionPolicy(
agent.permissionPolicy,
settings.defaultAgentPermissionPolicy,
);
if (isPolicyBroaderThanDefault(agentPolicy, defaultPolicy) && !confirmPolicyEscalation) {
throw new ColumnAgentBindingError({
message:
`Column '${col.id}' binds agent '${agentId}' whose permission policy is broader than ` +
`the project default; set confirmPolicyEscalation: true to confirm`,
columnId: col.id,
agentId,
reason: "policy-escalation",
});
}
}
}

View File

@@ -0,0 +1,219 @@
/**
* Column-agent effective resolution (column-agent plan KTD-2).
*
* One shared resolver in `@fusion/core` consumed by every reader (the three engine
* resolution sites and the dashboard write-validation route) so engine and route
* can never drift — the route/engine predicate-drift learning
* (`docs/solutions/integration-issues/branch-group-single-pr-synthetic-id-dead-wiring.md`).
*
* Two pure functions:
* - `resolveColumnAgentBinding(ir, nodeId)` — declared-column lookup with foreach
* template inheritance — answers "which column binding (if any) governs this
* node's work?".
* - `resolveEffectiveAgent(...)` — defer/override precedence as EXPLICIT named
* branches (never a `??` effective-value collapse), per the per-task
* auto-merge-override learning
* (`docs/solutions/logic-errors/per-task-auto-merge-override-ignored-by-trigger-gates.md`).
* Returns a discriminated result so callers and audit logs can state *why* an
* agent was chosen.
*
* This module must stay DI-clean: `@fusion/core` never imports from `@fusion/engine`.
*/
import type { WorkflowColumnAgent, WorkflowForeachConfig, WorkflowIr } from "./workflow-ir-types.js";
// ── Foreach instance node-id ownership (column-agent plan KTD-2) ──────────────
// The instance-id FORMAT (`<foreachId>#<stepIndex>:<templateNodeId>`) now has
// exactly one owner here in core. The engine re-points its import (was
// `workflow-graph-foreach.ts`). The format itself is unchanged.
/** Materialize a deterministic foreach instance node id (step-inversion KTD-3).
* Pure, no IR mutation. Format: `<foreachId>#<stepIndex>:<templateNodeId>`. */
export function instanceNodeId(
foreachNodeId: string,
stepIndex: number,
templateNodeId: string,
): string {
return `${foreachNodeId}#${stepIndex}:${templateNodeId}`;
}
/** Parsed components of a foreach instance node id. */
export interface ParsedInstanceNodeId {
foreachNodeId: string;
stepIndex: number;
templateNodeId: string;
}
/** Parse a foreach instance node id back into its components, or `undefined` when
* `nodeId` is not in instance form. Defensive against `templateNodeId` itself
* containing `:` — split on the FIRST `#`, then the FIRST `:` of the remainder,
* and keep everything after that as the template node id. The `templateNodeId` is
* not sanitized against `:`, so a greedy/last-delimiter split would corrupt it.
*
* NOTE: a `foreachNodeId` that itself contains `#` is ambiguous under any single
* split. Callers that hold the IR should use {@link parseInstanceNodeIdCandidates}
* and validate each candidate's `foreachNodeId` against the graph (as
* `resolveColumnAgentBinding` does) instead of trusting one split position. */
export function parseInstanceNodeId(nodeId: string): ParsedInstanceNodeId | undefined {
const hashIndex = nodeId.indexOf("#");
if (hashIndex < 0) return undefined;
return parseInstanceNodeIdAt(nodeId, hashIndex);
}
/** Parse treating the `#` at `hashIndex` as the instance-id delimiter. */
function parseInstanceNodeIdAt(nodeId: string, hashIndex: number): ParsedInstanceNodeId | undefined {
const foreachNodeId = nodeId.slice(0, hashIndex);
const remainder = nodeId.slice(hashIndex + 1);
const colonIndex = remainder.indexOf(":");
if (colonIndex < 0) return undefined;
const stepIndexRaw = remainder.slice(0, colonIndex);
const templateNodeId = remainder.slice(colonIndex + 1);
if (foreachNodeId === "" || templateNodeId === "") return undefined;
// stepIndex must be a non-negative integer; reject anything else as non-instance.
if (!/^\d+$/.test(stepIndexRaw)) return undefined;
const stepIndex = Number(stepIndexRaw);
return { foreachNodeId, stepIndex, templateNodeId };
}
/** Every plausible parse of `nodeId` as an instance id — one candidate per `#`
* whose suffix matches the `<digits>:` shape. The id format is ambiguous when
* node ids themselves contain `#` (e.g. foreach `f#a`, instance `f#a#0:t` — both
* the first and second `#` look like delimiters), so callers with access to the
* graph validate each candidate's `foreachNodeId` against real foreach nodes
* rather than committing to a single split position. Ordered left-to-right. */
export function parseInstanceNodeIdCandidates(nodeId: string): ParsedInstanceNodeId[] {
const candidates: ParsedInstanceNodeId[] = [];
for (let i = nodeId.indexOf("#"); i >= 0; i = nodeId.indexOf("#", i + 1)) {
const parsed = parseInstanceNodeIdAt(nodeId, i);
if (parsed) candidates.push(parsed);
}
return candidates;
}
// ── Binding lookup ───────────────────────────────────────────────────────────
/** Index a graph's top-level nodes by id (handles v1 + v2 shapes). */
function topLevelNodesById(ir: WorkflowIr): Map<string, WorkflowIr["nodes"][number]> {
return new Map(ir.nodes.map((n) => [n.id, n]));
}
/** Resolve the agent binding (if any) that governs the work of `nodeId`.
*
* A column WITHOUT an `agent` field yields `undefined` — that, not "column
* undeclared," is the operative guarantee, since v1→v2 upgrade synthesizes a
* column for every node (column-agent plan KTD-2).
*
* Foreach instance ids (`<foreachId>#<i>:<templateNodeId>`) resolve against the
* ENCLOSING foreach node's column, but a template node that declares its OWN
* `column` wins over inheritance (R4). */
export function resolveColumnAgentBinding(
ir: WorkflowIr,
nodeId: string,
): WorkflowColumnAgent | undefined {
// v1 graphs have no columns and therefore no bindings. (Callers normally parse
// to v2 first, but stay defensive.)
if (ir.version !== "v2") return undefined;
const columnsById = new Map(ir.columns.map((c) => [c.id, c]));
const bindingForColumn = (columnId: string | undefined): WorkflowColumnAgent | undefined => {
if (columnId === undefined) return undefined;
return columnsById.get(columnId)?.agent;
};
const nodesById = topLevelNodesById(ir);
// Direct (top-level) node.
const direct = nodesById.get(nodeId);
if (direct) {
return bindingForColumn(direct.column);
}
// Foreach instance node: resolve against the enclosing foreach, honoring a
// template node's own declared column. The instance-id format is ambiguous when
// node ids contain `#`, so try every plausible split and accept the first whose
// foreachNodeId names a REAL foreach node in this graph — a single fixed split
// (first-# or last-#) silently bypasses bindings for ids on the other side of
// the ambiguity (PR #1432 review).
for (const parsed of parseInstanceNodeIdCandidates(nodeId)) {
const foreachNode = nodesById.get(parsed.foreachNodeId);
if (!foreachNode || foreachNode.kind !== "foreach") continue;
const cfg = foreachNode.config as Partial<WorkflowForeachConfig> | undefined;
const templateNodes = cfg?.template?.nodes ?? [];
const templateNode = templateNodes.find((n) => n.id === parsed.templateNodeId);
// Disambiguation guard (PR #1432 review): a bogus prefix candidate can name a
// real foreach while its templateNodeId doesn't exist under it — skip it so a
// later exact parse isn't masked. A template with no nodes still inherits.
if (templateNodes.length > 0 && !templateNode) continue;
// Template node's own column wins; otherwise inherit the foreach node's column.
if (templateNode?.column !== undefined) {
return bindingForColumn(templateNode.column);
}
return bindingForColumn(foreachNode.column);
}
return undefined;
}
// ── Effective-agent precedence (defer / override) ────────────────────────────
/** Inputs to the effective-agent decision. `ownAgentId` is the work's own agent
* identity (node `cfg.agentId` or `task.assignedAgentId`); `ownModelProvider` /
* `ownModelId` are the work's own model pair (node cfg or task model fields). */
export interface EffectiveAgentInput {
/** The binding governing this node, from `resolveColumnAgentBinding`. */
binding: WorkflowColumnAgent | undefined;
/** The work's own agent identity, if any. */
ownAgentId?: string;
/** The work's own model provider, if any. */
ownModelProvider?: string;
/** The work's own model id, if any. */
ownModelId?: string;
}
/** Discriminated result of effective-agent resolution: callers and audit logs can
* state *why* an agent was (or was not) chosen (column-agent plan KTD-2). */
export type EffectiveAgentResult =
| { source: "column-agent"; agentId: string }
| { source: "own-settings" }
| { source: "none" };
/** Does the work carry "own settings" that suppress a `defer` column agent
* (column-agent plan KTD-5)? All-or-nothing: an own agent identity OR a COMPLETE
* modelProvider+modelId pair counts. A lone provider with no modelId and no
* agentId does NOT count — matching `resolveExecutorSessionModel`'s both-present
* rule (`packages/engine/src/agent-session-helpers.ts:147-150`). */
function hasOwnSettings(input: EffectiveAgentInput): boolean {
const hasOwnAgent = typeof input.ownAgentId === "string" && input.ownAgentId !== "";
const hasCompletePair =
typeof input.ownModelProvider === "string" &&
input.ownModelProvider !== "" &&
typeof input.ownModelId === "string" &&
input.ownModelId !== "";
return hasOwnAgent || hasCompletePair;
}
/** Decide the effective agent for a node's work using the two EXPLICIT named rules
* (column-agent plan KTD-2/KTD-5):
* - No binding → `own-settings` if the work has any, else `none`.
* - `override` → the column agent ALWAYS (identity + model + persona).
* - `defer` → the column agent ONLY when the work has no own settings; otherwise
* own settings win.
* No `??` collapse: each branch is named so audit can explain the choice. */
export function resolveEffectiveAgent(input: EffectiveAgentInput): EffectiveAgentResult {
const { binding } = input;
if (!binding) {
return hasOwnSettings(input) ? { source: "own-settings" } : { source: "none" };
}
if (binding.mode === "override") {
return { source: "column-agent", agentId: binding.agentId };
}
// mode === "defer": column agent only when the work carries no own settings.
if (hasOwnSettings(input)) {
return { source: "own-settings" };
}
return { source: "column-agent", agentId: binding.agentId };
}

View File

@@ -149,7 +149,7 @@ export function probeFts5(db: DatabaseSync): boolean {
// ── Schema Definition ────────────────────────────────────────────────
const SCHEMA_VERSION = 109;
const SCHEMA_VERSION = 113;
export { SCHEMA_VERSION };
@@ -385,6 +385,10 @@ CREATE TABLE IF NOT EXISTS workflow_steps (
defaultOn INTEGER DEFAULT 0,
modelProvider TEXT,
modelId TEXT,
-- (workflow-editor-consolidation U1/U2) when this step has been migrated into a
-- fragment WorkflowDefinition, the fragment's id is stamped here so re-runs of
-- the lazy migration skip already-migrated rows (marker idempotency).
migrated_fragment_id TEXT,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL
);
@@ -398,6 +402,11 @@ CREATE TABLE IF NOT EXISTS workflows (
description TEXT NOT NULL DEFAULT '',
ir TEXT NOT NULL,
layout TEXT NOT NULL DEFAULT '{}',
-- (workflow-editor-consolidation U1, KTD-1) discriminates reusable single-node
-- "fragment" templates from full "workflow" definitions. Fragments never appear
-- in task workflow pickers, default-workflow selection, or compile/selection
-- paths. Legacy rows default to 'workflow'.
kind TEXT NOT NULL DEFAULT 'workflow',
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL
);
@@ -616,6 +625,17 @@ CREATE TABLE IF NOT EXISTS workflow_run_step_instances (
);
CREATE INDEX IF NOT EXISTS idx_workflow_run_step_instances_task_run ON workflow_run_step_instances(taskId, runId);
-- Workflow setting values per (workflowId, projectId). JSON values map; validated
-- against the named workflow's declared settings by the store write authority.
CREATE TABLE IF NOT EXISTS workflow_settings (
workflowId TEXT NOT NULL,
projectId TEXT NOT NULL,
"values" TEXT DEFAULT '{}',
updatedAt TEXT NOT NULL,
PRIMARY KEY (workflowId, projectId)
);
CREATE INDEX IF NOT EXISTS idx_workflow_settings_project ON workflow_settings(projectId);
-- Task documents (key-value store per task with revision tracking)
CREATE TABLE IF NOT EXISTS task_documents (
id TEXT PRIMARY KEY,
@@ -1283,6 +1303,23 @@ export const MIGRATION_ONLY_TABLE_SCHEMAS: Record<string, Record<string, string>
updatedAt: "TEXT NOT NULL",
cliSessionFile: "TEXT",
inFlightGeneration: "TEXT",
cliExecutorAdapterId: "TEXT",
},
cli_sessions: {
id: "TEXT PRIMARY KEY",
taskId: "TEXT",
chatSessionId: "TEXT",
purpose: "TEXT NOT NULL",
projectId: "TEXT NOT NULL",
adapterId: "TEXT NOT NULL",
agentState: "TEXT NOT NULL DEFAULT 'starting'",
terminationReason: "TEXT",
nativeSessionId: "TEXT",
resumeAttempts: "INTEGER NOT NULL DEFAULT 0",
autonomyPosture: "TEXT",
worktreePath: "TEXT",
createdAt: "TEXT NOT NULL",
updatedAt: "TEXT NOT NULL",
},
chat_messages: {
id: "TEXT PRIMARY KEY",
@@ -4346,7 +4383,81 @@ export class Database {
});
}
// Migration 109: Unified PR entity (PR-lifecycle-as-workflow-nodes, U1).
// Migration 109: Workflow editor consolidation. Adds workflows.kind
// (fragment vs workflow discriminator; existing rows default 'workflow')
// and workflow_steps.migrated_fragment_id (idempotent lazy step migration).
// Additive-only, idempotent (addColumnIfMissing guards); no backfill.
if (version < 109) {
this.applyMigration(109, () => {
this.addColumnIfMissing("workflows", "kind", "TEXT NOT NULL DEFAULT 'workflow'");
this.addColumnIfMissing("workflow_steps", "migrated_fragment_id", "TEXT");
});
}
// Migration 110: Durable CLI agent session records (CLI Agent Executor U1).
// cli_sessions — one row per long-lived CLI agent session. agentState ∈
// starting|ready|busy|waitingOnInput|done|dead|needsAttention; terminationReason
// ∈ completed|userExited|killed|crashed|authFailed|engineDeath; purpose ∈
// execute|planning|validator|ce|chat. Additive-only, idempotent.
if (version < 110) {
this.applyMigration(110, () => {
this.db.exec(`
CREATE TABLE IF NOT EXISTS cli_sessions (
id TEXT PRIMARY KEY,
taskId TEXT,
chatSessionId TEXT,
purpose TEXT NOT NULL,
projectId TEXT NOT NULL,
adapterId TEXT NOT NULL,
agentState TEXT NOT NULL DEFAULT 'starting',
terminationReason TEXT,
nativeSessionId TEXT,
resumeAttempts INTEGER NOT NULL DEFAULT 0,
autonomyPosture TEXT,
worktreePath TEXT,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_cli_sessions_taskId ON cli_sessions(taskId);
CREATE INDEX IF NOT EXISTS idx_cli_sessions_chatSessionId ON cli_sessions(chatSessionId);
CREATE INDEX IF NOT EXISTS idx_cli_sessions_project_state ON cli_sessions(projectId, agentState);
`);
});
}
// Migration 111: per-chat-session cli-agent adapter selection (U12).
if (version < 111) {
this.applyMigration(111, () => {
if (this.hasTable("chat_sessions")) {
this.addColumnIfMissing("chat_sessions", "cliExecutorAdapterId", "TEXT");
}
});
}
// Migration 112: Workflow setting values (workflow-settings U2, KTD-2).
// Adds workflow_settings — one row per (workflowId, projectId) carrying a JSON
// map of setting values declared by the workflow's IR. Values are validated by
// the store write authority against the named workflow's declarations; built-in
// workflow ids are accepted for value writes even though their declarations are
// non-editable. Additive-only, idempotent (table-exists guard); no backfill.
// (Authored as 109 on the feature branch; renumbered as mainline migrations
// land first — currently 112.)
if (version < 112) {
this.applyMigration(112, () => {
this.db.exec(`
CREATE TABLE IF NOT EXISTS workflow_settings (
workflowId TEXT NOT NULL,
projectId TEXT NOT NULL,
"values" TEXT DEFAULT '{}',
updatedAt TEXT NOT NULL,
PRIMARY KEY (workflowId, projectId)
);
CREATE INDEX IF NOT EXISTS idx_workflow_settings_project ON workflow_settings(projectId);
`);
});
}
// Migration 113: Unified PR entity (PR-lifecycle-as-workflow-nodes, U1).
// Adds pull_requests + pull_request_thread_state and copies legacy
// branch_groups PR fields into entities flagged unverified (R19) — that
// legacy state may be fiction (prState:"open" was once written without a
@@ -4357,8 +4468,10 @@ export class Database {
// re-runs the entire body at next boot. Every statement below is therefore
// re-runnable — IF NOT EXISTS DDL and INSERT OR IGNORE keyed on the same
// columns as the partial unique indexes.
if (version < 109) {
this.applyMigration(109, () => {
// (Authored as 109 on the feature branch; renumbered to 113 behind main's
// workflows.kind(109)/cli_sessions(110)/adapter(111)/workflow_settings(112).)
if (version < 113) {
this.applyMigration(113, () => {
this.ensurePullRequestsSchemaCompatibility();
const now = Date.now();
// Copy legacy branch-group PRs (only groups that claim an open/merged PR)
@@ -4406,7 +4519,7 @@ export class Database {
* Idempotent schema reconciliation for the PR-entity tables. ensureSchema-
* Compatibility adds missing *columns* but never indexes, so the partial
* unique indexes must be (re)created here as well as in SCHEMA_SQL and the
* v109 migration block — a fresh-from-SCHEMA_SQL DB and a migrated DB must
* v113 migration block — a fresh-from-SCHEMA_SQL DB and a migrated DB must
* converge on identical constraints. Mirrors ensureEvalTaskResultsSchema-
* Compatibility.
*/
@@ -4530,7 +4643,8 @@ export class Database {
*/
private addColumnIfMissing(table: string, column: string, definition: string): void {
if (!this.hasColumn(table, column)) {
this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
// Quote the column identifier so reserved words (e.g. `values`) are legal.
this.db.exec(`ALTER TABLE ${table} ADD COLUMN "${column}" ${definition}`);
}
}
@@ -4548,7 +4662,8 @@ export class Database {
return;
}
this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
// Quote the column identifier so reserved words (e.g. `values`) are legal.
this.db.exec(`ALTER TABLE ${table} ADD COLUMN "${column}" ${definition}`);
columns.add(column);
if (cache) {
cache.set(table, columns);

View File

@@ -19,6 +19,7 @@ import { mkdir, readFile, writeFile, rename, chmod } from "node:fs/promises";
import { existsSync, mkdirSync, renameSync } from "node:fs";
import type { GlobalSettings } from "./types.js";
import { DEFAULT_GLOBAL_SETTINGS } from "./types.js";
import { sanitizeCliAgentsSettings } from "./settings-schema.js";
function getHomeDir(): string {
return process.env.HOME || process.env.USERPROFILE || homedir();
@@ -193,6 +194,11 @@ export class GlobalSettingsStore {
// null → delete this key from the merged object
// This effectively makes it fall through to the default
delete merged[key];
} else if (key === "cliAgents") {
// Validation at the write boundary (U15, Global Settings convention):
// unknown adapter ids and invalid fields are dropped before persist so
// a malformed `cliAgents` payload can never reach launch resolution.
merged[key] = sanitizeCliAgentsSettings(value);
} else {
// normal value → set it
merged[key] = value;

View File

@@ -1,5 +1,5 @@
export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, isMergeRequestContractShadowEnabled, resolvePersistAgentThinkingLog, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, SUPPORTED_LOCALES, DEFAULT_LOCALE, isLocale, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, PERMANENT_AGENT_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PROVISIONING_APPROVAL_MODES, SANDBOX_PROVISIONING_APPROVAL_MODES, AGENT_PERMISSION_POLICY_PRESET_IDS, LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, normalizeApprovalRequestActionCategory, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeIntegrationWorktreeMode, normalizeMergeAdvanceAutoSyncMode, MERGE_ADVANCE_AUTO_SYNC_MODES, normalizeMergeConflictStrategy, normalizeMergeStrategyOverlapBehavior, normalizePostMergeAuditMode, POST_MERGE_AUDIT_MODES, normalizeMergeAuditAutoRecovery, MERGE_AUDIT_AUTO_RECOVERY_MODES, normalizeMergerMode, MERGER_MODES, normalizeAutoRecovery, AUTO_RECOVERY_MODES, buildResearchDocumentKey, REPO_OVERRIDE_RE, SHARED_STATE_SNAPSHOT_VERSION } from "./types.js";
export type { Column, ColumnId, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, BranchGroupPrState, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, RetrySummary, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeIntegrationWorktreeMode, MergeAdvanceAutoSyncMode, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, MergerMode, MergerSettings, AutoRecoveryMode, AutoRecoveryFailureClass, AutoRecoverySettings, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, Locale, ExecutionMode, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, MergeRequestState, MergeRequestRecord, CompletionHandoffMarker, HandoffEvidence, HandoffToReviewOptions, UnavailableNodePolicy, OwningNodeHandoffPolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, SandboxProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure, TaskBranchContext } from "./types.js";
export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, isMergeRequestContractShadowEnabled, resolvePersistAgentThinkingLog, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, SUPPORTED_LOCALES, DEFAULT_LOCALE, isLocale, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, PERMANENT_AGENT_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PROVISIONING_APPROVAL_MODES, SANDBOX_PROVISIONING_APPROVAL_MODES, AGENT_PERMISSION_POLICY_PRESET_IDS, LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, normalizeApprovalRequestActionCategory, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeIntegrationWorktreeMode, normalizeMergeAdvanceAutoSyncMode, MERGE_ADVANCE_AUTO_SYNC_MODES, normalizeMergeConflictStrategy, normalizeMergeStrategyOverlapBehavior, normalizePostMergeAuditMode, POST_MERGE_AUDIT_MODES, normalizeMergeAuditAutoRecovery, MERGE_AUDIT_AUTO_RECOVERY_MODES, normalizeMergerMode, MERGER_MODES, normalizeAutoRecovery, AUTO_RECOVERY_MODES, buildResearchDocumentKey, REPO_OVERRIDE_RE, SHARED_STATE_SNAPSHOT_VERSION, sanitizeCliAgentSettings, sanitizeCliAgentsSettings, CLI_AGENT_ADAPTER_IDS, CLI_AGENT_AUTONOMY_MODES } from "./types.js";
export type { Column, ColumnId, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, BranchGroupPrState, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, RetrySummary, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeIntegrationWorktreeMode, MergeAdvanceAutoSyncMode, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, MergerMode, MergerSettings, AutoRecoveryMode, AutoRecoveryFailureClass, AutoRecoverySettings, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, Locale, ExecutionMode, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, MergeRequestState, MergeRequestRecord, CompletionHandoffMarker, HandoffEvidence, HandoffToReviewOptions, UnavailableNodePolicy, OwningNodeHandoffPolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, SandboxProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure, TaskBranchContext, CliAgentSettings } from "./types.js";
export { AGENT_VALID_TRANSITIONS, DUPLICATE_OF_METADATA_KEY } from "./types.js";
export {
resolveEntryPointBranchAssignment,
@@ -16,6 +16,7 @@ export type {
EntryPointBranchAssignment,
} from "./branch-assignment.js";
export { customProviderRegistryKey } from "./custom-provider-key.js";
export { redactSecrets } from "./redact-secrets.js";
export { MOCK_PROVIDER_ID } from "./mock-provider-constants.js";
export type { MockProviderId, MockSessionPurpose } from "./mock-provider-constants.js";
export {
@@ -48,8 +49,11 @@ export {
export {
parseWorkflowIr,
serializeWorkflowIr,
stripApprovalBypassFlags,
WorkflowIrError,
DEFAULT_WORKFLOW_COLUMN_IDS,
WORKFLOW_SETTING_TYPES,
SETTING_RENDER_WIDGETS,
} from "./workflow-ir.js";
export type {
WorkflowIr,
@@ -60,6 +64,7 @@ export type {
WorkflowIrNodeKind,
WorkflowIrColumn,
WorkflowIrColumnTrait,
WorkflowColumnAgent,
WorkflowHoldRelease,
WorkflowJoinMode,
WorkflowJoinBranchFailure,
@@ -70,15 +75,43 @@ export type {
WorkflowFieldType,
WorkflowFieldOption,
WorkflowFieldRender,
// Workflow-settings (U1): typed setting declaration IR types.
WorkflowSettingDefinition,
WorkflowSettingType,
WorkflowSettingOption,
WorkflowSettingRender,
// CLI Agent Executor (U7): node-config executor typing.
WorkflowNodeExecutorKind,
WorkflowNodeExecutorConfig,
} from "./workflow-ir-types.js";
export {
DEFAULT_MAX_REWORK_CYCLES,
MAX_REWORK_CYCLES_CAP,
resolveMaxReworkCycles,
} from "./workflow-ir-types.js";
export {
instanceNodeId,
parseInstanceNodeId,
resolveColumnAgentBinding,
resolveEffectiveAgent,
} from "./column-agent-resolver.js";
export type {
ParsedInstanceNodeId,
EffectiveAgentInput,
EffectiveAgentResult,
} from "./column-agent-resolver.js";
export { BUILTIN_CODING_WORKFLOW_IR } from "./builtin-coding-workflow-ir.js";
export { BUILTIN_STEPWISE_CODING_WORKFLOW_IR } from "./builtin-stepwise-coding-workflow-ir.js";
export { BUILTIN_PR_WORKFLOW_IR } from "./builtin-pr-workflow-ir.js";
export { BUILTIN_WORKFLOW_SETTINGS } from "./builtin-workflow-settings.js";
export {
MOVED_SETTINGS_KEYS,
SETTINGS_MIGRATION_VERSION,
SETTINGS_MIGRATION_MARKER_KEY,
isMovedSettingsKey,
stripMovedSettingsKeys,
patchContainsMovedKey,
} from "./moved-settings.js";
// ── Trait model (U2) ─────────────────────────────────────────────────
export type {
@@ -216,6 +249,20 @@ export type {
CustomFieldPatchResult,
FieldReconciliation,
} from "./task-fields.js";
export {
validateSettingValuePatch,
resolveEffectiveSettingValues,
findOrphanedSettingValues,
makeWorkflowSettingRejection,
WorkflowSettingRejectionError,
WORKFLOW_SETTING_REJECTION_CODES,
} from "./workflow-settings.js";
export type {
WorkflowSettingRejection,
WorkflowSettingRejectionCode,
SettingValuePatchResult,
OrphanedSettingValue,
} from "./workflow-settings.js";
export {
readTransitionPending,
writeTransitionPending,
@@ -226,6 +273,7 @@ export type {
WorkflowDefinition,
WorkflowDefinitionInput,
WorkflowDefinitionUpdate,
WorkflowDefinitionKind,
WorkflowNodeLayout,
} from "./workflow-definition-types.js";
export {
@@ -233,6 +281,11 @@ export {
validateLinearity,
WorkflowCompileError,
} from "./workflow-compiler.js";
export {
stepsToWorkflowIr,
stepToFragmentIr,
layoutForIr,
} from "./workflow-steps-to-ir.js";
export {
BUILTIN_WORKFLOWS,
BUILTIN_WORKFLOW_ID_PREFIX,
@@ -244,6 +297,14 @@ export {
resolveWorkflowIrById,
type WorkflowIrResolverStore,
} from "./workflow-ir-resolver.js";
export {
resolveEffectiveSettings,
resolveEffectiveSettingsDetailed,
resolveEffectiveSettingsById,
type WorkflowSettingsResolverStore,
type EffectiveSettingsResult,
type EffectiveSettingsTaskRef,
} from "./workflow-settings-resolver.js";
// ── Engine wiring (set by @fusion/engine at module load) ────────────
export {
@@ -298,8 +359,13 @@ export {
normalizeAgentPermissionPolicy,
resolveEffectiveAgentPermissionPolicy,
isAgentPermissionPolicyPresetId,
isPolicyBroaderThanDefault,
} from "./agent-permission-policy.js";
export type { BuiltInAgentPermissionPolicyPreset } from "./agent-permission-policy.js";
export {
validateColumnAgentBindings,
ColumnAgentBindingError,
} from "./column-agent-binding-validation.js";
export { AgentStore, DEFAULT_AGENT_HEARTBEAT_INTERVAL_MS } from "./agent-store.js";
export type { AgentStoreEvents } from "./agent-store.js";
export {
@@ -453,6 +519,7 @@ export {
toJson,
toJsonNullable,
fromJson,
SCHEMA_VERSION,
} from "./db.js";
export {
ProjectIdentityConflictError,
@@ -786,7 +853,7 @@ export {
} from "./plugin-types.js";
export { PluginStore } from "./plugin-store.js";
export type { PluginStoreEvents, PluginRegistrationInput, PluginUpdateInput } from "./plugin-store.js";
export { PluginLoader } from "./plugin-loader.js";
export { PluginLoader, resolvePluginEntryPath } from "./plugin-loader.js";
export { scanPluginSecurity } from "./plugin-security-scan.js";
export type { PluginSecurityScanResult, PluginSecurityFinding } from "./plugin-security-scan.js";
export type {
@@ -828,12 +895,14 @@ export {
generateExportFilename,
readExportFile,
writeExportFile,
SETTINGS_EXPORT_VERSION,
} from "./settings-export.js";
export type {
SettingsExportData,
ExportSettingsOptions,
ImportSettingsOptions,
ImportResult,
WorkflowSettingsExportSection,
} from "./settings-export.js";
// ── AI Summarization ─────────────────────────────────────────────────────
@@ -1550,6 +1619,25 @@ export type {
} from "./chat-types.js";
export { ChatStore } from "./chat-store.js";
export type { ChatStoreEvents } from "./chat-store.js";
export {
CLI_AGENT_STATES,
CLI_TERMINATION_REASONS,
CLI_SESSION_PURPOSES,
isCliAgentState,
isCliTerminationReason,
isCliSessionPurpose,
} from "./cli-session-types.js";
export type {
CliAgentState,
CliTerminationReason,
CliSessionPurpose,
CliAutonomyPosture,
CliSession,
CliSessionCreateInput,
CliSessionUpdateInput,
} from "./cli-session-types.js";
export { CliSessionStore } from "./cli-session-store.js";
export type { CliSessionStoreEvents } from "./cli-session-store.js";
export {
choosePreferredStoredCredential,
extractClaudeCliStoredCredential,

View File

@@ -0,0 +1,89 @@
/**
* Tombstone allowlist for the U4 hard-move (KTD-5).
*
* `MOVED_SETTINGS_KEYS` is the single, authoritative record of the settings keys
* that left `DEFAULT_PROJECT_SETTINGS` and now live exclusively as **workflow
* setting values** per `(workflowId, projectId)`. It is derived directly from the
* built-in workflow declaration catalog (`BUILTIN_WORKFLOW_SETTINGS`) so the move
* has exactly one source of truth — a key is "moved" iff a built-in workflow
* declares it. Adding/removing a key from the catalog automatically reflows the
* tombstone list, the migration write target, and the stale-writer guard.
*
* What the tombstone shields (KTD-5, R8):
* - the project/global settings WRITE paths (`updateSettings` /
* `updateGlobalSettings`) — incoming moved keys from stale writers are silently
* dropped, never persisted (they would otherwise re-materialize in raw
* storage and, via the default re-injection trap, silently override the
* migrated workflow value);
* - the migration's raw-key null-out (it nulls exactly these keys from the
* persisted project + global stores);
* - (in U5) settings export v2 / cross-node sync diff / v1 import.
*
* ── TYPE-vs-SCHEMA SPLIT (deliberate, documented per the U4 plan) ──────────────
* The moved keys are REMOVED from `DEFAULT_PROJECT_SETTINGS` (so they vanish from
* `PROJECT_SETTINGS_KEYS` / `isProjectSettingsKey` / the save-split), but the
* corresponding fields are RETAINED on the `ProjectSettings` / `Settings`
* TypeScript interfaces. This is intentional: the engine still types its ~20 flat
* `settings.<movedKey>` read sites and the U3 effective-settings merge off
* `Partial<Settings>`, so dropping the fields from the type would break those
* call sites. The schema MEMBERSHIP (key lists / predicates / persistence
* filters) is the thing that must not include moved keys — not the type shape.
*
* NOTE on `buildTimeoutMs`: it has NO reader anywhere in the engine, so it fails
* the per-task-reader rule (KTD-5 / catalog-shrink) and was removed from
* `BUILTIN_WORKFLOW_SETTINGS` entirely. It therefore stays a plain project
* setting and is intentionally ABSENT from this list.
*/
import { BUILTIN_WORKFLOW_SETTINGS } from "./builtin-workflow-settings.js";
/**
* The version of the per-project settings hard-move migration. Persisted per
* project as a `__meta` marker (`settingsMigrationVersion`). A project whose
* marker is `>= SETTINGS_MIGRATION_VERSION` has already migrated and the runner
* no-ops. Bump only if a future migration must re-run on already-migrated DBs.
*/
export const SETTINGS_MIGRATION_VERSION = 1;
/** The `__meta` key under which the migration marker is persisted (per project DB). */
export const SETTINGS_MIGRATION_MARKER_KEY = "settingsMigrationVersion";
/**
* The definitive moved-key catalog — derived from the built-in workflow
* declarations so it cannot drift from them. Frozen so callers cannot mutate it.
*/
export const MOVED_SETTINGS_KEYS: readonly string[] = Object.freeze(
BUILTIN_WORKFLOW_SETTINGS.map((s) => s.id),
);
/** Set form for O(1) membership checks on the hot write path. */
const MOVED_SETTINGS_KEY_SET: ReadonlySet<string> = new Set(MOVED_SETTINGS_KEYS);
/** Whether `key` is a moved (tombstoned) settings key. */
export function isMovedSettingsKey(key: string): boolean {
return MOVED_SETTINGS_KEY_SET.has(key);
}
/**
* Return a shallow copy of `patch` with every moved (tombstoned) key removed.
* Used by the project/global settings write paths to silently drop moved keys
* arriving from stale writers (R8) — they must never be persisted back into the
* raw settings store. Non-moved keys pass through untouched.
*/
export function stripMovedSettingsKeys<T extends Record<string, unknown>>(patch: T): Partial<T> {
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(patch)) {
if (!MOVED_SETTINGS_KEY_SET.has(key)) {
out[key] = value;
}
}
return out as Partial<T>;
}
/** Whether `patch` carries at least one moved key (for debug-logging the drop). */
export function patchContainsMovedKey(patch: Record<string, unknown>): boolean {
for (const key of Object.keys(patch)) {
if (MOVED_SETTINGS_KEY_SET.has(key)) return true;
}
return false;
}

View File

@@ -9,7 +9,8 @@
* - Error isolation (plugin crashes don't crash the loader)
*/
import { basename, dirname, extname, isAbsolute, resolve } from "node:path";
import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path";
import { existsSync } from "node:fs";
import { stat } from "node:fs/promises";
import { copyFile } from "node:fs/promises";
import { pathToFileURL } from "node:url";
@@ -48,6 +49,35 @@ import { scanPluginSecurity } from "./plugin-security-scan.js";
const MINIMUM_FUSION_VERSION = "0.1.0";
let moduleImportVersion = 0;
/**
* Resolve the actual loadable entry FILE path for a plugin directory. Node ESM
* does not allow directory imports, so the registered plugin path must be the
* explicit file the loader will dynamic-import. Preference order:
* 1. ./bundled.js (esbuild-bundled, shipped in npm tarball)
* 2. ./dist/index.js (legacy prebuilt fallback)
* 3. ./src/index.ts (workspace/dev fallback when no bundle exists)
*
* Returns null when the directory exists but none of the loadable entry files
* are present. Callers must treat that as a missing/unloadable plugin rather
* than persisting a directory path that Node cannot import.
*
* Keep in sync with resolvePluginEntryPath in the CLI's
* bundled-plugin-install.ts, which keeps a local copy so its fs mocks work.
*/
export function resolvePluginEntryPath(pluginDir: string): string | null {
const candidates = [
join(pluginDir, "bundled.js"),
join(pluginDir, "dist", "index.js"),
join(pluginDir, "src", "index.ts"),
];
for (const candidate of candidates) {
if (existsSync(candidate)) {
return candidate;
}
}
return null;
}
export interface PluginLoaderOptions {
/** Plugin store for persistence */
pluginStore: PluginStore;

View File

@@ -0,0 +1,31 @@
/**
* Shared secret-redaction helper.
*
* Pure string logic that strips token-like / auth patterns from text so auth
* errors and process output don't leak verbatim into logs or buffers. Best
* effort: covers bearer tokens, `Authorization:` header values,
* `key=`/`token=`/`secret=` assignments, and long base64/hex secrets.
*/
/**
* Redact token-like / auth patterns from `text`.
*/
export function redactSecrets(text: string): string {
return (
text
// Authorization: Bearer <token> / Authorization: <token>
.replace(/(authorization\s*[:=]\s*)(bearer\s+)?[^\s,;"']+/gi, "$1$2[REDACTED]")
// Bearer <token>
.replace(/\b(bearer)\s+[A-Za-z0-9._\-+/=]+/gi, "$1 [REDACTED]")
// key=... token=... secret=... password=... apikey=... (quoted or bare)
.replace(
/\b((?:api[_-]?key|key|token|secret|password|passwd|pwd|access[_-]?token|refresh[_-]?token|client[_-]?secret)\s*[:=]\s*)("?)[^\s,;"']+\2/gi,
"$1$2[REDACTED]$2",
)
// sk-/ghp_/github_pat_/xoxb-/AKIA-style long opaque tokens
.replace(/\b(sk-|ghp_|gho_|github_pat_|xox[abpr]-|AKIA)[A-Za-z0-9_-]{8,}/g, "[REDACTED]")
// standalone long base64/hex secrets (>=32 chars)
.replace(/\b[A-Za-z0-9+/]{40,}={0,2}\b/g, "[REDACTED]")
.replace(/\b[0-9a-fA-F]{32,}\b/g, "[REDACTED]")
);
}

View File

@@ -4,19 +4,47 @@
* This module provides utilities for exporting and importing fn settings,
* supporting both global (~/.fusion/settings.json) and project-level (.fusion/config.json)
* settings for backup, migration, and sharing.
*
* ── Export format versions ────────────────────────────────────────────────────
* - v1: `{ version: 1, global?, project? }` — the legacy shape. Project settings
* could carry the (now-moved) workflow/step/model-lane keys flat under
* `project`. Still importable: any moved key found in a v1 `project` section is
* UPGRADED into workflow setting VALUES (KTD-8) using the same write-target
* rule as the U4 migration, instead of dead-writing it back into project
* settings (the store guard would strip it anyway).
* - v2: adds a `workflowSettings` section carrying the per-project value table
* (`workflowId → { key: value }`). Moved keys never appear under `project` in a
* v2 export. Import round-trips the section via `updateWorkflowSettingValues`,
* dropping-and-logging invalid values without aborting.
*/
import { writeFile, readFile, rename } from "node:fs/promises";
import type { Settings, GlobalSettings, ProjectSettings } from "./types.js";
import { TaskStore } from "./store.js";
import {
MOVED_SETTINGS_KEYS,
stripMovedSettingsKeys,
} from "./moved-settings.js";
import { createLogger } from "./logger.js";
const log = createLogger("settings-export");
/** Current export format version emitted by {@link exportSettings}. */
export const SETTINGS_EXPORT_VERSION = 2;
/**
* Per-project workflow setting VALUE table carried by a v2 export:
* `workflowId → { settingKey: value }`.
*/
export type WorkflowSettingsExportSection = Record<string, Record<string, unknown>>;
/**
* Structure for exported settings JSON.
* Contains metadata about the export and the actual settings data.
*/
export interface SettingsExportData {
/** Export format version for future compatibility */
version: 1;
/** Export format version. 2 is current; 1 remains importable. */
version: 1 | 2;
/** Timestamp when the export was created */
exportedAt: string;
/** Source identifier (e.g., hostname, project path) */
@@ -25,6 +53,11 @@ export interface SettingsExportData {
global?: GlobalSettings;
/** Project settings (project-level, .fusion/config.json) */
project?: Partial<ProjectSettings>;
/**
* Workflow setting VALUES for the exporting project (v2+). Keyed
* `workflowId → { settingKey: value }`. Absent in v1 payloads.
*/
workflowSettings?: WorkflowSettingsExportSection;
}
/**
@@ -57,6 +90,8 @@ export interface ImportResult {
globalCount: number;
/** Number of project settings imported */
projectCount: number;
/** Number of workflow setting VALUES imported (across all workflows). */
workflowSettingsCount: number;
/** Error message if import failed */
error?: string;
}
@@ -64,6 +99,7 @@ export interface ImportResult {
/**
* Validate that data conforms to the SettingsExportData structure.
* Returns validation errors as an array of strings, or empty array if valid.
* Both v1 and v2 are accepted.
*/
export function validateImportData(data: unknown): string[] {
const errors: string[] = [];
@@ -75,9 +111,9 @@ export function validateImportData(data: unknown): string[] {
const obj = data as Record<string, unknown>;
// Check version
if (obj.version !== 1) {
errors.push(`Unsupported export version: ${obj.version}. Expected: 1`);
// Check version (v1 and v2 are both supported)
if (obj.version !== 1 && obj.version !== 2) {
errors.push(`Unsupported export version: ${obj.version}. Expected: 1 or 2`);
}
// Check exportedAt
@@ -99,9 +135,26 @@ export function validateImportData(data: unknown): string[] {
}
}
// At least one of global or project must be present
if (obj.global === undefined && obj.project === undefined) {
errors.push("Export data must contain at least one of 'global' or 'project' settings");
// Validate workflowSettings section if present (v2)
if (obj.workflowSettings !== undefined) {
if (
typeof obj.workflowSettings !== "object"
|| obj.workflowSettings === null
|| Array.isArray(obj.workflowSettings)
) {
errors.push("'workflowSettings' field must be an object if provided");
} else {
for (const [workflowId, values] of Object.entries(obj.workflowSettings as Record<string, unknown>)) {
if (typeof values !== "object" || values === null || Array.isArray(values)) {
errors.push(`'workflowSettings.${workflowId}' must be an object of setting values`);
}
}
}
}
// At least one of global, project, or workflowSettings must be present
if (obj.global === undefined && obj.project === undefined && obj.workflowSettings === undefined) {
errors.push("Export data must contain at least one of 'global', 'project', or 'workflowSettings' settings");
}
return errors;
@@ -124,7 +177,9 @@ export function generateExportFilename(date: Date = new Date()): string {
/**
* Export settings from the current project.
*
* Reads both global and project settings and returns them in an exportable structure.
* Reads both global and project settings and returns them in an exportable
* structure. When project scope is requested, the per-project workflow setting
* value table is carried under `workflowSettings` (v2).
*
* @param store - The TaskStore instance for accessing project settings
* @param options - Export options including scope selection
@@ -137,7 +192,7 @@ export async function exportSettings(
const { scope = "both", source } = options;
const result: SettingsExportData = {
version: 1,
version: SETTINGS_EXPORT_VERSION,
exportedAt: new Date().toISOString(),
source,
};
@@ -152,15 +207,157 @@ export async function exportSettings(
if (scope === "project" || scope === "both") {
const scopes = await store.getSettingsByScope();
result.project = scopes.project;
// Carry the per-project workflow setting value table (v2). Defensively strip
// any moved key that somehow lingered in the project section (post-migration
// it never should) so the two regimes can never both claim the same key.
if (result.project) {
result.project = stripMovedSettingsKeys(
result.project as Record<string, unknown>,
) as Partial<ProjectSettings>;
}
const workflowSettings = store.listWorkflowSettingValuesForProject();
// Only attach non-empty rows; an empty table omits the section entirely.
const nonEmpty: WorkflowSettingsExportSection = {};
for (const [workflowId, values] of Object.entries(workflowSettings)) {
if (values && Object.keys(values).length > 0) {
nonEmpty[workflowId] = values;
}
}
if (Object.keys(nonEmpty).length > 0) {
result.workflowSettings = nonEmpty;
}
}
return result;
}
/**
* Apply the `workflowSettings` value section (v2) into the store.
*
* Each `(workflowId, values)` pair is written via `store.updateWorkflowSettingValues`.
* Invalid values are dropped-and-logged per-key (the write never aborts the whole
* import): we pre-validate by attempting the write and, on rejection, retry with
* the offending keys removed. Returns the number of values successfully applied.
*
* Merge semantics:
* - merge=true → per-key merge into the existing row (store's default upsert).
* - merge=false → replace the exported workflow's row: delete keys present in the
* current row but absent from the import, then write the import values.
*/
async function applyWorkflowSettingsSection(
store: TaskStore,
section: WorkflowSettingsExportSection,
merge: boolean,
): Promise<number> {
const projectId = store.getWorkflowSettingsProjectId();
let applied = 0;
for (const [workflowId, rawValues] of Object.entries(section)) {
if (!rawValues || typeof rawValues !== "object" || Array.isArray(rawValues)) continue;
const patch: Record<string, unknown> = { ...(rawValues as Record<string, unknown>) };
if (!merge) {
// Replace mode: null out keys present in the current row but absent here so
// the row ends up matching the imported workflow exactly.
const current = store.getWorkflowSettingValues(workflowId, projectId);
for (const key of Object.keys(current)) {
if (!(key in patch)) {
patch[key] = null; // null-as-delete
}
}
}
// Attempt the write; on a validation rejection, drop the offending keys and
// retry so one bad value never blocks the rest. Never abort the import.
// Retry at most until the patch is empty.
while (Object.keys(patch).length > 0) {
try {
await store.updateWorkflowSettingValues(workflowId, projectId, patch);
// Count only the non-null (set) keys as applied values.
applied += Object.values(patch).filter((v) => v !== null).length;
break;
} catch (err) {
const rejectedIds = extractRejectedSettingIds(err);
if (rejectedIds.length === 0) {
// Unknown error (not a value-rejection) — log and skip this workflow.
log.warn("[settings-import] skipped workflow setting values", {
workflowId,
error: err instanceof Error ? err.message : String(err),
});
break;
}
for (const id of rejectedIds) {
delete patch[id];
log.warn("[settings-import] dropped invalid workflow setting value", {
workflowId,
settingId: id,
});
}
}
}
}
return applied;
}
/**
* Extract rejected setting ids from a {@link WorkflowSettingRejectionError}-shaped
* error without importing the class (avoids a hard dependency cycle). Returns an
* empty array for errors that don't carry per-key rejections.
*/
function extractRejectedSettingIds(err: unknown): string[] {
if (!err || typeof err !== "object") return [];
const rejections = (err as { rejections?: unknown }).rejections;
if (!Array.isArray(rejections)) return [];
const ids: string[] = [];
for (const r of rejections) {
if (r && typeof r === "object" && typeof (r as { settingId?: unknown }).settingId === "string") {
ids.push((r as { settingId: string }).settingId);
}
}
return ids;
}
/**
* Upgrade moved keys found in a v1 payload's `project` section into workflow
* setting VALUES (KTD-8). The moved keys are written to every target workflow
* (in-use selection workflows ∪ resolved default, unset → `builtin:coding`),
* mirroring the U4 migration. Invalid values are dropped-and-logged. Returns the
* total count of values applied across all target workflows.
*/
async function upgradeMovedKeysFromV1Project(
store: TaskStore,
projectSection: Record<string, unknown>,
): Promise<number> {
const movedSnapshot: Record<string, unknown> = {};
for (const key of MOVED_SETTINGS_KEYS) {
if (
Object.prototype.hasOwnProperty.call(projectSection, key)
&& projectSection[key] !== undefined
) {
movedSnapshot[key] = projectSection[key];
}
}
if (Object.keys(movedSnapshot).length === 0) return 0;
const targets = await store.computeMovedSettingsTargetWorkflowIds();
const section: WorkflowSettingsExportSection = {};
for (const workflowId of targets) {
section[workflowId] = { ...movedSnapshot };
}
// Always merge moved-key upgrades into existing rows (never replace) — they are
// an overlay onto whatever the workflow already has.
return applyWorkflowSettingsSection(store, section, true);
}
/**
* Import settings into the current project.
*
* Validates the import data and applies it to global and/or project settings.
* Validates the import data and applies it to global, project, and (v2) workflow
* setting values. v1 payloads whose `project` section carries moved keys upgrade
* those keys into workflow setting values instead of dead-writing them.
*
* @param store - The TaskStore instance for writing settings
* @param data - The settings data to import
@@ -181,20 +378,22 @@ export async function importSettings(
success: false,
globalCount: 0,
projectCount: 0,
workflowSettingsCount: 0,
error: validationErrors.join("; "),
};
}
let globalCount = 0;
let projectCount = 0;
let workflowSettingsCount = 0;
try {
// Import global settings if present and requested
// Import global settings if present and requested.
// (The store guard strips any moved key arriving here, so global is safe.)
if ((scope === "global" || scope === "both") && data.global) {
const globalSettings = data.global as GlobalSettings;
if (merge) {
// Merge mode: only import defined fields, keeping existing values for undefined ones
const definedEntries = Object.entries(globalSettings).filter(
([, value]) => value !== undefined
);
@@ -204,9 +403,6 @@ export async function importSettings(
globalCount = definedEntries.length;
}
} else {
// Replace mode: get current settings, then update with imported values
// For global settings, we still preserve values not in the import data
// because a full "clear" of settings isn't practical
const patch = data.global as Partial<GlobalSettings>;
await store.updateGlobalSettings(patch);
globalCount = Object.entries(globalSettings).filter(
@@ -215,12 +411,20 @@ export async function importSettings(
}
}
// Import project settings if present and requested
// Import project settings if present and requested.
if ((scope === "project" || scope === "both") && data.project) {
const projectSettings = data.project as Partial<ProjectSettings>;
const projectSection = data.project as Record<string, unknown>;
// KTD-8: a v1 payload may carry moved keys flat under `project`. Upgrade
// them into workflow setting values (the project write would strip them
// anyway). v2 payloads carry no moved keys here, so this is a no-op for v2.
workflowSettingsCount += await upgradeMovedKeysFromV1Project(store, projectSection);
// Non-moved project keys import as before. Strip moved keys defensively so
// the count reflects only what actually lands in project settings.
const projectSettings = stripMovedSettingsKeys(projectSection) as Partial<ProjectSettings>;
if (merge) {
// Merge mode: only import defined fields
const definedEntries = Object.entries(projectSettings).filter(
([, value]) => value !== undefined
);
@@ -230,8 +434,6 @@ export async function importSettings(
projectCount = definedEntries.length;
}
} else {
// Replace mode: We need to explicitly handle this by updating all project settings
// The store's updateSettings merges, so we need to be explicit about clearing
const patch = projectSettings as Partial<Settings>;
await store.updateSettings(patch);
projectCount = Object.entries(projectSettings).filter(
@@ -240,16 +442,29 @@ export async function importSettings(
}
}
// Import workflow setting values (v2). Only meaningful when project scope is
// in play (these values are project-scoped). Round-trips through the store's
// validated write path; invalid values drop-and-log without aborting.
if ((scope === "project" || scope === "both") && data.workflowSettings) {
workflowSettingsCount += await applyWorkflowSettingsSection(
store,
data.workflowSettings,
merge,
);
}
return {
success: true,
globalCount,
projectCount,
workflowSettingsCount,
};
} catch (err) {
return {
success: false,
globalCount,
projectCount,
workflowSettingsCount,
error: (err as Error).message,
};
}

View File

@@ -1,4 +1,4 @@
import type { GlobalSettings, ProjectSettings, Settings } from "./types.js";
import type { CliAgentSettings, GlobalSettings, ProjectSettings, Settings } from "./types.js";
export interface MergeRequestContractShadowSettingsSource {
mergeRequestContractShadowEnabled?: boolean;
@@ -6,6 +6,56 @@ export interface MergeRequestContractShadowSettingsSource {
type CompleteSettings<T> = { [K in keyof Required<T>]: Required<T>[K] | undefined };
/**
* The settings keys hard-MOVED to workflow settings in U4 (see
* `moved-settings.ts`). They are REMOVED from `DEFAULT_PROJECT_SETTINGS` (so they
* leave `PROJECT_SETTINGS_KEYS` / the save-split), but their FIELDS are retained
* on the `ProjectSettings` type for the engine's flat `settings.<key>` reads and
* the U3 effective-settings merge. `DEFAULT_PROJECT_SETTINGS` is therefore
* type-checked against `ProjectSettings` MINUS these keys — the type-vs-schema
* split documented in `moved-settings.ts`.
*
* This union is NOT compile-time-enforced against `MOVED_SETTINGS_KEYS`.
* Enforcement lives in `src/__tests__/settings-consistency.test.ts` (every key
* must belong to exactly one regime). A STALE entry here only loosens the `Omit`
* type — at worst it lets `DEFAULT_PROJECT_SETTINGS` drop a key it should keep;
* it can never re-add a key to the schema object. A MISSING entry surfaces as a
* type error on `DEFAULT_PROJECT_SETTINGS` if that key still has a default.
*/
type MovedProjectSettingsKey =
| "workflowStepTimeoutMs"
| "workflowStepScopeEnforcement"
| "planOnlyScopeLeakEnforcement"
| "workflowRevisionForkOnScopeMismatch"
| "strictScopeEnforcement"
| "runStepsInNewSessions"
| "maxParallelSteps"
| "buildRetryCount"
| "verificationFixRetries"
| "maxPostReviewFixes"
| "requirePrApproval"
| "requirePlanApproval"
| "reviewHandoffPolicy"
| "maxReviewerContextRetries"
| "maxReviewerFallbackRetries"
| "reflectionEnabled"
| "executionProvider"
| "executionModelId"
| "planningProvider"
| "planningModelId"
| "planningFallbackProvider"
| "planningFallbackModelId"
| "validatorProvider"
| "validatorModelId"
| "validatorFallbackProvider"
| "validatorFallbackModelId"
| "titleSummarizerProvider"
| "titleSummarizerModelId"
| "titleSummarizerFallbackProvider"
| "titleSummarizerFallbackModelId";
type ProjectSettingsSchema = Omit<ProjectSettings, MovedProjectSettingsKey>;
/**
* Settings schema source of truth.
*
@@ -180,6 +230,7 @@ export const DEFAULT_GLOBAL_SETTINGS = {
},
owningNodeHandoffPolicy: "reassign-to-local",
experimentalFeatures: {},
cliAgents: {},
} satisfies CompleteSettings<GlobalSettings>;
/** Default values for project-level settings. */
@@ -188,6 +239,7 @@ export const DEFAULT_PROJECT_SETTINGS = {
globalPauseReason: undefined,
defaultWorkflowId: undefined,
approvedWorkflowCliCommands: undefined,
approvedCliAutonomyAdapters: undefined,
enginePaused: false,
maxConcurrent: 2,
maxTriageConcurrent: 2,
@@ -209,7 +261,7 @@ export const DEFAULT_PROJECT_SETTINGS = {
mergeIntegrationWorktree: "reuse-task-worktree",
mergeAdvanceAutoSync: "stash-and-ff",
integrationBranch: undefined,
requirePrApproval: false,
// `requirePrApproval` MOVED to workflow settings (U4) — see MOVED_SETTINGS_KEYS.
pushAfterMerge: false,
pushRemote: "origin",
unavailableNodePolicy: "block",
@@ -236,19 +288,12 @@ export const DEFAULT_PROJECT_SETTINGS = {
commitAuthorEnabled: true,
commitAuthorName: "Fusion",
commitAuthorEmail: "noreply@runfusion.ai",
planningProvider: undefined,
planningModelId: undefined,
planningFallbackProvider: undefined,
planningFallbackModelId: undefined,
// Project-level default override and execution lane
// Per-phase model lanes (planning/execution/validator) MOVED to workflow
// settings (U4) — see MOVED_SETTINGS_KEYS. The GLOBAL baseline lanes
// (executionGlobalProvider etc.) stay global; project default overrides stay.
// Project-level default override (NOT moved — stays project-scoped)
defaultProviderOverride: undefined,
defaultModelIdOverride: undefined,
executionProvider: undefined,
executionModelId: undefined,
validatorProvider: undefined,
validatorModelId: undefined,
validatorFallbackProvider: undefined,
validatorFallbackModelId: undefined,
modelPresets: [],
autoSelectModelPreset: false,
completionDocumentationMode: "off",
@@ -283,15 +328,13 @@ export const DEFAULT_PROJECT_SETTINGS = {
maxRetries: 3,
},
reliabilityStatsResetAt: undefined,
workflowStepTimeoutMs: 360_000,
workflowStepScopeEnforcement: "block",
planOnlyScopeLeakEnforcement: "warn",
workflowRevisionForkOnScopeMismatch: true,
strictScopeEnforcement: false,
buildRetryCount: 0,
verificationFixRetries: 3,
// Step-execution knobs (workflowStepTimeoutMs, workflowStepScopeEnforcement,
// planOnlyScopeLeakEnforcement, workflowRevisionForkOnScopeMismatch,
// strictScopeEnforcement, buildRetryCount, verificationFixRetries,
// requirePlanApproval) MOVED to workflow settings (U4) — see
// MOVED_SETTINGS_KEYS. `buildTimeoutMs` is NOT moved (no engine reader) and
// stays a plain project setting:
buildTimeoutMs: 300_000,
requirePlanApproval: false,
ephemeralAgentsEnabled: true,
agentProvisioning: {},
sandboxProvisioning: {},
@@ -335,11 +378,11 @@ export const DEFAULT_PROJECT_SETTINGS = {
autoUnpauseMaxDelayMs: 3_600_000,
maxStuckKills: 6,
maxBranchConflictRecoveries: 5,
maxReviewerContextRetries: 2,
maxReviewerFallbackRetries: 2,
// maxReviewerContextRetries / maxReviewerFallbackRetries MOVED to workflow
// settings (U4) — see MOVED_SETTINGS_KEYS.
maxTotalRetriesBeforeFail: 25,
preserveProgressOnStuckRequeue: true,
maxPostReviewFixes: 1,
// maxPostReviewFixes MOVED to workflow settings (U4).
maxSpawnedAgentsPerParent: 5,
maxSpawnedAgentsGlobal: 20,
// Run maintenance (including WAL checkpointing) every 5 minutes by default.
@@ -368,10 +411,8 @@ export const DEFAULT_PROJECT_SETTINGS = {
memoryBackupScope: "all" as const,
autoSummarizeTitles: false,
useAiMergeCommitSummary: true,
titleSummarizerProvider: undefined,
titleSummarizerModelId: undefined,
titleSummarizerFallbackProvider: undefined,
titleSummarizerFallbackModelId: undefined,
// Title-summarizer model lanes MOVED to workflow settings (U4) —
// see MOVED_SETTINGS_KEYS.
scripts: undefined,
setupScript: undefined,
insightExtractionEnabled: false,
@@ -392,17 +433,19 @@ export const DEFAULT_PROJECT_SETTINGS = {
memoryDreamsSchedule: "0 4 * * *",
tokenCap: undefined,
taskTokenBudget: undefined,
runStepsInNewSessions: false,
maxParallelSteps: 2,
// runStepsInNewSessions / maxParallelSteps MOVED to workflow settings (U4) —
// see MOVED_SETTINGS_KEYS.
missionStaleThresholdMs: 600_000,
missionMaxTaskRetries: 3,
missionHealthCheckIntervalMs: 300_000,
agentPrompts: undefined,
promptOverrides: undefined,
reflectionEnabled: false,
// reflectionEnabled MOVED to workflow settings (U4). reflectionIntervalMs /
// reflectionAfterTask have no engine reader, so they STAY plain project
// settings (catalog-shrink rule) and are NOT in MOVED_SETTINGS_KEYS.
reflectionIntervalMs: 3_600_000,
reflectionAfterTask: true,
reviewHandoffPolicy: "disabled",
// reviewHandoffPolicy MOVED to workflow settings (U4) — see MOVED_SETTINGS_KEYS.
showQuickChatFAB: false,
chatAutoCleanupDays: 0,
mailAutoCleanupDays: 0,
@@ -451,7 +494,7 @@ export const DEFAULT_PROJECT_SETTINGS = {
researchDefaultTimeout: 300000,
researchMaxSourcesPerRun: 20,
researchMaxSynthesisRounds: 2,
} satisfies CompleteSettings<ProjectSettings>;
} satisfies CompleteSettings<ProjectSettingsSchema>;
/**
* Merged default settings (backward compatible).
@@ -521,3 +564,81 @@ export function resolvePersistAgentThinkingLog(
if (typeof settings?.persistAgentThinkingLog === "boolean") return settings.persistAgentThinkingLog;
return false;
}
// ── CLI-agent settings sanitization (U15) ───────────────────────────────────
/** Adapter ids accepted in `cliAgents`. Unknown ids are dropped at the write
* boundary so a settings file cannot carry config for non-existent adapters. */
export const CLI_AGENT_ADAPTER_IDS = Object.freeze([
"claude-code",
"codex",
"droid",
"pi",
"generic",
] as const);
/** Autonomy modes accepted in a `CliAgentSettings` entry. */
export const CLI_AGENT_AUTONOMY_MODES = Object.freeze(["default", "elevated"] as const);
function sanitizeStringArray(value: unknown): string[] | undefined {
if (!Array.isArray(value)) return undefined;
const cleaned = value
.filter((v): v is string => typeof v === "string")
.map((v) => v.trim())
.filter((v) => v.length > 0);
return cleaned.length > 0 ? cleaned : undefined;
}
/**
* Sanitize a single adapter's launch settings (U15). Drops unknown fields and
* invalid values; returns `undefined` when nothing survives (so the caller can
* omit an empty entry). Pure — no I/O.
*
* Validation rules:
* - `commandOverride`: non-empty trimmed string, else dropped.
* - `extraArgs` / `envAdditions`: arrays of non-empty trimmed strings, else dropped.
* - `autonomyMode`: one of CLI_AGENT_AUTONOMY_MODES, else dropped (falls back to
* the adapter baseline at resolution time).
*/
export function sanitizeCliAgentSettings(value: unknown): CliAgentSettings | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
const input = value as Record<string, unknown>;
const out: CliAgentSettings = {};
if (typeof input.commandOverride === "string") {
const trimmed = input.commandOverride.trim();
if (trimmed.length > 0) out.commandOverride = trimmed;
}
const extraArgs = sanitizeStringArray(input.extraArgs);
if (extraArgs) out.extraArgs = extraArgs;
const envAdditions = sanitizeStringArray(input.envAdditions);
if (envAdditions) out.envAdditions = envAdditions;
if (
typeof input.autonomyMode === "string" &&
(CLI_AGENT_AUTONOMY_MODES as readonly string[]).includes(input.autonomyMode)
) {
out.autonomyMode = input.autonomyMode as CliAgentSettings["autonomyMode"];
}
return Object.keys(out).length > 0 ? out : undefined;
}
/**
* Sanitize the whole `cliAgents` map at the write boundary (U15). Drops unknown
* adapter ids and any entry that sanitizes to nothing. Returns a fresh object;
* always returns an object (possibly empty) so the field round-trips cleanly.
*/
export function sanitizeCliAgentsSettings(value: unknown): Record<string, CliAgentSettings> {
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
const input = value as Record<string, unknown>;
const out: Record<string, CliAgentSettings> = {};
for (const adapterId of CLI_AGENT_ADAPTER_IDS) {
if (!(adapterId in input)) continue;
const entry = sanitizeCliAgentSettings(input[adapterId]);
if (entry) out[adapterId] = entry;
}
return out;
}

File diff suppressed because it is too large Load Diff

View File

@@ -541,6 +541,11 @@ export interface WorkflowStep {
* Must be set together with `modelProvider`. When both model fields are undefined,
* the executor uses global settings defaults. Only used when mode is "prompt". */
modelId?: string;
/** (workflow-editor-consolidation U1/U2, KTD-1/KTD-3) when this legacy step has
* been migrated into a fragment WorkflowDefinition, the fragment's id is stamped
* here so the lazy step migration is idempotent (already-stamped rows are
* skipped). Stored in the `migrated_fragment_id` column. */
migratedFragmentId?: string;
/** ISO-8601 timestamp of creation */
createdAt: string;
/** ISO-8601 timestamp of last update */
@@ -651,6 +656,9 @@ export interface WorkflowStepInput {
modelProvider?: string;
/** AI model ID override. Must be set together with modelProvider. Only used when mode is "prompt". */
modelId?: string;
/** (workflow-editor-consolidation U2, KTD-3) fragment id stamped when this step
* was migrated into a fragment WorkflowDefinition. Set by the migration only. */
migratedFragmentId?: string;
}
/** Result of a workflow step execution on a task. */
@@ -2393,6 +2401,23 @@ export interface TaskCreateInput {
noCommitsExpected?: boolean;
/** IDs of workflow steps to enable for this task */
enabledWorkflowSteps?: string[];
/**
* Workflow selection applied atomically at task creation (U6/R3/KTD-4).
*
* Semantics:
* - `undefined` → inherit the project default workflow (today's behavior:
* `materializeDefaultWorkflowSteps` runs, falling back to default-on steps).
* - `null` → explicitly NO workflow: skip default materialization entirely;
* the task is created with no custom workflow steps.
* - `string` → that workflow's compiled steps are materialized and selected
* inside the creation flow, overriding any project default. Fragment IDs
* and unknown IDs are rejected with a clear error BEFORE the task row is
* created.
*
* Mutually exclusive with `enabledWorkflowSteps`: when `enabledWorkflowSteps`
* is provided, it takes precedence and `workflowId` materialization is skipped.
*/
workflowId?: string | null;
/** Model preset selected during task creation. Presets resolve to concrete model overrides at creation time. */
modelPresetId?: string;
/** AI model provider override for the executor agent (e.g., "anthropic").
@@ -3052,6 +3077,39 @@ export interface GlobalSettings {
*
* Default: {} (empty object — no experimental features enabled). */
experimentalFeatures?: Record<string, boolean>;
/** Per-adapter CLI-agent launch configuration (CLI Agent Executor, U15).
* Keyed by adapter id (e.g. `"claude-code"`, `"codex"`, `"generic"`). Each
* entry carries operator overrides layered over the adapter's shipped
* defaults: a command override, extra args, an autonomy mode, and env
* allowlist additions. Validated + sanitized at the write boundary
* (`sanitizeCliAgentsSettings`); invalid entries/fields are dropped.
*
* Note: elevation expressed through ANY of these channels (autonomy mode,
* extra args, env additions, a non-default command override) is gated by a
* stored per-project approval at launch — see `@fusion/engine`'s
* `resolveEffectivePosture`. These settings only describe *intent*; the
* engine resolves and enforces posture. Default: {} (no overrides). */
cliAgents?: Record<string, CliAgentSettings>;
}
/** Operator launch config for one CLI-agent adapter (U15). Values are layered
* over the adapter's shipped defaults at launch. All fields optional; an empty
* object means "use shipped defaults". */
export interface CliAgentSettings {
/** Override for the binary path/name to invoke. A non-default value is treated
* as privileged (routes through the autonomy approval gate). */
commandOverride?: string;
/** Extra args appended after the adapter's computed base args. Free-form; the
* engine's elevation detector scans these for bypass markers. */
extraArgs?: string[];
/** Autonomy mode above the adapter baseline. `"default"` is the baseline (no
* elevation); `"elevated"` requests bypass-permissions-style autonomy and is
* gated. Kept as a string enum so adapters can map it to their own flags. */
autonomyMode?: "default" | "elevated";
/** Additional env var KEYS to forward from the parent process to the child.
* Names only (never values); the engine copies these from `process.env`.
* Service credentials (`FUSION_*`) are always excluded regardless. */
envAdditions?: string[];
}
export type RemoteAccessProvider = "tailscale" | "cloudflare";
@@ -3141,6 +3199,12 @@ export interface ProjectSettings {
* (trust-on-first-use). A node's command must appear here before it runs;
* named scripts (settings.scripts) never require approval. */
approvedWorkflowCliCommands?: string[];
/** CLI-agent adapter ids the project owner has approved for ELEVATED autonomy
* (CLI Agent Executor, U15). An adapter must appear here before a launch whose
* resolved posture is elevated (bypass-permissions-style) is permitted; an
* unapproved elevation fails the launch with a typed error. Approving
* principal in v1: the daemon-token holder (the single workspace owner). */
approvedCliAutonomyAdapters?: string[];
/** Engine pause (soft pause): when true, the scheduler and triage
* processor stop dispatching **new** work (scheduling, triage
* specification, and auto-merge), but currently running agent sessions
@@ -4001,6 +4065,10 @@ export {
isProjectSettingsKey,
isMergeRequestContractShadowEnabled,
resolvePersistAgentThinkingLog,
sanitizeCliAgentSettings,
sanitizeCliAgentsSettings,
CLI_AGENT_ADAPTER_IDS,
CLI_AGENT_AUTONOMY_MODES,
} from "./settings-schema.js";
export interface BoardConfig {

View File

@@ -95,6 +95,10 @@ export function validateLinearity(ir: WorkflowIr): WorkflowCompileError | null {
return new WorkflowCompileError(`node '${node.id}' has no outgoing edge`);
}
if (outs.length > 1) {
// NOTE: the `require the workflow interpreter (deferred)` suffix is matched
// by the dashboard editor (WorkflowNodeEditor handleSave, KTD-4) to render
// an info-tone "interpreter-only" banner instead of an error. Keep both
// interpreter-deferred messages carrying this exact suffix in sync.
return new WorkflowCompileError(
`node '${node.id}' branches into ${outs.length} edges — graphs with branches require the workflow interpreter (deferred)`,
);
@@ -155,6 +159,18 @@ function defaultGateMode(node: WorkflowIrNode, mode: "prompt" | "script"): Workf
return mode === "script" ? "gate" : "advisory";
}
/**
* Map a single user IR node onto a WorkflowStepInput. This is the forward half
* of the steps↔IR round-trip contract (workflow-editor-consolidation R4/KTD-2);
* its exact inverse is `stepInputToNode` in `workflow-steps-to-ir.ts`. Parity is
* pinned by `__tests__/workflow-steps-to-ir.test.ts` over exactly the
* compiler-visible fields: name / mode / phase / gateMode / prompt / scriptName /
* toolMode / modelProvider / modelId. `enabled` / `defaultOn` / `templateId` are
* NOT compiler-visible and are handled by migration policy, not the converter.
*
* INVERSION CONTRACT: when you add a field here, extend `stepInputToNode` (and
* the parity test) in `workflow-steps-to-ir.ts` to keep the round-trip exact.
*/
function nodeToStepInput(node: WorkflowIrNode, phase: "pre-merge" | "post-merge"): WorkflowStepInput {
const scriptName = configString(node, "scriptName");
const mode: "prompt" | "script" = node.kind === "script" || (node.kind === "gate" && scriptName) ? "script" : "prompt";

View File

@@ -7,6 +7,12 @@ export interface WorkflowNodeLayout {
y: number;
}
/** Discriminates a full, selectable workflow from a reusable single-node
* "fragment" template (workflow-editor-consolidation U1, KTD-1). Fragments are
* excluded from task workflow pickers, default-workflow selection, and the
* compile/selection paths; both kinds are stored as parseable full IRs. */
export type WorkflowDefinitionKind = "workflow" | "fragment";
/** A named, persisted workflow authored as a WorkflowIr graph plus editor layout. */
export interface WorkflowDefinition {
/** Unique identifier (e.g., "WF-001"). */
@@ -15,6 +21,8 @@ export interface WorkflowDefinition {
name: string;
/** Short description for UI display. */
description: string;
/** Discriminates full workflows from reusable fragment templates (KTD-1). */
kind: WorkflowDefinitionKind;
/** The validated workflow graph (v1 IR contract). */
ir: WorkflowIr;
/** Editor node positions keyed by IR node id. May be empty (auto-layout). */
@@ -32,6 +40,9 @@ export interface WorkflowDefinitionInput {
/** Workflow graph; validated via parseWorkflowIr on write. */
ir: WorkflowIr;
layout?: Record<string, WorkflowNodeLayout>;
/** Discriminates full workflows from reusable fragment templates (KTD-1).
* Defaults to "workflow" when omitted. */
kind?: WorkflowDefinitionKind;
}
/** Partial update for an existing workflow definition. */
@@ -48,6 +59,13 @@ export interface WorkflowDefinitionUpdate {
* the `workflowColumns` flag is ON.
*/
rehomeTo?: string;
/**
* Column-agent policy escalation (column-agent plan R13): set true to confirm
* binding a column agent whose permission policy is broader than the project
* default. Without it, the write surfaces (dashboard routes, fn_workflow_*
* tools) reject such bindings with a typed policy-escalation error.
*/
confirmPolicyEscalation?: boolean;
/**
* U11/KTD-13: when an IR update changes a custom field's type incompatibly for
* tasks that already hold a value under that field, the update is blocked with

View File

@@ -45,6 +45,50 @@ export function resolveMaxReworkCycles(raw: unknown): number {
return Math.max(1, Math.min(MAX_REWORK_CYCLES_CAP, Math.floor(n)));
}
/**
* Executor kinds selectable on a prompt/execute node's `config.executor` (CLI
* Agent Executor, U7). The engine reads `config.executor` as an open string; this
* union documents the recognized values and `WorkflowNodeExecutorConfig` the
* fields each one consumes. `config` itself stays an open `Record` so unknown
* keys remain forward-compatible.
*
* - `model` (default): run the prompt on the configured/override model.
* - `agent` : run as a named agent (adopt its model + persona).
* - `skill` : invoke a named skill with the prompt as input.
* - `cli` : run a named project script with the prompt via env.
* - `cli-agent` : drive a CLI coding agent (Claude Code / Codex / Droid / Pi /
* generic) in an engine-owned PTY for the execute step. Honors
* cancel/abort/re-entry semantics and positive-completion gating.
*/
export type WorkflowNodeExecutorKind = "model" | "agent" | "skill" | "cli" | "cli-agent";
/**
* The cli-agent slice of a workflow node's `config`. These ride on the open
* `WorkflowIrNode.config` record (read at U7's executor seam); they are NOT a
* separate column. The resolved values are SNAPSHOTTED at session launch — a
* mid-run edit to the node config applies to the next run only.
*/
export interface WorkflowNodeExecutorConfig {
/** Selected executor kind for this node. */
executor?: WorkflowNodeExecutorKind;
/** cli-agent: adapter id to drive the session (resolved against the registry). */
cliAdapterId?: string;
/**
* cli-agent: autonomy posture (drives privileged flags + resume caps). Stored
* verbatim; structured but extensible (mirrors `CliAutonomyPosture`).
*/
cliAutonomy?: {
autoApprove?: boolean;
maxResumeAttempts?: number;
[key: string]: unknown;
};
/**
* cli-agent: notification settings for waiting-on-input events on this node
* (origin R2/R11). Opaque to the engine seam; forwarded to the dispatch.
*/
cliNotify?: Record<string, unknown>;
}
export interface WorkflowIrEdge {
from: string;
to: string;
@@ -122,6 +166,48 @@ export interface WorkflowFieldDefinition {
render?: WorkflowFieldRender;
}
/** Workflow-settings (U1): the supported setting value types. A whitelist
* mirroring the scalar/enum subset of `WorkflowFieldType` — settings carry
* workflow-scoped policy (step timeouts, review gates, model lanes), so the
* date/url field types do not apply. */
export type WorkflowSettingType =
| "string"
| "text"
| "number"
| "boolean"
| "enum"
| "multi-enum";
/** A single enum/multi-enum option for a workflow setting (mirrors
* `WorkflowFieldOption`). */
export interface WorkflowSettingOption {
value: string;
label: string;
color?: string;
}
/** Rendering instructions for a workflow setting (U1, KTD-1). Settings get their
* OWN render-hint type: a widget only — NO `card`/`detail` placement, which is
* task-card-specific. The widget whitelist mirrors the field render widgets. */
export interface WorkflowSettingRender {
widget?: "select" | "radio" | "chips" | "input" | "textarea" | "toggle";
}
/** Workflow-settings (U1, R1, KTD-1): a workflow-declared typed setting. Clones
* the shape of `WorkflowFieldDefinition` (one level up) — declarations describe
* the schema; the per-`(workflowId, projectId)` value table (U2) carries data.
* `default` is consumed by the engine's effective-settings resolver (U3), so it
* is validated against its own type/options at parse time. */
export interface WorkflowSettingDefinition {
id: string;
name: string;
type: WorkflowSettingType;
default?: unknown;
options?: WorkflowSettingOption[];
description?: string;
render?: WorkflowSettingRender;
}
/** A single trait configuration applied to a column. The `trait` is an opaque
* registry id (resolved by the trait registry shipped in U2); `config` carries
* trait-specific options validated by that trait's schema. */
@@ -130,11 +216,32 @@ export interface WorkflowIrColumnTrait {
config?: Record<string, unknown>;
}
/** Per-column permanent-agent binding (column-agent plan KTD-1). A column may name
* one agent from the registry plus a mode that decides precedence against
* node-level / task-level agent and model settings:
* - `defer`: the column agent applies only when the work carries no own settings
* (no agent identity and no complete modelProvider+modelId pair — KTD-5).
* - `override`: the column agent supersedes node/task settings wholesale.
* This is execution identity (consumed by the executor's session-building paths),
* not a board-transition trait — hence a first-class typed field, not a trait
* config blob (KTD-1). Agent *existence* is not an IR concern (no agent store at
* this layer); it is enforced at write time (route) and falls back at read time. */
export interface WorkflowColumnAgent {
/** Registry agent id that staffs the column. Non-empty. */
agentId: string;
/** Precedence mode against node/task settings. */
mode: "defer" | "override";
}
/** A workflow-defined board column. */
export interface WorkflowIrColumn {
id: string;
name: string;
traits: WorkflowIrColumnTrait[];
/** Optional permanent-agent binding (column-agent plan KTD-1). Additive and
* omitted entirely when unset — never serialized as `agent: null` — so legacy
* and default workflows stay byte-identical (R9). */
agent?: WorkflowColumnAgent;
}
/** Release conditions for a `hold` node (KTD-2, R3). */
@@ -170,6 +277,9 @@ export interface WorkflowIrV2 {
edges: WorkflowIrEdge[];
artifacts?: WorkflowIrArtifact[];
fields?: WorkflowFieldDefinition[];
/** Workflow-settings (U1, R1): typed setting declarations. Additive; absent on
* legacy graphs. Values persist per-`(workflowId, projectId)` (U2), not here. */
settings?: WorkflowSettingDefinition[];
}
/** Either IR version. v1 graphs upgrade to v2 on parse (see parseWorkflowIr). */

View File

@@ -10,6 +10,8 @@ import type {
WorkflowForeachConfig,
WorkflowFieldDefinition,
WorkflowFieldType,
WorkflowSettingDefinition,
WorkflowSettingType,
} from "./workflow-ir-types.js";
export class WorkflowIrError extends Error {
@@ -64,6 +66,27 @@ const FIELD_RENDER_WIDGETS: ReadonlySet<string> = new Set([
"toggle",
]);
/** Workflow-settings (U1) value-type whitelist (mirrors WORKFLOW_FIELD_TYPES). */
export const WORKFLOW_SETTING_TYPES: ReadonlySet<WorkflowSettingType> = new Set([
"string",
"text",
"number",
"boolean",
"enum",
"multi-enum",
]);
/** Workflow-settings render-widget whitelist (mirrors FIELD_RENDER_WIDGETS;
* no placement — settings have no card/detail placement). */
export const SETTING_RENDER_WIDGETS: ReadonlySet<string> = new Set([
"select",
"radio",
"chips",
"input",
"textarea",
"toggle",
]);
/** Hard cap on a foreach `maxReworkCycles` (KTD-5: default 3, clamp >10 to 10,
* reject <1). */
const MAX_REWORK_CYCLES_CAP = 10;
@@ -271,7 +294,11 @@ function reachableFrom(
* - rework edges legal only when both endpoints are inside this template;
* - step-review verdict routing rules (KTD-4).
*/
function validateForeach(node: WorkflowIrNode, topLevelNodeIds: Set<string>): void {
function validateForeach(
node: WorkflowIrNode,
topLevelNodeIds: Set<string>,
columnIds: Set<string>,
): void {
const cfg = node.config as Partial<WorkflowForeachConfig> | undefined;
if (!cfg || cfg.source !== "task-steps") {
throw new WorkflowIrError(
@@ -341,13 +368,20 @@ function validateForeach(node: WorkflowIrNode, topLevelNodeIds: Set<string>): vo
);
}
// No nested foreach.
// No nested foreach. Also: a template node's declared `column` must resolve to a
// top-level column id (column-agent plan KTD-1) — otherwise a dangling reference
// is a silent no-binding no-op at runtime instead of a typed authoring error.
for (const inner of templateNodes) {
if (inner.kind === "foreach") {
throw new WorkflowIrError(
`foreach node '${node.id}' template may not contain a nested foreach ('${inner.id}')`,
);
}
if (inner.column !== undefined && !columnIds.has(inner.column)) {
throw new WorkflowIrError(
`Workflow node '${inner.id}' references undefined column '${inner.column}'`,
);
}
}
// Edge endpoints must reference template nodes; rework edges must stay intra-template.
@@ -726,6 +760,139 @@ function validateFields(fields: WorkflowFieldDefinition[] | undefined): void {
}
}
/** Validate that a setting's `default` conforms to its own type/options (U1).
* Unlike `validateFields`, settings validate defaults because the engine's
* effective-settings resolver (U3) consumes the default directly — a malformed
* default would feed garbage into execution. */
function validateSettingDefault(setting: WorkflowSettingDefinition): void {
const value = setting.default;
if (value === undefined) return;
const id = setting.id;
switch (setting.type) {
case "string":
case "text":
if (typeof value !== "string") {
throw new WorkflowIrError(
`Workflow setting '${id}' default must be a string for type '${setting.type}'`,
);
}
break;
case "number":
if (typeof value !== "number" || !Number.isFinite(value)) {
throw new WorkflowIrError(
`Workflow setting '${id}' default must be a finite number`,
);
}
break;
case "boolean":
if (typeof value !== "boolean") {
throw new WorkflowIrError(`Workflow setting '${id}' default must be a boolean`);
}
break;
case "enum": {
const allowed = new Set((setting.options ?? []).map((o) => o.value));
if (typeof value !== "string" || !allowed.has(value)) {
throw new WorkflowIrError(
`Workflow setting '${id}' default '${String(value)}' is not one of its enum options`,
);
}
break;
}
case "multi-enum": {
const allowed = new Set((setting.options ?? []).map((o) => o.value));
if (!Array.isArray(value)) {
throw new WorkflowIrError(
`Workflow setting '${id}' default must be an array for type 'multi-enum'`,
);
}
for (const entry of value) {
if (typeof entry !== "string" || !allowed.has(entry)) {
throw new WorkflowIrError(
`Workflow setting '${id}' default '${String(entry)}' is not one of its enum options`,
);
}
}
break;
}
}
}
/** Validate `settings` declarations (U1, R1). Mirrors `validateFields`: non-empty
* unique ids, type whitelist, options iff enum-kind, unique option values, render
* widget whitelist — plus default validation (settings need it; see
* `validateSettingDefault`). */
function validateSettings(settings: WorkflowSettingDefinition[] | undefined): void {
if (settings === undefined) return;
if (!Array.isArray(settings)) {
throw new WorkflowIrError("Workflow IR settings must be an array");
}
const seen = new Set<string>();
for (const setting of settings) {
if (!setting || typeof setting.id !== "string" || setting.id === "") {
throw new WorkflowIrError("Workflow setting must have a non-empty id");
}
if (seen.has(setting.id)) {
throw new WorkflowIrError(`Workflow IR has duplicate setting id '${setting.id}'`);
}
seen.add(setting.id);
if (typeof setting.name !== "string" || setting.name === "") {
throw new WorkflowIrError(
`Workflow setting '${setting.id}' must have a non-empty name`,
);
}
if (!WORKFLOW_SETTING_TYPES.has(setting.type)) {
throw new WorkflowIrError(
`Workflow setting '${setting.id}' has unknown type '${String(setting.type)}'`,
);
}
const isEnum = setting.type === "enum" || setting.type === "multi-enum";
if (isEnum) {
if (!Array.isArray(setting.options) || setting.options.length === 0) {
throw new WorkflowIrError(
`Workflow setting '${setting.id}' of type '${setting.type}' must declare non-empty options`,
);
}
const optSeen = new Set<string>();
for (const opt of setting.options) {
if (!opt || typeof opt.value !== "string" || opt.value === "") {
throw new WorkflowIrError(
`Workflow setting '${setting.id}' option must have a non-empty value`,
);
}
if (typeof opt.label !== "string" || opt.label === "") {
throw new WorkflowIrError(
`Workflow setting '${setting.id}' option '${opt.value}' must have a non-empty label`,
);
}
if (optSeen.has(opt.value)) {
throw new WorkflowIrError(
`Workflow setting '${setting.id}' has duplicate option value '${opt.value}'`,
);
}
optSeen.add(opt.value);
}
} else if (setting.options !== undefined) {
throw new WorkflowIrError(
`Workflow setting '${setting.id}' of type '${setting.type}' must not declare options`,
);
}
if (setting.description !== undefined && typeof setting.description !== "string") {
throw new WorkflowIrError(
`Workflow setting '${setting.id}' description must be a string`,
);
}
if (setting.render !== undefined) {
const r = setting.render;
if (r.widget !== undefined && !SETTING_RENDER_WIDGETS.has(r.widget)) {
throw new WorkflowIrError(
`Workflow setting '${setting.id}' render.widget '${String(r.widget)}' is not allowed`,
);
}
}
validateSettingDefault(setting);
}
}
function validateColumns(ir: WorkflowIrV2): void {
if (!Array.isArray(ir.columns)) {
throw new WorkflowIrError("Workflow IR v2 columns must be an array");
@@ -742,6 +909,29 @@ function validateColumns(ir: WorkflowIrV2): void {
if (!Array.isArray(column.traits)) {
throw new WorkflowIrError(`Workflow IR column '${column.id}' traits must be an array`);
}
validateColumnAgent(column);
}
}
/** Validate a column's optional permanent-agent binding (column-agent plan KTD-1).
* Mirrors the `validateFields` early-return shape: absent → no-op; present →
* `agentId` must be a non-empty string and `mode` exactly `defer`/`override`.
* Agent existence is NOT checked here (no agent store at the IR layer). */
function validateColumnAgent(column: WorkflowIrColumn): void {
const agent = column.agent;
if (agent === undefined) return;
if (!agent || typeof agent !== "object") {
throw new WorkflowIrError(`Workflow IR column '${column.id}' agent must be an object`);
}
if (typeof agent.agentId !== "string" || agent.agentId === "") {
throw new WorkflowIrError(
`Workflow IR column '${column.id}' agent must have a non-empty agentId`,
);
}
if (agent.mode !== "defer" && agent.mode !== "override") {
throw new WorkflowIrError(
`Workflow IR column '${column.id}' agent mode must be 'defer' or 'override' (got '${String(agent.mode)}')`,
);
}
}
@@ -775,12 +965,13 @@ function validateV2(ir: WorkflowIrV2): void {
const topLevelIds = new Set(ir.nodes.map((n) => n.id));
validateStepExecutePlacement(ir.nodes);
for (const node of ir.nodes) {
if (node.kind === "foreach") validateForeach(node, topLevelIds);
if (node.kind === "foreach") validateForeach(node, topLevelIds, columnIds);
}
validateStepReviewRouting(ir.nodes, outgoing, nodesById, false);
validateParseStepsNodes(ir);
validateCodeNodes(ir.nodes);
validateFields(ir.fields);
validateSettings(ir.settings);
// Rework edges are legal intra-template (foreach, KTD-5) and — since U6
// generalized the bounded-rework mechanism to the top-level walk — for a
@@ -877,8 +1068,13 @@ export function downgradeIrToV1IfPure(ir: WorkflowIr): WorkflowIr {
if (!V1_NODE_KINDS.has(node.kind)) return ir;
}
// Step-inversion declarations (artifacts/fields) are v2-only features.
if ((ir.artifacts && ir.artifacts.length > 0) || (ir.fields && ir.fields.length > 0)) {
// Step-inversion declarations (artifacts/fields) and workflow settings (U1)
// are v2-only features.
if (
(ir.artifacts && ir.artifacts.length > 0) ||
(ir.fields && ir.fields.length > 0) ||
(ir.settings && ir.settings.length > 0)
) {
return ir;
}
@@ -892,6 +1088,9 @@ export function downgradeIrToV1IfPure(ir: WorkflowIr): WorkflowIr {
if (col.id !== expectedId || col.name !== expectedId || col.traits.length !== 0) {
return ir;
}
// A permanent-agent binding is a v2-only feature (column-agent plan, R9): a
// graph that staffs a column can never round-trip through a pre-v2 binary.
if (col.agent !== undefined) return ir;
}
// Every node must sit in its default seam-derived column. A node placed
@@ -914,3 +1113,42 @@ export function downgradeIrToV1IfPure(ir: WorkflowIr): WorkflowIr {
export function serializeWorkflowIr(ir: WorkflowIr): string {
return JSON.stringify(ir, null, 2);
}
/**
* Strip the trust-escalating `cliSkipApproval`/`autoApprove` flags from every
* node config in an IR, recursing into foreach `config.template.nodes` at any
* nesting depth (foreach-in-foreach). Mutates the passed IR in place and returns
* it alongside a `stripped` flag indicating whether anything was removed.
*
* These flags bypass the CLI first-run approval gate (see executor.ts). They are
* legitimate only for workflows authored through the trusted dashboard editor /
* executor lane; on prompt-injectable surfaces (chat/planning authoring tools,
* import, AI design) they must be removed at the write boundary.
*/
export function stripApprovalBypassFlags(ir: WorkflowIr): { ir: WorkflowIr; stripped: boolean } {
const nodes = (ir as { nodes?: WorkflowIrNode[] }).nodes;
if (!Array.isArray(nodes)) return { ir, stripped: false };
let stripped = false;
const stripNode = (node: WorkflowIrNode): void => {
// Untrusted input may contain non-object entries (null, strings, numbers)
// in `nodes` / `template.nodes`; skip them rather than dereferencing.
if (!node || typeof node !== "object") return;
const cfg = node.config as Record<string, unknown> | undefined;
if (cfg && typeof cfg === "object") {
if ("cliSkipApproval" in cfg) {
delete cfg.cliSkipApproval;
stripped = true;
}
if ("autoApprove" in cfg) {
delete cfg.autoApprove;
stripped = true;
}
const template = (cfg as { template?: { nodes?: unknown } }).template;
if (template && Array.isArray(template.nodes)) {
for (const inner of template.nodes as WorkflowIrNode[]) stripNode(inner);
}
}
};
for (const node of nodes) stripNode(node);
return { ir, stripped };
}

View File

@@ -0,0 +1,181 @@
/**
* Per-task EFFECTIVE workflow-settings resolution (U3, R3, KTD-3).
*
* Sibling of `workflow-ir-resolver.ts`. Composes three steps into the flat,
* `Partial<ProjectSettings>`-shaped value map the engine reads at executor entry:
*
* 1. resolve the workflow IR (built-in or custom) → its `settings` declarations;
* 2. read the raw stored `(workflowId, projectId)` value map;
* 3. {@link resolveEffectiveSettingValues} → declaration default ?? stored value,
* dropping orphaned/invalid stored entries (KTD-6).
*
* The moved keys are all current `ProjectSettings` fields, so the returned map is a
* structurally-compatible `Partial<ProjectSettings>` today. The engine MERGES this
* over the project/global settings object so the ~20 flat `settings.<key>` read
* sites keep their exact expressions (KTD-3).
*
* NEVER-THROW contract (mirrors the IR resolver): a missing/corrupt workflow
* degrades to the built-in coding declarations; any store error degrades to an
* empty stored map, so the result falls back to declaration defaults. The caller
* always receives a usable map.
*
* IMPORTANT (parity): for built-in workflows with no stored values the effective
* map carries the declaration defaults, which are byte-equal to the legacy
* `DEFAULT_PROJECT_SETTINGS` literals — so merging it over project settings is a
* no-op when nothing is customized. Keys whose declaration omits a default (the
* per-phase model lanes) are ABSENT from the map (never `undefined`), so the merge
* never clobbers a real project value with `undefined`.
*/
import {
resolveWorkflowIrById,
resolveWorkflowIrForTask,
type WorkflowIrResolverStore,
} from "./workflow-ir-resolver.js";
import { resolveEffectiveSettingValues, findOrphanedSettingValues } from "./workflow-settings.js";
import { BUILTIN_WORKFLOW_SETTINGS } from "./builtin-workflow-settings.js";
import type { WorkflowSettingDefinition, WorkflowIr } from "./workflow-ir-types.js";
/**
* The effective map PLUS the subset of keys whose value came from an EXPLICIT
* STORED workflow value (not a declaration default). The engine entry merge uses
* `storedKeys` to decide override-vs-fill semantics:
*
* - a STORED key ALWAYS overrides the project/global base (the workflow tuned it);
* - a default-only key (in `effective` but NOT in `storedKeys`) only FILLS the
* base when the base lacks the key.
*
* This is what makes U3 behavior-identical pre-migration: a customized project
* setting (still present in the base before the U4 hard-move) is NOT clobbered by a
* declaration default; only a real stored workflow value overrides it. Post-
* migration the base lacks the moved key, so the declaration default fills it.
*/
export interface EffectiveSettingsResult {
effective: Record<string, unknown>;
storedKeys: Set<string>;
}
/** Minimal store surface the effective-settings resolver needs (public APIs). */
export interface WorkflowSettingsResolverStore extends WorkflowIrResolverStore {
/** Raw stored `(workflowId, projectId)` value map; `{}` when no row exists. */
getWorkflowSettingValues(workflowId: string, projectId: string): Record<string, unknown>;
/** The stable project id this store scopes `workflow_settings` rows by. A store
* instance is bound to one project, so the resolver derives the project key from
* the store rather than from the task (Task carries no projectId field). */
getWorkflowSettingsProjectId(): string;
}
/** The declarations carried by a resolved IR, with the built-in catalog as the
* defensive belt for built-in graphs that predate the embedded `settings` (the
* linear `BUILTIN_WORKFLOWS` carry them now, but keep the belt cheap). */
function declarationsFromIr(
ir: WorkflowIr,
workflowId: string | undefined,
): WorkflowSettingDefinition[] | undefined {
const declared = ir.version === "v2" ? ir.settings : undefined;
if (declared && declared.length > 0) return declared;
// Built-in workflows declare the full moved-key catalog (the migration parity
// anchor); fall back to it only when the resolved IR didn't embed it.
if (workflowId && workflowId.startsWith("builtin:")) return BUILTIN_WORKFLOW_SETTINGS;
return declared;
}
/** Compose declarations + raw stored values → effective flat map + the set of keys
* whose value came from an explicit stored workflow value (never throws). */
function effectiveFrom(
store: WorkflowSettingsResolverStore,
ir: WorkflowIr,
workflowId: string | undefined,
projectId: string,
): EffectiveSettingsResult {
const declarations = declarationsFromIr(ir, workflowId);
let stored: Record<string, unknown> = {};
if (workflowId) {
try {
stored = store.getWorkflowSettingValues(workflowId, projectId) ?? {};
} catch {
stored = {};
}
}
const effective = resolveEffectiveSettingValues(declarations, stored);
// A key is "stored" iff it appears in the effective map AND the stored row holds
// a value for it that did NOT orphan (i.e. it was not dropped). Orphaned stored
// entries fall to the declaration default, so they count as default-only.
const orphanedIds = new Set(findOrphanedSettingValues(declarations, stored).map((o) => o.id));
const storedKeys = new Set<string>();
for (const id of Object.keys(effective)) {
if (Object.prototype.hasOwnProperty.call(stored, id) && !orphanedIds.has(id)) {
const raw = stored[id];
if (raw !== null && raw !== undefined) storedKeys.add(id);
}
}
return { effective, storedKeys };
}
/**
* Resolve the effective workflow settings for an explicit `(workflowId,
* projectId)`. Used by the migration/export/agent-tool paths that name a
* workflow directly. Never throws.
*/
export async function resolveEffectiveSettingsById(
store: WorkflowSettingsResolverStore,
workflowId: string,
projectId: string,
irCache?: Map<string, WorkflowIr>,
): Promise<Record<string, unknown>> {
const ir = await resolveWorkflowIrById(store, workflowId, irCache);
return effectiveFrom(store, ir, workflowId, projectId).effective;
}
/** The minimal task identity the per-task resolver reads. Task carries no
* projectId field — the project key comes from the store. */
export interface EffectiveSettingsTaskRef {
id: string;
}
/**
* Resolve the effective workflow settings for a TASK (the engine's primary entry).
* Reads the task's workflow selection, resolves its IR, and composes the effective
* value map for `(resolvedWorkflowId, task.projectId)`.
*
* An absent/falsy selection degrades to `builtin:coding` (matching the IR
* resolver), so a selection-less task reads the built-in declaration defaults —
* byte-equal to legacy project-settings defaults. Never throws.
*/
export async function resolveEffectiveSettings(
store: WorkflowSettingsResolverStore,
task: EffectiveSettingsTaskRef,
irCache?: Map<string, WorkflowIr>,
): Promise<Record<string, unknown>> {
return (await resolveEffectiveSettingsDetailed(store, task, irCache)).effective;
}
/**
* Like {@link resolveEffectiveSettings}, but also returns `storedKeys` (the keys
* whose value came from an explicit stored workflow value vs. a declaration
* default). The engine entry merge uses this to override the base only for stored
* keys and fill-only for default-only keys. Never throws.
*/
export async function resolveEffectiveSettingsDetailed(
store: WorkflowSettingsResolverStore,
task: EffectiveSettingsTaskRef,
irCache?: Map<string, WorkflowIr>,
): Promise<EffectiveSettingsResult> {
let workflowId: string | undefined;
try {
workflowId = store.getTaskWorkflowSelection(task.id)?.workflowId;
} catch {
workflowId = undefined;
}
const effectiveWorkflowId = workflowId || "builtin:coding";
const ir = await resolveWorkflowIrForTask(store, task.id, irCache);
let projectId: string;
try {
projectId = store.getWorkflowSettingsProjectId();
} catch {
// Degrade to declaration defaults (empty stored map) on identity failure.
// Keep the resolved workflowId so builtin graphs still pick up the catalog fallback.
return effectiveFrom(store, ir, effectiveWorkflowId, "");
}
return effectiveFrom(store, ir, effectiveWorkflowId, projectId);
}

View File

@@ -0,0 +1,339 @@
/**
* Workflow setting-value validation & effective-resolution authority (U2, R2/R4).
*
* Workflows declare typed settings ({@link WorkflowSettingDefinition}); setting
* *values* live per `(workflowId, projectId)` in the `workflow_settings` table (a
* JSON object keyed by setting id). This module is the single, side-effect-free
* validation core that the store write authority
* (`updateWorkflowSettingValues`) delegates to. It mirrors `task-fields.ts`: a
* flat, JSON-safe typed rejection with a machine-stable `code`, the offending
* `settingId`, and a non-localized `detail` string for audit/logs.
*
* Two operations:
* - {@link validateSettingValuePatch} — validate a `Record<string, unknown>`
* patch against a setting schema, normalizing accepted values. `null`/`undefined`
* in the patch is a delete sentinel for that setting (always accepted).
* - {@link resolveEffectiveSettingValues} — compose stored values + declaration
* defaults into the effective value map, implementing DROP-ON-ORPHAN (KTD-6).
*
* KTD-6 — DELIBERATE DIVERGENCE FROM `task-fields.ts`. The custom-field reconciler
* (`reconcileFieldsOnWorkflowChange`) RETAINS orphaned values and surfaces them in
* a UI disclosure — safe for display data. Workflow settings are POLICY the engine
* consumes (a retyped enum→number setting with a stale string value would feed
* garbage into execution), so effective resolution DROPS any stored value that no
* longer validates against the current declaration and falls to the declaration
* `default`. The dropped raw values never reach the engine; the editor surfaces
* them via {@link findOrphanedSettingValues} for the U6 disclosure.
*/
import type {
WorkflowSettingDefinition,
} from "./workflow-ir-types.js";
// ---------------------------------------------------------------------------
// Typed rejection (TransitionRejection-style: flat, JSON-safe, no class)
// ---------------------------------------------------------------------------
/**
* Reason codes for a rejected setting-value write. Stable string literals — they
* cross the agent-tool / HTTP boundary and are matched by surfaces for copy, so
* they must not change without migrating consumers. Mirrors
* {@link import("./task-fields.js").CustomFieldRejectionCode}.
*/
export type WorkflowSettingRejectionCode =
| "no-settings-defined"
| "unknown-setting"
| "type-mismatch"
| "enum-violation";
/** The full, immutable set of setting-value rejection codes. */
export const WORKFLOW_SETTING_REJECTION_CODES: readonly WorkflowSettingRejectionCode[] = [
"no-settings-defined",
"unknown-setting",
"type-mismatch",
"enum-violation",
] as const;
/**
* A typed setting-value rejection. Flat and JSON-safe by construction — mirrors
* {@link import("./task-fields.js").CustomFieldRejection}.
*
* - `code` — machine-stable {@link WorkflowSettingRejectionCode}.
* - `settingId` — the offending setting id (the patch key that failed).
* - `message` — non-localized diagnostic context for audit/logs.
*/
export interface WorkflowSettingRejection {
code: WorkflowSettingRejectionCode;
settingId: string;
message: string;
}
/** Result of validating a setting-value patch. */
export interface SettingValuePatchResult {
/** The accepted, normalized values (a `null` entry is a delete sentinel). */
accepted: Record<string, unknown>;
/** The rejected keys with their typed reasons. */
rejections: WorkflowSettingRejection[];
}
/** Construct a {@link WorkflowSettingRejection}. */
export function makeWorkflowSettingRejection(
code: WorkflowSettingRejectionCode,
settingId: string,
message: string,
): WorkflowSettingRejection {
return { code, settingId, message };
}
/**
* Thrown by the throw-based store write path when a setting-value write rejects.
* Mirrors {@link import("./task-fields.js").CustomFieldRejectionError}: carries
* the structured rejection(s) so HTTP/agent surfaces can recover the setting path
* and code.
*/
export class WorkflowSettingRejectionError extends Error {
readonly rejections: WorkflowSettingRejection[];
constructor(rejections: WorkflowSettingRejection[]) {
const first = rejections[0];
super(
first
? `workflow setting '${first.settingId}' rejected (${first.code}): ${first.message}`
: "workflow setting value write rejected",
);
this.name = "WorkflowSettingRejectionError";
this.rejections = rejections;
}
}
// ---------------------------------------------------------------------------
// Per-type value validation
// ---------------------------------------------------------------------------
/** True iff `value` is an option-value member of `setting.options`. */
function isEnumMember(setting: WorkflowSettingDefinition, value: string): boolean {
return (setting.options ?? []).some((o) => o.value === value);
}
/**
* Validate (and normalize) a single non-null value against a setting's type.
* Returns the normalized value on success, or a rejection. The caller has already
* resolved the setting definition.
*/
function validateValue(
setting: WorkflowSettingDefinition,
value: unknown,
): { ok: true; value: unknown } | { ok: false; rejection: WorkflowSettingRejection } {
const reject = (
code: WorkflowSettingRejectionCode,
message: string,
): { ok: false; rejection: WorkflowSettingRejection } => ({
ok: false,
rejection: makeWorkflowSettingRejection(code, setting.id, message),
});
switch (setting.type) {
case "string":
case "text": {
if (typeof value !== "string") {
return reject("type-mismatch", `setting '${setting.id}' expects a string, got ${typeof value}`);
}
return { ok: true, value };
}
case "number": {
if (typeof value !== "number" || !Number.isFinite(value)) {
return reject(
"type-mismatch",
`setting '${setting.id}' expects a finite number, got ${typeof value === "number" ? String(value) : typeof value}`,
);
}
return { ok: true, value };
}
case "boolean": {
if (typeof value !== "boolean") {
return reject("type-mismatch", `setting '${setting.id}' expects a boolean, got ${typeof value}`);
}
return { ok: true, value };
}
case "enum": {
if (typeof value !== "string") {
return reject("type-mismatch", `setting '${setting.id}' (enum) expects a string option value, got ${typeof value}`);
}
if (!isEnumMember(setting, value)) {
return reject("enum-violation", `setting '${setting.id}' value '${value}' is not a declared option`);
}
return { ok: true, value };
}
case "multi-enum": {
if (!Array.isArray(value)) {
return reject("type-mismatch", `setting '${setting.id}' (multi-enum) expects an array, got ${typeof value}`);
}
const seen = new Set<string>();
for (const item of value) {
if (typeof item !== "string") {
return reject("type-mismatch", `setting '${setting.id}' (multi-enum) members must be strings`);
}
if (!isEnumMember(setting, item)) {
return reject("enum-violation", `setting '${setting.id}' member '${item}' is not a declared option`);
}
if (seen.has(item)) {
return reject("enum-violation", `setting '${setting.id}' has duplicate member '${item}'`);
}
seen.add(item);
}
return { ok: true, value: [...value] as string[] };
}
default: {
// Exhaustiveness guard — an unknown type cannot validate.
const _exhaustive: never = setting.type;
return reject("type-mismatch", `setting '${setting.id}' has unsupported type '${String(_exhaustive)}'`);
}
}
}
// ---------------------------------------------------------------------------
// Patch validation authority
// ---------------------------------------------------------------------------
/**
* Validate a setting-value `patch` against a workflow's `declarations`.
*
* - A `null`/`undefined` patch value is a DELETE sentinel: the setting's stored
* value should be removed. It is ALWAYS accepted (null-as-delete) and surfaces
* in `accepted` as `null` so the caller can apply the delete uniformly.
* - A non-null value is validated/normalized per the setting's type.
* - A patch key that names no declared setting → `unknown-setting`.
* - When `declarations` is undefined/empty and the patch carries any non-null key →
* that key is rejected `no-settings-defined`. (A delete against no declarations is
* harmless and accepted so stale rows can always be cleared.)
*
* Unlike the custom-field authority this is NOT fail-fast: every offending key is
* reported so the editor can render per-field errors while applying the rest.
*/
export function validateSettingValuePatch(
declarations: WorkflowSettingDefinition[] | undefined,
patch: Record<string, unknown>,
): SettingValuePatchResult {
const byId = new Map<string, WorkflowSettingDefinition>((declarations ?? []).map((d) => [d.id, d]));
const accepted: Record<string, unknown> = {};
const rejections: WorkflowSettingRejection[] = [];
for (const key of Object.keys(patch)) {
const value = patch[key];
// null/undefined = delete this setting's value. Always accepted, even when the
// declaration is gone (lets the editor clear orphaned rows).
if (value === null || value === undefined) {
accepted[key] = null;
continue;
}
const setting = byId.get(key);
if (byId.size === 0) {
rejections.push(
makeWorkflowSettingRejection(
"no-settings-defined",
key,
"the named workflow declares no settings; no values may be written",
),
);
continue;
}
if (!setting) {
rejections.push(
makeWorkflowSettingRejection(
"unknown-setting",
key,
`setting '${key}' is not declared by the named workflow`,
),
);
continue;
}
const res = validateValue(setting, value);
if (!res.ok) {
rejections.push(res.rejection);
continue;
}
accepted[key] = res.value;
}
return { accepted, rejections };
}
// ---------------------------------------------------------------------------
// Effective resolution (drop-on-orphan, KTD-6)
// ---------------------------------------------------------------------------
/** A stored value re-validates cleanly against the current declaration. */
function valueStillValid(setting: WorkflowSettingDefinition, value: unknown): boolean {
if (value === null || value === undefined) return false;
return validateValue(setting, value).ok;
}
/** An orphaned stored entry: a value that no longer validates against the current
* declaration (type change, enum option removed, declaration deleted). Surfaced to
* the U6 editor disclosure; never fed to the engine. */
export interface OrphanedSettingValue {
id: string;
value: unknown;
}
/**
* Resolve the EFFECTIVE setting values for a workflow from its `declarations` and
* the raw `stored` map, implementing DROP-ON-ORPHAN (KTD-6).
*
* For each declared setting:
* - if a stored value exists AND re-validates against the current declaration →
* use the stored value;
* - otherwise (no stored value, OR a stored value that no longer validates —
* type change, enum option removed) → DROP it and use the declaration `default`
* when one is present; absent declarations contribute nothing.
*
* Stored values for ids with NO current declaration (declaration deleted) are
* dropped entirely — they cannot reach the effective map. The raw `stored` row is
* never mutated here; this is a pure read. Use {@link findOrphanedSettingValues}
* to surface the dropped entries in the editor.
*/
export function resolveEffectiveSettingValues(
declarations: WorkflowSettingDefinition[] | undefined,
stored: Record<string, unknown> | undefined,
): Record<string, unknown> {
const storedMap = stored ?? {};
const effective: Record<string, unknown> = {};
for (const setting of declarations ?? []) {
const has = Object.prototype.hasOwnProperty.call(storedMap, setting.id);
const raw = has ? storedMap[setting.id] : undefined;
if (has && valueStillValid(setting, raw)) {
effective[setting.id] = raw;
continue;
}
// Drop-on-orphan / unset → declaration default (when present).
if (setting.default !== undefined) {
effective[setting.id] = setting.default;
}
}
return effective;
}
/**
* Compute the orphaned stored entries for the U6 editor disclosure: stored ids
* that either have no current declaration, or whose stored value no longer
* validates against the current declaration. These are exactly the entries
* {@link resolveEffectiveSettingValues} drops. The raw row is untouched.
*/
export function findOrphanedSettingValues(
declarations: WorkflowSettingDefinition[] | undefined,
stored: Record<string, unknown> | undefined,
): OrphanedSettingValue[] {
const byId = new Map<string, WorkflowSettingDefinition>((declarations ?? []).map((d) => [d.id, d]));
const orphaned: OrphanedSettingValue[] = [];
for (const [id, value] of Object.entries(stored ?? {})) {
if (value === null || value === undefined) continue;
const setting = byId.get(id);
if (!setting || !valueStillValid(setting, value)) {
orphaned.push({ id, value });
}
}
return orphaned;
}

View File

@@ -0,0 +1,162 @@
import type { WorkflowStep } from "./types.js";
import type { WorkflowIr, WorkflowIrNode, WorkflowIrEdge } from "./workflow-ir-types.js";
import type { WorkflowNodeLayout } from "./workflow-definition-types.js";
import { parseWorkflowIr } from "./workflow-ir.js";
/**
* Steps → IR converter (workflow-editor-consolidation U1, R4/KTD-2).
*
* This module is the exact INVERSE of the compiler's `nodeToStepInput`
* (`workflow-compiler.ts`). The round-trip contract is:
*
* compileWorkflowToSteps(stepsToWorkflowIr(steps, name)) ≡ steps
*
* over exactly the compiler-visible fields: name / mode / phase / gateMode /
* prompt / scriptName / toolMode / modelProvider / modelId. `enabled` /
* `defaultOn` / `templateId` / `migratedFragmentId` are NOT compiler-visible and
* are handled by migration policy (KTD-3), not by this converter. Parity is
* pinned by `__tests__/workflow-steps-to-ir.test.ts`.
*
* INVERSION CONTRACT: when a compiler-visible field is added to `nodeToStepInput`
* (see the contract comment there), extend `stepInputToNode` below and the parity
* test to keep the round-trip exact.
*
* Seam encoding mirrors `linear()` in `builtin-workflows.ts` exactly: the fixed
* execute → review → merge pipeline is emitted as prompt-kind nodes carrying
* `config.seam`, chained by `success` edges, with each seam also wired
* `failure → end`.
*/
/** The fixed seam pipeline, in canonical order. The `merge` seam is the
* pre-/post-merge boundary and is always emitted (R4). */
const SEAM_ORDER = ["execute", "review", "merge"] as const;
/** Horizontal spacing used by `linear()`; reused so migrated graphs lay out the
* same way built-ins do. */
const LAYOUT_X0 = 60;
const LAYOUT_DX = 170;
const LAYOUT_Y = 160;
/**
* Inverse of `nodeToStepInput` (workflow-compiler.ts). Produces a single user IR
* node whose forward compilation reproduces every compiler-visible field of the
* given step.
*
* kind ↔ mode/gateMode mapping (the heart of the contract):
* - mode "script" → kind "script", `config.scriptName` set. The compiler reads
* mode from `kind === "script"`, so this round-trips to mode "script".
* - mode "prompt" → kind "prompt", `config.prompt`/`toolMode`/model overrides.
* - gateMode is ALWAYS written to `config.gateMode` (both "gate" and "advisory").
* The compiler's `defaultGateMode` returns an explicit `config.gateMode` for
* non-gate-kind nodes verbatim, so this round-trips for both modes without
* needing the `gate` node kind (which the compiler only emits via scriptName
* heuristics — using explicit `config.gateMode` keeps the inverse total).
*/
function stepInputToNode(step: WorkflowStep, id: string): WorkflowIrNode {
const config: Record<string, unknown> = {
name: step.name,
// Always carry gateMode so the compiler reproduces it exactly for both modes.
gateMode: step.gateMode,
};
if (step.description) config.description = step.description;
if (step.mode === "script") {
if (step.scriptName) config.scriptName = step.scriptName;
return { id, kind: "script", config };
}
// prompt mode
config.prompt = step.prompt ?? "";
config.toolMode = step.toolMode === "coding" ? "coding" : "readonly";
// Model overrides only round-trip when BOTH are present (compiler requirement).
if (step.modelProvider && step.modelId) {
config.modelProvider = step.modelProvider;
config.modelId = step.modelId;
}
return { id, kind: "prompt", config };
}
/** Build a seam node exactly as `linear()` does: a prompt-kind node tagged with
* `config.seam`. */
function seamNode(seam: (typeof SEAM_ORDER)[number]): WorkflowIrNode {
return { id: seam, kind: "prompt", config: { seam } };
}
/**
* Convert an ordered `WorkflowStep[]` into a valid v1 WorkflowIr:
*
* start → [pre-merge user nodes] → execute → review → merge
* → [post-merge user nodes] → end
*
* Steps with `phase` undefined map to pre-merge (R4). Seam nodes get an extra
* `failure → end` edge, mirroring `linear()`. The result always passes
* `parseWorkflowIr`. An empty step list yields the minimal seam-only pipeline
* (which compiles back to `[]`).
*/
export function stepsToWorkflowIr(steps: WorkflowStep[], name: string): WorkflowIr {
const preMerge = steps.filter((s) => (s.phase ?? "pre-merge") === "pre-merge");
const postMerge = steps.filter((s) => s.phase === "post-merge");
const nodes: WorkflowIrNode[] = [{ id: "start", kind: "start" }];
const userNodeIds = new Set<string>();
// Deterministic ids that cannot collide with the reserved start/end/seam ids.
const userNode = (step: WorkflowStep, index: number): WorkflowIrNode => {
let id = `step-${index + 1}`;
while (userNodeIds.has(id)) id = `${id}-x`;
userNodeIds.add(id);
return stepInputToNode(step, id);
};
preMerge.forEach((step, i) => nodes.push(userNode(step, i)));
// Fixed execute → review → merge seam pipeline; merge is the boundary (R4).
for (const seam of SEAM_ORDER) nodes.push(seamNode(seam));
postMerge.forEach((step, i) => nodes.push(userNode(step, preMerge.length + i)));
nodes.push({ id: "end", kind: "end" });
const edges: WorkflowIrEdge[] = [];
for (let i = 0; i < nodes.length - 1; i += 1) {
edges.push({ from: nodes[i].id, to: nodes[i + 1].id, condition: "success" });
}
// Seam nodes also fail straight to end (mirrors `linear()` / the legacy pipeline).
for (const node of nodes) {
if (typeof node.config?.seam === "string") {
edges.push({ from: node.id, to: "end", condition: "failure" });
}
}
return parseWorkflowIr({ version: "v1", name, nodes, edges });
}
/**
* Convert a single `WorkflowStep` into a minimal fragment IR (R6/KTD-1):
*
* start → node → end
*
* No seams. The node mirrors the step via `stepInputToNode`. The result passes
* `parseWorkflowIr` and is a pure-v1 graph (survives `downgradeIrToV1IfPure`).
*/
export function stepToFragmentIr(step: WorkflowStep): WorkflowIr {
const node = stepInputToNode(step, "step-1");
return parseWorkflowIr({
version: "v1",
name: step.name,
nodes: [{ id: "start", kind: "start" }, node, { id: "end", kind: "end" }],
edges: [
{ from: "start", to: node.id, condition: "success" },
{ from: node.id, to: "end", condition: "success" },
],
});
}
/**
* Deterministic x-spaced layout for an IR, matching `linear()`'s geometry. Keyed
* by node id; supply alongside the IR when persisting a `WorkflowDefinitionInput`.
*/
export function layoutForIr(ir: WorkflowIr): Record<string, WorkflowNodeLayout> {
const layout: Record<string, WorkflowNodeLayout> = {};
ir.nodes.forEach((node, i) => {
layout[node.id] = { x: LAYOUT_X0 + i * LAYOUT_DX, y: LAYOUT_Y };
});
return layout;
}

View File

@@ -1239,9 +1239,9 @@ function AppInner() {
pushNav({ type: "modal", close: modalManager.closeScripts });
}, [modalManager, pushNav]);
const openWorkflowStepsWithNav = useCallback(() => {
modalManager.openWorkflowSteps();
pushNav({ type: "modal", close: modalManager.closeWorkflowSteps });
const openWorkflowEditorWithNav = useCallback(() => {
modalManager.openWorkflowEditor();
pushNav({ type: "modal", close: modalManager.closeWorkflowEditor });
}, [modalManager, pushNav]);
const openUsageWithNav = useCallback((anchorRect?: DOMRect | null) => {
@@ -1825,7 +1825,7 @@ function AppInner() {
onOpenGitManager={openGitManagerWithNav}
onOpenNodes={handleOpenNodesWithNav}
showNodesButton={nodesEnabled}
onOpenWorkflowSteps={openWorkflowStepsWithNav}
onOpenWorkflowEditor={openWorkflowEditorWithNav}
onOpenScripts={openScriptsWithNav}
onRunScript={runScriptWithNav}
onToggleTerminal={toggleTerminalWithNav}
@@ -2029,7 +2029,7 @@ function AppInner() {
chatHasUnreadResponse={chatHasUnreadResponse}
stashOrphanCount={stashOrphanCount}
onOpenGitManager={openGitManagerWithNav}
onOpenWorkflowSteps={openWorkflowStepsWithNav}
onOpenWorkflowEditor={openWorkflowEditorWithNav}
onOpenSchedules={openSchedulesWithNav}
onOpenScripts={openScriptsWithNav}
onToggleTerminal={toggleTerminalWithNav}

View File

@@ -49,7 +49,7 @@ const createDefaultMobileNavProps = () => ({
onOpenNodes: vi.fn(),
mailboxUnreadCount: 0,
onOpenGitManager: vi.fn(),
onOpenWorkflowSteps: vi.fn(),
onOpenWorkflowEditor: vi.fn(),
onOpenSchedules: vi.fn(),
onOpenScripts: vi.fn(),
onToggleTerminal: vi.fn(),

View File

@@ -0,0 +1,74 @@
/**
* Moved-key removal sweep (U9 / KTD-5, R10).
*
* After the hard-move (U4), every key in `MOVED_SETTINGS_KEYS` lives exclusively
* as a workflow setting value. None of them may be renderable or savable from the
* Settings modal anymore. A DOM sweep of every section is expensive and flaky, so
* we use the consistency-test pattern instead: assert the modal's source (and its
* extracted Project section components) never bind a moved key to a form
* control — i.e. no `form.<movedKey>` read and no `<movedKey>:` write inside a
* `setForm`/`setPresetDraft`-shaped object literal.
*
* The intentional exceptions are the redirect stubs and the `MODEL_LANES`
* descriptor table, which only NAMES the keys (as `projectProviderKey` /
* `projectModelKey` string literals) so the surviving "default" lane can be
* rendered — those are not form bindings. We therefore match the precise binding
* shapes (`form.<key>` and `<key>:`) and explicitly allow descriptor mentions.
*/
import { describe, it, expect } from "vitest";
import { readdirSync, readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { MOVED_SETTINGS_KEYS } from "@fusion/core";
const here = dirname(fileURLToPath(import.meta.url));
const componentsDir = join(here, "..", "components");
const sectionsDir = join(componentsDir, "settings", "sections");
/**
* Files that compose the modal's editable surface: the shell plus every
* extracted section component. The sections are discovered by walking the
* directory (not a hardcoded list) so a newly added section is swept
* automatically and a moved-key binding cannot slip in unnoticed.
*/
const SURFACE_FILES = [
{ dir: componentsDir, file: "SettingsModal.tsx" },
...readdirSync(sectionsDir)
.filter((name) => name.endsWith(".tsx"))
.map((file) => ({ dir: sectionsDir, file })),
];
/**
* Keys that are also legitimately referenced as nested object properties on
* non-settings shapes (e.g. `ModelPreset.validatorProvider`, a preset draft
* field that is NOT the top-level project setting). For these we only forbid the
* `form.<key>` read shape, which unambiguously binds the project setting.
*/
const PRESET_NESTED_KEYS = new Set([
"validatorProvider",
"validatorModelId",
]);
describe("SettingsModal moved-key removal sweep", () => {
for (const { dir, file } of SURFACE_FILES) {
const source = readFileSync(join(dir, file), "utf8");
for (const key of MOVED_SETTINGS_KEYS) {
it(`${file} does not read form.${key}`, () => {
// The form-binding read shape: `form.<movedKey>` (word boundary).
const formRead = new RegExp(`\\bform\\.${key}\\b`);
expect(source).not.toMatch(formRead);
});
if (!PRESET_NESTED_KEYS.has(key)) {
it(`${file} does not write ${key} into a form patch`, () => {
// The form-write shape inside a setForm object literal: `<key>:`.
// Allowed: descriptor table entries (`projectProviderKey: "<key>"`),
// which quote the key as a value, never as an object KEY.
const formWrite = new RegExp(`(^|[\\s{,])${key}\\s*:`, "m");
expect(source).not.toMatch(formWrite);
});
}
}
}
});

View File

@@ -0,0 +1,249 @@
// @vitest-environment jsdom
/**
* Settings UI primitives (U8 / KTD-10) — behavior + typing contract.
*
* Scope here is behavior and value typing (visual polish is verified in U9's
* browser pass): each primitive renders label/help/error, the scope badge
* renders, change events propagate with correctly-typed values (numbers not
* strings, booleans, the selected option value), and the clearable affordance
* emits the null-as-delete signal that preserves the modal's clear semantics.
*/
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import * as jestDomMatchers from "@testing-library/jest-dom/matchers";
import {
SettingsFieldRow,
SettingsToggleRow,
SettingsNumberRow,
SettingsSelectRow,
SettingsTextRow,
SettingsTextareaRow,
SettingsSection,
} from "../components/settings";
expect.extend(jestDomMatchers);
afterEach(() => cleanup());
describe("SettingsFieldRow", () => {
it("renders label, help, and error", () => {
render(
<SettingsFieldRow label="Theme" help="Pick a theme" error="Required">
<input aria-label="control" />
</SettingsFieldRow>,
);
expect(screen.getByText("Theme")).toBeInTheDocument();
expect(screen.getByText("Pick a theme")).toBeInTheDocument();
expect(screen.getByRole("alert")).toHaveTextContent("Required");
});
it("renders a scope badge when scope is set", () => {
render(
<SettingsFieldRow label="Theme" scope="global">
<input aria-label="control" />
</SettingsFieldRow>,
);
const badge = screen.getByTestId("settings-field-row-scope");
expect(badge).toHaveTextContent("global");
expect(badge).toHaveClass("settings-field-row-scope--global");
});
it("renders no scope badge by default", () => {
render(
<SettingsFieldRow label="Theme">
<input aria-label="control" />
</SettingsFieldRow>,
);
expect(screen.queryByTestId("settings-field-row-scope")).not.toBeInTheDocument();
});
it("renders the clear affordance and fires onClear when clearable", () => {
const onClear = vi.fn();
render(
<SettingsFieldRow label="Theme" clearable onClear={onClear}>
<input aria-label="control" />
</SettingsFieldRow>,
);
fireEvent.click(screen.getByRole("button", { name: "Reset to default" }));
expect(onClear).toHaveBeenCalledTimes(1);
});
it("hides the clear affordance when not clearable", () => {
render(
<SettingsFieldRow label="Theme">
<input aria-label="control" />
</SettingsFieldRow>,
);
expect(screen.queryByRole("button", { name: "Reset to default" })).not.toBeInTheDocument();
});
});
describe("SettingsToggleRow", () => {
const descriptor = { key: "notify", label: "Notifications", help: "Toggle alerts" };
it("renders label and help and reflects value", () => {
render(<SettingsToggleRow descriptor={descriptor} value={true} onChange={() => {}} />);
expect(screen.getByText("Notifications")).toBeInTheDocument();
expect(screen.getByText("Toggle alerts")).toBeInTheDocument();
expect(screen.getByRole("checkbox")).toBeChecked();
});
it("emits a boolean on change", () => {
const onChange = vi.fn();
render(<SettingsToggleRow descriptor={descriptor} value={false} onChange={onChange} />);
fireEvent.click(screen.getByRole("checkbox"));
expect(onChange).toHaveBeenCalledWith(true);
expect(typeof onChange.mock.calls[0][0]).toBe("boolean");
});
it("emits null when cleared", () => {
const onChange = vi.fn();
render(<SettingsToggleRow descriptor={descriptor} value={true} onChange={onChange} clearable />);
fireEvent.click(screen.getByRole("button", { name: "Reset to default" }));
expect(onChange).toHaveBeenCalledWith(null);
});
});
describe("SettingsNumberRow", () => {
const descriptor = { key: "max", label: "Max parallel", min: 1, max: 10, step: 1 };
it("renders label and reflects value", () => {
render(<SettingsNumberRow descriptor={descriptor} value={4} onChange={() => {}} />);
expect(screen.getByText("Max parallel")).toBeInTheDocument();
expect(screen.getByRole("spinbutton")).toHaveValue(4);
});
it("emits a number, not a string", () => {
const onChange = vi.fn();
render(<SettingsNumberRow descriptor={descriptor} value={4} onChange={onChange} />);
fireEvent.change(screen.getByRole("spinbutton"), { target: { value: "7" } });
expect(onChange).toHaveBeenCalledWith(7);
expect(typeof onChange.mock.calls[0][0]).toBe("number");
});
it("emits null when emptied", () => {
const onChange = vi.fn();
render(<SettingsNumberRow descriptor={descriptor} value={4} onChange={onChange} />);
fireEvent.change(screen.getByRole("spinbutton"), { target: { value: "" } });
expect(onChange).toHaveBeenCalledWith(null);
});
it("emits null when cleared", () => {
const onChange = vi.fn();
render(<SettingsNumberRow descriptor={descriptor} value={4} onChange={onChange} clearable />);
fireEvent.click(screen.getByRole("button", { name: "Reset to default" }));
expect(onChange).toHaveBeenCalledWith(null);
});
it("shows an empty field for a null value", () => {
render(<SettingsNumberRow descriptor={descriptor} value={null} onChange={() => {}} />);
expect(screen.getByRole("spinbutton")).toHaveValue(null);
});
});
describe("SettingsSelectRow", () => {
const descriptor = {
key: "theme",
label: "Theme",
options: [
{ value: "light", label: "Light" },
{ value: "dark", label: "Dark" },
],
};
it("renders all options", () => {
render(<SettingsSelectRow descriptor={descriptor} value="light" onChange={() => {}} />);
expect(screen.getByRole("option", { name: "Light" })).toBeInTheDocument();
expect(screen.getByRole("option", { name: "Dark" })).toBeInTheDocument();
expect(screen.getByRole("combobox")).toHaveValue("light");
});
it("emits the selected value", () => {
const onChange = vi.fn();
render(<SettingsSelectRow descriptor={descriptor} value="light" onChange={onChange} />);
fireEvent.change(screen.getByRole("combobox"), { target: { value: "dark" } });
expect(onChange).toHaveBeenCalledWith("dark");
});
it("emits null when cleared", () => {
const onChange = vi.fn();
render(<SettingsSelectRow descriptor={descriptor} value="dark" onChange={onChange} clearable />);
fireEvent.click(screen.getByRole("button", { name: "Reset to default" }));
expect(onChange).toHaveBeenCalledWith(null);
});
});
describe("SettingsTextRow", () => {
const descriptor = { key: "name", label: "Display name", placeholder: "e.g. Ada" };
it("renders label and placeholder and reflects value", () => {
render(<SettingsTextRow descriptor={descriptor} value="Ada" onChange={() => {}} />);
expect(screen.getByText("Display name")).toBeInTheDocument();
const input = screen.getByRole("textbox");
expect(input).toHaveValue("Ada");
expect(input).toHaveAttribute("placeholder", "e.g. Ada");
});
it("emits the string value", () => {
const onChange = vi.fn();
render(<SettingsTextRow descriptor={descriptor} value="" onChange={onChange} />);
fireEvent.change(screen.getByRole("textbox"), { target: { value: "Grace" } });
expect(onChange).toHaveBeenCalledWith("Grace");
expect(typeof onChange.mock.calls[0][0]).toBe("string");
});
it("emits null when cleared", () => {
const onChange = vi.fn();
render(<SettingsTextRow descriptor={descriptor} value="Ada" onChange={onChange} clearable />);
fireEvent.click(screen.getByRole("button", { name: "Reset to default" }));
expect(onChange).toHaveBeenCalledWith(null);
});
});
describe("SettingsTextareaRow", () => {
const descriptor = { key: "notes", label: "Notes", placeholder: "Anything..." };
it("renders label and reflects value", () => {
render(<SettingsTextareaRow descriptor={descriptor} value="hello" onChange={() => {}} />);
expect(screen.getByText("Notes")).toBeInTheDocument();
expect(screen.getByRole("textbox")).toHaveValue("hello");
});
it("emits the string value", () => {
const onChange = vi.fn();
render(<SettingsTextareaRow descriptor={descriptor} value="" onChange={onChange} />);
fireEvent.change(screen.getByRole("textbox"), { target: { value: "line1\nline2" } });
expect(onChange).toHaveBeenCalledWith("line1\nline2");
expect(typeof onChange.mock.calls[0][0]).toBe("string");
});
it("emits null when cleared", () => {
const onChange = vi.fn();
render(<SettingsTextareaRow descriptor={descriptor} value="hi" onChange={onChange} clearable />);
fireEvent.click(screen.getByRole("button", { name: "Reset to default" }));
expect(onChange).toHaveBeenCalledWith(null);
});
});
describe("SettingsSection", () => {
it("renders title, description, and children", () => {
render(
<SettingsSection title="General" description="Top-level options">
<div data-testid="child">content</div>
</SettingsSection>,
);
expect(screen.getByRole("heading", { name: "General" })).toBeInTheDocument();
expect(screen.getByText("Top-level options")).toBeInTheDocument();
expect(screen.getByTestId("child")).toBeInTheDocument();
});
it("renders without a description", () => {
render(
<SettingsSection title="General">
<div>content</div>
</SettingsSection>,
);
expect(screen.getByRole("heading", { name: "General" })).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,184 @@
/**
* Characterization of SettingsModal's save-split (U9 / KTD-10).
*
* Pins the regression-critical behavior the redesign must preserve byte-for-byte:
* - one global + one project edit in a single session produce the expected
* `updateGlobalSettings` / `updateSettings` patches with strict scope routing;
* - clearing a project override emits null-as-delete;
* - untouched inherited project values are NOT written (changed-only gate);
* - explicit clears of global keys emit null, plain undefined is dropped.
*
* The split logic was lifted out of the modal into the pure `splitSettingsSave`
* helper; this test exercises it against the real `@fusion/core` key predicates
* so it stays honest about which keys land in which scope.
*/
import { describe, it, expect } from "vitest";
import { isGlobalSettingsKey, isProjectSettingsKey } from "@fusion/core";
import { splitSettingsSave, MODEL_LANE_KEYS } from "../components/settings/save-split";
// Sanity-anchor the scope of the concrete keys this test relies on, so the
// assertions below remain meaningful if core's catalog ever shifts.
describe("scope anchors", () => {
it("language and ntfyTopic are global; maxConcurrent and integrationBranch are project", () => {
expect(isGlobalSettingsKey("language")).toBe(true);
expect(isGlobalSettingsKey("ntfyTopic")).toBe(true);
expect(isProjectSettingsKey("maxConcurrent")).toBe(true);
expect(isProjectSettingsKey("integrationBranch")).toBe(true);
});
it("every MODEL_LANE_KEYS entry is a project settings key", () => {
// MODEL_LANE_KEYS only gates project-branch behavior, which is reached only
// for keys that pass isProjectSettingsKey. Any entry that fails this check is
// dead (e.g. a per-phase model lane that moved to workflow settings).
expect(MODEL_LANE_KEYS.length).toBeGreaterThan(0);
for (const key of MODEL_LANE_KEYS) {
expect(isProjectSettingsKey(key)).toBe(true);
}
});
});
describe("splitSettingsSave", () => {
it("routes one global + one project edit into the right patches", () => {
const initialValues = { language: "en", maxConcurrent: 2 } as never;
const initialScopedValues = {
global: { language: "en" },
project: { maxConcurrent: 2 },
} as never;
const payload: Record<string, unknown> = {
language: "fr", // global edit
maxConcurrent: 5, // project edit
};
const { globalPatch, projectPatch } = splitSettingsSave({
payload,
initialValues,
initialScopedValues,
activeSection: "global-general",
});
expect(globalPatch).toEqual({ language: "fr" });
expect(projectPatch).toEqual({ maxConcurrent: 5 });
});
it("does not write project values that match the initial project-scoped value (changed-only gate)", () => {
// The gate compares the payload value against the initial *project-scoped*
// value: a value equal to its initial override is not re-written. This is
// what prevents every save from re-persisting unchanged overrides.
const initialScopedValues = {
global: {},
project: { maxConcurrent: 3, integrationBranch: "main" },
} as never;
const payload: Record<string, unknown> = {
maxConcurrent: 3, // unchanged override → skip
integrationBranch: "main", // unchanged override → skip
};
const { projectPatch } = splitSettingsSave({
payload,
initialValues: null,
initialScopedValues,
activeSection: "general",
});
expect(projectPatch).toEqual({});
});
it("writes a project value that differs from the initial project-scoped value", () => {
const initialScopedValues = {
global: {},
project: { maxConcurrent: 3 },
} as never;
const payload: Record<string, unknown> = {
maxConcurrent: 7, // changed from the initial override
};
const { projectPatch } = splitSettingsSave({
payload,
initialValues: null,
initialScopedValues,
activeSection: "general",
});
expect(projectPatch).toEqual({ maxConcurrent: 7 });
});
it("emits null-as-delete when a project override is cleared", () => {
const initialScopedValues = {
global: {},
project: { integrationBranch: "release" },
} as never;
const payload: Record<string, unknown> = {
integrationBranch: undefined, // user cleared the pinned branch
};
const { projectPatch } = splitSettingsSave({
payload,
initialValues: null,
initialScopedValues,
activeSection: "general",
});
expect(projectPatch).toEqual({ integrationBranch: null });
});
it("emits null-as-delete for an explicit clear of a global key", () => {
const initialValues = { ntfyTopic: "alerts" } as never;
const payload: Record<string, unknown> = {
ntfyTopic: undefined, // cleared; initial was defined → null
};
const { globalPatch } = splitSettingsSave({
payload,
initialValues,
initialScopedValues: { global: {}, project: {} } as never,
activeSection: "notifications",
});
expect(globalPatch).toEqual({ ntfyTopic: null });
});
it("drops plain-undefined global keys that were never set", () => {
const payload: Record<string, unknown> = {
ntfyTopic: undefined, // never had a value → passed through as undefined
};
const { globalPatch } = splitSettingsSave({
payload,
initialValues: {} as never,
initialScopedValues: { global: {}, project: {} } as never,
activeSection: "notifications",
});
// undefined survives the object but is dropped by JSON.stringify on the wire;
// the patch must not coerce it to null when there was nothing to clear.
expect(globalPatch.ntfyTopic).toBeUndefined();
});
it("routes githubTrackingDefaultRepo to global only on the global-general section", () => {
const payloadGlobal: Record<string, unknown> = { githubTrackingDefaultRepo: "org/repo" };
const onGlobal = splitSettingsSave({
payload: payloadGlobal,
initialValues: {} as never,
initialScopedValues: { global: {}, project: {} } as never,
activeSection: "global-general",
});
expect(onGlobal.globalPatch).toMatchObject({ githubTrackingDefaultRepo: "org/repo" });
expect("githubTrackingDefaultRepo" in onGlobal.projectPatch).toBe(false);
const onProject = splitSettingsSave({
payload: { githubTrackingDefaultRepo: "org/repo" },
initialValues: {} as never,
initialScopedValues: { global: {}, project: {} } as never,
activeSection: "general",
});
expect("githubTrackingDefaultRepo" in onProject.globalPatch).toBe(false);
// ...and is instead routed to the project patch on the project-scoped
// "general" section, rather than being dropped or erroring.
expect(onProject.projectPatch).toMatchObject({ githubTrackingDefaultRepo: "org/repo" });
});
});

View File

@@ -0,0 +1,185 @@
// @vitest-environment jsdom
/**
* Per-section smoke tests for the extracted SettingsModal sections (U9 / KTD-10).
*
* These pin the section-component contract: each section reads from `form` and
* emits edits via `setForm` (the shell keeps persistence/save-split). We cover
* three representative sections — an Appearance toggle round-trip, a
* Notifications field, and an Experimental flag — following the dashboard
* component-test conventions in settings-primitives.test.tsx.
*/
import { useState } from "react";
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import * as jestDomMatchers from "@testing-library/jest-dom/matchers";
import { AppearanceSection } from "../components/settings/sections/AppearanceSection";
import { NotificationsSection } from "../components/settings/sections/NotificationsSection";
import { ExperimentalSection } from "../components/settings/sections/ExperimentalSection";
import { MovedSettingsStub } from "../components/settings/sections/MovedSettingsStub";
import { PromptsSection } from "../components/settings/sections/PromptsSection";
import { SecretsSection } from "../components/settings/sections/SecretsSection";
import type { SettingsFormState } from "../components/settings/sections/context";
vi.mock("../components/AgentPromptsManager", () => ({
AgentPromptsManager: () => <div data-testid="agent-prompts-manager" />,
}));
vi.mock("../components/SecretsView", () => ({
SecretsView: () => <div data-testid="secrets-view" />,
}));
expect.extend(jestDomMatchers);
afterEach(() => cleanup());
const emptyForm = {} as SettingsFormState;
describe("AppearanceSection", () => {
function AppearanceHost() {
const [hidden, setHidden] = useState(false);
return (
<AppearanceSection
scopeBanner={null}
form={emptyForm}
setForm={vi.fn()}
themeMode="dark"
colorTheme="default"
dashboardFontScalePct={100}
sessionBannersHidden={hidden}
setSessionBannersHidden={setHidden}
/>
);
}
it("round-trips the session-banner toggle through its setter", () => {
render(<AppearanceHost />);
const toggle = screen.getByText("Hide AI session notification banners")
.closest("label")!
.querySelector("input[type=checkbox]") as HTMLInputElement;
expect(toggle.checked).toBe(false);
fireEvent.click(toggle);
expect(toggle.checked).toBe(true);
fireEvent.click(toggle);
expect(toggle.checked).toBe(false);
});
});
describe("NotificationsSection", () => {
it("emits the chosen failure-notification mode via setForm", () => {
const setForm = vi.fn();
render(
<NotificationsSection
scopeBanner={null}
form={emptyForm}
setForm={setForm}
testNotificationLoading={{}}
testNotificationResult={{}}
onTestProviderNotification={vi.fn()}
/>,
);
const select = screen.getByLabelText("Failure notification mode") as HTMLSelectElement;
fireEvent.change(select, { target: { value: "all" } });
expect(setForm).toHaveBeenCalledTimes(1);
const updater = setForm.mock.calls[0][0] as (f: SettingsFormState) => SettingsFormState;
expect(updater(emptyForm)).toMatchObject({ failureNotificationMode: "all" });
});
it("shows the ntfy topic field only when ntfy is enabled", () => {
const { rerender } = render(
<NotificationsSection
scopeBanner={null}
form={{ ntfyEnabled: false } as SettingsFormState}
setForm={vi.fn()}
testNotificationLoading={{}}
testNotificationResult={{}}
onTestProviderNotification={vi.fn()}
/>,
);
expect(screen.queryByLabelText("ntfy Topic")).not.toBeInTheDocument();
rerender(
<NotificationsSection
scopeBanner={null}
form={{ ntfyEnabled: true } as SettingsFormState}
setForm={vi.fn()}
testNotificationLoading={{}}
testNotificationResult={{}}
onTestProviderNotification={vi.fn()}
/>,
);
expect(screen.getByLabelText("ntfy Topic")).toBeInTheDocument();
});
});
describe("SecretsSection", () => {
it("renders the scope banner, title, and the SecretsView card", () => {
render(
<SecretsSection scopeBanner={<div data-testid="scope-banner" />} addToast={vi.fn()} />,
);
expect(screen.getByTestId("scope-banner")).toBeInTheDocument();
expect(screen.getByText("Secrets")).toBeInTheDocument();
expect(screen.getByTestId("secrets-view")).toBeInTheDocument();
});
});
describe("PromptsSection", () => {
it("renders the title and mounts AgentPromptsManager", () => {
render(
<PromptsSection scopeBanner={null} form={emptyForm} setForm={vi.fn()} />,
);
expect(screen.getByText("Prompts")).toBeInTheDocument();
expect(screen.getByTestId("agent-prompts-manager")).toBeInTheDocument();
});
});
describe("MovedSettingsStub", () => {
it("renders the message and fires the open-workflow-settings callback", () => {
const onOpen = vi.fn();
render(<MovedSettingsStub message="Step execution moved" onOpenWorkflowSettings={onOpen} />);
expect(screen.getByText("Step execution moved")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Open workflow settings" }));
expect(onOpen).toHaveBeenCalledTimes(1);
});
it("disables the action when no handler is wired", () => {
render(<MovedSettingsStub message="Moved" />);
expect(screen.getByRole("button", { name: "Open workflow settings" })).toBeDisabled();
});
});
describe("ExperimentalSection", () => {
const knownFeatures = { insights: "Insights", roadmap: "Roadmaps" };
const legacyAliases: Record<string, string> = { devServer: "devServerView" };
const getCanonicalKey = (k: string) => legacyAliases[k] ?? k;
const isFeatureEnabled = (features: Record<string, boolean>, key: string) => features[key] === true;
// Stateful host so the controlled checkbox actually toggles between renders
// (a bare mock setForm never re-renders, so jsdom reports the bound value).
function ExperimentalHost() {
const [form, setFormState] = useState<SettingsFormState>(
{ experimentalFeatures: {} } as SettingsFormState,
);
return (
<ExperimentalSection
scopeBanner={null}
form={form}
setForm={setFormState as never}
knownFeatures={knownFeatures}
legacyAliases={legacyAliases}
getCanonicalKey={getCanonicalKey}
isFeatureEnabled={isFeatureEnabled}
/>
);
}
it("renders a row per known flag and round-trips the canonical key", () => {
render(<ExperimentalHost />);
expect(screen.getByText("Insights")).toBeInTheDocument();
expect(screen.getByText("Roadmaps")).toBeInTheDocument();
const insightsToggle = document.getElementById("experimental-insights") as HTMLInputElement;
expect(insightsToggle.checked).toBe(false);
fireEvent.click(insightsToggle);
expect(insightsToggle.checked).toBe(true);
fireEvent.click(insightsToggle);
expect(insightsToggle.checked).toBe(false);
});
});

View File

@@ -178,9 +178,9 @@ describe("tablet header controls", () => {
expect(screen.queryByTitle("Git Manager")).toBeNull();
});
it("does not render workflow steps button inline on tablet", () => {
renderTabletHeader({ onOpenWorkflowSteps: noop });
expect(screen.queryByTitle("Workflow Steps")).toBeNull();
it("does not render workflows button inline on tablet", () => {
renderTabletHeader({ onOpenWorkflowEditor: noop });
expect(screen.queryByTitle("Workflows")).toBeNull();
});
// ── Overflow menu on tablet ────────────────────────────────────
@@ -254,8 +254,8 @@ describe("tablet header controls", () => {
expect(screen.getByTestId("overflow-git-btn")).toBeDefined();
});
it("overflow menu contains workflow steps on tablet when provided", () => {
renderTabletHeader({ onOpenWorkflowSteps: noop });
it("overflow menu contains workflows on tablet when provided", () => {
renderTabletHeader({ onOpenWorkflowEditor: noop });
fireEvent.click(screen.getByTitle("More header actions"));
expect(screen.getByTestId("overflow-workflow-steps-btn")).toBeDefined();
});
@@ -538,7 +538,7 @@ describe("tablet header controls", () => {
const { container } = renderTabletHeader({
onOpenUsage: noop,
onOpenActivityLog: noop,
onOpenWorkflowSteps: noop,
onOpenWorkflowEditor: noop,
onOpenFiles: noop,
onOpenGitManager: noop,
});

View File

@@ -83,6 +83,11 @@ import type {
WorkflowFieldType,
WorkflowFieldOption,
WorkflowFieldRender,
WorkflowSettingDefinition,
WorkflowSettingType,
WorkflowSettingOption,
WorkflowSettingRender,
WorkflowSettingRejection,
} from "@fusion/core";
import type { PlanningQuestion, PlanningSummary } from "@fusion/core";
import type { GithubIssueAction, ScheduledTask, ScheduledTaskCreateInput, ScheduledTaskUpdateInput, AutomationRunResult, Routine, RoutineCreateInput, RoutineUpdateInput, RoutineExecutionResult } from "@fusion/core";
@@ -371,6 +376,7 @@ export async function createTask(
dependencies,
breakIntoSubtasks,
enabledWorkflowSteps,
workflowId,
assignedAgentId,
modelPresetId,
modelProvider,
@@ -407,6 +413,7 @@ export async function createTask(
dependencies,
breakIntoSubtasks,
enabledWorkflowSteps,
workflowId,
assignedAgentId,
modelPresetId,
modelProvider,
@@ -560,6 +567,10 @@ export interface BoardWorkflowColumn {
// are re-exported from @fusion/core above (KTD-13/14).
export type { WorkflowFieldDefinition, WorkflowFieldType, WorkflowFieldOption, WorkflowFieldRender };
// Workflow-settings (U6/KTD-1) declaration types re-exported from @fusion/core so
// the WorkflowSettingsPanel imports them from `../api` like the field types.
export type { WorkflowSettingDefinition, WorkflowSettingType, WorkflowSettingOption, WorkflowSettingRender, WorkflowSettingRejection };
export interface BoardWorkflowDefinition {
id: string;
name: string;
@@ -5101,6 +5112,46 @@ export function deleteWorkflow(id: string, projectId?: string): Promise<void> {
return api<void>(withProjectId(`/workflows/${encodeURIComponent(id)}`, projectId), { method: "DELETE" });
}
/** The per-`(workflowId, project)` setting-value payload returned by the
* workflow setting-value endpoints (U6/R5): the raw `stored` map, the
* `effective` map (stored ?? declaration default, drop-on-orphan), and the
* `orphaned` stored entries that no longer validate against the declarations. */
export interface WorkflowSettingValuesPayload {
stored: Record<string, unknown>;
effective: Record<string, unknown>;
orphaned: Array<{ id: string; value: unknown }>;
}
/** Read the setting VALUES (stored/effective/orphaned) for a workflow in the
* current project context (U6). The project is bound server-side to the
* scoped store. */
export function fetchWorkflowSettingValues(
id: string,
projectId?: string,
): Promise<WorkflowSettingValuesPayload> {
return api<WorkflowSettingValuesPayload>(
withProjectId(`/workflows/${encodeURIComponent(id)}/setting-values`, projectId),
);
}
/** Write setting VALUES for a workflow in the current project context (U6). The
* `values` map is validated against the named workflow's declarations; a `null`
* value deletes that key. A typed rejection surfaces as an ApiRequestError with
* `status: 400` and `details.rejections: WorkflowSettingRejection[]`. */
export function updateWorkflowSettingValues(
id: string,
values: Record<string, unknown>,
projectId?: string,
): Promise<WorkflowSettingValuesPayload> {
return api<WorkflowSettingValuesPayload>(
withProjectId(`/workflows/${encodeURIComponent(id)}/setting-values`, projectId),
{
method: "PATCH",
body: JSON.stringify({ values }),
},
);
}
/** Preview the compiled steps for a workflow. Rejects (422) for non-linear graphs. */
export function compileWorkflow(id: string, projectId?: string): Promise<{ steps: WorkflowStepInput[] }> {
return api<{ steps: WorkflowStepInput[] }>(withProjectId(`/workflows/${encodeURIComponent(id)}/compile`, projectId), {
@@ -5108,6 +5159,106 @@ export function compileWorkflow(id: string, projectId?: string): Promise<{ steps
});
}
/** A workflow export envelope (U5/R9/KTD-5). `schemaVersion` is the SERVER's
* schema version at export time — the import route version-gates against it
* (the app build aliases @fusion/core to types-only, so the value can only come
* from the server, never an app-side core import). */
export interface WorkflowExportEnvelope {
fusionWorkflowExport: 1;
schemaVersion: number;
kind: import("@fusion/core").WorkflowDefinition["kind"];
name: string;
description: string;
ir: import("@fusion/core").WorkflowIr;
layout: import("@fusion/core").WorkflowDefinition["layout"];
}
/** Fetch a workflow's export envelope and trigger a browser download as
* `<name>.workflow.json` (U5/R9). Built-ins are exportable too. Mirrors the
* SettingsModal export pattern (Blob + createObjectURL + a.download). */
export async function exportWorkflow(id: string, projectId?: string): Promise<WorkflowExportEnvelope> {
const envelope = await api<WorkflowExportEnvelope>(
withProjectId(`/workflows/${encodeURIComponent(id)}/export`, projectId),
);
const safeName = (envelope.name || "workflow").replace(/[^\w.-]+/g, "-").replace(/^-+|-+$/g, "") || "workflow";
const blob = new Blob([JSON.stringify(envelope, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `${safeName}.workflow.json`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
return envelope;
}
/** Result of POST /api/workflows/import (U5/R10). `strippedApprovalFlags` is set
* when `cliSkipApproval`/`autoApprove` were removed from any node config at the
* trust boundary; `warnings` lists non-blocking issues (e.g. unknown scriptName). */
export interface ImportWorkflowResult {
workflow: import("@fusion/core").WorkflowDefinition;
strippedApprovalFlags: boolean;
warnings: string[];
}
/** Import a workflow export envelope (U5/R10). The server is the sole validator;
* validation failures reject with an ApiError carrying the server message. */
export function importWorkflow(
envelope: unknown,
projectId?: string,
): Promise<ImportWorkflowResult> {
return api<ImportWorkflowResult>(withProjectId("/workflows/import", projectId), {
method: "POST",
body: JSON.stringify(envelope),
});
}
/** Result of the lazy legacy-step migration (U2/R5). `migrated` is the number of
* newly converted user steps; `skipped` the count already migrated; when the
* defaultOn subset was non-empty a combined "Migrated steps" workflow id is set. */
export interface MigrateLegacyStepsResult {
migrated: number;
skipped: number;
combinedWorkflowId?: string;
}
/** Run the lazy, idempotent migration of legacy user-authored workflow steps into
* fragments + a combined workflow (U2/R5). Safe to call repeatedly. */
export function migrateLegacyWorkflowSteps(projectId?: string): Promise<MigrateLegacyStepsResult> {
return api<MigrateLegacyStepsResult>(withProjectId("/workflows/migrate-legacy-steps", projectId), {
method: "POST",
});
}
/** Result of POST /api/workflows/design (U10/R11). The server validates the
* AI-produced IR (parseWorkflowIr), triages compilability (`interpreterOnly`),
* and strips trust-escalating flags (`strippedApprovalFlags`). Persists nothing
* — the client decides what to do with the returned graph. */
export interface DesignWorkflowResult {
ir: import("@fusion/core").WorkflowIr;
layout: import("@fusion/core").WorkflowDefinition["layout"];
interpreterOnly: boolean;
strippedApprovalFlags: boolean;
}
/** Design a workflow from a natural-language prompt (U10/R11). When `workflowId`
* is supplied the route reads that workflow's persisted IR server-side and folds
* it into the prompt as the base graph (the client never posts IR). An optional
* AbortSignal cancels the in-flight request. Validation failures reject with an
* ApiError carrying the server message; 429 on rate limit. */
export function designWorkflow(
input: { prompt: string; workflowId?: string },
projectId?: string,
signal?: AbortSignal,
): Promise<DesignWorkflowResult> {
return api<DesignWorkflowResult>(withProjectId("/workflows/design", projectId), {
method: "POST",
body: JSON.stringify(input),
signal,
});
}
/** Read the workflow currently selected for a task. */
export function fetchTaskWorkflow(taskId: string, projectId?: string): Promise<{ workflowId: string | null }> {
return api<{ workflowId: string | null }>(
@@ -6226,6 +6377,7 @@ export interface SettingsImportResponse {
success: boolean;
globalCount: number;
projectCount: number;
workflowSettingsCount: number;
error?: string;
}
@@ -8394,10 +8546,36 @@ export function reorderTodoItems(listId: string, itemIds: string[], projectId?:
// ── AI Sessions (Background Tasks) ─────────────────────────────────────────
/**
* Needs-attention variants for a CLI agent session (CLI Agent Executor, U11).
* Each carries pinned banner copy + action verbs:
* - userExited → Advance / Retry / Cancel task
* - authFailed → Re-authenticate / Retry
* - resume-exhausted → Relaunch fresh / Cancel task
*/
export type CliNeedsAttentionVariant = "userExited" | "authFailed" | "resume-exhausted";
export interface AiSessionSummary {
id: string;
type: "planning" | "subtask" | "mission_interview" | "milestone_interview" | "slice_interview";
status: "draft" | "generating" | "awaiting_input" | "complete" | "error";
type:
| "planning"
| "subtask"
| "mission_interview"
| "milestone_interview"
| "slice_interview"
| "cli-agent";
status:
| "draft"
| "generating"
| "awaiting_input"
| "complete"
| "error"
| "waiting_on_input"
| "needs_attention";
/** For cli-agent sessions: which needs-attention variant (drives pinned copy/actions). */
cliVariant?: CliNeedsAttentionVariant;
/** Underlying CLI session id, for action wiring (confirm-advance / re-auth / etc.). */
cliSessionId?: string;
title: string;
/** Server-derived preview of the in-progress initialPlan; only set for draft planning sessions. */
preview?: string;

View File

@@ -20,7 +20,6 @@ import { NewTaskModal } from "./NewTaskModal";
import { SystemStatsModal } from "./SystemStatsModal";
import { ActivityLogModal } from "./ActivityLogModal";
import { GitManagerModal } from "./GitManagerModal";
import { WorkflowStepManager } from "./WorkflowStepManager";
import { AgentListModal } from "./AgentListModal";
import { ModelOnboardingModal } from "./ModelOnboardingModal";
import { ToastContainer } from "./ToastContainer";
@@ -242,6 +241,10 @@ export function AppModals({
onDashboardFontScaleChange={settings.setDashboardFontScalePct}
onReopenOnboarding={onReopenOnboarding}
onOpenApprovals={onOpenApprovals}
onOpenWorkflowSettings={() => {
handleSettingsClose();
modalManager.openWorkflowEditor("settings");
}}
/>
</Suspense>
</ModalErrorBoundary>
@@ -373,19 +376,6 @@ export function AppModals({
/>
</ModalErrorBoundary>
<ModalErrorBoundary>
<WorkflowStepManager
isOpen={modalManager.workflowStepsOpen}
onClose={modalManager.closeWorkflowSteps}
addToast={addToast}
projectId={projectId}
onOpenGraphEditor={() => {
modalManager.closeWorkflowSteps();
modalManager.openWorkflowEditor();
}}
/>
</ModalErrorBoundary>
{modalManager.workflowEditorOpen && (
<ModalErrorBoundary>
<Suspense fallback={null}>
@@ -394,6 +384,7 @@ export function AppModals({
onClose={modalManager.closeWorkflowEditor}
addToast={addToast}
projectId={projectId}
initialPanel={modalManager.workflowEditorInitialPanel}
/>
</Suspense>
</ModalErrorBoundary>

View File

@@ -1,6 +1,6 @@
import "./BackgroundTasksIndicator.css";
import { useState, useRef, useEffect, useMemo } from "react";
import { Lightbulb, Layers, Target, Loader2, HelpCircle, X, Lock, AlertCircle } from "lucide-react";
import { Lightbulb, Layers, Target, Terminal, Loader2, HelpCircle, X, Lock, AlertCircle } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { AiSessionSummary } from "../api";
import { useAiSessionSync } from "../hooks/useAiSessionSync";
@@ -21,6 +21,7 @@ const TYPE_ICONS = {
mission_interview: Target,
milestone_interview: Target,
slice_interview: Target,
"cli-agent": Terminal,
} as const;
export function BackgroundTasksIndicator({
@@ -49,6 +50,7 @@ export function BackgroundTasksIndicator({
mission_interview: t("backgroundTasks.typeLabel.missionInterview", "Mission Interview"),
milestone_interview: t("backgroundTasks.typeLabel.milestoneInterview", "Milestone Interview"),
slice_interview: t("backgroundTasks.typeLabel.sliceInterview", "Slice Interview"),
"cli-agent": t("backgroundTasks.typeLabel.cliAgent", "CLI Agent"),
}),
[t],
);

View File

@@ -37,6 +37,7 @@ import { AgentMentionPopup } from "./AgentMentionPopup";
import { AgentAvatar } from "./AgentAvatar";
import { FileMentionPopup } from "./FileMentionPopup";
import { CreateRoomModal } from "./CreateRoomModal";
import { CliChatSurface, type CliChatTier } from "./CliChatSurface";
import { useFileMention } from "../hooks/useFileMention";
import { useModelsCache } from "../hooks/useModelsCache";
import { useDiscoveredSkillsCache } from "../hooks/useDiscoveredSkillsCache";
@@ -2623,6 +2624,300 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
containerEl.scrollTo({ top, behavior: prefersReducedMotion ? "auto" : "smooth" });
}, []);
// ── CLI-backed chat mount (U12) ──────────────────────────────────────────
// When the active chat session selects a cli-agent executor, the message-pane
// + composer region is delegated to <CliChatSurface> (transcript + raw-terminal
// toggle for hybrid/native adapters, terminal-only for the generic adapter).
// The transcript renderer and composer renderer are the EXISTING ChatView JSX
// passed through as thunks so there is no parallel message/composer UI.
const cliAdapterId = activeSession?.cliExecutorAdapterId ?? null;
const cliChatActive = Boolean(cliAdapterId);
// Generic adapter has no structured transcript → terminal-only; every other
// bundled adapter exposes a transcript and gets the toggle (the authoritative
// tier is resolved server-side; this only needs the generic vs. non-generic
// split that drives the toggle's presence).
const cliChatTier: CliChatTier = cliAdapterId === "generic" ? "generic" : "hybrid";
// Terminal attach id: the native session linkage when known, else the chat id.
const cliTerminalSessionId = activeSession?.cliSessionFile || activeSession?.id || "";
// The session message pane and composer, captured once so both the normal
// provider path and the CLI-backed path (CliChatSurface thunks) render the
// exact same JSX — no parallel message/composer UI.
const renderSessionMessagesPane = () => (
<div className="chat-messages" ref={messagesContainerRef} onScroll={updateScrollState}>
<div ref={loadMoreSentinelRef} className="chat-load-more-sentinel">
{hasMoreMessages && messagesLoading && (
<div className="chat-loading-older">{t("chat.loadingOlderMessages", "Loading older messages…")}</div>
)}
</div>
{isStreaming ? (
<>
{messages.map((message) => (
<ChatMessageItem
key={message.id}
message={message}
forcePlain={showAllAsPlain}
agentName={agentName}
hideAssistantIdentity={hideAssistantIdentity}
showAssistantModelTag={showAssistantModelTag}
activeModelTag={activeModelTag}
activeModelProvider={activeModelProvider}
activeSessionId={activeSession?.id ?? null}
mentionAgentsByName={mentionAgentsByName}
roomContext={null}
copyAction={showProviderResponseCopy && message.role === "assistant" ? renderCopyAction(message.id, message.content) : undefined}
onScrollToTop={handleScrollMessageToTop}
/>
))}
<div className="chat-message chat-message--assistant chat-message--streaming">
{!hideAssistantIdentity && (
<div className="chat-message-avatar">
{activeModelProvider ? <ProviderIcon provider={activeModelProvider} size="sm" /> : <Bot size={14} />}
<span>{agentName}</span>
{showAssistantModelTag && <span className="chat-model-tag">{activeModelTag}</span>}
</div>
)}
{streamingText ? (
renderAssistantContent(streamingText, showAllAsPlain)
) : (
<div className="chat-message-content chat-message-content--waiting">
{streamingThinking ? t("chat.thinkingStatus", "Thinking…") : t("chat.connectingStatus", "Connecting…")}
</div>
)}
{showProviderResponseCopy && streamingText && renderCopyAction("__streaming__", streamingText, "chat-copy-response-streaming")}
{renderToolCalls(streamingToolCalls, t)}
{streamingThinking && (
<details className="chat-message-thinking">
<summary>{t("chat.thinking", "Thinking")}</summary>
<pre className="chat-message-thinking-content">{linkifyFilePaths(streamingThinking)}</pre>
</details>
)}
<div className="chat-typing-indicator">
<span />
<span />
<span />
</div>
</div>
</>
) : messagesLoading ? (
<div className="chat-empty-state">{t("chat.loadingMessages", "Loading messages...")}</div>
) : messages.length === 0 && !activeSession ? (
renderEmptyState()
) : messages.length === 0 && activeSession ? (
<div className="chat-empty-state">{t("chat.noMessagesYet", "No messages yet. Start the conversation!")}</div>
) : (
<>
{messages.map((message) => (
<ChatMessageItem
key={message.id}
message={message}
forcePlain={showAllAsPlain}
agentName={agentName}
hideAssistantIdentity={hideAssistantIdentity}
showAssistantModelTag={showAssistantModelTag}
activeModelTag={activeModelTag}
activeModelProvider={activeModelProvider}
activeSessionId={activeSession?.id ?? null}
mentionAgentsByName={mentionAgentsByName}
roomContext={null}
copyAction={showProviderResponseCopy && message.role === "assistant" ? renderCopyAction(message.id, message.content) : undefined}
onScrollToTop={handleScrollMessageToTop}
/>
))}
</>
)}
<div ref={messagesEndRef} />
</div>
);
const renderSessionComposerPane = () => (
<div className="chat-input-area">
<input
ref={fileInputRef}
type="file"
accept="image/*,.txt,.json,.yaml,.yml,.log,.csv,.xml,.md"
multiple
style={{ display: "none" }}
onChange={(event) => {
handleAttachmentFiles(event.target.files);
event.target.value = "";
}}
/>
{showSkillMenu && (
<div className="chat-skill-menu" data-testid="chat-skill-menu" role="listbox" aria-label={t("chat.skillSuggestions", "Skill suggestions")}>
{skillsLoading ? (
<div className="chat-skill-menu-empty">{t("chat.loadingSkills", "Loading skills…")}</div>
) : filteredSkills.length === 0 ? (
<div className="chat-skill-menu-empty">
{skillFilter ? t("chat.noSkillsFound", "No skills found") : t("chat.noSkillsAvailable", "No skills available")}
</div>
) : (
filteredSkills.map((skill, index) => (
<button
key={skill.id}
type="button"
role="option"
aria-selected={index === highlightedSkillIndex}
className={`chat-skill-menu-item${index === highlightedSkillIndex ? " chat-skill-menu-item--highlighted" : ""}`}
onMouseDown={(e) => e.preventDefault()}
onMouseEnter={() => setHighlightedSkillIndex(index)}
onClick={() => handleSkillSelect(skill)}
>
<span className="chat-skill-menu-item-name">{skill.name}</span>
<span className="chat-skill-menu-item-description" title={skill.relativePath}>
{skill.relativePath}
</span>
</button>
))
)}
</div>
)}
{pendingAttachments.length > 0 && (
<div className="chat-attachment-previews" data-testid="chat-attachment-previews">
{pendingAttachments.map((attachment, index) => (
<div
key={attachment.previewUrl || `${attachment.file.name}-${index}`}
className="chat-attachment-preview"
data-testid={`chat-attachment-preview-${index}`}
>
{attachment.previewUrl ? (
<img src={attachment.previewUrl} alt={attachment.file.name} />
) : (
<span className="chat-attachment-preview-name">{attachment.file.name}</span>
)}
<button
type="button"
className="chat-attachment-remove"
onClick={() => removeAttachment(index)}
data-testid={`chat-attachment-remove-${index}`}
aria-label={`Remove ${attachment.file.name}`}
>
×
</button>
</div>
))}
</div>
)}
<div className="chat-input-row">
<button
type="button"
className="btn-icon chat-attach-btn"
data-testid="chat-attach-btn"
aria-label={t("chat.attachFiles", "Attach files")}
onClick={() => fileInputRef.current?.click()}
>
<Paperclip size={16} />
</button>
<div
className={`chat-input-wrapper${isDragOver ? " chat-input-wrapper--dragover" : ""}`}
onDragOver={(event) => {
event.preventDefault();
setIsDragOver(true);
}}
onDragLeave={() => setIsDragOver(false)}
onDrop={(event) => {
event.preventDefault();
setIsDragOver(false);
handleAttachmentFiles(event.dataTransfer.files);
}}
>
<textarea
ref={handleComposerRef}
className="chat-input-textarea"
placeholder={t("chat.typeMessage", "Type a message...")}
value={messageInput}
onChange={handleInputChange}
onKeyDown={handleInputKeyDown}
onKeyUp={handleInputKeyUp}
onClick={handleInputSelectionChange}
onBlur={handleInputBlur}
onFocus={handleInputFocus}
onPaste={handlePaste}
onTouchStart={(event) => {
if (typeof window === "undefined") return;
if (window.innerWidth > 768) return;
if (!isIOS()) return;
if (document.activeElement === event.currentTarget) return;
event.preventDefault();
event.currentTarget.focus({ preventScroll: true });
}}
rows={1}
data-testid="chat-input"
/>
<AgentMentionPopup
agents={mentionAgents}
filter={mentionFilter}
highlightedIndex={mentionHighlightIndex}
visible={mentionPopupVisible}
onSelect={handleMentionSelect}
position="below"
roomMemberIds={roomContext?.memberIds}
roomName={roomContext?.roomName}
/>
<FileMentionPopup
visible={fileMention.mentionActive && !mentionPopupVisible}
position={fileMentionPosition}
tasks={fileMention.tasks}
files={fileMention.files}
selectedIndex={fileMention.selectedIndex}
onSelectTask={(task) => {
insertHashMention(fileMention.selectTask(task, messageInput), `#${task.id}`);
}}
onSelectFile={(file) => {
insertHashMention(fileMention.selectFile(file, messageInput), `#${file.path}`);
}}
loading={fileMention.loading}
/>
{pendingMessage && (
<div className="chat-pending-message" data-testid="chat-pending-indicator">
<span>{t("chat.queuedMessage", "Queued: {{preview}}", { preview: pendingPreview })}</span>
<button
type="button"
className="chat-pending-message-dismiss"
aria-label={t("chat.dismissQueuedMessage", "Dismiss queued message")}
data-testid="chat-pending-dismiss"
onClick={clearPendingMessage}
>
×
</button>
</div>
)}
</div>
{isStreaming ? (
<button
className="chat-input-stop"
onClick={stopStreaming}
aria-label={t("chat.stopGeneration", "Stop generation")}
data-testid="chat-stop-btn"
>
<Square size={14} />
</button>
) : (
<button
type="button"
className="chat-input-send"
onPointerDown={(event) => {
if (event.pointerType && event.pointerType !== "mouse") {
event.preventDefault();
}
}}
onMouseDown={(event) => {
event.preventDefault();
}}
onClick={() => {
void handleSend();
}}
disabled={!messageInput.trim() && pendingAttachments.length === 0}
data-testid="chat-send-btn"
style={{ touchAction: "manipulation" }}
>
<Send size={16} />
</button>
)}
</div>
</div>
);
return (
<div className="chat-view">
{/* Sidebar */}
@@ -3216,301 +3511,33 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
</div>
)}
{/* Messages */}
<div className="chat-messages" ref={messagesContainerRef} onScroll={updateScrollState}>
<div ref={loadMoreSentinelRef} className="chat-load-more-sentinel">
{hasMoreMessages && messagesLoading && (
<div className="chat-loading-older">{t("chat.loadingOlderMessages", "Loading older messages…")}</div>
)}
</div>
{isStreaming ? (
<>
{messages.map((message) => (
<ChatMessageItem
key={message.id}
message={message}
forcePlain={showAllAsPlain}
agentName={agentName}
hideAssistantIdentity={hideAssistantIdentity}
showAssistantModelTag={showAssistantModelTag}
activeModelTag={activeModelTag}
activeModelProvider={activeModelProvider}
activeSessionId={activeSession?.id ?? null}
mentionAgentsByName={mentionAgentsByName}
roomContext={null}
copyAction={showProviderResponseCopy && message.role === "assistant" ? renderCopyAction(message.id, message.content) : undefined}
onScrollToTop={handleScrollMessageToTop}
/>
))}
<div className="chat-message chat-message--assistant chat-message--streaming">
{!hideAssistantIdentity && (
<div className="chat-message-avatar">
{activeModelProvider ? <ProviderIcon provider={activeModelProvider} size="sm" /> : <Bot size={14} />}
<span>{agentName}</span>
{showAssistantModelTag && <span className="chat-model-tag">{activeModelTag}</span>}
</div>
)}
{streamingText ? (
renderAssistantContent(streamingText, showAllAsPlain)
) : (
<div className="chat-message-content chat-message-content--waiting">
{streamingThinking ? t("chat.thinkingStatus", "Thinking…") : t("chat.connectingStatus", "Connecting…")}
</div>
)}
{showProviderResponseCopy && streamingText && renderCopyAction("__streaming__", streamingText, "chat-copy-response-streaming")}
{renderToolCalls(streamingToolCalls, t)}
{streamingThinking && (
<details className="chat-message-thinking">
<summary>{t("chat.thinking", "Thinking")}</summary>
<pre className="chat-message-thinking-content">{linkifyFilePaths(streamingThinking)}</pre>
</details>
)}
<div className="chat-typing-indicator">
<span />
<span />
<span />
</div>
</div>
</>
) : messagesLoading ? (
<div className="chat-empty-state">{t("chat.loadingMessages", "Loading messages...")}</div>
) : messages.length === 0 && !activeSession ? (
renderEmptyState()
) : messages.length === 0 && activeSession ? (
<div className="chat-empty-state">{t("chat.noMessagesYet", "No messages yet. Start the conversation!")}</div>
) : (
<>
{messages.map((message) => (
<ChatMessageItem
key={message.id}
message={message}
forcePlain={showAllAsPlain}
agentName={agentName}
hideAssistantIdentity={hideAssistantIdentity}
showAssistantModelTag={showAssistantModelTag}
activeModelTag={activeModelTag}
activeModelProvider={activeModelProvider}
activeSessionId={activeSession?.id ?? null}
mentionAgentsByName={mentionAgentsByName}
roomContext={null}
copyAction={showProviderResponseCopy && message.role === "assistant" ? renderCopyAction(message.id, message.content) : undefined}
onScrollToTop={handleScrollMessageToTop}
/>
))}
</>
)}
<div ref={messagesEndRef} />
</div>
{isUserScrolling && (
<button
type="button"
className="btn btn-sm chat-jump-to-latest"
data-testid="chat-jump-to-latest"
onClick={() => scrollToBottom("fab-click")}
>
<ChevronDown size={14} />
{t("chat.latest", "Latest")}
</button>
)}
{/* Input */}
{activeSession && (
<div className="chat-input-area">
<input
ref={fileInputRef}
type="file"
accept="image/*,.txt,.json,.yaml,.yml,.log,.csv,.xml,.md"
multiple
style={{ display: "none" }}
onChange={(event) => {
handleAttachmentFiles(event.target.files);
event.target.value = "";
}}
/>
{showSkillMenu && (
<div className="chat-skill-menu" data-testid="chat-skill-menu" role="listbox" aria-label={t("chat.skillSuggestions", "Skill suggestions")}>
{skillsLoading ? (
<div className="chat-skill-menu-empty">{t("chat.loadingSkills", "Loading skills…")}</div>
) : filteredSkills.length === 0 ? (
<div className="chat-skill-menu-empty">
{skillFilter ? t("chat.noSkillsFound", "No skills found") : t("chat.noSkillsAvailable", "No skills available")}
</div>
) : (
filteredSkills.map((skill, index) => (
<button
key={skill.id}
type="button"
role="option"
aria-selected={index === highlightedSkillIndex}
className={`chat-skill-menu-item${index === highlightedSkillIndex ? " chat-skill-menu-item--highlighted" : ""}`}
onMouseDown={(e) => e.preventDefault()}
onMouseEnter={() => setHighlightedSkillIndex(index)}
onClick={() => handleSkillSelect(skill)}
>
<span className="chat-skill-menu-item-name">{skill.name}</span>
<span className="chat-skill-menu-item-description" title={skill.relativePath}>
{skill.relativePath}
</span>
</button>
))
)}
</div>
)}
{pendingAttachments.length > 0 && (
<div className="chat-attachment-previews" data-testid="chat-attachment-previews">
{pendingAttachments.map((attachment, index) => (
<div
key={attachment.previewUrl || `${attachment.file.name}-${index}`}
className="chat-attachment-preview"
data-testid={`chat-attachment-preview-${index}`}
>
{attachment.previewUrl ? (
<img src={attachment.previewUrl} alt={attachment.file.name} />
) : (
<span className="chat-attachment-preview-name">{attachment.file.name}</span>
)}
<button
type="button"
className="chat-attachment-remove"
onClick={() => removeAttachment(index)}
data-testid={`chat-attachment-remove-${index}`}
aria-label={`Remove ${attachment.file.name}`}
>
×
</button>
</div>
))}
</div>
)}
<div className="chat-input-row">
{/* Messages + composer. CLI-backed chat sessions delegate this
region to <CliChatSurface> (transcript/raw-terminal toggle +
queued composer); generic-tier adapters render terminal-only. */}
{cliChatActive ? (
<CliChatSurface
cliSessionId={cliTerminalSessionId}
tier={cliChatTier}
projectId={projectId}
renderTranscript={renderSessionMessagesPane}
renderComposer={() => (activeSession ? renderSessionComposerPane() : null)}
/>
) : (
<>
{renderSessionMessagesPane()}
{isUserScrolling && (
<button
type="button"
className="btn-icon chat-attach-btn"
data-testid="chat-attach-btn"
aria-label={t("chat.attachFiles", "Attach files")}
onClick={() => fileInputRef.current?.click()}
className="btn btn-sm chat-jump-to-latest"
data-testid="chat-jump-to-latest"
onClick={() => scrollToBottom("fab-click")}
>
<Paperclip size={16} />
<ChevronDown size={14} />
{t("chat.latest", "Latest")}
</button>
<div
className={`chat-input-wrapper${isDragOver ? " chat-input-wrapper--dragover" : ""}`}
onDragOver={(event) => {
event.preventDefault();
setIsDragOver(true);
}}
onDragLeave={() => setIsDragOver(false)}
onDrop={(event) => {
event.preventDefault();
setIsDragOver(false);
handleAttachmentFiles(event.dataTransfer.files);
}}
>
<textarea
ref={handleComposerRef}
className="chat-input-textarea"
placeholder={t("chat.typeMessage", "Type a message...")}
value={messageInput}
onChange={handleInputChange}
onKeyDown={handleInputKeyDown}
onKeyUp={handleInputKeyUp}
onClick={handleInputSelectionChange}
onBlur={handleInputBlur}
onFocus={handleInputFocus}
onPaste={handlePaste}
onTouchStart={(event) => {
if (typeof window === "undefined") return;
if (window.innerWidth > 768) return;
// iOS-only: see comment on the other chat-input touchstart
// handler above. On Android, preventDefault blocks the
// soft keyboard from opening.
if (!isIOS()) return;
if (document.activeElement === event.currentTarget) return;
event.preventDefault();
event.currentTarget.focus({ preventScroll: true });
}}
rows={1}
data-testid="chat-input"
/>
<AgentMentionPopup
agents={mentionAgents}
filter={mentionFilter}
highlightedIndex={mentionHighlightIndex}
visible={mentionPopupVisible}
onSelect={handleMentionSelect}
position="below"
roomMemberIds={roomContext?.memberIds}
roomName={roomContext?.roomName}
/>
<FileMentionPopup
visible={fileMention.mentionActive && !mentionPopupVisible}
position={fileMentionPosition}
tasks={fileMention.tasks}
files={fileMention.files}
selectedIndex={fileMention.selectedIndex}
onSelectTask={(task) => {
insertHashMention(fileMention.selectTask(task, messageInput), `#${task.id}`);
}}
onSelectFile={(file) => {
insertHashMention(fileMention.selectFile(file, messageInput), `#${file.path}`);
}}
loading={fileMention.loading}
/>
{pendingMessage && (
<div className="chat-pending-message" data-testid="chat-pending-indicator">
<span>{t("chat.queuedMessage", "Queued: {{preview}}", { preview: pendingPreview })}</span>
<button
type="button"
className="chat-pending-message-dismiss"
aria-label={t("chat.dismissQueuedMessage", "Dismiss queued message")}
data-testid="chat-pending-dismiss"
onClick={clearPendingMessage}
>
×
</button>
</div>
)}
</div>
{isStreaming ? (
<button
className="chat-input-stop"
onClick={stopStreaming}
aria-label={t("chat.stopGeneration", "Stop generation")}
data-testid="chat-stop-btn"
>
<Square size={14} />
</button>
) : (
<button
type="button"
className="chat-input-send"
// Keep keyboard up when sending. preventDefault fires on
// pointerdown for touch pointers (BEFORE iOS blurs the
// textarea — the synthesized mousedown is too late on
// iOS), and on mousedown for desktop. Crucially we do NOT
// call preventDefault on touchstart and we do NOT run the
// action here — both of those broke quick taps. Click
// still fires from the iOS touch sequence and runs the
// action reliably.
onPointerDown={(event) => {
if (event.pointerType && event.pointerType !== "mouse") {
event.preventDefault();
}
}}
onMouseDown={(event) => {
event.preventDefault();
}}
onClick={() => {
void handleSend();
}}
disabled={!messageInput.trim() && pendingAttachments.length === 0}
data-testid="chat-send-btn"
style={{ touchAction: "manipulation" }}
>
<Send size={16} />
</button>
)}
</div>
</div>
)}
{activeSession && renderSessionComposerPane()}
</>
)}
</div>
)}

View File

@@ -0,0 +1,125 @@
// CliChatSurface — the CLI-backed chat rendering surface (CLI Agent Executor, U12).
//
// ChatView delegates to this component when the active chat session selects a
// cli-agent executor. It encapsulates the three KTD behaviors that distinguish
// a CLI-backed chat from a provider chat:
//
// 1. Hybrid (native/hybrid tier): render the durable transcript as today PLUS a
// transcript ↔ terminal toggle. Raw-terminal mode swaps the message list for
// <SessionTerminal> and HIDES the composer (the terminal owns input);
// toggling back restores the transcript and composer.
// 2. Generic tier: terminal-ONLY. No toggle, no transcript pane — the affordance
// is absent, not empty (screen-output parsing for a structured transcript is
// out of scope for generic CLIs).
// 3. Composer queued state: while the underlying CLI session is busy, sends are
// queued with a visible indicator. The flush decision is owned server-side
// (CliChatSessionRunner, which re-fetches authoritative state — the
// stale-isGenerating learning); this component only surfaces the queued count.
//
// Rendering of the transcript message list itself stays with ChatView's existing
// renderer (passed in as `renderTranscript`) so there is no parallel message UI.
import React, { useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Terminal as TerminalIcon, MessageSquare } from "lucide-react";
import { SessionTerminal, type SessionTerminalProps } from "./SessionTerminal";
/** Adapter capability tier — drives whether a transcript view exists at all. */
export type CliChatTier = "native" | "hybrid" | "generic";
export interface CliChatSurfaceProps {
/** Live CLI session id to attach the terminal to. */
cliSessionId: string;
/** Adapter tier. Generic → terminal-only (no toggle, no transcript). */
tier: CliChatTier;
projectId?: string;
/** Renders the existing ChatView transcript message list. */
renderTranscript: () => ReactNode;
/** Renders the existing ChatView composer (hidden in raw-terminal mode). */
renderComposer: () => ReactNode;
/** Number of composer messages queued behind a busy session (0 = none). */
queuedCount?: number;
/** Extra props forwarded to SessionTerminal (posture, settings link, etc.). */
terminalProps?: Partial<Omit<SessionTerminalProps, "sessionId" | "projectId">>;
}
type SurfaceView = "transcript" | "terminal";
export function CliChatSurface({
cliSessionId,
tier,
projectId,
renderTranscript,
renderComposer,
queuedCount = 0,
terminalProps,
}: CliChatSurfaceProps) {
const { t } = useTranslation("app");
const isGeneric = tier === "generic";
// Generic tier is terminal-only; hybrid/native default to the transcript view.
const [view, setView] = useState<SurfaceView>(isGeneric ? "terminal" : "transcript");
// Generic tier: render the terminal directly, no toggle, no composer, no
// transcript pane. The terminal owns all input.
if (isGeneric) {
return (
<div className="cli-chat-surface cli-chat-surface--generic" data-tier="generic">
<SessionTerminal sessionId={cliSessionId} projectId={projectId} {...terminalProps} />
</div>
);
}
const showTerminal = view === "terminal";
return (
<div className="cli-chat-surface" data-tier={tier} data-view={view}>
<div className="cli-chat-surface__toolbar" role="tablist" aria-label={t("cliChat.viewToggleLabel", "Chat view")}>
<button
type="button"
role="tab"
aria-selected={!showTerminal}
className={`cli-chat-surface__tab${!showTerminal ? " is-active" : ""}`}
onClick={() => setView("transcript")}
>
<MessageSquare size={14} aria-hidden="true" />
<span>{t("cliChat.transcriptTab", "Transcript")}</span>
</button>
<button
type="button"
role="tab"
aria-selected={showTerminal}
className={`cli-chat-surface__tab${showTerminal ? " is-active" : ""}`}
onClick={() => setView("terminal")}
>
<TerminalIcon size={14} aria-hidden="true" />
<span>{t("cliChat.terminalTab", "Terminal")}</span>
</button>
</div>
<div className="cli-chat-surface__body">
{showTerminal ? (
// Raw-terminal mode: the message list is swapped out and the terminal
// owns input. Composer is hidden below.
<SessionTerminal sessionId={cliSessionId} projectId={projectId} {...terminalProps} />
) : (
renderTranscript()
)}
</div>
{/* Composer is hidden in raw-terminal mode — the terminal owns input. */}
{!showTerminal && (
<div className="cli-chat-surface__composer">
{queuedCount > 0 && (
<div className="cli-chat-surface__queued" role="status" aria-live="polite">
{t("cliChat.queued", "{{count}} message queued — will send when the agent is ready", {
count: queuedCount,
})}
</div>
)}
{renderComposer()}
</div>
)}
</div>
);
}
export default CliChatSurface;

View File

@@ -197,7 +197,7 @@ export interface HeaderProps {
onOpenNodes?: () => void;
/** When false, hides the Nodes management button. Defaults to true for backward compat. */
showNodesButton?: boolean;
onOpenWorkflowSteps?: () => void;
onOpenWorkflowEditor?: () => void;
onOpenScripts?: () => void;
onRunScript?: (name: string, command: string) => void;
onToggleTerminal?: () => void;
@@ -266,7 +266,7 @@ export function Header({
onOpenGitManager,
onOpenNodes,
showNodesButton,
onOpenWorkflowSteps,
onOpenWorkflowEditor,
onOpenScripts,
onRunScript,
onToggleTerminal,
@@ -1593,12 +1593,12 @@ export function Header({
</button>
)}
{/* Workflow Steps - desktop only (moved to overflow on mobile/tablet) */}
{!isCompact && onOpenWorkflowSteps && (
{/* Workflows - desktop only (moved to overflow on mobile/tablet) */}
{!isCompact && onOpenWorkflowEditor && (
<button
className="btn-icon"
onClick={onOpenWorkflowSteps}
title={t("header.workflowSteps", "Workflow Steps")}
onClick={onOpenWorkflowEditor}
title={t("header.workflows", "Workflows")}
data-testid="workflow-steps-btn"
>
<Workflow size={16} />
@@ -1938,16 +1938,16 @@ export function Header({
<span>{t("header.viewUsage", "View Usage")}</span>
</button>
)}
{/* Workflow Steps - in overflow on mobile */}
{onOpenWorkflowSteps && (
{/* Workflows - in overflow on mobile */}
{onOpenWorkflowEditor && (
<button
className="mobile-overflow-item"
onClick={() => handleOverflowAction(onOpenWorkflowSteps)}
onClick={() => handleOverflowAction(onOpenWorkflowEditor)}
role="menuitem"
data-testid="overflow-workflow-steps-btn"
>
<Workflow size={16} />
<span>{t("header.workflowSteps", "Workflow Steps")}</span>
<span>{t("header.workflows", "Workflows")}</span>
</button>
)}
{/* Settings - always last in overflow menu */}

View File

@@ -60,7 +60,7 @@ export interface MobileNavBarProps {
chatHasUnreadResponse?: boolean;
stashOrphanCount?: number;
onOpenGitManager?: () => void;
onOpenWorkflowSteps?: () => void;
onOpenWorkflowEditor?: () => void;
onOpenSchedules?: () => void;
onOpenScripts?: () => void;
onToggleTerminal?: () => void;
@@ -127,7 +127,7 @@ export function MobileNavBar({
chatHasUnreadResponse = false,
stashOrphanCount = 0,
onOpenGitManager,
onOpenWorkflowSteps,
onOpenWorkflowEditor,
onOpenSchedules,
onOpenScripts,
onToggleTerminal,
@@ -590,10 +590,10 @@ export function MobileNavBar({
type="button"
className="mobile-more-item"
data-testid="mobile-more-item-workflow"
onClick={() => handleMoreAction(onOpenWorkflowSteps)}
onClick={() => handleMoreAction(onOpenWorkflowEditor)}
>
<Workflow />
<span>{t("nav.workflowSteps", "Workflow Steps")}</span>
<span>{t("nav.workflows", "Workflows")}</span>
</button>
<button

View File

@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
import { DEFAULT_TASK_PRIORITY, type Task, type TaskCreateInput, type TaskPriority } from "@fusion/core";
import { getErrorMessage } from "@fusion/core";
import type { ToastType } from "../hooks/useToast";
import { uploadAttachment, selectTaskWorkflow } from "../api";
import { uploadAttachment } from "../api";
import { Bot } from "lucide-react";
import { useSetupReadiness } from "../hooks/useSetupReadiness";
import { SetupWarningBanner } from "./SetupWarningBanner";
@@ -16,7 +16,6 @@ import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
import { useNodes } from "../hooks/useNodes";
import { useViewportMode } from "../hooks/useViewportMode";
import { useAgentsMapCache } from "../hooks/useAgentsMapCache";
import { WorkflowSelector } from "./WorkflowSelector";
interface NewTaskModalProps {
isOpen: boolean;
@@ -56,9 +55,10 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
const [selectedPresetId, setSelectedPresetId] = useState<string>("");
const [presetMode, setPresetMode] = useState<"default" | "preset" | "custom">("default");
const [hasDirtyState, setHasDirtyState] = useState(false);
const [selectedWorkflowId, setSelectedWorkflowId] = useState<string | null>(null);
const [selectedWorkflowSteps, setSelectedWorkflowSteps] = useState<string[]>([]);
const [workflowStepsExplicitlySet, setWorkflowStepsExplicitlySet] = useState(false);
// U6/R3: tri-state workflow selection. `undefined` = inherit project default,
// `null` = explicit "No workflow", `string` = a specific workflow. Materialized
// atomically at create time via the `workflowId` create parameter.
const [selectedWorkflowId, setSelectedWorkflowId] = useState<string | null | undefined>(undefined);
const [reviewLevel, setReviewLevel] = useState<number | undefined>(undefined);
const [autoMerge, setAutoMerge] = useState<boolean | undefined>(undefined);
const [priority, setPriority] = useState<TaskPriority>(DEFAULT_TASK_PRIORITY);
@@ -80,18 +80,6 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
const { hasAiProvider, hasGithub, loading: setupReadinessLoading } = useSetupReadiness(projectId);
const { nodes } = useNodes();
// Handler for workflow step changes that detects explicit user interaction
const handleWorkflowStepsChange = useCallback((steps: string[]) => {
setWorkflowStepsExplicitlySet(true);
setSelectedWorkflowSteps(steps);
}, []);
// Callback when defaultOn steps are auto-applied by TaskForm
const handleDefaultOnApplied = useCallback(() => {
// defaultOn auto-selection is not "explicit" user interaction
setWorkflowStepsExplicitlySet(false);
}, []);
// Load agents for agent picker
const loadAgents = useCallback(() => {
setShowAgentPicker(true);
@@ -159,12 +147,11 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
description.trim() !== "" ||
dependencies.length > 0 ||
pendingImages.length > 0 ||
selectedWorkflowId !== null ||
selectedWorkflowId !== undefined ||
executorModel !== "" ||
validatorModel !== "" ||
planningModel !== "" ||
thinkingLevel !== "" ||
selectedWorkflowSteps.length > 0 ||
selectedAgentId !== null ||
reviewLevel !== undefined ||
autoMerge !== undefined ||
@@ -176,7 +163,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
githubTrackingEnabled ||
githubRepoOverrideTrimmed !== "";
setHasDirtyState(isDirty);
}, [description, dependencies, pendingImages, selectedWorkflowId, executorModel, validatorModel, planningModel, thinkingLevel, selectedWorkflowSteps, selectedAgentId, reviewLevel, autoMerge, priority, nodeId, branchMode, branch, baseBranch, githubTrackingEnabled, githubRepoOverrideTrimmed]);
}, [description, dependencies, pendingImages, selectedWorkflowId, executorModel, validatorModel, planningModel, thinkingLevel, selectedAgentId, reviewLevel, autoMerge, priority, nodeId, branchMode, branch, baseBranch, githubTrackingEnabled, githubRepoOverrideTrimmed]);
const handleClose = useCallback(async () => {
if (hasDirtyState) {
@@ -199,9 +186,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
setThinkingLevel("");
setSelectedPresetId("");
setPresetMode("default");
setSelectedWorkflowId(null);
setSelectedWorkflowSteps([]);
setWorkflowStepsExplicitlySet(false);
setSelectedWorkflowId(undefined);
setSelectedAgentId(null);
setShowAgentPicker(false);
setReviewLevel(undefined);
@@ -238,9 +223,11 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
description: trimmedDesc,
column: "triage",
dependencies: dependencies.length ? dependencies : undefined,
// When user explicitly cleared all workflow steps, send empty array to prevent backend re-applying defaults.
// When user hasn't interacted with workflow steps (or left auto-selected defaults), send undefined to let backend apply defaults.
enabledWorkflowSteps: workflowStepsExplicitlySet ? (selectedWorkflowSteps.length > 0 ? selectedWorkflowSteps : []) : undefined,
// U6/R3: forward the workflow selection only when the user changed it.
// - undefined → omit (store inherits the project default, today's behavior)
// - null → explicit "No workflow" (store skips default materialization)
// - string → that workflow, materialized atomically at create time.
...(selectedWorkflowId !== undefined ? { workflowId: selectedWorkflowId } : {}),
...(selectedAgentId ? { assignedAgentId: selectedAgentId } : {}),
modelPresetId: presetMode === "preset" ? selectedPresetId || undefined : undefined,
modelProvider: executorModel && executorSlashIdx !== -1 ? executorModel.slice(0, executorSlashIdx) : undefined,
@@ -269,17 +256,11 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
: {}),
};
// U6/R3: the workflow is now materialized atomically inside createTask via
// the `workflowId` parameter — no post-create selectTaskWorkflow call, so
// the executor can never observe the task with the wrong step set.
const task = await onCreateTask(createInput);
// Apply custom workflow if selected (non-blocking — task already exists)
if (selectedWorkflowId) {
try {
await selectTaskWorkflow(task.id, selectedWorkflowId, projectId);
} catch (err) {
addToast(getErrorMessage(err) || "Failed to apply workflow", "error");
}
}
// Upload pending images as attachments
if (pendingImages.length > 0) {
const failures: string[] = [];
@@ -306,9 +287,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
setThinkingLevel("");
setSelectedPresetId("");
setPresetMode("default");
setSelectedWorkflowId(null);
setSelectedWorkflowSteps([]);
setWorkflowStepsExplicitlySet(false);
setSelectedWorkflowId(undefined);
setSelectedAgentId(null);
setShowAgentPicker(false);
setReviewLevel(undefined);
@@ -326,7 +305,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
} finally {
setIsSubmitting(false);
}
}, [description, dependencies, pendingImages, executorModel, validatorModel, planningModel, thinkingLevel, isSubmitting, githubRepoOverrideInvalid, hasInvalidBranchSelection, onCreateTask, addToast, onClose, projectId, presetMode, selectedPresetId, selectedWorkflowId, selectedWorkflowSteps, workflowStepsExplicitlySet, selectedAgentId, reviewLevel, autoMerge, priority, nodeId, branchMode, isBranchNameRequired, branch, baseBranch, githubTrackingEnabled, githubRepoOverrideTrimmed, t]);
}, [description, dependencies, pendingImages, executorModel, validatorModel, planningModel, thinkingLevel, isSubmitting, githubRepoOverrideInvalid, hasInvalidBranchSelection, onCreateTask, addToast, onClose, projectId, presetMode, selectedPresetId, selectedWorkflowId, selectedAgentId, reviewLevel, autoMerge, priority, nodeId, branchMode, isBranchNameRequired, branch, baseBranch, githubTrackingEnabled, githubRepoOverrideTrimmed, t]);
// Handle keyboard shortcuts
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
@@ -468,17 +447,9 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
)}
</div>
</div>
{/* Custom Workflow */}
<div className="form-group">
<WorkflowSelector
value={selectedWorkflowId}
onChange={(id) => setSelectedWorkflowId(id)}
projectId={projectId}
addToast={addToast}
label="Custom workflow"
disabled={isSubmitting}
/>
</div>
{/* U6/R3: the workflow picker now lives inside TaskForm (a whole-workflow
dropdown materialized atomically at create time), replacing the prior
standalone WorkflowSelector + post-create selectTaskWorkflow flow. */}
</div>
);
@@ -520,9 +491,8 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
onPresetModeChange={setPresetMode}
selectedPresetId={selectedPresetId}
onSelectedPresetIdChange={setSelectedPresetId}
selectedWorkflowSteps={selectedWorkflowSteps}
onWorkflowStepsChange={handleWorkflowStepsChange}
onDefaultOnApplied={handleDefaultOnApplied}
selectedWorkflowId={selectedWorkflowId}
onWorkflowIdChange={setSelectedWorkflowId}
pendingImages={pendingImages}
onImagesChange={setPendingImages}
tasks={tasks}

View File

@@ -1,22 +1,35 @@
import "./SessionNotificationBanner.css";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { AlertCircle, Lightbulb, Layers, Target, X } from "lucide-react";
import type { AiSessionSummary } from "../api";
import { AlertCircle, Lightbulb, Layers, Target, Terminal, X } from "lucide-react";
import type { AiSessionSummary, CliNeedsAttentionVariant } from "../api";
type CliActionId = "advance" | "retry" | "cancel" | "reauthenticate" | "relaunch";
interface SessionNotificationBannerProps {
sessions: AiSessionSummary[];
onResumeSession: (session: AiSessionSummary) => void;
onDismissSession: (id: string) => void;
onDismissAll: () => void;
/**
* CLI agent needs-attention / confirm-advance actions (CLI Agent Executor,
* U11). `advance` wires the userExited "Advance" verb + generic-tier
* confirm-advance; the others map to existing endpoints where present, else
* are no-op callbacks marked TODO-wire by the caller.
*/
onCliAction?: (session: AiSessionSummary, action: CliActionId) => void;
}
// `cli-agent` extends the previously-closed union: a SINGLE Terminal icon for
// all adapters (reusing the banner without this entry crashes on the unknown
// type — the union-regression the U11 tests guard).
const TYPE_ICONS = {
planning: Lightbulb,
subtask: Layers,
mission_interview: Target,
milestone_interview: Target,
slice_interview: Target,
"cli-agent": Terminal,
} as const;
const TYPE_LABEL_KEYS: Record<keyof typeof TYPE_ICONS, { key: string; defaultVal: string }> = {
@@ -25,6 +38,38 @@ const TYPE_LABEL_KEYS: Record<keyof typeof TYPE_ICONS, { key: string; defaultVal
mission_interview: { key: "sessionBanner.typeLabel.missionInterview", defaultVal: "Mission Interview" },
milestone_interview: { key: "sessionBanner.typeLabel.milestoneInterview", defaultVal: "Milestone Interview" },
slice_interview: { key: "sessionBanner.typeLabel.sliceInterview", defaultVal: "Slice Interview" },
"cli-agent": { key: "sessionBanner.typeLabel.cliAgent", defaultVal: "CLI Agent" },
};
/** Action verb defaults (i18n) for each pinned needs-attention variant. */
const CLI_ACTION_LABELS: Record<CliActionId, { key: string; defaultVal: string }> = {
advance: { key: "sessionBanner.cli.advance", defaultVal: "Advance" },
retry: { key: "sessionBanner.cli.retry", defaultVal: "Retry" },
cancel: { key: "sessionBanner.cli.cancelTask", defaultVal: "Cancel task" },
reauthenticate: { key: "sessionBanner.cli.reauthenticate", defaultVal: "Re-authenticate" },
relaunch: { key: "sessionBanner.cli.relaunch", defaultVal: "Relaunch fresh" },
};
/** Pinned copy + ordered actions per needs-attention variant (U11). */
const CLI_VARIANT_SPEC: Record<
CliNeedsAttentionVariant,
{ messageKey: string; messageDefault: string; actions: CliActionId[] }
> = {
userExited: {
messageKey: "sessionBanner.cli.userExited",
messageDefault: "Agent exited before completing",
actions: ["advance", "retry", "cancel"],
},
authFailed: {
messageKey: "sessionBanner.cli.authFailed",
messageDefault: "CLI authentication failed",
actions: ["reauthenticate", "retry"],
},
"resume-exhausted": {
messageKey: "sessionBanner.cli.resumeExhausted",
messageDefault: "Couldn't resume the session",
actions: ["relaunch", "cancel"],
},
};
const STORAGE_KEY = "fusion:session-banner-dismissed";
@@ -66,6 +111,21 @@ function persistDismissed(map: Map<string, number>): void {
}
}
/**
* Statuses that warrant a banner entry. Extended for CLI agent sessions:
* `waiting_on_input` (F2) and `needs_attention` (pinned variants) join the
* existing `awaiting_input` / `error`. A CLI session returning to `busy`
* (no longer in this set) clears the banner entry — covering F2.
*/
function isNotifyingStatus(status: AiSessionSummary["status"]): boolean {
return (
status === "awaiting_input" ||
status === "error" ||
status === "waiting_on_input" ||
status === "needs_attention"
);
}
// Map of sessionId → epoch-ms timestamp at which the user dismissed the
// banner for that session. The banner re-shows the session only when the
// session's `updatedAt` advances strictly past the recorded dismissal time
@@ -78,6 +138,7 @@ export function SessionNotificationBanner({
onResumeSession,
onDismissSession,
onDismissAll,
onCliAction,
}: SessionNotificationBannerProps) {
const { t } = useTranslation("app");
const [dismissRevision, setDismissRevision] = useState(0);
@@ -98,7 +159,7 @@ export function SessionNotificationBanner({
for (const [id, dismissedAtMs] of dismissedIds) {
const session = sessionById.get(id);
if (!session) continue;
const stillNotifying = session.status === "awaiting_input" || session.status === "error";
const stillNotifying = isNotifyingStatus(session.status);
if (!stillNotifying) {
dismissedIds.delete(id);
pruned = true;
@@ -117,7 +178,7 @@ export function SessionNotificationBanner({
const sessionsNeedingInput = useMemo(
() =>
sessions.filter((session) => {
if (session.status !== "awaiting_input" && session.status !== "error") return false;
if (!isNotifyingStatus(session.status)) return false;
const dismissedAtMs = dismissedIds.get(session.id);
if (dismissedAtMs === undefined) return true;
return parseUpdatedAtMs(session.updatedAt) > dismissedAtMs;
@@ -129,8 +190,14 @@ export function SessionNotificationBanner({
return null;
}
const awaitingInputCount = sessionsNeedingInput.filter((s) => s.status === "awaiting_input").length;
const errorCount = sessionsNeedingInput.filter((s) => s.status === "error").length;
// CLI `waiting_on_input` rolls into the "needs input" count; `needs_attention`
// rolls into the "failed" count for the summary header.
const awaitingInputCount = sessionsNeedingInput.filter(
(s) => s.status === "awaiting_input" || s.status === "waiting_on_input",
).length;
const errorCount = sessionsNeedingInput.filter(
(s) => s.status === "error" || s.status === "needs_attention",
).length;
let headerText = "";
if (awaitingInputCount > 0 && errorCount > 0) {
@@ -207,6 +274,64 @@ export function SessionNotificationBanner({
{sessionsNeedingInput.map((session) => {
const Icon = TYPE_ICONS[session.type];
const isError = session.status === "error";
const variantSpec =
session.type === "cli-agent" && session.cliVariant
? CLI_VARIANT_SPEC[session.cliVariant]
: null;
// Pinned needs-attention variant: per-variant copy + ordered actions.
if (variantSpec) {
return (
<article
className="session-notification-banner__item session-notification-banner__item--cli session-notification-banner__item--error"
key={session.id}
data-session-type={session.type}
data-session-status={session.status}
data-cli-variant={session.cliVariant}
>
<div className="session-notification-banner__item-main">
<Icon size={16} className="session-notification-banner__type-icon" aria-hidden="true" />
<div className="session-notification-banner__text">
<p className="session-notification-banner__title" title={session.title}>{session.title}</p>
<p className="session-notification-banner__meta">
{t(variantSpec.messageKey, variantSpec.messageDefault)}
</p>
</div>
</div>
<div className="session-notification-banner__actions">
{variantSpec.actions.map((action) => (
<button
key={action}
className="session-notification-banner__resume"
data-cli-action={action}
onClick={() => {
// "advance" wires confirm-advance; other verbs hit
// existing endpoints or remain TODO-wire no-ops upstream.
onCliAction?.(session, action);
if (action === "cancel" || action === "advance") {
dismissLocally(session);
onDismissSession(session.id);
}
}}
>
{t(CLI_ACTION_LABELS[action].key, CLI_ACTION_LABELS[action].defaultVal)}
</button>
))}
<button
className="session-notification-banner__dismiss"
onClick={() => {
dismissLocally(session);
onDismissSession(session.id);
}}
aria-label={t("sessionBanner.dismissItem", "Dismiss {{title}}", { title: session.title })}
>
<X size={14} aria-hidden="true" />
</button>
</div>
</article>
);
}
return (
<article

View File

@@ -0,0 +1,276 @@
/* SessionTerminal (CLI Agent Executor, U11) — canonical tokens only. */
.cli-session-terminal {
display: flex;
flex-direction: column;
min-height: 0;
height: 100%;
background: var(--terminal-bg, var(--bg));
border: 1px solid var(--border);
border-radius: var(--radius-md);
overflow: hidden;
}
.cli-session-terminal__header {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-xs) var(--space-sm);
background: var(--surface);
border-bottom: 1px solid var(--border);
flex-wrap: wrap;
}
.cli-session-terminal__posture-wrap {
position: relative;
}
.cli-posture-chip {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
padding: 2px var(--space-sm);
font-size: 0.75rem;
line-height: 1.4;
color: var(--text);
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
cursor: pointer;
transition: background var(--transition-fast, 0.12s) ease;
}
.cli-posture-chip:hover {
background: var(--card-hover);
}
.cli-posture-chip--elevated {
color: var(--warning, var(--color-warning));
border-color: var(--warning, var(--color-warning));
}
.cli-posture-chip__mode {
color: var(--text-muted);
}
.cli-posture-chip__flag {
font-weight: 600;
}
.cli-posture-tooltip {
position: absolute;
top: calc(100% + var(--space-xs));
left: 0;
z-index: 10;
min-width: 220px;
padding: var(--space-sm);
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius-md);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
}
.cli-posture-tooltip__title {
margin: 0 0 var(--space-xs);
font-size: 0.75rem;
font-weight: 600;
color: var(--text);
}
.cli-posture-tooltip__list {
margin: 0;
padding-left: var(--space-md);
font-size: 0.75rem;
color: var(--text-muted);
}
.cli-posture-tooltip__settings {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
margin-top: var(--space-sm);
padding: 2px var(--space-sm);
font-size: 0.75rem;
color: var(--accent, var(--color-primary));
background: transparent;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
cursor: pointer;
}
.cli-session-terminal__readonly-badge,
.cli-session-terminal__replay-badge {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
padding: 2px var(--space-sm);
font-size: 0.7rem;
color: var(--text-muted);
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
}
.cli-session-terminal__replay-badge[data-replay-mode="ended"] {
color: var(--text-muted);
}
.cli-session-terminal__viewport {
flex: 1 1 auto;
min-height: 0;
padding: var(--space-xs);
background: var(--terminal-bg, var(--bg));
}
.cli-session-terminal__advance-strip {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
padding: var(--space-sm);
background: var(--surface);
border-top: 1px solid var(--border);
flex-wrap: wrap;
}
.cli-session-terminal__advance-copy {
font-size: 0.8125rem;
color: var(--text);
}
.cli-session-terminal__advance-actions {
display: flex;
gap: var(--space-xs);
}
.cli-session-terminal__advance-btn {
padding: 4px var(--space-md);
font-size: 0.8125rem;
color: var(--button-primary-text, var(--accent-text));
background: var(--button-primary-bg, var(--accent));
border: 1px solid transparent;
border-radius: var(--radius-sm);
cursor: pointer;
}
.cli-session-terminal__advance-btn:disabled {
opacity: 0.6;
cursor: default;
}
.cli-session-terminal__advance-btn--secondary {
color: var(--text);
background: var(--card);
border-color: var(--border);
}
/* ── Mobile input model (U13) ──────────────────────────────────────────────
* Visible input field + accessory key bar. xterm's hidden-textarea input is
* unreliable on mobile (KTD), so the bar is the primary input surface. The
* bar is a fixed footer that lifts above the virtual keyboard when it opens
* (driven by useMobileKeyboard's keyboardOverlap, applied inline).
*/
.cli-session-terminal__mobile-bar {
position: sticky;
bottom: 0;
z-index: 5;
display: flex;
flex-direction: column;
gap: var(--space-xs);
padding: var(--space-xs) var(--space-sm)
calc(var(--space-sm) + env(safe-area-inset-bottom, 0px));
background: var(--surface);
border-top: 1px solid var(--border);
}
.cli-session-terminal__mobile-bar--keyboard-open {
position: fixed;
left: 0;
right: 0;
/* `bottom` is set inline to keyboardOverlap so the bar clears the keyboard. */
padding-bottom: var(--space-sm);
}
.cli-session-terminal__key-row {
display: flex;
gap: var(--space-xs);
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.cli-session-terminal__key-row::-webkit-scrollbar {
display: none;
}
.cli-terminal-key {
flex: 0 0 auto;
min-width: 40px;
min-height: 36px;
padding: var(--space-xs) var(--space-sm);
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
font-size: 0.8125rem;
color: var(--text);
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
cursor: pointer;
touch-action: manipulation;
}
.cli-terminal-key:active {
background: var(--card-hover);
}
.cli-terminal-key--ctrl.cli-terminal-key--active {
color: var(--accent-text, var(--button-primary-text));
background: var(--accent, var(--color-primary));
border-color: var(--accent, var(--color-primary));
}
.cli-terminal-key--ctrlc {
color: var(--warning, var(--color-warning));
border-color: var(--warning, var(--color-warning));
}
.cli-session-terminal__input-row {
display: flex;
gap: var(--space-xs);
}
.cli-session-terminal__mobile-input {
flex: 1 1 auto;
min-width: 0;
min-height: 38px;
padding: var(--space-xs) var(--space-sm);
font-size: 16px; /* >=16px avoids iOS focus zoom */
color: var(--text);
background: var(--input-bg, var(--bg));
border: 1px solid var(--border);
border-radius: var(--radius-sm);
}
.cli-session-terminal__mobile-input:focus {
outline: none;
border-color: var(--accent, var(--color-primary));
}
.cli-session-terminal__mobile-send {
flex: 0 0 auto;
min-height: 38px;
padding: var(--space-xs) var(--space-md);
font-size: 0.8125rem;
color: var(--button-primary-text, var(--accent-text));
background: var(--button-primary-bg, var(--accent));
border: 1px solid transparent;
border-radius: var(--radius-sm);
cursor: pointer;
touch-action: manipulation;
}
/* On mobile the terminal viewport is read-mostly; the bar drives input. */
@media (max-width: 768px), (max-height: 480px) {
.cli-session-terminal__viewport {
-webkit-overflow-scrolling: touch;
}
}

View File

@@ -0,0 +1,730 @@
import "./SessionTerminal.css";
import "@xterm/xterm/css/xterm.css";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Terminal as TerminalIcon, ShieldAlert, Settings, Eye } from "lucide-react";
import type { Terminal as XTerm, ITerminalAddon } from "@xterm/xterm";
import { appendTokenQuery } from "../auth";
import { api } from "../api";
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
/**
* SessionTerminal (CLI Agent Executor, U11) — shared xterm terminal for a CLI
* agent session. Lazy-loads xterm + fit/webgl/unicode11 addons (kept out of the
* main bundle), bridges to the U10 WebSocket attach channel with ACK flow
* control, and renders the posture chip / read-only badge / confirm-advance
* strip / replay states described in the U11 visibility matrix.
*
* The WS bridge:
* 1. POST /api/cli-sessions/:id/attach-ticket → { ticket }
* 2. open WS /api/cli-sessions/ws?sessionId=&ticket= (fn_token carried on URL)
* 3. base64 scrollback/data → term.write; term.onData → input frames
* 4. fit + debounced ResizeObserver → resize frames
* 5. ACK {type:"ack",bytes} via term.write callbacks (~32KB cadence)
*/
/** ACK cadence — ACK roughly every 32KB of consumed output. */
const ACK_THRESHOLD_BYTES = 32 * 1024;
const RESIZE_DEBOUNCE_MS = 100;
/**
* Canonical mobile breakpoint (matches the repo CSS convention). Landscape
* phones exceed 768px wide, so the height clause covers them too.
*/
const MOBILE_MEDIA_QUERY = "(max-width: 768px), (max-height: 480px)";
/**
* Control sequences emitted by the accessory key bar (U13). These are
* deliberate user keystrokes routed straight to the session input path —
* exempt from U2's injected-text neutralization (which governs composed /
* injected strings, not real keystrokes).
*/
const SEQ_ESC = "\x1b"; // 0x1B
const SEQ_TAB = "\x09"; // 0x09
const SEQ_CTRL_C = "\x03"; // 0x03
const SEQ_ARROW_UP = "\x1b[A"; // CSI A
const SEQ_ARROW_DOWN = "\x1b[B"; // CSI B
const SEQ_ARROW_RIGHT = "\x1b[C"; // CSI C
const SEQ_ARROW_LEFT = "\x1b[D"; // CSI D
/**
* Resolve the control byte for a sticky-Ctrl + key combination. Ctrl maps a
* letter to its control code (A→0x01 … Z→0x1A): code = (toUpper(ch) & 0x1f).
* Returns null for keys that have no meaningful Ctrl combination.
*/
function ctrlCombo(key: string): string | null {
if (key.length !== 1) return null;
const upper = key.toUpperCase();
const code = upper.charCodeAt(0);
if (code >= 0x40 && code <= 0x5f) {
// @ A-Z [ \ ] ^ _ → 0x00-0x1F
return String.fromCharCode(code & 0x1f);
}
return null;
}
/** Reactive mobile-viewport detection via the repo breakpoint convention. */
function useIsMobileViewport(): boolean {
const [isMobile, setIsMobile] = useState<boolean>(() => {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
return false;
}
return window.matchMedia(MOBILE_MEDIA_QUERY).matches;
});
useEffect(() => {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
return;
}
const mql = window.matchMedia(MOBILE_MEDIA_QUERY);
const onChange = () => setIsMobile(mql.matches);
onChange();
// Safari < 14 only has addListener/removeListener.
if (typeof mql.addEventListener === "function") {
mql.addEventListener("change", onChange);
return () => mql.removeEventListener("change", onChange);
}
mql.addListener(onChange);
return () => mql.removeListener(onChange);
}, []);
return isMobile;
}
/** The posture surfaced on the session record (denormalized at launch, U15). */
export interface SessionTerminalPosture {
/** Adapter display name (single Terminal icon for all adapters). */
adapterName: string;
/** Resolved autonomy mode label (e.g. "auto-approve", "default"). */
mode?: string;
/**
* Whether the resolved argv+env elevates above the adapter baseline. When
* true the chip renders in warning color with a shield naming the flag.
*/
elevated?: boolean;
/** The elevated flag(s), named on the chip / tooltip when elevated. */
elevatedFlags?: string[];
/** Resolved posture lines shown in the click tooltip. */
resolved?: string[];
}
/** Replay/live mode for the terminal viewport. */
export type SessionTerminalMode = "live" | "idle" | "ended";
export interface SessionTerminalProps {
sessionId: string;
/** When true, term.onData is dropped (one-shot / replay sessions). */
readOnly?: boolean;
posture?: SessionTerminalPosture;
/** Drives the replay header: live | "session idle" | "session ended". */
mode?: SessionTerminalMode;
projectId?: string;
/** Generic-tier idle confirm-advance strip — POST confirm-advance on Advance. */
onConfirmAdvance?: (decision: "advance" | "not-yet") => void | Promise<void>;
/** Whether the confirm-advance strip is offered (generic-tier idle). */
showConfirmAdvance?: boolean;
/** Settings deep link for the posture chip tooltip. */
onOpenAdapterSettings?: () => void;
}
interface AttachTicketResponse {
ticket: string;
expiresAt: string;
readOnly: boolean;
}
/** Build the WS URL for the cli-sessions attach channel (mirrors useTerminal). */
function buildCliWsUrl(sessionId: string, ticket: string): string {
if (typeof window === "undefined") return "";
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const base =
`${protocol}//${window.location.host}/api/cli-sessions/ws` +
`?sessionId=${encodeURIComponent(sessionId)}&ticket=${encodeURIComponent(ticket)}`;
return appendTokenQuery(base);
}
function decodeBase64ToString(b64: string): string {
if (typeof window !== "undefined" && typeof window.atob === "function") {
// atob → binary string → UTF-8 decode.
const binary = window.atob(b64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return new TextDecoder("utf-8").decode(bytes);
}
return Buffer.from(b64, "base64").toString("utf8");
}
export function SessionTerminal({
sessionId,
readOnly = false,
posture,
mode = "live",
projectId,
onConfirmAdvance,
showConfirmAdvance = false,
onOpenAdapterSettings,
}: SessionTerminalProps) {
const { t } = useTranslation("app");
const containerRef = useRef<HTMLDivElement | null>(null);
const xtermRef = useRef<XTerm | null>(null);
const fitAddonRef = useRef<ITerminalAddon | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const [postureTooltipOpen, setPostureTooltipOpen] = useState(false);
const [advanceDismissed, setAdvanceDismissed] = useState(false);
const [advancePending, setAdvancePending] = useState(false);
// ── Mobile input model (U13) ───────────────────────────────────────────────
const isMobile = useIsMobileViewport();
// Only arm keyboard tracking on mobile (the hook no-ops off-mobile anyway).
const { keyboardOpen, keyboardOverlap } = useMobileKeyboard({ enabled: isMobile });
const inputRef = useRef<HTMLInputElement | null>(null);
const [mobileInput, setMobileInput] = useState("");
// Sticky Ctrl: tap Ctrl, then the next tapped key combines into a control
// sequence (Ctrl-C → 0x03, Ctrl-D → 0x04, Ctrl-Z → 0x1A).
const [ctrlSticky, setCtrlSticky] = useState(false);
/** Write raw bytes to the session input path (mobile bar + submit). */
const sendInput = useCallback((data: string) => {
if (!data) return;
const ws = wsRef.current;
if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "input", data }));
}
}, []);
/**
* Emit one accessory-bar key. If sticky Ctrl is active and the key has a
* Ctrl combination, send the combined control byte and clear the modifier;
* otherwise send the literal sequence. Keeps the input focused (the caller's
* pointerdown preventDefault stops the blur).
*/
const emitBarKey = useCallback(
(seq: string) => {
if (ctrlSticky) {
const combined = ctrlCombo(seq);
setCtrlSticky(false);
if (combined) {
sendInput(combined);
return;
}
}
sendInput(seq);
},
[ctrlSticky, sendInput],
);
/** iOS composer pattern: keep focus on the visible input when tapping a key. */
const keepFocus = useCallback((e: { preventDefault: () => void }) => {
e.preventDefault();
}, []);
const handleMobileSubmit = useCallback(
(e?: { preventDefault?: () => void }) => {
e?.preventDefault?.();
// User-typed text + Enter — deliberate input, no neutralization.
if (mobileInput) sendInput(mobileInput);
sendInput("\r");
setMobileInput("");
},
[mobileInput, sendInput],
);
/**
* Input onChange. When sticky Ctrl is armed, the next typed character is
* captured as a Ctrl combination (Ctrl-D `0x04`, Ctrl-Z `0x1A`, …) instead of
* landing in the field — this is how Ctrl-letter chords beyond the bar's
* dedicated Ctrl-C are reached on mobile. Otherwise the value updates
* normally for free-text + Enter submit.
*/
const handleMobileInputChange = useCallback(
(next: string) => {
if (ctrlSticky && next.length > mobileInput.length) {
// The newly-typed character is the last one appended.
const ch = next.slice(mobileInput.length, mobileInput.length + 1);
const combined = ctrlCombo(ch);
setCtrlSticky(false);
if (combined) {
sendInput(combined);
return; // swallow — do not echo the raw key into the field
}
}
setMobileInput(next);
},
[ctrlSticky, mobileInput, sendInput],
);
// Re-arm the strip whenever a fresh idle window is offered.
useEffect(() => {
if (showConfirmAdvance) setAdvanceDismissed(false);
}, [showConfirmAdvance, sessionId]);
// ── xterm lifecycle + WS bridge ──────────────────────────────────────────
useEffect(() => {
if (!sessionId || typeof window === "undefined") return;
let disposed = false;
let resizeObserver: ResizeObserver | null = null;
let resizeTimer: ReturnType<typeof setTimeout> | null = null;
let unackedBytes = 0;
const sendResize = (cols: number, rows: number) => {
const ws = wsRef.current;
if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "resize", cols, rows }));
}
};
const ackBytes = (n: number) => {
unackedBytes += n;
if (unackedBytes < ACK_THRESHOLD_BYTES) return;
const ws = wsRef.current;
if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "ack", bytes: unackedBytes }));
}
unackedBytes = 0;
};
const init = async () => {
// 1. Mint a single-use attach ticket via the app API helper.
let ticketRes: AttachTicketResponse;
try {
ticketRes = await api<AttachTicketResponse>(
`/cli-sessions/${encodeURIComponent(sessionId)}/attach-ticket`,
{ method: "POST", body: JSON.stringify(projectId ? { projectId } : {}) },
);
} catch {
return; // surfaced via the "disconnected" state header below
}
if (disposed) return;
// 2. Lazy-load xterm + addons (out of the main bundle).
const [{ Terminal }, { FitAddon }, { Unicode11Addon }] = await Promise.all([
import("@xterm/xterm"),
import("@xterm/addon-fit"),
import("@xterm/addon-unicode11"),
]);
if (disposed || !containerRef.current) return;
const term = new Terminal({
convertEol: false,
cursorBlink: !readOnly && mode === "live",
disableStdin: readOnly,
scrollback: 10000,
// Defensive: do NOT register an OSC 52 (clipboard-write) handler. The
// server-side neutralizer (U10) strips it; we add no client handling.
fontFamily:
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
fontSize: 13,
});
const fitAddon = new FitAddon();
term.loadAddon(fitAddon);
const unicode11 = new Unicode11Addon();
term.loadAddon(unicode11);
term.unicode.activeVersion = "11";
term.open(containerRef.current);
xtermRef.current = term;
fitAddonRef.current = fitAddon as unknown as ITerminalAddon;
// WebGL renderer with context-loss fallback to the DOM renderer.
try {
const { WebglAddon } = await import("@xterm/addon-webgl");
if (!disposed) {
const webgl = new WebglAddon();
webgl.onContextLoss(() => {
try {
webgl.dispose();
} catch {
/* fall back to DOM renderer */
}
});
term.loadAddon(webgl);
}
} catch {
/* WebGL unavailable — DOM renderer is the default fallback */
}
try {
(fitAddon as unknown as { fit: () => void }).fit();
} catch {
/* container not measurable yet */
}
// term.onData → input frames (skip entirely when read-only).
if (!readOnly) {
term.onData((data: string) => {
const ws = wsRef.current;
if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "input", data }));
}
});
}
// Debounced ResizeObserver → resize frames.
resizeObserver = new ResizeObserver(() => {
if (resizeTimer) clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
try {
(fitAddon as unknown as { fit: () => void }).fit();
sendResize(term.cols, term.rows);
} catch {
/* ignore transient measure failures */
}
}, RESIZE_DEBOUNCE_MS);
});
resizeObserver.observe(containerRef.current);
// 3. Open the WS attach channel.
const ws = new WebSocket(buildCliWsUrl(sessionId, ticketRes.ticket));
wsRef.current = ws;
ws.onopen = () => {
sendResize(term.cols, term.rows);
};
ws.onmessage = (event) => {
let msg: { type?: string; data?: string };
try {
msg = JSON.parse(typeof event.data === "string" ? event.data : "");
} catch {
return;
}
switch (msg.type) {
case "scrollback":
case "data": {
if (typeof msg.data !== "string") return;
const text = decodeBase64ToString(msg.data);
const byteLen = text.length;
// ACK once xterm has flushed the chunk to the screen.
term.write(text, () => ackBytes(byteLen));
break;
}
// state / error / exit frames are advisory; the SSE channel and the
// mode prop drive header copy. We intentionally do not mutate the
// viewport on them.
default:
break;
}
};
};
void init();
return () => {
disposed = true;
if (resizeTimer) clearTimeout(resizeTimer);
if (resizeObserver) resizeObserver.disconnect();
const ws = wsRef.current;
if (ws) {
ws.onopen = null;
ws.onmessage = null;
ws.onclose = null;
ws.onerror = null;
try {
ws.close();
} catch {
/* already closing */
}
wsRef.current = null;
}
const term = xtermRef.current;
if (term) {
try {
term.dispose();
} catch {
/* ignore */
}
xtermRef.current = null;
}
fitAddonRef.current = null;
};
}, [sessionId, readOnly, mode, projectId]);
const replayLabel = useMemo(() => {
if (mode === "idle") return t("cliTerminal.replayIdle", "Session idle");
if (mode === "ended") return t("cliTerminal.replayEnded", "Session ended");
return null;
}, [mode, t]);
const handleAdvance = useCallback(async () => {
if (!onConfirmAdvance) return;
setAdvancePending(true);
try {
await onConfirmAdvance("advance");
setAdvanceDismissed(true);
} finally {
setAdvancePending(false);
}
}, [onConfirmAdvance]);
const handleNotYet = useCallback(async () => {
if (onConfirmAdvance) await onConfirmAdvance("not-yet");
// "Not yet" stays in execute and re-arms the idle timer (server-side); the
// strip hides until the next idle window re-offers it.
setAdvanceDismissed(true);
}, [onConfirmAdvance]);
const elevated = Boolean(posture?.elevated);
const flagSummary = posture?.elevatedFlags?.join(", ");
return (
<div
className={`cli-session-terminal${isMobile ? " cli-session-terminal--mobile" : ""}${
isMobile && keyboardOpen ? " cli-session-terminal--keyboard-open" : ""
}`}
data-mode={mode}
data-read-only={readOnly}
data-mobile={isMobile}
data-keyboard-open={isMobile && keyboardOpen}
>
<header className="cli-session-terminal__header">
{posture && (
<div className="cli-session-terminal__posture-wrap">
<button
type="button"
className={`cli-posture-chip${elevated ? " cli-posture-chip--elevated" : ""}`}
data-elevated={elevated}
aria-expanded={postureTooltipOpen}
onClick={() => setPostureTooltipOpen((v) => !v)}
>
{elevated ? (
<ShieldAlert size={13} aria-hidden="true" />
) : (
<TerminalIcon size={13} aria-hidden="true" />
)}
<span className="cli-posture-chip__name">{posture.adapterName}</span>
{posture.mode && (
<span className="cli-posture-chip__mode">{posture.mode}</span>
)}
{elevated && flagSummary && (
<span className="cli-posture-chip__flag">{flagSummary}</span>
)}
</button>
{postureTooltipOpen && (
<div className="cli-posture-tooltip" role="tooltip">
<p className="cli-posture-tooltip__title">
{t("cliTerminal.postureResolved", "Resolved posture")}
</p>
<ul className="cli-posture-tooltip__list">
{(posture.resolved ?? []).map((line, i) => (
<li key={i}>{line}</li>
))}
{(posture.resolved ?? []).length === 0 && (
<li>{posture.mode ?? t("cliTerminal.postureBaseline", "Baseline")}</li>
)}
</ul>
{onOpenAdapterSettings && (
<button
type="button"
className="cli-posture-tooltip__settings"
onClick={() => {
setPostureTooltipOpen(false);
onOpenAdapterSettings();
}}
>
<Settings size={12} aria-hidden="true" />
{t("cliTerminal.adapterSettings", "Adapter settings")}
</button>
)}
</div>
)}
</div>
)}
{readOnly && (
<span className="cli-session-terminal__readonly-badge">
<Eye size={12} aria-hidden="true" />
{t("cliTerminal.readOnly", "Read-only")}
</span>
)}
{replayLabel && (
<span className="cli-session-terminal__replay-badge" data-replay-mode={mode}>
{replayLabel}
</span>
)}
</header>
<div
className="cli-session-terminal__viewport"
ref={containerRef}
data-testid="cli-terminal-viewport"
/>
{showConfirmAdvance && !advanceDismissed && (
<div className="cli-session-terminal__advance-strip" role="region">
<span className="cli-session-terminal__advance-copy">
{t(
"cliTerminal.advancePrompt",
"This session looks idle — advance to review?",
)}
</span>
<div className="cli-session-terminal__advance-actions">
<button
type="button"
className="cli-session-terminal__advance-btn"
disabled={advancePending}
onClick={handleAdvance}
>
{t("cliTerminal.advance", "Advance")}
</button>
<button
type="button"
className="cli-session-terminal__advance-btn cli-session-terminal__advance-btn--secondary"
disabled={advancePending}
onClick={handleNotYet}
>
{t("cliTerminal.notYet", "Not yet")}
</button>
</div>
</div>
)}
{isMobile && !readOnly && (
<div
className={`cli-session-terminal__mobile-bar${
keyboardOpen ? " cli-session-terminal__mobile-bar--keyboard-open" : ""
}`}
data-testid="cli-terminal-mobile-bar"
style={
// Lift the fixed footer above the virtual keyboard when it's open.
keyboardOpen ? { bottom: `${keyboardOverlap}px` } : undefined
}
>
<div
className="cli-session-terminal__key-row"
data-testid="cli-terminal-key-bar"
>
<button
type="button"
className={`cli-terminal-key cli-terminal-key--ctrl${
ctrlSticky ? " cli-terminal-key--active" : ""
}`}
data-testid="cli-key-ctrl"
aria-label={t("cliTerminal.mobileKeyCtrl", "Sticky Ctrl modifier")}
aria-pressed={ctrlSticky}
onPointerDown={keepFocus}
onMouseDown={keepFocus}
onClick={() => setCtrlSticky((v) => !v)}
>
Ctrl
</button>
<button
type="button"
className="cli-terminal-key"
data-testid="cli-key-esc"
aria-label={t("cliTerminal.mobileKeyEsc", "Send Escape")}
onPointerDown={keepFocus}
onMouseDown={keepFocus}
onClick={() => emitBarKey(SEQ_ESC)}
>
Esc
</button>
<button
type="button"
className="cli-terminal-key"
data-testid="cli-key-tab"
aria-label={t("cliTerminal.mobileKeyTab", "Send Tab")}
onPointerDown={keepFocus}
onMouseDown={keepFocus}
onClick={() => emitBarKey(SEQ_TAB)}
>
Tab
</button>
<button
type="button"
className="cli-terminal-key cli-terminal-key--ctrlc"
data-testid="cli-key-ctrl-c"
aria-label={t("cliTerminal.mobileKeyCtrlC", "Send Ctrl-C")}
onPointerDown={keepFocus}
onMouseDown={keepFocus}
onClick={() => {
// Dedicated shortcut: always Ctrl-C, regardless of sticky state.
setCtrlSticky(false);
sendInput(SEQ_CTRL_C);
}}
>
^C
</button>
<button
type="button"
className="cli-terminal-key"
data-testid="cli-key-arrow-up"
aria-label={t("cliTerminal.mobileKeyArrowUp", "Cursor up")}
onPointerDown={keepFocus}
onMouseDown={keepFocus}
onClick={() => emitBarKey(SEQ_ARROW_UP)}
>
↑
</button>
<button
type="button"
className="cli-terminal-key"
data-testid="cli-key-arrow-down"
aria-label={t("cliTerminal.mobileKeyArrowDown", "Cursor down")}
onPointerDown={keepFocus}
onMouseDown={keepFocus}
onClick={() => emitBarKey(SEQ_ARROW_DOWN)}
>
↓
</button>
<button
type="button"
className="cli-terminal-key"
data-testid="cli-key-arrow-left"
aria-label={t("cliTerminal.mobileKeyArrowLeft", "Cursor left")}
onPointerDown={keepFocus}
onMouseDown={keepFocus}
onClick={() => emitBarKey(SEQ_ARROW_LEFT)}
>
←
</button>
<button
type="button"
className="cli-terminal-key"
data-testid="cli-key-arrow-right"
aria-label={t("cliTerminal.mobileKeyArrowRight", "Cursor right")}
onPointerDown={keepFocus}
onMouseDown={keepFocus}
onClick={() => emitBarKey(SEQ_ARROW_RIGHT)}
>
→
</button>
</div>
<form
className="cli-session-terminal__input-row"
onSubmit={handleMobileSubmit}
>
<input
ref={inputRef}
type="text"
className="cli-session-terminal__mobile-input"
data-testid="cli-terminal-mobile-input"
value={mobileInput}
placeholder={t(
"cliTerminal.mobileInputPlaceholder",
"Type to send to the session…",
)}
autoCapitalize="off"
autoCorrect="off"
autoComplete="off"
spellCheck={false}
onChange={(e) => handleMobileInputChange(e.target.value)}
/>
<button
type="submit"
className="cli-session-terminal__mobile-send"
data-testid="cli-terminal-mobile-send"
aria-label={t("cliTerminal.mobileSend", "Send")}
// iOS pattern: act on click, preventDefault on pointer/mouse down
// so the input doesn't blur (which dismisses the keyboard).
onPointerDown={keepFocus}
onMouseDown={keepFocus}
onClick={() => handleMobileSubmit()}
>
{t("cliTerminal.mobileSend", "Send")}
</button>
</form>
</div>
)}
</div>
);
}

View File

@@ -550,7 +550,7 @@
background: var(--text-muted);
}
.settings-content > * {
animation: settingsFadeIn var(--transition-normal);
animation: settingsFadeIn var(--duration-normal) ease;
}
@keyframes settingsFadeIn {
from {
@@ -2110,6 +2110,13 @@
margin-right: var(--space-xs);
}
/* KTD-8: informational note at the bottom of the Node Sync section. */
.settings-sync-workflow-note {
margin-top: var(--space-md);
font-size: var(--font-size-sm);
color: var(--text-muted);
}
@media (max-width: 768px) {
.auth-custom-provider-item {
flex-direction: column;

File diff suppressed because it is too large Load Diff

View File

@@ -413,6 +413,25 @@ interface TaskCardProps {
prNode?: { id: string; state: "creating" | "open" | "responding" | "merged" | "closed" | "failed"; prNumber?: number };
/** Called when the PR node badge is clicked — opens the dedicated PR view (R12). */
onOpenPullRequest?: (prEntityId: string) => void;
/**
* CLI agent session state for this task's session (CLI Agent Executor, U11).
* Drives the waiting-on-input / needs-attention card badges, which are
* DISTINCT from staleness/stall badges (which U8 suppresses in these states).
* Undefined when the task has no CLI session → no badge (card unchanged).
*/
cliSessionState?: CliCardState;
}
/** Minimal CLI session shape the card needs for its badges (U11). */
export interface CliCardState {
agentState:
| "starting"
| "ready"
| "busy"
| "waitingOnInput"
| "done"
| "dead"
| "needsAttention";
}
function getTaskPrimaryPrInfo(task: Pick<Task, "prInfo" | "prInfos">): PrInfo | undefined {
@@ -550,6 +569,7 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo
previous.prNode?.id === next.prNode?.id &&
previous.prNode?.state === next.prNode?.state &&
previous.prNode?.prNumber === next.prNode?.prNumber &&
previous.cliSessionState?.agentState === next.cliSessionState?.agentState &&
previous.cardFieldDefs === next.cardFieldDefs &&
(previous.cardFieldDefs == null && next.cardFieldDefs == null
? true
@@ -670,6 +690,7 @@ function TaskCardComponent({
cardFieldDefs,
prNode,
onOpenPullRequest,
cliSessionState,
}: TaskCardProps) {
const { t } = useTranslation("app");
const columnLabel = useColumnLabel();
@@ -936,6 +957,9 @@ function TaskCardComponent({
const stalledReview = getStalledReviewSignal(task);
const showStalledReview = Boolean(stalledReview && task.column === "in-review" && !isPaused);
const hasInReviewStall = shouldShowInReviewStallBadge(task);
// CLI agent session badges (U11) — distinct from staleness/stall badges.
const cliWaitingOnInput = cliSessionState?.agentState === "waitingOnInput";
const cliNeedsAttention = cliSessionState?.agentState === "needsAttention";
const stallCopy = task.inReviewStall
? getInReviewStallCopy(task.inReviewStall, {
mergeRetries: task.mergeRetries,
@@ -1823,6 +1847,24 @@ function TaskCardComponent({
{stallCopy.badgeLabel}{stallCopy.counter ? ` ${stallCopy.counter}` : ""}
</span>
)}
{cliWaitingOnInput && (
<span
className="card-status-badge card-status-badge--cli-waiting"
data-cli-state="waitingOnInput"
title={t("tasks.cliWaitingOnInputTitle", "The CLI agent is waiting for your input")}
>
{t("tasks.cliWaitingOnInput", "Waiting on input")}
</span>
)}
{cliNeedsAttention && (
<span
className="card-status-badge card-status-badge--cli-attention failed"
data-cli-state="needsAttention"
title={t("tasks.cliNeedsAttentionTitle", "The CLI agent needs your attention")}
>
{t("tasks.cliNeedsAttention", "Needs attention")}
</span>
)}
{hasStalePausedReview && stalePausedReviewCopy && (
<span
className={`card-status-badge card-status-badge--in-review stale-paused-review stale-paused-review--${stalePausedReviewCopy.code}`}

Some files were not shown because too many files have changed in this diff Show More