@worca/app 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/README.md +403 -0
  2. package/agents/clarify.meta.json +19 -0
  3. package/agents/decomposer.meta.json +21 -0
  4. package/agents/implementer.meta.json +20 -0
  5. package/agents/manualTestsChecklist.meta.json +18 -0
  6. package/agents/manualWebUiTesting.meta.json +18 -0
  7. package/agents/planReviewer.meta.json +19 -0
  8. package/agents/planner.meta.json +20 -0
  9. package/agents/refiner.meta.json +19 -0
  10. package/agents/reviewer.meta.json +19 -0
  11. package/agents/worca-cc-clarify.md +67 -0
  12. package/agents/worca-cc-code-reviewer.md +66 -0
  13. package/agents/worca-cc-decomposer.md +84 -0
  14. package/agents/worca-cc-implementer.md +69 -0
  15. package/agents/worca-cc-manual-tests-checklist.md +63 -0
  16. package/agents/worca-cc-manual-web-ui-testing.md +64 -0
  17. package/agents/worca-cc-plan-refiner.md +69 -0
  18. package/agents/worca-cc-plan-reviewer.md +70 -0
  19. package/agents/worca-cc-planner.md +70 -0
  20. package/agents/worca-cc-workspace-reviewer.md +56 -0
  21. package/agents/worca-cc-workspace-scanner.md +55 -0
  22. package/agents/workspaceReviewer.meta.json +20 -0
  23. package/agents/workspaceScanner.meta.json +18 -0
  24. package/package.json +61 -0
  25. package/scripts/install.mjs +209 -0
  26. package/skills/worca/SKILL.md +66 -0
  27. package/src/cli/worca-cc.mjs +1520 -0
  28. package/src/core/agent-gen.mjs +206 -0
  29. package/src/core/agent-registry.mjs +417 -0
  30. package/src/core/agent-store.mjs +143 -0
  31. package/src/core/artifacts.mjs +2019 -0
  32. package/src/core/channels.mjs +302 -0
  33. package/src/core/chat/allowlist.mjs +27 -0
  34. package/src/core/chat/channel-host.mjs +562 -0
  35. package/src/core/chat/channel-protocol.mjs +117 -0
  36. package/src/core/chat/channel-worker-child.mjs +211 -0
  37. package/src/core/chat/chat-context.mjs +66 -0
  38. package/src/core/chat/command-router.mjs +343 -0
  39. package/src/core/chat/notifier.mjs +120 -0
  40. package/src/core/chat/parser.mjs +30 -0
  41. package/src/core/chat/rate-limiter.mjs +133 -0
  42. package/src/core/chat/redact.mjs +27 -0
  43. package/src/core/chat/renderers.mjs +136 -0
  44. package/src/core/claude-runner.mjs +1356 -0
  45. package/src/core/config.mjs +882 -0
  46. package/src/core/cost-budget.mjs +103 -0
  47. package/src/core/db.mjs +864 -0
  48. package/src/core/fanout.mjs +48 -0
  49. package/src/core/folder-dialog.mjs +138 -0
  50. package/src/core/fs-browse.mjs +49 -0
  51. package/src/core/git-info.mjs +200 -0
  52. package/src/core/guardrail-store.mjs +204 -0
  53. package/src/core/guardrails.mjs +302 -0
  54. package/src/core/marketplaces.mjs +267 -0
  55. package/src/core/migrate-fs-to-db.mjs +612 -0
  56. package/src/core/model-env.mjs +74 -0
  57. package/src/core/orchestrator.mjs +4279 -0
  58. package/src/core/overview-agent.mjs +124 -0
  59. package/src/core/phases.mjs +1279 -0
  60. package/src/core/pipeline-delete.mjs +428 -0
  61. package/src/core/plugin-api.mjs +13 -0
  62. package/src/core/plugin-config.mjs +100 -0
  63. package/src/core/plugin-inventory.mjs +50 -0
  64. package/src/core/plugin-manifest.mjs +447 -0
  65. package/src/core/plugin-models.mjs +130 -0
  66. package/src/core/plugin-repo.mjs +303 -0
  67. package/src/core/plugin-shim-child.mjs +76 -0
  68. package/src/core/plugin-shim.mjs +197 -0
  69. package/src/core/plugin-store.mjs +485 -0
  70. package/src/core/plugin-workflows.mjs +179 -0
  71. package/src/core/plugins-lock.mjs +49 -0
  72. package/src/core/preflight-node.mjs +122 -0
  73. package/src/core/preflight.mjs +341 -0
  74. package/src/core/projects.mjs +157 -0
  75. package/src/core/protocol.mjs +257 -0
  76. package/src/core/recoverable-error.mjs +51 -0
  77. package/src/core/results.mjs +188 -0
  78. package/src/core/run-context.mjs +1375 -0
  79. package/src/core/run-log.mjs +64 -0
  80. package/src/core/run-manifest.mjs +317 -0
  81. package/src/core/runners.mjs +167 -0
  82. package/src/core/settings.mjs +682 -0
  83. package/src/core/skills.mjs +210 -0
  84. package/src/core/sources.mjs +232 -0
  85. package/src/core/stats.mjs +182 -0
  86. package/src/core/store.mjs +67 -0
  87. package/src/core/title.mjs +64 -0
  88. package/src/core/workflow-validator.mjs +185 -0
  89. package/src/core/workflows.mjs +568 -0
  90. package/src/core/workspace-scan.mjs +420 -0
  91. package/src/core/workspaces.mjs +353 -0
  92. package/src/core/worktree.mjs +708 -0
  93. package/src/feature.mjs +9 -0
  94. package/ui/public/app.js +10647 -0
  95. package/ui/public/assets/worca-favicon.png +0 -0
  96. package/ui/public/assets/worca-logo.png +0 -0
  97. package/ui/public/chat-settings-view.mjs +89 -0
  98. package/ui/public/composer-core.mjs +211 -0
  99. package/ui/public/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
  100. package/ui/public/fonts/poppins-latin-400-normal.woff2 +0 -0
  101. package/ui/public/fonts/poppins-latin-500-normal.woff2 +0 -0
  102. package/ui/public/fonts/poppins-latin-600-normal.woff2 +0 -0
  103. package/ui/public/fonts/poppins-latin-700-normal.woff2 +0 -0
  104. package/ui/public/guardrails-view.mjs +244 -0
  105. package/ui/public/index.html +1145 -0
  106. package/ui/public/log-filter.mjs +81 -0
  107. package/ui/public/log-line.mjs +86 -0
  108. package/ui/public/models-view.mjs +433 -0
  109. package/ui/public/plugins-view.mjs +430 -0
  110. package/ui/public/results-view.mjs +121 -0
  111. package/ui/public/source-pane.mjs +156 -0
  112. package/ui/public/stats-view.mjs +523 -0
  113. package/ui/public/style.css +1557 -0
  114. package/ui/server.mjs +3573 -0
