dsh-plugin-worktrees 0.1.0
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/AGENTS.md +120 -0
- package/CHANGELOG.md +266 -0
- package/LICENSE +21 -0
- package/README.md +194 -0
- package/README.zh.md +151 -0
- package/SECURITY.md +56 -0
- package/cordis.patch.yml +30 -0
- package/docs/DESIGN.md +783 -0
- package/docs/TASKS.md +164 -0
- package/lib/config.js +126 -0
- package/lib/engine-face.js +328 -0
- package/lib/git-port.js +773 -0
- package/lib/index.js +402 -0
- package/lib/merge-queue.js +832 -0
- package/lib/naming.js +107 -0
- package/lib/repo-gate.js +202 -0
- package/lib/state-store.js +512 -0
- package/lib/tools/worktree-cleanup.js +127 -0
- package/lib/tools/worktree-create.js +212 -0
- package/lib/tools/worktree-list.js +234 -0
- package/lib/tools/worktree-merge.js +396 -0
- package/lib/tools/worktree-queue.js +330 -0
- package/lib/tools/worktree-status.js +194 -0
- package/lib/worktree-service.js +673 -0
- package/package.json +55 -0
- package/scripts/link-harness-dsh-tools.sh +95 -0
- package/scripts/lint.js +142 -0
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* StateStore — DESIGN §7 state & persistence for dsh-worktrees.
|
|
3
|
+
*
|
|
4
|
+
* One JSON file, atomically replaced on every write. Family precedent:
|
|
5
|
+
* dsh-plugin-subagents `lib/registry.js` (tmp + rename, owner-only 0600,
|
|
6
|
+
* `__proto__` key guard); dsh-atomic-write's `writeFileAtomic` semantics
|
|
7
|
+
* (wx exclusive create + rename + mode enforcement + symlink resistance)
|
|
8
|
+
* are the spec this implements locally, zero-dependency.
|
|
9
|
+
*
|
|
10
|
+
* Write discipline (§7.1):
|
|
11
|
+
* deep-copy + sanitize the in-memory state → `JSON.stringify(,2)` →
|
|
12
|
+
* tmp file in the SAME directory as the target — `openSync(tmp, "wx")`
|
|
13
|
+
* (exclusive create; a symlink pre-planted at the tmp name fails loudly
|
|
14
|
+
* instead of being followed), mode 0o600, best-effort chmod to force the
|
|
15
|
+
* mode on filesystems that ignore the create mode → `renameSync` atomic
|
|
16
|
+
* replace. A random suffix on the tmp name makes collisions (and hence
|
|
17
|
+
* EEXIST races between concurrent writers) practically impossible; a
|
|
18
|
+
* failed write removes the tmp best-effort so nothing litters the state
|
|
19
|
+
* directory.
|
|
20
|
+
*
|
|
21
|
+
* Read discipline (§7.1):
|
|
22
|
+
* `load()` reads the file (missing → initial state; unparseable → loud
|
|
23
|
+
* error naming the path), rebuilds every record through the same
|
|
24
|
+
* sanitizer (dangerous keys — `__proto__` / `constructor` / `prototype` —
|
|
25
|
+
* are dropped RECURSIVELY, in both the load and the upsert direction),
|
|
26
|
+
* and prunes terminal merge jobs to the newest 200.
|
|
27
|
+
*
|
|
28
|
+
* After `load()`, the in-memory index IS the truth (§7.2: single process;
|
|
29
|
+
* all writes happen inside the serial apply chain or a tool's single-step
|
|
30
|
+
* critical section). Mutations go through the upsert methods; `persist()`
|
|
31
|
+
* flushes the snapshot back atomically. The map getters hand out the live
|
|
32
|
+
* internal maps (zero-copy read access for services); the contract is
|
|
33
|
+
* read-mostly — structural abuse through those references is bounded
|
|
34
|
+
* because every persist() re-sanitizes a deep copy, so nothing dangerous
|
|
35
|
+
* or non-JSON ever reaches disk and the JSON round-trip stays exact.
|
|
36
|
+
*/
|
|
37
|
+
import {
|
|
38
|
+
chmodSync,
|
|
39
|
+
closeSync,
|
|
40
|
+
mkdirSync,
|
|
41
|
+
openSync,
|
|
42
|
+
readFileSync,
|
|
43
|
+
renameSync,
|
|
44
|
+
rmSync,
|
|
45
|
+
writeFileSync,
|
|
46
|
+
} from "node:fs";
|
|
47
|
+
import { randomBytes } from "node:crypto";
|
|
48
|
+
import { dirname } from "node:path";
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Merge-job states that end the job's lifecycle (DESIGN §5.2.2 state
|
|
52
|
+
* machine). Terminal jobs are pruned to the newest `keep` (§7.1); `conflicted`
|
|
53
|
+
* is NOT terminal — it is an active state whose worktree is the live scene.
|
|
54
|
+
*/
|
|
55
|
+
export const TERMINAL_JOB_STATES = new Set([
|
|
56
|
+
"succeeded",
|
|
57
|
+
"failed",
|
|
58
|
+
"cancelled",
|
|
59
|
+
"resolved",
|
|
60
|
+
]);
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Merge-job states that hold the per-branch queue slot (§6 invariants: at
|
|
64
|
+
* most one active job per repo+integrationBranch).
|
|
65
|
+
*/
|
|
66
|
+
export const ACTIVE_JOB_STATES = new Set(["queued", "applying", "conflicted"]);
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Key names that must never become own properties of stored records: an
|
|
70
|
+
* own `__proto__` data property (exactly what `JSON.parse` produces for a
|
|
71
|
+
* hostile document) pollutes prototypes the moment anyone merges the record
|
|
72
|
+
* with `Object.assign`; `constructor` / `prototype` are the classic gadget
|
|
73
|
+
* chain companions. Dropped on upsert AND on load, recursively — the
|
|
74
|
+
* registry.js precedent.
|
|
75
|
+
*/
|
|
76
|
+
const DANGEROUS_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
|
77
|
+
|
|
78
|
+
/** Terminal-job retention bound applied on every load() (§7.1). */
|
|
79
|
+
const DEFAULT_PRUNE_KEEP = 200;
|
|
80
|
+
|
|
81
|
+
/** @returns {StateFile} */
|
|
82
|
+
function initialState() {
|
|
83
|
+
return { version: 1, worktrees: {}, jobs: {}, repos: {} };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function isPlainObject(value) {
|
|
87
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Deep copy that produces ONLY JSON-round-trip-safe data and drops dangerous
|
|
92
|
+
* keys at every level. This is simultaneously:
|
|
93
|
+
* - the upsert-time intake sanitizer (hostile record fields never enter the
|
|
94
|
+
* in-memory index),
|
|
95
|
+
* - the persist-time deep copy (§7.1: write = deep copy → stringify), and
|
|
96
|
+
* - the load-time sanitizer (a hand-tampered state file cannot smuggle a
|
|
97
|
+
* `__proto__` key back into memory).
|
|
98
|
+
*
|
|
99
|
+
* JSON semantics are mirrored exactly so `persist() → load()` compares
|
|
100
|
+
* deep-equal: `undefined` / functions / symbols are dropped from objects and
|
|
101
|
+
* become `null` inside arrays, non-finite numbers become `null`, and objects
|
|
102
|
+
* with a `toJSON()` (e.g. Date) are converted through it. A true cycle
|
|
103
|
+
* throws a loud TypeError (a DAG — the same sub-object referenced twice — is
|
|
104
|
+
* fine and stringifies twice, exactly like JSON.stringify).
|
|
105
|
+
*
|
|
106
|
+
* @param {unknown} value
|
|
107
|
+
* @param {boolean} inArray array-position context: JSON nulls unrepresentable
|
|
108
|
+
* values instead of dropping them.
|
|
109
|
+
* @param {Set<object>} seen cycle guard (entries removed on exit, so only
|
|
110
|
+
* cycles trip it, not shared references).
|
|
111
|
+
* @returns {unknown}
|
|
112
|
+
*/
|
|
113
|
+
function sanitizeDeep(value, inArray = false, seen = new Set()) {
|
|
114
|
+
if (value === null) return null;
|
|
115
|
+
const type = typeof value;
|
|
116
|
+
if (type === "string" || type === "boolean") return value;
|
|
117
|
+
if (type === "number") return Number.isFinite(value) ? value : null;
|
|
118
|
+
if (type === "undefined" || type === "symbol" || type === "function" || type === "bigint") {
|
|
119
|
+
return inArray ? null : undefined; // caller drops the undefined entries
|
|
120
|
+
}
|
|
121
|
+
if (typeof value.toJSON === "function") {
|
|
122
|
+
return sanitizeDeep(value.toJSON(), inArray, seen);
|
|
123
|
+
}
|
|
124
|
+
if (seen.has(value)) {
|
|
125
|
+
throw new TypeError("state-store: circular reference in state record");
|
|
126
|
+
}
|
|
127
|
+
seen.add(value);
|
|
128
|
+
try {
|
|
129
|
+
if (Array.isArray(value)) {
|
|
130
|
+
return value.map((element) => sanitizeDeep(element, true, seen));
|
|
131
|
+
}
|
|
132
|
+
const out = {};
|
|
133
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
134
|
+
if (DANGEROUS_KEYS.has(key)) continue;
|
|
135
|
+
const clean = sanitizeDeep(entry, false, seen);
|
|
136
|
+
if (clean !== undefined) out[key] = clean;
|
|
137
|
+
}
|
|
138
|
+
return out;
|
|
139
|
+
} finally {
|
|
140
|
+
seen.delete(value);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Rebuild one top-level map from parsed data: non-object maps fail LOUD
|
|
146
|
+
* (the crash reconcile must not silently trust a corrupt file), while
|
|
147
|
+
* non-object ENTRIES are unusable as records and are dropped.
|
|
148
|
+
*
|
|
149
|
+
* @param {unknown} value
|
|
150
|
+
* @param {string} name
|
|
151
|
+
* @param {string} path
|
|
152
|
+
* @returns {Record<string, object>}
|
|
153
|
+
*/
|
|
154
|
+
function requireMap(value, name, path) {
|
|
155
|
+
if (value === undefined) return {};
|
|
156
|
+
if (!isPlainObject(value)) {
|
|
157
|
+
throw new Error(
|
|
158
|
+
`state-store: state file ${path} is corrupt: "${name}" must be an object`,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
const out = {};
|
|
162
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
163
|
+
if (DANGEROUS_KEYS.has(key)) continue;
|
|
164
|
+
if (isPlainObject(entry)) out[key] = entry;
|
|
165
|
+
}
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* @typedef {object} WorktreeRecord DESIGN §5.2.2 (+ §10 seam fields).
|
|
171
|
+
* @property {string} id
|
|
172
|
+
* @property {string} repoKey
|
|
173
|
+
* @property {string} repoRoot
|
|
174
|
+
* @property {string} branch
|
|
175
|
+
* @property {string} path
|
|
176
|
+
* @property {string} baseCommit
|
|
177
|
+
* @property {string} integrationBranch
|
|
178
|
+
* @property {string} sessionId
|
|
179
|
+
* @property {string} task
|
|
180
|
+
* @property {"active"|"merging"|"merged"|"conflicted"|"abandoned"|"vanished"} state
|
|
181
|
+
* @property {string} [headCommit]
|
|
182
|
+
* @property {string} [mergeJobId]
|
|
183
|
+
* @property {string} [note]
|
|
184
|
+
* @property {"tool"|"dag"} [origin] creation provenance (§10 seam);
|
|
185
|
+
* absent on records written before the engine facade existed — the
|
|
186
|
+
* sanitize pass preserves whatever is present, old files stay valid.
|
|
187
|
+
* @property {string} [correlationId] create-time correlation token (the
|
|
188
|
+
* DAG attempt id); the reuse-ownership gate's evidence. Optional for the
|
|
189
|
+
* same backward-compatibility reason.
|
|
190
|
+
* @property {number} createdAt
|
|
191
|
+
* @property {number} updatedAt
|
|
192
|
+
*/
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* @typedef {object} MergeJobRecord DESIGN §5.2.2.
|
|
196
|
+
* @property {string} id
|
|
197
|
+
* @property {string} repoKey
|
|
198
|
+
* @property {string} integrationBranch
|
|
199
|
+
* @property {string} worktreeId
|
|
200
|
+
* @property {string} sourceBranch
|
|
201
|
+
* @property {string} sourceHead
|
|
202
|
+
* @property {number} orderIndex
|
|
203
|
+
* @property {"queued"|"applying"|"succeeded"|"conflicted"|"failed"|"cancelled"|"resolved"} state
|
|
204
|
+
* @property {string} [integrationWorktree]
|
|
205
|
+
* @property {string} [integrationHeadBefore]
|
|
206
|
+
* @property {string} [integratedCommit]
|
|
207
|
+
* @property {string[]} [conflictFiles]
|
|
208
|
+
* @property {string} [error]
|
|
209
|
+
* @property {string} [message]
|
|
210
|
+
* @property {"tool"|"dag"} origin
|
|
211
|
+
* @property {string} [correlationId]
|
|
212
|
+
* @property {number} createdAt
|
|
213
|
+
* @property {number} updatedAt
|
|
214
|
+
*/
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* @typedef {object} RepoRecord DESIGN §5.2.2 — crash-reconcile enumeration
|
|
218
|
+
* source (`repoKey → { root, lastSeenAt }`).
|
|
219
|
+
* @property {string} root
|
|
220
|
+
* @property {number} lastSeenAt
|
|
221
|
+
*/
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* @typedef {object} StateFile state.json root shape.
|
|
225
|
+
* @property {number} version
|
|
226
|
+
* @property {Record<string, WorktreeRecord>} worktrees
|
|
227
|
+
* @property {Record<string, MergeJobRecord>} jobs
|
|
228
|
+
* @property {Record<string, RepoRecord>} repos
|
|
229
|
+
*/
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Create a StateStore bound to one state.json path. Pure ESM, zero runtime
|
|
233
|
+
* dependencies (node:fs / node:crypto / node:path only), Node >= 18.
|
|
234
|
+
*
|
|
235
|
+
* @param {{path: string}} options `path` is required — the default location
|
|
236
|
+
* (`~/.dsh/dsh-worktrees/state.json`) is resolved by the config layer
|
|
237
|
+
* (DESIGN §7.1 `statePath`), not here.
|
|
238
|
+
* @returns {{
|
|
239
|
+
* load: () => StateFile,
|
|
240
|
+
* persist: () => void,
|
|
241
|
+
* worktrees: Record<string, WorktreeRecord>,
|
|
242
|
+
* jobs: Record<string, MergeJobRecord>,
|
|
243
|
+
* repos: Record<string, RepoRecord>,
|
|
244
|
+
* upsertWorktree: (record: WorktreeRecord) => WorktreeRecord,
|
|
245
|
+
* upsertJob: (record: MergeJobRecord) => MergeJobRecord,
|
|
246
|
+
* pruneTerminalJobs: (keep?: number) => number,
|
|
247
|
+
* findActiveJob: (repoKey: string, integrationBranch: string) => MergeJobRecord | undefined,
|
|
248
|
+
* findQueuedJobs: (repoKey: string, integrationBranch: string) => MergeJobRecord[],
|
|
249
|
+
* nextOrderIndex: (repoKey: string, integrationBranch: string) => number,
|
|
250
|
+
* }}
|
|
251
|
+
*/
|
|
252
|
+
export function createStateStore({ path }) {
|
|
253
|
+
if (typeof path !== "string" || path.length === 0) {
|
|
254
|
+
throw new Error("state-store: createStateStore requires a { path } string");
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
let state = initialState();
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Read the state file into memory (startup path; §7.1 "读 = 启动一次").
|
|
261
|
+
* Missing file → initial state. Unreadable/unparseable/corrupt-shaped
|
|
262
|
+
* file → loud error naming the path. Terminal jobs are pruned to the
|
|
263
|
+
* newest 200 immediately; the pruned result flushes on the next persist().
|
|
264
|
+
*
|
|
265
|
+
* @returns {StateFile}
|
|
266
|
+
*/
|
|
267
|
+
function load() {
|
|
268
|
+
let raw;
|
|
269
|
+
try {
|
|
270
|
+
raw = readFileSync(path, "utf8");
|
|
271
|
+
} catch (error) {
|
|
272
|
+
if (error && error.code === "ENOENT") {
|
|
273
|
+
state = initialState();
|
|
274
|
+
pruneTerminalJobs(DEFAULT_PRUNE_KEEP);
|
|
275
|
+
return state;
|
|
276
|
+
}
|
|
277
|
+
throw new Error(`state-store: cannot read state file ${path}: ${error.message}`);
|
|
278
|
+
}
|
|
279
|
+
let doc;
|
|
280
|
+
try {
|
|
281
|
+
doc = JSON.parse(raw);
|
|
282
|
+
} catch (error) {
|
|
283
|
+
throw new Error(`state-store: state file ${path} is not valid JSON: ${error.message}`);
|
|
284
|
+
}
|
|
285
|
+
if (!isPlainObject(doc)) {
|
|
286
|
+
throw new Error(
|
|
287
|
+
`state-store: state file ${path} is corrupt: expected a JSON object at the root`,
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
const clean = sanitizeDeep(doc);
|
|
291
|
+
state = {
|
|
292
|
+
version:
|
|
293
|
+
typeof clean.version === "number" && Number.isFinite(clean.version)
|
|
294
|
+
? clean.version
|
|
295
|
+
: 1,
|
|
296
|
+
worktrees: requireMap(clean.worktrees, "worktrees", path),
|
|
297
|
+
jobs: requireMap(clean.jobs, "jobs", path),
|
|
298
|
+
repos: requireMap(clean.repos, "repos", path),
|
|
299
|
+
};
|
|
300
|
+
pruneTerminalJobs(DEFAULT_PRUNE_KEEP);
|
|
301
|
+
return state;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Atomically flush the in-memory state to disk (§7.1 write discipline).
|
|
306
|
+
* Throws on failure (after best-effort tmp cleanup) — callers in the apply
|
|
307
|
+
* chain surface the error; the previous state file is never damaged.
|
|
308
|
+
*/
|
|
309
|
+
function persist() {
|
|
310
|
+
const snapshot = sanitizeDeep(state);
|
|
311
|
+
const json = JSON.stringify(snapshot, null, 2);
|
|
312
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
313
|
+
const tmp = `${path}.${randomBytes(6).toString("hex")}.tmp`;
|
|
314
|
+
try {
|
|
315
|
+
// "wx" = O_CREAT | O_EXCL: exclusive create. A leftover tmp (or a
|
|
316
|
+
// pre-planted symlink) at this random name fails loudly instead of
|
|
317
|
+
// being followed or silently overwritten.
|
|
318
|
+
const fd = openSync(tmp, "wx", 0o600);
|
|
319
|
+
try {
|
|
320
|
+
writeFileSync(fd, json, "utf8");
|
|
321
|
+
} finally {
|
|
322
|
+
closeSync(fd);
|
|
323
|
+
}
|
|
324
|
+
// Best-effort mode enforcement (registry.js precedent): belt-and-braces
|
|
325
|
+
// against filesystems that weaken the create mode under umask.
|
|
326
|
+
try {
|
|
327
|
+
chmodSync(tmp, 0o600);
|
|
328
|
+
} catch {
|
|
329
|
+
/* best-effort */
|
|
330
|
+
}
|
|
331
|
+
renameSync(tmp, path);
|
|
332
|
+
} catch (error) {
|
|
333
|
+
// Never leave a half-written tmp behind; rethrow loudly.
|
|
334
|
+
try {
|
|
335
|
+
rmSync(tmp, { force: true });
|
|
336
|
+
} catch {
|
|
337
|
+
/* best-effort */
|
|
338
|
+
}
|
|
339
|
+
throw new Error(`state-store: failed to persist state to ${path}: ${error.message}`, {
|
|
340
|
+
cause: error,
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Upsert one record into a map by id: sanitizer first (dangerous keys and
|
|
347
|
+
* non-JSON values never enter the index), then `updatedAt = Date.now()`;
|
|
348
|
+
* `createdAt` is set on first insert and PRESERVED across updates (a
|
|
349
|
+
* caller re-upserting a fetched record keeps its original createdAt too).
|
|
350
|
+
*
|
|
351
|
+
* @param {Record<string, object>} map
|
|
352
|
+
* @param {unknown} record
|
|
353
|
+
* @param {"Worktree"|"Job"} kind
|
|
354
|
+
*/
|
|
355
|
+
function upsert(map, record, kind) {
|
|
356
|
+
if (!isPlainObject(record)) {
|
|
357
|
+
throw new Error(`state-store: upsert${kind} requires a record object`);
|
|
358
|
+
}
|
|
359
|
+
const id = record.id;
|
|
360
|
+
if (typeof id !== "string" || id.length === 0) {
|
|
361
|
+
throw new Error(`state-store: upsert${kind} requires a record with a non-empty string id`);
|
|
362
|
+
}
|
|
363
|
+
if (DANGEROUS_KEYS.has(id)) {
|
|
364
|
+
// Assigning such an id as a key would set the map's prototype instead
|
|
365
|
+
// of creating an entry — refuse loudly (ids are internal, generated).
|
|
366
|
+
throw new Error(`state-store: upsert${kind} refused dangerous record id "${id}"`);
|
|
367
|
+
}
|
|
368
|
+
const clean = sanitizeDeep(record);
|
|
369
|
+
const existing = map[id];
|
|
370
|
+
if (existing !== undefined && existing.createdAt !== undefined) {
|
|
371
|
+
clean.createdAt = existing.createdAt;
|
|
372
|
+
}
|
|
373
|
+
if (clean.createdAt === undefined) {
|
|
374
|
+
clean.createdAt = Date.now();
|
|
375
|
+
}
|
|
376
|
+
clean.updatedAt = Date.now();
|
|
377
|
+
map[id] = clean;
|
|
378
|
+
return clean;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Upsert a worktree record (§5.2.2 shape). Not auto-persisted — callers
|
|
383
|
+
* flush explicitly inside their critical section.
|
|
384
|
+
* @param {WorktreeRecord} record
|
|
385
|
+
* @returns {WorktreeRecord}
|
|
386
|
+
*/
|
|
387
|
+
function upsertWorktree(record) {
|
|
388
|
+
return upsert(state.worktrees, record, "Worktree");
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Upsert a merge-job record (§5.2.2 shape). Not auto-persisted.
|
|
393
|
+
* @param {MergeJobRecord} record
|
|
394
|
+
* @returns {MergeJobRecord}
|
|
395
|
+
*/
|
|
396
|
+
function upsertJob(record) {
|
|
397
|
+
return upsert(state.jobs, record, "Job");
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Prune terminal jobs (succeeded/failed/cancelled/resolved) to the newest
|
|
402
|
+
* `keep` by updatedAt, deleting the rest. Active jobs are never touched.
|
|
403
|
+
* Ties on updatedAt (same millisecond) break by insertion order — the
|
|
404
|
+
* OLDEST entries are pruned deterministically (registry.js precedent).
|
|
405
|
+
*
|
|
406
|
+
* @param {number} [keep=200]
|
|
407
|
+
* @returns {number} count of pruned jobs.
|
|
408
|
+
*/
|
|
409
|
+
function pruneTerminalJobs(keep = DEFAULT_PRUNE_KEEP) {
|
|
410
|
+
const limit = Math.max(0, Math.floor(Number(keep) || 0));
|
|
411
|
+
const terminal = [];
|
|
412
|
+
const ids = Object.keys(state.jobs);
|
|
413
|
+
for (let index = 0; index < ids.length; index += 1) {
|
|
414
|
+
const job = state.jobs[ids[index]];
|
|
415
|
+
if (job !== undefined && TERMINAL_JOB_STATES.has(job.state)) {
|
|
416
|
+
terminal.push({ id: ids[index], at: Number(job.updatedAt) || 0, seq: index });
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
if (terminal.length <= limit) return 0;
|
|
420
|
+
terminal.sort((a, b) => b.at - a.at || b.seq - a.seq); // newest first
|
|
421
|
+
for (const { id } of terminal.slice(limit)) {
|
|
422
|
+
delete state.jobs[id];
|
|
423
|
+
}
|
|
424
|
+
return terminal.length - limit;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* The active (queued/applying/conflicted) job for one repo+integration
|
|
429
|
+
* branch, or undefined. The invariant allows at most one; if a crash ever
|
|
430
|
+
* leaves several, the newest by updatedAt wins (most useful for reconcile).
|
|
431
|
+
*
|
|
432
|
+
* @param {string} repoKey
|
|
433
|
+
* @param {string} integrationBranch
|
|
434
|
+
* @returns {MergeJobRecord | undefined}
|
|
435
|
+
*/
|
|
436
|
+
function findActiveJob(repoKey, integrationBranch) {
|
|
437
|
+
let best = null;
|
|
438
|
+
for (const job of Object.values(state.jobs)) {
|
|
439
|
+
if (job.repoKey !== repoKey || job.integrationBranch !== integrationBranch) continue;
|
|
440
|
+
if (!ACTIVE_JOB_STATES.has(job.state)) continue;
|
|
441
|
+
const at = Number(job.updatedAt) || 0;
|
|
442
|
+
if (best === null || at >= (Number(best.updatedAt) || 0)) best = job;
|
|
443
|
+
}
|
|
444
|
+
return best === null ? undefined : best;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Queued jobs for one repo+integration branch, ascending by orderIndex
|
|
449
|
+
* (stable: equal/missing orderIndex keeps insertion order; missing sorts
|
|
450
|
+
* last). This is the drain order of the serial apply chain.
|
|
451
|
+
*
|
|
452
|
+
* @param {string} repoKey
|
|
453
|
+
* @param {string} integrationBranch
|
|
454
|
+
* @returns {MergeJobRecord[]}
|
|
455
|
+
*/
|
|
456
|
+
function findQueuedJobs(repoKey, integrationBranch) {
|
|
457
|
+
const orderIndexOf = (job) => {
|
|
458
|
+
const n = Number(job.orderIndex);
|
|
459
|
+
return Number.isFinite(n) ? n : Infinity;
|
|
460
|
+
};
|
|
461
|
+
return Object.values(state.jobs)
|
|
462
|
+
.filter(
|
|
463
|
+
(job) =>
|
|
464
|
+
job.repoKey === repoKey &&
|
|
465
|
+
job.integrationBranch === integrationBranch &&
|
|
466
|
+
job.state === "queued",
|
|
467
|
+
)
|
|
468
|
+
.sort((a, b) => orderIndexOf(a) - orderIndexOf(b));
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Next queue slot for one repo+integration branch: max orderIndex across
|
|
473
|
+
* that branch's jobs (any state) + 1. With no jobs the first slot is 0;
|
|
474
|
+
* successive calls without deletions are strictly monotonic.
|
|
475
|
+
*
|
|
476
|
+
* @param {string} repoKey
|
|
477
|
+
* @param {string} integrationBranch
|
|
478
|
+
* @returns {number}
|
|
479
|
+
*/
|
|
480
|
+
function nextOrderIndex(repoKey, integrationBranch) {
|
|
481
|
+
let max = -1;
|
|
482
|
+
for (const job of Object.values(state.jobs)) {
|
|
483
|
+
if (job.repoKey !== repoKey || job.integrationBranch !== integrationBranch) continue;
|
|
484
|
+
const n = Number(job.orderIndex);
|
|
485
|
+
if (Number.isFinite(n) && n > max) max = n;
|
|
486
|
+
}
|
|
487
|
+
return max + 1;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
return {
|
|
491
|
+
load,
|
|
492
|
+
persist,
|
|
493
|
+
upsertWorktree,
|
|
494
|
+
upsertJob,
|
|
495
|
+
pruneTerminalJobs,
|
|
496
|
+
findActiveJob,
|
|
497
|
+
findQueuedJobs,
|
|
498
|
+
nextOrderIndex,
|
|
499
|
+
// Live internal maps (read-mostly contract — see the file header). The
|
|
500
|
+
// getters re-read `state` on every access, so a load() rebinding is
|
|
501
|
+
// always reflected.
|
|
502
|
+
get worktrees() {
|
|
503
|
+
return state.worktrees;
|
|
504
|
+
},
|
|
505
|
+
get jobs() {
|
|
506
|
+
return state.jobs;
|
|
507
|
+
},
|
|
508
|
+
get repos() {
|
|
509
|
+
return state.repos;
|
|
510
|
+
},
|
|
511
|
+
};
|
|
512
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `worktree_cleanup` tool (T09, DESIGN §5.8).
|
|
3
|
+
*
|
|
4
|
+
* Thin tool layer: parameters schema, worktree_id-or-path input resolution,
|
|
5
|
+
* and a projection of the WorktreeService.cleanup result into the §5.8
|
|
6
|
+
* return shape. The double-confirmation protection (force && acknowledge,
|
|
7
|
+
* two INDEPENDENT booleans) lives in the engine; `cleanup_protected`
|
|
8
|
+
* errors re-throw verbatim so the surfaced message keeps its evidence
|
|
9
|
+
* (sourceHead / integration head / the force+acknowledge usage hint).
|
|
10
|
+
*
|
|
11
|
+
* deps contract:
|
|
12
|
+
* - service (required) createWorktreeService() product;
|
|
13
|
+
* - cfg (optional) reserved for T10 wiring parity (unused today).
|
|
14
|
+
*
|
|
15
|
+
* json-safe discipline (E3): the vanished-path `note` is a conditional
|
|
16
|
+
* spread; no key ever carries an `undefined` value.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
20
|
+
|
|
21
|
+
/** Registered tool name (DESIGN §5.8). */
|
|
22
|
+
const TOOL_NAME = 'worktree_cleanup'
|
|
23
|
+
|
|
24
|
+
/** A message already starting with a stable snake_case code stays verbatim. */
|
|
25
|
+
const CODED_MESSAGE = /^[a-z][a-z0-9_]*(\s*—|:)/
|
|
26
|
+
|
|
27
|
+
/** Coded engine errors pass through; uncoded ones get the tool prefix. */
|
|
28
|
+
function surfaceError(error) {
|
|
29
|
+
const message = String(error instanceof Error ? error.message : error)
|
|
30
|
+
if (CODED_MESSAGE.test(message)) throw error
|
|
31
|
+
throw new Error(`${TOOL_NAME}: ${message}`)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Register the `worktree_cleanup` tool (DESIGN §5.8).
|
|
36
|
+
*
|
|
37
|
+
* @param {Object} ctx host ctx (needs ctx.tools.register)
|
|
38
|
+
* @param {Object} deps see the module header for the contract
|
|
39
|
+
*/
|
|
40
|
+
export function registerWorktreeCleanupTool(ctx, deps = {}) {
|
|
41
|
+
const { service } = deps
|
|
42
|
+
if (!service || typeof service.cleanup !== 'function') {
|
|
43
|
+
throw new Error(
|
|
44
|
+
`${TOOL_NAME}: deps.service must be a WorktreeService (createWorktreeService product)`,
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
ctx.tools.register(defineTool({
|
|
49
|
+
name: TOOL_NAME,
|
|
50
|
+
description:
|
|
51
|
+
'Remove one managed worktree and — unless keep_branch — its task branch, or prune a vanished record. '
|
|
52
|
+
+ 'PROTECTION: a worktree whose work is NOT yet integrated is only removed with the DOUBLE confirmation force:true AND acknowledge:true (two independent booleans — a single force is not enough); otherwise cleanup_protected is raised with the evidence. '
|
|
53
|
+
+ 'Unintegrated means EITHER the task branch head has not reached the integration branch, OR the integration branch does not exist yet (no merge has run) while the worktree holds commits past its base — both are unmerged work. '
|
|
54
|
+
+ 'A merged worktree (worktree_merge succeeded) needs no force. Use worktree_list first to pick the record.',
|
|
55
|
+
parameters: {
|
|
56
|
+
worktree_id: {
|
|
57
|
+
type: 'string',
|
|
58
|
+
description: 'Worktree id (alternative: path).',
|
|
59
|
+
},
|
|
60
|
+
path: {
|
|
61
|
+
type: 'string',
|
|
62
|
+
description: 'Absolute worktree path (alternative: worktree_id).',
|
|
63
|
+
},
|
|
64
|
+
force: {
|
|
65
|
+
type: 'boolean',
|
|
66
|
+
description:
|
|
67
|
+
'Also remove a worktree whose branch is NOT yet integrated (default false → protected).',
|
|
68
|
+
},
|
|
69
|
+
acknowledge: {
|
|
70
|
+
type: 'boolean',
|
|
71
|
+
description:
|
|
72
|
+
'Second confirmation gate: must be true together with force to delete unmerged work. Never implied.',
|
|
73
|
+
},
|
|
74
|
+
keep_branch: {
|
|
75
|
+
type: 'boolean',
|
|
76
|
+
description: 'Remove the worktree directory but keep the branch (default false).',
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
output: {
|
|
80
|
+
schema: {
|
|
81
|
+
type: 'object',
|
|
82
|
+
additionalProperties: false,
|
|
83
|
+
properties: {
|
|
84
|
+
kind: { type: 'string', required: true, const: 'cleanup' },
|
|
85
|
+
id: { type: 'string', required: true },
|
|
86
|
+
removed_worktree: { type: 'boolean', required: true },
|
|
87
|
+
removed_branch: { type: 'boolean', required: true },
|
|
88
|
+
note: { type: 'string' },
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
render: (_args, value) => [{
|
|
92
|
+
type: 'text',
|
|
93
|
+
text: `cleanup ${value.id}: worktree=${value.removed_worktree ? 'removed' : 'absent'} branch=${value.removed_branch ? 'removed' : 'kept'}${value.note ? ` (${value.note})` : ''}`,
|
|
94
|
+
}],
|
|
95
|
+
},
|
|
96
|
+
// Deletes shared state (record + git objects) behind a check-then-act
|
|
97
|
+
// protection gate; sibling calls must never interleave.
|
|
98
|
+
isConcurrencySafe: () => false,
|
|
99
|
+
async execute(args) {
|
|
100
|
+
try {
|
|
101
|
+
const idOrPath = args.worktree_id !== undefined ? args.worktree_id : args.path
|
|
102
|
+
if (idOrPath === undefined || idOrPath === '') {
|
|
103
|
+
throw new Error(
|
|
104
|
+
`${TOOL_NAME}: worktree_id_or_path_missing — pass worktree_id (from worktree_create) or the absolute worktree path`,
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const result = await service.cleanup({
|
|
109
|
+
idOrPath,
|
|
110
|
+
...(args.force !== undefined ? { force: args.force } : {}),
|
|
111
|
+
...(args.acknowledge !== undefined ? { acknowledge: args.acknowledge } : {}),
|
|
112
|
+
...(args.keep_branch !== undefined ? { keepBranch: args.keep_branch } : {}),
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
kind: 'cleanup',
|
|
117
|
+
id: result.id,
|
|
118
|
+
removed_worktree: result.removedWorktree,
|
|
119
|
+
removed_branch: result.removedBranch,
|
|
120
|
+
...(typeof result.note === 'string' ? { note: result.note } : {}),
|
|
121
|
+
}
|
|
122
|
+
} catch (error) {
|
|
123
|
+
surfaceError(error)
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
}))
|
|
127
|
+
}
|