openclaw-node-harness 2.0.4 → 2.1.1

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.
Files changed (134) hide show
  1. package/README.md +646 -3
  2. package/bin/hyperagent.mjs +419 -0
  3. package/bin/lane-watchdog.js +23 -2
  4. package/bin/mesh-agent.js +439 -28
  5. package/bin/mesh-bridge.js +69 -3
  6. package/bin/mesh-health-publisher.js +41 -1
  7. package/bin/mesh-task-daemon.js +821 -26
  8. package/bin/mesh.js +411 -20
  9. package/config/claude-settings.json +95 -0
  10. package/config/daemon.json.template +2 -1
  11. package/config/git-hooks/pre-commit +13 -0
  12. package/config/git-hooks/pre-push +12 -0
  13. package/config/harness-rules.json +174 -0
  14. package/config/plan-templates/team-bugfix.yaml +52 -0
  15. package/config/plan-templates/team-deploy.yaml +50 -0
  16. package/config/plan-templates/team-feature.yaml +71 -0
  17. package/config/roles/qa-engineer.yaml +36 -0
  18. package/config/roles/solidity-dev.yaml +51 -0
  19. package/config/roles/tech-architect.yaml +36 -0
  20. package/config/rules/framework/solidity.md +22 -0
  21. package/config/rules/framework/typescript.md +21 -0
  22. package/config/rules/framework/unity.md +21 -0
  23. package/config/rules/universal/design-docs.md +18 -0
  24. package/config/rules/universal/git-hygiene.md +18 -0
  25. package/config/rules/universal/security.md +19 -0
  26. package/config/rules/universal/test-standards.md +19 -0
  27. package/identity/DELEGATION.md +6 -6
  28. package/install.sh +296 -10
  29. package/lib/agent-activity.js +2 -2
  30. package/lib/circling-parser.js +119 -0
  31. package/lib/exec-safety.js +105 -0
  32. package/lib/hyperagent-store.mjs +652 -0
  33. package/lib/kanban-io.js +24 -31
  34. package/lib/llm-providers.js +16 -0
  35. package/lib/mcp-knowledge/bench.mjs +118 -0
  36. package/lib/mcp-knowledge/core.mjs +530 -0
  37. package/lib/mcp-knowledge/package.json +25 -0
  38. package/lib/mcp-knowledge/server.mjs +252 -0
  39. package/lib/mcp-knowledge/test.mjs +802 -0
  40. package/lib/memory-budget.mjs +261 -0
  41. package/lib/mesh-collab.js +483 -165
  42. package/lib/mesh-harness.js +427 -0
  43. package/lib/mesh-plans.js +79 -50
  44. package/lib/mesh-tasks.js +132 -49
  45. package/lib/nats-resolve.js +4 -4
  46. package/lib/plan-templates.js +226 -0
  47. package/lib/pre-compression-flush.mjs +322 -0
  48. package/lib/role-loader.js +292 -0
  49. package/lib/rule-loader.js +358 -0
  50. package/lib/session-store.mjs +461 -0
  51. package/lib/transcript-parser.mjs +292 -0
  52. package/mission-control/drizzle/soul_schema_update.sql +29 -0
  53. package/mission-control/drizzle.config.ts +1 -4
  54. package/mission-control/package-lock.json +1571 -83
  55. package/mission-control/package.json +6 -2
  56. package/mission-control/scripts/gen-chronology.js +3 -3
  57. package/mission-control/scripts/import-pipeline-v2.js +0 -16
  58. package/mission-control/scripts/import-pipeline.js +0 -15
  59. package/mission-control/src/app/api/cowork/clusters/[id]/members/route.ts +117 -0
  60. package/mission-control/src/app/api/cowork/clusters/[id]/route.ts +84 -0
  61. package/mission-control/src/app/api/cowork/clusters/route.ts +141 -0
  62. package/mission-control/src/app/api/cowork/dispatch/route.ts +128 -0
  63. package/mission-control/src/app/api/cowork/events/route.ts +65 -0
  64. package/mission-control/src/app/api/cowork/intervene/route.ts +259 -0
  65. package/mission-control/src/app/api/cowork/sessions/[id]/route.ts +37 -0
  66. package/mission-control/src/app/api/cowork/sessions/route.ts +64 -0
  67. package/mission-control/src/app/api/diagnostics/route.ts +97 -0
  68. package/mission-control/src/app/api/diagnostics/test-runner/route.ts +990 -0
  69. package/mission-control/src/app/api/memory/search/route.ts +6 -3
  70. package/mission-control/src/app/api/mesh/events/route.ts +95 -19
  71. package/mission-control/src/app/api/mesh/identity/route.ts +11 -0
  72. package/mission-control/src/app/api/mesh/tasks/[id]/route.ts +92 -0
  73. package/mission-control/src/app/api/mesh/tasks/route.ts +91 -0
  74. package/mission-control/src/app/api/souls/[id]/evolution/route.ts +21 -5
  75. package/mission-control/src/app/api/souls/[id]/prompt/route.ts +7 -1
  76. package/mission-control/src/app/api/souls/[id]/propagate/route.ts +14 -2
  77. package/mission-control/src/app/api/tasks/[id]/handoff/route.ts +8 -2
  78. package/mission-control/src/app/api/tasks/[id]/route.ts +90 -4
  79. package/mission-control/src/app/api/tasks/route.ts +21 -30
  80. package/mission-control/src/app/api/workspace/read/route.ts +11 -0
  81. package/mission-control/src/app/cowork/page.tsx +261 -0
  82. package/mission-control/src/app/diagnostics/page.tsx +385 -0
  83. package/mission-control/src/app/graph/page.tsx +26 -0
  84. package/mission-control/src/app/memory/page.tsx +1 -1
  85. package/mission-control/src/app/obsidian/page.tsx +36 -6
  86. package/mission-control/src/app/roadmap/page.tsx +24 -0
  87. package/mission-control/src/app/souls/page.tsx +2 -2
  88. package/mission-control/src/components/board/execution-config.tsx +431 -0
  89. package/mission-control/src/components/board/kanban-board.tsx +75 -9
  90. package/mission-control/src/components/board/kanban-column.tsx +135 -19
  91. package/mission-control/src/components/board/task-card.tsx +55 -2
  92. package/mission-control/src/components/board/unified-task-dialog.tsx +82 -4
  93. package/mission-control/src/components/cowork/cluster-card.tsx +176 -0
  94. package/mission-control/src/components/cowork/create-cluster-dialog.tsx +251 -0
  95. package/mission-control/src/components/cowork/dispatch-form.tsx +423 -0
  96. package/mission-control/src/components/cowork/role-picker.tsx +102 -0
  97. package/mission-control/src/components/cowork/session-card.tsx +284 -0
  98. package/mission-control/src/components/layout/sidebar.tsx +39 -2
  99. package/mission-control/src/lib/__tests__/daily-log.test.ts +82 -0
  100. package/mission-control/src/lib/__tests__/memory-md.test.ts +87 -0
  101. package/mission-control/src/lib/__tests__/mesh-kv-sync.test.ts +465 -0
  102. package/mission-control/src/lib/__tests__/mocks/mock-kv.ts +131 -0
  103. package/mission-control/src/lib/__tests__/status-kanban.test.ts +46 -0
  104. package/mission-control/src/lib/__tests__/task-markdown.test.ts +188 -0
  105. package/mission-control/src/lib/__tests__/wikilinks.test.ts +175 -0
  106. package/mission-control/src/lib/config.ts +67 -0
  107. package/mission-control/src/lib/db/index.ts +85 -1
  108. package/mission-control/src/lib/db/schema.ts +61 -3
  109. package/mission-control/src/lib/hooks.ts +309 -0
  110. package/mission-control/src/lib/memory/entities.ts +3 -2
  111. package/mission-control/src/lib/memory/extract.ts +2 -1
  112. package/mission-control/src/lib/memory/retrieval.ts +3 -2
  113. package/mission-control/src/lib/nats.ts +66 -1
  114. package/mission-control/src/lib/parsers/task-markdown.ts +52 -2
  115. package/mission-control/src/lib/parsers/transcript.ts +4 -4
  116. package/mission-control/src/lib/scheduler.ts +12 -11
  117. package/mission-control/src/lib/sync/mesh-kv.ts +279 -0
  118. package/mission-control/src/lib/sync/tasks.ts +23 -1
  119. package/mission-control/src/lib/task-id.ts +32 -0
  120. package/mission-control/src/lib/tts/index.ts +33 -9
  121. package/mission-control/src/middleware.ts +82 -0
  122. package/mission-control/tsconfig.json +2 -1
  123. package/mission-control/vitest.config.ts +14 -0
  124. package/package.json +15 -2
  125. package/services/launchd/ai.openclaw.log-rotate.plist +11 -0
  126. package/services/launchd/ai.openclaw.mesh-deploy-listener.plist +4 -0
  127. package/services/launchd/ai.openclaw.mesh-health-publisher.plist +4 -0
  128. package/services/launchd/ai.openclaw.mission-control.plist +1 -1
  129. package/services/service-manifest.json +1 -1
  130. package/skills/cc-godmode/references/agents.md +8 -8
  131. package/uninstall.sh +37 -9
  132. package/workspace-bin/memory-daemon.mjs +199 -5
  133. package/workspace-bin/session-search.mjs +204 -0
  134. package/workspace-bin/web-fetch.mjs +65 -0
