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:
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;
|
||||
}
|
||||
Reference in New Issue
Block a user