@worca/app 1.0.0 → 1.2.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -9
- package/agents/clarify.meta.json +4 -4
- package/agents/decomposer.meta.json +5 -5
- package/agents/implementer.meta.json +15 -5
- package/agents/manualTestsChecklist.meta.json +5 -4
- package/agents/manualWebUiTesting.meta.json +9 -4
- package/agents/planReviewer.meta.json +12 -4
- package/agents/planner.meta.json +12 -5
- package/agents/refiner.meta.json +15 -4
- package/agents/reviewer.meta.json +14 -4
- package/agents/worca-cc-clarify.md +7 -0
- package/agents/worca-cc-code-reviewer.md +11 -6
- package/agents/worca-cc-decomposer.md +7 -0
- package/agents/worca-cc-implementer.md +9 -0
- package/agents/worca-cc-manual-tests-checklist.md +8 -5
- package/agents/worca-cc-manual-web-ui-testing.md +10 -6
- package/agents/worca-cc-plan-refiner.md +11 -6
- package/agents/worca-cc-plan-reviewer.md +10 -7
- package/agents/worca-cc-planner.md +9 -0
- package/agents/worca-cc-workspace-reviewer.md +11 -4
- package/agents/worca-cc-workspace-scanner.md +8 -4
- package/agents/workspaceReviewer.meta.json +15 -4
- package/agents/workspaceScanner.meta.json +5 -4
- package/package.json +8 -2
- package/skills/worca/SKILL.md +5 -5
- package/src/cli/render.mjs +148 -0
- package/src/cli/worca-cc.mjs +386 -56
- package/src/core/agent-gen.mjs +69 -31
- package/src/core/agent-registry.mjs +124 -144
- package/src/core/agent-store.mjs +164 -4
- package/src/core/artifacts.mjs +199 -23
- package/src/core/ask/attachment-kind.mjs +95 -0
- package/src/core/ask/catalog.mjs +111 -0
- package/src/core/ask/comment-deps.mjs +55 -0
- package/src/core/ask/events.mjs +545 -0
- package/src/core/ask/follow.mjs +113 -0
- package/src/core/ask/git-allowlist.mjs +226 -0
- package/src/core/ask/limits.mjs +57 -0
- package/src/core/ask/mcp-stdio.mjs +135 -0
- package/src/core/ask/models.mjs +125 -0
- package/src/core/ask/prompt.mjs +286 -0
- package/src/core/ask/proposal.mjs +170 -0
- package/src/core/ask/redact.mjs +30 -0
- package/src/core/ask/spawn.mjs +156 -0
- package/src/core/ask/store.mjs +438 -0
- package/src/core/ask/tool-deps.mjs +87 -0
- package/src/core/ask/tools.mjs +879 -0
- package/src/core/ask/turn.mjs +462 -0
- package/src/core/ask/worktree-deps.mjs +27 -0
- package/src/core/ask/worktrees.mjs +285 -0
- package/src/core/chat/command-router.mjs +28 -7
- package/src/core/chat/notifier.mjs +6 -1
- package/src/core/chat/renderers.mjs +15 -8
- package/src/core/claude-runner.mjs +541 -62
- package/src/core/config.mjs +310 -44
- package/src/core/cost-budget.mjs +29 -2
- package/src/core/db.mjs +773 -53
- package/src/core/diff-anchor.mjs +213 -0
- package/src/core/diff-comments.mjs +273 -0
- package/src/core/engine-select.mjs +32 -0
- package/src/core/failure-policy.mjs +201 -0
- package/src/core/git-info.mjs +49 -10
- package/src/core/graph/builtin-workflows.mjs +51 -0
- package/src/core/graph/executor.mjs +894 -0
- package/src/core/graph/registry-ports.mjs +12 -0
- package/src/core/graph/scheduler.mjs +1072 -0
- package/src/core/graph/seed-templates.mjs +318 -0
- package/src/core/host-guard.mjs +271 -0
- package/src/core/model-env.mjs +180 -8
- package/src/core/model-test.mjs +79 -0
- package/src/core/orchestrator.mjs +994 -4097
- package/src/core/overview-agent.mjs +15 -3
- package/src/core/phases.mjs +208 -537
- package/src/core/pipeline-delete.mjs +13 -2
- package/src/core/plugin-api.mjs +8 -3
- package/src/core/plugin-config.mjs +178 -28
- package/src/core/plugin-inventory.mjs +6 -2
- package/src/core/plugin-manifest.mjs +199 -11
- package/src/core/plugin-models.mjs +1 -0
- package/src/core/plugin-repo.mjs +16 -4
- package/src/core/plugin-shim-child.mjs +9 -3
- package/src/core/plugin-shim.mjs +80 -17
- package/src/core/plugin-store.mjs +236 -29
- package/src/core/plugin-workflows.mjs +90 -41
- package/src/core/preflight.mjs +135 -3
- package/src/core/projects.mjs +7 -5
- package/src/core/protocol.mjs +8 -35
- package/src/core/recoverable-error.mjs +1 -1
- package/src/core/run-harness.mjs +3934 -0
- package/src/core/run-manifest.mjs +5 -1
- package/src/core/settings.mjs +184 -13
- package/src/core/skills.mjs +10 -3
- package/src/core/source-bindings.mjs +175 -0
- package/src/core/sources.mjs +87 -25
- package/src/core/stats.mjs +25 -6
- package/src/core/title.mjs +51 -4
- package/src/core/workflows.mjs +358 -259
- package/src/core/workspace-scan.mjs +4 -0
- package/src/core/worktree.mjs +98 -7
- package/src/shared/graph/agent-meta.mjs +278 -0
- package/src/shared/graph/constants.mjs +105 -0
- package/src/shared/graph/geometry.mjs +157 -0
- package/src/shared/graph/layout.mjs +134 -0
- package/src/shared/graph/loops.mjs +130 -0
- package/src/shared/graph/manifest.mjs +257 -0
- package/src/shared/graph/ports.mjs +153 -0
- package/src/shared/graph/route.mjs +397 -0
- package/src/shared/graph/template.mjs +165 -0
- package/src/shared/graph/thumbnail.mjs +67 -0
- package/src/shared/graph/validate.mjs +491 -0
- package/src/shared/graph/verdict.mjs +41 -0
- package/ui/public/app.js +4240 -1682
- package/ui/public/ask-markdown.mjs +145 -0
- package/ui/public/ask-model.mjs +317 -0
- package/ui/public/ask-panel.mjs +2129 -0
- package/ui/public/chat-settings-view.mjs +6 -2
- package/ui/public/diff-view.mjs +66 -11
- package/ui/public/file-tree.mjs +305 -0
- package/ui/public/graph/composer.mjs +889 -0
- package/ui/public/graph/inspector.mjs +183 -0
- package/ui/public/graph/model.mjs +37 -0
- package/ui/public/graph/palette.mjs +144 -0
- package/ui/public/graph/run-decor.mjs +410 -0
- package/ui/public/graph/run-hosts.mjs +201 -0
- package/ui/public/graph/save-dialog.mjs +56 -0
- package/ui/public/graph/view.mjs +858 -0
- package/ui/public/guardrails-view.mjs +4 -2
- package/ui/public/hljs-loader.mjs +180 -0
- package/ui/public/index.html +311 -265
- package/ui/public/log-filter.mjs +22 -4
- package/ui/public/log-line.mjs +45 -19
- package/ui/public/models-view.mjs +171 -9
- package/ui/public/plugins-view.mjs +106 -4
- package/ui/public/source-pane.mjs +190 -8
- package/ui/public/stats-view.mjs +81 -1
- package/ui/public/style.css +1487 -229
- package/ui/public/syntax-highlight.mjs +270 -0
- package/ui/public/thinking-orb.mjs +110 -0
- package/ui/server.mjs +1894 -104
- package/src/core/channels.mjs +0 -302
- package/src/core/runners.mjs +0 -167
- package/src/core/workflow-validator.mjs +0 -185
- package/ui/public/composer-core.mjs +0 -211
package/src/core/agent-store.mjs
CHANGED
|
@@ -8,6 +8,8 @@ import { mkdir, readFile, writeFile, rm } from 'node:fs/promises';
|
|
|
8
8
|
import { join } from 'node:path';
|
|
9
9
|
import { loadAgentRegistry, normalizeMeta, userAgentsDir } from './agent-registry.mjs';
|
|
10
10
|
import { listWorkflows } from './workflows.mjs';
|
|
11
|
+
import { validateMetaV2 } from '../shared/graph/agent-meta.mjs';
|
|
12
|
+
import { AWAIT_PORT } from '../shared/graph/constants.mjs'; // the synthesized gate port is wirable
|
|
11
13
|
|
|
12
14
|
export { userAgentsDir }; // single source: the Phase 1 layer resolver
|
|
13
15
|
|
|
@@ -16,6 +18,15 @@ export const AGENT_KEY_RE = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
|
|
|
16
18
|
|
|
17
19
|
function err(message, code) { return Object.assign(new Error(message), { code }); }
|
|
18
20
|
|
|
21
|
+
/** The v2 capabilities whose ABSENCE is their off state in a sidecar. A complete
|
|
22
|
+
* v2 PUT is a REPLACE of this surface — otherwise `{...existing, ...raw}` turns
|
|
23
|
+
* every one of them into a one-way switch and `placeable:false` /
|
|
24
|
+
* `scope:'workspace-only'` can never be undone from the editor. Everything else
|
|
25
|
+
* (including the v1 wiring the registry still derives, which P8 owns) MERGES. */
|
|
26
|
+
const V2_CLEARABLE = ['verdict', 'sideEffect', 'mockRole', 'wantsRequest', 'workspaceFanOut',
|
|
27
|
+
'workspaceStrategy', 'workspaceVariantOf', 'placeable', 'scope', 'domain', 'icon',
|
|
28
|
+
'promptHints', 'requiresSkills'];
|
|
29
|
+
|
|
19
30
|
/** The writable user layer dir. userAgentsDir() returns null only when the home
|
|
20
31
|
* cannot be resolved (no WORCA_HOME under node:test) — surface that as a 400. */
|
|
21
32
|
function requireUserDir() {
|
|
@@ -59,6 +70,12 @@ export async function createAgent({ meta: rawMeta, markdown } = {}) {
|
|
|
59
70
|
raw.key = key;
|
|
60
71
|
raw.agentFile = `${key}.md`; // store-owned sibling file
|
|
61
72
|
if (!Number.isFinite(Number(raw.order))) raw.order = 99; // sort after built-ins by default
|
|
73
|
+
// The v2 gate runs BEFORE normalizeMeta: normalizeMeta is lossy by design
|
|
74
|
+
// (fixed key set, silent coercions, and it returns null rather than a reason),
|
|
75
|
+
// so a broken sidecar would otherwise be "fixed" into something the user never
|
|
76
|
+
// wrote — or rejected with "invalid agent metadata". Every failed rule is named.
|
|
77
|
+
const issues = validateMetaV2(raw).errors;
|
|
78
|
+
if (issues.length) throw err(issues.join('; '), 'BAD_REQUEST');
|
|
62
79
|
const meta = normalizeMeta(raw);
|
|
63
80
|
if (!meta) throw err('invalid agent metadata', 'BAD_REQUEST');
|
|
64
81
|
const existing = loadAgentRegistry()[key];
|
|
@@ -73,6 +90,126 @@ export async function createAgent({ meta: rawMeta, markdown } = {}) {
|
|
|
73
90
|
return { meta: { ...meta, origin: 'user' }, markdown };
|
|
74
91
|
}
|
|
75
92
|
|
|
93
|
+
/** Per-port fields the workspace-variant signature deliberately EXCLUDES
|
|
94
|
+
* (workflows.mjs portSignature): a variant may render and store differently, it
|
|
95
|
+
* may not fire differently. These survive a propagation; everything else on the
|
|
96
|
+
* port comes from the base. */
|
|
97
|
+
const VARIANT_OWN_PORT_FIELDS = ['label', 'description', 'as', 'directive', 'filename', 'store', 'artifactKind'];
|
|
98
|
+
|
|
99
|
+
/** One side of a variant's ports, rebuilt from the base's: the base decides the
|
|
100
|
+
* set, the order and every scheduling field; the variant keeps its own rendering.
|
|
101
|
+
* @param {object[]} basePorts @param {object[]} variantPorts */
|
|
102
|
+
function mergeVariantPorts(basePorts, variantPorts) {
|
|
103
|
+
const mine = new Map((Array.isArray(variantPorts) ? variantPorts : []).map((p) => [p.id, p]));
|
|
104
|
+
return (Array.isArray(basePorts) ? basePorts : []).map((bp) => {
|
|
105
|
+
const own = mine.get(bp.id);
|
|
106
|
+
const out = { ...bp };
|
|
107
|
+
if (!own) return out;
|
|
108
|
+
// `as` is TYPE-COUPLED (agent-meta.mjs AS_REQUIRES_TYPE: worktree=>void,
|
|
109
|
+
// answers=>json, fix-review=>md; a bare 'file' demands a NON-void port and
|
|
110
|
+
// readInputs materializes it on every non-void input). The TYPE is the
|
|
111
|
+
// base's — the signature owns it — so the variant's stored `as` only
|
|
112
|
+
// survives a type it was written for; on a type change the base's own `as`
|
|
113
|
+
// stands. Without this, a base input that BECOMES void (the shipped
|
|
114
|
+
// reviewer/workspaceReviewer `done` worktree port) leaves the variant
|
|
115
|
+
// carrying `as:'file'` on a void port and the WHOLE merge is refused.
|
|
116
|
+
const keep = own.type === bp.type
|
|
117
|
+
? VARIANT_OWN_PORT_FIELDS : VARIANT_OWN_PORT_FIELDS.filter((f) => f !== 'as');
|
|
118
|
+
for (const f of keep) if (own[f] !== undefined) out[f] = own[f];
|
|
119
|
+
// A void port carries none of these, whatever the variant used to say.
|
|
120
|
+
if (out.type === 'void') { delete out.filename; delete out.store; delete out.artifactKind; }
|
|
121
|
+
return out;
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Re-point every USER workspace variant of `key` at the base's new port signature.
|
|
127
|
+
* A variant is BY DEFINITION the same ports with a different workspace strategy, so
|
|
128
|
+
* a base port edit that is not propagated makes resolveGraph refuse every workspace
|
|
129
|
+
* run ("workspace variant X does not match the port signature of Y") at a moment
|
|
130
|
+
* disconnected from the edit. Non-user variants (builtin/plugin) cannot be written
|
|
131
|
+
* — they are reported instead. Never throws: a variant that will not validate
|
|
132
|
+
* after the merge, or whose sidecar cannot be written, is left untouched and
|
|
133
|
+
* reported — the base's own save has already succeeded and must stand.
|
|
134
|
+
* @param {string} key the edited base key
|
|
135
|
+
* @param {object} baseMeta the base's NEW normalized meta
|
|
136
|
+
* @param {string} dir the writable user layer
|
|
137
|
+
* @returns {Promise<{updated:string[], warnings:string[]}>}
|
|
138
|
+
*/
|
|
139
|
+
async function propagateToVariants(key, baseMeta, dir) {
|
|
140
|
+
const updated = [];
|
|
141
|
+
const warnings = [];
|
|
142
|
+
for (const variant of Object.values(loadAgentRegistry())) {
|
|
143
|
+
if (!variant || variant.workspaceVariantOf !== key) continue;
|
|
144
|
+
if (variant.origin !== 'user') {
|
|
145
|
+
warnings.push(`workspace variant "${variant.key}" (${variant.origin}) still declares the old ports `
|
|
146
|
+
+ '— workspace runs using it will be refused until it is updated');
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
const raw = {
|
|
150
|
+
...variant,
|
|
151
|
+
inputs: mergeVariantPorts(baseMeta.inputs, variant.inputs),
|
|
152
|
+
outputs: mergeVariantPorts(baseMeta.outputs, variant.outputs),
|
|
153
|
+
};
|
|
154
|
+
// Boolean(verdict) IS part of the signature; its filename is not. Copying the
|
|
155
|
+
// base's verdict into a variant that had none also copies the base's filename
|
|
156
|
+
// TEMPLATE, so the variant writes its verdict there — correct for the
|
|
157
|
+
// `{base}`-tokenised names every shipped verifier uses; a hardcoded one would
|
|
158
|
+
// need re-pointing by hand.
|
|
159
|
+
if (baseMeta.verdict && !variant.verdict) raw.verdict = { ...baseMeta.verdict };
|
|
160
|
+
if (!baseMeta.verdict) delete raw.verdict;
|
|
161
|
+
if (variant.descriptionDerived) raw.description = ''; // never persist a derived blurb (agent-store.mjs:114)
|
|
162
|
+
delete raw.origin; delete raw.agentPath; delete raw.descriptionDerived;
|
|
163
|
+
const issues = validateMetaV2(raw).errors;
|
|
164
|
+
const merged = issues.length ? null : normalizeMeta(raw);
|
|
165
|
+
if (!merged) {
|
|
166
|
+
warnings.push(`workspace variant "${variant.key}" could not adopt the new ports `
|
|
167
|
+
+ `(${issues.join('; ') || 'invalid agent metadata'}) — re-point it by hand`);
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
try {
|
|
171
|
+
await writeFile(join(dir, `${variant.key}.meta.json`), JSON.stringify(merged, null, 2) + '\n', 'utf8');
|
|
172
|
+
} catch (e) {
|
|
173
|
+
// Only the fs CODE: the message carries the absolute home path, which no
|
|
174
|
+
// banner may surface. `updatedVariants` lists what was actually written.
|
|
175
|
+
warnings.push(`workspace variant "${variant.key}" could not adopt the new ports `
|
|
176
|
+
+ `(write failed: ${e?.code || 'unknown error'}) — re-point it by hand`);
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
updated.push(variant.key);
|
|
180
|
+
}
|
|
181
|
+
return { updated: updated.sort(), warnings };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Saved-template wires that the NEW port set of `key` no longer satisfies:
|
|
186
|
+
* `["<workflow name> (<nodeId>.<portId>)", …]`. A port rename/removal is NOT
|
|
187
|
+
* refused here — no saved template can reference the new port before it exists,
|
|
188
|
+
* so a 409 would make renaming impossible. The RUN refuses instead
|
|
189
|
+
* (assertRunnableWorkflow -> INVALID_GRAPH); this list is the editor's heads-up.
|
|
190
|
+
* `await` is the engine-synthesized gate input and is always wirable.
|
|
191
|
+
* @param {object[]} workflows every saved template (archived included)
|
|
192
|
+
* @param {string} key the edited agent key
|
|
193
|
+
* @param {object} meta the NEW normalized meta
|
|
194
|
+
* @returns {string[]}
|
|
195
|
+
*/
|
|
196
|
+
function stalePortRefs(workflows, key, meta) {
|
|
197
|
+
const outs = new Set((meta.outputs || []).map((p) => p.id));
|
|
198
|
+
const ins = new Set([...(meta.inputs || []).map((p) => p.id), AWAIT_PORT.id]);
|
|
199
|
+
const hits = [];
|
|
200
|
+
for (const wf of workflows) {
|
|
201
|
+
const mine = new Set((wf.nodes || [])
|
|
202
|
+
.filter((n) => n && n.kind === 'agent' && n.key === key).map((n) => n.id));
|
|
203
|
+
if (!mine.size) continue;
|
|
204
|
+
const label = wf.name || wf.id;
|
|
205
|
+
for (const w of wf.wires || []) {
|
|
206
|
+
if (mine.has(w?.from?.node) && !outs.has(w.from.port)) hits.push(`${label} (${w.from.node}.${w.from.port})`);
|
|
207
|
+
if (mine.has(w?.to?.node) && !ins.has(w.to.port)) hits.push(`${label} (${w.to.node}.${w.to.port})`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return hits;
|
|
211
|
+
}
|
|
212
|
+
|
|
76
213
|
/** Update a USER agent (meta and/or markdown). Built-ins -> BUILTIN (409). */
|
|
77
214
|
export async function updateAgent(key, { meta: rawMeta, markdown } = {}) {
|
|
78
215
|
if (!AGENT_KEY_RE.test(String(key || ''))) throw err(`agent not found: ${key}`, 'NOT_FOUND');
|
|
@@ -96,10 +233,14 @@ export async function updateAgent(key, { meta: rawMeta, markdown } = {}) {
|
|
|
96
233
|
// field) freezes the fallback into the sidecar and it stops tracking the .md.
|
|
97
234
|
const base = { ...existing };
|
|
98
235
|
if (base.descriptionDerived) base.description = '';
|
|
236
|
+
if (Number(rawMeta?.metaVersion) === 2) for (const k of V2_CLEARABLE) delete base[k];
|
|
99
237
|
const raw = { ...base, ...(rawMeta && typeof rawMeta === 'object' ? rawMeta : {}) };
|
|
100
238
|
raw.key = key; // key immutable on update
|
|
101
239
|
raw.agentFile = `${key}.md`;
|
|
102
240
|
if (!Number.isFinite(Number(raw.order))) raw.order = existing.order;
|
|
241
|
+
// The same gate the create path applies: every failed rule named, nothing written.
|
|
242
|
+
const updIssues = validateMetaV2(raw).errors;
|
|
243
|
+
if (updIssues.length) throw err(updIssues.join('; '), 'BAD_REQUEST');
|
|
103
244
|
const meta = normalizeMeta(raw);
|
|
104
245
|
if (!meta) throw err('invalid agent metadata', 'BAD_REQUEST');
|
|
105
246
|
const dir = requireUserDir();
|
|
@@ -111,7 +252,12 @@ export async function updateAgent(key, { meta: rawMeta, markdown } = {}) {
|
|
|
111
252
|
const body = typeof markdown === 'string'
|
|
112
253
|
? markdown
|
|
113
254
|
: await readFile(join(dir, `${key}.md`), 'utf8').catch(() => '');
|
|
114
|
-
|
|
255
|
+
// Always present, possibly empty: the Agents view reads both unconditionally.
|
|
256
|
+
const stale = stalePortRefs(await listWorkflows({ includeArchived: true }), key, meta);
|
|
257
|
+
const warnings = stale.length ? [`saved pipelines reference a removed port: ${stale.join(', ')}`] : [];
|
|
258
|
+
const variants = await propagateToVariants(key, meta, dir);
|
|
259
|
+
warnings.push(...variants.warnings);
|
|
260
|
+
return { meta: { ...meta, origin: 'user' }, markdown: body, warnings, updatedVariants: variants.updated };
|
|
115
261
|
}
|
|
116
262
|
|
|
117
263
|
/** Delete a USER agent; REFERENCED (409) while a saved workflow uses the key. */
|
|
@@ -130,11 +276,25 @@ export async function deleteAgent(key) {
|
|
|
130
276
|
);
|
|
131
277
|
}
|
|
132
278
|
if (!existing) throw err(`agent not found: ${key}`, 'NOT_FOUND');
|
|
133
|
-
const refs = (await listWorkflows())
|
|
134
|
-
|
|
279
|
+
const refs = (await listWorkflows({ includeArchived: true }))
|
|
280
|
+
// v1 rows are still live until the engine cut-over; v2 rows carry a key only
|
|
281
|
+
// on kind:'agent' nodes (a task/end/and/or/combine card never does).
|
|
282
|
+
.filter((wf) => (wf.steps || []).some((col) => (col || []).some((n) => n && n.key === key))
|
|
283
|
+
|| (wf.nodes || []).some((n) => n && n.kind === 'agent' && n.key === key))
|
|
135
284
|
.map((wf) => wf.name || wf.id);
|
|
136
285
|
if (refs.length) {
|
|
137
|
-
throw err(`agent "${key}" is used by saved workflow(s): ${refs.join(', ')}
|
|
286
|
+
throw err(`agent "${key}" is used by saved workflow(s): ${refs.join(', ')} `
|
|
287
|
+
+ '— delete or edit those first (archived rows count)', 'REFERENCED');
|
|
288
|
+
}
|
|
289
|
+
// A workspace variant substitutes for its target agent by KEY: deleting the
|
|
290
|
+
// target would leave the variant pointing at nothing, and the substitution
|
|
291
|
+
// would silently stop happening on workspace runs.
|
|
292
|
+
const variants = Object.values(loadAgentRegistry())
|
|
293
|
+
.filter((m) => m && m.workspaceVariantOf === key)
|
|
294
|
+
.map((m) => m.key);
|
|
295
|
+
if (variants.length) {
|
|
296
|
+
throw err(`agent "${key}" is the workspace variant target of: ${variants.join(', ')} `
|
|
297
|
+
+ '— delete or re-point those first', 'REFERENCED');
|
|
138
298
|
}
|
|
139
299
|
const dir = requireUserDir();
|
|
140
300
|
await rm(join(dir, `${key}.meta.json`), { force: true });
|
package/src/core/artifacts.mjs
CHANGED
|
@@ -322,7 +322,8 @@ export function readPipelineExtras(pipelineId) {
|
|
|
322
322
|
* @param {string} pipelineId
|
|
323
323
|
* @param {{id:string, label?:string, nodeId?:string, stepIndex?:number, cycle?:number,
|
|
324
324
|
* stepKey?:string, status?:string, startedAt?:string, finishedAt?:string,
|
|
325
|
-
* durationMs?:number, tokens?:number, costUsd?:number, subagentType?:string
|
|
325
|
+
* durationMs?:number, tokens?:number, costUsd?:number, subagentType?:string,
|
|
326
|
+
* runModel?:string}} rec
|
|
326
327
|
*/
|
|
327
328
|
export function upsertSubAgent(pipelineId, rec) {
|
|
328
329
|
if (!pipelineId || !rec || !rec.id) return;
|
|
@@ -330,9 +331,11 @@ export function upsertSubAgent(pipelineId, rec) {
|
|
|
330
331
|
tx(() => {
|
|
331
332
|
getDb().prepare(`
|
|
332
333
|
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
|
+
label, status, started_at, finished_at, duration_ms, tokens, cost_usd, ui_phase, skills, subagent_type, graphify_count,
|
|
335
|
+
run_model)
|
|
334
336
|
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
|
|
337
|
+
@started_at,@finished_at,@duration_ms,@tokens,@cost_usd,@ui_phase,@skills,@subagent_type,@graphify_count,
|
|
338
|
+
@run_model)
|
|
336
339
|
ON CONFLICT(pipeline_id, id) DO UPDATE SET
|
|
337
340
|
status = excluded.status,
|
|
338
341
|
step_key = COALESCE(excluded.step_key, step_key),
|
|
@@ -348,7 +351,8 @@ export function upsertSubAgent(pipelineId, rec) {
|
|
|
348
351
|
ui_phase = COALESCE(excluded.ui_phase, ui_phase),
|
|
349
352
|
skills = COALESCE(excluded.skills, skills),
|
|
350
353
|
subagent_type = COALESCE(excluded.subagent_type, subagent_type),
|
|
351
|
-
graphify_count = COALESCE(excluded.graphify_count, graphify_count)
|
|
354
|
+
graphify_count = COALESCE(excluded.graphify_count, graphify_count),
|
|
355
|
+
run_model = COALESCE(excluded.run_model, run_model)
|
|
352
356
|
`).run({
|
|
353
357
|
pipeline_id: pipelineId,
|
|
354
358
|
id: rec.id,
|
|
@@ -367,6 +371,10 @@ export function upsertSubAgent(pipelineId, rec) {
|
|
|
367
371
|
skills: s(rec.skills), // s() = JSON.stringify or null; growing supersets overwrite via COALESCE
|
|
368
372
|
subagent_type: rec.subagentType ?? null, // scalar TEXT: bound directly (no s() JSON wrap)
|
|
369
373
|
graphify_count: Number.isFinite(rec.graphifyCount) ? rec.graphifyCount : null, // scalar INTEGER
|
|
374
|
+
// The model this child actually ran on: the alias its Task call asked for,
|
|
375
|
+
// else the parent node's model (what it inherits). Written at spawn and
|
|
376
|
+
// COALESCE-guarded, so a later finish/telemetry update never nulls it.
|
|
377
|
+
run_model: rec.runModel ?? null,
|
|
370
378
|
});
|
|
371
379
|
});
|
|
372
380
|
} catch { /* best-effort: live state.subAgents is the reconcile source of truth; a swallowed write is caught by tests, not a crashed run. */ }
|
|
@@ -382,13 +390,14 @@ export function upsertSubAgent(pipelineId, rec) {
|
|
|
382
390
|
* @returns {Array<{id:string, label:string|null, nodeId:string|null, stepIndex:number|null,
|
|
383
391
|
* cycle:number|null, stepKey:string|null, status:string, startedAt:string|null,
|
|
384
392
|
* finishedAt:string|null, durationMs:number|null, tokens:number|null, costUsd:number|null,
|
|
385
|
-
* subagentType:string|null}>}
|
|
393
|
+
* subagentType:string|null, runModel:string|null}>}
|
|
386
394
|
*/
|
|
387
395
|
export function listSubAgents(pipelineId) {
|
|
388
396
|
if (!pipelineId) return [];
|
|
389
397
|
return getDb().prepare(`
|
|
390
398
|
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
|
|
399
|
+
started_at, finished_at, duration_ms, tokens, cost_usd, ui_phase, skills, subagent_type, graphify_count,
|
|
400
|
+
run_model
|
|
392
401
|
FROM sub_agents WHERE pipeline_id = ? ORDER BY started_at, id
|
|
393
402
|
`).all(pipelineId).map((r) => ({
|
|
394
403
|
id: r.id,
|
|
@@ -407,6 +416,7 @@ export function listSubAgents(pipelineId) {
|
|
|
407
416
|
skills: j(r.skills, []), // NULL -> [] so the UI always has an array (no pills)
|
|
408
417
|
subagentType: r.subagent_type ?? null, // scalar TEXT: mapped directly (no j() parse)
|
|
409
418
|
graphifyCount: r.graphify_count ?? null, // scalar INTEGER: NULL -> null (no badge)
|
|
419
|
+
runModel: r.run_model ?? null, // scalar TEXT: NULL -> null (no pill on pre-v25 rows)
|
|
410
420
|
}));
|
|
411
421
|
}
|
|
412
422
|
|
|
@@ -728,6 +738,42 @@ function resolveAgainst(base, p) {
|
|
|
728
738
|
return isAbsolute(p) ? p : resolve(base, p);
|
|
729
739
|
}
|
|
730
740
|
|
|
741
|
+
/**
|
|
742
|
+
* The ONE typed error every prompt-file reader throws. Carried by `code` so the
|
|
743
|
+
* CLI can fail() on it and ui/server.mjs can answer 400 instead of letting it
|
|
744
|
+
* surface as an anonymous mid-run error event.
|
|
745
|
+
* @param {string} absPath the resolved path the caller named
|
|
746
|
+
* @param {unknown} cause
|
|
747
|
+
*/
|
|
748
|
+
export function promptFileError(absPath, cause) {
|
|
749
|
+
const err = new Error(`cannot read prompt file ${absPath}: ${cause?.message || cause}`);
|
|
750
|
+
err.code = 'PROMPT_FILE_UNREADABLE';
|
|
751
|
+
err.promptFile = absPath;
|
|
752
|
+
err.cause = cause;
|
|
753
|
+
return err;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* Read a NAMED prompt file, resolved against `projectDir` exactly as createPipeline
|
|
758
|
+
* does. Never degrades: a file the caller named but we cannot read is an ERROR, not
|
|
759
|
+
* an empty prompt. The old bare `catch { promptText = '' }` ran a whole pipeline on
|
|
760
|
+
* an empty task and exited 0 — in real mode spending tokens and cutting a worktree
|
|
761
|
+
* + feature branch for nothing. The empty-string fallback is only meaningful when
|
|
762
|
+
* NO file was named.
|
|
763
|
+
* @param {string} projectDir
|
|
764
|
+
* @param {string} promptFile absolute, or relative to projectDir
|
|
765
|
+
* @returns {Promise<string>}
|
|
766
|
+
* @throws {Error & {code:'PROMPT_FILE_UNREADABLE'}}
|
|
767
|
+
*/
|
|
768
|
+
export async function readPromptFile(projectDir, promptFile) {
|
|
769
|
+
const abs = resolveAgainst(projectDir, promptFile);
|
|
770
|
+
try {
|
|
771
|
+
return await readFile(abs, 'utf8');
|
|
772
|
+
} catch (cause) {
|
|
773
|
+
throw promptFileError(abs, cause);
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
731
777
|
/**
|
|
732
778
|
* Create a new pipeline directory and seed it with the prompt, extras and an audit
|
|
733
779
|
* header (pipeline.md). The structured run state is INSERTed as a pipelines row
|
|
@@ -785,12 +831,14 @@ export async function createPipeline(projectDir, opts = {}) {
|
|
|
785
831
|
// verbatim copy below is unchanged).
|
|
786
832
|
let promptText = typeof prompt === 'string' ? prompt : '';
|
|
787
833
|
if (!promptText && typeof precomputedPromptText === 'string') promptText = precomputedPromptText;
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
834
|
+
// A NAMED prompt file must be readable, and it is checked BEFORE anything is
|
|
835
|
+
// created (no dir, no row). It is checked even when an inline prompt or a
|
|
836
|
+
// precomputed body already won the text, because the file is still copied
|
|
837
|
+
// verbatim into prompt.md below — the old bare catch there silently substituted
|
|
838
|
+
// the inline text for the file the caller named.
|
|
839
|
+
if (promptFile) {
|
|
840
|
+
const fileText = await readPromptFile(projectDir, promptFile);
|
|
841
|
+
if (!promptText) promptText = fileText;
|
|
794
842
|
}
|
|
795
843
|
|
|
796
844
|
const resolvedTitle =
|
|
@@ -810,8 +858,11 @@ export async function createPipeline(projectDir, opts = {}) {
|
|
|
810
858
|
if (promptFile) {
|
|
811
859
|
try {
|
|
812
860
|
await copyFile(resolveAgainst(projectDir, promptFile), promptDest);
|
|
813
|
-
} catch {
|
|
814
|
-
|
|
861
|
+
} catch (cause) {
|
|
862
|
+
// The read above already proved this path readable, so a failure here is a
|
|
863
|
+
// TOCTOU (the file vanished) or a destination problem — either way the run
|
|
864
|
+
// must not proceed on a substituted prompt.
|
|
865
|
+
throw promptFileError(resolveAgainst(projectDir, promptFile), cause);
|
|
815
866
|
}
|
|
816
867
|
} else {
|
|
817
868
|
await writeFile(promptDest, promptText, 'utf8');
|
|
@@ -992,11 +1043,11 @@ export async function writeState(pipelineDir, stateObj) {
|
|
|
992
1043
|
INSERT INTO pipelines (id, project_key, workspace_key, target, title, base_name,
|
|
993
1044
|
date_prefix, status, phase, cycle, started_at, updated_at, total_cost_usd,
|
|
994
1045
|
total_active_ms, prompt, branch, workspace_meta, stepper, tools, resume_point,
|
|
995
|
-
source_type, source_ref, guardrails_id)
|
|
1046
|
+
source_type, source_ref, guardrails_id, outcome)
|
|
996
1047
|
VALUES (@id,@project_key,@workspace_key,@target,@title,@base_name,@date_prefix,
|
|
997
1048
|
@status,@phase,@cycle,@started_at,@updated_at,@total_cost_usd,@total_active_ms,
|
|
998
1049
|
@prompt,@branch,@workspace_meta,@stepper,@tools,@resume_point,
|
|
999
|
-
@source_type,@source_ref,@guardrails_id)
|
|
1050
|
+
@source_type,@source_ref,@guardrails_id,@outcome)
|
|
1000
1051
|
ON CONFLICT(id) DO UPDATE SET
|
|
1001
1052
|
status=excluded.status, phase=excluded.phase, cycle=excluded.cycle,
|
|
1002
1053
|
updated_at=excluded.updated_at, total_cost_usd=excluded.total_cost_usd,
|
|
@@ -1004,6 +1055,7 @@ export async function writeState(pipelineDir, stateObj) {
|
|
|
1004
1055
|
workspace_meta=excluded.workspace_meta, stepper=excluded.stepper,
|
|
1005
1056
|
tools=excluded.tools,
|
|
1006
1057
|
resume_point=excluded.resume_point,
|
|
1058
|
+
outcome=excluded.outcome,
|
|
1007
1059
|
base_name=COALESCE(excluded.base_name, base_name),
|
|
1008
1060
|
date_prefix=COALESCE(excluded.date_prefix, date_prefix)
|
|
1009
1061
|
`).run(toPipelineRow(obj));
|
|
@@ -1011,10 +1063,19 @@ export async function writeState(pipelineDir, stateObj) {
|
|
|
1011
1063
|
getDb().prepare('DELETE FROM pipeline_steps WHERE pipeline_id = ?').run(id);
|
|
1012
1064
|
const ins = getDb().prepare(`
|
|
1013
1065
|
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,
|
|
1015
|
-
|
|
1066
|
+
status, started_at, updated_at, active_ms, running_since, cost_usd, session_id,
|
|
1067
|
+
skills, graphify_count,
|
|
1068
|
+
execution_id, exec_kind, agent_key, ended_at, exec_trigger, exec_result, exec_meta)
|
|
1069
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
|
1016
1070
|
`);
|
|
1017
1071
|
for (const st of Array.isArray(obj.steps) ? obj.steps : []) {
|
|
1072
|
+
// v2 rows: execution_id === key. v1 rows leave every exec_* column NULL, so
|
|
1073
|
+
// the readers below reproduce today's exact shape for a v1 pipeline.
|
|
1074
|
+
const meta = (st.taskId != null || st.parentExecutionId != null || st.title != null || st.phaseOrdinal != null)
|
|
1075
|
+
? s({ taskId: st.taskId ?? null, parentExecutionId: st.parentExecutionId ?? null,
|
|
1076
|
+
title: st.title ?? null, phaseOrdinal: st.phaseOrdinal ?? null,
|
|
1077
|
+
taskIndex: st.taskIndex ?? null, taskTotal: st.taskTotal ?? null })
|
|
1078
|
+
: null;
|
|
1018
1079
|
ins.run(
|
|
1019
1080
|
id, st.key, st.nodeId ?? null, st.phase ?? null,
|
|
1020
1081
|
st.stepIndex ?? null, st.cycle ?? null, st.status ?? null,
|
|
@@ -1025,6 +1086,13 @@ export async function writeState(pipelineDir, stateObj) {
|
|
|
1025
1086
|
st.sessionId ?? null,
|
|
1026
1087
|
s(st.skills),
|
|
1027
1088
|
Number.isFinite(st.graphifyCount) ? st.graphifyCount : null,
|
|
1089
|
+
st.executionId ?? null,
|
|
1090
|
+
st.kind ?? null,
|
|
1091
|
+
st.agentKey ?? null,
|
|
1092
|
+
st.endedAt ?? null,
|
|
1093
|
+
st.trigger === undefined ? null : s(st.trigger),
|
|
1094
|
+
st.result === undefined ? null : s(st.result),
|
|
1095
|
+
meta,
|
|
1028
1096
|
);
|
|
1029
1097
|
}
|
|
1030
1098
|
});
|
|
@@ -1334,11 +1402,23 @@ function toPipelineRow(o) {
|
|
|
1334
1402
|
source_type: o.sourceType ?? 'prompt',
|
|
1335
1403
|
source_ref: s(o.sourceMeta),
|
|
1336
1404
|
guardrails_id: o.guardrailsId ?? null,
|
|
1405
|
+
// §5.9 outcome: the derived run-level v2 facts, so a rehydrated state matches
|
|
1406
|
+
// a live one. NULL for a v1 run (nothing to say), so v1 rows are unchanged.
|
|
1407
|
+
outcome: (o.engine === 2 || o.endReached !== undefined)
|
|
1408
|
+
? s({
|
|
1409
|
+
endReached: !!o.endReached,
|
|
1410
|
+
result: o.result ?? null,
|
|
1411
|
+
warnings: Array.isArray(o.warnings) ? o.warnings : [],
|
|
1412
|
+
wireDeliveries: o.wireDeliveries ?? {},
|
|
1413
|
+
tokens: o.tokens ?? {},
|
|
1414
|
+
})
|
|
1415
|
+
: null,
|
|
1337
1416
|
};
|
|
1338
1417
|
}
|
|
1339
1418
|
|
|
1340
1419
|
/**
|
|
1341
|
-
* The pipeline's {cost, active} totals for the history list
|
|
1420
|
+
* The pipeline's {cost, active} totals for the history list (and the Ask Worca
|
|
1421
|
+
* `get_run` tool, ask-worca-design.md §6.4), read from the DB row.
|
|
1342
1422
|
* Normal runs carry NOT-NULL 0-defaulted totals, so when a total is > 0 it is used
|
|
1343
1423
|
* verbatim and NO extra query runs. Only when a total is 0 do we fall back to the
|
|
1344
1424
|
* per-step SUM/COUNT (the DB-native equivalent of the old pipelineTotalCost/
|
|
@@ -1349,7 +1429,7 @@ function toPipelineRow(o) {
|
|
|
1349
1429
|
* @param {object} row a pipelines DB row (total_cost_usd / total_active_ms)
|
|
1350
1430
|
* @returns {{cost:number|null, active:number|null}}
|
|
1351
1431
|
*/
|
|
1352
|
-
function totalsFor(row) {
|
|
1432
|
+
export function totalsFor(row) {
|
|
1353
1433
|
const agg = getDb().prepare(`
|
|
1354
1434
|
SELECT COUNT(cost_usd) cc, SUM(cost_usd) sc, COUNT(active_ms) ca, SUM(active_ms) sa
|
|
1355
1435
|
FROM pipeline_steps WHERE pipeline_id = ?
|
|
@@ -1439,6 +1519,7 @@ async function rowToHistoryEntry(row, repoDir = null, opts = {}) {
|
|
|
1439
1519
|
sourceBranch: source,
|
|
1440
1520
|
guardrailsId: row.guardrails_id ?? null,
|
|
1441
1521
|
pauseReason: row.pause_reason ?? null,
|
|
1522
|
+
pauseDetail: row.pause_detail ?? null,
|
|
1442
1523
|
retainedWork: retainedWorkFor(row),
|
|
1443
1524
|
survived,
|
|
1444
1525
|
added,
|
|
@@ -1494,7 +1575,8 @@ export async function listPipelines(projectDir, opts = {}, workspaceKey) {
|
|
|
1494
1575
|
const rows = getDb().prepare(`
|
|
1495
1576
|
SELECT id, project_key, target, title, status, started_at, updated_at, total_cost_usd, total_active_ms,
|
|
1496
1577
|
branch, workspace_meta, guardrails_id,
|
|
1497
|
-
json_extract(CASE WHEN json_valid(resume_point) THEN resume_point END, '$.pauseReason') AS pause_reason
|
|
1578
|
+
json_extract(CASE WHEN json_valid(resume_point) THEN resume_point END, '$.pauseReason') AS pause_reason,
|
|
1579
|
+
json_extract(CASE WHEN json_valid(resume_point) THEN resume_point END, '$.pauseDetail') AS pause_detail
|
|
1498
1580
|
FROM pipelines
|
|
1499
1581
|
WHERE ${workspaceKey ? 'workspace_key = ?' : 'project_key = ?'} AND archived_at IS NULL
|
|
1500
1582
|
ORDER BY started_at DESC
|
|
@@ -1522,7 +1604,8 @@ export async function listAllPipelines(opts = {}, { batchSize = 16 } = {}) {
|
|
|
1522
1604
|
const rows = getDb().prepare(`
|
|
1523
1605
|
SELECT id, project_key, workspace_key, target, title, status, started_at, updated_at,
|
|
1524
1606
|
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
|
|
1607
|
+
json_extract(CASE WHEN json_valid(resume_point) THEN resume_point END, '$.pauseReason') AS pause_reason,
|
|
1608
|
+
json_extract(CASE WHEN json_valid(resume_point) THEN resume_point END, '$.pauseDetail') AS pause_detail
|
|
1526
1609
|
FROM pipelines
|
|
1527
1610
|
WHERE archived_at IS NULL
|
|
1528
1611
|
ORDER BY COALESCE(updated_at, started_at) DESC, project_key, id
|
|
@@ -1646,6 +1729,22 @@ function stepRowToStep(r) {
|
|
|
1646
1729
|
};
|
|
1647
1730
|
if (r.node_id != null) step.nodeId = r.node_id;
|
|
1648
1731
|
if (r.step_index != null) step.stepIndex = r.step_index;
|
|
1732
|
+
if (r.execution_id != null) step.executionId = r.execution_id;
|
|
1733
|
+
if (r.exec_kind != null) step.kind = r.exec_kind;
|
|
1734
|
+
if (r.agent_key != null) step.agentKey = r.agent_key;
|
|
1735
|
+
if (r.ended_at != null) step.endedAt = r.ended_at;
|
|
1736
|
+
if (r.execution_id != null && r.cycle != null) step.ordinal = r.cycle; // `ordinal` is the v2 name; `cycle` is its alias
|
|
1737
|
+
if (r.exec_trigger != null) step.trigger = j(r.exec_trigger, { wireIds: [], freshPorts: [] });
|
|
1738
|
+
if (r.exec_result != null) step.result = j(r.exec_result, null);
|
|
1739
|
+
const em = r.exec_meta != null ? j(r.exec_meta, null) : null;
|
|
1740
|
+
if (em) {
|
|
1741
|
+
if (em.taskId != null) step.taskId = em.taskId;
|
|
1742
|
+
if (em.parentExecutionId != null) step.parentExecutionId = em.parentExecutionId;
|
|
1743
|
+
if (em.title != null) step.title = em.title;
|
|
1744
|
+
if (em.phaseOrdinal != null) step.phaseOrdinal = em.phaseOrdinal;
|
|
1745
|
+
if (em.taskIndex != null) step.taskIndex = em.taskIndex;
|
|
1746
|
+
if (em.taskTotal != null) step.taskTotal = em.taskTotal;
|
|
1747
|
+
}
|
|
1649
1748
|
return step;
|
|
1650
1749
|
}
|
|
1651
1750
|
|
|
@@ -1678,13 +1777,33 @@ function rowToState(row) {
|
|
|
1678
1777
|
stepper: j(row.stepper, null),
|
|
1679
1778
|
tools: j(row.tools, null),
|
|
1680
1779
|
guardrailsId: row.guardrails_id ?? null,
|
|
1780
|
+
// A retired v1 resume point was NULLed by the v2 upgrade: the run stays in
|
|
1781
|
+
// History with an honest status, but it can never be resumed again.
|
|
1782
|
+
resumable: row.resume_point != null,
|
|
1681
1783
|
steps: getDb().prepare(`
|
|
1682
1784
|
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
|
|
1785
|
+
active_ms, running_since, cost_usd, session_id, skills, graphify_count,
|
|
1786
|
+
execution_id, exec_kind, agent_key, ended_at, exec_trigger, exec_result, exec_meta
|
|
1684
1787
|
FROM pipeline_steps WHERE pipeline_id = ? ORDER BY rowid
|
|
1685
1788
|
`).all(row.id).map(stepRowToStep),
|
|
1686
1789
|
subAgents: listSubAgents(row.id),
|
|
1687
1790
|
};
|
|
1791
|
+
// The pause cause rides resume_point (no column): expose it on the DETAIL payload
|
|
1792
|
+
// too, so a deep-linked History detail no longer waits for the LIST row.
|
|
1793
|
+
const rp = j(row.resume_point, null);
|
|
1794
|
+
state.pauseReason = typeof rp?.pauseReason === 'string' ? rp.pauseReason : null;
|
|
1795
|
+
state.pauseDetail = typeof rp?.pauseDetail === 'string' ? rp.pauseDetail : null;
|
|
1796
|
+
const outcome = j(row.outcome, null);
|
|
1797
|
+
if (outcome) {
|
|
1798
|
+
state.engine = 2;
|
|
1799
|
+
state.endReached = !!outcome.endReached;
|
|
1800
|
+
state.result = outcome.result ?? null;
|
|
1801
|
+
state.warnings = Array.isArray(outcome.warnings) ? outcome.warnings : [];
|
|
1802
|
+
state.wireDeliveries = outcome.wireDeliveries ?? {};
|
|
1803
|
+
state.tokens = outcome.tokens ?? {};
|
|
1804
|
+
state.active = []; // nothing is in flight in a rehydrated snapshot
|
|
1805
|
+
state.gate = null;
|
|
1806
|
+
}
|
|
1688
1807
|
const meta = readStoreMeta(row.project_key);
|
|
1689
1808
|
state.projectDir = meta?.path ?? null;
|
|
1690
1809
|
// Workspace superset: spread workspace_meta back onto the top level + target.
|
|
@@ -1752,6 +1871,24 @@ export function lookupPipelineRow(key, id) {
|
|
|
1752
1871
|
return row || null;
|
|
1753
1872
|
}
|
|
1754
1873
|
|
|
1874
|
+
/**
|
|
1875
|
+
* Ask Worca (ask-worca-design.md §6.4 get_run): one pipelines row by short id
|
|
1876
|
+
* across EVERY store key, archived included. `pipelines.id` is the PRIMARY KEY, so
|
|
1877
|
+
* there is at most one row; the dir-name form (`…-<8hex>`) is accepted like
|
|
1878
|
+
* lookupPipelineRow does.
|
|
1879
|
+
* @param {string} id
|
|
1880
|
+
* @returns {object|null}
|
|
1881
|
+
*/
|
|
1882
|
+
export function findPipelineRowById(id) {
|
|
1883
|
+
const raw = String(id ?? '').trim();
|
|
1884
|
+
if (!raw) return null;
|
|
1885
|
+
let row = getDb().prepare('SELECT * FROM pipelines WHERE id = ?').get(raw.toLowerCase());
|
|
1886
|
+
if (row) return row;
|
|
1887
|
+
const m = DIR_ID_RE.exec(raw);
|
|
1888
|
+
if (m) row = getDb().prepare('SELECT * FROM pipelines WHERE id = ?').get(m[1].toLowerCase());
|
|
1889
|
+
return row || null;
|
|
1890
|
+
}
|
|
1891
|
+
|
|
1755
1892
|
/**
|
|
1756
1893
|
* The DB-backed lookups `sweepRunRoots` (worktree.mjs) needs, in ONE place shared by
|
|
1757
1894
|
* both callers — `ui/server.mjs`'s boot sweep and the `worca doctor` subcommand.
|
|
@@ -1980,6 +2117,45 @@ export async function findRunDir(pipelinesDir, id) {
|
|
|
1980
2117
|
return null;
|
|
1981
2118
|
}
|
|
1982
2119
|
|
|
2120
|
+
/**
|
|
2121
|
+
* Read one INDEXED artifact of a pipelines ROW. `rel` never reaches the
|
|
2122
|
+
* filesystem: it only SELECTS among the rows the artifacts table already holds —
|
|
2123
|
+
* the exact rel_path first, else the LONGEST rel_path that is a path SUFFIX of it
|
|
2124
|
+
* (`…/<rel_path>`; the engine records absolute paths and the client sends
|
|
2125
|
+
* `result.path` verbatim). Basenames CAN collide across dirs, so a bare basename
|
|
2126
|
+
* never matches a nested row. The path that is read is ALWAYS the stored one,
|
|
2127
|
+
* run dir first, store root second (plan/review markdown is store-root-relative);
|
|
2128
|
+
* a stored path with a `..` segment is refused outright. Null when the row, the
|
|
2129
|
+
* index row or the file is missing.
|
|
2130
|
+
*/
|
|
2131
|
+
export async function resolveIndexedArtifactForRow(row, rel) {
|
|
2132
|
+
const norm = (p) => String(p || '').replace(/\\/g, '/');
|
|
2133
|
+
const want = norm(rel);
|
|
2134
|
+
if (!row || !want) return null;
|
|
2135
|
+
const arts = (await listArtifacts(row.id))
|
|
2136
|
+
.filter((a) => a && typeof a.relPath === 'string' && a.relPath && !a.relPath.split('/').includes('..'));
|
|
2137
|
+
// Exact first, then the LONGEST suffix — with rows `plan.md` and `a/plan.md`
|
|
2138
|
+
// a request for `/x/a/plan.md` ends with BOTH `/plan.md` and `/a/plan.md`, and a
|
|
2139
|
+
// first-match `find` would serve whichever row the table happens to list first.
|
|
2140
|
+
const hit = arts.find((a) => a.relPath === want)
|
|
2141
|
+
|| arts.filter((a) => want.endsWith(`/${a.relPath}`))
|
|
2142
|
+
.sort((x, y) => y.relPath.length - x.relPath.length)[0];
|
|
2143
|
+
if (!hit) return null;
|
|
2144
|
+
const isWs = row.target === 'workspace' || !!row.workspace_key;
|
|
2145
|
+
const storeRoot = isWs ? workspaceStorePath(row.workspace_key) : projectStorePath(row.project_key);
|
|
2146
|
+
const runDir = await runDirForRow(row);
|
|
2147
|
+
for (const base of [runDir, storeRoot]) {
|
|
2148
|
+
try { return { rel: hit.relPath, text: await readFile(join(base, hit.relPath), 'utf8') }; } catch { /* try the next base */ }
|
|
2149
|
+
}
|
|
2150
|
+
return null;
|
|
2151
|
+
}
|
|
2152
|
+
|
|
2153
|
+
/** The keyed form (`/api/history/:key/:id`, workspace twin) over lookupPipelineRow. */
|
|
2154
|
+
export async function resolveIndexedArtifact(key, id, rel) {
|
|
2155
|
+
const row = lookupPipelineRow(key, id);
|
|
2156
|
+
return row ? resolveIndexedArtifactForRow(row, rel) : null;
|
|
2157
|
+
}
|
|
2158
|
+
|
|
1983
2159
|
/** Read a pipeline-local artifact file as text, or null if absent. */
|
|
1984
2160
|
export async function readRunArtifactText(key, id, relPath) {
|
|
1985
2161
|
const row = lookupPipelineRow(key, id);
|