sensorium-mcp 3.1.1-board.2 → 3.1.2

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 (98) hide show
  1. package/dist/dashboard/routes/types.d.ts +0 -6
  2. package/dist/dashboard/routes/types.d.ts.map +1 -1
  3. package/dist/dashboard/routes/types.js.map +1 -1
  4. package/dist/dashboard/routes.d.ts.map +1 -1
  5. package/dist/dashboard/routes.js +0 -32
  6. package/dist/dashboard/routes.js.map +1 -1
  7. package/dist/dashboard/spa.html +12 -12
  8. package/dist/data/memory/migration-runner.d.ts.map +1 -1
  9. package/dist/data/memory/migration-runner.js +0 -4
  10. package/dist/data/memory/migration-runner.js.map +1 -1
  11. package/dist/data/memory/schema.d.ts.map +1 -1
  12. package/dist/data/memory/schema.js +0 -4
  13. package/dist/data/memory/schema.js.map +1 -1
  14. package/dist/index.js +2 -39
  15. package/dist/index.js.map +1 -1
  16. package/dist/server/http-server.d.ts +1 -3
  17. package/dist/server/http-server.d.ts.map +1 -1
  18. package/dist/server/http-server.js +1 -3
  19. package/dist/server/http-server.js.map +1 -1
  20. package/dist/services/broker-client.d.ts +17 -4
  21. package/dist/services/broker-client.d.ts.map +1 -1
  22. package/dist/services/broker-client.js +14 -3
  23. package/dist/services/broker-client.js.map +1 -1
  24. package/dist/services/self-update.service.d.ts +3 -2
  25. package/dist/services/self-update.service.d.ts.map +1 -1
  26. package/dist/services/self-update.service.js +20 -21
  27. package/dist/services/self-update.service.js.map +1 -1
  28. package/dist/services/spawn-common.d.ts +44 -19
  29. package/dist/services/spawn-common.d.ts.map +1 -1
  30. package/dist/services/spawn-common.js +65 -33
  31. package/dist/services/spawn-common.js.map +1 -1
  32. package/dist/services/worker-cleanup.service.d.ts +1 -6
  33. package/dist/services/worker-cleanup.service.d.ts.map +1 -1
  34. package/dist/services/worker-cleanup.service.js +10 -123
  35. package/dist/services/worker-cleanup.service.js.map +1 -1
  36. package/dist/tools/delegate-tool.d.ts.map +1 -1
  37. package/dist/tools/delegate-tool.js +0 -32
  38. package/dist/tools/delegate-tool.js.map +1 -1
  39. package/dist/tools/wait/message-delivery.d.ts.map +1 -1
  40. package/dist/tools/wait/message-delivery.js +0 -13
  41. package/dist/tools/wait/message-delivery.js.map +1 -1
  42. package/package.json +1 -1
  43. package/dist/dashboard/routes/board.d.ts +0 -67
  44. package/dist/dashboard/routes/board.d.ts.map +0 -1
  45. package/dist/dashboard/routes/board.js +0 -612
  46. package/dist/dashboard/routes/board.js.map +0 -1
  47. package/dist/data/board/board-schema.d.ts +0 -22
  48. package/dist/data/board/board-schema.d.ts.map +0 -1
  49. package/dist/data/board/board-schema.js +0 -103
  50. package/dist/data/board/board-schema.js.map +0 -1
  51. package/dist/data/board/board-types.d.ts +0 -117
  52. package/dist/data/board/board-types.d.ts.map +0 -1
  53. package/dist/data/board/board-types.js +0 -9
  54. package/dist/data/board/board-types.js.map +0 -1
  55. package/dist/data/board/card.repository.d.ts +0 -64
  56. package/dist/data/board/card.repository.d.ts.map +0 -1
  57. package/dist/data/board/card.repository.js +0 -272
  58. package/dist/data/board/card.repository.js.map +0 -1
  59. package/dist/data/board/comment.repository.d.ts +0 -12
  60. package/dist/data/board/comment.repository.d.ts.map +0 -1
  61. package/dist/data/board/comment.repository.js +0 -31
  62. package/dist/data/board/comment.repository.js.map +0 -1
  63. package/dist/data/board/lane.repository.d.ts +0 -18
  64. package/dist/data/board/lane.repository.d.ts.map +0 -1
  65. package/dist/data/board/lane.repository.js +0 -44
  66. package/dist/data/board/lane.repository.js.map +0 -1
  67. package/dist/data/board/profile-defaults.d.ts +0 -19
  68. package/dist/data/board/profile-defaults.d.ts.map +0 -1
  69. package/dist/data/board/profile-defaults.js +0 -118
  70. package/dist/data/board/profile-defaults.js.map +0 -1
  71. package/dist/services/board/board-signals.d.ts +0 -25
  72. package/dist/services/board/board-signals.d.ts.map +0 -1
  73. package/dist/services/board/board-signals.js +0 -83
  74. package/dist/services/board/board-signals.js.map +0 -1
  75. package/dist/services/board/card-briefing.d.ts +0 -17
  76. package/dist/services/board/card-briefing.d.ts.map +0 -1
  77. package/dist/services/board/card-briefing.js +0 -86
  78. package/dist/services/board/card-briefing.js.map +0 -1
  79. package/dist/services/board/orchestrator.service.d.ts +0 -55
  80. package/dist/services/board/orchestrator.service.d.ts.map +0 -1
  81. package/dist/services/board/orchestrator.service.js +0 -198
  82. package/dist/services/board/orchestrator.service.js.map +0 -1
  83. package/dist/services/board/release-worker.d.ts +0 -32
  84. package/dist/services/board/release-worker.d.ts.map +0 -1
  85. package/dist/services/board/release-worker.js +0 -49
  86. package/dist/services/board/release-worker.js.map +0 -1
  87. package/dist/services/board/report-capture.d.ts +0 -48
  88. package/dist/services/board/report-capture.d.ts.map +0 -1
  89. package/dist/services/board/report-capture.js +0 -172
  90. package/dist/services/board/report-capture.js.map +0 -1
  91. package/dist/services/board/worker-spawn.d.ts +0 -30
  92. package/dist/services/board/worker-spawn.d.ts.map +0 -1
  93. package/dist/services/board/worker-spawn.js +0 -90
  94. package/dist/services/board/worker-spawn.js.map +0 -1
  95. package/dist/services/passive-notice.service.d.ts +0 -39
  96. package/dist/services/passive-notice.service.d.ts.map +0 -1
  97. package/dist/services/passive-notice.service.js +0 -274
  98. package/dist/services/passive-notice.service.js.map +0 -1
