@worca/app 0.0.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 (114) hide show
  1. package/README.md +403 -0
  2. package/agents/clarify.meta.json +19 -0
  3. package/agents/decomposer.meta.json +21 -0
  4. package/agents/implementer.meta.json +20 -0
  5. package/agents/manualTestsChecklist.meta.json +18 -0
  6. package/agents/manualWebUiTesting.meta.json +18 -0
  7. package/agents/planReviewer.meta.json +19 -0
  8. package/agents/planner.meta.json +20 -0
  9. package/agents/refiner.meta.json +19 -0
  10. package/agents/reviewer.meta.json +19 -0
  11. package/agents/worca-cc-clarify.md +67 -0
  12. package/agents/worca-cc-code-reviewer.md +66 -0
  13. package/agents/worca-cc-decomposer.md +84 -0
  14. package/agents/worca-cc-implementer.md +69 -0
  15. package/agents/worca-cc-manual-tests-checklist.md +63 -0
  16. package/agents/worca-cc-manual-web-ui-testing.md +64 -0
  17. package/agents/worca-cc-plan-refiner.md +69 -0
  18. package/agents/worca-cc-plan-reviewer.md +70 -0
  19. package/agents/worca-cc-planner.md +70 -0
  20. package/agents/worca-cc-workspace-reviewer.md +56 -0
  21. package/agents/worca-cc-workspace-scanner.md +55 -0
  22. package/agents/workspaceReviewer.meta.json +20 -0
  23. package/agents/workspaceScanner.meta.json +18 -0
  24. package/package.json +61 -0
  25. package/scripts/install.mjs +209 -0
  26. package/skills/worca/SKILL.md +66 -0
  27. package/src/cli/worca-cc.mjs +1520 -0
  28. package/src/core/agent-gen.mjs +206 -0
  29. package/src/core/agent-registry.mjs +417 -0
  30. package/src/core/agent-store.mjs +143 -0
  31. package/src/core/artifacts.mjs +2019 -0
  32. package/src/core/channels.mjs +302 -0
  33. package/src/core/chat/allowlist.mjs +27 -0
  34. package/src/core/chat/channel-host.mjs +562 -0
  35. package/src/core/chat/channel-protocol.mjs +117 -0
  36. package/src/core/chat/channel-worker-child.mjs +211 -0
  37. package/src/core/chat/chat-context.mjs +66 -0
  38. package/src/core/chat/command-router.mjs +343 -0
  39. package/src/core/chat/notifier.mjs +120 -0
  40. package/src/core/chat/parser.mjs +30 -0
  41. package/src/core/chat/rate-limiter.mjs +133 -0
  42. package/src/core/chat/redact.mjs +27 -0
  43. package/src/core/chat/renderers.mjs +136 -0
  44. package/src/core/claude-runner.mjs +1356 -0
  45. package/src/core/config.mjs +882 -0
  46. package/src/core/cost-budget.mjs +103 -0
  47. package/src/core/db.mjs +864 -0
  48. package/src/core/fanout.mjs +48 -0
  49. package/src/core/folder-dialog.mjs +138 -0
  50. package/src/core/fs-browse.mjs +49 -0
  51. package/src/core/git-info.mjs +200 -0
  52. package/src/core/guardrail-store.mjs +204 -0
  53. package/src/core/guardrails.mjs +302 -0
  54. package/src/core/marketplaces.mjs +267 -0
  55. package/src/core/migrate-fs-to-db.mjs +612 -0
  56. package/src/core/model-env.mjs +74 -0
  57. package/src/core/orchestrator.mjs +4279 -0
  58. package/src/core/overview-agent.mjs +124 -0
  59. package/src/core/phases.mjs +1279 -0
  60. package/src/core/pipeline-delete.mjs +428 -0
  61. package/src/core/plugin-api.mjs +13 -0
  62. package/src/core/plugin-config.mjs +100 -0
  63. package/src/core/plugin-inventory.mjs +50 -0
  64. package/src/core/plugin-manifest.mjs +447 -0
  65. package/src/core/plugin-models.mjs +130 -0
  66. package/src/core/plugin-repo.mjs +303 -0
  67. package/src/core/plugin-shim-child.mjs +76 -0
  68. package/src/core/plugin-shim.mjs +197 -0
  69. package/src/core/plugin-store.mjs +485 -0
  70. package/src/core/plugin-workflows.mjs +179 -0
  71. package/src/core/plugins-lock.mjs +49 -0
  72. package/src/core/preflight-node.mjs +122 -0
  73. package/src/core/preflight.mjs +341 -0
  74. package/src/core/projects.mjs +157 -0
  75. package/src/core/protocol.mjs +257 -0
  76. package/src/core/recoverable-error.mjs +51 -0
  77. package/src/core/results.mjs +188 -0
  78. package/src/core/run-context.mjs +1375 -0
  79. package/src/core/run-log.mjs +64 -0
  80. package/src/core/run-manifest.mjs +317 -0
  81. package/src/core/runners.mjs +167 -0
  82. package/src/core/settings.mjs +682 -0
  83. package/src/core/skills.mjs +210 -0
  84. package/src/core/sources.mjs +232 -0
  85. package/src/core/stats.mjs +182 -0
  86. package/src/core/store.mjs +67 -0
  87. package/src/core/title.mjs +64 -0
  88. package/src/core/workflow-validator.mjs +185 -0
  89. package/src/core/workflows.mjs +568 -0
  90. package/src/core/workspace-scan.mjs +420 -0
  91. package/src/core/workspaces.mjs +353 -0
  92. package/src/core/worktree.mjs +708 -0
  93. package/src/feature.mjs +9 -0
  94. package/ui/public/app.js +10647 -0
  95. package/ui/public/assets/worca-favicon.png +0 -0
  96. package/ui/public/assets/worca-logo.png +0 -0
  97. package/ui/public/chat-settings-view.mjs +89 -0
  98. package/ui/public/composer-core.mjs +211 -0
  99. package/ui/public/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
  100. package/ui/public/fonts/poppins-latin-400-normal.woff2 +0 -0
  101. package/ui/public/fonts/poppins-latin-500-normal.woff2 +0 -0
  102. package/ui/public/fonts/poppins-latin-600-normal.woff2 +0 -0
  103. package/ui/public/fonts/poppins-latin-700-normal.woff2 +0 -0
  104. package/ui/public/guardrails-view.mjs +244 -0
  105. package/ui/public/index.html +1145 -0
  106. package/ui/public/log-filter.mjs +81 -0
  107. package/ui/public/log-line.mjs +86 -0
  108. package/ui/public/models-view.mjs +433 -0
  109. package/ui/public/plugins-view.mjs +430 -0
  110. package/ui/public/results-view.mjs +121 -0
  111. package/ui/public/source-pane.mjs +156 -0
  112. package/ui/public/stats-view.mjs +523 -0
  113. package/ui/public/style.css +1557 -0
  114. package/ui/server.mjs +3573 -0
