@worca/app 1.0.0 → 1.2.0-rc.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 (143) hide show
  1. package/README.md +30 -9
  2. package/agents/clarify.meta.json +4 -4
  3. package/agents/decomposer.meta.json +5 -5
  4. package/agents/implementer.meta.json +15 -5
  5. package/agents/manualTestsChecklist.meta.json +5 -4
  6. package/agents/manualWebUiTesting.meta.json +9 -4
  7. package/agents/planReviewer.meta.json +12 -4
  8. package/agents/planner.meta.json +12 -5
  9. package/agents/refiner.meta.json +15 -4
  10. package/agents/reviewer.meta.json +14 -4
  11. package/agents/worca-cc-clarify.md +7 -0
  12. package/agents/worca-cc-code-reviewer.md +11 -6
  13. package/agents/worca-cc-decomposer.md +7 -0
  14. package/agents/worca-cc-implementer.md +9 -0
  15. package/agents/worca-cc-manual-tests-checklist.md +8 -5
  16. package/agents/worca-cc-manual-web-ui-testing.md +10 -6
  17. package/agents/worca-cc-plan-refiner.md +11 -6
  18. package/agents/worca-cc-plan-reviewer.md +10 -7
  19. package/agents/worca-cc-planner.md +9 -0
  20. package/agents/worca-cc-workspace-reviewer.md +11 -4
  21. package/agents/worca-cc-workspace-scanner.md +8 -4
  22. package/agents/workspaceReviewer.meta.json +15 -4
  23. package/agents/workspaceScanner.meta.json +5 -4
  24. package/package.json +8 -2
  25. package/skills/worca/SKILL.md +5 -5
  26. package/src/cli/render.mjs +148 -0
  27. package/src/cli/worca-cc.mjs +386 -56
  28. package/src/core/agent-gen.mjs +69 -31
  29. package/src/core/agent-registry.mjs +124 -144
  30. package/src/core/agent-store.mjs +164 -4
  31. package/src/core/artifacts.mjs +199 -23
  32. package/src/core/ask/attachment-kind.mjs +95 -0
  33. package/src/core/ask/catalog.mjs +111 -0
  34. package/src/core/ask/comment-deps.mjs +55 -0
  35. package/src/core/ask/events.mjs +545 -0
  36. package/src/core/ask/follow.mjs +113 -0
  37. package/src/core/ask/git-allowlist.mjs +226 -0
  38. package/src/core/ask/limits.mjs +57 -0
  39. package/src/core/ask/mcp-stdio.mjs +135 -0
  40. package/src/core/ask/models.mjs +125 -0
  41. package/src/core/ask/prompt.mjs +286 -0
  42. package/src/core/ask/proposal.mjs +170 -0
  43. package/src/core/ask/redact.mjs +30 -0
  44. package/src/core/ask/spawn.mjs +156 -0
  45. package/src/core/ask/store.mjs +438 -0
  46. package/src/core/ask/tool-deps.mjs +87 -0
  47. package/src/core/ask/tools.mjs +879 -0
  48. package/src/core/ask/turn.mjs +462 -0
  49. package/src/core/ask/worktree-deps.mjs +27 -0
  50. package/src/core/ask/worktrees.mjs +285 -0
  51. package/src/core/chat/command-router.mjs +28 -7
  52. package/src/core/chat/notifier.mjs +6 -1
  53. package/src/core/chat/renderers.mjs +15 -8
  54. package/src/core/claude-runner.mjs +541 -62
  55. package/src/core/config.mjs +310 -44
  56. package/src/core/cost-budget.mjs +29 -2
  57. package/src/core/db.mjs +773 -53
  58. package/src/core/diff-anchor.mjs +213 -0
  59. package/src/core/diff-comments.mjs +273 -0
  60. package/src/core/engine-select.mjs +32 -0
  61. package/src/core/failure-policy.mjs +201 -0
  62. package/src/core/git-info.mjs +49 -10
  63. package/src/core/graph/builtin-workflows.mjs +51 -0
  64. package/src/core/graph/executor.mjs +894 -0
  65. package/src/core/graph/registry-ports.mjs +12 -0
  66. package/src/core/graph/scheduler.mjs +1072 -0
  67. package/src/core/graph/seed-templates.mjs +318 -0
  68. package/src/core/host-guard.mjs +271 -0
  69. package/src/core/model-env.mjs +180 -8
  70. package/src/core/model-test.mjs +79 -0
  71. package/src/core/orchestrator.mjs +994 -4097
  72. package/src/core/overview-agent.mjs +15 -3
  73. package/src/core/phases.mjs +208 -537
  74. package/src/core/pipeline-delete.mjs +13 -2
  75. package/src/core/plugin-api.mjs +8 -3
  76. package/src/core/plugin-config.mjs +178 -28
  77. package/src/core/plugin-inventory.mjs +6 -2
  78. package/src/core/plugin-manifest.mjs +199 -11
  79. package/src/core/plugin-models.mjs +1 -0
  80. package/src/core/plugin-repo.mjs +16 -4
  81. package/src/core/plugin-shim-child.mjs +9 -3
  82. package/src/core/plugin-shim.mjs +80 -17
  83. package/src/core/plugin-store.mjs +236 -29
  84. package/src/core/plugin-workflows.mjs +90 -41
  85. package/src/core/preflight.mjs +135 -3
  86. package/src/core/projects.mjs +7 -5
  87. package/src/core/protocol.mjs +8 -35
  88. package/src/core/recoverable-error.mjs +1 -1
  89. package/src/core/run-harness.mjs +3934 -0
  90. package/src/core/run-manifest.mjs +5 -1
  91. package/src/core/settings.mjs +184 -13
  92. package/src/core/skills.mjs +10 -3
  93. package/src/core/source-bindings.mjs +175 -0
  94. package/src/core/sources.mjs +87 -25
  95. package/src/core/stats.mjs +25 -6
  96. package/src/core/title.mjs +51 -4
  97. package/src/core/workflows.mjs +358 -259
  98. package/src/core/workspace-scan.mjs +4 -0
  99. package/src/core/worktree.mjs +98 -7
  100. package/src/shared/graph/agent-meta.mjs +278 -0
  101. package/src/shared/graph/constants.mjs +105 -0
  102. package/src/shared/graph/geometry.mjs +157 -0
  103. package/src/shared/graph/layout.mjs +134 -0
  104. package/src/shared/graph/loops.mjs +130 -0
  105. package/src/shared/graph/manifest.mjs +257 -0
  106. package/src/shared/graph/ports.mjs +153 -0
  107. package/src/shared/graph/route.mjs +397 -0
  108. package/src/shared/graph/template.mjs +165 -0
  109. package/src/shared/graph/thumbnail.mjs +67 -0
  110. package/src/shared/graph/validate.mjs +491 -0
  111. package/src/shared/graph/verdict.mjs +41 -0
  112. package/ui/public/app.js +4240 -1682
  113. package/ui/public/ask-markdown.mjs +145 -0
  114. package/ui/public/ask-model.mjs +317 -0
  115. package/ui/public/ask-panel.mjs +2129 -0
  116. package/ui/public/chat-settings-view.mjs +6 -2
  117. package/ui/public/diff-view.mjs +66 -11
  118. package/ui/public/file-tree.mjs +305 -0
  119. package/ui/public/graph/composer.mjs +889 -0
  120. package/ui/public/graph/inspector.mjs +183 -0
  121. package/ui/public/graph/model.mjs +37 -0
  122. package/ui/public/graph/palette.mjs +144 -0
  123. package/ui/public/graph/run-decor.mjs +410 -0
  124. package/ui/public/graph/run-hosts.mjs +201 -0
  125. package/ui/public/graph/save-dialog.mjs +56 -0
  126. package/ui/public/graph/view.mjs +858 -0
  127. package/ui/public/guardrails-view.mjs +4 -2
  128. package/ui/public/hljs-loader.mjs +180 -0
  129. package/ui/public/index.html +311 -265
  130. package/ui/public/log-filter.mjs +22 -4
  131. package/ui/public/log-line.mjs +45 -19
  132. package/ui/public/models-view.mjs +171 -9
  133. package/ui/public/plugins-view.mjs +106 -4
  134. package/ui/public/source-pane.mjs +190 -8
  135. package/ui/public/stats-view.mjs +81 -1
  136. package/ui/public/style.css +1487 -229
  137. package/ui/public/syntax-highlight.mjs +270 -0
  138. package/ui/public/thinking-orb.mjs +110 -0
  139. package/ui/server.mjs +1894 -104
  140. package/src/core/channels.mjs +0 -302
  141. package/src/core/runners.mjs +0 -167
  142. package/src/core/workflow-validator.mjs +0 -185
  143. package/ui/public/composer-core.mjs +0 -211
