@worca/app 1.0.0 → 1.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 (138) hide show
  1. package/README.md +22 -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 +319 -45
  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 +189 -21
  32. package/src/core/ask/catalog.mjs +111 -0
  33. package/src/core/ask/comment-deps.mjs +55 -0
  34. package/src/core/ask/events.mjs +506 -0
  35. package/src/core/ask/follow.mjs +107 -0
  36. package/src/core/ask/git-allowlist.mjs +226 -0
  37. package/src/core/ask/limits.mjs +54 -0
  38. package/src/core/ask/mcp-stdio.mjs +135 -0
  39. package/src/core/ask/models.mjs +125 -0
  40. package/src/core/ask/prompt.mjs +261 -0
  41. package/src/core/ask/proposal.mjs +170 -0
  42. package/src/core/ask/redact.mjs +30 -0
  43. package/src/core/ask/spawn.mjs +153 -0
  44. package/src/core/ask/store.mjs +360 -0
  45. package/src/core/ask/tool-deps.mjs +63 -0
  46. package/src/core/ask/tools.mjs +848 -0
  47. package/src/core/ask/turn.mjs +416 -0
  48. package/src/core/ask/worktree-deps.mjs +27 -0
  49. package/src/core/ask/worktrees.mjs +285 -0
  50. package/src/core/chat/command-router.mjs +20 -3
  51. package/src/core/claude-runner.mjs +434 -57
  52. package/src/core/config.mjs +264 -41
  53. package/src/core/cost-budget.mjs +29 -2
  54. package/src/core/db.mjs +684 -47
  55. package/src/core/diff-anchor.mjs +213 -0
  56. package/src/core/diff-comments.mjs +273 -0
  57. package/src/core/engine-select.mjs +32 -0
  58. package/src/core/git-info.mjs +49 -10
  59. package/src/core/graph/builtin-workflows.mjs +51 -0
  60. package/src/core/graph/executor.mjs +894 -0
  61. package/src/core/graph/registry-ports.mjs +12 -0
  62. package/src/core/graph/scheduler.mjs +1065 -0
  63. package/src/core/graph/seed-templates.mjs +318 -0
  64. package/src/core/model-env.mjs +112 -8
  65. package/src/core/model-test.mjs +79 -0
  66. package/src/core/orchestrator.mjs +902 -4098
  67. package/src/core/overview-agent.mjs +15 -3
  68. package/src/core/phases.mjs +208 -537
  69. package/src/core/pipeline-delete.mjs +13 -2
  70. package/src/core/plugin-api.mjs +8 -3
  71. package/src/core/plugin-config.mjs +178 -28
  72. package/src/core/plugin-inventory.mjs +6 -2
  73. package/src/core/plugin-manifest.mjs +199 -11
  74. package/src/core/plugin-models.mjs +1 -0
  75. package/src/core/plugin-repo.mjs +16 -4
  76. package/src/core/plugin-shim-child.mjs +9 -3
  77. package/src/core/plugin-shim.mjs +77 -14
  78. package/src/core/plugin-store.mjs +236 -29
  79. package/src/core/plugin-workflows.mjs +90 -41
  80. package/src/core/preflight.mjs +135 -3
  81. package/src/core/projects.mjs +7 -5
  82. package/src/core/protocol.mjs +8 -35
  83. package/src/core/recoverable-error.mjs +1 -1
  84. package/src/core/run-harness.mjs +3585 -0
  85. package/src/core/run-manifest.mjs +5 -1
  86. package/src/core/settings.mjs +109 -13
  87. package/src/core/skills.mjs +10 -3
  88. package/src/core/source-bindings.mjs +175 -0
  89. package/src/core/sources.mjs +87 -25
  90. package/src/core/stats.mjs +25 -6
  91. package/src/core/title.mjs +51 -4
  92. package/src/core/workflows.mjs +358 -259
  93. package/src/core/workspace-scan.mjs +4 -0
  94. package/src/core/worktree.mjs +98 -7
  95. package/src/shared/graph/agent-meta.mjs +278 -0
  96. package/src/shared/graph/constants.mjs +105 -0
  97. package/src/shared/graph/geometry.mjs +157 -0
  98. package/src/shared/graph/layout.mjs +134 -0
  99. package/src/shared/graph/loops.mjs +130 -0
  100. package/src/shared/graph/manifest.mjs +257 -0
  101. package/src/shared/graph/ports.mjs +153 -0
  102. package/src/shared/graph/route.mjs +397 -0
  103. package/src/shared/graph/template.mjs +165 -0
  104. package/src/shared/graph/thumbnail.mjs +67 -0
  105. package/src/shared/graph/validate.mjs +491 -0
  106. package/src/shared/graph/verdict.mjs +41 -0
  107. package/ui/public/app.js +4008 -1670
  108. package/ui/public/ask-markdown.mjs +145 -0
  109. package/ui/public/ask-model.mjs +264 -0
  110. package/ui/public/ask-panel.mjs +1880 -0
  111. package/ui/public/chat-settings-view.mjs +6 -2
  112. package/ui/public/diff-view.mjs +66 -11
  113. package/ui/public/file-tree.mjs +305 -0
  114. package/ui/public/graph/composer.mjs +889 -0
  115. package/ui/public/graph/inspector.mjs +183 -0
  116. package/ui/public/graph/model.mjs +37 -0
  117. package/ui/public/graph/palette.mjs +144 -0
  118. package/ui/public/graph/run-decor.mjs +410 -0
  119. package/ui/public/graph/run-hosts.mjs +201 -0
  120. package/ui/public/graph/save-dialog.mjs +56 -0
  121. package/ui/public/graph/view.mjs +858 -0
  122. package/ui/public/guardrails-view.mjs +4 -2
  123. package/ui/public/hljs-loader.mjs +180 -0
  124. package/ui/public/index.html +269 -265
  125. package/ui/public/log-filter.mjs +22 -4
  126. package/ui/public/log-line.mjs +45 -19
  127. package/ui/public/models-view.mjs +171 -9
  128. package/ui/public/plugins-view.mjs +106 -4
  129. package/ui/public/source-pane.mjs +190 -8
  130. package/ui/public/stats-view.mjs +81 -1
  131. package/ui/public/style.css +1459 -229
  132. package/ui/public/syntax-highlight.mjs +270 -0
  133. package/ui/public/thinking-orb.mjs +110 -0
  134. package/ui/server.mjs +1667 -98
  135. package/src/core/channels.mjs +0 -302
  136. package/src/core/runners.mjs +0 -167
  137. package/src/core/workflow-validator.mjs +0 -185
  138. package/ui/public/composer-core.mjs +0 -211