@@ -0,0 +1,465 @@
1
+ /**
2
+ * mesh-kv-sync.test.ts — Unit tests for distributed MC (Phase 1+2).
3
+ *
4
+ * Tests: CAS operations, authority model, proposal lifecycle,
5
+ * task merge/deduplication, watcher lifecycle, sync skip logic.
6
+ *
7
+ * No external dependencies — uses MockKV for all NATS KV operations.
8
+ */
9
+
10
+ import { describe, it, expect, beforeEach } from "vitest";
11
+ import { MockKV, encode, decode } from "./mocks/mock-kv";
12
+
13
+ let kv: MockKV;
14
+
15
+ beforeEach(() => {
16
+ kv = new MockKV();
17
+ });
18
+
19
+ // ── CAS (Compare-And-Swap) Operations ──
20
+
21
+ describe("CAS operations", () => {
22
+ it("put creates entry with incrementing revision", async () => {
23
+ const rev1 = await kv.put("task-1", encode({ title: "Task 1" }));
24
+ const rev2 = await kv.put("task-2", encode({ title: "Task 2" }));
25
+ expect(rev1).toBe(1);
26
+ expect(rev2).toBe(2);
27
+ });
28
+
29
+ it("get returns stored entry with revision", async () => {
30
+ await kv.put("task-1", encode({ title: "First" }));
31
+ const entry = await kv.get("task-1");
32
+ expect(entry).not.toBeNull();
33
+ expect(decode(entry!.value)).toEqual({ title: "First" });
34
+ expect(entry!.revision).toBe(1);
35
+ });
36
+
37
+ it("get returns null for missing key", async () => {
38
+ const entry = await kv.get("nonexistent");
39
+ expect(entry).toBeNull();
40
+ });
41
+
42
+ it("create fails if key already exists", async () => {
43
+ await kv.put("task-1", encode({ title: "V1" }));
44
+ await expect(
45
+ kv.create("task-1", encode({ title: "V2" }))
46
+ ).rejects.toThrow("key already exists");
47
+ });
48
+
49
+ it("create succeeds for new key", async () => {
50
+ const rev = await kv.create("task-new", encode({ title: "New" }));
51
+ expect(rev).toBeGreaterThan(0);
52
+ const entry = await kv.get("task-new");
53
+ expect(decode(entry!.value)).toEqual({ title: "New" });
54
+ });
55
+
56
+ it("update succeeds with correct revision", async () => {
57
+ await kv.put("task-1", encode({ title: "V1" }));
58
+ const entry = await kv.get("task-1");
59
+ await kv.update("task-1", encode({ title: "V2" }), entry!.revision);
60
+ const updated = await kv.get("task-1");
61
+ expect(decode(updated!.value)).toEqual({ title: "V2" });
62
+ });
63
+
64
+ it("update fails with stale revision (CAS conflict)", async () => {
65
+ await kv.put("task-1", encode({ title: "V1" }));
66
+ const entry = await kv.get("task-1");
67
+ const staleRev = entry!.revision;
68
+
69
+ // Another write bumps the revision
70
+ await kv.put("task-1", encode({ title: "V2" }));
71
+
72
+ try {
73
+ await kv.update("task-1", encode({ title: "V3" }), staleRev);
74
+ expect.unreachable("Update should have thrown");
75
+ } catch (err: any) {
76
+ expect(err.message).toContain("wrong last sequence");
77
+ expect(err.message).toContain("revision mismatch");
78
+ }
79
+ });
80
+
81
+ it("update fails for nonexistent key", async () => {
82
+ await expect(
83
+ kv.update("ghost", encode({ title: "X" }), 1)
84
+ ).rejects.toThrow("revision mismatch");
85
+ });
86
+
87
+ it("delete removes entry", async () => {
88
+ await kv.put("task-1", encode({ title: "V1" }));
89
+ await kv.delete("task-1");
90
+ const entry = await kv.get("task-1");
91
+ expect(entry).toBeNull();
92
+ });
93
+ });
94
+
95
+ // ── Authority Model ──
96
+
97
+ describe("Authority model", () => {
98
+ it("lead can update any task", async () => {
99
+ const nodeRole = "lead";
100
+ const nodeId = "mac-lead";
101
+
102
+ await kv.put(
103
+ "T-001",
104
+ encode({ task_id: "T-001", origin: "ubuntu-worker", status: "queued" })
105
+ );
106
+ const entry = await kv.get("T-001");
107
+ const task = decode(entry!.value);
108
+
109
+ // Lead can update regardless of origin
110
+ const canUpdate = nodeRole === "lead" || task.origin === nodeId;
111
+ expect(canUpdate).toBe(true);
112
+ });
113
+
114
+ it("worker can only update tasks it originated", async () => {
115
+ const nodeRole = "worker";
116
+ const nodeId = "ubuntu-worker";
117
+
118
+ await kv.put(
119
+ "T-001",
120
+ encode({ task_id: "T-001", origin: "ubuntu-worker", status: "proposed" })
121
+ );
122
+ const entry = await kv.get("T-001");
123
+ const task = decode(entry!.value);
124
+
125
+ const canUpdate = nodeRole === "lead" || task.origin === nodeId;
126
+ expect(canUpdate).toBe(true);
127
+ });
128
+
129
+ it("worker cannot update tasks from other nodes", async () => {
130
+ const nodeRole = "worker";
131
+ const nodeId = "ubuntu-worker-2";
132
+
133
+ await kv.put(
134
+ "T-001",
135
+ encode({ task_id: "T-001", origin: "ubuntu-worker-1", status: "queued" })
136
+ );
137
+ const entry = await kv.get("T-001");
138
+ const task = decode(entry!.value);
139
+
140
+ const canUpdate = nodeRole === "lead" || task.origin === nodeId;
141
+ expect(canUpdate).toBe(false);
142
+ });
143
+
144
+ it("worker proposals get status 'proposed'", () => {
145
+ const nodeRole = "worker";
146
+ const status = nodeRole === "lead" ? "queued" : "proposed";
147
+ expect(status).toBe("proposed");
148
+ });
149
+
150
+ it("lead proposals get status 'queued' directly", () => {
151
+ const nodeRole = "lead";
152
+ const status = nodeRole === "lead" ? "queued" : "proposed";
153
+ expect(status).toBe("queued");
154
+ });
155
+ });
156
+
157
+ // ── Proposal Lifecycle ──
158
+
159
+ describe("Proposal lifecycle", () => {
160
+ it("worker proposes → daemon accepts → queued", async () => {
161
+ // Worker creates with proposed
162
+ await kv.put(
163
+ "T-PROP-001",
164
+ encode({
165
+ task_id: "T-PROP-001",
166
+ title: "Fix bug",
167
+ origin: "worker-1",
168
+ status: "proposed",
169
+ })
170
+ );
171
+
172
+ // Daemon reads proposed tasks
173
+ const entry = await kv.get("T-PROP-001");
174
+ const task = decode(entry!.value);
175
+ expect(task.status).toBe("proposed");
176
+
177
+ // Daemon validates and accepts
178
+ task.status = "queued";
179
+ await kv.put("T-PROP-001", encode(task));
180
+
181
+ const updated = await kv.get("T-PROP-001");
182
+ expect(decode(updated!.value).status).toBe("queued");
183
+ });
184
+
185
+ it("daemon rejects invalid proposal", async () => {
186
+ // Worker proposes without title
187
+ await kv.put(
188
+ "T-BAD-001",
189
+ encode({
190
+ task_id: "T-BAD-001",
191
+ title: "",
192
+ origin: "worker-1",
193
+ status: "proposed",
194
+ })
195
+ );
196
+
197
+ const entry = await kv.get("T-BAD-001");
198
+ const task = decode(entry!.value);
199
+
200
+ // Daemon validates: empty title → reject
201
+ if (!task.title || !task.origin) {
202
+ task.status = "rejected";
203
+ task.result = { success: false, summary: "Missing required fields" };
204
+ }
205
+ await kv.put("T-BAD-001", encode(task));
206
+
207
+ const updated = await kv.get("T-BAD-001");
208
+ expect(decode(updated!.value).status).toBe("rejected");
209
+ });
210
+
211
+ it("proposal with missing origin gets rejected", async () => {
212
+ await kv.put(
213
+ "T-BAD-002",
214
+ encode({
215
+ task_id: "T-BAD-002",
216
+ title: "Good title",
217
+ origin: "",
218
+ status: "proposed",
219
+ })
220
+ );
221
+
222
+ const entry = await kv.get("T-BAD-002");
223
+ const task = decode(entry!.value);
224
+
225
+ if (!task.title || !task.origin) {
226
+ task.status = "rejected";
227
+ }
228
+ await kv.put("T-BAD-002", encode(task));
229
+
230
+ const updated = await kv.get("T-BAD-002");
231
+ expect(decode(updated!.value).status).toBe("rejected");
232
+ });
233
+ });
234
+
235
+ // ── Watcher Lifecycle ──
236
+
237
+ describe("KV Watcher lifecycle", () => {
238
+ it("watcher receives put events", async () => {
239
+ const watcher = await kv.watch();
240
+ const events: any[] = [];
241
+
242
+ // Start collecting in background
243
+ const collecting = (async () => {
244
+ for await (const entry of watcher) {
245
+ events.push(entry);
246
+ if (events.length >= 2) break;
247
+ }
248
+ })();
249
+
250
+ await kv.put("task-1", encode({ title: "First" }));
251
+ await kv.put("task-2", encode({ title: "Second" }));
252
+
253
+ await collecting;
254
+
255
+ expect(events).toHaveLength(2);
256
+ expect(events[0].key).toBe("task-1");
257
+ expect(events[1].key).toBe("task-2");
258
+ });
259
+
260
+ it("watcher receives delete events", async () => {
261
+ await kv.put("task-1", encode({ title: "Exists" }));
262
+
263
+ const watcher = await kv.watch();
264
+ const events: any[] = [];
265
+
266
+ const collecting = (async () => {
267
+ for await (const entry of watcher) {
268
+ events.push(entry);
269
+ if (events.length >= 1) break;
270
+ }
271
+ })();
272
+
273
+ await kv.delete("task-1");
274
+ await collecting;
275
+
276
+ expect(events).toHaveLength(1);
277
+ expect(events[0].key).toBe("task-1");
278
+ expect(events[0].operation).toBe("DEL");
279
+ });
280
+
281
+ it("watcher.stop() ends iteration", async () => {
282
+ const watcher = await kv.watch();
283
+ let iterations = 0;
284
+
285
+ const collecting = (async () => {
286
+ for await (const _entry of watcher) {
287
+ iterations++;
288
+ }
289
+ })();
290
+
291
+ await kv.put("task-1", encode({ title: "First" }));
292
+
293
+ // Give time for the event to be processed
294
+ await new Promise((r) => setTimeout(r, 10));
295
+
296
+ watcher.stop();
297
+ await collecting;
298
+
299
+ expect(iterations).toBe(1);
300
+ });
301
+
302
+ it("stopped watcher does not receive new events", async () => {
303
+ const watcher = await kv.watch();
304
+ const events: any[] = [];
305
+
306
+ const collecting = (async () => {
307
+ for await (const entry of watcher) {
308
+ events.push(entry);
309
+ }
310
+ })();
311
+
312
+ await kv.put("task-1", encode({ title: "Before stop" }));
313
+ await new Promise((r) => setTimeout(r, 10));
314
+
315
+ watcher.stop();
316
+ await collecting;
317
+
318
+ // This write happens after stop
319
+ await kv.put("task-2", encode({ title: "After stop" }));
320
+ await new Promise((r) => setTimeout(r, 10));
321
+
322
+ expect(events).toHaveLength(1);
323
+ expect(events[0].key).toBe("task-1");
324
+ });
325
+ });
326
+
327
+ // ── Sync Skip Logic ──
328
+
329
+ describe("Sync skip logic (worker nodes)", () => {
330
+ it("worker role skips markdown write", () => {
331
+ const nodeRole = "worker";
332
+ let wrote = false;
333
+ if (nodeRole !== "worker") {
334
+ wrote = true;
335
+ }
336
+ expect(wrote).toBe(false);
337
+ });
338
+
339
+ it("lead role allows markdown write", () => {
340
+ const nodeRole = "lead";
341
+ let wrote = false;
342
+ if (nodeRole !== "worker") {
343
+ wrote = true;
344
+ }
345
+ expect(wrote).toBe(true);
346
+ });
347
+ });
348
+
349
+ // ── Collision-proof ID Generation ──
350
+
351
+ describe("Collision-proof task IDs", () => {
352
+ it("generates unique IDs sequentially", () => {
353
+ const ids = new Set<string>();
354
+ for (let i = 0; i < 100; i++) {
355
+ const { randomBytes } = require("crypto");
356
+ const suffix = randomBytes(3).toString("hex");
357
+ const now = new Date();
358
+ const dateStr =
359
+ now.getFullYear().toString() +
360
+ (now.getMonth() + 1).toString().padStart(2, "0") +
361
+ now.getDate().toString().padStart(2, "0");
362
+ ids.add(`T-${dateStr}-${suffix}`);
363
+ }
364
+ expect(ids.size).toBe(100);
365
+ });
366
+ });
367
+
368
+ // ── Task Merge Logic (useTasks on worker nodes) ──
369
+
370
+ describe("Task merge logic (worker node deduplication)", () => {
371
+ function mergeTasks(
372
+ sqliteTasks: Array<{ id: string; title: string; source: "sqlite" }>,
373
+ kvTasks: Array<{ task_id: string; title: string; source: "kv" }>,
374
+ nodeRole: "lead" | "worker"
375
+ ) {
376
+ const merged = new Map<string, any>();
377
+
378
+ for (const t of sqliteTasks) {
379
+ merged.set(t.id, { ...t, mergedFrom: "sqlite" });
380
+ }
381
+
382
+ for (const t of kvTasks) {
383
+ const existing = merged.get(t.task_id);
384
+ if (!existing) {
385
+ merged.set(t.task_id, { id: t.task_id, ...t, mergedFrom: "kv" });
386
+ } else if (nodeRole === "worker") {
387
+ merged.set(t.task_id, { id: t.task_id, ...t, mergedFrom: "kv" });
388
+ }
389
+ }
390
+
391
+ return Array.from(merged.values());
392
+ }
393
+
394
+ it("merges non-overlapping tasks from both sources", () => {
395
+ const sqlite = [
396
+ { id: "T-001", title: "Local task", source: "sqlite" as const },
397
+ ];
398
+ const kvTasks = [
399
+ { task_id: "T-002", title: "Mesh task", source: "kv" as const },
400
+ ];
401
+ const result = mergeTasks(sqlite, kvTasks, "worker");
402
+ expect(result).toHaveLength(2);
403
+ expect(result.find((t: any) => t.id === "T-001")).toBeTruthy();
404
+ expect(result.find((t: any) => t.id === "T-002")).toBeTruthy();
405
+ });
406
+
407
+ it("deduplicates overlapping tasks — worker prefers KV", () => {
408
+ const sqlite = [
409
+ { id: "T-001", title: "SQLite version", source: "sqlite" as const },
410
+ { id: "T-002", title: "Local only", source: "sqlite" as const },
411
+ ];
412
+ const kvTasks = [
413
+ {
414
+ task_id: "T-001",
415
+ title: "KV version (newer)",
416
+ source: "kv" as const,
417
+ },
418
+ { task_id: "T-003", title: "Mesh only", source: "kv" as const },
419
+ ];
420
+ const result = mergeTasks(sqlite, kvTasks, "worker");
421
+ expect(result).toHaveLength(3);
422
+ const t001 = result.find((t: any) => t.id === "T-001");
423
+ expect(t001.mergedFrom).toBe("kv");
424
+ expect(t001.title).toBe("KV version (newer)");
425
+ });
426
+
427
+ it("deduplicates overlapping tasks — lead prefers SQLite", () => {
428
+ const sqlite = [
429
+ {
430
+ id: "T-001",
431
+ title: "SQLite version (richer)",
432
+ source: "sqlite" as const,
433
+ },
434
+ ];
435
+ const kvTasks = [
436
+ { task_id: "T-001", title: "KV version", source: "kv" as const },
437
+ ];
438
+ const result = mergeTasks(sqlite, kvTasks, "lead");
439
+ expect(result).toHaveLength(1);
440
+ const t001 = result.find((t: any) => t.id === "T-001");
441
+ expect(t001.mergedFrom).toBe("sqlite");
442
+ expect(t001.title).toBe("SQLite version (richer)");
443
+ });
444
+
445
+ it("handles empty KV gracefully", () => {
446
+ const sqlite = [
447
+ { id: "T-001", title: "Only local", source: "sqlite" as const },
448
+ ];
449
+ const result = mergeTasks(sqlite, [], "worker");
450
+ expect(result).toHaveLength(1);
451
+ });
452
+
453
+ it("handles empty SQLite gracefully", () => {
454
+ const kvTasks = [
455
+ { task_id: "T-001", title: "Only mesh", source: "kv" as const },
456
+ ];
457
+ const result = mergeTasks([], kvTasks, "worker");
458
+ expect(result).toHaveLength(1);
459
+ });
460
+
461
+ it("handles both empty gracefully", () => {
462
+ const result = mergeTasks([], [], "worker");
463
+ expect(result).toHaveLength(0);
464
+ });
465
+ });
@@ -0,0 +1,131 @@
1
+ /**
2
+ * mock-kv.ts — Reusable mock for NATS KV bucket.
3
+ *
4
+ * Provides a Map-backed in-memory implementation of the NATS KV interface
5
+ * with CAS (Compare-And-Swap) semantics: create(), update() with revision guards.
6
+ *
7
+ * Used by: mesh-kv-sync.test.ts, future collab tests, deploy result tests, health tests.
8
+ *
9
+ * Import: import { MockKV, encode, decode } from "./mocks/mock-kv";
10
+ */
11
+
12
+ const encoder = new TextEncoder();
13
+ const decoder = new TextDecoder();
14
+
15
+ export function encode(obj: any): Uint8Array {
16
+ return encoder.encode(JSON.stringify(obj));
17
+ }
18
+
19
+ export function decode(buf: Uint8Array): any {
20
+ return JSON.parse(decoder.decode(buf));
21
+ }
22
+
23
+ export class MockKV {
24
+ store = new Map<string, { value: Uint8Array; revision: number }>();
25
+ private rev = 0;
26
+ watchers: Array<{ callback: (entry: any) => void; stopped: boolean }> = [];
27
+
28
+ async put(key: string, value: Uint8Array) {
29
+ this.rev++;
30
+ this.store.set(key, { value, revision: this.rev });
31
+ for (const w of this.watchers) {
32
+ if (!w.stopped) {
33
+ w.callback({ key, value, revision: this.rev, operation: "PUT" });
34
+ }
35
+ }
36
+ return this.rev;
37
+ }
38
+
39
+ async get(key: string) {
40
+ return this.store.get(key) || null;
41
+ }
42
+
43
+ async create(key: string, value: Uint8Array) {
44
+ if (this.store.has(key)) {
45
+ throw new Error("wrong last sequence: key already exists");
46
+ }
47
+ return this.put(key, value);
48
+ }
49
+
50
+ async update(key: string, value: Uint8Array, expectedRevision: number) {
51
+ const current = this.store.get(key);
52
+ if (!current || current.revision !== expectedRevision) {
53
+ throw new Error(
54
+ `wrong last sequence: revision mismatch (expected ${expectedRevision}, got ${current?.revision ?? "none"})`
55
+ );
56
+ }
57
+ return this.put(key, value);
58
+ }
59
+
60
+ async delete(key: string) {
61
+ this.store.delete(key);
62
+ for (const w of this.watchers) {
63
+ if (!w.stopped) {
64
+ w.callback({
65
+ key,
66
+ value: null,
67
+ revision: ++this.rev,
68
+ operation: "DEL",
69
+ });
70
+ }
71
+ }
72
+ }
73
+
74
+ async keys() {
75
+ const iter = this.store.keys();
76
+ return {
77
+ [Symbol.asyncIterator]() {
78
+ return {
79
+ next() {
80
+ const r = iter.next();
81
+ return Promise.resolve(r);
82
+ },
83
+ };
84
+ },
85
+ };
86
+ }
87
+
88
+ async watch(_opts?: any) {
89
+ const entries: any[] = [];
90
+ let resolveNext: ((v: any) => void) | null = null;
91
+ let stopped = false;
92
+
93
+ const watcher = {
94
+ callback: (entry: any) => {
95
+ if (resolveNext) {
96
+ const r = resolveNext;
97
+ resolveNext = null;
98
+ r({ value: entry, done: false });
99
+ } else {
100
+ entries.push(entry);
101
+ }
102
+ },
103
+ stopped: false,
104
+ };
105
+ this.watchers.push(watcher);
106
+
107
+ return {
108
+ [Symbol.asyncIterator]() {
109
+ return {
110
+ next() {
111
+ if (stopped)
112
+ return Promise.resolve({ value: undefined, done: true });
113
+ if (entries.length > 0) {
114
+ return Promise.resolve({ value: entries.shift(), done: false });
115
+ }
116
+ return new Promise((resolve) => {
117
+ resolveNext = resolve;
118
+ });
119
+ },
120
+ };
121
+ },
122
+ stop() {
123
+ stopped = true;
124
+ watcher.stopped = true;
125
+ if (resolveNext) {
126
+ resolveNext({ value: undefined, done: true });
127
+ }
128
+ },
129
+ };
130
+ }
131
+ }
@@ -0,0 +1,46 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { statusToKanban, kanbanToStatus } from "../parsers/task-markdown";
3
+
4
+ describe("statusToKanban", () => {
5
+ const cases: [string, string][] = [
6
+ ["queued", "backlog"],
7
+ ["ready", "backlog"],
8
+ ["submitted", "in_progress"],
9
+ ["running", "in_progress"],
10
+ ["blocked", "in_progress"],
11
+ ["waiting-user", "review"],
12
+ ["done", "done"],
13
+ ["cancelled", "done"],
14
+ ["archived", "done"],
15
+ ];
16
+
17
+ for (const [status, expected] of cases) {
18
+ it(`maps "${status}" → "${expected}"`, () => {
19
+ expect(statusToKanban(status)).toBe(expected);
20
+ });
21
+ }
22
+
23
+ it("falls back to backlog for unknown statuses", () => {
24
+ expect(statusToKanban("unknown")).toBe("backlog");
25
+ expect(statusToKanban("not started")).toBe("backlog");
26
+ });
27
+ });
28
+
29
+ describe("kanbanToStatus", () => {
30
+ const cases: [string, string][] = [
31
+ ["backlog", "queued"],
32
+ ["in_progress", "running"],
33
+ ["review", "waiting-user"],
34
+ ["done", "done"],
35
+ ];
36
+
37
+ for (const [column, expected] of cases) {
38
+ it(`maps "${column}" → "${expected}"`, () => {
39
+ expect(kanbanToStatus(column)).toBe(expected);
40
+ });
41
+ }
42
+
43
+ it("falls back to queued for unknown columns", () => {
44
+ expect(kanbanToStatus("unknown")).toBe("queued");
45
+ });
46
+ });