@danielblomma/cortex-mcp 2.0.5 → 2.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cortex.mjs +24 -0
- package/package.json +1 -1
- package/scaffold/mcp/package-lock.json +63 -4
- package/scaffold/mcp/package.json +4 -1
- package/scaffold/mcp/src/cli/stage.ts +325 -0
- package/scaffold/mcp/src/core/workflow/artifact-io.ts +156 -0
- package/scaffold/mcp/src/core/workflow/capabilities.ts +100 -0
- package/scaffold/mcp/src/core/workflow/default-workflows.ts +83 -0
- package/scaffold/mcp/src/core/workflow/enforcement.ts +206 -0
- package/scaffold/mcp/src/core/workflow/envelope.ts +220 -0
- package/scaffold/mcp/src/core/workflow/index.ts +9 -0
- package/scaffold/mcp/src/core/workflow/mcp-tools.ts +215 -0
- package/scaffold/mcp/src/core/workflow/run-lifecycle.ts +165 -0
- package/scaffold/mcp/src/core/workflow/schemas.ts +125 -0
- package/scaffold/mcp/src/core/workflow/synced-registry.ts +64 -0
- package/scaffold/mcp/src/daemon/main.ts +15 -0
- package/scaffold/mcp/src/daemon/workflow-sync-checker.ts +301 -0
- package/scaffold/mcp/src/hooks/pre-tool-use.ts +30 -0
- package/scaffold/mcp/src/server.ts +75 -0
- package/scaffold/mcp/tests/workflow-cli.test.mjs +293 -0
- package/scaffold/mcp/tests/workflow-enforcement.test.mjs +370 -0
- package/scaffold/mcp/tests/workflow-envelope.test.mjs +247 -0
- package/scaffold/mcp/tests/workflow-mcp-tools.test.mjs +293 -0
- package/scaffold/mcp/tests/workflow-synced-registry.test.mjs +179 -0
- package/scaffold/mcp/tests/workflow.test.mjs +283 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
loadSyncedWorkflows,
|
|
9
|
+
syncedWorkflowsCachePath,
|
|
10
|
+
} from "../dist/core/workflow/synced-registry.js";
|
|
11
|
+
import { runWorkflowStart } from "../dist/core/workflow/mcp-tools.js";
|
|
12
|
+
import { SECURE_BUILD_WORKFLOW } from "../dist/core/workflow/default-workflows.js";
|
|
13
|
+
|
|
14
|
+
function makeWorkspace() {
|
|
15
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), "cortex-synced-registry-"));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const TINY_WORKFLOW = {
|
|
19
|
+
id: "tiny",
|
|
20
|
+
description: "Two-stage tests workflow",
|
|
21
|
+
version: 1,
|
|
22
|
+
stages: [
|
|
23
|
+
{
|
|
24
|
+
name: "plan",
|
|
25
|
+
artifact: "plan.md",
|
|
26
|
+
reads: [],
|
|
27
|
+
required_fields: [],
|
|
28
|
+
capability: "planner",
|
|
29
|
+
description: "Produce a plan.",
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
name: "review",
|
|
33
|
+
artifact: "review.md",
|
|
34
|
+
reads: ["plan"],
|
|
35
|
+
required_fields: ["approved"],
|
|
36
|
+
capability: "reviewer",
|
|
37
|
+
description: "Review the plan.",
|
|
38
|
+
},
|
|
39
|
+
],
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
function writeCache(dir, payload) {
|
|
43
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
44
|
+
fs.writeFileSync(
|
|
45
|
+
syncedWorkflowsCachePath(dir),
|
|
46
|
+
JSON.stringify(payload, null, 2),
|
|
47
|
+
"utf8",
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
test("syncedWorkflowsCachePath: defaults to ~/.cortex/workflows.local.json", () => {
|
|
52
|
+
const expected = path.join(os.homedir(), ".cortex", "workflows.local.json");
|
|
53
|
+
assert.equal(syncedWorkflowsCachePath(), expected);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("loadSyncedWorkflows: returns {} when cache is missing", () => {
|
|
57
|
+
const dir = makeWorkspace();
|
|
58
|
+
assert.deepEqual(loadSyncedWorkflows(dir), {});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("loadSyncedWorkflows: returns {} when cache is unreadable JSON", () => {
|
|
62
|
+
const dir = makeWorkspace();
|
|
63
|
+
fs.writeFileSync(syncedWorkflowsCachePath(dir), "not valid json", "utf8");
|
|
64
|
+
assert.deepEqual(loadSyncedWorkflows(dir), {});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("loadSyncedWorkflows: returns {} when 'workflows' key is missing", () => {
|
|
68
|
+
const dir = makeWorkspace();
|
|
69
|
+
writeCache(dir, { last_synced_at: "x" });
|
|
70
|
+
assert.deepEqual(loadSyncedWorkflows(dir), {});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("loadSyncedWorkflows: drops entries whose definition fails schema", () => {
|
|
74
|
+
const dir = makeWorkspace();
|
|
75
|
+
writeCache(dir, {
|
|
76
|
+
workflows: {
|
|
77
|
+
"valid-one": {
|
|
78
|
+
workflow_id: "valid-one",
|
|
79
|
+
version: 1,
|
|
80
|
+
updated_at: "2026-05-06T12:00:00.000Z",
|
|
81
|
+
definition: TINY_WORKFLOW,
|
|
82
|
+
},
|
|
83
|
+
"broken-one": {
|
|
84
|
+
workflow_id: "broken-one",
|
|
85
|
+
version: 1,
|
|
86
|
+
updated_at: "2026-05-06T12:00:00.000Z",
|
|
87
|
+
definition: { id: "broken-one" /* missing stages */ },
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
const loaded = loadSyncedWorkflows(dir);
|
|
92
|
+
assert.deepEqual(Object.keys(loaded).sort(), ["valid-one"]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("loadSyncedWorkflows: returns valid workflow definitions keyed by workflow_id", () => {
|
|
96
|
+
const dir = makeWorkspace();
|
|
97
|
+
writeCache(dir, {
|
|
98
|
+
workflows: {
|
|
99
|
+
tiny: {
|
|
100
|
+
workflow_id: "tiny",
|
|
101
|
+
version: 1,
|
|
102
|
+
updated_at: "2026-05-06T12:00:00.000Z",
|
|
103
|
+
definition: TINY_WORKFLOW,
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
const loaded = loadSyncedWorkflows(dir);
|
|
108
|
+
assert.equal(loaded.tiny.id, "tiny");
|
|
109
|
+
assert.equal(loaded.tiny.stages.length, 2);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("resolveWorkflow integration: synced workflow takes precedence over bundled default", () => {
|
|
113
|
+
// We can't easily intercept loadSyncedWorkflows() from inside
|
|
114
|
+
// mcp-tools.ts (it reads from a fixed home-dir path). Instead, exercise
|
|
115
|
+
// the explicit-registry path which mirrors what the merge would do
|
|
116
|
+
// and confirm the contract: passing a registry that includes the same
|
|
117
|
+
// id as a bundled default uses the registry version.
|
|
118
|
+
const cwd = makeWorkspace();
|
|
119
|
+
process.env.HOME = cwd; // sandbox the cache lookup
|
|
120
|
+
try {
|
|
121
|
+
const overridden = {
|
|
122
|
+
...SECURE_BUILD_WORKFLOW,
|
|
123
|
+
description: "Org-overridden secure-build",
|
|
124
|
+
};
|
|
125
|
+
const registry = { "secure-build": overridden };
|
|
126
|
+
const result = runWorkflowStart(
|
|
127
|
+
{
|
|
128
|
+
task_id: "task-1",
|
|
129
|
+
task_description: "test",
|
|
130
|
+
workflow_id: "secure-build",
|
|
131
|
+
},
|
|
132
|
+
{ cwd, workflows: registry },
|
|
133
|
+
);
|
|
134
|
+
assert.equal(result.state.workflow_id, "secure-build");
|
|
135
|
+
// The envelope renders the workflow description verbatim — confirms
|
|
136
|
+
// we got the org-overridden version and not the bundled one.
|
|
137
|
+
assert.match(result.envelope.prompt, /Org-overridden secure-build/);
|
|
138
|
+
} finally {
|
|
139
|
+
delete process.env.HOME;
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("resolveWorkflow integration: synced cache adds new workflow_ids beyond defaults", () => {
|
|
144
|
+
const cwd = makeWorkspace();
|
|
145
|
+
// Build a cache under a tmp HOME and point HOME at it, so that the
|
|
146
|
+
// home-dir-based loader picks it up.
|
|
147
|
+
const fakeHome = makeWorkspace();
|
|
148
|
+
process.env.HOME = fakeHome;
|
|
149
|
+
try {
|
|
150
|
+
fs.mkdirSync(path.join(fakeHome, ".cortex"), { recursive: true });
|
|
151
|
+
fs.writeFileSync(
|
|
152
|
+
path.join(fakeHome, ".cortex", "workflows.local.json"),
|
|
153
|
+
JSON.stringify({
|
|
154
|
+
workflows: {
|
|
155
|
+
tiny: {
|
|
156
|
+
workflow_id: "tiny",
|
|
157
|
+
version: 1,
|
|
158
|
+
updated_at: "2026-05-06T12:00:00.000Z",
|
|
159
|
+
definition: TINY_WORKFLOW,
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
}),
|
|
163
|
+
"utf8",
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
const result = runWorkflowStart(
|
|
167
|
+
{
|
|
168
|
+
task_id: "task-2",
|
|
169
|
+
task_description: "Use synced workflow",
|
|
170
|
+
workflow_id: "tiny",
|
|
171
|
+
},
|
|
172
|
+
{ cwd },
|
|
173
|
+
);
|
|
174
|
+
assert.equal(result.state.workflow_id, "tiny");
|
|
175
|
+
assert.equal(result.state.current_stage, "plan");
|
|
176
|
+
} finally {
|
|
177
|
+
delete process.env.HOME;
|
|
178
|
+
}
|
|
179
|
+
});
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
parseStageArtifact,
|
|
9
|
+
renderStageArtifact,
|
|
10
|
+
readRunState,
|
|
11
|
+
readStageArtifact,
|
|
12
|
+
} from "../dist/core/workflow/artifact-io.js";
|
|
13
|
+
import {
|
|
14
|
+
createRun,
|
|
15
|
+
advanceStage,
|
|
16
|
+
getRunState,
|
|
17
|
+
} from "../dist/core/workflow/run-lifecycle.js";
|
|
18
|
+
import {
|
|
19
|
+
workflowDefinitionSchema,
|
|
20
|
+
stageArtifactFrontmatterSchema,
|
|
21
|
+
runStateSchema,
|
|
22
|
+
} from "../dist/core/workflow/schemas.js";
|
|
23
|
+
import { SECURE_BUILD_WORKFLOW } from "../dist/core/workflow/default-workflows.js";
|
|
24
|
+
|
|
25
|
+
function makeWorkspace() {
|
|
26
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), "cortex-workflow-"));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const TINY_WORKFLOW = {
|
|
30
|
+
id: "tiny",
|
|
31
|
+
description: "Two-stage workflow used for tests",
|
|
32
|
+
version: 1,
|
|
33
|
+
stages: [
|
|
34
|
+
{
|
|
35
|
+
name: "plan",
|
|
36
|
+
artifact: "plan.md",
|
|
37
|
+
reads: [],
|
|
38
|
+
required_fields: [],
|
|
39
|
+
description: "Produce a plan",
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
name: "review",
|
|
43
|
+
artifact: "review.md",
|
|
44
|
+
reads: ["plan"],
|
|
45
|
+
required_fields: ["approved"],
|
|
46
|
+
description: "Review the plan",
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
test("schemas: SECURE_BUILD_WORKFLOW validates against workflowDefinitionSchema", () => {
|
|
52
|
+
const parsed = workflowDefinitionSchema.parse(SECURE_BUILD_WORKFLOW);
|
|
53
|
+
assert.equal(parsed.id, "secure-build");
|
|
54
|
+
assert.ok(parsed.stages.length > 0);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("schemas: stage names must be slug-cased", () => {
|
|
58
|
+
assert.throws(() =>
|
|
59
|
+
workflowDefinitionSchema.parse({
|
|
60
|
+
...TINY_WORKFLOW,
|
|
61
|
+
stages: [
|
|
62
|
+
{ ...TINY_WORKFLOW.stages[0], name: "Bad Name" },
|
|
63
|
+
TINY_WORKFLOW.stages[1],
|
|
64
|
+
],
|
|
65
|
+
}),
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("schemas: stage artifact frontmatter requires status + stage", () => {
|
|
70
|
+
assert.throws(() =>
|
|
71
|
+
stageArtifactFrontmatterSchema.parse({
|
|
72
|
+
stage: "plan",
|
|
73
|
+
// status missing
|
|
74
|
+
written_at: new Date().toISOString(),
|
|
75
|
+
}),
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("artifact-io: render + parse round-trips frontmatter", () => {
|
|
80
|
+
const fm = stageArtifactFrontmatterSchema.parse({
|
|
81
|
+
stage: "plan",
|
|
82
|
+
status: "complete",
|
|
83
|
+
references: [],
|
|
84
|
+
written_at: "2026-05-06T19:00:00.000Z",
|
|
85
|
+
});
|
|
86
|
+
const text = renderStageArtifact(fm, "# Plan\n\nDo the thing.");
|
|
87
|
+
const parsed = parseStageArtifact(text);
|
|
88
|
+
assert.equal(parsed.frontmatter.stage, "plan");
|
|
89
|
+
assert.equal(parsed.frontmatter.status, "complete");
|
|
90
|
+
assert.equal(parsed.body, "# Plan\n\nDo the thing.");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("artifact-io: parseStageArtifact rejects missing frontmatter", () => {
|
|
94
|
+
assert.throws(() => parseStageArtifact("# No frontmatter here\n"));
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("artifact-io: parseStageArtifact rejects unterminated frontmatter", () => {
|
|
98
|
+
assert.throws(() =>
|
|
99
|
+
parseStageArtifact("---\nstage: plan\nstatus: complete\n# no close marker\n"),
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("artifact-io: parseStageArtifact preserves passthrough fields", () => {
|
|
104
|
+
const text = `---
|
|
105
|
+
stage: review
|
|
106
|
+
status: complete
|
|
107
|
+
references:
|
|
108
|
+
- plan.md
|
|
109
|
+
written_at: "2026-05-06T19:00:00.000Z"
|
|
110
|
+
approved: true
|
|
111
|
+
blocking_comments: 0
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
# Review
|
|
115
|
+
|
|
116
|
+
Looks good.
|
|
117
|
+
`;
|
|
118
|
+
const parsed = parseStageArtifact(text);
|
|
119
|
+
assert.equal(parsed.frontmatter.stage, "review");
|
|
120
|
+
assert.deepEqual(parsed.frontmatter.references, ["plan.md"]);
|
|
121
|
+
assert.equal(parsed.frontmatter.approved, true);
|
|
122
|
+
assert.equal(parsed.frontmatter.blocking_comments, 0);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("createRun: writes state.json with all stages pending and current_stage = first", () => {
|
|
126
|
+
const cwd = makeWorkspace();
|
|
127
|
+
const state = createRun({
|
|
128
|
+
cwd,
|
|
129
|
+
taskId: "2026-05-06-fixture",
|
|
130
|
+
workflow: TINY_WORKFLOW,
|
|
131
|
+
taskDescription: "Test run",
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
assert.equal(state.task_id, "2026-05-06-fixture");
|
|
135
|
+
assert.equal(state.current_stage, "plan");
|
|
136
|
+
assert.equal(state.outcome, "in_progress");
|
|
137
|
+
assert.deepEqual(
|
|
138
|
+
state.stages.map((s) => s.status),
|
|
139
|
+
["pending", "pending"],
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
const persisted = readRunState(cwd, "2026-05-06-fixture");
|
|
143
|
+
assert.deepEqual(persisted, state);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("advanceStage: writes artifact, updates state, advances current_stage", () => {
|
|
147
|
+
const cwd = makeWorkspace();
|
|
148
|
+
const taskId = "2026-05-06-advance";
|
|
149
|
+
createRun({ cwd, taskId, workflow: TINY_WORKFLOW, taskDescription: "Test" });
|
|
150
|
+
|
|
151
|
+
const after = advanceStage({
|
|
152
|
+
cwd,
|
|
153
|
+
taskId,
|
|
154
|
+
workflow: TINY_WORKFLOW,
|
|
155
|
+
stageName: "plan",
|
|
156
|
+
artifactName: "plan.md",
|
|
157
|
+
frontmatter: { stage: "plan", status: "complete", references: [] },
|
|
158
|
+
body: "# Plan\n\n- step 1\n- step 2",
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
assert.equal(after.current_stage, "review");
|
|
162
|
+
assert.equal(after.outcome, "in_progress");
|
|
163
|
+
assert.equal(after.stages[0].status, "complete");
|
|
164
|
+
assert.equal(after.stages[0].artifact, "plan.md");
|
|
165
|
+
assert.equal(after.stages[1].status, "pending");
|
|
166
|
+
|
|
167
|
+
// Artifact lives on disk under .agents/<taskId>/
|
|
168
|
+
const artifactPath = path.join(cwd, ".agents", taskId, "plan.md");
|
|
169
|
+
assert.ok(fs.existsSync(artifactPath));
|
|
170
|
+
const parsed = readStageArtifact(cwd, taskId, "plan.md");
|
|
171
|
+
assert.equal(parsed.frontmatter.stage, "plan");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("advanceStage: marks run complete after final stage", () => {
|
|
175
|
+
const cwd = makeWorkspace();
|
|
176
|
+
const taskId = "2026-05-06-final";
|
|
177
|
+
createRun({ cwd, taskId, workflow: TINY_WORKFLOW, taskDescription: "Test" });
|
|
178
|
+
|
|
179
|
+
advanceStage({
|
|
180
|
+
cwd,
|
|
181
|
+
taskId,
|
|
182
|
+
workflow: TINY_WORKFLOW,
|
|
183
|
+
stageName: "plan",
|
|
184
|
+
artifactName: "plan.md",
|
|
185
|
+
frontmatter: { stage: "plan", status: "complete", references: [] },
|
|
186
|
+
body: "# Plan",
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
const after = advanceStage({
|
|
190
|
+
cwd,
|
|
191
|
+
taskId,
|
|
192
|
+
workflow: TINY_WORKFLOW,
|
|
193
|
+
stageName: "review",
|
|
194
|
+
artifactName: "review.md",
|
|
195
|
+
frontmatter: {
|
|
196
|
+
stage: "review",
|
|
197
|
+
status: "complete",
|
|
198
|
+
references: ["plan.md"],
|
|
199
|
+
approved: true,
|
|
200
|
+
},
|
|
201
|
+
body: "# Review\n\napproved",
|
|
202
|
+
outcome: { approved: true },
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
assert.equal(after.current_stage, null);
|
|
206
|
+
assert.equal(after.outcome, "complete");
|
|
207
|
+
assert.ok(after.completed_at);
|
|
208
|
+
assert.deepEqual(after.stages[1].outcome, { approved: true });
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("advanceStage: blocked status surfaces as run outcome", () => {
|
|
212
|
+
const cwd = makeWorkspace();
|
|
213
|
+
const taskId = "2026-05-06-blocked";
|
|
214
|
+
createRun({ cwd, taskId, workflow: TINY_WORKFLOW, taskDescription: "Test" });
|
|
215
|
+
|
|
216
|
+
const after = advanceStage({
|
|
217
|
+
cwd,
|
|
218
|
+
taskId,
|
|
219
|
+
workflow: TINY_WORKFLOW,
|
|
220
|
+
stageName: "plan",
|
|
221
|
+
artifactName: "plan.md",
|
|
222
|
+
frontmatter: { stage: "plan", status: "blocked", references: [] },
|
|
223
|
+
body: "# Plan blocked",
|
|
224
|
+
status: "blocked",
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
assert.equal(after.outcome, "blocked");
|
|
228
|
+
assert.equal(after.current_stage, null);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("advanceStage: refuses to advance the wrong stage", () => {
|
|
232
|
+
const cwd = makeWorkspace();
|
|
233
|
+
const taskId = "2026-05-06-wrong";
|
|
234
|
+
createRun({ cwd, taskId, workflow: TINY_WORKFLOW, taskDescription: "Test" });
|
|
235
|
+
|
|
236
|
+
assert.throws(() =>
|
|
237
|
+
advanceStage({
|
|
238
|
+
cwd,
|
|
239
|
+
taskId,
|
|
240
|
+
workflow: TINY_WORKFLOW,
|
|
241
|
+
stageName: "review",
|
|
242
|
+
artifactName: "review.md",
|
|
243
|
+
frontmatter: { stage: "review", status: "complete", references: [] },
|
|
244
|
+
body: "# Out of order",
|
|
245
|
+
}),
|
|
246
|
+
);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("getRunState: returns null for missing tasks", () => {
|
|
250
|
+
const cwd = makeWorkspace();
|
|
251
|
+
assert.equal(getRunState(cwd, "no-such-task"), null);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test("readRunState: validates persisted state against schema", () => {
|
|
255
|
+
const cwd = makeWorkspace();
|
|
256
|
+
const taskId = "2026-05-06-corrupt";
|
|
257
|
+
createRun({ cwd, taskId, workflow: TINY_WORKFLOW, taskDescription: "Test" });
|
|
258
|
+
|
|
259
|
+
// Corrupt the file: drop required field.
|
|
260
|
+
const statePath = path.join(cwd, ".agents", taskId, "state.json");
|
|
261
|
+
const raw = JSON.parse(fs.readFileSync(statePath, "utf8"));
|
|
262
|
+
delete raw.workflow_id;
|
|
263
|
+
fs.writeFileSync(statePath, JSON.stringify(raw, null, 2));
|
|
264
|
+
|
|
265
|
+
assert.throws(() => readRunState(cwd, taskId));
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test("runStateSchema: rejects unknown outcome", () => {
|
|
269
|
+
assert.throws(() =>
|
|
270
|
+
runStateSchema.parse({
|
|
271
|
+
schema_version: 1,
|
|
272
|
+
task_id: "x",
|
|
273
|
+
workflow_id: "tiny",
|
|
274
|
+
workflow_version: 1,
|
|
275
|
+
task_description: "y",
|
|
276
|
+
current_stage: null,
|
|
277
|
+
outcome: "totally-bogus",
|
|
278
|
+
started_at: new Date().toISOString(),
|
|
279
|
+
completed_at: null,
|
|
280
|
+
stages: [{ name: "plan", status: "complete" }],
|
|
281
|
+
}),
|
|
282
|
+
);
|
|
283
|
+
});
|