@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,708 @@
|
|
|
1
|
+
// src/core/worktree.mjs
|
|
2
|
+
// Per-pipeline git worktree isolation. Every helper is async, returns plain data,
|
|
3
|
+
// and never throws on missing files / non-zero git exits unless explicitly noted
|
|
4
|
+
// (createWorktree throws on a fatal git failure because the run cannot continue).
|
|
5
|
+
//
|
|
6
|
+
// Layout (legacy, the retained default):
|
|
7
|
+
// <projectDir>/.worca-cc/worktrees/<pipelineId>/ <- checkout
|
|
8
|
+
// Layout (detached, caller passes baseDir + checkoutName):
|
|
9
|
+
// <worcaHome>/runs/<pipelineId>/repos/<projectKey>/
|
|
10
|
+
// Either way the shared .git store stays in <projectDir>/.git <- no duplication.
|
|
11
|
+
//
|
|
12
|
+
// Branch naming: callers pass a fully-formed feature branch (e.g. "worca-cc/foo-abc12345")
|
|
13
|
+
// OR the orchestrator derives one via suggestBranchName(). sanitizeBranchName is the
|
|
14
|
+
// single source of truth for what reaches `git branch`.
|
|
15
|
+
|
|
16
|
+
import { spawn } from 'node:child_process';
|
|
17
|
+
import { existsSync } from 'node:fs';
|
|
18
|
+
import { mkdir, rm, realpath, readdir, rename, stat } from 'node:fs/promises';
|
|
19
|
+
import { basename, join, resolve, sep } from 'node:path';
|
|
20
|
+
|
|
21
|
+
import { slugify } from './artifacts.mjs';
|
|
22
|
+
// settings.mjs is a leaf (node builtins only), so importing the §10 flag reader here
|
|
23
|
+
// adds no dependency on the DB layer — worktree.mjs stays DB-free.
|
|
24
|
+
import { runRootMode } from './settings.mjs';
|
|
25
|
+
import {
|
|
26
|
+
readRunManifest, rmGuarded, rescueModifiedMounts, scanStrayEntries, copyRunManifestTo,
|
|
27
|
+
} from './run-manifest.mjs';
|
|
28
|
+
|
|
29
|
+
const WORKTREES_DIRNAME = join('.worca-cc', 'worktrees');
|
|
30
|
+
const BRANCH_PREFIX = 'worca-cc/';
|
|
31
|
+
const MAX_BRANCH_LEN = 80;
|
|
32
|
+
// Keep branch names short and readable: at most this many significant words
|
|
33
|
+
// survive into the slug (the shortId is appended after).
|
|
34
|
+
const MAX_BRANCH_WORDS = 6;
|
|
35
|
+
// Generic filler dropped from a prompt-derived slug so the name carries the
|
|
36
|
+
// meaningful nouns, not boilerplate. Articles, prepositions, conjunctions,
|
|
37
|
+
// pronouns, and the imperative scaffolding that prefixes most task prompts
|
|
38
|
+
// ("build a…", "let's create…"). Deliberately conservative — domain verbs like
|
|
39
|
+
// add/fix/refactor are kept because they carry intent.
|
|
40
|
+
const BRANCH_STOPWORDS = new Set([
|
|
41
|
+
'a', 'an', 'the', 'to', 'of', 'for', 'and', 'or', 'in', 'on', 'into', 'with',
|
|
42
|
+
'at', 'by', 'from', 'we', 'i', 'you', 'it', 'that', 'this', 'these', 'those',
|
|
43
|
+
'lets', 'let', 'please', 'just', 'also', 'should', 'would', 'could', 'can',
|
|
44
|
+
'will', 'want', 'wants', 'need', 'needs', 'make', 'build', 'create', 'some',
|
|
45
|
+
'our', 'my',
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
// Checkout-heavy ops (worktree add/remove) legitimately exceed the default
|
|
49
|
+
// 30 s on large repos — give them a longer leash before the SIGKILL deadline.
|
|
50
|
+
const SLOW_GIT_TIMEOUT_MS = 120_000;
|
|
51
|
+
|
|
52
|
+
/** Run git and resolve to { ok, stdout, stderr, code }. Never throws. */
|
|
53
|
+
function git(cwd, args, { signal, timeout = 30_000 } = {}) {
|
|
54
|
+
return new Promise((res) => {
|
|
55
|
+
let child;
|
|
56
|
+
try {
|
|
57
|
+
child = spawn('git', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'], signal });
|
|
58
|
+
} catch (err) {
|
|
59
|
+
res({ ok: false, stdout: '', stderr: err.message, code: -1 });
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
let stdout = '', stderr = '';
|
|
63
|
+
let settled = false;
|
|
64
|
+
const done = (val) => {
|
|
65
|
+
if (settled) return;
|
|
66
|
+
settled = true;
|
|
67
|
+
clearTimeout(timer);
|
|
68
|
+
res(val);
|
|
69
|
+
};
|
|
70
|
+
const timer = timeout > 0
|
|
71
|
+
? setTimeout(() => {
|
|
72
|
+
try { child.kill('SIGKILL'); } catch {}
|
|
73
|
+
done({ ok: false, stdout, stderr: stderr ? `git timed out: ${stderr}` : 'git timed out', code: -1 });
|
|
74
|
+
}, timeout)
|
|
75
|
+
: null;
|
|
76
|
+
child.stdout?.on('data', (b) => (stdout += b.toString()));
|
|
77
|
+
child.stderr?.on('data', (b) => (stderr += b.toString()));
|
|
78
|
+
child.on('error', (err) => done({ ok: false, stdout, stderr: stderr || err.message, code: -1 }));
|
|
79
|
+
child.on('close', (code) => done({ ok: code === 0, stdout, stderr, code: code ?? -1 }));
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Reduce arbitrary text to a git-safe branch name fragment.
|
|
85
|
+
*/
|
|
86
|
+
export function sanitizeBranchName(raw) {
|
|
87
|
+
const s = String(raw ?? '')
|
|
88
|
+
.toLowerCase()
|
|
89
|
+
.replace(/[^a-z0-9/_-]+/g, '-')
|
|
90
|
+
.replace(/-{2,}/g, '-')
|
|
91
|
+
.replace(/^[-/_.]+|[-/_.]+$/g, '');
|
|
92
|
+
return s.slice(0, MAX_BRANCH_LEN);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Reduce free text to a short, meaningful kebab slug: slugify, drop generic
|
|
97
|
+
* stopwords, keep the first MAX_BRANCH_WORDS significant words. If stopword
|
|
98
|
+
* removal would empty the slug (every word was filler), keep the raw words so
|
|
99
|
+
* we never return nothing. Returns '' only for empty/punctuation-only input.
|
|
100
|
+
*/
|
|
101
|
+
function keywordSlug(text) {
|
|
102
|
+
const line = firstLine(text);
|
|
103
|
+
if (!line) return ''; // slugify('') yields 'untitled'; let the caller pick 'feature'
|
|
104
|
+
const words = slugify(line).split('-').filter(Boolean);
|
|
105
|
+
if (!words.length) return '';
|
|
106
|
+
const kept = words.filter((w) => !BRANCH_STOPWORDS.has(w));
|
|
107
|
+
return (kept.length ? kept : words).slice(0, MAX_BRANCH_WORDS).join('-');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Propose a feature branch name WITHOUT an LLM (so preflight stays free and
|
|
112
|
+
* fully deterministic). Title-first: a caller-supplied title is the clearest
|
|
113
|
+
* intent, so slugify it directly; otherwise derive a keyword slug from the
|
|
114
|
+
* prompt's first line. Always returns a sanitized "<prefix><slug>-<shortId>".
|
|
115
|
+
*/
|
|
116
|
+
export function suggestBranchName({ prompt, title, pipelineId } = {}) {
|
|
117
|
+
const shortId = String(pipelineId || '').slice(0, 8) || 'run';
|
|
118
|
+
// A title is explicit intent — slugify it verbatim (only word-cap for length),
|
|
119
|
+
// never strip stopwords (would mangle deliberate names like "A/B Testing").
|
|
120
|
+
const fromTitle = title
|
|
121
|
+
? slugify(title).split('-').filter(Boolean).slice(0, MAX_BRANCH_WORDS).join('-')
|
|
122
|
+
: '';
|
|
123
|
+
const core = fromTitle || keywordSlug(prompt) || 'feature';
|
|
124
|
+
return sanitizeBranchName(`${BRANCH_PREFIX}${core}-${shortId}`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** All local branch names (no remotes). Empty array on a non-repo. */
|
|
128
|
+
export async function listLocalBranches(projectDir) {
|
|
129
|
+
const r = await git(projectDir, ['for-each-ref', '--format=%(refname:short)', 'refs/heads/']);
|
|
130
|
+
if (!r.ok) return [];
|
|
131
|
+
return r.stdout.split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The branch HEAD currently points to in `projectDir`, or null. */
|
|
135
|
+
export async function currentBranch(projectDir) {
|
|
136
|
+
const r = await git(projectDir, ['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
137
|
+
if (!r.ok) return null;
|
|
138
|
+
const name = r.stdout.trim();
|
|
139
|
+
return name && name !== 'HEAD' ? name : null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Best-effort default-branch resolution. HEAD branch, then init.defaultBranch,
|
|
144
|
+
* then origin/HEAD, then first local branch. On a detached HEAD with no local
|
|
145
|
+
* branches, fall back to the HEAD SHA (always a valid commit-ish for
|
|
146
|
+
* `worktree add`) and only then to the literal 'main' (m1).
|
|
147
|
+
*/
|
|
148
|
+
export async function resolveDefaultBranch(projectDir) {
|
|
149
|
+
const head = await currentBranch(projectDir);
|
|
150
|
+
if (head) return head;
|
|
151
|
+
const cfg = await git(projectDir, ['config', '--get', 'init.defaultBranch']);
|
|
152
|
+
if (cfg.ok && cfg.stdout.trim()) return cfg.stdout.trim();
|
|
153
|
+
const originHead = await git(projectDir, ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD']);
|
|
154
|
+
if (originHead.ok && originHead.stdout.trim()) {
|
|
155
|
+
return originHead.stdout.trim().replace(/^origin\//, '');
|
|
156
|
+
}
|
|
157
|
+
const branches = await listLocalBranches(projectDir);
|
|
158
|
+
if (branches.length) return branches.sort()[0];
|
|
159
|
+
// Detached HEAD, no branches: a raw SHA is a valid source for `worktree add`.
|
|
160
|
+
const sha = await git(projectDir, ['rev-parse', 'HEAD']);
|
|
161
|
+
if (sha.ok && sha.stdout.trim()) return sha.stdout.trim();
|
|
162
|
+
return 'main';
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* True iff `ref` resolves to a commit in `projectDir`. Rejects any value
|
|
167
|
+
* beginning with '-' (would be parsed as a git option). The single source of
|
|
168
|
+
* truth for "is this a usable worktree source" — used both by createWorktree
|
|
169
|
+
* (throws) and the API (clean 400) so an injected `--force`/`-q`/unknown ref
|
|
170
|
+
* never reaches `git worktree add`. (M1)
|
|
171
|
+
*/
|
|
172
|
+
export async function isValidSourceRef(projectDir, ref) {
|
|
173
|
+
if (typeof ref !== 'string' || !ref || /^-/.test(ref)) return false;
|
|
174
|
+
const r = await git(projectDir, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]);
|
|
175
|
+
return r.ok && !!r.stdout.trim();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Parse `git worktree list --porcelain` and return the path of the worktree
|
|
180
|
+
* that currently has `branch` checked out, or null. Used to detect the
|
|
181
|
+
* "branch already in use" conflict before a reuse `worktree add` (M2).
|
|
182
|
+
*/
|
|
183
|
+
export async function worktreePathForBranch(projectDir, branch) {
|
|
184
|
+
const r = await git(projectDir, ['worktree', 'list', '--porcelain']);
|
|
185
|
+
if (!r.ok) return null;
|
|
186
|
+
let curPath = null;
|
|
187
|
+
for (const line of r.stdout.split(/\r?\n/)) {
|
|
188
|
+
if (line.startsWith('worktree ')) curPath = line.slice('worktree '.length).trim();
|
|
189
|
+
else if (line.startsWith('branch ')) {
|
|
190
|
+
const ref = line.slice('branch '.length).trim().replace(/^refs\/heads\//, '');
|
|
191
|
+
if (ref === branch) return curPath;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Create a worktree checking out a new branch <featureBranch> off <sourceBranch>.
|
|
199
|
+
* When the branch already exists locally we attach to it instead of forking (resume
|
|
200
|
+
* semantics) and set reusedExisting=true.
|
|
201
|
+
*
|
|
202
|
+
* Placement: `<baseDir>/<checkoutName>` when both are supplied (the detached run
|
|
203
|
+
* root: `<worcaHome>/runs/<pipelineId>/repos/<projectKey>`), else the retained
|
|
204
|
+
* legacy default `<projectDir>/.worca-cc/worktrees/<pipelineId>` (§10 rollback).
|
|
205
|
+
* Git mechanics are safe out of tree: the shared object store stays in
|
|
206
|
+
* `<projectDir>/.git` and the worktree's gitdir pointers are absolute.
|
|
207
|
+
*
|
|
208
|
+
* @param {object} args
|
|
209
|
+
* @param {string} [args.baseDir] parent dir for the checkout (default: legacy base)
|
|
210
|
+
* @param {string} [args.checkoutName] checkout dir name (default: pipelineId)
|
|
211
|
+
*/
|
|
212
|
+
export async function createWorktree({
|
|
213
|
+
projectDir, pipelineId, sourceBranch, featureBranch, signal, baseDir, checkoutName,
|
|
214
|
+
}) {
|
|
215
|
+
if (!projectDir) throw new Error('projectDir required');
|
|
216
|
+
if (!pipelineId) throw new Error('pipelineId required');
|
|
217
|
+
if (!sourceBranch) throw new Error('sourceBranch required');
|
|
218
|
+
// S2: pipelineId becomes a path segment and is later passed to a recursive
|
|
219
|
+
// remove — reject anything that could escape the worktrees base.
|
|
220
|
+
if (!/^[A-Za-z0-9._-]+$/.test(pipelineId) || pipelineId === '.' || pipelineId === '..') {
|
|
221
|
+
throw new Error(`invalid pipelineId: ${JSON.stringify(pipelineId)}`);
|
|
222
|
+
}
|
|
223
|
+
const branch = sanitizeBranchName(featureBranch);
|
|
224
|
+
if (!branch) throw new Error('featureBranch resolves to empty after sanitize');
|
|
225
|
+
// Compare sanitized forms so case/format variants of the same name don't slip past.
|
|
226
|
+
if (branch === sanitizeBranchName(sourceBranch)) {
|
|
227
|
+
throw new Error(`featureBranch and sourceBranch both resolve to "${branch}" — they must differ`);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const base = baseDir
|
|
231
|
+
? resolve(baseDir)
|
|
232
|
+
: join(resolve(projectDir), WORKTREES_DIRNAME); // legacy path retained for rollback
|
|
233
|
+
await mkdir(base, { recursive: true });
|
|
234
|
+
// Canonicalize the base so worktreeDir matches what `git worktree list`
|
|
235
|
+
// reports (git emits realpaths). Without this the M2 self-equality check below
|
|
236
|
+
// mis-fires on symlinked roots (e.g. macOS /tmp -> /private/tmp).
|
|
237
|
+
const baseReal = await realpath(base).catch(() => resolve(base));
|
|
238
|
+
const name = checkoutName || pipelineId;
|
|
239
|
+
if (!/^[A-Za-z0-9._-]+$/.test(name) || name === '.' || name === '..') {
|
|
240
|
+
throw new Error(`invalid checkout name: ${JSON.stringify(name)}`);
|
|
241
|
+
}
|
|
242
|
+
const worktreeDir = join(baseReal, name);
|
|
243
|
+
// Belt-and-braces: the sanitized name can't traverse, but assert containment
|
|
244
|
+
// so a future caller can't turn this into a delete-anything primitive.
|
|
245
|
+
if (!worktreeDir.startsWith(baseReal + sep)) {
|
|
246
|
+
throw new Error(`worktree path escapes base: ${worktreeDir}`);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const branches = await listLocalBranches(projectDir);
|
|
250
|
+
const reusedExisting = branches.includes(branch);
|
|
251
|
+
|
|
252
|
+
let args;
|
|
253
|
+
if (reusedExisting) {
|
|
254
|
+
// M2: git forbids checking out one branch in two worktrees at once. Reap
|
|
255
|
+
// stale registrations first; if a *live* worktree still holds it, fail with
|
|
256
|
+
// an actionable message rather than a raw exit-128 mid-pipeline.
|
|
257
|
+
await git(projectDir, ['worktree', 'prune']);
|
|
258
|
+
const inUse = await worktreePathForBranch(projectDir, branch);
|
|
259
|
+
if (inUse && resolve(inUse) !== resolve(worktreeDir)) {
|
|
260
|
+
throw new Error(`branch "${branch}" is already checked out in worktree ${inUse}`);
|
|
261
|
+
}
|
|
262
|
+
args = ['worktree', 'add', '--', worktreeDir, branch];
|
|
263
|
+
} else {
|
|
264
|
+
// M1: validate the source resolves to a real commit (rejects leading-dash
|
|
265
|
+
// option injection and unknown refs); '--' stops git parsing the trailing
|
|
266
|
+
// positionals as options as a second line of defense.
|
|
267
|
+
if (!(await isValidSourceRef(projectDir, sourceBranch))) {
|
|
268
|
+
throw new Error(`sourceBranch is not a valid ref: ${JSON.stringify(sourceBranch)}`);
|
|
269
|
+
}
|
|
270
|
+
args = ['worktree', 'add', '-b', branch, '--', worktreeDir, sourceBranch];
|
|
271
|
+
}
|
|
272
|
+
const r = await git(projectDir, args, { signal, timeout: SLOW_GIT_TIMEOUT_MS });
|
|
273
|
+
if (!r.ok) {
|
|
274
|
+
const err = new Error(`git worktree add failed: ${r.stderr.trim() || `exit ${r.code}`}`);
|
|
275
|
+
// The ONE abort path that surfaced as a PLAIN error: on signal abort Node
|
|
276
|
+
// kills the spawned git and git() resolves ok:false (spawn's 'error' yields
|
|
277
|
+
// "The operation was aborted" — or whatever git wrote before dying, which
|
|
278
|
+
// may not mention the abort at all). Stamp the name every other abort/stop
|
|
279
|
+
// site sets so isAbort callers classify the stop as a stop, not a failure.
|
|
280
|
+
if (signal?.aborted) err.name = 'AbortError';
|
|
281
|
+
throw err;
|
|
282
|
+
}
|
|
283
|
+
return { worktreeDir, branch, sourceBranch, reusedExisting };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Remove a worktree dir + (optionally) its branch. Returns
|
|
288
|
+
* { ok, steps: [{ step, ok, stderr }] } so callers can log/assert — failures are
|
|
289
|
+
* never silently swallowed (M3).
|
|
290
|
+
*
|
|
291
|
+
* IMPORTANT: the non-force path only succeeds on a *pristine* checkout — git
|
|
292
|
+
* refuses to remove a worktree with modified/untracked files. Agents always
|
|
293
|
+
* edit files, so teardown of an agent-run worktree MUST pass force:true or it
|
|
294
|
+
* leaks. With force:true the dir is also removed directly as a backstop, and
|
|
295
|
+
* the registration is pruned so a later reuse of the branch won't trip M2.
|
|
296
|
+
*/
|
|
297
|
+
export async function removeWorktree({ projectDir, worktreeDir, branch, force = false } = {}) {
|
|
298
|
+
const steps = [];
|
|
299
|
+
if (worktreeDir) {
|
|
300
|
+
const args = force
|
|
301
|
+
? ['worktree', 'remove', '--force', worktreeDir]
|
|
302
|
+
: ['worktree', 'remove', worktreeDir];
|
|
303
|
+
const r = await git(projectDir, args, { timeout: SLOW_GIT_TIMEOUT_MS });
|
|
304
|
+
steps.push({ step: 'worktree-remove', ok: r.ok, stderr: r.stderr.trim() });
|
|
305
|
+
if (force) {
|
|
306
|
+
const fsRes = await rm(worktreeDir, { recursive: true, force: true })
|
|
307
|
+
.then(() => null)
|
|
308
|
+
.catch((e) => e.message);
|
|
309
|
+
if (fsRes) steps.push({ step: 'rm-dir', ok: false, stderr: fsRes });
|
|
310
|
+
}
|
|
311
|
+
// Reap the (now-missing) registration so it can't collide on reuse.
|
|
312
|
+
await git(projectDir, ['worktree', 'prune']);
|
|
313
|
+
}
|
|
314
|
+
if (branch) {
|
|
315
|
+
const r = await git(projectDir, ['branch', force ? '-D' : '-d', branch]);
|
|
316
|
+
steps.push({ step: 'branch-delete', ok: r.ok, stderr: r.stderr.trim() });
|
|
317
|
+
}
|
|
318
|
+
return { ok: steps.every((s) => s.ok), steps };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Stage every remaining change and render a binary-capable patch against HEAD
|
|
323
|
+
* DIRECTLY INTO outFile (no in-memory patch string — agent-created artifacts can
|
|
324
|
+
* be huge). Staging is intentional: it makes untracked files part of the patch.
|
|
325
|
+
* Uses the slow git timeout: big binary diffs legitimately take time.
|
|
326
|
+
* Crash-safe: streams to `<outFile>.part` and renames on success, so a
|
|
327
|
+
* SIGKILL/timeout/full disk can never leave a TRUNCATED patch under the final
|
|
328
|
+
* name. A clean tree is SUCCESS with `file: null` (nothing to save is not a
|
|
329
|
+
* failure — discard after a manual commit relies on it). A git failure returns
|
|
330
|
+
* without removing anything so the checkout stays authoritative.
|
|
331
|
+
*/
|
|
332
|
+
export async function snapshotWorktreePatch(worktreeDir, outFile) {
|
|
333
|
+
if (!worktreeDir || !outFile) {
|
|
334
|
+
return { ok: false, step: 'path', message: 'worktreeDir and outFile are required' };
|
|
335
|
+
}
|
|
336
|
+
const add = await git(worktreeDir, ['add', '-A'], { timeout: SLOW_GIT_TIMEOUT_MS });
|
|
337
|
+
if (!add.ok) {
|
|
338
|
+
return { ok: false, step: 'add', message: add.stderr.trim() || `exit ${add.code}`, fromStderr: !!add.stderr.trim() };
|
|
339
|
+
}
|
|
340
|
+
const part = `${outFile}.part`;
|
|
341
|
+
const diff = await git(worktreeDir, ['diff', '--binary', `--output=${part}`, 'HEAD', '--'],
|
|
342
|
+
{ timeout: SLOW_GIT_TIMEOUT_MS });
|
|
343
|
+
if (!diff.ok) {
|
|
344
|
+
await rm(part, { force: true }).catch(() => {});
|
|
345
|
+
return { ok: false, step: 'diff', message: diff.stderr.trim() || `exit ${diff.code}`, fromStderr: !!diff.stderr.trim() };
|
|
346
|
+
}
|
|
347
|
+
let bytes = 0;
|
|
348
|
+
try { bytes = (await stat(part)).size; } catch { /* treated as empty below */ }
|
|
349
|
+
if (!bytes) {
|
|
350
|
+
// Nothing uncommitted: a 0-byte "recovery patch" on disk would be a lie.
|
|
351
|
+
await rm(part, { force: true }).catch(() => {});
|
|
352
|
+
return { ok: true, file: null, bytes: 0 };
|
|
353
|
+
}
|
|
354
|
+
await rename(part, outFile);
|
|
355
|
+
return { ok: true, file: outFile, bytes };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function firstLine(text) {
|
|
359
|
+
if (!text) return '';
|
|
360
|
+
for (const line of String(text).split(/\r?\n/)) {
|
|
361
|
+
const t = line.replace(/^#+\s*/, '').trim();
|
|
362
|
+
if (t) return t;
|
|
363
|
+
}
|
|
364
|
+
return '';
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// ── run-root sweeps (§8.12) ───────────────────────────────────────────────────
|
|
368
|
+
//
|
|
369
|
+
// The ACTIVE set. `paused` and `interrupted` are the two RESUMABLE statuses
|
|
370
|
+
// (the resume route rejects everything else — ui/server.mjs), `running` and
|
|
371
|
+
// `pausing` are LIVE (the live-entry guard treats them as non-terminal). All four
|
|
372
|
+
// must keep their run roots. Stated POSITIVELY — never "terminal ⇒ delete": at boot
|
|
373
|
+
// reconcileStaleRunning stamps every stale `running` row → `interrupted`, and
|
|
374
|
+
// `interrupted` runs ARE resumable, so a "not paused and not running ⇒ terminal"
|
|
375
|
+
// rule would destroy the uncommitted work of every crashed run at the exact boot
|
|
376
|
+
// that made it resumable (_commitWork runs only at teardown; _buildResults only on
|
|
377
|
+
// the done path — there is no patch artifact to fall back on).
|
|
378
|
+
export const RUN_ROOT_KEEP = new Set(['running', 'pausing', 'paused', 'interrupted']);
|
|
379
|
+
export const RUN_ROOT_REMOVE = new Set(['done', 'stopped', 'error']);
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Sweep `<worcaHome>/runs/*`: keep active run roots, reclaim terminal ones,
|
|
383
|
+
* quarantine anything undecidable. Called at server boot (AFTER
|
|
384
|
+
* reconcileStaleRunning — the ordering is pinned, §8.12) and by `worca doctor`.
|
|
385
|
+
*
|
|
386
|
+
* `worktree.mjs` stays DB-FREE: every pipelines-row lookup is an INJECTED callback
|
|
387
|
+
* owned by the caller (server boot / doctor), which also makes this helper
|
|
388
|
+
* unit-testable with plain stubs.
|
|
389
|
+
*
|
|
390
|
+
* **A LOOKUP FAILURE IS NOT "NO ROW".** `statusOf` must return a status string for a
|
|
391
|
+
* row that exists, `null` only when the row is VERIFIABLY absent, and THROW when the
|
|
392
|
+
* lookup itself could not be performed. The distinction is load-bearing: the row-less
|
|
393
|
+
* disposition is *reclaim*, so a DB that cannot be opened (corrupt file, ABI mismatch
|
|
394
|
+
* after a Node upgrade, bad permissions) collapsing to null would force-remove the
|
|
395
|
+
* worktrees and rm -rf the run roots of every `paused`/`interrupted` run at the next
|
|
396
|
+
* boot — the §8.12 catastrophe, on the error path. A throw therefore skips that run
|
|
397
|
+
* root untouched (no rename, no removal: a transient DB problem must not
|
|
398
|
+
* orphan-quarantine everything either) and is reported in `failed` + logged loudly.
|
|
399
|
+
* The same applies to `membersOf`: if we cannot enumerate what to clean up, we do not
|
|
400
|
+
* remove anything.
|
|
401
|
+
*
|
|
402
|
+
* @param {object} args
|
|
403
|
+
* @param {string} args.worcaHome worcaHome() — `<home>/.worca-cc`
|
|
404
|
+
* @param {Function} args.statusOf (id) => status string | null (null = row
|
|
405
|
+
* verifiably absent); THROWS on lookup failure
|
|
406
|
+
* @param {Function} [args.membersOf] (id) => Promise<[{projectDir, worktreeDir}]> | null
|
|
407
|
+
* DB fallback used only when run.json is
|
|
408
|
+
* missing; THROWS on lookup failure
|
|
409
|
+
* @param {Function} [args.pipelineDirOf] (id) => Promise<string|null> — the durable
|
|
410
|
+
* artifact dir, for the rescue-before-remove
|
|
411
|
+
* triple (§8.11/§8.20/§5.2). Injected for the
|
|
412
|
+
* same DB-free reason as the two above. May
|
|
413
|
+
* degrade to null: it is consulted after the
|
|
414
|
+
* disposition and only names a copy target.
|
|
415
|
+
* @param {Function} [args.retainOf] (id) => retained-work record | null. DB
|
|
416
|
+
* fallback when the manifest is absent or
|
|
417
|
+
* its recorded worktrees are no longer live.
|
|
418
|
+
* @param {Function} [args.log] (level, message) sink; defaults to console
|
|
419
|
+
* @returns {Promise<{keep:string[], removed:string[], quarantined:string[],
|
|
420
|
+
* failed:string[], warnings:string[]}>}
|
|
421
|
+
*/
|
|
422
|
+
export async function sweepRunRoots({
|
|
423
|
+
worcaHome, statusOf, membersOf, pipelineDirOf, retainOf, log,
|
|
424
|
+
} = {}) {
|
|
425
|
+
const out = { keep: [], removed: [], quarantined: [], failed: [], warnings: [] };
|
|
426
|
+
const say = typeof log === 'function'
|
|
427
|
+
? log
|
|
428
|
+
: (level, msg) => { (level === 'warn' ? console.warn : console.log)(`[worca] run-root sweep: ${msg}`); };
|
|
429
|
+
if (!worcaHome) return out;
|
|
430
|
+
const runsBase = join(worcaHome, 'runs');
|
|
431
|
+
let entries;
|
|
432
|
+
try { entries = await readdir(runsBase, { withFileTypes: true }); } catch { return out; }
|
|
433
|
+
|
|
434
|
+
for (const entry of entries.sort((a, b) => (a.name < b.name ? -1 : 1))) {
|
|
435
|
+
if (!entry.isDirectory()) continue;
|
|
436
|
+
// A previously quarantined root is skipped entirely, so nothing is re-logged
|
|
437
|
+
// forever and nothing is deleted on a guess.
|
|
438
|
+
if (/\.orphan-/.test(entry.name)) continue;
|
|
439
|
+
const dir = join(runsBase, entry.name);
|
|
440
|
+
const manifest = await readRunManifest(dir); // may be null
|
|
441
|
+
const id = manifest?.pipelineId || basename(dir);
|
|
442
|
+
|
|
443
|
+
// Three states, never two: status string / verifiably absent (null) / lookup
|
|
444
|
+
// FAILED. Only "verifiably absent" may reach the reclaim path below.
|
|
445
|
+
let status;
|
|
446
|
+
try {
|
|
447
|
+
if (typeof statusOf !== 'function') throw new Error('statusOf callback is required');
|
|
448
|
+
status = statusOf(id);
|
|
449
|
+
} catch (err) {
|
|
450
|
+
const reason = `skip ${dir}: pipelines-row lookup FAILED (${err?.message || err}) — leaving it untouched`;
|
|
451
|
+
out.failed.push(dir);
|
|
452
|
+
out.warnings.push(reason);
|
|
453
|
+
say('warn', reason);
|
|
454
|
+
continue; // never guess: a lookup failure removes and renames nothing
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if (status && RUN_ROOT_KEEP.has(status)) {
|
|
458
|
+
out.keep.push(dir);
|
|
459
|
+
say('info', `keep ${dir} (${status})`);
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// A manifest retain record is live only while at least one named checkout
|
|
464
|
+
// still exists. This makes manual recovery/removal self-clearing instead of
|
|
465
|
+
// leaking the run root forever. The DB callback is an independent fallback.
|
|
466
|
+
const manifestRetain = manifest?.retain;
|
|
467
|
+
const manifestMembers = Array.isArray(manifestRetain?.members) ? manifestRetain.members : [];
|
|
468
|
+
let retained = manifestMembers.some((m) => m?.worktreeDir && existsSync(m.worktreeDir))
|
|
469
|
+
? manifestRetain
|
|
470
|
+
: null;
|
|
471
|
+
// Same three-state doctrine as statusOf/membersOf: "retention unknown" must
|
|
472
|
+
// never collapse into "remove", so a lookup throw skips this root untouched
|
|
473
|
+
// and is reported in `failed` + logged loudly (it must NOT abort the sweep —
|
|
474
|
+
// this call site is outside the statusOf try/catch).
|
|
475
|
+
if (!retained && typeof retainOf === 'function') {
|
|
476
|
+
try {
|
|
477
|
+
retained = await retainOf(id);
|
|
478
|
+
} catch (err) {
|
|
479
|
+
const reason = `skip ${dir}: retention lookup FAILED (${err?.message || err}) — leaving it untouched`;
|
|
480
|
+
out.failed.push(dir);
|
|
481
|
+
out.warnings.push(reason);
|
|
482
|
+
say('warn', reason);
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
if (retained) {
|
|
487
|
+
out.keep.push(dir);
|
|
488
|
+
say('info', `keep ${dir} (${status || 'no row'}, retained: ${retained.reason || 'unknown'})`);
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
if (status && !RUN_ROOT_REMOVE.has(status)) {
|
|
492
|
+
out.quarantined.push(dir);
|
|
493
|
+
say('warn', `quarantine ${dir}: unknown status ${status}`);
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// status ∈ REMOVE, or the row is gone (deleted pipeline): reclaim.
|
|
498
|
+
if (status) {
|
|
499
|
+
// rescue-before-remove runs ONLY when a pipelines row exists (its artifact
|
|
500
|
+
// dir is derivable). Row-less roots skip it — the pipeline was deliberately
|
|
501
|
+
// deleted and its artifact dir is gone.
|
|
502
|
+
let pipelineDir = null;
|
|
503
|
+
try { pipelineDir = pipelineDirOf ? await pipelineDirOf(id) : null; } catch { pipelineDir = null; }
|
|
504
|
+
const injected = manifest?.injectedPaths || {};
|
|
505
|
+
for (const [scope, list] of Object.entries(injected)) {
|
|
506
|
+
const baseDir = scope === 'runRoot'
|
|
507
|
+
? dir
|
|
508
|
+
: (manifest?.members || []).find((m) => m?.projectKey === scope)?.worktreeDir || join(dir, 'repos', scope);
|
|
509
|
+
const w = await rescueModifiedMounts({
|
|
510
|
+
baseDir, entries: list, pipelineDir, scope, pipelineId: id,
|
|
511
|
+
});
|
|
512
|
+
out.warnings.push(...w);
|
|
513
|
+
for (const line of w) say('warn', `${id}: ${line}`);
|
|
514
|
+
}
|
|
515
|
+
const strays = await scanStrayEntries({ runRoot: dir, pipelineDir });
|
|
516
|
+
out.warnings.push(...strays);
|
|
517
|
+
for (const line of strays) say('warn', `${id}: ${line}`);
|
|
518
|
+
await copyRunManifestTo(dir, pipelineDir);
|
|
519
|
+
} else if (!manifest) {
|
|
520
|
+
// Row-less AND no readable manifest: quarantine ONCE by renaming, rather
|
|
521
|
+
// than deleting on a guess. Later sweeps skip `*.orphan-*` entirely.
|
|
522
|
+
const dest = `${dir}.orphan-${Date.now()}`;
|
|
523
|
+
try {
|
|
524
|
+
await rename(dir, dest);
|
|
525
|
+
out.quarantined.push(dest);
|
|
526
|
+
say('warn', `quarantine ${dir} -> ${basename(dest)}: no pipelines row and no readable run.json`);
|
|
527
|
+
} catch (err) {
|
|
528
|
+
out.warnings.push(`quarantine rename failed for ${dir}: ${err?.message || err}`);
|
|
529
|
+
say('warn', `quarantine rename failed for ${dir}: ${err?.message || err}`);
|
|
530
|
+
}
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// Same three-state rule for the DB fallback: if we cannot enumerate the members
|
|
535
|
+
// we must not remove anything, or the run root would be rm -rf'd while its
|
|
536
|
+
// worktrees stayed registered in the real repos.
|
|
537
|
+
let members = manifest?.members ?? null;
|
|
538
|
+
if (!members && membersOf) {
|
|
539
|
+
try {
|
|
540
|
+
members = await membersOf(id);
|
|
541
|
+
} catch (err) {
|
|
542
|
+
const reason = `skip ${dir}: member lookup FAILED (${err?.message || err}) — leaving it untouched`;
|
|
543
|
+
out.failed.push(dir);
|
|
544
|
+
out.warnings.push(reason);
|
|
545
|
+
say('warn', reason);
|
|
546
|
+
continue;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
members = members ?? [];
|
|
550
|
+
for (const m of members) {
|
|
551
|
+
if (!m?.projectDir || !m?.worktreeDir) continue;
|
|
552
|
+
await removeWorktree({ projectDir: m.projectDir, worktreeDir: m.worktreeDir, branch: null, force: true });
|
|
553
|
+
}
|
|
554
|
+
const res = await rmGuarded(dir, { worcaHome, pipelineId: basename(dir) });
|
|
555
|
+
if (res.removed) {
|
|
556
|
+
out.removed.push(dir);
|
|
557
|
+
say('info', `removed ${dir}${status ? ` (${status})` : ' (no pipelines row)'}`);
|
|
558
|
+
} else {
|
|
559
|
+
out.warnings.push(res.reason || `removal refused for ${dir}`);
|
|
560
|
+
say('warn', res.reason || `removal refused for ${dir}`);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
return out;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* One-time migration sweep of the LEGACY worktree base
|
|
568
|
+
* `<projectDir>/.worca-cc/worktrees/*` for a registered project. DEFINED + unit
|
|
569
|
+
* tested in Phase 1; wired at server boot in Phase 7.
|
|
570
|
+
*
|
|
571
|
+
* "Leftover" is defined, not assumed: the directory basename IS the pipelineId, so
|
|
572
|
+
* each candidate is looked up in `pipelines` before anything is removed; a candidate
|
|
573
|
+
* is SKIPPED when its status is in RUN_ROOT_KEEP or when `referencedPaths` still
|
|
574
|
+
* names that path (a run may have been recorded under a different id shape); it is
|
|
575
|
+
* removed only for RUN_ROOT_REMOVE statuses; anything else is quarantine-logged.
|
|
576
|
+
*
|
|
577
|
+
* **A TOTAL NO-OP while the effective run-root mode is `legacy`** — under legacy
|
|
578
|
+
* `<projectDir>/.worca-cc/worktrees/<id>` is the LIVE location of every active run,
|
|
579
|
+
* so sweeping there would make the documented rollback self-destroying (run legacy →
|
|
580
|
+
* pause → restart → the paused run's checkout is deleted and resume() hard-fails
|
|
581
|
+
* with the agent's uncommitted work gone). This is a migration step for trees left
|
|
582
|
+
* behind by the flip, and it only ever runs when the flip is in effect.
|
|
583
|
+
*
|
|
584
|
+
* **A LOOKUP FAILURE IS NOT "NO ROW"** — the same three-state rule sweepRunRoots
|
|
585
|
+
* documents above: `statusOf` returns a status for a row that exists, `null` only
|
|
586
|
+
* when the row is VERIFIABLY absent, and THROWS when the lookup could not be
|
|
587
|
+
* performed. A throw skips that candidate untouched and is reported in `failed` +
|
|
588
|
+
* logged loudly, instead of collapsing into the row-less quarantine-log (which would
|
|
589
|
+
* misreport "the DB is unreadable" as "these runs were deleted").
|
|
590
|
+
*
|
|
591
|
+
* @param {string} projectDir
|
|
592
|
+
* @param {object} args
|
|
593
|
+
* @param {Function} args.statusOf (id) => status | null; THROWS on lookup failure
|
|
594
|
+
* @param {Function|string} [args.mode] injected effective mode; DEFAULTS to the
|
|
595
|
+
* real runRootMode() reader, so the Phase-7
|
|
596
|
+
* boot call needs only { statusOf }
|
|
597
|
+
* @param {Set<string>|string[]} [args.referencedPaths] worktree paths any row still
|
|
598
|
+
* claims (state.branch.worktreeDir /
|
|
599
|
+
* state.branches[*].worktreeDir /
|
|
600
|
+
* workspace_meta); injected because
|
|
601
|
+
* worktree.mjs stays DB-free
|
|
602
|
+
* @param {Function} [args.log]
|
|
603
|
+
*/
|
|
604
|
+
export async function sweepLegacyWorktrees(projectDir, {
|
|
605
|
+
statusOf, mode = runRootMode, referencedPaths, log,
|
|
606
|
+
} = {}) {
|
|
607
|
+
const out = { keep: [], removed: [], quarantined: [], failed: [], warnings: [], skipped: false };
|
|
608
|
+
const say = typeof log === 'function'
|
|
609
|
+
? log
|
|
610
|
+
: (level, msg) => { (level === 'warn' ? console.warn : console.log)(`[worca] legacy sweep: ${msg}`); };
|
|
611
|
+
const effective = typeof mode === 'function' ? mode() : mode;
|
|
612
|
+
if (effective !== 'detached') {
|
|
613
|
+
out.skipped = true; // §10: never sweep the live legacy base
|
|
614
|
+
return out;
|
|
615
|
+
}
|
|
616
|
+
if (!projectDir) return out;
|
|
617
|
+
const base = join(resolve(projectDir), WORKTREES_DIRNAME);
|
|
618
|
+
const referenced = new Set(
|
|
619
|
+
[...(referencedPaths || [])].filter(Boolean).map((p) => resolve(p)),
|
|
620
|
+
);
|
|
621
|
+
let entries;
|
|
622
|
+
try { entries = await readdir(base, { withFileTypes: true }); } catch { return out; }
|
|
623
|
+
|
|
624
|
+
for (const entry of entries.sort((a, b) => (a.name < b.name ? -1 : 1))) {
|
|
625
|
+
if (!entry.isDirectory()) continue;
|
|
626
|
+
const dir = join(base, entry.name);
|
|
627
|
+
if (referenced.has(resolve(dir))) {
|
|
628
|
+
out.keep.push(dir);
|
|
629
|
+
say('info', `keep ${dir} (still referenced by a pipelines row)`);
|
|
630
|
+
continue;
|
|
631
|
+
}
|
|
632
|
+
const id = entry.name; // worktree.mjs names the dir by pipelineId
|
|
633
|
+
// Three states, never two: status string / verifiably absent (null) / lookup
|
|
634
|
+
// FAILED. Only "verifiably absent" may reach the quarantine-log below.
|
|
635
|
+
let status;
|
|
636
|
+
try {
|
|
637
|
+
if (typeof statusOf !== 'function') throw new Error('statusOf callback is required');
|
|
638
|
+
status = statusOf(id);
|
|
639
|
+
} catch (err) {
|
|
640
|
+
const reason = `skip ${dir}: pipelines-row lookup FAILED (${err?.message || err}) — leaving it untouched`;
|
|
641
|
+
out.failed.push(dir);
|
|
642
|
+
out.warnings.push(reason);
|
|
643
|
+
say('warn', reason);
|
|
644
|
+
continue; // never guess: a lookup failure removes and quarantine-logs nothing
|
|
645
|
+
}
|
|
646
|
+
if (status && RUN_ROOT_KEEP.has(status)) {
|
|
647
|
+
out.keep.push(dir);
|
|
648
|
+
say('info', `keep ${dir} (${status})`);
|
|
649
|
+
continue;
|
|
650
|
+
}
|
|
651
|
+
if (!status || !RUN_ROOT_REMOVE.has(status)) {
|
|
652
|
+
out.quarantined.push(dir);
|
|
653
|
+
say('warn', `quarantine ${dir}: ${status ? `unknown status ${status}` : 'no pipelines row'}`);
|
|
654
|
+
continue;
|
|
655
|
+
}
|
|
656
|
+
const res = await removeWorktree({ projectDir, worktreeDir: dir, branch: null, force: true });
|
|
657
|
+
out.removed.push(dir);
|
|
658
|
+
for (const s of res.steps.filter((x) => !x.ok)) {
|
|
659
|
+
out.warnings.push(`${entry.name}: ${s.step}: ${s.stderr || 'failed'}`);
|
|
660
|
+
}
|
|
661
|
+
say('info', `removed ${dir} (${status})`);
|
|
662
|
+
}
|
|
663
|
+
return out;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* Fan `sweepLegacyWorktrees` out over a list of project dirs — the projects registry
|
|
668
|
+
* (`projects.mjs#listProjects`) at server boot and in `worca doctor` — aggregating
|
|
669
|
+
* every disposition into one report. Both wirings share this so the aggregation and
|
|
670
|
+
* the mode gate can never drift between them, while each keeps its own printing.
|
|
671
|
+
*
|
|
672
|
+
* Takes plain dirs plus the INJECTED lookups (`artifacts.mjs#legacySweepLookups`), so
|
|
673
|
+
* worktree.mjs stays DB-free. `mode` is resolved ONCE here and passed down verbatim:
|
|
674
|
+
* one boot can never sweep half the registry in one mode and half in the other.
|
|
675
|
+
*
|
|
676
|
+
* Idempotent by construction — every disposition is decided from the caller's row
|
|
677
|
+
* snapshot, and a second pass finds the removed dirs already gone — so re-running it
|
|
678
|
+
* at every boot, or during a §10 rollback, is safe. A dir with no
|
|
679
|
+
* `.worca-cc/worktrees` (or no such project on disk) contributes nothing, silently.
|
|
680
|
+
*
|
|
681
|
+
* @param {string[]} projectDirs
|
|
682
|
+
* @param {object} args statusOf / referencedPaths / log forwarded verbatim
|
|
683
|
+
* @param {Function|string} [args.mode]
|
|
684
|
+
* @returns {Promise<{projects:number, skipped:boolean, keep:string[], removed:string[],
|
|
685
|
+
* quarantined:string[], failed:string[], warnings:string[]}>}
|
|
686
|
+
*/
|
|
687
|
+
export async function sweepLegacyWorktreesAll(projectDirs, {
|
|
688
|
+
statusOf, mode = runRootMode, referencedPaths, log,
|
|
689
|
+
} = {}) {
|
|
690
|
+
const out = {
|
|
691
|
+
projects: 0, skipped: false,
|
|
692
|
+
keep: [], removed: [], quarantined: [], failed: [], warnings: [],
|
|
693
|
+
};
|
|
694
|
+
const effective = typeof mode === 'function' ? mode() : mode;
|
|
695
|
+
if (effective !== 'detached') {
|
|
696
|
+
out.skipped = true; // §10: under legacy those paths hold every live and paused run
|
|
697
|
+
return out;
|
|
698
|
+
}
|
|
699
|
+
for (const dir of projectDirs || []) {
|
|
700
|
+
if (!dir) continue;
|
|
701
|
+
const res = await sweepLegacyWorktrees(dir, { statusOf, mode: effective, referencedPaths, log });
|
|
702
|
+
out.projects += 1;
|
|
703
|
+
for (const key of ['keep', 'removed', 'quarantined', 'failed', 'warnings']) {
|
|
704
|
+
out[key].push(...(res[key] || []));
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
return out;
|
|
708
|
+
}
|