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