@@ -0,0 +1,428 @@
1
+ // src/core/pipeline-delete.mjs
2
+ // ARCHIVE a finished pipeline: reclaim everything that costs disk — its store
3
+ // folder, its shared plan/review markdown (resolved EXACTLY via the artifacts
4
+ // index, no more baseName guessing), its detached run root, and its local branch
5
+ // + worktree — then SOFT-DELETE the DB row by stamping archived_at. The remote
6
+ // branch is never touched. Best-effort on git: filesystem store removal always
7
+ // proceeds; git failures are reported as warnings, not thrown.
8
+ //
9
+ // The pipelines row is PERMANENT: it is the run's statistical record (cost,
10
+ // duration, status), so it is never DELETEd and no FK ON DELETE CASCADE fires.
11
+ // Its steps/events/clarify/reviews children stay for the same reason. The only
12
+ // child rows removed are `artifacts` — that index points at the exact files this
13
+ // function just unlinked, so leaving it would strand dead pointers.
14
+ // List and count reads filter on `archived_at IS NULL`; by-id reads still resolve.
15
+
16
+ import { rm, mkdir } from 'node:fs/promises';
17
+ import { existsSync } from 'node:fs';
18
+ import { join, isAbsolute } from 'node:path';
19
+
20
+ import { projectKey, projectStorePath } from './store.mjs';
21
+ import {
22
+ listArtifacts, readPipelineByKey, persistPrState, retainedWorkFor,
23
+ recordArtifact, appendAudit, findRunDir,
24
+ } from './artifacts.mjs';
25
+ import { worcaHome } from './projects.mjs';
26
+ import { getDb, tx } from './db.mjs';
27
+ import { removeWorktree, snapshotWorktreePatch } from './worktree.mjs';
28
+ import {
29
+ rmGuarded, readRunManifest, rescueModifiedMounts, scanStrayEntries, copyRunManifestTo,
30
+ } from './run-manifest.mjs';
31
+ import { branchExists, hasGh, findPrForBranch } from './git-info.mjs';
32
+ import { retainedWorkPatchName } from './results.mjs';
33
+
34
+ // Statuses for which deletion is refused (the entry is or may be live).
35
+ const ACTIVE = new Set(['running', 'starting', 'created', 'pausing']);
36
+ function err(message, code) { return Object.assign(new Error(message), { code }); }
37
+
38
+ /**
39
+ * Resolve the pipelines row for a store key + (short id | run-dir basename). Mirrors
40
+ * artifacts.lookupPipelineRow's WHERE logic: exact id first, then the 8-hex id parsed
41
+ * from a run-dir basename. A workspace key ("workspaces/<wk>") filters on workspace_key.
42
+ */
43
+ function lookupRow(storeKey, id) {
44
+ const isWs = typeof storeKey === 'string' && storeKey.startsWith('workspaces/');
45
+ const col = isWs ? 'workspace_key' : 'project_key';
46
+ const val = isWs ? storeKey.slice('workspaces/'.length) : storeKey;
47
+ let row = getDb().prepare(`SELECT * FROM pipelines WHERE ${col} = ? AND id = ?`).get(val, id);
48
+ if (row) return row;
49
+ const m = /-([0-9a-f]{8})$/i.exec(String(id));
50
+ if (m) row = getDb().prepare(`SELECT * FROM pipelines WHERE ${col} = ? AND id = ?`).get(val, m[1].toLowerCase());
51
+ return row || null;
52
+ }
53
+
54
+ /**
55
+ * Resolve an indexed artifact's absolute path. The artifacts index encodes scope by
56
+ * convention (recordArtifact / orchestrator._artifact): plans/ and reviews/ are
57
+ * store-root-relative (the shared markdown, a sibling of pipelines/); everything else
58
+ * (prompt.md, manual-tests-checklist.md, webui-review-cycleN.md, extras/*) is
59
+ * pipeline-dir-relative.
60
+ */
61
+ function artifactAbsPath(relPath, pipelineDir, storeRootDir) {
62
+ if (isAbsolute(relPath)) return relPath;
63
+ if (relPath.startsWith('plans/') || relPath.startsWith('reviews/')) return join(storeRootDir, relPath);
64
+ return join(pipelineDir, relPath);
65
+ }
66
+
67
+ /**
68
+ * @param {{ projectDir?:string, key?:string, workspaceKey?:string, id:string }} args
69
+ * @returns {Promise<null | { ok, id, archived, pipelineDir, planFiles, reviewFiles, branch, worktree, warnings }
70
+ * | { ok, id, alreadyArchived, warnings }>}
71
+ * null => no pipeline with that id (404). Throws err(code:'RUNNING'|'BAD_REQUEST') for guards.
72
+ * An already-archived row short-circuits to { alreadyArchived: true } — the FS was
73
+ * reclaimed by the first call, so a second pass has nothing to do (idempotent).
74
+ *
75
+ * When `workspaceKey` is set the pipeline lives in the workspace store
76
+ * (store/workspaces/<workspaceKey>/); branch/worktree cleanup iterates the
77
+ * per-project `state.branches` map (each entry {feature,worktreeDir} keyed by
78
+ * projectKey) against the matching `state.projects[].projectDir`, instead of the
79
+ * single scalar `state.branch`. The state is reconstructed from the DB row by the
80
+ * same reader history uses. Result warnings[] aggregates per-project failures.
81
+ */
82
+ export async function archivePipeline({ projectDir = null, key = null, workspaceKey = null, id } = {}) {
83
+ if (!id || typeof id !== 'string') throw err('id is required', 'BAD_REQUEST');
84
+
85
+ // Resolve the store key ONCE so the run dir and the shared plan/review files are
86
+ // always read from the same store root. A workspace pipeline lives under the
87
+ // literal "workspaces/<workspaceKey>" segment (projectStorePath joins it under
88
+ // storeRoot()), so the dir/plan/review resolution below is reused as-is.
89
+ const storeKey = workspaceKey
90
+ ? `workspaces/${workspaceKey}`
91
+ : (key || (projectDir ? projectKey(projectDir) : null));
92
+ if (!storeKey) throw err('projectKey, projectDir or workspaceKey is required', 'BAD_REQUEST');
93
+
94
+ const row = lookupRow(storeKey, id);
95
+ if (!row) return null;
96
+ if (ACTIVE.has(String(row.status || '').toLowerCase())) {
97
+ throw err('cannot delete a running pipeline', 'RUNNING');
98
+ }
99
+ // Idempotent: the first archive already reclaimed the FS. Re-running the unlink /
100
+ // worktree / rm passes would only produce noise (and warnings) over gone paths.
101
+ if (row.archived_at) {
102
+ return { ok: true, id: row.id, alreadyArchived: true, warnings: [] };
103
+ }
104
+ // UI state is advisory. Enforce the no-data-loss rule here as well, because a
105
+ // stale client or a direct API caller could otherwise force-remove the very
106
+ // checkout that was retained after its commit failed.
107
+ if (retainedWorkFor(row)) {
108
+ throw err('cannot archive while retained uncommitted work exists; recover it or discard the worktree first', 'RETAINED_WORKTREE');
109
+ }
110
+ // The run root is the one thing archive destroys that can still hold retained
111
+ // checkouts (detached members live at runs/<id>/repos/<key>). Resolve it ONCE,
112
+ // here, for the guards below AND the 2b) removal further down.
113
+ const runRoot = join(worcaHome(), 'runs', row.id);
114
+ // Fail CLOSED on unreadable retention metadata — but ONLY while the run root
115
+ // still exists. With it gone, archive's per-member cleanup is already a no-op
116
+ // for an unparseable column (rowToState yields branches:{} / a null branch), so
117
+ // refusing forever would only wedge the row: discard cannot clear it
118
+ // (retainedWorkFor returns null) and the UI hides the Discard button. Naming
119
+ // the path keeps the refusal hand-clearable.
120
+ const unreadable = (col) => {
121
+ if (typeof col !== 'string' || !col.trim()) return false;
122
+ // A JSON *string* branch ("worca/foo") is a SUPPORTED legacy shape
123
+ // (rowToHistoryEntry reads it) — it carries no retention, so it is readable.
124
+ try { const p = JSON.parse(col); return p === null || (typeof p !== 'object' && typeof p !== 'string'); }
125
+ catch { return true; }
126
+ };
127
+ const metaUnreadable = row.target === 'workspace' ? unreadable(row.workspace_meta) : unreadable(row.branch);
128
+ if (metaUnreadable && existsSync(runRoot)) {
129
+ throw err(
130
+ `cannot archive: retention metadata is unreadable, so retained uncommitted work inside ${runRoot} cannot be ruled out — inspect and remove that run root, then archive again`,
131
+ 'RETAINED_WORKTREE',
132
+ );
133
+ }
134
+ // The DB stamp is teardown's LAST best-effort write; the run.json retain block
135
+ // is written earlier. Trust either representation (the sweep already does).
136
+ if (existsSync(runRoot)) {
137
+ const guardManifest = await readRunManifest(runRoot);
138
+ const members = Array.isArray(guardManifest?.retain?.members) ? guardManifest.retain.members : [];
139
+ if (members.some((m) => m?.worktreeDir && existsSync(m.worktreeDir))) {
140
+ throw err('cannot archive while retained uncommitted work exists; recover it or discard the worktree first', 'RETAINED_WORKTREE');
141
+ }
142
+ }
143
+
144
+ // Reconstruct state (branch/branches/projects) via the same reader history uses.
145
+ const { state } = (await readPipelineByKey(storeKey, row.id)) || { state: null };
146
+
147
+ // The real on-disk run dir (markdown + extras live here). Resolve by the -<id> suffix.
148
+ const storeRootDir = projectStorePath(storeKey);
149
+ const pipelinesDir = join(storeRootDir, 'pipelines');
150
+ const runDir = await findRunDir(pipelinesDir, row.id);
151
+
152
+ const report = {
153
+ ok: true, id: row.id, archived: true, pipelineDir: runDir,
154
+ planFiles: [], reviewFiles: [], branch: null, worktree: null, runRoot: null, warnings: [],
155
+ };
156
+ if (metaUnreadable) {
157
+ report.warnings.push('retention metadata was unreadable; per-member branch/worktree cleanup was skipped');
158
+ }
159
+
160
+ // Final PR observation while the branch still exists (spec §6.8.3). Single-
161
+ // project runs only: workspace rows carry a branches MAP (state.branches) —
162
+ // their per-project PR facts are left to prior enrichment passes (accepted
163
+ // limitation). A merge after archive is never observed — also accepted.
164
+ try {
165
+ const branch = state?.branch?.feature;
166
+ if (branch && state?.projectDir && await hasGh()) {
167
+ const pr = await findPrForBranch({ projectDir: state.projectDir, head: branch });
168
+ if (pr) persistPrState(row.id, pr);
169
+ }
170
+ } catch (e) {
171
+ // Named `e`, not `err`: `err` is this module's error-factory helper and a catch
172
+ // parameter would shadow it for the whole block.
173
+ report.warnings.push(`pr refresh failed: ${e?.message || e}`);
174
+ }
175
+
176
+ // 1) Unlink the EXACT indexed markdown (no baseName-derivation). Pipeline-local
177
+ // artifacts (prompt/extras/checklist/webui) live INSIDE runDir and are cleared
178
+ // by the rm(runDir) below; only the shared store-rooted plan/review md need an
179
+ // explicit unlink here.
180
+ const arts = await listArtifacts(row.id);
181
+ for (const a of arts) {
182
+ const abs = artifactAbsPath(a.relPath, runDir || join(pipelinesDir, row.id), storeRootDir);
183
+ if (!abs || (runDir && abs.startsWith(runDir))) continue; // pipeline-local handled by rm(runDir)
184
+ try {
185
+ if (existsSync(abs)) { await rm(abs, { force: true }); }
186
+ if (a.kind === 'plan') report.planFiles.push(abs);
187
+ else if (a.kind === 'review') report.reviewFiles.push(abs);
188
+ } catch { /* best-effort */ }
189
+ }
190
+
191
+ // 2) Local branch(es) + worktree(s) (remote untouched). Best-effort. Reads the
192
+ // reconstructed state (branch / branches / projects from the row's JSON columns).
193
+ if (workspaceKey || state?.target === 'workspace') {
194
+ // Per-project: iterate state.branches keyed by projectKey, cleaning each
195
+ // member's worktree+branch in its OWN repo (state.projects[].projectDir).
196
+ const branches = state?.branches && typeof state.branches === 'object' ? state.branches : {};
197
+ const projects = Array.isArray(state?.projects) ? state.projects : [];
198
+ const dirByKey = new Map(projects.map((p) => [p.projectKey, p.projectDir]));
199
+ for (const [pk, br] of Object.entries(branches)) {
200
+ const repoDir = dirByKey.get(pk) || null;
201
+ const feature = br?.feature || null;
202
+ const wt = br?.worktreeDir || null;
203
+ if (!repoDir || (!feature && !wt)) continue;
204
+ const liveWt = wt && existsSync(wt) ? wt : null;
205
+ const liveBranch = feature && (await branchExists(repoDir, feature)) ? feature : null;
206
+ if (!liveWt && !liveBranch) continue;
207
+ const res = await removeWorktree({ projectDir: repoDir, worktreeDir: liveWt, branch: liveBranch, force: true });
208
+ for (const stp of res.steps.filter((x) => !x.ok)) {
209
+ report.warnings.push(`${pk}: ${stp.step}: ${stp.stderr || 'failed'}`);
210
+ }
211
+ }
212
+ } else {
213
+ const repoDir = state?.projectDir || projectDir || null;
214
+ const feature = state?.branch?.feature || null;
215
+ const wt = state?.branch?.worktreeDir || null;
216
+ if (repoDir && (feature || wt)) {
217
+ const liveWt = wt && existsSync(wt) ? wt : null; // skip already-removed worktrees
218
+ const liveBranch = feature && (await branchExists(repoDir, feature)) ? feature : null; // skip merged/deleted
219
+ if (liveWt || liveBranch) {
220
+ const res = await removeWorktree({ projectDir: repoDir, worktreeDir: liveWt, branch: liveBranch, force: true });
221
+ report.branch = liveBranch;
222
+ report.worktree = liveWt;
223
+ for (const stp of res.steps.filter((x) => !x.ok)) {
224
+ report.warnings.push(`${stp.step}: ${stp.stderr || 'failed'}`);
225
+ }
226
+ }
227
+ }
228
+ }
229
+
230
+ // 2b) The detached run root, under the same §8.13 assertions rmGuarded enforces
231
+ // everywhere (`<worcaHome>/runs/` prefix + basename === pipelineId). Without
232
+ // this, archiving a paused or interrupted detached run from the UI would leave
233
+ // the generated CLAUDE.md, mcp.json, run.json, the workspace skill mount and
234
+ // the emptied repos/ shell on disk permanently. Archiving KEEPS the pipelines
235
+ // row, so the boot sweep can still resolve this id — but the run root is gone
236
+ // and unreclaimable-by-id later, so it must be removed here, not deferred.
237
+ // A legacy run simply has no such dir, so this is a no-op there.
238
+ if (existsSync(runRoot)) {
239
+ const res = await rmGuarded(runRoot, { worcaHome: worcaHome(), pipelineId: row.id });
240
+ if (res.removed) report.runRoot = runRoot;
241
+ else report.warnings.push(`run-root: ${res.reason || 'removal refused'}`);
242
+ }
243
+
244
+ // 3) The run folder itself (prompt.md, pipeline.md header, extras/, any
245
+ // pipeline-local md). Everything else lives inside it.
246
+ if (runDir) await rm(runDir, { recursive: true, force: true });
247
+
248
+ // 4) Soft delete: the run's statistical record is permanent (spec §6.7). The
249
+ // FS was reclaimed above; the row now only carries history. The artifacts
250
+ // INDEX rows are deleted explicitly — their files were just unlinked, and
251
+ // the CASCADE that used to clear them no longer fires (no row DELETE).
252
+ // pipeline_steps and the other children stay: stats fallback sums need them.
253
+ tx(() => {
254
+ getDb().prepare('DELETE FROM artifacts WHERE pipeline_id = ?').run(row.id);
255
+ getDb().prepare('UPDATE pipelines SET archived_at = ? WHERE id = ?')
256
+ .run(new Date().toISOString(), row.id);
257
+ });
258
+
259
+ return report;
260
+ }
261
+
262
+ /**
263
+ * Explicitly reclaim worktrees retained after a teardown commit failure while
264
+ * preserving the pipeline row and artifact directory. Every live checkout is
265
+ * snapshotted first; any snapshot failure aborts before removal.
266
+ */
267
+ export async function discardRetainedWorktrees({ projectDir = null, key = null, workspaceKey = null, id } = {}) {
268
+ if (!id || typeof id !== 'string') throw err('id is required', 'BAD_REQUEST');
269
+ const storeKey = workspaceKey
270
+ ? `workspaces/${workspaceKey}`
271
+ : (key || (projectDir ? projectKey(projectDir) : null));
272
+ if (!storeKey) throw err('projectKey, projectDir or workspaceKey is required', 'BAD_REQUEST');
273
+
274
+ const row = lookupRow(storeKey, id);
275
+ if (!row) return null;
276
+ if (ACTIVE.has(String(row.status || '').toLowerCase())) {
277
+ throw err('cannot discard a running pipeline worktree', 'RUNNING');
278
+ }
279
+ const runRoot = join(worcaHome(), 'runs', row.id);
280
+ let retained = retainedWorkFor(row);
281
+ if (!retained && existsSync(runRoot)) {
282
+ // The DB stamp is teardown's LAST best-effort write and can be lost (F2's
283
+ // crash window). The run.json retain ledger is written earlier — honor it so
284
+ // manifest-only retention is still discardable instead of a permanent wedge
285
+ // (archive refuses it; this is the only exit). existsSync keeps it
286
+ // self-clearing, same contract as retainedWorkFor.
287
+ const manifest = await readRunManifest(runRoot);
288
+ const live = (Array.isArray(manifest?.retain?.members) ? manifest.retain.members : [])
289
+ .filter((m) => m?.worktreeDir && existsSync(m.worktreeDir));
290
+ if (live.length) retained = { reason: manifest.retain.reason || 'unknown', members: live };
291
+ }
292
+ if (!retained) {
293
+ return { ok: true, id: row.id, discarded: false, remaining: 0, worktrees: [], patches: [], runRoot: null, warnings: [] };
294
+ }
295
+
296
+ const { state } = (await readPipelineByKey(storeKey, row.id)) || { state: null };
297
+ if (!state) throw err('pipeline state is unavailable', 'BAD_REQUEST');
298
+ const storeRootDir = projectStorePath(storeKey);
299
+ const runDir = await findRunDir(join(storeRootDir, 'pipelines'), row.id);
300
+ if (!runDir) {
301
+ throw err('cannot discard retained work: the pipeline directory needed for the recovery patch is missing', 'SNAPSHOT_FAILED');
302
+ }
303
+
304
+ const projects = Array.isArray(state.projects) ? state.projects : [];
305
+ const dirByKey = new Map(projects.map((p) => [p.projectKey, p.projectDir]));
306
+ const targets = retained.members.map((member) => ({
307
+ ...member,
308
+ projectDir: state.target === 'workspace'
309
+ ? (dirByKey.get(member.projectKey) || null)
310
+ : (state.projectDir || projectDir || null),
311
+ }));
312
+ if (targets.some((t) => !t.projectDir)) {
313
+ throw err('cannot discard retained work: a member repository could not be resolved', 'SNAPSHOT_FAILED');
314
+ }
315
+
316
+ // Snapshot ALL members before deleting ANY member, straight to their final
317
+ // files. This prevents a later snapshot failure from leaving a half-discarded
318
+ // workspace, without ever holding a whole patch in memory.
319
+ await mkdir(runDir, { recursive: true });
320
+ const patches = [];
321
+ for (const target of targets) {
322
+ const name = retainedWorkPatchName(state.target === 'workspace' ? (target.projectKey || 'member') : null);
323
+ const snap = await snapshotWorktreePatch(target.worktreeDir, join(runDir, name));
324
+ if (!snap.ok) {
325
+ throw err(
326
+ `cannot save recovery patch for ${target.projectKey || target.worktreeDir}: git ${snap.step} failed: ${snap.message}`,
327
+ 'SNAPSHOT_FAILED',
328
+ );
329
+ }
330
+ if (snap.file) { // a clean tree yields no patch — nothing to record
331
+ recordArtifact(row.id, 'retained-work-patch', name);
332
+ patches.push(snap.file);
333
+ }
334
+ }
335
+
336
+ const report = {
337
+ ok: true, id: row.id, discarded: false, remaining: 0, worktrees: [], patches,
338
+ runRoot: null, warnings: [],
339
+ };
340
+ if (existsSync(runRoot)) {
341
+ const manifest = await readRunManifest(runRoot);
342
+ for (const [scope, entries] of Object.entries(manifest?.injectedPaths || {})) {
343
+ const baseDir = scope === 'runRoot'
344
+ ? runRoot
345
+ : targets.find((t) => t.projectKey === scope)?.worktreeDir || join(runRoot, 'repos', scope);
346
+ const warnings = await rescueModifiedMounts({
347
+ baseDir, entries, pipelineDir: runDir, scope, pipelineId: row.id,
348
+ });
349
+ report.warnings.push(...warnings);
350
+ }
351
+ report.warnings.push(...await scanStrayEntries({ runRoot, pipelineDir: runDir }));
352
+ await copyRunManifestTo(runRoot, runDir);
353
+ }
354
+
355
+ const clearedKeys = [];
356
+ for (const target of targets) {
357
+ const result = await removeWorktree({
358
+ projectDir: target.projectDir, worktreeDir: target.worktreeDir, branch: null, force: true,
359
+ });
360
+ for (const step of result.steps.filter((s) => !s.ok)) {
361
+ report.warnings.push(`${target.projectKey || 'project'}: ${step.step}: ${step.stderr || 'failed'}`);
362
+ }
363
+ if (!existsSync(target.worktreeDir)) {
364
+ report.worktrees.push(target.worktreeDir);
365
+ clearedKeys.push(target.projectKey ?? null);
366
+ } else {
367
+ report.remaining += 1;
368
+ report.warnings.push(`${target.projectKey || 'project'}: worktree still exists at ${target.worktreeDir}`);
369
+ }
370
+ }
371
+ report.discarded = report.remaining === 0;
372
+
373
+ // Targeted update: clear ONLY the retention stamps. A full writeState would
374
+ // restamp updated_at (the stats terminal-write proxy), NULL resume_point, and
375
+ // rewrite pipeline_steps — none of which a checkout reclaim may touch.
376
+ if (clearedKeys.length) {
377
+ const parse = (t) => { try { return JSON.parse(t); } catch { return undefined; } };
378
+ const clear = (br) => { delete br.commitFailed; br.worktreeRemoved = true; br.branchKept = true; };
379
+ tx(() => {
380
+ const fresh = getDb().prepare('SELECT branch, workspace_meta FROM pipelines WHERE id = ?').get(row.id);
381
+ if (state.target === 'workspace') {
382
+ const wm = typeof fresh?.workspace_meta === 'string' ? parse(fresh.workspace_meta) : fresh?.workspace_meta;
383
+ // NEVER write a rebuilt {} here: workspace_meta carries the whole §5.2
384
+ // superset (projects/projectKeys/checkpointRefs/runRootMode). Unreadable
385
+ // or branch-less meta is reported, not overwritten.
386
+ if (!wm || typeof wm !== 'object' || !wm.branches || typeof wm.branches !== 'object') {
387
+ // A NULL column is the manifest-only case (Task 11) — nothing to clear,
388
+ // nothing to warn about. Warn only when a non-null column is corrupt.
389
+ if (fresh?.workspace_meta != null) {
390
+ report.warnings.push('retention stamp not cleared: workspace metadata is unreadable');
391
+ }
392
+ return;
393
+ }
394
+ let touched = false;
395
+ for (const k of clearedKeys) { const br = wm.branches[k]; if (br) { clear(br); touched = true; } }
396
+ if (touched) {
397
+ getDb().prepare('UPDATE pipelines SET workspace_meta = ? WHERE id = ?')
398
+ .run(JSON.stringify(wm), row.id);
399
+ }
400
+ } else {
401
+ const br = typeof fresh?.branch === 'string' ? parse(fresh.branch) : fresh?.branch;
402
+ if (!br || typeof br !== 'object') {
403
+ if (fresh?.branch != null) { // NULL column = manifest-only discard: silent skip
404
+ report.warnings.push('retention stamp not cleared: branch metadata is unreadable');
405
+ }
406
+ return;
407
+ }
408
+ clear(br);
409
+ getDb().prepare('UPDATE pipelines SET branch = ? WHERE id = ?').run(JSON.stringify(br), row.id);
410
+ }
411
+ });
412
+ }
413
+ if (existsSync(runRoot) && report.remaining === 0) {
414
+ const removal = await rmGuarded(runRoot, { worcaHome: worcaHome(), pipelineId: row.id });
415
+ if (removal.removed) report.runRoot = runRoot;
416
+ else report.warnings.push(`run-root: ${removal.reason || 'removal refused'}`);
417
+ }
418
+ await appendAudit(runDir,
419
+ `Discarded ${report.worktrees.length} retained worktree(s) after saving recovery patch(es): ` +
420
+ patches.map((p) => `\`${p}\``).join(', ')).catch(() => {});
421
+ return report;
422
+ }
423
+
424
+ // Compat alias. The server now calls `archivePipeline` directly; this name is
425
+ // kept for the pre-existing suites (test/pipeline-delete.test.mjs,
426
+ // test/persist-roundtrip.test.mjs) that assert the FS/branch reclamation half
427
+ // under the old name — which archive still performs in full.
428
+ export { archivePipeline as deletePipeline };
@@ -0,0 +1,13 @@
1
+ // src/core/plugin-api.mjs
2
+ // Host plugin API versions (plugin spec §10). Integers, bumped on breaking
3
+ // change ONLY. WORCA_PLUGIN_API is the current/max API; WORCA_PLUGIN_APIS lists
4
+ // every API this host still satisfies, so old manifests (e.g. ">=1 <2") keep
5
+ // installing after a bump. Checked against manifests' engines.worca-cc-api at
6
+ // install AND at load (plugin-manifest.mjs apiSatisfies). Kept in its own
7
+ // dependency-free module so the shim child (Task 11) can import it without the
8
+ // core graph.
9
+ //
10
+ // API 2 adds the chatChannels contribution + persistent channel worker
11
+ // protocol; the task-source connector contract is unchanged between 1 and 2.
12
+ export const WORCA_PLUGIN_API = 2;
13
+ export const WORCA_PLUGIN_APIS = [1, 2];
@@ -0,0 +1,100 @@
1
+ // src/core/plugin-config.mjs
2
+ // Per-plugin settings/secrets/state under <pluginDir>/data (spec §5, §7.6).
3
+ // Secrets: data/secrets.json, mode 0600, atomic temp+rename (settings.mjs:89-92
4
+ // idiom), {"$env":"VAR"} indirection resolved at READ time only — stored
5
+ // verbatim so the value never touches disk. Explicitly NOT in worca-cc.db.
6
+ // All functions are sync (contract; callers are the shim + server routes).
7
+
8
+ import { readFileSync, writeFileSync, renameSync, mkdirSync, chmodSync } from 'node:fs';
9
+ import { join, dirname } from 'node:path';
10
+ import { randomBytes } from 'node:crypto';
11
+ import { pluginDataDir } from './plugins-lock.mjs';
12
+
13
+ function readJson(file) {
14
+ try {
15
+ const v = JSON.parse(readFileSync(file, 'utf8'));
16
+ return v && typeof v === 'object' && !Array.isArray(v) ? v : {};
17
+ } catch {
18
+ return {};
19
+ }
20
+ }
21
+
22
+ function writeJsonAtomic(file, obj, { mode } = {}) {
23
+ mkdirSync(dirname(file), { recursive: true });
24
+ const tmp = `${file}.${randomBytes(4).toString('hex')}.tmp`;
25
+ writeFileSync(tmp, JSON.stringify(obj, null, 2) + '\n', mode !== undefined ? { mode } : { encoding: 'utf8' });
26
+ if (mode !== undefined) chmodSync(tmp, mode); // umask-proof: mode is exact
27
+ renameSync(tmp, file);
28
+ }
29
+
30
+ const isEnvRef = (v) => !!v && typeof v === 'object' && !Array.isArray(v) && typeof v.$env === 'string';
31
+ /** The exact redaction marker redactedConfig emits — must never be persisted. */
32
+ const isSetMarker = (v) => !!v && typeof v === 'object' && v.set === true && Object.keys(v).length === 1;
33
+
34
+ function files(name) {
35
+ const dir = pluginDataDir(name);
36
+ return { config: join(dir, 'config.json'), secrets: join(dir, 'secrets.json'), state: join(dir, 'state.json') };
37
+ }
38
+
39
+ /** Merged config.json + secrets.json (secrets win), schema defaults applied,
40
+ * {"$env":"VAR"} resolved (unset env -> null). */
41
+ export function readPluginConfig(name, configSchema = []) {
42
+ const f = files(name);
43
+ const raw = { ...readJson(f.config), ...readJson(f.secrets) };
44
+ const out = {};
45
+ for (const field of configSchema) {
46
+ let v = raw[field.key];
47
+ if (v === undefined || v === null || v === '') v = field.default ?? null;
48
+ if (isEnvRef(v)) v = process.env[v.$env] ?? null;
49
+ out[field.key] = v;
50
+ }
51
+ return out;
52
+ }
53
+
54
+ /** Route values by schema: secret:true -> secrets.json (0600), else config.json.
55
+ * undefined / redaction-marker values keep the prior stored value; null clears.
56
+ * $env refs are stored verbatim. Both writes are temp+rename atomic. */
57
+ export function writePluginConfig(name, configSchema = [], values = {}) {
58
+ const f = files(name);
59
+ const config = readJson(f.config);
60
+ const secrets = readJson(f.secrets);
61
+ const secretKeys = new Set(configSchema.filter((x) => x && x.secret).map((x) => x.key));
62
+ for (const [k, v] of Object.entries(values && typeof values === 'object' ? values : {})) {
63
+ if (v === undefined || isSetMarker(v)) continue; // absent / echoed marker -> keep prior
64
+ const bucket = secretKeys.has(k) ? secrets : config;
65
+ const other = secretKeys.has(k) ? config : secrets;
66
+ delete other[k]; // field migrated buckets across schema versions
67
+ if (v === null) delete bucket[k];
68
+ else bucket[k] = v;
69
+ }
70
+ writeJsonAtomic(f.config, config);
71
+ writeJsonAtomic(f.secrets, secrets, { mode: 0o600 });
72
+ return { ok: true };
73
+ }
74
+
75
+ /** UI echo shape: secrets -> { set: true|false } markers, non-secrets verbatim
76
+ * (with defaults). Secret VALUES never reach the browser after save (§7.6). */
77
+ export function redactedConfig(name, configSchema = []) {
78
+ const f = files(name);
79
+ const config = readJson(f.config);
80
+ const secrets = readJson(f.secrets);
81
+ const out = {};
82
+ for (const field of configSchema) {
83
+ if (field.secret) out[field.key] = { set: secrets[field.key] !== undefined };
84
+ else out[field.key] = config[field.key] ?? field.default ?? null;
85
+ }
86
+ return out;
87
+ }
88
+
89
+ /** Connector KV (cursors, etags) — host-persisted ctx.state backing (§7.1). */
90
+ export function readPluginState(name) {
91
+ return readJson(files(name).state);
92
+ }
93
+
94
+ /** Shallow-merge patch into state.json, atomic write. Returns the new state. */
95
+ export function writePluginState(name, patch = {}) {
96
+ const f = files(name);
97
+ const next = { ...readJson(f.state), ...(patch && typeof patch === 'object' ? patch : {}) };
98
+ writeJsonAtomic(f.state, next);
99
+ return next;
100
+ }
@@ -0,0 +1,50 @@
1
+ // src/core/plugin-inventory.mjs
2
+ // Consent inventory for a NOT-YET-INSTALLED plugin (spec §4.8): git-archive the
3
+ // pinned SHA from the bare cache into a throwaway temp dir, buildInstallInventory
4
+ // it, delete it. Nothing lands under ~/.worca-cc/plugins and no plugin code runs.
5
+ // Shared by the marketplace snapshot sync (marketplaces.mjs) and the web server.
6
+
7
+ import { execFile } from 'node:child_process';
8
+ import { promisify } from 'node:util';
9
+ import { mkdtemp, rm } from 'node:fs/promises';
10
+ import { rmSync } from 'node:fs';
11
+ import { tmpdir } from 'node:os';
12
+ import { join } from 'node:path';
13
+ import { repoCacheDir } from './plugin-repo.mjs';
14
+ import { buildInstallInventory } from './plugin-store.mjs';
15
+ import { findEscapingSymlinks } from './plugin-manifest.mjs';
16
+
17
+ const execFileP = promisify(execFile);
18
+ const defaultExec = (cmd, args, opts = {}) =>
19
+ execFileP(cmd, args, {
20
+ maxBuffer: 16 * 1024 * 1024, timeout: 120_000, killSignal: 'SIGKILL',
21
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, ...opts,
22
+ });
23
+
24
+ export async function inventoryFromCache(repoUrl, sha, subdir, { exec = defaultExec } = {}) {
25
+ const tmp = await mkdtemp(join(tmpdir(), 'worca-cc-consent-'));
26
+ try {
27
+ const tar = join(tmp, 'x.tar');
28
+ // `--` before the subdir positional so a dir like `-x`/`--output=…` can never
29
+ // be parsed as a git option (defense layer 2; the parser rejects it too).
30
+ await exec('git', ['--git-dir', repoCacheDir(repoUrl), 'archive', '--format=tar', '-o', tar,
31
+ ...(subdir ? [sha, '--', subdir] : [sha])]);
32
+ await exec('tar', ['-xf', tar, '-C', tmp,
33
+ ...(subdir ? ['--strip-components', String(subdir.split('/').length)] : [])]);
34
+ await rm(tar, { force: true });
35
+ // Defense-in-depth for the consent display: strip any symlink that escapes the
36
+ // export root before inventorying (the tmp dir is deleted in finally regardless).
37
+ for (const rel of findEscapingSymlinks(tmp)) rmSync(join(tmp, rel), { force: true });
38
+ const inv = buildInstallInventory(tmp);
39
+ // buildInstallInventory bakes the throwaway export dir into setup commands
40
+ // (e.g. `npm ci --prefix <tmp>`); this inventory is PERSISTED in
41
+ // marketplaces.json and shown in the consent modal long after `tmp` is gone,
42
+ // so replace the tmp path with a stable placeholder.
43
+ if (Array.isArray(inv.setupCommands)) {
44
+ inv.setupCommands = inv.setupCommands.map((c) => String(c).split(tmp).join('<plugin-dir>'));
45
+ }
46
+ return inv;
47
+ } finally {
48
+ await rm(tmp, { recursive: true, force: true });
49
+ }
50
+ }