@@ -0,0 +1,438 @@
1
+ // Persistence for the Ask Worca chat (ask-worca-design.md §7): ask_threads,
2
+ // ask_messages, ask_attachments, ask_run_links over db.mjs. Everything here is
3
+ // SYNCHRONOUS (node:sqlite) and goes through getDb()/prepare()/tx() — never
4
+ // node:sqlite directly. tx() is NOT re-entrant (db.mjs:897): the server must never
5
+ // call a writer from inside its own tx(). Attachment bodies live on disk under
6
+ // <worcaHome>/ask/<threadId>/att/<attachmentId><ext> — the path is built from the
7
+ // ROW ID plus the row's kind/mime (attachment-kind.mjs), never from the
8
+ // user-supplied name. Text kinds stay `.txt`/utf8; binary kinds (#398) keep the
9
+ // extension of their SNIFFED mime and raw bytes.
10
+ import { randomBytes } from 'node:crypto';
11
+ import { mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from 'node:fs';
12
+ import { basename, join } from 'node:path';
13
+ import { getDb, prepare, tx } from '../db.mjs';
14
+ import { worcaHome } from '../projects.mjs';
15
+ import { extensionForAttachment } from './attachment-kind.mjs';
16
+
17
+ export const ASK_ID_RE = /^[a-z]+_[0-9a-f]{8}$/;
18
+ const ROLES = new Set(['user', 'assistant', 'system']);
19
+
20
+ /** `<prefix>_<8 hex>` — prefixes: ask (thread), askm (message), att, card. */
21
+ export function newAskId(prefix) { return `${prefix}_${randomBytes(4).toString('hex')}`; }
22
+ /** New top-level root next to store/ — attachment bodies only (docs/storage.md). */
23
+ export function askRoot() { return join(worcaHome(), 'ask'); }
24
+ /**
25
+ * `<askRoot>/<thread>/att`. The id is the only thing between this path and the rest
26
+ * of the disk, so it is shape-checked here rather than at each caller: a thread row
27
+ * the store never minted (raw SQL, a foreign writer, a future import) with id `..`
28
+ * aimed the write at <home>/ask/att and `../../etc` outside the worca home entirely.
29
+ * Throws rather than returning null so no caller can build a path from the failure.
30
+ */
31
+ export function attachmentsDir(threadId) {
32
+ if (typeof threadId !== 'string' || !ASK_ID_RE.test(threadId)) throw new Error('attachmentsDir: refusing a thread id the store never minted');
33
+ return join(askRoot(), threadId, 'att');
34
+ }
35
+
36
+ const now = () => new Date().toISOString();
37
+ const parse = (v, fallback) => { if (v == null) return fallback; try { return JSON.parse(v); } catch { return fallback; } };
38
+ const str = (v) => (v === undefined || v === null ? null : JSON.stringify(v));
39
+ const emptyTotals = () => ({ costUsd: 0, input: 0, output: 0, cacheRead: 0, cacheCreation: 0, turns: 0, agents: 0 });
40
+ const round6 = (n) => Math.round(n * 1e6) / 1e6;
41
+
42
+ function rowToThread(r) {
43
+ return {
44
+ id: r.id, title: r.title ?? null, createdAt: r.created_at, updatedAt: r.updated_at,
45
+ model: r.model ?? null, effort: r.effort ?? null, sessionId: r.session_id ?? null,
46
+ context: parse(r.context, null),
47
+ totals: { ...emptyTotals(), ...(parse(r.totals, {}) || {}) },
48
+ };
49
+ }
50
+ function rowToMessage(r) {
51
+ return {
52
+ id: r.id, threadId: r.thread_id, seq: r.seq, role: r.role, text: r.text ?? '',
53
+ blocks: parse(r.blocks, null), status: r.status ?? null, reason: r.reason ?? null,
54
+ model: r.model ?? null, effort: r.effort ?? null, usage: parse(r.usage, null),
55
+ costUsd: r.cost_usd ?? null, durationMs: r.duration_ms ?? null, createdAt: r.created_at,
56
+ };
57
+ }
58
+ function rowToAttachment(r) {
59
+ return {
60
+ id: r.id, threadId: r.thread_id, messageId: r.message_id ?? null, name: r.name, bytes: r.bytes,
61
+ kind: r.kind ?? 'text', mime: r.mime ?? null, // pre-v27 rows carry neither column value: they are text
62
+ createdAt: r.created_at,
63
+ };
64
+ }
65
+ function rowToRunLink(r) {
66
+ return {
67
+ threadId: r.thread_id, runId: r.run_id, pipelineId: r.pipeline_id ?? null, cardId: r.card_id ?? null,
68
+ status: r.status ?? null, phase: r.phase ?? null,
69
+ commentIds: parse(r.comment_ids, null) || [], // v22: diff comments this run addresses
70
+ createdAt: r.created_at,
71
+ };
72
+ }
73
+
74
+ // ── threads ─────────────────────────────────────────────────────────────────
75
+
76
+ export function createThread({ title = null, model = null, effort = null } = {}) {
77
+ getDb();
78
+ const id = newAskId('ask');
79
+ const t = now();
80
+ prepare('INSERT INTO ask_threads (id, title, created_at, updated_at, model, effort, totals) VALUES (?, ?, ?, ?, ?, ?, ?)')
81
+ .run(id, title, t, t, model, effort, JSON.stringify(emptyTotals()));
82
+ return getThread(id);
83
+ }
84
+
85
+ export function getThread(id) {
86
+ getDb();
87
+ const r = prepare('SELECT * FROM ask_threads WHERE id = ?').get(id);
88
+ return r ? rowToThread(r) : null;
89
+ }
90
+
91
+ export function listThreads({ limit = 50 } = {}) {
92
+ getDb();
93
+ const n = Number.isInteger(limit) && limit > 0 ? limit : 50;
94
+ const rows = prepare(`
95
+ SELECT t.*, (SELECT count(*) FROM ask_run_links l WHERE l.thread_id = t.id) AS run_links,
96
+ (SELECT count(*) FROM ask_worktrees w WHERE w.thread_id = t.id) AS worktrees
97
+ FROM ask_threads t ORDER BY t.updated_at DESC, t.id LIMIT ?
98
+ `).all(n);
99
+ return rows.map((r) => ({ ...rowToThread(r), runLinks: r.run_links, worktrees: r.worktrees }));
100
+ }
101
+
102
+ /** Total saved chats — the History popover shows this, not the capped page listThreads returns. */
103
+ export function countThreads() {
104
+ getDb();
105
+ const row = prepare('SELECT count(*) AS n FROM ask_threads').get();
106
+ return row ? Number(row.n) : 0;
107
+ }
108
+
109
+ /** Every thread id, oldest-updated first, NO limit — the bulk delete walks all of them. */
110
+ export function listThreadIds() {
111
+ getDb();
112
+ return prepare('SELECT id FROM ask_threads ORDER BY updated_at, id').all().map((r) => r.id);
113
+ }
114
+
115
+ /** Global ask_worktrees row count (the per-thread count rides listThreads rows). */
116
+ export function countWorktrees() {
117
+ getDb();
118
+ const row = prepare('SELECT count(*) AS n FROM ask_worktrees').get();
119
+ return row ? Number(row.n) : 0;
120
+ }
121
+
122
+ /** Global ask_attachments row count. */
123
+ export function countAttachments() {
124
+ getDb();
125
+ const row = prepare('SELECT count(*) AS n FROM ask_attachments').get();
126
+ return row ? Number(row.n) : 0;
127
+ }
128
+
129
+ const THREAD_PATCH_COLS = { title: 'title', model: 'model', effort: 'effort', sessionId: 'session_id', context: 'context' };
130
+
131
+ /** Patch ⊆ {title, model, effort, sessionId, context}; unknown keys ignored; always bumps updated_at. */
132
+ export function updateThread(id, patch = {}) {
133
+ const db = getDb();
134
+ const sets = [];
135
+ const vals = [];
136
+ for (const [k, col] of Object.entries(THREAD_PATCH_COLS)) {
137
+ if (!Object.prototype.hasOwnProperty.call(patch, k)) continue;
138
+ sets.push(`${col} = ?`);
139
+ vals.push(k === 'context' ? str(patch[k]) : (patch[k] ?? null));
140
+ }
141
+ sets.push('updated_at = ?');
142
+ vals.push(now(), id);
143
+ const info = db.prepare(`UPDATE ask_threads SET ${sets.join(', ')} WHERE id = ?`).run(...vals);
144
+ return info.changes ? getThread(id) : null;
145
+ }
146
+
147
+ /** D13: `onlyIf` = replace the title only while it still IS that value (the user may have renamed it). */
148
+ export function setThreadTitle(id, title, { onlyIf } = {}) {
149
+ getDb();
150
+ const info = onlyIf === undefined
151
+ ? prepare('UPDATE ask_threads SET title = ?, updated_at = ? WHERE id = ?').run(title, now(), id)
152
+ : prepare('UPDATE ask_threads SET title = ?, updated_at = ? WHERE id = ? AND title IS ?').run(title, now(), id, onlyIf);
153
+ return info.changes > 0;
154
+ }
155
+
156
+ /** Every turn — done, stopped or error — adds to the thread totals; a null cost adds 0 but counts the turn. */
157
+ export function addThreadTotals(id, { costUsd = null, usage = null, agents = 0 } = {}) {
158
+ return tx(() => {
159
+ const row = prepare('SELECT totals FROM ask_threads WHERE id = ?').get(id);
160
+ if (!row) return null;
161
+ const t = { ...emptyTotals(), ...(parse(row.totals, {}) || {}) };
162
+ t.costUsd = round6(t.costUsd + (typeof costUsd === 'number' && Number.isFinite(costUsd) ? costUsd : 0));
163
+ for (const k of ['input', 'output', 'cacheRead', 'cacheCreation']) t[k] += Number(usage?.[k]) || 0;
164
+ if (Number.isFinite(usage?.ctx)) t.ctx = usage.ctx; // context fill: the turn's last per-call figure REPLACES (never sums)
165
+ t.turns += 1;
166
+ t.agents += Number.isInteger(agents) && agents > 0 ? agents : 0;
167
+ prepare('UPDATE ask_threads SET totals = ?, updated_at = ? WHERE id = ?').run(JSON.stringify(t), now(), id);
168
+ return t;
169
+ });
170
+ }
171
+
172
+ /**
173
+ * Row delete (cascades to messages/attachments/links) then rm -rf of the attachment
174
+ * root (spec §7.5). The id is validated FIRST because the rm path is built from it:
175
+ * a row this store never minted (raw SQL, a foreign writer, a future import) with id
176
+ * `..` would aim the rmSync at the whole worca home, and sweepEmptyThreads runs it
177
+ * unattended at boot. "The DELETE matched" is not a shape check.
178
+ */
179
+ export function deleteThread(id) {
180
+ if (typeof id !== 'string' || !ASK_ID_RE.test(id)) return false;
181
+ const removed = tx(() => {
182
+ // ask_card_comments is keyed by card id, and card ids live inside message
183
+ // blocks (JSON) — no FK can cascade them, so a deleted thread used to leave
184
+ // its proposals' pending comment ids behind for ever (review of PR #376).
185
+ prepare(`DELETE FROM ask_card_comments WHERE card_id IN (
186
+ SELECT json_extract(b.value, '$.id') FROM ask_messages m, json_each(m.blocks) b
187
+ WHERE m.thread_id = ? AND json_valid(m.blocks) AND json_extract(b.value, '$.kind') = 'card')`).run(id);
188
+ return prepare('DELETE FROM ask_threads WHERE id = ?').run(id).changes > 0;
189
+ });
190
+ if (removed) rmSync(join(askRoot(), id), { recursive: true, force: true });
191
+ return removed;
192
+ }
193
+
194
+ /** Boot sweep (spec §6.2.1): threads that never received a message and are older than the cutoff. */
195
+ export function sweepEmptyThreads({ olderThanMs = 24 * 60 * 60 * 1000, now: nowMs = Date.now() } = {}) {
196
+ getDb();
197
+ const cutoff = new Date(nowMs - olderThanMs).toISOString();
198
+ const ids = prepare(`
199
+ SELECT t.id FROM ask_threads t
200
+ WHERE t.created_at < ? AND NOT EXISTS (SELECT 1 FROM ask_messages m WHERE m.thread_id = t.id)
201
+ `).all(cutoff).map((r) => r.id);
202
+ let removed = 0;
203
+ for (const id of ids) if (deleteThread(id)) removed += 1; // a row deleteThread refuses is not counted as swept
204
+ return removed;
205
+ }
206
+
207
+ // ── messages ────────────────────────────────────────────────────────────────
208
+
209
+ /** seq = MAX(seq)+1 inside tx(): follower notices interleave with turns (spec §7.1). */
210
+ export function appendMessage(threadId, { role, text = '', blocks = null, status = null, model = null, effort = null } = {}) {
211
+ if (!ROLES.has(role)) throw new Error(`appendMessage: invalid role ${JSON.stringify(role)}`);
212
+ return tx(() => {
213
+ if (!prepare('SELECT 1 FROM ask_threads WHERE id = ?').get(threadId)) {
214
+ throw new Error(`appendMessage: unknown thread ${threadId}`);
215
+ }
216
+ const { next } = prepare('SELECT COALESCE(MAX(seq), 0) + 1 AS next FROM ask_messages WHERE thread_id = ?').get(threadId);
217
+ const id = newAskId('askm');
218
+ const t = now();
219
+ prepare(`INSERT INTO ask_messages (id, thread_id, seq, role, text, blocks, status, model, effort, created_at)
220
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
221
+ .run(id, threadId, next, role, String(text ?? ''), str(blocks), status, model, effort, t);
222
+ prepare('UPDATE ask_threads SET updated_at = ? WHERE id = ?').run(t, threadId);
223
+ return getMessage(id);
224
+ });
225
+ }
226
+
227
+ export function getMessage(id) {
228
+ getDb();
229
+ const r = prepare('SELECT * FROM ask_messages WHERE id = ?').get(id);
230
+ return r ? rowToMessage(r) : null;
231
+ }
232
+
233
+ export function listMessages(threadId) {
234
+ getDb();
235
+ return prepare('SELECT * FROM ask_messages WHERE thread_id = ? ORDER BY seq').all(threadId).map(rowToMessage);
236
+ }
237
+
238
+ export function finishMessage(id, { text, blocks, status, reason = null, usage = null, costUsd = null, durationMs = null } = {}) {
239
+ getDb();
240
+ const info = prepare(`UPDATE ask_messages SET text = ?, blocks = ?, status = ?, reason = ?, usage = ?, cost_usd = ?, duration_ms = ?
241
+ WHERE id = ?`)
242
+ .run(String(text ?? ''), str(blocks), status ?? null, reason, str(usage), costUsd, durationMs, id);
243
+ if (!info.changes) return null;
244
+ const m = getMessage(id);
245
+ prepare('UPDATE ask_threads SET updated_at = ? WHERE id = ?').run(now(), m.threadId);
246
+ return m;
247
+ }
248
+
249
+ export function setMessageBlocks(id, blocks) {
250
+ getDb();
251
+ const info = prepare('UPDATE ask_messages SET blocks = ? WHERE id = ?').run(str(blocks), id);
252
+ return info.changes ? getMessage(id) : null;
253
+ }
254
+
255
+ export function findCard(threadId, cardId) {
256
+ for (const message of listMessages(threadId)) {
257
+ if (!Array.isArray(message.blocks)) continue; // a non-array JSON value parses truthy; skip it rather than throw
258
+ const block = message.blocks.find((b) => b && b.kind === 'card' && b.id === cardId);
259
+ if (block) return { message, block };
260
+ }
261
+ return null;
262
+ }
263
+
264
+ const CARD_PATCH_KEYS = ['state', 'runId', 'error'];
265
+
266
+ /** Patch ⊆ {state, runId, error} on one card block; the 'proposed' precondition is the caller's (route) business. */
267
+ export function updateCardBlock(threadId, cardId, patch = {}) {
268
+ return tx(() => {
269
+ const found = findCard(threadId, cardId);
270
+ if (!found) return null;
271
+ const allowed = {};
272
+ for (const k of CARD_PATCH_KEYS) if (Object.prototype.hasOwnProperty.call(patch, k)) allowed[k] = patch[k];
273
+ const blocks = found.message.blocks.map((b) => (b && b.kind === 'card' && b.id === cardId ? { ...b, ...allowed } : b));
274
+ prepare('UPDATE ask_messages SET blocks = ? WHERE id = ?').run(JSON.stringify(blocks), found.message.id);
275
+ return blocks.find((b) => b && b.kind === 'card' && b.id === cardId);
276
+ });
277
+ }
278
+
279
+ /** Boot sweep (spec §6.2): a turn the previous server process never finished. */
280
+ export function sweepStreamingMessages({ text = 'interrupted by restart' } = {}) {
281
+ return tx(() => {
282
+ const rows = prepare("SELECT id, blocks FROM ask_messages WHERE status = 'streaming'").all();
283
+ for (const r of rows) {
284
+ // The whole sweep is ONE tx(): a TypeError on a single poisoned row would roll
285
+ // back every other row's fix, and would do so again on every later boot.
286
+ const prev = parse(r.blocks, []);
287
+ const blocks = Array.isArray(prev) ? prev : [];
288
+ blocks.push({ kind: 'notice', text });
289
+ prepare("UPDATE ask_messages SET status = 'error', blocks = ? WHERE id = ?").run(JSON.stringify(blocks), r.id);
290
+ }
291
+ return rows.length;
292
+ });
293
+ }
294
+
295
+ // ── attachments ─────────────────────────────────────────────────────────────
296
+
297
+ /**
298
+ * Text kinds pass `{name, text}` (the pre-#398 signature, kind defaults 'text');
299
+ * binary kinds pass `{name, kind, mime, data}` with a Buffer that has already
300
+ * been sniffed by the route (attachment-kind.mjs) — the store trusts kind/mime
301
+ * only to pick the on-disk extension, never to build a path from `name`.
302
+ * NOTE (#398, issue point 8): binary bodies are raw pixel/PDF bytes — the
303
+ * redactAskText guard that runs over text attachment content structurally
304
+ * cannot apply to them; the model reads them via its Read tool as-is.
305
+ */
306
+ export function addAttachment(threadId, messageId, { name, text, kind = 'text', mime = null, data = null } = {}) {
307
+ getDb();
308
+ if (!prepare('SELECT 1 FROM ask_threads WHERE id = ?').get(threadId)) {
309
+ throw new Error(`addAttachment: unknown thread ${threadId}`);
310
+ }
311
+ const id = newAskId('att');
312
+ const safeName = (basename(String(name ?? '')).slice(0, 255)) || 'attachment.txt';
313
+ const dir = attachmentsDir(threadId);
314
+ mkdirSync(dir, { recursive: true });
315
+ let bytes;
316
+ if (kind === 'text') {
317
+ const body = String(text ?? '');
318
+ bytes = Buffer.byteLength(body, 'utf8');
319
+ writeFileSync(join(dir, `${id}.txt`), body, 'utf8'); // file FIRST: a row without a file would 404 on read
320
+ } else {
321
+ if (!Buffer.isBuffer(data)) throw new Error('addAttachment: a non-text attachment needs a Buffer body');
322
+ bytes = data.length;
323
+ writeFileSync(join(dir, `${id}${extensionForAttachment(kind, mime)}`), data); // no encoding: raw bytes
324
+ }
325
+ prepare('INSERT INTO ask_attachments (id, thread_id, message_id, name, bytes, kind, mime, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)')
326
+ .run(id, threadId, messageId ?? null, safeName, bytes, kind, mime, now());
327
+ return getAttachment(threadId, id);
328
+ }
329
+
330
+ export function listAttachments(threadId) {
331
+ getDb();
332
+ return prepare('SELECT * FROM ask_attachments WHERE thread_id = ? ORDER BY created_at, id').all(threadId).map(rowToAttachment);
333
+ }
334
+
335
+ export function getAttachment(threadId, id) {
336
+ getDb();
337
+ const r = prepare('SELECT * FROM ask_attachments WHERE thread_id = ? AND id = ?').get(threadId, id);
338
+ return r ? rowToAttachment(r) : null;
339
+ }
340
+
341
+ /**
342
+ * Thread-scoped read; the file path comes from the row id, never from `name` — and
343
+ * the row is not proof of the id's shape, so `a.id` is checked too: an
344
+ * `ask_attachments` row this store never minted, hung off a legitimate thread,
345
+ * otherwise read any file on disk (`../../../../etc/hosts`). Both failures are the
346
+ * same `null` a missing row returns; the caller (tool-deps.mjs) turns it into a
347
+ * not-found, and a reader must not throw where a 404 is the answer.
348
+ */
349
+ export function readAttachmentText(threadId, id) {
350
+ const a = getAttachment(threadId, id);
351
+ if (!a || !ASK_ID_RE.test(a.id) || a.kind !== 'text') return null; // a binary body is not utf8-readable
352
+ try {
353
+ return { ...a, text: readFileSync(join(attachmentsDir(threadId), `${a.id}.txt`), 'utf8') };
354
+ } catch {
355
+ return null;
356
+ }
357
+ }
358
+
359
+ /**
360
+ * Absolute on-disk path of an attachment's body — the pointer the read_attachment
361
+ * tool hands the model for binary kinds (its Read tool renders images and PDFs
362
+ * natively; the ask/ subtree is deliberately outside spawn.mjs ASK_DENY_RULES).
363
+ * Same guards as readAttachmentText: row must exist, id must be store-minted —
364
+ * and the body must actually be on disk. A row that outlived its file (DB-only
365
+ * restore, an external sweep of ask/<thread>/att) is the same `null` as a
366
+ * missing row: the model must never be handed a path whose Read fails ENOENT.
367
+ */
368
+ export function attachmentPath(threadId, id) {
369
+ const a = getAttachment(threadId, id);
370
+ if (!a || !ASK_ID_RE.test(a.id)) return null;
371
+ const path = join(attachmentsDir(threadId), `${a.id}${extensionForAttachment(a.kind, a.mime)}`);
372
+ return existsSync(path) ? path : null;
373
+ }
374
+
375
+ /** Raw thread-scoped read for the download route: any kind, body as a Buffer. */
376
+ export function readAttachmentRaw(threadId, id) {
377
+ const a = getAttachment(threadId, id);
378
+ const path = attachmentPath(threadId, id);
379
+ if (!a || !path) return null;
380
+ try {
381
+ return { ...a, buffer: readFileSync(path) };
382
+ } catch {
383
+ return null;
384
+ }
385
+ }
386
+
387
+ export function threadAttachmentBytes(threadId) {
388
+ getDb();
389
+ return prepare('SELECT COALESCE(SUM(bytes), 0) AS n FROM ask_attachments WHERE thread_id = ?').get(threadId).n;
390
+ }
391
+
392
+ // ── run links ───────────────────────────────────────────────────────────────
393
+
394
+ export function linkRun(threadId, { runId, cardId = null, pipelineId = null, status = null, phase = null } = {}) {
395
+ getDb();
396
+ prepare('INSERT INTO ask_run_links (thread_id, run_id, pipeline_id, card_id, status, phase, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)')
397
+ .run(threadId, runId, pipelineId, cardId, status, phase, now());
398
+ return getRunLink(threadId, runId);
399
+ }
400
+
401
+ function getRunLink(threadId, runId) {
402
+ const r = prepare('SELECT * FROM ask_run_links WHERE thread_id = ? AND run_id = ?').get(threadId, runId);
403
+ return r ? rowToRunLink(r) : null;
404
+ }
405
+
406
+ // `runId` is patchable so a RESUMED run (a new runs-Map id for the same pipeline)
407
+ // can take over its link row instead of leaving it on the dead lineage.
408
+ const LINK_PATCH_COLS = { runId: 'run_id', pipelineId: 'pipeline_id', status: 'status', phase: 'phase', commentIds: 'comment_ids' };
409
+
410
+ export function updateRunLink(threadId, runId, patch = {}) {
411
+ const db = getDb();
412
+ const sets = [];
413
+ const vals = [];
414
+ for (const [k, col] of Object.entries(LINK_PATCH_COLS)) {
415
+ if (!Object.prototype.hasOwnProperty.call(patch, k)) continue;
416
+ sets.push(`${col} = ?`);
417
+ // comment_ids is the one JSON column here; every other patch key is a scalar.
418
+ // An empty array stores NULL so "no pending comments" has exactly one encoding.
419
+ vals.push(k === 'commentIds' ? (Array.isArray(patch[k]) && patch[k].length ? str(patch[k]) : null) : (patch[k] ?? null));
420
+ }
421
+ if (!sets.length) return getRunLink(threadId, runId);
422
+ vals.push(threadId, runId);
423
+ const info = db.prepare(`UPDATE ask_run_links SET ${sets.join(', ')} WHERE thread_id = ? AND run_id = ?`).run(...vals);
424
+ return info.changes ? getRunLink(threadId, Object.prototype.hasOwnProperty.call(patch, 'runId') ? patch.runId : runId) : null;
425
+ }
426
+
427
+ /** Every link row (any thread) pointing at a History pipeline id — what resumeRun
428
+ * re-attaches followers for. */
429
+ export function findRunLinksByPipeline(pipelineId) {
430
+ getDb();
431
+ if (typeof pipelineId !== 'string' || !pipelineId) return [];
432
+ return prepare('SELECT * FROM ask_run_links WHERE pipeline_id = ? ORDER BY created_at DESC, run_id').all(pipelineId).map(rowToRunLink);
433
+ }
434
+
435
+ export function listRunLinks(threadId) {
436
+ getDb();
437
+ return prepare('SELECT * FROM ask_run_links WHERE thread_id = ? ORDER BY created_at DESC, run_id').all(threadId).map(rowToRunLink);
438
+ }
@@ -0,0 +1,87 @@
1
+ // src/core/ask/tool-deps.mjs
2
+ // The REAL reader bundle for tools.mjs. tools.mjs itself must not import db.mjs
3
+ // (its source is scanned for writes); everything that opens the DB or the store
4
+ // is wired here and injected. Used by mcp-stdio.mjs (the child) and by tests.
5
+ import { readFile, access } from 'node:fs/promises';
6
+ import { join } from 'node:path';
7
+ import {
8
+ listAllPipelines, lookupPipelineRow, findPipelineRowById, totalsFor, readStoreMeta, runDirForRow,
9
+ } from '../artifacts.mjs';
10
+ import { DIFF_PATCH_FILE } from '../results.mjs';
11
+ import { GUARDRAIL_PRESETS } from '../guardrails.mjs';
12
+ import { buildCatalog } from './catalog.mjs';
13
+ import { validateProposal } from './proposal.mjs';
14
+ import { readAttachmentText, getAttachment, attachmentPath, getThread } from './store.mjs';
15
+ import { redactAskText } from './redact.mjs';
16
+ import { ASK_LIMITS } from './limits.mjs';
17
+
18
+ /** The patch file of a run row, or null when there is none (results.mjs#DIFF_PATCH_FILE only — never a caller path). */
19
+ export async function readDiffPatch(row) {
20
+ try {
21
+ const dir = await runDirForRow(row);
22
+ return await readFile(join(dir, DIFF_PATCH_FILE), 'utf8');
23
+ } catch {
24
+ return null;
25
+ }
26
+ }
27
+
28
+ export async function hasDiffPatch(row) {
29
+ try {
30
+ const dir = await runDirForRow(row);
31
+ await access(join(dir, DIFF_PATCH_FILE));
32
+ return true;
33
+ } catch {
34
+ return false;
35
+ }
36
+ }
37
+
38
+ /**
39
+ * @param {{threadId:string}} opts attachments are readable only for this thread (spec §6.4 read_attachment)
40
+ */
41
+ export function defaultToolDeps({ threadId }) {
42
+ return {
43
+ buildCatalog,
44
+ listAllPipelines,
45
+ lookupPipelineRow,
46
+ findPipelineRowById,
47
+ totalsFor,
48
+ readStoreMeta,
49
+ readDiffPatch,
50
+ hasDiffPatch,
51
+ readAttachment: (id) => {
52
+ const row = threadId ? getAttachment(threadId, id) : null;
53
+ if (!row) return null;
54
+ if (row.kind === 'text') {
55
+ const a = readAttachmentText(threadId, id);
56
+ return a ? { name: a.name, kind: 'text', text: a.text } : null;
57
+ }
58
+ // Binary kinds (#398): metadata plus the on-disk path — the model views the
59
+ // body with its own Read tool; sliceBytes over raw bytes would be garbage.
60
+ // attachmentPath is null when the body is gone (DB-only restore, an external
61
+ // sweep of ask/<t>/att): the same not-found the text branch reports, never a
62
+ // path whose Read then fails with a raw ENOENT the model may retry.
63
+ const path = attachmentPath(threadId, id);
64
+ return path ? { name: row.name, kind: row.kind, mime: row.mime, bytes: row.bytes, path } : null;
65
+ },
66
+ validateProposal,
67
+ // #397: the user-pinned scope of the owning thread — {projectKey}|{workspaceId}|
68
+ // null — read fresh from the thread row per call, so a selector change lands on
69
+ // the very next tool call. A missing thread or an unreadable DB means "nothing
70
+ // pinned", never an error.
71
+ pinnedScope: () => {
72
+ if (!threadId) return null;
73
+ let c = null;
74
+ try { c = getThread(threadId)?.context ?? null; } catch { return null; }
75
+ if (!c || c.pinned !== true) return null;
76
+ if (typeof c.projectKey === 'string' && c.projectKey) return { projectKey: c.projectKey };
77
+ if (typeof c.workspaceId === 'string' && c.workspaceId) return { workspaceId: c.workspaceId };
78
+ return null;
79
+ },
80
+ // The SECURE preset is the floor, not the run's own set: guardrailsId defaults
81
+ // to 'permissive' (empty protectedPaths), so resolving per row would show the
82
+ // model every credential file on most runs. This only ever omits more.
83
+ protectedPaths: [...GUARDRAIL_PRESETS.secure.protectedPaths],
84
+ redact: redactAskText,
85
+ limits: ASK_LIMITS,
86
+ };
87
+ }