@@ -0,0 +1,2019 @@
1
+ // src/core/artifacts.mjs
2
+ // Filesystem layout, naming, and pipeline persistence/audit helpers.
3
+ //
4
+ // All paths returned are absolute and rooted in the EXTERNAL store at
5
+ // <worcaHome>/store/<projectKey>/ (see store.mjs) — NOT inside the project
6
+ // working tree. This keeps history machine-wide and out of git. Pipelines are
7
+ // self-describing: each run dir holds the prompt + any extra files; the audit
8
+ // timeline and machine state live in the DB (pipeline_events + the pipelines row),
9
+ // which is the authoritative store (no more pipeline.md / state.json on disk).
10
+
11
+ import { mkdir, writeFile, readFile, copyFile, readdir } from 'node:fs/promises';
12
+ import { join, basename, resolve, isAbsolute } from 'node:path';
13
+ import { randomBytes } from 'node:crypto';
14
+ import { realpathSync, existsSync } from 'node:fs';
15
+ import { hostname } from 'node:os';
16
+ import { projectKey, projectStorePath, canonicalProjectRoot, workspaceStorePath } from './store.mjs';
17
+ import { listProjects } from './projects.mjs';
18
+ import { branchExists, diffShortstat, hasGh, findPrForBranch } from './git-info.mjs';
19
+ import { getDb, tx } from './db.mjs';
20
+ import { RUN_LOG_FILE } from './run-log.mjs';
21
+
22
+ // ── DB row <-> state object mapping (Phase 3) ──────────────────────────────────
23
+ // JSON columns are TEXT; (de)serialize at THIS boundary only. Reads are fail-safe:
24
+ // a null/empty/corrupt column yields the fallback, never a throw.
25
+
26
+ /** Parse a TEXT JSON column to an object/array, or `fallback` on null/empty/bad. */
27
+ function j(text, fallback = null) {
28
+ if (text == null || text === '') return fallback;
29
+ try { return JSON.parse(text); } catch { return fallback; }
30
+ }
31
+ /** Stringify a value for a TEXT JSON column, or null when the value is null/undefined. */
32
+ function s(value) {
33
+ return value == null ? null : JSON.stringify(value);
34
+ }
35
+
36
+ /** The 8-hex pipeline id embedded as the final "-<id>" segment of a run dir name. */
37
+ const DIR_ID_RE = /-([0-9a-f]{8})$/i;
38
+
39
+ /**
40
+ * Read a store_meta row's JSON payload by key, or null when absent. Replaces the
41
+ * per-project / per-workspace meta.json file read. Fail-safe: a corrupt/empty
42
+ * payload reads as null rather than throwing.
43
+ * @param {string} key
44
+ * @returns {object|null}
45
+ */
46
+ export function readStoreMeta(key) {
47
+ const row = getDb().prepare('SELECT data FROM store_meta WHERE key = ?').get(key);
48
+ return row ? j(row.data, null) : null;
49
+ }
50
+
51
+ /**
52
+ * Upsert a store_meta row. `kind` is 'project' | 'workspace'; `data` is the full
53
+ * meta object (stored as a JSON string). Replaces writing meta.json.
54
+ * @param {string} key
55
+ * @param {'project'|'workspace'} kind
56
+ * @param {object} data
57
+ */
58
+ export function writeStoreMeta(key, kind, data) {
59
+ tx(() => {
60
+ getDb().prepare(`
61
+ INSERT INTO store_meta (key, kind, data) VALUES (?, ?, ?)
62
+ ON CONFLICT(key) DO UPDATE SET kind = excluded.kind, data = excluded.data
63
+ `).run(key, kind, JSON.stringify(data ?? {}));
64
+ });
65
+ }
66
+
67
+ /**
68
+ * Delete a store_meta row (used by workspace delete in Phase 2). No-op when absent.
69
+ * @param {string} key
70
+ */
71
+ export function deleteStoreMeta(key) {
72
+ tx(() => { getDb().prepare('DELETE FROM store_meta WHERE key = ?').run(key); });
73
+ }
74
+
75
+ /**
76
+ * Index a markdown / extras path kept on the FS after the migration, so the
77
+ * pipeline-delete (Task 3.13) can unlink the EXACT files instead of re-deriving
78
+ * names. `kind` is e.g. prompt | workspace-description | extra | plan | review |
79
+ * checklist | webui; `relPath` is relative to the pipeline dir for pipeline-local
80
+ * files (prompt.md, extras/*, manual-tests-checklist.md, webui-review-cycleN.md)
81
+ * and relative to the store root for the shared plan/review markdown (which live
82
+ * in plans//reviews/, siblings of pipelines/). Idempotent (INSERT OR IGNORE on the
83
+ * (pipeline_id, kind, rel_path) PK), best-effort: a logging failure never breaks a
84
+ * run. A null/empty path is a no-op. The pipelines row must already exist (FK).
85
+ * @param {string} pipelineId
86
+ * @param {string} kind
87
+ * @param {string} relPath
88
+ */
89
+ export function recordArtifact(pipelineId, kind, relPath) {
90
+ if (!pipelineId || !kind || !relPath) return;
91
+ try {
92
+ tx(() => {
93
+ getDb().prepare(
94
+ 'INSERT OR IGNORE INTO artifacts (pipeline_id, kind, rel_path) VALUES (?, ?, ?)',
95
+ ).run(pipelineId, kind, relPath);
96
+ });
97
+ } catch { /* artifact indexing is best-effort; never break a run on it */ }
98
+ }
99
+
100
+ /**
101
+ * List a pipeline's indexed artifacts as [{ kind, relPath }]. The inverse of
102
+ * recordArtifact: pipeline-delete (Task 3.13) reads these to unlink the EXACT FS
103
+ * markdown/extras files instead of re-deriving names. rel_path scope is encoded by
104
+ * the convention recordArtifact documents (dir-relative for pipeline-local files,
105
+ * store-root-relative for the shared plan/review markdown).
106
+ * @param {string} pipelineId
107
+ * @returns {Promise<Array<{kind:string, relPath:string}>>}
108
+ */
109
+ export async function listArtifacts(pipelineId) {
110
+ return getDb().prepare('SELECT kind, rel_path FROM artifacts WHERE pipeline_id = ?')
111
+ .all(pipelineId).map((r) => ({ kind: r.kind, relPath: r.rel_path }));
112
+ }
113
+
114
+ /**
115
+ * Upsert the clarify row for a pipeline. Pass { questions } and/or { answers }. A
116
+ * partial call updates only the provided column, preserving the other. JSON-encoded
117
+ * TEXT columns. The agent writes clarify.json as transient run-dir scratch;
118
+ * runPlannerClarify ingests it here and reads it back, so this row is the
119
+ * AUTHORITATIVE clarify store (questions + answers). The pipelines row must exist (FK).
120
+ * @param {string} pipelineId
121
+ * @param {{questions?:object, answers?:object}} payload
122
+ */
123
+ export async function writeClarify(pipelineId, { questions, answers } = {}) {
124
+ if (!pipelineId) return;
125
+ try {
126
+ tx(() => {
127
+ getDb().prepare('INSERT INTO clarify (pipeline_id) VALUES (?) ON CONFLICT(pipeline_id) DO NOTHING')
128
+ .run(pipelineId);
129
+ if (questions !== undefined) {
130
+ getDb().prepare('UPDATE clarify SET questions = ? WHERE pipeline_id = ?')
131
+ .run(s(questions), pipelineId);
132
+ }
133
+ if (answers !== undefined) {
134
+ getDb().prepare('UPDATE clarify SET answers = ? WHERE pipeline_id = ?')
135
+ .run(s(answers), pipelineId);
136
+ }
137
+ });
138
+ } catch { /* defensive: keep the row write resilient under WAL contention; authoritative callers await + read back (M1), so a swallowed write is caught by tests, not a crashed run. */ }
139
+ }
140
+
141
+ /**
142
+ * Read the clarify row as { questions, answers } (each parsed JSON or null). When no
143
+ * row exists both are null. The authoritative clarify reader (questions ingested by
144
+ * runPlannerClarify; answers by the orchestrator).
145
+ * @param {string} pipelineId
146
+ * @returns {{questions:object|null, answers:object|null}}
147
+ */
148
+ export function readClarifyRow(pipelineId) {
149
+ const row = getDb().prepare('SELECT questions, answers FROM clarify WHERE pipeline_id = ?').get(pipelineId);
150
+ if (!row) return { questions: null, answers: null };
151
+ return { questions: j(row.questions, null), answers: j(row.answers, null) };
152
+ }
153
+
154
+ /**
155
+ * Upsert one round of a node's ask-then-resume Q&A (spec 2026-07-11). Pass
156
+ * { questions } and/or { answers }; a partial call updates only the provided
157
+ * column (writeClarify pattern). stepKey is the step record's stable
158
+ * "<stepIndex>:<nodeId>[#cycle]" key; nodeId is denormalized for per-node
159
+ * re-injection. Best-effort under WAL contention.
160
+ * @param {string} pipelineId
161
+ * @param {string} stepKey
162
+ * @param {number} round 1-based round within one node run
163
+ * @param {{agentKey?:string, nodeId?:string, questions?:object, answers?:object}} payload
164
+ */
165
+ export async function writeStepQuestions(pipelineId, stepKey, round, { agentKey, nodeId, questions, answers } = {}) {
166
+ if (!pipelineId || !stepKey || !Number.isFinite(Number(round))) return;
167
+ try {
168
+ tx(() => {
169
+ getDb().prepare(`
170
+ INSERT INTO step_questions (pipeline_id, step_key, round, node_id, agent_key) VALUES (?, ?, ?, ?, ?)
171
+ ON CONFLICT(pipeline_id, step_key, round)
172
+ DO UPDATE SET node_id = COALESCE(excluded.node_id, node_id),
173
+ agent_key = COALESCE(excluded.agent_key, agent_key)
174
+ `).run(pipelineId, stepKey, Number(round), nodeId ?? null, agentKey ?? null);
175
+ if (questions !== undefined) {
176
+ getDb().prepare('UPDATE step_questions SET questions = ? WHERE pipeline_id = ? AND step_key = ? AND round = ?')
177
+ .run(s(questions), pipelineId, stepKey, Number(round));
178
+ }
179
+ if (answers !== undefined) {
180
+ getDb().prepare('UPDATE step_questions SET answers = ? WHERE pipeline_id = ? AND step_key = ? AND round = ?')
181
+ .run(s(answers), pipelineId, stepKey, Number(round));
182
+ }
183
+ });
184
+ } catch (err) {
185
+ // Defensive: mirrors writeClarify — a transient lock must not crash a run.
186
+ // Anything OTHER than lock contention (e.g. schema drift that once left
187
+ // step_questions missing) is silent Q&A loss, so at least say so.
188
+ const msg = err && err.message ? err.message : String(err);
189
+ if (!/locked|busy/i.test(msg)) console.warn(`[artifacts] step_questions write dropped: ${msg}`);
190
+ }
191
+ }
192
+
193
+ /**
194
+ * All ask-then-resume rounds of a pipeline, unwrapped to plain arrays, in
195
+ * chronological insert order (rowid — lexicographic step_key would mis-order
196
+ * '10:' before '2:' on big workflows). Always returns an array.
197
+ * @param {string} pipelineId
198
+ * @returns {Array<{stepKey:string, round:number, nodeId:string, agentKey:string, questions:Array, answers:Array}>}
199
+ */
200
+ export function readStepQuestions(pipelineId) {
201
+ const rows = getDb().prepare(
202
+ 'SELECT step_key, round, node_id, agent_key, questions, answers FROM step_questions WHERE pipeline_id = ? ORDER BY rowid'
203
+ ).all(pipelineId);
204
+ return rows.map((r) => {
205
+ const qWrap = j(r.questions, null);
206
+ const aWrap = j(r.answers, null);
207
+ return {
208
+ stepKey: r.step_key,
209
+ round: r.round,
210
+ nodeId: r.node_id || '',
211
+ agentKey: r.agent_key || '',
212
+ questions: Array.isArray(qWrap?.questions) ? qWrap.questions : [],
213
+ answers: Array.isArray(aWrap?.answers) ? aWrap.answers : [],
214
+ };
215
+ });
216
+ }
217
+
218
+ /**
219
+ * Map a channels.allocate() review base name to the reviews-table `kind`. A2: the
220
+ * kind is a 5-value OPEN set {refine, impl, plan, ws, webui} derived by stripping the
221
+ * "-review-cycleN.json" suffix from the legacy filename; treat it as free text (an
222
+ * unknown base passes through unchanged so the mapping is lossless).
223
+ * @param {string} base e.g. 'impl-review' | 'plan-review' | 'refine-review' | 'ws-review' | 'webui-review'
224
+ * @returns {string}
225
+ */
226
+ const REVIEW_KIND = {
227
+ 'refine-review': 'refine', 'impl-review': 'impl', 'plan-review': 'plan',
228
+ 'ws-review': 'ws', 'webui-review': 'webui',
229
+ };
230
+ export function reviewKindOf(base) { return REVIEW_KIND[base] || base; }
231
+
232
+ /**
233
+ * Upsert a per-cycle review verdict. `kind` ∈ refine|impl|plan|ws|webui (free text,
234
+ * A2); `cycle` is the run cycle; `verdict` is the normalized { issues:[...], summary }
235
+ * object protocol.readReview returns. The AUTHORITATIVE per-cycle verdict store. The
236
+ * agent writes *-review-cycleN.json as transient scratch; the runner parses it once
237
+ * and returns the verdict, which the orchestrator persists here (awaited). The live
238
+ * loop gates on that returned verdict in-memory. Re-running a cycle REPLACES its
239
+ * verdict (ON CONFLICT). The pipelines row must exist (FK).
240
+ * @param {string} pipelineId
241
+ * @param {string} kind
242
+ * @param {number} cycle
243
+ * @param {object} verdict
244
+ */
245
+ export async function writeReview(pipelineId, kind, cycle, verdict) {
246
+ if (!pipelineId || !kind) return;
247
+ try {
248
+ tx(() => {
249
+ getDb().prepare(`
250
+ INSERT INTO reviews (pipeline_id, kind, cycle, verdict) VALUES (?, ?, ?, ?)
251
+ ON CONFLICT(pipeline_id, kind, cycle) DO UPDATE SET verdict = excluded.verdict
252
+ `).run(pipelineId, kind, cycle, s(verdict));
253
+ });
254
+ } catch { /* defensive: keep the row write resilient under WAL contention; authoritative callers await + read back (M1), so a swallowed write is caught by tests, not a crashed run. */ }
255
+ }
256
+
257
+ /**
258
+ * Read a single per-cycle verdict (the parsed JSON object), or null when absent.
259
+ * @param {string} pipelineId
260
+ * @param {string} kind
261
+ * @param {number} cycle
262
+ * @returns {object|null}
263
+ */
264
+ export function readReviewRow(pipelineId, kind, cycle) {
265
+ const row = getDb().prepare(
266
+ 'SELECT verdict FROM reviews WHERE pipeline_id = ? AND kind = ? AND cycle = ?')
267
+ .get(pipelineId, kind, cycle);
268
+ return row ? j(row.verdict, null) : null;
269
+ }
270
+
271
+ /**
272
+ * Enumerate the History-side "extras" for a pipeline: the clarify Q&A and EVERY
273
+ * per-cycle review verdict. The single-row readers (readClarifyRow/readReviewRow)
274
+ * need a key/cycle up front; History has neither, so this lists them. clarify
275
+ * halves are UNWRAPPED to plain arrays (the columns store {questions:[…]} /
276
+ * {answers:[…]} — see writeClarify); a missing clarify row yields empty arrays.
277
+ * reviews is a flat, deterministically ordered (kind, cycle) list, each entry the
278
+ * parsed verdict spread with its {kind,cycle} so the UI can group/label without a
279
+ * second lookup. stepQuestions is the per-step ask-then-resume Q&A rounds
280
+ * (readStepQuestions, chronological insert order). Always returns arrays (never
281
+ * null) so callers render unconditionally.
282
+ * @param {string} pipelineId
283
+ * @returns {{clarify:{questions:Array, answers:Array}, reviews:Array<{kind:string,cycle:number,issues:Array,summary:string}>, stepQuestions:Array<{stepKey:string,round:number,nodeId:string,agentKey:string,questions:Array,answers:Array}>}}
284
+ */
285
+ export function readPipelineExtras(pipelineId) {
286
+ const c = getDb().prepare('SELECT questions, answers FROM clarify WHERE pipeline_id = ?').get(pipelineId);
287
+ const qWrap = c ? j(c.questions, null) : null;
288
+ const aWrap = c ? j(c.answers, null) : null;
289
+ const clarify = {
290
+ questions: Array.isArray(qWrap?.questions) ? qWrap.questions : [],
291
+ answers: Array.isArray(aWrap?.answers) ? aWrap.answers : [],
292
+ };
293
+ const reviews = getDb().prepare(
294
+ 'SELECT kind, cycle, verdict FROM reviews WHERE pipeline_id = ? ORDER BY kind, cycle'
295
+ ).all(pipelineId).map((r) => {
296
+ const v = j(r.verdict, {}) || {};
297
+ return {
298
+ kind: r.kind,
299
+ cycle: r.cycle,
300
+ issues: Array.isArray(v.issues) ? v.issues : [],
301
+ summary: typeof v.summary === 'string' ? v.summary : '',
302
+ };
303
+ });
304
+ return { clarify, reviews, stepQuestions: readStepQuestions(pipelineId) };
305
+ }
306
+
307
+ /**
308
+ * Upsert one sub_agents row (a Task/Agent child agent of a pipeline node). Idempotent
309
+ * on the (pipeline_id, id) PK: the spawn writes the full record; later lifecycle updates
310
+ * (finish / telemetry) pass only the changed fields and DO NOT clobber the rest. The
311
+ * UPDATE arm COALESCE-guards label/started_at/duration_ms/tokens/cost_usd exactly like
312
+ * writeState guards base_name/date_prefix (a NULL excluded never overwrites a set value),
313
+ * so a status-only finish update can never null the spawn-time label or accrued telemetry.
314
+ * status/finished_at/node_id/step_index/cycle/step_key always take the newest non-null.
315
+ *
316
+ * This is the IDEMPOTENT UPSERT path, NEVER the delete-all path — sub_agents must outlive
317
+ * writeState's pipeline_steps DELETE-all + re-INSERT (which is why the table FKs to
318
+ * pipelines, not pipeline_steps). Best-effort under WAL contention (mirrors writeReview/
319
+ * recordArtifact): the orchestrator's live `state.subAgents` snapshot is the reconcile
320
+ * source of truth, so a swallowed write surfaces in tests, never as a crashed run. A
321
+ * missing id/pipelineId is a no-op. The pipelines row must already exist (FK).
322
+ * @param {string} pipelineId
323
+ * @param {{id:string, label?:string, nodeId?:string, stepIndex?:number, cycle?:number,
324
+ * stepKey?:string, status?:string, startedAt?:string, finishedAt?:string,
325
+ * durationMs?:number, tokens?:number, costUsd?:number, subagentType?:string}} rec
326
+ */
327
+ export function upsertSubAgent(pipelineId, rec) {
328
+ if (!pipelineId || !rec || !rec.id) return;
329
+ try {
330
+ tx(() => {
331
+ getDb().prepare(`
332
+ INSERT INTO sub_agents (pipeline_id, id, step_key, node_id, step_index, cycle,
333
+ label, status, started_at, finished_at, duration_ms, tokens, cost_usd, ui_phase, skills, subagent_type, graphify_count)
334
+ VALUES (@pipeline_id,@id,@step_key,@node_id,@step_index,@cycle,@label,@status,
335
+ @started_at,@finished_at,@duration_ms,@tokens,@cost_usd,@ui_phase,@skills,@subagent_type,@graphify_count)
336
+ ON CONFLICT(pipeline_id, id) DO UPDATE SET
337
+ status = excluded.status,
338
+ step_key = COALESCE(excluded.step_key, step_key),
339
+ node_id = COALESCE(excluded.node_id, node_id),
340
+ step_index = COALESCE(excluded.step_index, step_index),
341
+ cycle = COALESCE(excluded.cycle, cycle),
342
+ label = COALESCE(excluded.label, label),
343
+ started_at = COALESCE(excluded.started_at, started_at),
344
+ finished_at = COALESCE(excluded.finished_at, finished_at),
345
+ duration_ms = COALESCE(excluded.duration_ms, duration_ms),
346
+ tokens = COALESCE(excluded.tokens, tokens),
347
+ cost_usd = COALESCE(excluded.cost_usd, cost_usd),
348
+ ui_phase = COALESCE(excluded.ui_phase, ui_phase),
349
+ skills = COALESCE(excluded.skills, skills),
350
+ subagent_type = COALESCE(excluded.subagent_type, subagent_type),
351
+ graphify_count = COALESCE(excluded.graphify_count, graphify_count)
352
+ `).run({
353
+ pipeline_id: pipelineId,
354
+ id: rec.id,
355
+ step_key: rec.stepKey ?? null,
356
+ node_id: rec.nodeId ?? null,
357
+ step_index: Number.isFinite(rec.stepIndex) ? rec.stepIndex : null,
358
+ cycle: Number.isFinite(rec.cycle) ? rec.cycle : null,
359
+ label: rec.label ?? null,
360
+ status: rec.status ?? 'running',
361
+ started_at: rec.startedAt ?? null,
362
+ finished_at: rec.finishedAt ?? null,
363
+ duration_ms: Number.isFinite(rec.durationMs) ? rec.durationMs : null,
364
+ tokens: Number.isFinite(rec.tokens) ? rec.tokens : null,
365
+ cost_usd: Number.isFinite(rec.costUsd) ? rec.costUsd : null,
366
+ ui_phase: rec.uiPhase ?? null,
367
+ skills: s(rec.skills), // s() = JSON.stringify or null; growing supersets overwrite via COALESCE
368
+ subagent_type: rec.subagentType ?? null, // scalar TEXT: bound directly (no s() JSON wrap)
369
+ graphify_count: Number.isFinite(rec.graphifyCount) ? rec.graphifyCount : null, // scalar INTEGER
370
+ });
371
+ });
372
+ } catch { /* best-effort: live state.subAgents is the reconcile source of truth; a swallowed write is caught by tests, not a crashed run. */ }
373
+ }
374
+
375
+ /**
376
+ * List a pipeline's sub-agents as the shared camelCase record array, ordered by
377
+ * (started_at, id) — the same order the UI groups/renders. Inverse of upsertSubAgent's
378
+ * column mapping (snake_case row -> camelCase record), mirroring stepRowToStep. Always
379
+ * returns an array (never null) so callers render unconditionally; nullable telemetry
380
+ * columns surface as null. Wired into rowToState so it rides every detail response.
381
+ * @param {string} pipelineId
382
+ * @returns {Array<{id:string, label:string|null, nodeId:string|null, stepIndex:number|null,
383
+ * cycle:number|null, stepKey:string|null, status:string, startedAt:string|null,
384
+ * finishedAt:string|null, durationMs:number|null, tokens:number|null, costUsd:number|null,
385
+ * subagentType:string|null}>}
386
+ */
387
+ export function listSubAgents(pipelineId) {
388
+ if (!pipelineId) return [];
389
+ return getDb().prepare(`
390
+ SELECT id, label, node_id, step_index, cycle, step_key, status,
391
+ started_at, finished_at, duration_ms, tokens, cost_usd, ui_phase, skills, subagent_type, graphify_count
392
+ FROM sub_agents WHERE pipeline_id = ? ORDER BY started_at, id
393
+ `).all(pipelineId).map((r) => ({
394
+ id: r.id,
395
+ label: r.label ?? null,
396
+ nodeId: r.node_id ?? null,
397
+ stepIndex: r.step_index ?? null,
398
+ cycle: r.cycle ?? null,
399
+ stepKey: r.step_key ?? null,
400
+ status: r.status,
401
+ startedAt: r.started_at ?? null,
402
+ finishedAt: r.finished_at ?? null,
403
+ durationMs: r.duration_ms ?? null,
404
+ tokens: r.tokens ?? null,
405
+ costUsd: r.cost_usd ?? null,
406
+ uiPhase: r.ui_phase ?? null,
407
+ skills: j(r.skills, []), // NULL -> [] so the UI always has an array (no pills)
408
+ subagentType: r.subagent_type ?? null, // scalar TEXT: mapped directly (no j() parse)
409
+ graphifyCount: r.graphify_count ?? null, // scalar INTEGER: NULL -> null (no badge)
410
+ }));
411
+ }
412
+
413
+ /**
414
+ * Persist a run's decomposition: its ordered phases + the self-contained task files.
415
+ * Idempotent UPSERT on the PKs (never the delete-all path), so a re-write or a later
416
+ * status update never duplicates rows. Best-effort under WAL contention (mirrors
417
+ * upsertSubAgent): the live decomposition is the runtime source of truth, so a
418
+ * swallowed write surfaces in tests, not as a crashed run. The pipelines row must
419
+ * already exist (FK). `phases` is [{ ordinal, tasks:[{ id, title, file, nodeId }] }].
420
+ * @param {string} pipelineId
421
+ * @param {Array<{ordinal:number, tasks:Array<{id:string,title?:string,file?:string,nodeId?:string}>}>} phases
422
+ */
423
+ export function writeDecomposition(pipelineId, phases) {
424
+ if (!pipelineId || !Array.isArray(phases)) return;
425
+ try {
426
+ tx(() => {
427
+ const insPhase = getDb().prepare(`
428
+ INSERT INTO pipeline_phases (pipeline_id, ordinal, status)
429
+ VALUES (?, ?, 'pending')
430
+ ON CONFLICT(pipeline_id, ordinal) DO NOTHING
431
+ `);
432
+ const insTask = getDb().prepare(`
433
+ INSERT INTO pipeline_tasks (pipeline_id, id, phase_ordinal, task_index, title, file_rel_path, node_id, status)
434
+ VALUES (@pipeline_id,@id,@phase_ordinal,@task_index,@title,@file_rel_path,@node_id,'pending')
435
+ ON CONFLICT(pipeline_id, id) DO UPDATE SET
436
+ phase_ordinal = excluded.phase_ordinal,
437
+ task_index = excluded.task_index,
438
+ title = COALESCE(excluded.title, title),
439
+ file_rel_path = COALESCE(excluded.file_rel_path, file_rel_path),
440
+ node_id = COALESCE(excluded.node_id, node_id)
441
+ `);
442
+ for (const ph of phases) {
443
+ if (!ph || !Number.isFinite(Number(ph.ordinal))) continue;
444
+ insPhase.run(pipelineId, Number(ph.ordinal));
445
+ const tasks = Array.isArray(ph.tasks) ? ph.tasks : [];
446
+ tasks.forEach((t, i) => {
447
+ if (!t || !t.id) return;
448
+ insTask.run({
449
+ pipeline_id: pipelineId,
450
+ id: String(t.id),
451
+ phase_ordinal: Number(ph.ordinal),
452
+ task_index: i,
453
+ title: t.title ?? null,
454
+ file_rel_path: t.file ?? null,
455
+ node_id: t.nodeId ?? null,
456
+ });
457
+ });
458
+ }
459
+ });
460
+ } catch { /* best-effort: live decomposition is the reconcile source of truth */ }
461
+ }
462
+
463
+ /** List a pipeline's phases, ordered by ordinal. Always an array. */
464
+ export function listPhases(pipelineId) {
465
+ if (!pipelineId) return [];
466
+ return getDb().prepare(
467
+ 'SELECT ordinal, status, started_at, finished_at FROM pipeline_phases WHERE pipeline_id = ? ORDER BY ordinal'
468
+ ).all(pipelineId).map((r) => ({
469
+ ordinal: r.ordinal,
470
+ status: r.status,
471
+ startedAt: r.started_at ?? null,
472
+ finishedAt: r.finished_at ?? null,
473
+ }));
474
+ }
475
+
476
+ /** List a pipeline's tasks, ordered by (phase_ordinal, task_index). Always an array. */
477
+ export function listTasks(pipelineId) {
478
+ if (!pipelineId) return [];
479
+ return getDb().prepare(`
480
+ SELECT id, phase_ordinal, task_index, title, file_rel_path, node_id, status, started_at, finished_at
481
+ FROM pipeline_tasks WHERE pipeline_id = ? ORDER BY phase_ordinal, task_index
482
+ `).all(pipelineId).map((r) => ({
483
+ id: r.id,
484
+ phaseOrdinal: r.phase_ordinal,
485
+ taskIndex: r.task_index,
486
+ title: r.title ?? null,
487
+ fileRelPath: r.file_rel_path ?? null,
488
+ nodeId: r.node_id ?? null,
489
+ status: r.status,
490
+ startedAt: r.started_at ?? null,
491
+ finishedAt: r.finished_at ?? null,
492
+ }));
493
+ }
494
+
495
+ /** Set a task's status + the matching timestamp (running -> started_at, terminal -> finished_at). Best-effort. */
496
+ export function updateTaskStatus(pipelineId, taskId, status, ts) {
497
+ if (!pipelineId || !taskId || !status) return;
498
+ const startedCol = status === 'running' ? ts ?? null : null;
499
+ const finishedCol = (status === 'done' || status === 'error') ? ts ?? null : null;
500
+ try {
501
+ tx(() => {
502
+ getDb().prepare(`
503
+ UPDATE pipeline_tasks SET
504
+ status = ?,
505
+ started_at = COALESCE(?, started_at),
506
+ finished_at = COALESCE(?, finished_at)
507
+ WHERE pipeline_id = ? AND id = ?
508
+ `).run(status, startedCol, finishedCol, pipelineId, taskId);
509
+ });
510
+ } catch { /* best-effort */ }
511
+ }
512
+
513
+ /** Set a phase's status + the matching timestamp. Best-effort. */
514
+ export function updatePhaseStatus(pipelineId, ordinal, status, ts) {
515
+ if (!pipelineId || !Number.isFinite(Number(ordinal)) || !status) return;
516
+ const startedCol = status === 'running' ? ts ?? null : null;
517
+ const finishedCol = (status === 'done' || status === 'error') ? ts ?? null : null;
518
+ try {
519
+ tx(() => {
520
+ getDb().prepare(`
521
+ UPDATE pipeline_phases SET
522
+ status = ?,
523
+ started_at = COALESCE(?, started_at),
524
+ finished_at = COALESCE(?, finished_at)
525
+ WHERE pipeline_id = ? AND ordinal = ?
526
+ `).run(status, startedCol, finishedCol, pipelineId, Number(ordinal));
527
+ });
528
+ } catch { /* best-effort */ }
529
+ }
530
+
531
+ /**
532
+ * Convert an arbitrary string to a safe kebab-case slug.
533
+ * - Lowercases, replaces non-alphanumerics with hyphens, collapses repeats,
534
+ * trims leading/trailing hyphens.
535
+ * - Returns "untitled" for empty input.
536
+ * @param {string} s
537
+ * @returns {string}
538
+ */
539
+ export function slugify(s) {
540
+ const out = String(s ?? '')
541
+ .normalize('NFKD')
542
+ .replace(/[̀-ͯ]/g, '') // strip combining marks
543
+ .toLowerCase()
544
+ .replace(/[^a-z0-9]+/g, '-')
545
+ .replace(/-{2,}/g, '-')
546
+ .replace(/^-+|-+$/g, '');
547
+ return out || 'untitled';
548
+ }
549
+
550
+ /**
551
+ * Current date as "DD-MM-YY" using the runtime system clock.
552
+ * @returns {string}
553
+ */
554
+ export function today() {
555
+ const d = new Date();
556
+ const dd = String(d.getDate()).padStart(2, '0');
557
+ const mm = String(d.getMonth() + 1).padStart(2, '0');
558
+ const yy = String(d.getFullYear() % 100).padStart(2, '0');
559
+ return `${dd}-${mm}-${yy}`;
560
+ }
561
+
562
+ /**
563
+ * Absolute artifact directory paths in the external store for `projectDir`,
564
+ * i.e. <worcaHome>/store/<projectKey(projectDir)>/{plans,reviews,pipelines}.
565
+ * Every reader/writer (plans, reviews, pipeline history) routes through here, so
566
+ * redirecting this one function moves all three out of the working tree at once.
567
+ * When `workspaceKey` is given, the root is the workspace store namespace
568
+ * (<worcaHome>/store/workspaces/<workspaceKey>) instead of the per-project key
569
+ * dir. Single-project callers (no second arg) are byte-identical.
570
+ * @param {string} projectDir
571
+ * @param {string} [workspaceKey] when set, routes to the workspace store
572
+ * @returns {{root:string, plans:string, reviews:string, pipelines:string}}
573
+ */
574
+ export function artifactPaths(projectDir, workspaceKey) {
575
+ const root = workspaceKey
576
+ ? workspaceStorePath(workspaceKey)
577
+ : projectStorePath(projectKey(projectDir));
578
+ return {
579
+ root,
580
+ plans: join(root, 'plans'),
581
+ reviews: join(root, 'reviews'),
582
+ pipelines: join(root, 'pipelines'),
583
+ };
584
+ }
585
+
586
+ /**
587
+ * Read or create the per-project meta (now a store_meta row, was meta.json).
588
+ * Returns the meta object either way. Never throws: a failed write still returns
589
+ * the computed meta so callers (createPipeline) get a project name without
590
+ * re-reading. `firstSeenAt` is preserved across re-runs because an existing row
591
+ * short-circuits. `_root` is retained for signature stability (now unused).
592
+ */
593
+ async function ensureMeta(projectDir, _root) {
594
+ void _root;
595
+ const key = projectKey(projectDir);
596
+ const existing = readStoreMeta(key);
597
+ if (existing) return existing; // firstSeenAt preserved
598
+ const canonical = canonicalProjectRoot(projectDir);
599
+ let name = basename(canonical) || 'project';
600
+ try {
601
+ const projects = await listProjects();
602
+ const hit = projects.find((pr) => {
603
+ try { return realpathSync(pr.path) === canonical; } catch { return resolve(pr.path) === canonical; }
604
+ });
605
+ if (hit) name = hit.name;
606
+ } catch { /* registry optional */ }
607
+ const meta = { key, path: canonical, name, firstSeenAt: new Date().toISOString() };
608
+ try { writeStoreMeta(key, 'project', meta); } catch { /* never block a run */ }
609
+ return meta;
610
+ }
611
+
612
+ /**
613
+ * Workspace variant of ensureMeta. Persists the §5.2 workspace meta shape
614
+ * ({key,id,name,projectKeys,projectPaths,firstSeenAt}) to a store_meta row —
615
+ * distinct from the project shape. Name resolution prefers the registry
616
+ * (readWorkspace(workspaceId).name) and falls back to the primary canonical root
617
+ * basename. Never throws, never blocks a run. `projectKeys`/`projectPaths` are
618
+ * index-aligned and sorted by projectKey ascending.
619
+ */
620
+ async function ensureWorkspaceMeta(primaryProjectDir, workspaceKey, opts = {}) {
621
+ const existing = readStoreMeta(workspaceKey);
622
+ if (existing) return existing;
623
+
624
+ const members = Array.isArray(opts.projects) ? opts.projects.slice() : [];
625
+ members.sort((a, b) => {
626
+ const ka = a?.projectKey ?? '';
627
+ const kb = b?.projectKey ?? '';
628
+ return ka < kb ? -1 : ka > kb ? 1 : 0;
629
+ });
630
+ let name = typeof opts.workspaceName === 'string' && opts.workspaceName ? opts.workspaceName : '';
631
+ if (!name) {
632
+ try {
633
+ const { readWorkspace } = await import('./workspaces.mjs');
634
+ const ws = await readWorkspace(opts.workspaceId || workspaceKey);
635
+ if (ws && ws.name) name = ws.name;
636
+ } catch { /* registry optional */ }
637
+ }
638
+ if (!name) name = basename(canonicalProjectRoot(primaryProjectDir)) || 'workspace';
639
+
640
+ const meta = {
641
+ key: workspaceKey,
642
+ id: opts.workspaceId || workspaceKey,
643
+ name,
644
+ projectKeys: members.map((m) => m.projectKey),
645
+ projectPaths: members.map((m) => resolve(m.projectDir)),
646
+ firstSeenAt: new Date().toISOString(),
647
+ };
648
+ try { writeStoreMeta(workspaceKey, 'workspace', meta); } catch { /* never block a run */ }
649
+ return meta;
650
+ }
651
+
652
+ /**
653
+ * Ensure the artifact directories (plans/reviews/pipelines) + meta.json exist.
654
+ * When `workspaceKey` is set, routes to the workspace store and writes the
655
+ * workspace meta shape; `opts` carries {workspaceId, workspaceName, projects}.
656
+ * Single-project callers (no second arg) are byte-identical.
657
+ */
658
+ export async function ensureArtifactDirs(projectDir, workspaceKey, opts = {}) {
659
+ const p = artifactPaths(projectDir, workspaceKey);
660
+ await mkdir(p.plans, { recursive: true });
661
+ await mkdir(p.reviews, { recursive: true });
662
+ await mkdir(p.pipelines, { recursive: true });
663
+ const meta = workspaceKey
664
+ ? await ensureWorkspaceMeta(projectDir, workspaceKey, opts)
665
+ : await ensureMeta(projectDir, p.root);
666
+ return { ...p, meta };
667
+ }
668
+
669
+ /**
670
+ * Path for a plan markdown file.
671
+ * version 1 => <DD-MM-YY-baseName>.md ; version N>1 => <...>-vN.md
672
+ *
673
+ * The date prefix is stamped at call time by default. Long-running pipelines
674
+ * that cross midnight would otherwise give v1 one date and v2/v3 the next day's
675
+ * date, breaking the shared-base linkage. The orchestrator therefore captures
676
+ * the prefix once at run start and passes it as `datePrefix` so every -vN
677
+ * version shares the v1 prefix.
678
+ * @param {string} projectDir
679
+ * @param {string} baseName already-slugified base (date is prefixed here)
680
+ * @param {number} [version=1]
681
+ * @param {string} [datePrefix] fixed DD-MM-YY prefix (defaults to today())
682
+ * @param {string} [workspaceKey] when set, routes to the workspace store
683
+ * @returns {string}
684
+ */
685
+ export function planPath(projectDir, baseName, version = 1, datePrefix, workspaceKey) {
686
+ const { plans } = artifactPaths(projectDir, workspaceKey);
687
+ const v = Number(version) > 1 ? `-v${Number(version)}` : '';
688
+ const date = datePrefix || today();
689
+ return join(plans, `${date}-${baseName}${v}.md`);
690
+ }
691
+
692
+ /**
693
+ * Path for an implementation review markdown file.
694
+ * @param {string} projectDir
695
+ * @param {string} baseName
696
+ * @param {string} [datePrefix] fixed DD-MM-YY prefix (defaults to today())
697
+ * @param {string} [kind='impl-review']
698
+ * @param {string} [workspaceKey] when set, routes to the workspace store
699
+ * @returns {string}
700
+ */
701
+ export function reviewPath(projectDir, baseName, datePrefix, kind = 'impl-review', workspaceKey) {
702
+ const { reviews } = artifactPaths(projectDir, workspaceKey);
703
+ const date = datePrefix || today();
704
+ return join(reviews, `${date}-${baseName}-${kind}.md`);
705
+ }
706
+
707
+ /** Short random id (8 hex chars) used to make pipeline dirs unique. */
708
+ function shortId() {
709
+ return randomBytes(4).toString('hex');
710
+ }
711
+
712
+ /**
713
+ * Freeze a workspace description into a run: take a VERBATIM snapshot of the text so
714
+ * later registry edits never retroactively alter a started run. No length cap — the
715
+ * description's size is bounded only by the workspace-scanner prompt. Non-strings
716
+ * become ''.
717
+ * @param {string} text
718
+ * @returns {string}
719
+ */
720
+ function freezeDescription(text) {
721
+ return typeof text === 'string' ? text : '';
722
+ }
723
+
724
+ /**
725
+ * Resolve a possibly-relative path against a base directory.
726
+ */
727
+ function resolveAgainst(base, p) {
728
+ return isAbsolute(p) ? p : resolve(base, p);
729
+ }
730
+
731
+ /**
732
+ * Create a new pipeline directory and seed it with the prompt, extras and an audit
733
+ * header (pipeline.md). The structured run state is INSERTed as a pipelines row
734
+ * (Task 3.3/3.5) — there is no state.json. The prompt (and workspace-description /
735
+ * extras) markdown is indexed in the artifacts table.
736
+ *
737
+ * When `opts.workspaceKey` is set the pipeline is written to the WORKSPACE store
738
+ * (store/workspaces/<key>/), the pipelines row carries the §5.2 workspace superset
739
+ * (collapsed into workspace_meta)
740
+ * (target:'workspace', workspaceId/Key/Name, frozen workspaceDescription, sorted
741
+ * projectKeys, projects[], empty checkpointRefs/branches), and a frozen
742
+ * workspace-description.md snapshot is written into the pipeline dir. The frozen
743
+ * description is frozen verbatim (cap-on-freeze snapshot, no length cap; the editable registry
744
+ * copy is untouched). `projectDir` is the PRIMARY member (projects[0] after sort).
745
+ * Absent the workspace opts the single-project path is byte-identical.
746
+ *
747
+ * @param {string} projectDir single-project dir, or the workspace primary dir
748
+ * @param {object} opts
749
+ * @param {string} [opts.prompt] inline prompt text
750
+ * @param {string} [opts.promptFile] path to a markdown file to use as the prompt
751
+ * @param {string} [opts.promptText] precomputed prompt body (sources.mjs seam);
752
+ * used when inline `prompt` is absent/empty
753
+ * @param {string} [opts.sourceType] 'prompt' | 'markdown' | 'plugin' (defaults derived)
754
+ * @param {object} [opts.sourceMeta] { plugin, sourceId, taskId, url, title } | null
755
+ * @param {string} [opts.guardrailsId] the run's selected guardrail set id (per-run)
756
+ * @param {string[]} [opts.extras] paths to extra files copied into dir/extras
757
+ * @param {string} [opts.title] human title (defaults to derived text)
758
+ * @param {string} [opts.workspaceKey] opt-in: route to the workspace store
759
+ * @param {string} [opts.workspaceId] == workspaceKey
760
+ * @param {string} [opts.workspaceName]
761
+ * @param {string} [opts.workspaceDescription] frozen verbatim (no cap)
762
+ * @param {Array<{projectKey,projectDir,projectName}>} [opts.projects] sorted members
763
+ * @returns {Promise<{id:string, dir:string, promptText:string}>}
764
+ */
765
+ export async function createPipeline(projectDir, opts = {}) {
766
+ const {
767
+ prompt, promptFile, extras = [], title,
768
+ promptText: precomputedPromptText = null, sourceType = null, sourceMeta = null,
769
+ guardrailsId = null,
770
+ workspaceKey = null, workspaceId = null, workspaceName = null,
771
+ workspaceDescription = '', projects = null,
772
+ } = opts;
773
+ const paths = await ensureArtifactDirs(projectDir, workspaceKey || undefined, {
774
+ workspaceId: workspaceId || workspaceKey,
775
+ workspaceName,
776
+ projects,
777
+ });
778
+ const key = projectKey(projectDir);
779
+ const projectName = (paths.meta && paths.meta.name) || basename(resolve(projectDir));
780
+
781
+ // Resolve the prompt text. Precedence: inline `prompt` (legacy callers) >
782
+ // precomputed `promptText` (the sources.mjs seam) > `promptFile` read (direct
783
+ // CLI/test callers that pass only a path — kept so pre-seam callers behave
784
+ // byte-identically; the seam passes both promptText AND promptFile so the
785
+ // verbatim copy below is unchanged).
786
+ let promptText = typeof prompt === 'string' ? prompt : '';
787
+ if (!promptText && typeof precomputedPromptText === 'string') promptText = precomputedPromptText;
788
+ if (!promptText && promptFile) {
789
+ try {
790
+ promptText = await readFile(resolveAgainst(projectDir, promptFile), 'utf8');
791
+ } catch {
792
+ promptText = '';
793
+ }
794
+ }
795
+
796
+ const resolvedTitle =
797
+ (title && String(title).trim()) ||
798
+ firstMeaningfulLine(promptText) ||
799
+ 'orchestration';
800
+
801
+ const id = shortId();
802
+ const slug = slugify(resolvedTitle).slice(0, 48) || 'pipeline';
803
+ const dirName = `${today()}-${slug}-${id}`;
804
+ const dir = join(paths.pipelines, dirName);
805
+ await mkdir(dir, { recursive: true });
806
+
807
+ // Seed the prompt. If a markdown file was provided, copy it verbatim;
808
+ // otherwise persist the inline text. Either way prompt.md is the source.
809
+ const promptDest = join(dir, 'prompt.md');
810
+ if (promptFile) {
811
+ try {
812
+ await copyFile(resolveAgainst(projectDir, promptFile), promptDest);
813
+ } catch {
814
+ await writeFile(promptDest, promptText, 'utf8');
815
+ }
816
+ } else {
817
+ await writeFile(promptDest, promptText, 'utf8');
818
+ }
819
+
820
+ // Copy optional extra files. Each successfully-copied extra is indexed in the
821
+ // artifacts table (dir-relative "extras/<name>") AFTER writeState INSERTs the
822
+ // pipelines row (FK) — collect them here, record below.
823
+ const copiedExtras = [];
824
+ if (Array.isArray(extras) && extras.length) {
825
+ const extrasDir = join(dir, 'extras');
826
+ await mkdir(extrasDir, { recursive: true });
827
+ for (const ex of extras) {
828
+ if (!ex) continue;
829
+ const src = resolveAgainst(projectDir, ex);
830
+ try {
831
+ await copyFile(src, join(extrasDir, basename(src)));
832
+ copiedExtras.push(join('extras', basename(src)));
833
+ } catch {
834
+ // Skip unreadable extras; never fail pipeline creation on a bad path.
835
+ }
836
+ }
837
+ }
838
+
839
+ const startedAt = new Date().toISOString();
840
+ const state = {
841
+ id,
842
+ title: resolvedTitle,
843
+ projectDir: resolve(projectDir),
844
+ projectKey: key,
845
+ projectName,
846
+ status: 'created',
847
+ phase: 'created',
848
+ cycle: 0,
849
+ startedAt,
850
+ updatedAt: startedAt,
851
+ prompt: promptText, // persisted to the pipelines.prompt column (was prompt.md only)
852
+ artifacts: [],
853
+ // Task-source provenance (spec §10). Derivation covers direct legacy callers;
854
+ // the orchestrator passes sourceType explicitly through the sources.mjs seam.
855
+ sourceType: sourceType || (sourceMeta ? 'plugin' : (promptFile ? 'markdown' : 'prompt')),
856
+ sourceMeta: sourceMeta || null,
857
+ // The run's selected guardrail set id (per-run model; 'permissive' for
858
+ // unguarded runs). Creation-immutable, like sourceType: written on INSERT,
859
+ // never touched by updates. NULL = legacy/pre-entity or non-orchestrator row.
860
+ guardrailsId: guardrailsId || null,
861
+ };
862
+
863
+ // Workspace runs carry the §5.2 superset, discriminated by target:'workspace'.
864
+ // The description is FROZEN here verbatim (no cap) so later registry edits never
865
+ // retroactively alter a started run; branches/checkpointRefs start empty and are
866
+ // populated by the orchestrator at worktree/checkpoint setup.
867
+ if (workspaceKey) {
868
+ const members = (Array.isArray(projects) ? projects.slice() : []).sort(
869
+ (a, b) => {
870
+ const ka = a?.projectKey ?? '';
871
+ const kb = b?.projectKey ?? '';
872
+ return ka < kb ? -1 : ka > kb ? 1 : 0;
873
+ },
874
+ );
875
+ const frozenDescription = freezeDescription(workspaceDescription);
876
+ state.target = 'workspace';
877
+ state.workspaceId = workspaceId || workspaceKey;
878
+ state.workspaceKey = workspaceKey;
879
+ state.workspaceName = workspaceName || (paths.meta && paths.meta.name) || '';
880
+ state.workspaceDescription = frozenDescription;
881
+ state.projectKeys = members.map((m) => m.projectKey);
882
+ state.projects = members.map((m) => ({
883
+ projectKey: m.projectKey,
884
+ projectDir: resolve(m.projectDir),
885
+ projectName: m.projectName ?? basename(resolve(m.projectDir)),
886
+ }));
887
+ state.checkpointRefs = {};
888
+ state.branches = {};
889
+ await writeFile(join(dir, 'workspace-description.md'), frozenDescription, 'utf8');
890
+ }
891
+
892
+ // Persist the run state by INSERTing the pipelines row (writeState, Task 3.3) —
893
+ // no more state.json on disk. writeState also seeds the dir->id cache
894
+ // (rememberDir) so appendAudit resolves this run without any call-site change
895
+ // (A4). recordArtifact runs AFTER the row exists (FK -> pipelines).
896
+ await writeState(dir, state);
897
+ recordArtifact(id, 'prompt', 'prompt.md');
898
+ if (workspaceKey) recordArtifact(id, 'workspace-description', 'workspace-description.md');
899
+ for (const rel of copiedExtras) recordArtifact(id, 'extra', rel);
900
+
901
+ return { id, dir, promptText };
902
+ }
903
+
904
+ function firstMeaningfulLine(text) {
905
+ if (!text) return '';
906
+ for (const line of String(text).split(/\r?\n/)) {
907
+ const t = line.replace(/^#+\s*/, '').trim();
908
+ if (t) return t.slice(0, 80);
909
+ }
910
+ return '';
911
+ }
912
+
913
+ /**
914
+ * Append a timeline entry to the pipeline's audit trail (was a markdown line in
915
+ * pipeline.md). Now inserts a pipeline_events row {ts, text}. The pipeline id is
916
+ * resolved from the dir: a dir->id cache fast path (seeded by createPipeline/
917
+ * writeState), falling back to parsing the trailing 8-hex id from the dir basename
918
+ * (createPipeline names dirs "<DD-MM-YY>-<slug>-<id>", id = 8 lowercase hex). When
919
+ * no id can be resolved the call is a safe no-op (audit is best-effort, exactly as
920
+ * the old appendFile could fail silently). Signature + async-ness unchanged so the
921
+ * ~20 orchestrator call sites need no edit (A4).
922
+ * @param {string} pipelineDir
923
+ * @param {string} markdownLine
924
+ * @returns {Promise<void>}
925
+ */
926
+ export async function appendAudit(pipelineDir, markdownLine) {
927
+ const id = resolvePipelineId(pipelineDir);
928
+ if (!id) return;
929
+ const ts = new Date().toISOString();
930
+ const text = String(markdownLine ?? '').trim();
931
+ try {
932
+ tx(() => {
933
+ getDb().prepare('INSERT INTO pipeline_events (pipeline_id, ts, text) VALUES (?, ?, ?)')
934
+ .run(id, ts, text);
935
+ });
936
+ } catch { /* audit is best-effort; never break a run on a logging failure */ }
937
+ }
938
+
939
+ /** Resolve the 8-hex pipeline id for a run dir: cache hit, else parse the basename. */
940
+ export function resolvePipelineId(pipelineDir) {
941
+ if (!pipelineDir) return null;
942
+ const hit = _dirIdCache.get(resolve(pipelineDir));
943
+ if (hit) return hit;
944
+ const m = DIR_ID_RE.exec(basename(pipelineDir));
945
+ return m ? m[1].toLowerCase() : null;
946
+ }
947
+
948
+ /** Absolute pipelineDir -> 8-hex id cache; the appendAudit (Task 3.4) fast path. */
949
+ const _dirIdCache = new Map();
950
+ /** Seed the dir->id cache (called by writeState and, later, createPipeline). */
951
+ function rememberDir(dir, id) { if (dir && id) _dirIdCache.set(resolve(dir), id); }
952
+
953
+ /**
954
+ * Persist the full state object: UPSERT its pipelines row and REPLACE its
955
+ * pipeline_steps rows, in one transaction. The id is resolved from stateObj.id.
956
+ * `pipelineDir` is retained for signature stability + to seed the dir->id cache
957
+ * (appendAudit fast path). Returns the object actually persisted (updatedAt
958
+ * stamped), matching the legacy contract. node:sqlite is synchronous; the function
959
+ * stays async so every existing `await writeState(...)` call site is unchanged.
960
+ *
961
+ * A11(a): the ON CONFLICT(id) DO UPDATE clause SETs ONLY the columns that
962
+ * legitimately mutate during a run. It deliberately does NOT touch the
963
+ * creation-immutable identity columns (project_key, prompt, target, title,
964
+ * workspace_key, started_at, source_type, source_ref, guardrails_id) — the
965
+ * orchestrator's this.state omits several of them (orchestrator.mjs:174-191), so
966
+ * a blanket "SET <every column>=excluded.<column>" would null them on the first
967
+ * post-create persist (and _persist's catch{} would hide the loss). The INSERT
968
+ * arm still writes every column; only the UPDATE arm is curated.
969
+ *
970
+ * 3.5 fix: base_name/date_prefix are the one exception that must still be in the
971
+ * UPDATE arm — createPipeline's INSERT leaves them NULL (state has neither field,
972
+ * §0.2) and the orchestrator sets this.state.baseName/datePrefix only at
973
+ * orchestrator.mjs:351-352, just before the first _persist(). If they were
974
+ * EXCLUDED from UPDATE (as the literal A11a list said) they would persist as
975
+ * permanent NULL and Task 3.7's reader + the Task 3.13 delete (keyed on
976
+ * <datePrefix>-<base>) would fail to find the shared plans/reviews markdown. So
977
+ * they are updated with a COALESCE guard: COALESCE(excluded.col, col) fills
978
+ * NULL->value once and NEVER clobbers a set value back to NULL — preserving the
979
+ * A11a anti-clobber guarantee (a NULL excluded never overwrites a set value)
980
+ * while closing the dead-NULL bug.
981
+ * @param {string} pipelineDir
982
+ * @param {object} stateObj
983
+ * @returns {Promise<object>}
984
+ */
985
+ export async function writeState(pipelineDir, stateObj) {
986
+ const obj = { ...stateObj, updatedAt: new Date().toISOString() };
987
+ const id = obj.id;
988
+ if (!id) return obj; // pre-id state (constructor default): nothing to persist yet
989
+ rememberDir(pipelineDir, id); // dir->id cache for appendAudit
990
+ tx(() => {
991
+ getDb().prepare(`
992
+ INSERT INTO pipelines (id, project_key, workspace_key, target, title, base_name,
993
+ date_prefix, status, phase, cycle, started_at, updated_at, total_cost_usd,
994
+ total_active_ms, prompt, branch, workspace_meta, stepper, tools, resume_point,
995
+ source_type, source_ref, guardrails_id)
996
+ VALUES (@id,@project_key,@workspace_key,@target,@title,@base_name,@date_prefix,
997
+ @status,@phase,@cycle,@started_at,@updated_at,@total_cost_usd,@total_active_ms,
998
+ @prompt,@branch,@workspace_meta,@stepper,@tools,@resume_point,
999
+ @source_type,@source_ref,@guardrails_id)
1000
+ ON CONFLICT(id) DO UPDATE SET
1001
+ status=excluded.status, phase=excluded.phase, cycle=excluded.cycle,
1002
+ updated_at=excluded.updated_at, total_cost_usd=excluded.total_cost_usd,
1003
+ total_active_ms=excluded.total_active_ms, branch=excluded.branch,
1004
+ workspace_meta=excluded.workspace_meta, stepper=excluded.stepper,
1005
+ tools=excluded.tools,
1006
+ resume_point=excluded.resume_point,
1007
+ base_name=COALESCE(excluded.base_name, base_name),
1008
+ date_prefix=COALESCE(excluded.date_prefix, date_prefix)
1009
+ `).run(toPipelineRow(obj));
1010
+
1011
+ getDb().prepare('DELETE FROM pipeline_steps WHERE pipeline_id = ?').run(id);
1012
+ const ins = getDb().prepare(`
1013
+ INSERT INTO pipeline_steps (pipeline_id, key, node_id, phase, step_index, cycle,
1014
+ status, started_at, updated_at, active_ms, running_since, cost_usd, session_id, skills, graphify_count)
1015
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
1016
+ `);
1017
+ for (const st of Array.isArray(obj.steps) ? obj.steps : []) {
1018
+ ins.run(
1019
+ id, st.key, st.nodeId ?? null, st.phase ?? null,
1020
+ st.stepIndex ?? null, st.cycle ?? null, st.status ?? null,
1021
+ st.startedAt ?? null, st.updatedAt ?? null,
1022
+ Number.isFinite(st.activeMs) ? st.activeMs : 0,
1023
+ st.runningSince == null ? null : String(st.runningSince),
1024
+ Number.isFinite(st.costUsd) ? st.costUsd : 0,
1025
+ st.sessionId ?? null,
1026
+ s(st.skills),
1027
+ Number.isFinite(st.graphifyCount) ? st.graphifyCount : null,
1028
+ );
1029
+ }
1030
+ });
1031
+ return obj;
1032
+ }
1033
+
1034
+ /**
1035
+ * Mutate ONLY the title of an existing pipeline row. writeState()'s UPSERT treats
1036
+ * title as creation-immutable, so this is the single sanctioned post-creation path.
1037
+ * Best-effort (mirrors writeReview/recordArtifact): a failure must not crash a run.
1038
+ * @param {string} pipelineId
1039
+ * @param {string} title
1040
+ */
1041
+ export function updatePipelineTitle(pipelineId, title) {
1042
+ if (!pipelineId || typeof title !== 'string' || !title.trim()) return;
1043
+ try {
1044
+ tx(() => {
1045
+ getDb()
1046
+ .prepare('UPDATE pipelines SET title = @title, updated_at = @updated_at WHERE id = @id')
1047
+ .run({ id: pipelineId, title: title.trim(), updated_at: new Date().toISOString() });
1048
+ });
1049
+ } catch {
1050
+ /* best-effort: the live state event still carries the new title */
1051
+ }
1052
+ }
1053
+
1054
+ /**
1055
+ * Persist the last observed PR facts on the pipeline row (spec §6.8).
1056
+ * Best-effort (PR facts are re-observable); NEVER touches updated_at — the
1057
+ * stats layer uses updated_at as the terminal-write proxy.
1058
+ * @param {string} pipelineId
1059
+ * @param {{ url:string, number?:number|null, state?:string }} pr
1060
+ */
1061
+ export function persistPrState(pipelineId, pr) {
1062
+ if (!pipelineId || !pr || !pr.url) return;
1063
+ try {
1064
+ tx(() => {
1065
+ getDb().prepare(
1066
+ `UPDATE pipelines SET pr_url = ?, pr_number = ?, pr_state = ?, pr_checked_at = ?
1067
+ WHERE id = ?`,
1068
+ ).run(pr.url, pr.number ?? null, pr.state ?? 'OPEN', new Date().toISOString(), pipelineId);
1069
+ });
1070
+ } catch { /* best-effort */ }
1071
+ }
1072
+
1073
+ /**
1074
+ * The status a stale (crashed/killed) run is reconciled to. Distinct from a user
1075
+ * 'stopped' and a real 'error': the owning process died before Orchestrator.run()'s
1076
+ * catch/finally could write a terminal status, so the row was frozen at 'running'.
1077
+ */
1078
+ export const INTERRUPTED_STATUS = 'interrupted';
1079
+
1080
+ // The non-terminal statuses a run can be frozen at by a crash. 'pausing' is the
1081
+ // graceful-shutdown window — a crash there is an interruption. 'paused' is NOT
1082
+ // here: it is intentional and indefinite, never swept.
1083
+ const RECONCILE_NON_TERMINAL = ['created', 'starting', 'running', 'pausing'];
1084
+
1085
+ /**
1086
+ * Staleness window (ms). A non-terminal row older than this AND not live is dead.
1087
+ * 30 min is deliberately generous: _persist() advances updated_at only on phase
1088
+ * boundaries (orchestrator.mjs:1846) and on an agent's terminal `result` event
1089
+ * (_recordCost :1888-1901) — a single long agent call can be silent for minutes.
1090
+ * A window shorter than that risks relabeling a genuinely-live run owned by a
1091
+ * CONCURRENT process (CLI+UI share one DB, db.mjs:44). The user's stuck record is
1092
+ * hours/days old, so it is swept immediately regardless. Override via env for tests/ops.
1093
+ */
1094
+ const DEFAULT_STALE_RUN_MS = 30 * 60 * 1000;
1095
+ function staleRunMs() {
1096
+ const n = Number(process.env.WORCA_STALE_RUN_MS);
1097
+ return Number.isFinite(n) && n > 0 ? n : DEFAULT_STALE_RUN_MS;
1098
+ }
1099
+
1100
+ /** Heartbeat refresh interval (ms): the running process re-stamps heartbeat_at this often. */
1101
+ export const HEARTBEAT_INTERVAL_MS = 30 * 1000;
1102
+
1103
+ /**
1104
+ * Heartbeat staleness window (ms). A running/pausing row whose heartbeat_at is older than
1105
+ * this is treated as dead regardless of host — the heartbeat arm is the authoritative liveness
1106
+ * signal and handles PID reuse (a reused pid reads "alive" from probe, but heartbeat went cold
1107
+ * when the original process died). 90s ≫ the 30s interval, so two missed beats are tolerated.
1108
+ */
1109
+ const DEFAULT_HEARTBEAT_STALE_MS = 90 * 1000;
1110
+ function heartbeatStaleMs() {
1111
+ const n = Number(process.env.WORCA_HEARTBEAT_STALE_MS);
1112
+ return Number.isFinite(n) && n > 0 ? n : DEFAULT_HEARTBEAT_STALE_MS;
1113
+ }
1114
+
1115
+ /**
1116
+ * Default pid liveness probe. kill(pid,0) sends no signal — probes existence/permission only.
1117
+ * EPERM => the pid exists but is owned by another user (alive). ESRCH / any other error => dead.
1118
+ * Non-int / <=0 => dead. CALLER MUST gate this on owner_host === thisHost.
1119
+ * @param {number} pid
1120
+ * @returns {boolean}
1121
+ */
1122
+ function defaultPidAlive(pid) {
1123
+ if (!Number.isInteger(pid) || pid <= 0) return false;
1124
+ try { process.kill(pid, 0); return true; }
1125
+ catch (e) { return !!e && e.code === 'EPERM'; }
1126
+ }
1127
+
1128
+ /**
1129
+ * Stamp ownership when a process starts driving a run: owner_pid/owner_host + initial heartbeat.
1130
+ * Status-guarded so it never resurrects a terminal row. Best-effort.
1131
+ * @param {string} pipelineId
1132
+ * @param {{ pid?:number, host?:string, now?:number }} [opts]
1133
+ */
1134
+ export function claimPipelineOwnership(pipelineId, { pid = process.pid, host = hostname(), now = Date.now() } = {}) {
1135
+ if (!pipelineId) return;
1136
+ try {
1137
+ tx(() => {
1138
+ getDb().prepare(`
1139
+ UPDATE pipelines SET owner_pid = ?, owner_host = ?, heartbeat_at = ?
1140
+ WHERE id = ? AND status IN ('running', 'pausing')
1141
+ `).run(pid, host, new Date(now).toISOString(), pipelineId);
1142
+ });
1143
+ } catch { /* liveness is best-effort; never crash a run */ }
1144
+ }
1145
+
1146
+ /**
1147
+ * Lightweight heartbeat tick: refresh heartbeat_at ONLY. Status-guarded so a beat that fires
1148
+ * after a terminal write is a no-op. Returns the number of rows updated (0 or 1).
1149
+ * @param {string} pipelineId
1150
+ * @param {{ now?:number }} [opts]
1151
+ * @returns {number}
1152
+ */
1153
+ export function touchHeartbeat(pipelineId, { now = Date.now() } = {}) {
1154
+ if (!pipelineId) return 0;
1155
+ try {
1156
+ return getDb().prepare(`
1157
+ UPDATE pipelines SET heartbeat_at = ?
1158
+ WHERE id = ? AND status IN ('running', 'pausing')
1159
+ `).run(new Date(now).toISOString(), pipelineId).changes;
1160
+ } catch { return 0; }
1161
+ }
1162
+
1163
+ /**
1164
+ * Drop ownership/heartbeat (NULL all three) when a run reaches any terminal/paused status.
1165
+ * Unconditional by status (the run is over); best-effort.
1166
+ * @param {string} pipelineId
1167
+ */
1168
+ export function clearPipelineOwnership(pipelineId) {
1169
+ if (!pipelineId) return;
1170
+ try {
1171
+ tx(() => {
1172
+ getDb().prepare(
1173
+ 'UPDATE pipelines SET owner_pid = NULL, owner_host = NULL, heartbeat_at = NULL WHERE id = ?',
1174
+ ).run(pipelineId);
1175
+ });
1176
+ } catch { /* best-effort */ }
1177
+ }
1178
+
1179
+ /**
1180
+ * Decide whether a non-terminal row is dead. Three arms (first match wins):
1181
+ *
1182
+ * Arm 1 (PID, same-host dead detection):
1183
+ * owner_host === thisHost && owner_pid set && pid NOT alive → dead immediately.
1184
+ *
1185
+ * Arm 2 (Heartbeat — authoritative, handles PID reuse):
1186
+ * heartbeat_at set && older than hbStaleMs → dead.
1187
+ * Fires for ANY row (any host), including same-host rows where pid appears alive.
1188
+ * A reused pid reads "alive" from probe but the original process's heartbeat went cold.
1189
+ *
1190
+ * Arm 3 (Legacy time window, ownerless rows only):
1191
+ * No heartbeat_at AND no owner_pid AND COALESCE(updated_at,started_at) < cutoff → dead.
1192
+ * Preserves today's behavior for pre-v10 rows. NULL-timestamp ownerless rows return false.
1193
+ *
1194
+ * @param {object} row
1195
+ * @param {{ host:string, now:number, staleMs:number, hbStaleMs:number, pidAlive?:(pid:number)=>boolean }} ctx
1196
+ * @returns {boolean}
1197
+ */
1198
+ export function isDeadOwner(row, { host, now, staleMs, hbStaleMs, pidAlive = defaultPidAlive }) {
1199
+ const ownedHere = row.owner_host === host && row.owner_pid != null;
1200
+
1201
+ // Arm 1: dead pid on this host → dead immediately.
1202
+ if (ownedHere && !pidAlive(row.owner_pid)) return true;
1203
+
1204
+ // Arm 2: heartbeat arm — authoritative, covers cross-host and same-host PID reuse.
1205
+ if (row.heartbeat_at != null) {
1206
+ const beat = Date.parse(row.heartbeat_at);
1207
+ return Number.isFinite(beat) && (now - beat) > hbStaleMs;
1208
+ }
1209
+
1210
+ // Arm 3: legacy ownerless rows (both owner_pid and heartbeat_at are NULL).
1211
+ if (row.owner_pid == null) {
1212
+ const ts = Date.parse(row.updated_at || row.started_at || '');
1213
+ return Number.isFinite(ts) && ts < (now - staleMs);
1214
+ }
1215
+
1216
+ // owner_pid set on another host, no heartbeat: leave for that host's sweep.
1217
+ return false;
1218
+ }
1219
+
1220
+ /**
1221
+ * Flip stale/crashed non-terminal pipeline rows (created/starting/running/pausing) to
1222
+ * INTERRUPTED. Uses three-arm liveness detection (PID + heartbeat + legacy time). Rows in
1223
+ * `liveIds` (live in THIS process) are never touched. The status-guarded UPDATE also NULLs
1224
+ * the owner columns so a reclassified row is clean and idempotent.
1225
+ *
1226
+ * @param {{ host?:string, staleMs?:number, hbStaleMs?:number, liveIds?:string[], now?:number, pidAlive?:Function }} [opts]
1227
+ * @returns {{ reconciled:number, ids:string[] }}
1228
+ */
1229
+ export function reconcileStaleRunning({
1230
+ host = hostname(), staleMs = staleRunMs(), hbStaleMs = heartbeatStaleMs(),
1231
+ liveIds = [], now = Date.now(),
1232
+ pidAlive = defaultPidAlive,
1233
+ } = {}) {
1234
+ const live = new Set(liveIds.filter(Boolean));
1235
+ const placeholders = RECONCILE_NON_TERMINAL.map(() => '?').join(', ');
1236
+
1237
+ // Read ALL non-terminal rows (no SQL time pre-filter: the PID arm must see fresh rows).
1238
+ // In normal operation non-terminal rows are near-zero, so this is cheap.
1239
+ const rows = getDb().prepare(`
1240
+ SELECT id, status, updated_at, started_at, owner_pid, owner_host, heartbeat_at
1241
+ FROM pipelines
1242
+ WHERE status IN (${placeholders})
1243
+ `).all(...RECONCILE_NON_TERMINAL);
1244
+
1245
+ const candidates = rows
1246
+ .filter((r) => !live.has(r.id) && isDeadOwner(r, { host, now, staleMs, hbStaleMs, pidAlive }))
1247
+ .map((r) => r.id);
1248
+ if (candidates.length === 0) return { reconciled: 0, ids: [] };
1249
+
1250
+ // Status-guarded UPDATE. Also NULLs owner columns so reclassified rows are clean.
1251
+ return tx(() => {
1252
+ const upd = getDb().prepare(
1253
+ `UPDATE pipelines SET status = ?, owner_pid = NULL, owner_host = NULL, heartbeat_at = NULL
1254
+ WHERE id = ? AND status IN (${placeholders})`);
1255
+ const flipped = [];
1256
+ for (const id of candidates) {
1257
+ const info = upd.run(INTERRUPTED_STATUS, id, ...RECONCILE_NON_TERMINAL);
1258
+ if (info.changes > 0) flipped.push(id);
1259
+ }
1260
+ return { reconciled: flipped.length, ids: flipped };
1261
+ });
1262
+ }
1263
+
1264
+ /**
1265
+ * Load everything resume needs for one pipeline: the raw pipelines row, the parsed
1266
+ * resume_point, and the saved steps (camelCase via rowToState, sessionId included).
1267
+ * Returns null when the id is unknown. Pure read — no status checks here (callers
1268
+ * guard on row.status).
1269
+ */
1270
+ export function readPipelineForResume(pipelineId) {
1271
+ const row = getDb().prepare('SELECT * FROM pipelines WHERE id = ?').get(pipelineId);
1272
+ if (!row) return null;
1273
+ let resumePoint = null;
1274
+ try {
1275
+ resumePoint = row.resume_point ? JSON.parse(row.resume_point) : null;
1276
+ } catch {
1277
+ resumePoint = null;
1278
+ }
1279
+ const state = rowToState(row);
1280
+ return { row, resumePoint, steps: state?.steps || [] };
1281
+ }
1282
+
1283
+ /**
1284
+ * Map a live state object to the named params of the pipelines UPSERT. JSON columns
1285
+ * are stringified here; the workspace superset collapses into workspace_meta.
1286
+ *
1287
+ * C1: the orchestrator's this.state carries projectDir but NOT projectKey
1288
+ * (orchestrator.mjs:174-191), and _persist() writes this.state verbatim. Reading
1289
+ * o.projectKey alone would write NULL on every post-creation persist → project_key
1290
+ * NOT NULL violation (swallowed by _persist's catch) → the run would freeze at its
1291
+ * 'created' snapshot. Derive from the always-present projectDir when the key is
1292
+ * absent. (Single-project AND workspace runs both carry projectDir = the primary
1293
+ * member dir.) projectKey is imported into artifacts.mjs from store.mjs.
1294
+ */
1295
+ function toPipelineRow(o) {
1296
+ const workspaceMeta = o.target === 'workspace'
1297
+ ? s({
1298
+ workspaceId: o.workspaceId ?? null,
1299
+ workspaceName: o.workspaceName ?? null,
1300
+ workspaceDescription: o.workspaceDescription ?? '',
1301
+ projectKeys: Array.isArray(o.projectKeys) ? o.projectKeys : [],
1302
+ projects: Array.isArray(o.projects) ? o.projects : [],
1303
+ checkpointRefs: o.checkpointRefs ?? {},
1304
+ branches: o.branches ?? {},
1305
+ // §5.2 mode pin for WORKSPACE rows: workspace_meta is a fixed whitelist, so
1306
+ // without this entry every paused/interrupted detached workspace run silently
1307
+ // resumes in legacy shape. Read off the top-level state.runRootMode that
1308
+ // _setupRunRoot / resume() stamp. (Single-project rows need nothing — the pin
1309
+ // rides state.branch, already serialized verbatim below.)
1310
+ runRootMode: o.runRootMode ?? null,
1311
+ })
1312
+ : null;
1313
+ return {
1314
+ id: o.id,
1315
+ project_key: o.projectKey ?? (o.projectDir ? projectKey(o.projectDir) : null),
1316
+ workspace_key: o.workspaceKey ?? null,
1317
+ target: o.target ?? 'project',
1318
+ title: o.title ?? null,
1319
+ base_name: o.baseName ?? null,
1320
+ date_prefix: o.datePrefix ?? null,
1321
+ status: o.status ?? 'created',
1322
+ phase: o.phase ?? 'created',
1323
+ cycle: Number.isFinite(o.cycle) ? o.cycle : 0,
1324
+ started_at: o.startedAt ?? null,
1325
+ updated_at: o.updatedAt ?? null,
1326
+ total_cost_usd: Number.isFinite(o.totalCostUsd) ? o.totalCostUsd : 0,
1327
+ total_active_ms: Number.isFinite(o.totalActiveMs) ? o.totalActiveMs : 0,
1328
+ prompt: o.prompt ?? null,
1329
+ branch: s(o.branch),
1330
+ workspace_meta: workspaceMeta,
1331
+ stepper: s(o.stepper),
1332
+ tools: s(o.tools),
1333
+ resume_point: o.resumePoint == null ? null : s(o.resumePoint),
1334
+ source_type: o.sourceType ?? 'prompt',
1335
+ source_ref: s(o.sourceMeta),
1336
+ guardrails_id: o.guardrailsId ?? null,
1337
+ };
1338
+ }
1339
+
1340
+ /**
1341
+ * The pipeline's {cost, active} totals for the history list, read from the DB row.
1342
+ * Normal runs carry NOT-NULL 0-defaulted totals, so when a total is > 0 it is used
1343
+ * verbatim and NO extra query runs. Only when a total is 0 do we fall back to the
1344
+ * per-step SUM/COUNT (the DB-native equivalent of the old pipelineTotalCost/
1345
+ * pipelineTotalActiveMs step-sum): COUNT=0 ⇒ null (genuinely no figures anywhere ⇒
1346
+ * the UI shows a blank chip), else the SUM (which may be a recorded $0 / 0ms). This
1347
+ * matches the legacy "recorded $0 shows, absent shows blank" semantics without
1348
+ * needing NULL in the NOT-NULL-DEFAULT-0 columns.
1349
+ * @param {object} row a pipelines DB row (total_cost_usd / total_active_ms)
1350
+ * @returns {{cost:number|null, active:number|null}}
1351
+ */
1352
+ function totalsFor(row) {
1353
+ const agg = getDb().prepare(`
1354
+ SELECT COUNT(cost_usd) cc, SUM(cost_usd) sc, COUNT(active_ms) ca, SUM(active_ms) sa
1355
+ FROM pipeline_steps WHERE pipeline_id = ?
1356
+ `).get(row.id) || {};
1357
+ const cost = row.total_cost_usd > 0
1358
+ ? row.total_cost_usd
1359
+ : (agg.cc ? Math.round((agg.sc || 0) * 1e4) / 1e4 : null);
1360
+ const active = row.total_active_ms > 0
1361
+ ? row.total_active_ms
1362
+ : (agg.ca ? (agg.sa || 0) : null);
1363
+ return { cost, active };
1364
+ }
1365
+
1366
+ /**
1367
+ * Return the still-live worktrees retained after a teardown commit failure.
1368
+ * The DB stores single-project metadata in `branch` and workspace metadata in
1369
+ * `workspace_meta.branches`; callers may also pass reconstructed state objects.
1370
+ * A missing checkout self-clears the derived warning without mutating history.
1371
+ */
1372
+ export function retainedWorkFor(row) {
1373
+ if (!row || typeof row !== 'object') return null;
1374
+ const branch = typeof row.branch === 'string' ? j(row.branch, null) : row.branch;
1375
+ const wm = typeof row.workspace_meta === 'string' ? j(row.workspace_meta, null) : row.workspace_meta;
1376
+ const workspaceBranches = wm?.branches || row.branches;
1377
+ const isWorkspace = row.target === 'workspace' && workspaceBranches && typeof workspaceBranches === 'object';
1378
+ const candidates = isWorkspace
1379
+ ? Object.entries(workspaceBranches)
1380
+ : [[row.project_key ?? row.projectKey ?? null, branch]];
1381
+ const members = [];
1382
+ for (const [projectKey_, br] of candidates) {
1383
+ const failure = br?.commitFailed;
1384
+ const worktreeDir = br?.worktreeDir;
1385
+ if (!failure || !worktreeDir || !existsSync(worktreeDir)) continue;
1386
+ members.push({
1387
+ projectKey: projectKey_ || null,
1388
+ worktreeDir,
1389
+ branch: br?.feature || br?.branch || null,
1390
+ code: failure.code || null,
1391
+ step: failure.step || null,
1392
+ message: failure.message || '',
1393
+ at: failure.at || null,
1394
+ });
1395
+ }
1396
+ if (!members.length) return null;
1397
+ return { reason: members[0].code || 'unknown', members };
1398
+ }
1399
+
1400
+ /**
1401
+ * Build a history row from a pipelines DB row. Mirrors the legacy pipelineEntry
1402
+ * wire shape EXACTLY: { id, dir, title, status, startedAt, branch, sourceBranch,
1403
+ * survived, added, removed, totalCostUsd, totalActiveMs, mtime[, pr] }. Git/PR work
1404
+ * (branchExists / diffShortstat / findPrForBranch) is UNCHANGED — it still shells
1405
+ * out — and is fed the DB row's branch JSON instead of a parsed state.json.
1406
+ * - `branch` (wire) = state.branch.feature; `sourceBranch` = state.branch.source.
1407
+ * - `mtime` maps to updated_at parsed to ms (a SORT KEY only; never displayed).
1408
+ * - `row.dir` is attached by the caller (the real on-disk run dir).
1409
+ * - `guardrailsId` (additive, v14+): the run's selected guardrail set id
1410
+ * ('permissive' = unguarded) or null for legacy rows.
1411
+ * - `retainedWork` is non-null only while a commit-failed worktree still exists.
1412
+ * @param {object} row a pipelines row (incl. row.dir set by the caller)
1413
+ * @param {string|null} repoDir git repo root for live branch facts
1414
+ * @param {object} opts { withPr? }
1415
+ */
1416
+ async function rowToHistoryEntry(row, repoDir = null, opts = {}) {
1417
+ const branchObj = j(row.branch, null);
1418
+ const feature = branchObj?.feature ?? (typeof branchObj === 'string' ? branchObj : null);
1419
+ const source = branchObj?.source ?? null;
1420
+ let survived = false;
1421
+ let added = 0;
1422
+ let removed = 0;
1423
+ if (repoDir && feature) {
1424
+ survived = await branchExists(repoDir, feature);
1425
+ if (survived && source) {
1426
+ const d = await diffShortstat(repoDir, source, feature);
1427
+ added = d.added;
1428
+ removed = d.removed;
1429
+ }
1430
+ }
1431
+ const { cost, active } = totalsFor(row);
1432
+ const entry = {
1433
+ id: row.id,
1434
+ dir: row.dir,
1435
+ title: row.title ?? row.id,
1436
+ status: row.status ?? 'unknown',
1437
+ startedAt: row.started_at ?? null,
1438
+ branch: feature,
1439
+ sourceBranch: source,
1440
+ guardrailsId: row.guardrails_id ?? null,
1441
+ pauseReason: row.pause_reason ?? null,
1442
+ retainedWork: retainedWorkFor(row),
1443
+ survived,
1444
+ added,
1445
+ removed,
1446
+ totalCostUsd: cost,
1447
+ totalActiveMs: active,
1448
+ mtime: row.updated_at ? (Date.parse(row.updated_at) || 0) : 0,
1449
+ };
1450
+ // Live PR state (opt-in; only the UI history endpoints request it). When gh is
1451
+ // unavailable we still set pr:null (the field is present whenever requested), so
1452
+ // callers can distinguish "looked, none" from "did not look".
1453
+ if (opts.withPr && repoDir && feature) {
1454
+ entry.pr = (await hasGh()) ? await findPrForBranch({ projectDir: repoDir, head: feature }) : null;
1455
+ }
1456
+ return entry;
1457
+ }
1458
+
1459
+ /**
1460
+ * Map every run dir under `pipelinesDir` to its 8-hex id (parsed from the
1461
+ * basename). One readdir; used to attach the real on-disk `dir` to a DB-sourced
1462
+ * history row and to locate a run for detail/delete. Returns an empty Map when the
1463
+ * dir is absent. This is O(#runs in that key), not a git scan, and runs ONCE per
1464
+ * store key — not once per pipeline.
1465
+ * @param {string} pipelinesDir
1466
+ * @returns {Promise<Map<string,string>>} id (lowercase 8-hex) -> absolute run dir
1467
+ */
1468
+ async function runDirIndex(pipelinesDir) {
1469
+ const map = new Map();
1470
+ let entries;
1471
+ try { entries = await readdir(pipelinesDir, { withFileTypes: true }); } catch { return map; }
1472
+ for (const ent of entries) {
1473
+ if (!ent.isDirectory()) continue;
1474
+ const m = DIR_ID_RE.exec(ent.name);
1475
+ if (m) map.set(m[1].toLowerCase(), join(pipelinesDir, ent.name));
1476
+ }
1477
+ return map;
1478
+ }
1479
+
1480
+ /**
1481
+ * List all pipelines for a project (or workspace, when `workspaceKey` is set),
1482
+ * newest first. SELECTs from the pipelines table via the spec indexes
1483
+ * (idx_pipelines_project_started / idx_pipelines_workspace_started), replacing the
1484
+ * O(N) readdir + per-dir state.json parse. The wire shape (§0.6) is unchanged; the
1485
+ * real on-disk run dir is resolved by a single readdir per store key (runDirIndex).
1486
+ * @param {string} projectDir
1487
+ * @param {object} [opts] { withPr? }
1488
+ * @param {string} [workspaceKey] route to the workspace store + filter on it
1489
+ * @returns {Promise<Array>}
1490
+ */
1491
+ export async function listPipelines(projectDir, opts = {}, workspaceKey) {
1492
+ const pipelinesDir = artifactPaths(projectDir, workspaceKey).pipelines;
1493
+ const dirById = await runDirIndex(pipelinesDir);
1494
+ const rows = getDb().prepare(`
1495
+ SELECT id, project_key, target, title, status, started_at, updated_at, total_cost_usd, total_active_ms,
1496
+ branch, workspace_meta, guardrails_id,
1497
+ json_extract(CASE WHEN json_valid(resume_point) THEN resume_point END, '$.pauseReason') AS pause_reason
1498
+ FROM pipelines
1499
+ WHERE ${workspaceKey ? 'workspace_key = ?' : 'project_key = ?'} AND archived_at IS NULL
1500
+ ORDER BY started_at DESC
1501
+ `).all(workspaceKey ? workspaceKey : projectKey(projectDir));
1502
+ const out = [];
1503
+ for (const row of rows) {
1504
+ row.dir = dirById.get(row.id) || join(pipelinesDir, row.id);
1505
+ out.push(await rowToHistoryEntry(row, projectDir, opts));
1506
+ }
1507
+ out.sort((a, b) => b.mtime - a.mtime);
1508
+ return out;
1509
+ }
1510
+
1511
+ /** Every pipeline across every store key, newest-first, tagged with project. One
1512
+ * SQL scan over pipelines replaces the store-tree walk; project/workspace names
1513
+ * come from store_meta rows. The per-pipeline build (which still spawns git/gh)
1514
+ * runs in parallel batches so a large store does not pay N serialized git
1515
+ * round-trips. Wire format (§0.6) unchanged: project rows tag {projectKey,
1516
+ * projectName, projectDir}; workspace rows tag {projectKey:"workspaces/<wk>",
1517
+ * projectName, workspaceName, projectDir:primaryPath, target:'workspace'}.
1518
+ * `opts.limit` (positive integer) bounds the rows in SQL; `opts.lite` skips ALL git
1519
+ * enrichment (survived/added/removed stay false/0/0). Both default off, so existing
1520
+ * callers see exactly what they saw before. */
1521
+ export async function listAllPipelines(opts = {}, { batchSize = 16 } = {}) {
1522
+ const rows = getDb().prepare(`
1523
+ SELECT id, project_key, workspace_key, target, title, status, started_at, updated_at,
1524
+ total_cost_usd, total_active_ms, branch, workspace_meta, guardrails_id,
1525
+ json_extract(CASE WHEN json_valid(resume_point) THEN resume_point END, '$.pauseReason') AS pause_reason
1526
+ FROM pipelines
1527
+ WHERE archived_at IS NULL
1528
+ ORDER BY COALESCE(updated_at, started_at) DESC, project_key, id
1529
+ LIMIT ?
1530
+ `).all(Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : -1); // -1 = unlimited (SQLite)
1531
+
1532
+ const metaCache = new Map(); // store key -> meta object (or null)
1533
+ const meta = (k) => {
1534
+ if (metaCache.has(k)) return metaCache.get(k);
1535
+ const m = readStoreMeta(k); metaCache.set(k, m); return m;
1536
+ };
1537
+ const dirIndexCache = new Map(); // pipelinesDir -> (id->dir) map
1538
+
1539
+ // Phase 1 — cheap tagging + repoDir + pipelinesDir resolution per row (no git).
1540
+ const tasks = rows.map((row) => {
1541
+ const isWs = row.target === 'workspace' && row.workspace_key;
1542
+ const storeKey = isWs ? `workspaces/${row.workspace_key}` : row.project_key;
1543
+ const pipelinesDir = join(projectStorePath(storeKey), 'pipelines');
1544
+ let tag;
1545
+ let repoDir;
1546
+ if (isWs) {
1547
+ const m = meta(row.workspace_key);
1548
+ const primary = Array.isArray(m?.projectPaths) ? (m.projectPaths[0] ?? null) : null;
1549
+ tag = {
1550
+ projectKey: `workspaces/${row.workspace_key}`,
1551
+ projectName: m?.name ?? row.workspace_key,
1552
+ workspaceName: m?.name ?? row.workspace_key, // explicit field the History UI prefers
1553
+ projectDir: primary,
1554
+ target: 'workspace',
1555
+ };
1556
+ repoDir = primary;
1557
+ } else {
1558
+ const m = meta(row.project_key);
1559
+ tag = { projectKey: row.project_key, projectName: m?.name ?? row.project_key, projectDir: m?.path ?? null };
1560
+ repoDir = m?.path ?? null;
1561
+ }
1562
+ // `lite` drops repoDir so rowToHistoryEntry skips branchExists/diffShortstat (and
1563
+ // withPr) entirely — callers that read only the DB fields (chat history) pay no git.
1564
+ // tag.projectDir stays intact: /resume still needs it.
1565
+ return { row, tag, repoDir: opts.lite ? null : repoDir, pipelinesDir };
1566
+ });
1567
+
1568
+ // Phase 2 — build rows in parallel, capped at `batchSize` concurrent git/gh fans.
1569
+ const out = [];
1570
+ for (let i = 0; i < tasks.length; i += batchSize) {
1571
+ const slice = tasks.slice(i, i + batchSize);
1572
+ const built = await Promise.all(slice.map(async (t) => {
1573
+ let idx = dirIndexCache.get(t.pipelinesDir);
1574
+ if (!idx) { idx = await runDirIndex(t.pipelinesDir); dirIndexCache.set(t.pipelinesDir, idx); }
1575
+ t.row.dir = idx.get(t.row.id) || join(t.pipelinesDir, t.row.id);
1576
+ const e = await rowToHistoryEntry(t.row, t.repoDir, opts);
1577
+ return Object.assign(e, t.tag); // same tag fields as before; tag has no `pr` key
1578
+ }));
1579
+ out.push(...built);
1580
+ }
1581
+
1582
+ // Newest-first, with a deterministic tiebreaker so equal-mtime rows do not
1583
+ // reorder run-to-run now that build order is non-deterministic (parallel).
1584
+ out.sort((a, b) =>
1585
+ (b.mtime - a.mtime) ||
1586
+ (a.projectKey < b.projectKey ? -1 : a.projectKey > b.projectKey ? 1 : 0) ||
1587
+ (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
1588
+ return out;
1589
+ }
1590
+
1591
+ /**
1592
+ * Total number of NON-ARCHIVED pipelines across every project + workspace store
1593
+ * (all statuses). Backs the History sidebar count, so it must agree with what the
1594
+ * list reads show — archived rows are hidden there too. Cheap COUNT(*) — never
1595
+ * lists or loads rows. Sync (getDb().prepare(...).get()), matching this module's
1596
+ * idiom (it imports only getDb + tx, not a bare `prepare`).
1597
+ * @returns {number}
1598
+ */
1599
+ export function countPipelines() {
1600
+ const row = getDb().prepare('SELECT COUNT(*) AS n FROM pipelines WHERE archived_at IS NULL').get();
1601
+ return row ? Number(row.n) : 0;
1602
+ }
1603
+
1604
+ /**
1605
+ * Re-walk the skeleton and resolve live PR state per branch, pushed to `onBatch`
1606
+ * in parallel batches. v1 sends ONLY `pr` (state OPEN/MERGED or null) —
1607
+ * findPrForBranch already distinguishes merged-vs-open. We do NOT compute live
1608
+ * mergeability (no prMergeable call). `onBatch(items, isFinal)` is awaited so a
1609
+ * caller can broadcast incrementally; the FINAL call always carries isFinal=true
1610
+ * (even with no gh / no targets) so a client spinner provably clears.
1611
+ */
1612
+ export async function enrichPipelinesPr(onBatch, { batchSize = 16 } = {}) {
1613
+ if (!(await hasGh())) { await onBatch([], true); return; } // no gh: one empty final batch
1614
+ const rows = await listAllPipelines(); // skeleton (no withPr), parallelized
1615
+ const targets = rows.filter((r) => r.projectDir && r.branch);
1616
+ if (targets.length === 0) { await onBatch([], true); return; }
1617
+ for (let i = 0; i < targets.length; i += batchSize) {
1618
+ const slice = targets.slice(i, i + batchSize);
1619
+ const items = await Promise.all(slice.map(async (r) => {
1620
+ const pr = (await findPrForBranch({ projectDir: r.projectDir, head: r.branch })) || null;
1621
+ if (pr) persistPrState(r.id, pr); // positive observations only (null never clears)
1622
+ return { projectKey: r.projectKey, id: r.id, pr };
1623
+ }));
1624
+ await onBatch(items, i + batchSize >= targets.length); // (items, isFinal)
1625
+ }
1626
+ }
1627
+
1628
+ /**
1629
+ * Reconstruct one state.steps[] entry from a pipeline_steps row. Inverse of the
1630
+ * step INSERT in writeState. running_since is stored as TEXT (epoch-ms) and comes
1631
+ * back as a Number (null when paused); optional fields (phase/cycle/status/
1632
+ * startedAt/updatedAt) collapse to undefined when null so the shape matches what
1633
+ * the orchestrator emitted; nodeId/stepIndex are present only when set.
1634
+ */
1635
+ function stepRowToStep(r) {
1636
+ const step = {
1637
+ key: r.key, phase: r.phase ?? undefined, cycle: r.cycle ?? undefined,
1638
+ status: r.status ?? undefined, startedAt: r.started_at ?? undefined,
1639
+ updatedAt: r.updated_at ?? undefined,
1640
+ activeMs: r.active_ms ?? 0,
1641
+ runningSince: r.running_since == null ? null : Number(r.running_since),
1642
+ costUsd: r.cost_usd ?? 0,
1643
+ sessionId: r.session_id ?? undefined,
1644
+ skills: j(r.skills, []),
1645
+ graphifyCount: r.graphify_count ?? undefined,
1646
+ };
1647
+ if (r.node_id != null) step.nodeId = r.node_id;
1648
+ if (r.step_index != null) step.stepIndex = r.step_index;
1649
+ return step;
1650
+ }
1651
+
1652
+ /**
1653
+ * Reconstruct the full state object (the old state.json shape the UI consumed)
1654
+ * from a pipelines row + its pipeline_steps rows. Inverse of toPipelineRow + the
1655
+ * step INSERT. projectDir is recovered from the project's store_meta row (the old
1656
+ * state carried state.projectDir; the server's PR route reads it). The workspace
1657
+ * superset is spread back onto the top level from workspace_meta.
1658
+ * @param {object|null} row
1659
+ * @returns {object|null}
1660
+ */
1661
+ function rowToState(row) {
1662
+ if (!row) return null;
1663
+ const state = {
1664
+ id: row.id,
1665
+ title: row.title ?? null,
1666
+ projectKey: row.project_key ?? null,
1667
+ status: row.status ?? 'unknown',
1668
+ phase: row.phase ?? null,
1669
+ cycle: row.cycle ?? 0,
1670
+ startedAt: row.started_at ?? null,
1671
+ updatedAt: row.updated_at ?? null,
1672
+ totalCostUsd: row.total_cost_usd ?? 0,
1673
+ totalActiveMs: row.total_active_ms ?? 0,
1674
+ prompt: row.prompt ?? null,
1675
+ baseName: row.base_name ?? null,
1676
+ datePrefix: row.date_prefix ?? null,
1677
+ branch: j(row.branch, null),
1678
+ stepper: j(row.stepper, null),
1679
+ tools: j(row.tools, null),
1680
+ guardrailsId: row.guardrails_id ?? null,
1681
+ steps: getDb().prepare(`
1682
+ SELECT key, node_id, phase, step_index, cycle, status, started_at, updated_at,
1683
+ active_ms, running_since, cost_usd, session_id, skills, graphify_count
1684
+ FROM pipeline_steps WHERE pipeline_id = ? ORDER BY rowid
1685
+ `).all(row.id).map(stepRowToStep),
1686
+ subAgents: listSubAgents(row.id),
1687
+ };
1688
+ const meta = readStoreMeta(row.project_key);
1689
+ state.projectDir = meta?.path ?? null;
1690
+ // Workspace superset: spread workspace_meta back onto the top level + target.
1691
+ if (row.target === 'workspace') {
1692
+ const wm = j(row.workspace_meta, {}) || {};
1693
+ state.target = 'workspace';
1694
+ state.workspaceKey = row.workspace_key ?? null;
1695
+ state.workspaceId = wm.workspaceId ?? row.workspace_key ?? null;
1696
+ state.workspaceName = wm.workspaceName ?? null;
1697
+ state.workspaceDescription = wm.workspaceDescription ?? '';
1698
+ state.projectKeys = wm.projectKeys ?? [];
1699
+ state.projects = wm.projects ?? [];
1700
+ state.checkpointRefs = wm.checkpointRefs ?? {};
1701
+ state.branches = wm.branches ?? {};
1702
+ state.runRootMode = wm.runRootMode ?? null; // §5.2 mode pin (absent ⇒ legacy)
1703
+ // For a workspace run, the PR/branch route reads the primary projectDir from meta.
1704
+ const wmeta = readStoreMeta(row.workspace_key);
1705
+ if (!state.projectDir) state.projectDir = Array.isArray(wmeta?.projectPaths) ? (wmeta.projectPaths[0] ?? null) : null;
1706
+ }
1707
+ return state;
1708
+ }
1709
+
1710
+ /**
1711
+ * Rebuild the pipeline.md-format audit document from the row + pipeline_events,
1712
+ * reproducing createPipeline's header (artifacts createPipeline) + appendAudit's
1713
+ * "- `ts` text" timeline lines (A7), so a History detail view renders identically
1714
+ * to today. The header's `## Prompt` body uses the DB prompt column; the `project`
1715
+ * line uses the store_meta path.
1716
+ * @param {object} row a pipelines row
1717
+ * @returns {string}
1718
+ */
1719
+ function buildAuditMarkdown(row) {
1720
+ const events = getDb().prepare(
1721
+ 'SELECT ts, text FROM pipeline_events WHERE pipeline_id = ? ORDER BY id').all(row.id);
1722
+ const header =
1723
+ `# Pipeline: ${row.title ?? row.id}\n\n` +
1724
+ `- **id**: ${row.id}\n` +
1725
+ `- **project**: ${(readStoreMeta(row.project_key)?.path) ?? ''}\n` +
1726
+ `- **started**: ${row.started_at ?? ''}\n` +
1727
+ `- **prompt file**: prompt.md\n\n` +
1728
+ `## Prompt\n\n` +
1729
+ (row.prompt && row.prompt.trim() ? row.prompt.trim() + '\n' : '_(empty prompt)_\n') +
1730
+ `\n## Timeline\n\n`;
1731
+ const lines = events.map((e) => `- \`${e.ts}\` ${e.text}\n`).join('');
1732
+ return header + lines;
1733
+ }
1734
+
1735
+ /**
1736
+ * Find a pipelines row by store key + (short id OR run-dir basename). Maps a store
1737
+ * key back to the WHERE column: "workspaces/<wk>" -> workspace_key=<wk>; otherwise
1738
+ * project_key=<key>. Tries a direct id hit first, then falls back to extracting the
1739
+ * trailing 8-hex from a run-dir basename like "<DD-MM-YY>-<slug>-<id>".
1740
+ * @param {string} key
1741
+ * @param {string} id
1742
+ * @returns {object|null}
1743
+ */
1744
+ export function lookupPipelineRow(key, id) {
1745
+ const isWs = typeof key === 'string' && key.startsWith('workspaces/');
1746
+ const col = isWs ? 'workspace_key' : 'project_key';
1747
+ const val = isWs ? key.slice('workspaces/'.length) : key;
1748
+ let row = getDb().prepare(`SELECT * FROM pipelines WHERE ${col} = ? AND id = ?`).get(val, id);
1749
+ if (row) return row;
1750
+ const m = DIR_ID_RE.exec(String(id));
1751
+ if (m) row = getDb().prepare(`SELECT * FROM pipelines WHERE ${col} = ? AND id = ?`).get(val, m[1].toLowerCase());
1752
+ return row || null;
1753
+ }
1754
+
1755
+ /**
1756
+ * The DB-backed lookups `sweepRunRoots` (worktree.mjs) needs, in ONE place shared by
1757
+ * both callers — `ui/server.mjs`'s boot sweep and the `worca doctor` subcommand.
1758
+ *
1759
+ * The sweep helper itself is deliberately DB-FREE (worktree.mjs is a leaf that must
1760
+ * not depend on the DB layer; git-info.mjs documents the same layering rule), so
1761
+ * every pipelines-row read is injected from here:
1762
+ * statusOf(id) -> the row status, or null when the row is VERIFIABLY gone
1763
+ * (deleted run). THROWS when the lookup itself fails.
1764
+ * membersOf(id) -> [{ projectKey, projectDir, worktreeDir }] from the SAME
1765
+ * JSON columns pipeline-delete.mjs consumes today
1766
+ * (workspace: state.branches x state.projects; single: the
1767
+ * state.branch scalar x the store_meta path). Used only when
1768
+ * `run.json` is missing. THROWS when the lookup fails.
1769
+ * pipelineDirOf(id) -> the durable artifact dir, for rescue-before-remove.
1770
+ * retainOf(id) -> a derived retained-work record, or null once no recorded
1771
+ * checkout is still present. THROWS when the lookup fails.
1772
+ *
1773
+ * THREE STATES, NEVER TWO. A DB *failure* must never masquerade as "no pipelines
1774
+ * row", because the row-less disposition is RECLAIM: if an unopenable sqlite file
1775
+ * (corrupt DB, ABI mismatch after a Node upgrade, bad permissions) collapsed to
1776
+ * null, the very next boot would force-remove the worktrees and rm -rf the run roots
1777
+ * of every `paused`/`interrupted` run — with the rescue triple skipped too, since
1778
+ * that only runs when a row exists. That is exactly the §8.12 catastrophe the
1779
+ * keep-set exists to prevent, reintroduced on the error path. So these callbacks
1780
+ * deliberately DO NOT catch: the throw reaches sweepRunRoots, which skips that run
1781
+ * root untouched and logs loudly.
1782
+ * @returns {{statusOf:Function, membersOf:Function, pipelineDirOf:Function,retainOf:Function}}
1783
+ */
1784
+ export function runRootSweepLookups() {
1785
+ // No try/catch by design (see above): only a successful query that matched nothing
1786
+ // returns null. Any DB-level failure propagates to the caller.
1787
+ //
1788
+ // ARCHIVED rows read as row-less, which is exactly what the hard DELETE they
1789
+ // replaced produced. Archive already reclaimed the FS; if a worktree/run root
1790
+ // survived that pass (dirty tree, index.lock), a kept `paused` status would put
1791
+ // it in RUN_ROOT_KEEP forever — the row can never be resumed, re-archived, or
1792
+ // seen in History again, so nothing would ever release it. Row-less means
1793
+ // reclaim, and the rescue triple is correctly skipped (the artifact index rows
1794
+ // and the run dir are already gone).
1795
+ const rowById = (id) => getDb()
1796
+ .prepare('SELECT * FROM pipelines WHERE id = ? AND archived_at IS NULL').get(id) || null;
1797
+ return {
1798
+ statusOf: (id) => rowById(id)?.status ?? null,
1799
+ retainOf: (id) => {
1800
+ const row = rowById(id);
1801
+ return row ? retainedWorkFor(row) : null;
1802
+ },
1803
+ membersOf: async (id) => {
1804
+ const row = rowById(id);
1805
+ if (!row) return null;
1806
+ // rowToState issues further queries (steps, sub-agents, store meta); a failure
1807
+ // there is also a lookup failure, not "this run has no members", so it throws.
1808
+ const state = rowToState(row);
1809
+ if (!state) return null;
1810
+ if (state.target === 'workspace') {
1811
+ const branches = state.branches && typeof state.branches === 'object' ? state.branches : {};
1812
+ const dirByKey = new Map((Array.isArray(state.projects) ? state.projects : [])
1813
+ .map((p) => [p.projectKey, p.projectDir]));
1814
+ return Object.entries(branches)
1815
+ .map(([pk, br]) => ({ projectKey: pk, projectDir: dirByKey.get(pk) || null, worktreeDir: br?.worktreeDir || null }))
1816
+ .filter((m) => m.projectDir && m.worktreeDir);
1817
+ }
1818
+ const projectDir = state.projectDir || null;
1819
+ const worktreeDir = state.branch?.worktreeDir || null;
1820
+ if (!projectDir || !worktreeDir) return [];
1821
+ return [{ projectKey: row.project_key || null, projectDir, worktreeDir }];
1822
+ },
1823
+ pipelineDirOf: async (id) => {
1824
+ const row = rowById(id);
1825
+ if (!row) return null;
1826
+ // Unlike the two above, this one may degrade: it is consulted only AFTER the
1827
+ // disposition is decided, and it only names where the rescue copies go. A
1828
+ // readdir failure costs the durable copies, never a removal decision.
1829
+ try { return await runDirForRow(row); } catch { return null; }
1830
+ },
1831
+ };
1832
+ }
1833
+
1834
+ /**
1835
+ * The DB-backed lookups `sweepLegacyWorktrees` / `sweepLegacyWorktreesAll`
1836
+ * (worktree.mjs) need, in ONE place shared by both callers — `ui/server.mjs`'s boot
1837
+ * maintenance and the `worca doctor` subcommand. The exact mirror of
1838
+ * `runRootSweepLookups` above, for the OTHER sweep (§6 Phase 7 / §8.12).
1839
+ *
1840
+ * ONE snapshot query, not a per-candidate lookup, because the legacy sweep needs two
1841
+ * things off the same three columns: the status of every candidate id (each legacy
1842
+ * worktree dir is named by its pipelineId, `worktree.mjs`), and the set of worktree
1843
+ * paths ANY row still claims — the "still referenced" skip that protects a run whose
1844
+ * path was recorded under a different id shape.
1845
+ *
1846
+ * THREE STATES, NEVER TWO — and the snapshot is what makes that trivially true here:
1847
+ * either the read succeeds, in which case an id that is missing from it is
1848
+ * VERIFIABLY row-less, or the read throws out of this factory and the caller sweeps
1849
+ * nothing at all. A DB failure can therefore never masquerade as "no pipelines row".
1850
+ * (For the legacy sweep row-less means quarantine-log, so the blast radius is smaller
1851
+ * than sweepRunRoots' reclaim — but "the DB is broken, so every worktree of every
1852
+ * registered project is unclassifiable" is a report, not a disposition.)
1853
+ *
1854
+ * Referenced paths are read from the same JSON columns pipeline-delete.mjs consumes:
1855
+ * a single-project run records `branch.worktreeDir`; a workspace run records one
1856
+ * `workspace_meta.branches[<projectKey>].worktreeDir` per member.
1857
+ * @returns {{statusOf:Function, referencedPaths:Set<string>}}
1858
+ */
1859
+ export function legacySweepLookups() {
1860
+ // No try/catch by design (see above): a DB-level failure propagates to the caller.
1861
+ // archived_at IS NULL for the same reason runRootSweepLookups filters it: an
1862
+ // archived row must stop both claiming its worktree path via referencedPaths and
1863
+ // reporting a KEEP status, or a path archive failed to remove is pinned forever.
1864
+ const rows = getDb()
1865
+ .prepare('SELECT id, status, branch, workspace_meta FROM pipelines WHERE archived_at IS NULL').all();
1866
+ const status = new Map();
1867
+ const referencedPaths = new Set();
1868
+ for (const row of rows) {
1869
+ status.set(row.id, row.status ?? null);
1870
+ const branch = j(row.branch, null);
1871
+ if (branch?.worktreeDir) referencedPaths.add(branch.worktreeDir);
1872
+ for (const br of Object.values(j(row.workspace_meta, null)?.branches || {})) {
1873
+ if (br?.worktreeDir) referencedPaths.add(br.worktreeDir);
1874
+ }
1875
+ }
1876
+ return { statusOf: (id) => (status.has(id) ? status.get(id) : null), referencedPaths };
1877
+ }
1878
+
1879
+ /**
1880
+ * Read a single pipeline by id, returning its reconstructed state and audit
1881
+ * markdown (both rebuilt from the DB). Returns null when no pipeline with that id
1882
+ * exists (so callers can map a not-found to a 404 rather than a 500). Resolves the
1883
+ * project store key from projectDir and delegates to readPipelineByKey.
1884
+ * @param {string} projectDir
1885
+ * @param {string} id
1886
+ * @returns {Promise<{state:object|null, auditMarkdown:string}|null>}
1887
+ */
1888
+ export async function readPipeline(projectDir, id) {
1889
+ return readPipelineByKey(projectKey(projectDir), id);
1890
+ }
1891
+
1892
+ /**
1893
+ * Read a pipeline directly from a store key (project-agnostic), reconstructing
1894
+ * { state, auditMarkdown } from the DB. Matches by pipeline short-id or by run-dir
1895
+ * basename (the old readers matched both). Returns null when the key or id is
1896
+ * unknown (so the API maps it to a 404). Accepts a workspace composite key
1897
+ * "workspaces/<workspaceKey>".
1898
+ */
1899
+ export async function readPipelineByKey(key, id) {
1900
+ const row = lookupPipelineRow(key, id);
1901
+ if (!row) return null;
1902
+ // Layer-1 results + (if generated) the Layer-2 overview, read from the run dir.
1903
+ // File-name literals inlined (not imported from results.mjs) to avoid a load-order
1904
+ // cycle: results.mjs imports recordArtifact/resolvePipelineId from this module.
1905
+ const dir = await runDirForRow(row);
1906
+ const results = await readJsonFile(join(dir, 'results.json'));
1907
+ const overview = await readJsonFile(join(dir, 'overview.json'));
1908
+ return {
1909
+ state: rowToState(row),
1910
+ auditMarkdown: buildAuditMarkdown(row),
1911
+ artifacts: await listArtifacts(row.id), // [{kind, relPath}] — drives the Live-logs dropdown (project + workspace)
1912
+ results,
1913
+ overview,
1914
+ ...readPipelineExtras(row.id),
1915
+ };
1916
+ }
1917
+
1918
+ /** Local helper: read + JSON-parse a file, null on any failure. */
1919
+ async function readJsonFile(p) {
1920
+ try { return JSON.parse(await readFile(p, 'utf8')); } catch { return null; }
1921
+ }
1922
+
1923
+ /**
1924
+ * Read a run's persisted live-log NDJSON as text, by store key + id. Resolves the
1925
+ * on-disk run dir via runDirIndex (a Map<id, absolute run dir>), the same readdir
1926
+ * index listPipelines/listAllPipelines use. Workspace runs pass key
1927
+ * "workspaces/<workspaceKey>". Returns null when the run or file is unknown (so the
1928
+ * API maps it to a 404). Best-effort: any FS error -> null.
1929
+ * @param {string} key project store key, or "workspaces/<workspaceKey>"
1930
+ * @param {string} id
1931
+ * @returns {Promise<string|null>}
1932
+ */
1933
+ export async function readRunLogText(key, id) {
1934
+ const row = lookupPipelineRow(key, id);
1935
+ if (!row) return null;
1936
+ const isWs = typeof key === 'string' && key.startsWith('workspaces/');
1937
+ const storeRoot = isWs ? workspaceStorePath(key.slice('workspaces/'.length)) : projectStorePath(key);
1938
+ const pipelinesDir = join(storeRoot, 'pipelines');
1939
+ const dirById = await runDirIndex(pipelinesDir);
1940
+ const runDir = dirById.get(row.id) || join(pipelinesDir, row.id);
1941
+ try {
1942
+ return await readFile(join(runDir, RUN_LOG_FILE), 'utf8');
1943
+ } catch {
1944
+ return null; // no log file (older run / never bound)
1945
+ }
1946
+ }
1947
+
1948
+ /**
1949
+ * Resolve a pipeline row's absolute on-disk run dir (mirrors readRunLogText).
1950
+ * Workspace rows (target==='workspace') live under the workspace store namespace,
1951
+ * keyed by workspace_key; project rows live under their project store namespace.
1952
+ * @returns {Promise<string>}
1953
+ */
1954
+ export async function runDirForRow(row) {
1955
+ const isWs = row.target === 'workspace' || !!row.workspace_key;
1956
+ const storeRoot = isWs
1957
+ ? workspaceStorePath(row.workspace_key)
1958
+ : projectStorePath(row.project_key);
1959
+ const pipelinesDir = join(storeRoot, 'pipelines');
1960
+ const dirById = await runDirIndex(pipelinesDir);
1961
+ const indexed = dirById.get(row.id);
1962
+ if (indexed) return indexed;
1963
+ // Production ids are 8-hex and always covered by runDirIndex; this fallback
1964
+ // serves older/manual rows via the SAME matcher archive uses (findRunDir),
1965
+ // adopting its case-insensitive suffix-first precedence deliberately (F11).
1966
+ const hit = await findRunDir(pipelinesDir, row.id);
1967
+ if (hit) return hit;
1968
+ return join(pipelinesDir, row.id);
1969
+ }
1970
+
1971
+ /** Find the on-disk run dir for an id under pipelinesDir (basename ends in -<id>).
1972
+ * Case-insensitive suffix pass first, exact-basename pass second — the archive
1973
+ * resolver's historical behavior. */
1974
+ export async function findRunDir(pipelinesDir, id) {
1975
+ let entries;
1976
+ try { entries = await readdir(pipelinesDir, { withFileTypes: true }); } catch { return null; }
1977
+ const esc = String(id).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1978
+ for (const e of entries) if (e.isDirectory() && new RegExp(`-${esc}$`, 'i').test(e.name)) return join(pipelinesDir, e.name);
1979
+ for (const e of entries) if (e.isDirectory() && e.name === id) return join(pipelinesDir, e.name);
1980
+ return null;
1981
+ }
1982
+
1983
+ /** Read a pipeline-local artifact file as text, or null if absent. */
1984
+ export async function readRunArtifactText(key, id, relPath) {
1985
+ const row = lookupPipelineRow(key, id);
1986
+ if (!row) return null;
1987
+ const dir = await runDirForRow(row);
1988
+ try { return await readFile(join(dir, relPath), 'utf8'); } catch { return null; }
1989
+ }
1990
+
1991
+ /** Read + JSON-parse a pipeline-local artifact, or null. */
1992
+ export async function readRunArtifactJson(key, id, relPath) {
1993
+ const txt = await readRunArtifactText(key, id, relPath);
1994
+ if (txt == null) return null;
1995
+ try { return JSON.parse(txt); } catch { return null; }
1996
+ }
1997
+
1998
+ /**
1999
+ * List pipelines for a workspace from its OWN store namespace
2000
+ * (store/workspaces/<workspaceKey>/pipelines), newest-first. `primaryDir` supplies
2001
+ * live branch facts (best-effort, primary repo only). Mirrors listPipelines.
2002
+ * @param {string} workspaceKey
2003
+ * @param {string} [primaryDir]
2004
+ * @param {object} [opts]
2005
+ */
2006
+ export async function listWorkspacePipelines(workspaceKey, primaryDir = null, opts = {}) {
2007
+ return listPipelines(primaryDir, opts, workspaceKey);
2008
+ }
2009
+
2010
+ /**
2011
+ * Read a single workspace pipeline by id, reconstructed from the DB. Resolves the
2012
+ * composite "workspaces/<workspaceKey>" store key and delegates to
2013
+ * readPipelineByKey (which filters on workspace_key), so there is no
2014
+ * path-traversal surface (the server validates the key against WORKSPACE_ID_RE).
2015
+ * Returns null when the workspace or id is unknown.
2016
+ */
2017
+ export async function readWorkspacePipeline(workspaceKey, id) {
2018
+ return readPipelineByKey(`workspaces/${workspaceKey}`, id);
2019
+ }