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