@@ -0,0 +1,360 @@
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>.txt — the path is built from the
7
+ // ROW ID only, never from the user-supplied name.
8
+ import { randomBytes } from 'node:crypto';
9
+ import { mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
10
+ import { basename, join } from 'node:path';
11
+ import { getDb, prepare, tx } from '../db.mjs';
12
+ import { worcaHome } from '../projects.mjs';
13
+
14
+ export const ASK_ID_RE = /^[a-z]+_[0-9a-f]{8}$/;
15
+ const ROLES = new Set(['user', 'assistant', 'system']);
16
+
17
+ /** `<prefix>_<8 hex>` — prefixes: ask (thread), askm (message), att, card. */
18
+ export function newAskId(prefix) { return `${prefix}_${randomBytes(4).toString('hex')}`; }
19
+ /** New top-level root next to store/ — attachment bodies only (docs/storage.md). */
20
+ export function askRoot() { return join(worcaHome(), 'ask'); }
21
+ /**
22
+ * `<askRoot>/<thread>/att`. The id is the only thing between this path and the rest
23
+ * of the disk, so it is shape-checked here rather than at each caller: a thread row
24
+ * the store never minted (raw SQL, a foreign writer, a future import) with id `..`
25
+ * aimed the write at <home>/ask/att and `../../etc` outside the worca home entirely.
26
+ * Throws rather than returning null so no caller can build a path from the failure.
27
+ */
28
+ export function attachmentsDir(threadId) {
29
+ if (typeof threadId !== 'string' || !ASK_ID_RE.test(threadId)) throw new Error('attachmentsDir: refusing a thread id the store never minted');
30
+ return join(askRoot(), threadId, 'att');
31
+ }
32
+
33
+ const now = () => new Date().toISOString();
34
+ const parse = (v, fallback) => { if (v == null) return fallback; try { return JSON.parse(v); } catch { return fallback; } };
35
+ const str = (v) => (v === undefined || v === null ? null : JSON.stringify(v));
36
+ const emptyTotals = () => ({ costUsd: 0, input: 0, output: 0, cacheRead: 0, cacheCreation: 0, turns: 0, agents: 0 });
37
+ const round6 = (n) => Math.round(n * 1e6) / 1e6;
38
+
39
+ function rowToThread(r) {
40
+ return {
41
+ id: r.id, title: r.title ?? null, createdAt: r.created_at, updatedAt: r.updated_at,
42
+ model: r.model ?? null, effort: r.effort ?? null, sessionId: r.session_id ?? null,
43
+ context: parse(r.context, null),
44
+ totals: { ...emptyTotals(), ...(parse(r.totals, {}) || {}) },
45
+ };
46
+ }
47
+ function rowToMessage(r) {
48
+ return {
49
+ id: r.id, threadId: r.thread_id, seq: r.seq, role: r.role, text: r.text ?? '',
50
+ blocks: parse(r.blocks, null), status: r.status ?? null, reason: r.reason ?? null,
51
+ model: r.model ?? null, effort: r.effort ?? null, usage: parse(r.usage, null),
52
+ costUsd: r.cost_usd ?? null, durationMs: r.duration_ms ?? null, createdAt: r.created_at,
53
+ };
54
+ }
55
+ function rowToAttachment(r) {
56
+ return { id: r.id, threadId: r.thread_id, messageId: r.message_id ?? null, name: r.name, bytes: r.bytes, createdAt: r.created_at };
57
+ }
58
+ function rowToRunLink(r) {
59
+ return {
60
+ threadId: r.thread_id, runId: r.run_id, pipelineId: r.pipeline_id ?? null, cardId: r.card_id ?? null,
61
+ status: r.status ?? null, phase: r.phase ?? null,
62
+ commentIds: parse(r.comment_ids, null) || [], // v22: diff comments this run addresses
63
+ createdAt: r.created_at,
64
+ };
65
+ }
66
+
67
+ // ── threads ─────────────────────────────────────────────────────────────────
68
+
69
+ export function createThread({ title = null, model = null, effort = null } = {}) {
70
+ getDb();
71
+ const id = newAskId('ask');
72
+ const t = now();
73
+ prepare('INSERT INTO ask_threads (id, title, created_at, updated_at, model, effort, totals) VALUES (?, ?, ?, ?, ?, ?, ?)')
74
+ .run(id, title, t, t, model, effort, JSON.stringify(emptyTotals()));
75
+ return getThread(id);
76
+ }
77
+
78
+ export function getThread(id) {
79
+ getDb();
80
+ const r = prepare('SELECT * FROM ask_threads WHERE id = ?').get(id);
81
+ return r ? rowToThread(r) : null;
82
+ }
83
+
84
+ export function listThreads({ limit = 50 } = {}) {
85
+ getDb();
86
+ const n = Number.isInteger(limit) && limit > 0 ? limit : 50;
87
+ const rows = prepare(`
88
+ SELECT t.*, (SELECT count(*) FROM ask_run_links l WHERE l.thread_id = t.id) AS run_links,
89
+ (SELECT count(*) FROM ask_worktrees w WHERE w.thread_id = t.id) AS worktrees
90
+ FROM ask_threads t ORDER BY t.updated_at DESC, t.id LIMIT ?
91
+ `).all(n);
92
+ return rows.map((r) => ({ ...rowToThread(r), runLinks: r.run_links, worktrees: r.worktrees }));
93
+ }
94
+
95
+ const THREAD_PATCH_COLS = { title: 'title', model: 'model', effort: 'effort', sessionId: 'session_id', context: 'context' };
96
+
97
+ /** Patch ⊆ {title, model, effort, sessionId, context}; unknown keys ignored; always bumps updated_at. */
98
+ export function updateThread(id, patch = {}) {
99
+ const db = getDb();
100
+ const sets = [];
101
+ const vals = [];
102
+ for (const [k, col] of Object.entries(THREAD_PATCH_COLS)) {
103
+ if (!Object.prototype.hasOwnProperty.call(patch, k)) continue;
104
+ sets.push(`${col} = ?`);
105
+ vals.push(k === 'context' ? str(patch[k]) : (patch[k] ?? null));
106
+ }
107
+ sets.push('updated_at = ?');
108
+ vals.push(now(), id);
109
+ const info = db.prepare(`UPDATE ask_threads SET ${sets.join(', ')} WHERE id = ?`).run(...vals);
110
+ return info.changes ? getThread(id) : null;
111
+ }
112
+
113
+ /** D13: `onlyIf` = replace the title only while it still IS that value (the user may have renamed it). */
114
+ export function setThreadTitle(id, title, { onlyIf } = {}) {
115
+ getDb();
116
+ const info = onlyIf === undefined
117
+ ? prepare('UPDATE ask_threads SET title = ?, updated_at = ? WHERE id = ?').run(title, now(), id)
118
+ : prepare('UPDATE ask_threads SET title = ?, updated_at = ? WHERE id = ? AND title IS ?').run(title, now(), id, onlyIf);
119
+ return info.changes > 0;
120
+ }
121
+
122
+ /** Every turn — done, stopped or error — adds to the thread totals; a null cost adds 0 but counts the turn. */
123
+ export function addThreadTotals(id, { costUsd = null, usage = null, agents = 0 } = {}) {
124
+ return tx(() => {
125
+ const row = prepare('SELECT totals FROM ask_threads WHERE id = ?').get(id);
126
+ if (!row) return null;
127
+ const t = { ...emptyTotals(), ...(parse(row.totals, {}) || {}) };
128
+ t.costUsd = round6(t.costUsd + (typeof costUsd === 'number' && Number.isFinite(costUsd) ? costUsd : 0));
129
+ for (const k of ['input', 'output', 'cacheRead', 'cacheCreation']) t[k] += Number(usage?.[k]) || 0;
130
+ if (Number.isFinite(usage?.ctx)) t.ctx = usage.ctx; // context fill: the turn's last per-call figure REPLACES (never sums)
131
+ t.turns += 1;
132
+ t.agents += Number.isInteger(agents) && agents > 0 ? agents : 0;
133
+ prepare('UPDATE ask_threads SET totals = ?, updated_at = ? WHERE id = ?').run(JSON.stringify(t), now(), id);
134
+ return t;
135
+ });
136
+ }
137
+
138
+ /**
139
+ * Row delete (cascades to messages/attachments/links) then rm -rf of the attachment
140
+ * root (spec §7.5). The id is validated FIRST because the rm path is built from it:
141
+ * a row this store never minted (raw SQL, a foreign writer, a future import) with id
142
+ * `..` would aim the rmSync at the whole worca home, and sweepEmptyThreads runs it
143
+ * unattended at boot. "The DELETE matched" is not a shape check.
144
+ */
145
+ export function deleteThread(id) {
146
+ if (typeof id !== 'string' || !ASK_ID_RE.test(id)) return false;
147
+ const removed = tx(() => {
148
+ // ask_card_comments is keyed by card id, and card ids live inside message
149
+ // blocks (JSON) — no FK can cascade them, so a deleted thread used to leave
150
+ // its proposals' pending comment ids behind for ever (review of PR #376).
151
+ prepare(`DELETE FROM ask_card_comments WHERE card_id IN (
152
+ SELECT json_extract(b.value, '$.id') FROM ask_messages m, json_each(m.blocks) b
153
+ WHERE m.thread_id = ? AND json_valid(m.blocks) AND json_extract(b.value, '$.kind') = 'card')`).run(id);
154
+ return prepare('DELETE FROM ask_threads WHERE id = ?').run(id).changes > 0;
155
+ });
156
+ if (removed) rmSync(join(askRoot(), id), { recursive: true, force: true });
157
+ return removed;
158
+ }
159
+
160
+ /** Boot sweep (spec §6.2.1): threads that never received a message and are older than the cutoff. */
161
+ export function sweepEmptyThreads({ olderThanMs = 24 * 60 * 60 * 1000, now: nowMs = Date.now() } = {}) {
162
+ getDb();
163
+ const cutoff = new Date(nowMs - olderThanMs).toISOString();
164
+ const ids = prepare(`
165
+ SELECT t.id FROM ask_threads t
166
+ WHERE t.created_at < ? AND NOT EXISTS (SELECT 1 FROM ask_messages m WHERE m.thread_id = t.id)
167
+ `).all(cutoff).map((r) => r.id);
168
+ let removed = 0;
169
+ for (const id of ids) if (deleteThread(id)) removed += 1; // a row deleteThread refuses is not counted as swept
170
+ return removed;
171
+ }
172
+
173
+ // ── messages ────────────────────────────────────────────────────────────────
174
+
175
+ /** seq = MAX(seq)+1 inside tx(): follower notices interleave with turns (spec §7.1). */
176
+ export function appendMessage(threadId, { role, text = '', blocks = null, status = null, model = null, effort = null } = {}) {
177
+ if (!ROLES.has(role)) throw new Error(`appendMessage: invalid role ${JSON.stringify(role)}`);
178
+ return tx(() => {
179
+ if (!prepare('SELECT 1 FROM ask_threads WHERE id = ?').get(threadId)) {
180
+ throw new Error(`appendMessage: unknown thread ${threadId}`);
181
+ }
182
+ const { next } = prepare('SELECT COALESCE(MAX(seq), 0) + 1 AS next FROM ask_messages WHERE thread_id = ?').get(threadId);
183
+ const id = newAskId('askm');
184
+ const t = now();
185
+ prepare(`INSERT INTO ask_messages (id, thread_id, seq, role, text, blocks, status, model, effort, created_at)
186
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
187
+ .run(id, threadId, next, role, String(text ?? ''), str(blocks), status, model, effort, t);
188
+ prepare('UPDATE ask_threads SET updated_at = ? WHERE id = ?').run(t, threadId);
189
+ return getMessage(id);
190
+ });
191
+ }
192
+
193
+ export function getMessage(id) {
194
+ getDb();
195
+ const r = prepare('SELECT * FROM ask_messages WHERE id = ?').get(id);
196
+ return r ? rowToMessage(r) : null;
197
+ }
198
+
199
+ export function listMessages(threadId) {
200
+ getDb();
201
+ return prepare('SELECT * FROM ask_messages WHERE thread_id = ? ORDER BY seq').all(threadId).map(rowToMessage);
202
+ }
203
+
204
+ export function finishMessage(id, { text, blocks, status, reason = null, usage = null, costUsd = null, durationMs = null } = {}) {
205
+ getDb();
206
+ const info = prepare(`UPDATE ask_messages SET text = ?, blocks = ?, status = ?, reason = ?, usage = ?, cost_usd = ?, duration_ms = ?
207
+ WHERE id = ?`)
208
+ .run(String(text ?? ''), str(blocks), status ?? null, reason, str(usage), costUsd, durationMs, id);
209
+ if (!info.changes) return null;
210
+ const m = getMessage(id);
211
+ prepare('UPDATE ask_threads SET updated_at = ? WHERE id = ?').run(now(), m.threadId);
212
+ return m;
213
+ }
214
+
215
+ export function setMessageBlocks(id, blocks) {
216
+ getDb();
217
+ const info = prepare('UPDATE ask_messages SET blocks = ? WHERE id = ?').run(str(blocks), id);
218
+ return info.changes ? getMessage(id) : null;
219
+ }
220
+
221
+ export function findCard(threadId, cardId) {
222
+ for (const message of listMessages(threadId)) {
223
+ if (!Array.isArray(message.blocks)) continue; // a non-array JSON value parses truthy; skip it rather than throw
224
+ const block = message.blocks.find((b) => b && b.kind === 'card' && b.id === cardId);
225
+ if (block) return { message, block };
226
+ }
227
+ return null;
228
+ }
229
+
230
+ const CARD_PATCH_KEYS = ['state', 'runId', 'error'];
231
+
232
+ /** Patch ⊆ {state, runId, error} on one card block; the 'proposed' precondition is the caller's (route) business. */
233
+ export function updateCardBlock(threadId, cardId, patch = {}) {
234
+ return tx(() => {
235
+ const found = findCard(threadId, cardId);
236
+ if (!found) return null;
237
+ const allowed = {};
238
+ for (const k of CARD_PATCH_KEYS) if (Object.prototype.hasOwnProperty.call(patch, k)) allowed[k] = patch[k];
239
+ const blocks = found.message.blocks.map((b) => (b && b.kind === 'card' && b.id === cardId ? { ...b, ...allowed } : b));
240
+ prepare('UPDATE ask_messages SET blocks = ? WHERE id = ?').run(JSON.stringify(blocks), found.message.id);
241
+ return blocks.find((b) => b && b.kind === 'card' && b.id === cardId);
242
+ });
243
+ }
244
+
245
+ /** Boot sweep (spec §6.2): a turn the previous server process never finished. */
246
+ export function sweepStreamingMessages({ text = 'interrupted by restart' } = {}) {
247
+ return tx(() => {
248
+ const rows = prepare("SELECT id, blocks FROM ask_messages WHERE status = 'streaming'").all();
249
+ for (const r of rows) {
250
+ // The whole sweep is ONE tx(): a TypeError on a single poisoned row would roll
251
+ // back every other row's fix, and would do so again on every later boot.
252
+ const prev = parse(r.blocks, []);
253
+ const blocks = Array.isArray(prev) ? prev : [];
254
+ blocks.push({ kind: 'notice', text });
255
+ prepare("UPDATE ask_messages SET status = 'error', blocks = ? WHERE id = ?").run(JSON.stringify(blocks), r.id);
256
+ }
257
+ return rows.length;
258
+ });
259
+ }
260
+
261
+ // ── attachments ─────────────────────────────────────────────────────────────
262
+
263
+ export function addAttachment(threadId, messageId, { name, text } = {}) {
264
+ getDb();
265
+ if (!prepare('SELECT 1 FROM ask_threads WHERE id = ?').get(threadId)) {
266
+ throw new Error(`addAttachment: unknown thread ${threadId}`);
267
+ }
268
+ const id = newAskId('att');
269
+ const safeName = (basename(String(name ?? '')).slice(0, 255)) || 'attachment.txt';
270
+ const body = String(text ?? '');
271
+ const bytes = Buffer.byteLength(body, 'utf8');
272
+ const dir = attachmentsDir(threadId);
273
+ mkdirSync(dir, { recursive: true });
274
+ writeFileSync(join(dir, `${id}.txt`), body, 'utf8'); // file FIRST: a row without a file would 404 on read
275
+ prepare('INSERT INTO ask_attachments (id, thread_id, message_id, name, bytes, created_at) VALUES (?, ?, ?, ?, ?, ?)')
276
+ .run(id, threadId, messageId ?? null, safeName, bytes, now());
277
+ return getAttachment(threadId, id);
278
+ }
279
+
280
+ export function listAttachments(threadId) {
281
+ getDb();
282
+ return prepare('SELECT * FROM ask_attachments WHERE thread_id = ? ORDER BY created_at, id').all(threadId).map(rowToAttachment);
283
+ }
284
+
285
+ export function getAttachment(threadId, id) {
286
+ getDb();
287
+ const r = prepare('SELECT * FROM ask_attachments WHERE thread_id = ? AND id = ?').get(threadId, id);
288
+ return r ? rowToAttachment(r) : null;
289
+ }
290
+
291
+ /**
292
+ * Thread-scoped read; the file path comes from the row id, never from `name` — and
293
+ * the row is not proof of the id's shape, so `a.id` is checked too: an
294
+ * `ask_attachments` row this store never minted, hung off a legitimate thread,
295
+ * otherwise read any file on disk (`../../../../etc/hosts`). Both failures are the
296
+ * same `null` a missing row returns; the caller (tool-deps.mjs) turns it into a
297
+ * not-found, and a reader must not throw where a 404 is the answer.
298
+ */
299
+ export function readAttachmentText(threadId, id) {
300
+ const a = getAttachment(threadId, id);
301
+ if (!a || !ASK_ID_RE.test(a.id)) return null;
302
+ try {
303
+ return { ...a, text: readFileSync(join(attachmentsDir(threadId), `${a.id}.txt`), 'utf8') };
304
+ } catch {
305
+ return null;
306
+ }
307
+ }
308
+
309
+ export function threadAttachmentBytes(threadId) {
310
+ getDb();
311
+ return prepare('SELECT COALESCE(SUM(bytes), 0) AS n FROM ask_attachments WHERE thread_id = ?').get(threadId).n;
312
+ }
313
+
314
+ // ── run links ───────────────────────────────────────────────────────────────
315
+
316
+ export function linkRun(threadId, { runId, cardId = null, pipelineId = null, status = null, phase = null } = {}) {
317
+ getDb();
318
+ prepare('INSERT INTO ask_run_links (thread_id, run_id, pipeline_id, card_id, status, phase, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)')
319
+ .run(threadId, runId, pipelineId, cardId, status, phase, now());
320
+ return getRunLink(threadId, runId);
321
+ }
322
+
323
+ function getRunLink(threadId, runId) {
324
+ const r = prepare('SELECT * FROM ask_run_links WHERE thread_id = ? AND run_id = ?').get(threadId, runId);
325
+ return r ? rowToRunLink(r) : null;
326
+ }
327
+
328
+ // `runId` is patchable so a RESUMED run (a new runs-Map id for the same pipeline)
329
+ // can take over its link row instead of leaving it on the dead lineage.
330
+ const LINK_PATCH_COLS = { runId: 'run_id', pipelineId: 'pipeline_id', status: 'status', phase: 'phase', commentIds: 'comment_ids' };
331
+
332
+ export function updateRunLink(threadId, runId, patch = {}) {
333
+ const db = getDb();
334
+ const sets = [];
335
+ const vals = [];
336
+ for (const [k, col] of Object.entries(LINK_PATCH_COLS)) {
337
+ if (!Object.prototype.hasOwnProperty.call(patch, k)) continue;
338
+ sets.push(`${col} = ?`);
339
+ // comment_ids is the one JSON column here; every other patch key is a scalar.
340
+ // An empty array stores NULL so "no pending comments" has exactly one encoding.
341
+ vals.push(k === 'commentIds' ? (Array.isArray(patch[k]) && patch[k].length ? str(patch[k]) : null) : (patch[k] ?? null));
342
+ }
343
+ if (!sets.length) return getRunLink(threadId, runId);
344
+ vals.push(threadId, runId);
345
+ const info = db.prepare(`UPDATE ask_run_links SET ${sets.join(', ')} WHERE thread_id = ? AND run_id = ?`).run(...vals);
346
+ return info.changes ? getRunLink(threadId, Object.prototype.hasOwnProperty.call(patch, 'runId') ? patch.runId : runId) : null;
347
+ }
348
+
349
+ /** Every link row (any thread) pointing at a History pipeline id — what resumeRun
350
+ * re-attaches followers for. */
351
+ export function findRunLinksByPipeline(pipelineId) {
352
+ getDb();
353
+ if (typeof pipelineId !== 'string' || !pipelineId) return [];
354
+ return prepare('SELECT * FROM ask_run_links WHERE pipeline_id = ? ORDER BY created_at DESC, run_id').all(pipelineId).map(rowToRunLink);
355
+ }
356
+
357
+ export function listRunLinks(threadId) {
358
+ getDb();
359
+ return prepare('SELECT * FROM ask_run_links WHERE thread_id = ? ORDER BY created_at DESC, run_id').all(threadId).map(rowToRunLink);
360
+ }
@@ -0,0 +1,63 @@
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 } 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 a = threadId ? readAttachmentText(threadId, id) : null;
53
+ return a ? { name: a.name, text: a.text } : null;
54
+ },
55
+ validateProposal,
56
+ // The SECURE preset is the floor, not the run's own set: guardrailsId defaults
57
+ // to 'permissive' (empty protectedPaths), so resolving per row would show the
58
+ // model every credential file on most runs. This only ever omits more.
59
+ protectedPaths: [...GUARDRAIL_PRESETS.secure.protectedPaths],
60
+ redact: redactAskText,
61
+ limits: ASK_LIMITS,
62
+ };
63
+ }