@@ -1,67 +0,0 @@
1
- /**
2
- * Dashboard API — Agent Task Board routes.
3
- *
4
- * The ONLY write surface for the board (single-writer invariant): the operator
5
- * acts here; agents never see these endpoints. The orchestrator service is the
6
- * other writer (worker reports → comments, status moves) but that is server
7
- * code, not an exposed surface.
8
- *
9
- * Routes (all Bearer-auth like every /api/*):
10
- * GET /api/board/cards?includeArchived=1&lane=<threadId>
11
- * POST /api/board/cards
12
- * GET /api/board/cards/:id (card + comments)
13
- * PATCH /api/board/cards/:id
14
- * DELETE /api/board/cards/:id
15
- * POST /api/board/cards/:id/comments
16
- * GET /api/board/lanes
17
- * POST /api/board/lanes
18
- * DELETE /api/board/lanes/:threadId
19
- * GET /api/board/profiles
20
- * PUT /api/board/profiles
21
- */
22
- import { type RouteArgs, type RouteHandler } from "./types.js";
23
- /** GET /api/board/cards → { cards } (comments not included; fetch per card). */
24
- export declare const handleGetBoardCards: RouteHandler;
25
- /** POST /api/board/cards — create; type seeds the profile, explicit fields win. */
26
- export declare const handlePostBoardCard: RouteHandler;
27
- /** GET /api/board/cards/:id → { card, comments } */
28
- export declare function handleGetBoardCard(args: RouteArgs, cardId: number): boolean;
29
- /**
30
- * PATCH /api/board/cards/:id — operator edits, drag-drop status moves, resume.
31
- *
32
- * Live-card policy (design D2/D4): a card that is in_progress is TASK-IMMUTABLE.
33
- * The bound worker owns what it is DOING; the only way to change that is an
34
- * explicit Stop (POST .../stop) that decommissions it. So while in_progress:
35
- * • re-laning, archiving, and profile/type edits are REFUSED (409) — "Stop
36
- * first" — because they would silently diverge from what the worker runs.
37
- * • moving to backlog (re-run) or done ALLOWS the move but first releases
38
- * (kills + tears down) the bound worker, so no orphaned/duplicate worker
39
- * survives to double-push or leak its report (R1 closes B1/B2).
40
- * Leaving in_progress/blocked clears the worker binding + blocked reason so a
41
- * later dispatch spawns a FRESH stateless worker (design §3.5).
42
- */
43
- export declare function handlePatchBoardCard(args: RouteArgs, cardId: number): Promise<boolean>;
44
- /**
45
- * POST /api/board/cards/:id/stop — explicit operator Stop (design D3).
46
- * Decommission the bound worker (kill + teardown) and park the card in blocked
47
- * with a clear reason. Blocked is never auto-dispatched, so the card sits safely
48
- * until the operator resumes it (→ backlog re-dispatches a fresh worker).
49
- */
50
- export declare function handleStopBoardCard(args: RouteArgs, cardId: number): Promise<boolean>;
51
- /** DELETE /api/board/cards/:id — remove the card. A live card's bound worker
52
- * is decommissioned first (R1/B8) so deleting it never orphans a running
53
- * worker that would push/report against a card that no longer exists. */
54
- export declare function handleDeleteBoardCard(args: RouteArgs, cardId: number): Promise<boolean>;
55
- /** POST /api/board/cards/:id/comments — operator comment. */
56
- export declare function handlePostBoardComment(args: RouteArgs, cardId: number): Promise<boolean>;
57
- /** GET /api/board/lanes → lanes decorated with thread names for the UI. */
58
- export declare const handleGetBoardLanes: RouteHandler;
59
- /** POST /api/board/lanes — connect/disconnect a thread, set max_concurrent. */
60
- export declare const handlePostBoardLane: RouteHandler;
61
- /** DELETE /api/board/lanes/:threadId */
62
- export declare function handleDeleteBoardLane(args: RouteArgs, threadId: number): boolean;
63
- /** GET /api/board/profiles → effective per-type templates (builtin ⊕ overrides). */
64
- export declare const handleGetBoardProfiles: RouteHandler;
65
- /** PUT /api/board/profiles — persist operator overrides wholesale. */
66
- export declare const handlePutBoardProfiles: RouteHandler;
67
- //# sourceMappingURL=board.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"board.d.ts","sourceRoot":"","sources":["../../../src/dashboard/routes/board.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,OAAO,EAAY,KAAK,SAAS,EAAE,KAAK,YAAY,EAAE,MAAM,YAAY,CAAC;AAsJzE,gFAAgF;AAChF,eAAO,MAAM,mBAAmB,EAAE,YAuBjC,CAAC;AAEF,mFAAmF;AACnF,eAAO,MAAM,mBAAmB,EAAE,YA4DjC,CAAC;AAEF,oDAAoD;AACpD,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAa3E;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAsB,oBAAoB,CAAC,IAAI,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAiJ5F;AAED;;;;;GAKG;AACH,wBAAsB,mBAAmB,CAAC,IAAI,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAwB3F;AAED;;0EAE0E;AAC1E,wBAAsB,qBAAqB,CAAC,IAAI,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAoB7F;AAED,6DAA6D;AAC7D,wBAAsB,sBAAsB,CAAC,IAAI,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAkB9F;AAID,2EAA2E;AAC3E,eAAO,MAAM,mBAAmB,EAAE,YAWjC,CAAC;AAEF,+EAA+E;AAC/E,eAAO,MAAM,mBAAmB,EAAE,YAkCjC,CAAC;AAEF,wCAAwC;AACxC,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAuBhF;AAID,oFAAoF;AACpF,eAAO,MAAM,sBAAsB,EAAE,YAOpC,CAAC;AAEF,sEAAsE;AACtE,eAAO,MAAM,sBAAsB,EAAE,YAgCpC,CAAC"}
@@ -1,612 +0,0 @@
1
- /**
2
- * Dashboard API — Agent Task Board routes.
3
- *
4
- * The ONLY write surface for the board (single-writer invariant): the operator
5
- * acts here; agents never see these endpoints. The orchestrator service is the
6
- * other writer (worker reports → comments, status moves) but that is server
7
- * code, not an exposed surface.
8
- *
9
- * Routes (all Bearer-auth like every /api/*):
10
- * GET /api/board/cards?includeArchived=1&lane=<threadId>
11
- * POST /api/board/cards
12
- * GET /api/board/cards/:id (card + comments)
13
- * PATCH /api/board/cards/:id
14
- * DELETE /api/board/cards/:id
15
- * POST /api/board/cards/:id/comments
16
- * GET /api/board/lanes
17
- * POST /api/board/lanes
18
- * DELETE /api/board/lanes/:threadId
19
- * GET /api/board/profiles
20
- * PUT /api/board/profiles
21
- */
22
- import { readBody } from "./types.js";
23
- import { errorMessage } from "../../lib/utils.js";
24
- import { getMaxConcurrentThreads } from "../../config/index.js";
25
- import { buildDonePatch, createCard, deleteCard, getCard, listCards, updateCard, } from "../../data/board/card.repository.js";
26
- import { addComment, listComments } from "../../data/board/comment.repository.js";
27
- import { listLanes, removeLane, upsertLane } from "../../data/board/lane.repository.js";
28
- import { getProfileForType, getProfileTemplates, saveProfileOverrides, } from "../../data/board/profile-defaults.js";
29
- import { getThread } from "../../data/memory/thread-registry.js";
30
- import { releaseWorkerForCard, } from "../../services/board/release-worker.js";
31
- /** Assemble the decommission deps the release-worker path needs from the
32
- * dashboard context (single place carrying telegram/chatId into the routes). */
33
- function releaseDeps(args) {
34
- return {
35
- db: args.db,
36
- telegram: args.ctx.telegram,
37
- chatId: args.ctx.chatId,
38
- threadLifecycle: args.ctx.threadLifecycle,
39
- };
40
- }
41
- /** Operator-legal status targets. in_progress is entered ONLY by the
42
- * orchestrator's atomic claim (a hand-set in_progress card would have no
43
- * worker and sit stuck); blocked ONLY by worker reports / exit safety nets
44
- * (a hand-set blocked card would carry no reason). */
45
- const OPERATOR_STATUS_TARGETS = ["backlog", "done"];
46
- async function parseJsonBody(args) {
47
- try {
48
- const parsed = JSON.parse(await readBody(args.req));
49
- return typeof parsed === "object" && parsed !== null
50
- ? parsed
51
- : null;
52
- }
53
- catch {
54
- return null;
55
- }
56
- }
57
- const asInt = (v) => {
58
- const n = typeof v === "number" ? v : typeof v === "string" ? Number(v) : NaN;
59
- return Number.isInteger(n) ? n : undefined;
60
- };
61
- /** A lane must be a REAL registered thread — a typo'd id must not silently
62
- * become a live connected lane that dispatch then spawns workers against. */
63
- function validateLaneThread(db, threadId) {
64
- const thread = getThread(db, threadId);
65
- if (!thread)
66
- return `Thread ${threadId} does not exist in the thread registry`;
67
- if (thread.type === "worker")
68
- return `Thread ${threadId} is a worker — lanes must be root/branch threads`;
69
- return null;
70
- }
71
- /** Valid card types = builtin + operator-defined profile templates. An unknown
72
- * type would silently seed an EMPTY profile, stripping tests/PR/review rigor. */
73
- function validateCardType(type) {
74
- const known = Object.keys(getProfileTemplates());
75
- return known.includes(type)
76
- ? null
77
- : `Unknown card type '${type}' — known types: ${known.join(", ")}`;
78
- }
79
- /**
80
- * Empty/zeroed input means "no recurrence" — NULL, not a 1-minute respawn loop.
81
- * An emptied number input arrives as '' (asInt('')=0) or 0; treating either as
82
- * Math.max(1,0)=1 turned "no recurrence" into "every minute" (H3 regression).
83
- * Only an intentional integer ≥1 sets a real cadence; everything falsy → null.
84
- */
85
- function parseRecurDelay(v) {
86
- if (v === null || v === undefined || v === "")
87
- return null;
88
- const n = asInt(v);
89
- if (n === undefined)
90
- return undefined;
91
- return n >= 1 ? n : null;
92
- }
93
- /** Order-independent equality for two execution profiles (flat primitives +
94
- * a skills string[]). Used to tell an actual profile edit apart from an
95
- * unchanged profile bundled into a PATCH that only touches allowed fields. */
96
- function profileChanged(a, b) {
97
- const keys = [...new Set([...Object.keys(a), ...Object.keys(b)])].sort();
98
- return JSON.stringify(a, keys) !== JSON.stringify(b, keys);
99
- }
100
- /** Providers a card/profile may name (ExecutionProfile.agentType contract). An
101
- * unknown value must 400 — otherwise it slips past card creation and silently
102
- * falls through to the default Claude spawn branch instead of erroring. */
103
- const AGENT_TYPES = ["claude", "copilot", "codex", "fable"];
104
- /** Every legal ExecutionProfile field. PUT /profiles validates each persisted
105
- * per-type profile against this whitelist so junk keys can't be stored. */
106
- const PROFILE_FIELDS = [
107
- "repository",
108
- "createBranch",
109
- "skills",
110
- "operatorReview",
111
- "openPr",
112
- "expertReview",
113
- "reviewAgentType",
114
- "workerSplit",
115
- "agentType",
116
- "size",
117
- "runTests",
118
- "build",
119
- "push",
120
- "ciCheck",
121
- "inline",
122
- "ttlMinutes",
123
- ];
124
- /** Reject a profile naming an unknown agentType/reviewAgentType. Empty (absent/
125
- * null) means "default" and is fine; a garbage provider is a 400, not a silent
126
- * fall-through to the Claude spawn branch. */
127
- function validateProfileAgents(profile) {
128
- for (const field of ["agentType", "reviewAgentType"]) {
129
- const v = profile[field];
130
- if (v !== undefined && v !== null && !AGENT_TYPES.includes(v))
131
- return `Unknown ${field} '${String(v)}' — allowed: ${AGENT_TYPES.join(", ")}`;
132
- }
133
- return null;
134
- }
135
- /** Well-formedness of a single per-type profile persisted via PUT /profiles: a
136
- * plain object whose keys are all real ExecutionProfile fields with valid agent
137
- * enums. A non-object or a junk key is rejected outright. */
138
- function validateProfileShape(value) {
139
- if (typeof value !== "object" || value === null || Array.isArray(value))
140
- return "each profile must be a JSON object";
141
- const profile = value;
142
- for (const key of Object.keys(profile))
143
- if (!PROFILE_FIELDS.includes(key))
144
- return `unknown profile field '${key}'`;
145
- return validateProfileAgents(profile);
146
- }
147
- // ── Cards ────────────────────────────────────────────────────────────────────
148
- /** GET /api/board/cards → { cards } (comments not included; fetch per card). */
149
- export const handleGetBoardCards = ({ url, json, db }) => {
150
- try {
151
- // A malformed ?lane= must 400, not silently drop the filter and return ALL
152
- // cards (a caller scoping to one lane would leak every lane's cards).
153
- const laneParam = url.searchParams.get("lane");
154
- let laneThreadId;
155
- if (laneParam !== null) {
156
- laneThreadId = asInt(laneParam);
157
- if (laneThreadId === undefined) {
158
- json({ error: "'lane' must be an integer thread id" }, 400);
159
- return true;
160
- }
161
- }
162
- json({
163
- cards: listCards(db, {
164
- includeArchived: url.searchParams.get("includeArchived") === "1",
165
- ...(laneThreadId !== undefined ? { laneThreadId } : {}),
166
- }),
167
- });
168
- }
169
- catch (err) {
170
- json({ error: errorMessage(err) }, 500);
171
- }
172
- return true;
173
- };
174
- /** POST /api/board/cards — create; type seeds the profile, explicit fields win. */
175
- export const handlePostBoardCard = async (args) => {
176
- const { json, db } = args;
177
- try {
178
- const body = await parseJsonBody(args);
179
- const title = typeof body?.title === "string" ? body.title.trim() : "";
180
- const laneThreadId = asInt(body?.laneThreadId);
181
- if (!body || !title || laneThreadId === undefined) {
182
- json({ error: "'title' and integer 'laneThreadId' are required" }, 400);
183
- return true;
184
- }
185
- const laneError = validateLaneThread(db, laneThreadId);
186
- if (laneError) {
187
- json({ error: laneError }, 400);
188
- return true;
189
- }
190
- const type = typeof body.type === "string" ? body.type : "chore";
191
- const typeError = validateCardType(type);
192
- if (typeError) {
193
- json({ error: typeError }, 400);
194
- return true;
195
- }
196
- // A garbage non-empty recurDelayMin ("abc") must 400, not silently clear
197
- // recurrence. parseRecurDelay returns undefined ONLY for such garbage;
198
- // ''/null/absent → null (no recurrence, fine).
199
- const recurDelay = parseRecurDelay(body.recurDelayMin);
200
- if (recurDelay === undefined) {
201
- json({ error: "'recurDelayMin' must be an integer ≥ 1 (or empty to disable)" }, 400);
202
- return true;
203
- }
204
- if (typeof body.profile === "object" && body.profile !== null) {
205
- const agentError = validateProfileAgents(body.profile);
206
- if (agentError) {
207
- json({ error: agentError }, 400);
208
- return true;
209
- }
210
- }
211
- const profile = {
212
- ...getProfileForType(type),
213
- ...(typeof body.profile === "object" && body.profile !== null
214
- ? body.profile
215
- : {}),
216
- };
217
- // Connecting the lane on first use is idempotent and saves a separate call.
218
- upsertLane(db, laneThreadId);
219
- const card = createCard(db, {
220
- title,
221
- description: typeof body.description === "string" ? body.description : "",
222
- acceptance: typeof body.acceptance === "string" ? body.acceptance : "",
223
- type,
224
- laneThreadId,
225
- priority: asInt(body.priority) ?? 0,
226
- profile,
227
- escalate: body.escalate === true,
228
- ...(recurDelay != null ? { recurDelayMin: recurDelay } : {}),
229
- });
230
- json({ card }, 201);
231
- }
232
- catch (err) {
233
- json({ error: errorMessage(err) }, 500);
234
- }
235
- return true;
236
- };
237
- /** GET /api/board/cards/:id → { card, comments } */
238
- export function handleGetBoardCard(args, cardId) {
239
- const { json, db } = args;
240
- try {
241
- const card = getCard(db, cardId);
242
- if (!card) {
243
- json({ error: `Card ${cardId} not found` }, 404);
244
- return true;
245
- }
246
- json({ card, comments: listComments(db, cardId) });
247
- }
248
- catch (err) {
249
- json({ error: errorMessage(err) }, 500);
250
- }
251
- return true;
252
- }
253
- /**
254
- * PATCH /api/board/cards/:id — operator edits, drag-drop status moves, resume.
255
- *
256
- * Live-card policy (design D2/D4): a card that is in_progress is TASK-IMMUTABLE.
257
- * The bound worker owns what it is DOING; the only way to change that is an
258
- * explicit Stop (POST .../stop) that decommissions it. So while in_progress:
259
- * • re-laning, archiving, and profile/type edits are REFUSED (409) — "Stop
260
- * first" — because they would silently diverge from what the worker runs.
261
- * • moving to backlog (re-run) or done ALLOWS the move but first releases
262
- * (kills + tears down) the bound worker, so no orphaned/duplicate worker
263
- * survives to double-push or leak its report (R1 closes B1/B2).
264
- * Leaving in_progress/blocked clears the worker binding + blocked reason so a
265
- * later dispatch spawns a FRESH stateless worker (design §3.5).
266
- */
267
- export async function handlePatchBoardCard(args, cardId) {
268
- const { json, db } = args;
269
- try {
270
- const body = await parseJsonBody(args);
271
- if (!body) {
272
- json({ error: "JSON body required" }, 400);
273
- return true;
274
- }
275
- const existing = getCard(db, cardId);
276
- if (!existing) {
277
- json({ error: `Card ${cardId} not found` }, 404);
278
- return true;
279
- }
280
- // Does this PATCH move the card OUT of in_progress by an operator status
281
- // target? Only then do the live-card edit guards relax (the move itself is
282
- // the sanctioned "Stop-then-change" — it releases the worker below).
283
- const targetStatus = typeof body.status === "string" && body.status !== existing.status
284
- ? body.status
285
- : undefined;
286
- const leavingInProgress = existing.status === "in_progress" &&
287
- targetStatus !== undefined &&
288
- OPERATOR_STATUS_TARGETS.includes(targetStatus);
289
- const liveLocked = existing.status === "in_progress" && !leavingInProgress;
290
- const patch = {};
291
- if (typeof body.title === "string" && body.title.trim())
292
- patch.title = body.title.trim();
293
- if (typeof body.description === "string")
294
- patch.description = body.description;
295
- if (typeof body.acceptance === "string")
296
- patch.acceptance = body.acceptance;
297
- if (typeof body.type === "string") {
298
- const typeError = validateCardType(body.type);
299
- if (typeError) {
300
- json({ error: typeError }, 400);
301
- return true;
302
- }
303
- // M3: type reseeds the standing orders — a task mutation. Refuse on a live
304
- // card (the running worker was already briefed from the old type).
305
- if (liveLocked && body.type !== existing.type) {
306
- json({ error: "Card is locked while running — Stop the card to change its type" }, 409);
307
- return true;
308
- }
309
- patch.type = body.type;
310
- }
311
- const patchLane = asInt(body.laneThreadId);
312
- if (patchLane !== undefined) {
313
- const laneError = validateLaneThread(db, patchLane);
314
- if (laneError) {
315
- json({ error: laneError }, 400);
316
- return true;
317
- }
318
- // M1: re-laning a live card would leave the worker reporting to the old
319
- // lane → lost report / false BLOCKED. Refuse until stopped.
320
- if (liveLocked && patchLane !== existing.laneThreadId) {
321
- json({ error: "Stop the card before re-laning" }, 409);
322
- return true;
323
- }
324
- upsertLane(db, patchLane);
325
- patch.laneThreadId = patchLane;
326
- }
327
- if (asInt(body.priority) !== undefined)
328
- patch.priority = asInt(body.priority);
329
- if (typeof body.escalate === "boolean")
330
- patch.escalate = body.escalate;
331
- if ("recurDelayMin" in body) {
332
- // A garbage non-empty recurDelayMin must 400, not silently clear
333
- // recurrence (undefined ⇒ garbage; ''/null ⇒ intentional disable).
334
- const parsedDelay = parseRecurDelay(body.recurDelayMin);
335
- if (parsedDelay === undefined) {
336
- json({ error: "'recurDelayMin' must be an integer ≥ 1 (or empty to disable)" }, 400);
337
- return true;
338
- }
339
- const nextDelay = parsedDelay;
340
- patch.recurDelayMin = nextDelay;
341
- // M7.1: enabling recurrence on a card that was never a recurring original
342
- // must anchor the group here. Otherwise the card completes but
343
- // listDueRecurrences (which requires recur_group_id NOT NULL) never
344
- // respawns it AND archiveDoneOlderThan skips it (next_spawn_at is set) —
345
- // a permanent zombie stuck in Done. Anchor to its own id, matching how
346
- // createCard seeds a brand-new recurring original.
347
- if (nextDelay != null && existing.recurDelayMin == null && existing.recurGroupId == null)
348
- patch.recurGroupId = cardId;
349
- }
350
- if (typeof body.profile === "object" && body.profile !== null) {
351
- // A typo'd agentType/reviewAgentType must 400 (would otherwise fall
352
- // through to the default spawn branch on the next dispatch).
353
- const agentError = validateProfileAgents(body.profile);
354
- if (agentError) {
355
- json({ error: agentError }, 400);
356
- return true;
357
- }
358
- // M3: profile/agentType is baked at spawn — CHANGING it mid-run is a
359
- // misleading no-op against the live worker. Refuse a real change until
360
- // stopped; an unchanged profile bundled with an allowed edit passes.
361
- if (liveLocked && profileChanged(existing.profile, body.profile)) {
362
- json({ error: "Card is locked while running — Stop the card to change its profile" }, 409);
363
- return true;
364
- }
365
- patch.profile = body.profile;
366
- }
367
- if ("archivedAt" in body) {
368
- const archiving = body.archivedAt !== null;
369
- // M2: archiving a live card orphans its worker and (pre-fix) leaked a
370
- // concurrency slot forever. Refuse until stopped; un-archiving is fine.
371
- if (archiving && liveLocked) {
372
- json({ error: "Stop the card before archiving" }, 409);
373
- return true;
374
- }
375
- patch.archivedAt = archiving ? new Date().toISOString() : null;
376
- // M7.2: archiving a DONE recurring card must also stop recurrence — else
377
- // next_spawn_at survives and the card still respawns (possibly into a
378
- // since-deleted lane). Mirror the resume path's clear.
379
- if (archiving)
380
- patch.nextSpawnAt = null;
381
- }
382
- if (targetStatus !== undefined) {
383
- if (!OPERATOR_STATUS_TARGETS.includes(targetStatus)) {
384
- json({
385
- error: `Operator moves may only target ${OPERATOR_STATUS_TARGETS.join("/")} — in_progress and blocked are set by the board itself`,
386
- }, 400);
387
- return true;
388
- }
389
- // R1: releasing a live card's worker BEFORE the DB move is what prevents
390
- // the next tick from spawning a second worker on the same card.
391
- if (leavingInProgress)
392
- await releaseWorkerForCard(releaseDeps(args), existing.workerThreadId);
393
- patch.status = targetStatus;
394
- if (targetStatus === "backlog") {
395
- // Resume/re-run: next dispatch spawns a fresh worker briefed from the
396
- // card. The resumed card IS the group's live instance again — clear any
397
- // pending respawn time or the group could double-spawn (H3).
398
- patch.workerThreadId = null;
399
- patch.blockedReason = null;
400
- patch.nextSpawnAt = null;
401
- }
402
- else if (targetStatus === "done") {
403
- // Single DONE-transition source — same patch the worker-report path uses.
404
- Object.assign(patch, buildDonePatch(existing));
405
- }
406
- }
407
- json({ card: updateCard(db, cardId, patch) });
408
- }
409
- catch (err) {
410
- json({ error: errorMessage(err) }, 500);
411
- }
412
- return true;
413
- }
414
- /**
415
- * POST /api/board/cards/:id/stop — explicit operator Stop (design D3).
416
- * Decommission the bound worker (kill + teardown) and park the card in blocked
417
- * with a clear reason. Blocked is never auto-dispatched, so the card sits safely
418
- * until the operator resumes it (→ backlog re-dispatches a fresh worker).
419
- */
420
- export async function handleStopBoardCard(args, cardId) {
421
- const { json, db } = args;
422
- try {
423
- const existing = getCard(db, cardId);
424
- if (!existing) {
425
- json({ error: `Card ${cardId} not found` }, 404);
426
- return true;
427
- }
428
- if (existing.status !== "in_progress") {
429
- json({ error: `Card ${cardId} is not running (status: ${existing.status})` }, 409);
430
- return true;
431
- }
432
- await releaseWorkerForCard(releaseDeps(args), existing.workerThreadId);
433
- const card = updateCard(db, cardId, {
434
- status: "blocked",
435
- blockedReason: "Stopped by operator",
436
- workerThreadId: null,
437
- nextSpawnAt: null,
438
- });
439
- json({ card });
440
- }
441
- catch (err) {
442
- json({ error: errorMessage(err) }, 500);
443
- }
444
- return true;
445
- }
446
- /** DELETE /api/board/cards/:id — remove the card. A live card's bound worker
447
- * is decommissioned first (R1/B8) so deleting it never orphans a running
448
- * worker that would push/report against a card that no longer exists. */
449
- export async function handleDeleteBoardCard(args, cardId) {
450
- const { json, db } = args;
451
- try {
452
- const existing = getCard(db, cardId);
453
- if (!existing) {
454
- json({ error: `Card ${cardId} not found` }, 404);
455
- return true;
456
- }
457
- if (existing.status === "in_progress") {
458
- await releaseWorkerForCard(releaseDeps(args), existing.workerThreadId);
459
- }
460
- if (!deleteCard(db, cardId)) {
461
- json({ error: `Card ${cardId} not found` }, 404);
462
- return true;
463
- }
464
- json({ ok: true });
465
- }
466
- catch (err) {
467
- json({ error: errorMessage(err) }, 500);
468
- }
469
- return true;
470
- }
471
- /** POST /api/board/cards/:id/comments — operator comment. */
472
- export async function handlePostBoardComment(args, cardId) {
473
- const { json, db } = args;
474
- try {
475
- const body = await parseJsonBody(args);
476
- const content = typeof body?.content === "string" ? body.content.trim() : "";
477
- if (!content) {
478
- json({ error: "'content' is required" }, 400);
479
- return true;
480
- }
481
- if (!getCard(db, cardId)) {
482
- json({ error: `Card ${cardId} not found` }, 404);
483
- return true;
484
- }
485
- json({ comment: addComment(db, cardId, "operator", content) }, 201);
486
- }
487
- catch (err) {
488
- json({ error: errorMessage(err) }, 500);
489
- }
490
- return true;
491
- }
492
- // ── Lanes ────────────────────────────────────────────────────────────────────
493
- /** GET /api/board/lanes → lanes decorated with thread names for the UI. */
494
- export const handleGetBoardLanes = ({ json, db }) => {
495
- try {
496
- const lanes = listLanes(db).map((lane) => ({
497
- ...lane,
498
- name: getThread(db, lane.threadId)?.name ?? `Thread ${lane.threadId}`,
499
- }));
500
- json({ lanes });
501
- }
502
- catch (err) {
503
- json({ error: errorMessage(err) }, 500);
504
- }
505
- return true;
506
- };
507
- /** POST /api/board/lanes — connect/disconnect a thread, set max_concurrent. */
508
- export const handlePostBoardLane = async (args) => {
509
- const { json, db } = args;
510
- try {
511
- const body = await parseJsonBody(args);
512
- const threadId = asInt(body?.threadId);
513
- if (threadId === undefined) {
514
- json({ error: "integer 'threadId' is required" }, 400);
515
- return true;
516
- }
517
- const laneError = validateLaneThread(db, threadId);
518
- if (laneError) {
519
- json({ error: laneError }, 400);
520
- return true;
521
- }
522
- // maxConcurrent must be a sane slot count: 0/negative makes a lane silently
523
- // never dispatch; an absurd value would let one lane monopolise the global
524
- // cap. Bound it to 1..global-cap.
525
- const maxConcurrent = asInt(body?.maxConcurrent);
526
- if (maxConcurrent !== undefined) {
527
- const maxAllowed = getMaxConcurrentThreads();
528
- if (maxConcurrent < 1 || maxConcurrent > maxAllowed) {
529
- json({ error: `'maxConcurrent' must be an integer between 1 and ${maxAllowed}` }, 400);
530
- return true;
531
- }
532
- }
533
- const lane = upsertLane(db, threadId, {
534
- ...(typeof body?.connected === "boolean" ? { connected: body.connected } : {}),
535
- ...(maxConcurrent !== undefined ? { maxConcurrent } : {}),
536
- });
537
- json({ lane });
538
- }
539
- catch (err) {
540
- json({ error: errorMessage(err) }, 500);
541
- }
542
- return true;
543
- };
544
- /** DELETE /api/board/lanes/:threadId */
545
- export function handleDeleteBoardLane(args, threadId) {
546
- const { json, db } = args;
547
- try {
548
- // No FK on board_cards.lane_thread_id (SQLite would need a table rebuild),
549
- // so guard here instead: removing a lane that still owns live cards would
550
- // orphan them invisibly. Archived cards don't block removal; use
551
- // "disconnect" to merely stop dispatch without removing the lane.
552
- const liveCards = listCards(db, { laneThreadId: threadId }).length;
553
- if (liveCards > 0) {
554
- json({
555
- error: `Lane ${threadId} still has ${liveCards} non-archived card(s) — move or archive them first, or disconnect the lane instead`,
556
- }, 409);
557
- return true;
558
- }
559
- if (removeLane(db, threadId))
560
- json({ ok: true });
561
- else
562
- json({ error: "Lane not found" }, 404);
563
- }
564
- catch (err) {
565
- json({ error: errorMessage(err) }, 500);
566
- }
567
- return true;
568
- }
569
- // ── Profile templates ────────────────────────────────────────────────────────
570
- /** GET /api/board/profiles → effective per-type templates (builtin ⊕ overrides). */
571
- export const handleGetBoardProfiles = ({ json }) => {
572
- try {
573
- json({ profiles: getProfileTemplates() });
574
- }
575
- catch (err) {
576
- json({ error: errorMessage(err) }, 500);
577
- }
578
- return true;
579
- };
580
- /** PUT /api/board/profiles — persist operator overrides wholesale. */
581
- export const handlePutBoardProfiles = async (args) => {
582
- const { json } = args;
583
- try {
584
- const body = await parseJsonBody(args);
585
- if (!body || Array.isArray(body)) {
586
- json({ error: "JSON body required ({ '<type>': {profile} })" }, 400);
587
- return true;
588
- }
589
- // Reject an empty {} that would erase every override (a fat-finger wipe).
590
- const types = Object.keys(body);
591
- if (types.length === 0) {
592
- json({ error: "Refusing to erase all profile overrides — send at least one per-type profile" }, 400);
593
- return true;
594
- }
595
- // Persist only well-formed per-type profiles: each value must be a plain
596
- // object whose keys are real ExecutionProfile fields with valid agent enums.
597
- for (const type of types) {
598
- const shapeError = validateProfileShape(body[type]);
599
- if (shapeError) {
600
- json({ error: `profile '${type}': ${shapeError}` }, 400);
601
- return true;
602
- }
603
- }
604
- saveProfileOverrides(body);
605
- json({ profiles: getProfileTemplates() });
606
- }
607
- catch (err) {
608
- json({ error: errorMessage(err) }, 500);
609
- }
610
- return true;
611
- };
612
- //# sourceMappingURL=board.js.map