@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.
- package/README.md +403 -0
- package/agents/clarify.meta.json +19 -0
- package/agents/decomposer.meta.json +21 -0
- package/agents/implementer.meta.json +20 -0
- package/agents/manualTestsChecklist.meta.json +18 -0
- package/agents/manualWebUiTesting.meta.json +18 -0
- package/agents/planReviewer.meta.json +19 -0
- package/agents/planner.meta.json +20 -0
- package/agents/refiner.meta.json +19 -0
- package/agents/reviewer.meta.json +19 -0
- package/agents/worca-cc-clarify.md +67 -0
- package/agents/worca-cc-code-reviewer.md +66 -0
- package/agents/worca-cc-decomposer.md +84 -0
- package/agents/worca-cc-implementer.md +69 -0
- package/agents/worca-cc-manual-tests-checklist.md +63 -0
- package/agents/worca-cc-manual-web-ui-testing.md +64 -0
- package/agents/worca-cc-plan-refiner.md +69 -0
- package/agents/worca-cc-plan-reviewer.md +70 -0
- package/agents/worca-cc-planner.md +70 -0
- package/agents/worca-cc-workspace-reviewer.md +56 -0
- package/agents/worca-cc-workspace-scanner.md +55 -0
- package/agents/workspaceReviewer.meta.json +20 -0
- package/agents/workspaceScanner.meta.json +18 -0
- package/package.json +61 -0
- package/scripts/install.mjs +209 -0
- package/skills/worca/SKILL.md +66 -0
- package/src/cli/worca-cc.mjs +1520 -0
- package/src/core/agent-gen.mjs +206 -0
- package/src/core/agent-registry.mjs +417 -0
- package/src/core/agent-store.mjs +143 -0
- package/src/core/artifacts.mjs +2019 -0
- package/src/core/channels.mjs +302 -0
- package/src/core/chat/allowlist.mjs +27 -0
- package/src/core/chat/channel-host.mjs +562 -0
- package/src/core/chat/channel-protocol.mjs +117 -0
- package/src/core/chat/channel-worker-child.mjs +211 -0
- package/src/core/chat/chat-context.mjs +66 -0
- package/src/core/chat/command-router.mjs +343 -0
- package/src/core/chat/notifier.mjs +120 -0
- package/src/core/chat/parser.mjs +30 -0
- package/src/core/chat/rate-limiter.mjs +133 -0
- package/src/core/chat/redact.mjs +27 -0
- package/src/core/chat/renderers.mjs +136 -0
- package/src/core/claude-runner.mjs +1356 -0
- package/src/core/config.mjs +882 -0
- package/src/core/cost-budget.mjs +103 -0
- package/src/core/db.mjs +864 -0
- package/src/core/fanout.mjs +48 -0
- package/src/core/folder-dialog.mjs +138 -0
- package/src/core/fs-browse.mjs +49 -0
- package/src/core/git-info.mjs +200 -0
- package/src/core/guardrail-store.mjs +204 -0
- package/src/core/guardrails.mjs +302 -0
- package/src/core/marketplaces.mjs +267 -0
- package/src/core/migrate-fs-to-db.mjs +612 -0
- package/src/core/model-env.mjs +74 -0
- package/src/core/orchestrator.mjs +4279 -0
- package/src/core/overview-agent.mjs +124 -0
- package/src/core/phases.mjs +1279 -0
- package/src/core/pipeline-delete.mjs +428 -0
- package/src/core/plugin-api.mjs +13 -0
- package/src/core/plugin-config.mjs +100 -0
- package/src/core/plugin-inventory.mjs +50 -0
- package/src/core/plugin-manifest.mjs +447 -0
- package/src/core/plugin-models.mjs +130 -0
- package/src/core/plugin-repo.mjs +303 -0
- package/src/core/plugin-shim-child.mjs +76 -0
- package/src/core/plugin-shim.mjs +197 -0
- package/src/core/plugin-store.mjs +485 -0
- package/src/core/plugin-workflows.mjs +179 -0
- package/src/core/plugins-lock.mjs +49 -0
- package/src/core/preflight-node.mjs +122 -0
- package/src/core/preflight.mjs +341 -0
- package/src/core/projects.mjs +157 -0
- package/src/core/protocol.mjs +257 -0
- package/src/core/recoverable-error.mjs +51 -0
- package/src/core/results.mjs +188 -0
- package/src/core/run-context.mjs +1375 -0
- package/src/core/run-log.mjs +64 -0
- package/src/core/run-manifest.mjs +317 -0
- package/src/core/runners.mjs +167 -0
- package/src/core/settings.mjs +682 -0
- package/src/core/skills.mjs +210 -0
- package/src/core/sources.mjs +232 -0
- package/src/core/stats.mjs +182 -0
- package/src/core/store.mjs +67 -0
- package/src/core/title.mjs +64 -0
- package/src/core/workflow-validator.mjs +185 -0
- package/src/core/workflows.mjs +568 -0
- package/src/core/workspace-scan.mjs +420 -0
- package/src/core/workspaces.mjs +353 -0
- package/src/core/worktree.mjs +708 -0
- package/src/feature.mjs +9 -0
- package/ui/public/app.js +10647 -0
- package/ui/public/assets/worca-favicon.png +0 -0
- package/ui/public/assets/worca-logo.png +0 -0
- package/ui/public/chat-settings-view.mjs +89 -0
- package/ui/public/composer-core.mjs +211 -0
- package/ui/public/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
- package/ui/public/fonts/poppins-latin-400-normal.woff2 +0 -0
- package/ui/public/fonts/poppins-latin-500-normal.woff2 +0 -0
- package/ui/public/fonts/poppins-latin-600-normal.woff2 +0 -0
- package/ui/public/fonts/poppins-latin-700-normal.woff2 +0 -0
- package/ui/public/guardrails-view.mjs +244 -0
- package/ui/public/index.html +1145 -0
- package/ui/public/log-filter.mjs +81 -0
- package/ui/public/log-line.mjs +86 -0
- package/ui/public/models-view.mjs +433 -0
- package/ui/public/plugins-view.mjs +430 -0
- package/ui/public/results-view.mjs +121 -0
- package/ui/public/source-pane.mjs +156 -0
- package/ui/public/stats-view.mjs +523 -0
- package/ui/public/style.css +1557 -0
- 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
|
+
}
|