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/lib/index.js ADDED
@@ -0,0 +1,402 @@
1
+ /**
2
+ * dsh-worktrees — apply() assembly (T10, DESIGN §3 / §7.3 / §8.1 / §8.4).
3
+ *
4
+ * Single instance: git port / StateStore / WorktreeService / MergeQueue are
5
+ * created exactly once inside the apply() closure (red line: shared state is
6
+ * held by the single instance — the same discipline as the engine layer's
7
+ * "no host service imports" seam for a future dag-orchestrator, §10).
8
+ *
9
+ * apply() order (DESIGN §3 diagram + §7.3 "before tool registration"):
10
+ *
11
+ * a. validateConfig (lib/config.js zod strict; unknown keys fail loud);
12
+ * b. assertSingleDshToolsInstance — the @deepseek-ai/dsh-tools dual-
13
+ * instance self-check (§8.4, pattern carried over verbatim from
14
+ * dsh-plugin-subagents lib/index.js): the host ToolRuntime carries its
15
+ * scheduler under the dsh-tools module-level TOOL_RUNTIME_SCHEDULER
16
+ * Symbol; a second physical copy of the package makes
17
+ * `ctx.tools[Symbol]` undefined and every tool call dies with
18
+ * "Cannot read properties of undefined (reading 'prepare')" — fatal at
19
+ * apply time, with the setup:peer re-link hint;
20
+ * c. createGitPort (timeoutMs = gitTimeoutMs) + createStateStore +
21
+ * store.load() (a corrupt state file aborts the boot loudly);
22
+ * d. reconcile — the crash reconciliation (§7.3), BEFORE any tool is
23
+ * registered, so the first model-visible call already sees a truthful
24
+ * state: per repo — unreachable repo ⇒ `orphaned: true` MARKER on its
25
+ * worktree records (state kept verbatim — orphaned is a §5.4 flag, not
26
+ * a §5.2.2 state) + applying jobs failed; reachable repo ⇒ records
27
+ * whose path left `git worktree list` become `vanished`, applying jobs
28
+ * become failed (their still-live integration worktree retained for
29
+ * inspection), conflicted jobs survive verbatim, and orphan
30
+ * `.integration/` worktrees are best-effort removed;
31
+ * e. createWorktreeService + createMergeQueue over the SAME store/git,
32
+ * then provide the §10 `worktreesEngine` service face (the DAG
33
+ * composition seam) over those same singletons;
34
+ * f. the §5.2.0 gate prebound once (workspaceRegistry probed OPTIONALLY —
35
+ * it is not in `inject`, so headless hosts degrade to the session-cwd
36
+ * + allowedRoots arms) and shared by all six tools;
37
+ * g. register the six tools per the register switches (default all on).
38
+ *
39
+ * Return value: ALWAYS undefined — the loader treats the plugin callback's
40
+ * return as a disposable (a non-nullable non-function fails real boots with
41
+ * "TypeError: Invalid effect"). Introspection goes through the registered
42
+ * tools (fake ctx) or the internal modules, never the return value.
43
+ */
44
+
45
+ import { TOOL_RUNTIME_SCHEDULER } from '@deepseek-ai/dsh-tools'
46
+ import path from 'node:path'
47
+ import { realpathSync } from 'node:fs'
48
+ import { validateConfig } from './config.js'
49
+ import { createGitPort } from './git-port.js'
50
+ import { createStateStore, ACTIVE_JOB_STATES } from './state-store.js'
51
+ import { createWorktreeService } from './worktree-service.js'
52
+ import { createMergeQueue } from './merge-queue.js'
53
+ import { createEngineFace } from './engine-face.js'
54
+ import { resolveRepoRoot } from './repo-gate.js'
55
+ import { registerWorktreeCreateTool } from './tools/worktree-create.js'
56
+ import { registerWorktreeListTool } from './tools/worktree-list.js'
57
+ import { registerWorktreeStatusTool } from './tools/worktree-status.js'
58
+ import { registerWorktreeMergeTool } from './tools/worktree-merge.js'
59
+ import { registerWorktreeQueueTool } from './tools/worktree-queue.js'
60
+ import { registerWorktreeCleanupTool } from './tools/worktree-cleanup.js'
61
+
62
+ export const name = 'dsh-worktrees'
63
+
64
+ // workspaceRegistry is used through OPTIONAL probing (§8.2 peer-face
65
+ // minimisation: headless hosts without a workspace registry still boot) —
66
+ // only `tools` is a hard injection.
67
+ export const inject = ['tools']
68
+
69
+ /** Error message of an unknown throw. */
70
+ function errorText(error) {
71
+ return error instanceof Error ? error.message : String(error ?? '')
72
+ }
73
+
74
+ /** realpath() that yields null instead of throwing (missing-path probe). */
75
+ function realPathBestEffort(target) {
76
+ try {
77
+ return realpathSync(target)
78
+ } catch {
79
+ return null
80
+ }
81
+ }
82
+
83
+ /**
84
+ * dsh-tools dual-instance self-check (§8.4; the verbatim probe pattern from
85
+ * dsh-plugin-subagents lib/index.js):
86
+ *
87
+ * 1. `ctx.tools[TOOL_RUNTIME_SCHEDULER] !== undefined` → same physical
88
+ * module — healthy;
89
+ * 2. Symbol absent but ctx.tools looks like a real ToolRuntime
90
+ * (view + schemas) → a true second copy: logger.fatal + throw (apply
91
+ * failing beats every tool call dying after load), pointing at the
92
+ * peer re-link (npm run setup:peer);
93
+ * 3. anything else (fake ctx / an unseen host shape) → cannot reliably
94
+ * judge; warn only. Tests and non-standard hosts must not be killed by
95
+ * a false positive.
96
+ */
97
+ function assertSingleDshToolsInstance(ctx) {
98
+ const tools = ctx && ctx.tools
99
+ if (!tools || typeof tools.register !== 'function') return
100
+ if (tools[TOOL_RUNTIME_SCHEDULER] !== undefined) return // same physical module — healthy
101
+ const looksLikeToolRuntime =
102
+ typeof tools.view === 'function' && typeof tools.schemas === 'function'
103
+ if (looksLikeToolRuntime) {
104
+ const detail =
105
+ 'dsh-worktrees: detected a second @deepseek-ai/dsh-tools module instance — '
106
+ + "every tool call from this plugin would die with \"Cannot read properties of undefined (reading 'prepare')\". "
107
+ + 'Run npm run setup:peer (scripts/link-harness-dsh-tools.sh) in the dsh-worktrees package '
108
+ + 'so its dsh-tools copy resolves to the live harness root, then restart dsh.'
109
+ if (ctx.logger && typeof ctx.logger.fatal === 'function') ctx.logger.fatal(detail)
110
+ throw new Error(detail)
111
+ }
112
+ if (ctx.logger && typeof ctx.logger.warn === 'function') {
113
+ ctx.logger.warn(
114
+ 'dsh-worktrees: could not reliably verify the @deepseek-ai/dsh-tools single-instance invariant '
115
+ + 'from this ctx (no scheduler symbol, no ToolRuntime shape) — run npm run setup:peer to check the dedupe link',
116
+ )
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Probe the OPTIONAL workspace registry (§5.2.0 gate arm b): absent host
122
+ * face, non-array result, or a throwing list() all degrade to [] (the gate
123
+ * then runs on the session-cwd + allowedRoots arms alone).
124
+ */
125
+ function probeWorkspacePaths(ctx) {
126
+ try {
127
+ const list = ctx && ctx.workspaceRegistry ? ctx.workspaceRegistry.list : undefined
128
+ if (typeof list !== 'function') return []
129
+ const entries = list.call(ctx.workspaceRegistry)
130
+ if (!Array.isArray(entries)) return []
131
+ return entries
132
+ .map((entry) => (entry && typeof entry === 'object' ? entry.path : undefined))
133
+ .filter((p) => typeof p === 'string' && p.length > 0)
134
+ } catch {
135
+ return []
136
+ }
137
+ }
138
+
139
+ /** True when a stored path matches the live set (raw OR realpath form). */
140
+ function pathInLiveSet(storedPath, liveSet) {
141
+ if (liveSet.has(storedPath)) return true
142
+ const real = realPathBestEffort(storedPath)
143
+ return real !== null && liveSet.has(real)
144
+ }
145
+
146
+ /**
147
+ * Crash reconciliation for ONE repo (DESIGN §7.3, step by step).
148
+ *
149
+ * Marking only — the ONE destructive action is the best-effort removal of
150
+ * orphan integration worktrees; everything destructive stays behind the
151
+ * explicit worktree_cleanup tool.
152
+ *
153
+ * @param {object} args { ctx, cfg, git, store, repoKey, repoRoot }
154
+ */
155
+ async function reconcileRepo({ ctx, cfg, git, store, repoKey, repoRoot }) {
156
+ const recordsOfRepo = () =>
157
+ Object.values(store.worktrees).filter((record) => record.repoKey === repoKey)
158
+ const jobsOfRepo = () => Object.values(store.jobs).filter((job) => job.repoKey === repoKey)
159
+
160
+ // §7.3 branch 1 — repo unreachable (deleted / unmounted / not a git repo):
161
+ // every worktree record gets the `orphaned: true` MARKER with its state
162
+ // kept verbatim (orphaned is the §5.4 flag, not a §5.2.2 state — writing
163
+ // it into `state` would break the state machine), and applying jobs fail
164
+ // with the restart provenance.
165
+ if (!(await git.isGitRepo(repoRoot))) {
166
+ for (const record of recordsOfRepo()) {
167
+ if (record.orphaned === true) continue
168
+ store.upsertWorktree({ ...record, orphaned: true })
169
+ }
170
+ for (const job of jobsOfRepo()) {
171
+ if (job.state !== 'applying') continue
172
+ store.upsertJob({ ...job, state: 'failed', error: 'host restart; repo unreachable' })
173
+ }
174
+ return
175
+ }
176
+
177
+ // §7.3 branch 2 — repo reachable: reconcile against the LIVE worktree
178
+ // list (git-port returns the parsed entries already).
179
+ const live = await git.listWorktrees(repoRoot)
180
+ const livePaths = new Set(live.map((entry) => entry.path))
181
+
182
+ // (a) vanished: record present, scene gone (manual prune / disk cleared).
183
+ // A record that is live again sheds a stale persisted orphaned marker
184
+ // (the marker would otherwise leak through every later list projection).
185
+ for (const record of recordsOfRepo()) {
186
+ const liveNow = pathInLiveSet(record.path, livePaths)
187
+ if (!liveNow) {
188
+ if (record.state !== 'vanished') {
189
+ store.upsertWorktree({ ...record, state: 'vanished' })
190
+ }
191
+ } else if (record.orphaned !== undefined) {
192
+ const { orphaned, ...rest } = record
193
+ void orphaned
194
+ store.upsertWorktree({ ...rest, state: rest.state })
195
+ }
196
+ }
197
+
198
+ // (b) applying jobs: the apply died with the host. Failed + provenance;
199
+ // a still-live integration worktree is RETAINED and annotated (§6.3's
200
+ // write-ahead means the record already points at the partial scene — the
201
+ // operator may inspect it). The task record mirrors merging → active,
202
+ // the same transition the engine's own failure path makes (markFailedOutcome).
203
+ const retainedForInspection = new Set()
204
+ for (const job of jobsOfRepo()) {
205
+ if (job.state !== 'applying') continue
206
+ let error =
207
+ 'host restarted during apply — the recorded integrationWorktree may hold a partial state'
208
+ if (
209
+ typeof job.integrationWorktree === 'string'
210
+ && job.integrationWorktree.length > 0
211
+ && pathInLiveSet(job.integrationWorktree, livePaths)
212
+ ) {
213
+ retainedForInspection.add(
214
+ realPathBestEffort(job.integrationWorktree) ?? job.integrationWorktree,
215
+ )
216
+ error += `; the integration worktree is retained at ${job.integrationWorktree} for inspection`
217
+ }
218
+ store.upsertJob({ ...job, state: 'failed', error })
219
+ const taskRecord = job.worktreeId ? store.worktrees[job.worktreeId] : undefined
220
+ if (taskRecord !== undefined && taskRecord.repoKey === repoKey && taskRecord.state === 'merging') {
221
+ store.upsertWorktree({ ...taskRecord, state: 'active' })
222
+ }
223
+ }
224
+
225
+ // (c) conflicted jobs survive verbatim — the retained scene IS the job's
226
+ // state (source semantics); nothing to do, stated explicitly.
227
+
228
+ // (d) orphan integration worktrees: live paths inside OUR
229
+ // `<worktreeRoot>/<repoKey>/.integration` namespace (realpath-compared —
230
+ // git reports realpaths while worktreeRoot may be a symlinked prefix)
231
+ // that no non-terminal job references. Best-effort removal, failures
232
+ // swallowed. Worktrees referenced by a job failed in THIS pass are
233
+ // protected: the error text just promised the operator an inspection
234
+ // scene — deleting it in the same breath would falsify it. On a later
235
+ // run (no applying jobs) such a path is a plain orphan and is reclaimed,
236
+ // mirroring the engine's releaseStaleIntegrationHolds (only live
237
+ // conflicted scenes persist indefinitely).
238
+ const namespaceReal = realPathBestEffort(path.join(cfg.worktreeRoot, repoKey, '.integration'))
239
+ if (namespaceReal !== null) {
240
+ const referenced = new Set(retainedForInspection)
241
+ for (const job of jobsOfRepo()) {
242
+ if (!ACTIVE_JOB_STATES.has(job.state)) continue
243
+ if (typeof job.integrationWorktree === 'string' && job.integrationWorktree.length > 0) {
244
+ referenced.add(realPathBestEffort(job.integrationWorktree) ?? job.integrationWorktree)
245
+ }
246
+ }
247
+ for (const entry of live) {
248
+ if (path.dirname(entry.path) !== namespaceReal) continue
249
+ if (referenced.has(entry.path)) continue
250
+ await git.removeWorktree(repoRoot, entry.path).catch(() => {})
251
+ }
252
+ }
253
+ }
254
+
255
+ /**
256
+ * The crash reconciliation pass (§7.3): per-repo reconcile with per-repo
257
+ * isolation — one broken repo (a probe failure, a git error) logs a warning
258
+ * and never aborts the whole apply. One persist at the end flushes every
259
+ * marking this pass made.
260
+ */
261
+ async function reconcile({ ctx, cfg, git, store }) {
262
+ for (const [repoKey, repo] of Object.entries(store.repos)) {
263
+ const repoRoot = repo && typeof repo.root === 'string' ? repo.root : ''
264
+ if (repoRoot === '') {
265
+ ctx.logger?.warn?.(`dsh-worktrees: reconcile skipped for repo ${repoKey} (no root recorded)`)
266
+ continue
267
+ }
268
+ try {
269
+ await reconcileRepo({ ctx, cfg, git, store, repoKey, repoRoot })
270
+ } catch (error) {
271
+ ctx.logger?.warn?.(
272
+ `dsh-worktrees: reconcile skipped for repo ${repoKey} (${repoRoot}): ${errorText(error)}`,
273
+ )
274
+ }
275
+ }
276
+ store.persist()
277
+ }
278
+
279
+ /**
280
+ * Plugin entry (T10). See the module header for the assembly order.
281
+ *
282
+ * @param {Object} ctx Cordis ctx (needs ctx.tools.register; logger +
283
+ * workspaceRegistry are optional faces)
284
+ * @param {Object} [config] raw plugin config (validated here; zod strict)
285
+ * @returns {Promise<undefined>} always undefined — the loader treats the
286
+ * plugin callback's return value as a disposable
287
+ */
288
+ export async function apply(ctx, config = {}) {
289
+ // a. config (zod strict, defaults resolved — lib/config.js)
290
+ const cfg = validateConfig(config)
291
+
292
+ // b. peer dual-instance defence (§8.4)
293
+ assertSingleDshToolsInstance(ctx)
294
+
295
+ // c. engine singletons — created exactly once per apply() closure
296
+ const git = createGitPort({ timeoutMs: cfg.gitTimeoutMs })
297
+ const store = createStateStore({ path: cfg.statePath })
298
+ store.load() // loud on a corrupt state file: a bad boot beats silent garbage
299
+
300
+ // d. crash reconciliation BEFORE any tool registration (§7.3)
301
+ await reconcile({ ctx, cfg, git, store })
302
+
303
+ // e. shared engines over the same store/git singletons. The queue's
304
+ // swallowed background-drain failures surface through the host logger.
305
+ const service = createWorktreeService({ git, store, config: cfg })
306
+ const queue = createMergeQueue({
307
+ git,
308
+ store,
309
+ config: {
310
+ ...cfg,
311
+ onError: (message) => {
312
+ try {
313
+ ctx.logger?.warn?.(message)
314
+ } catch {
315
+ /* logging must never throw */
316
+ }
317
+ },
318
+ },
319
+ })
320
+
321
+ // e2. the §10 composition seam: provide `worktreesEngine` on the ctx so
322
+ // dsh-dag-orchestrator's opportunistic `ctx.get('worktreesEngine')`
323
+ // probe finds the SAME service/queue singletons the tools use (red
324
+ // line 10: no second write entrance). The face adapts the DAG's
325
+ // four-key enqueue / five-state DrainOutcome dialect on top of the
326
+ // live engines (lib/engine-face.js) — the tool-layer contracts stay
327
+ // untouched. provide() is probed: a host ctx without the service
328
+ // face (fakes) degrades silently.
329
+ try {
330
+ if (ctx && typeof ctx.provide === 'function') {
331
+ ctx.provide(
332
+ 'worktreesEngine',
333
+ createEngineFace({ service, queue, store, git, engineSessionId: 'dsh-dag-engine' }),
334
+ )
335
+ }
336
+ } catch (error) {
337
+ ctx.logger?.warn?.(
338
+ `dsh-worktrees: could not provide the worktreesEngine service face: ${errorText(error)}`,
339
+ )
340
+ }
341
+
342
+ // f. the §5.2.0 gate, prebound ONCE and shared by every tool. The tools
343
+ // pass { repoArg, sessionCwd } (sessionCwd extracted from their exec);
344
+ // workspacePaths comes from the OPTIONAL registry probe. With
345
+ // requireWorkspaceRegistration === false the workspace arm is disabled —
346
+ // an empty list can never admit, which is exactly "skip that tier" (the
347
+ // session-cwd and allowedRoots arms still apply; fail-closed default).
348
+ const workspacePaths = probeWorkspacePaths(ctx)
349
+ const gatedWorkspacePaths = cfg.requireWorkspaceRegistration === false ? [] : workspacePaths
350
+ const resolveRepo = (opts) =>
351
+ resolveRepoRoot({
352
+ repoArg: opts.repoArg,
353
+ sessionCwd: opts.sessionCwd,
354
+ workspacePaths: gatedWorkspacePaths,
355
+ allowedRoots: cfg.allowedRoots,
356
+ git,
357
+ })
358
+
359
+ // g. register the six tools per the register switches (default all on).
360
+ // deps shape per each tool's own contract (service/queue/store +
361
+ // resolveRepo; the git+cfg fallback stays available for parity).
362
+ const shared = {
363
+ service,
364
+ queue,
365
+ store,
366
+ git,
367
+ cfg: { workspacePaths: gatedWorkspacePaths, allowedRoots: cfg.allowedRoots },
368
+ resolveRepo,
369
+ }
370
+ let registered = 0
371
+ if (cfg.register.create) {
372
+ registerWorktreeCreateTool(ctx, shared)
373
+ registered += 1
374
+ }
375
+ if (cfg.register.list) {
376
+ registerWorktreeListTool(ctx, shared)
377
+ registered += 1
378
+ }
379
+ if (cfg.register.status) {
380
+ registerWorktreeStatusTool(ctx, shared)
381
+ registered += 1
382
+ }
383
+ if (cfg.register.merge) {
384
+ registerWorktreeMergeTool(ctx, shared)
385
+ registered += 1
386
+ }
387
+ if (cfg.register.queue) {
388
+ registerWorktreeQueueTool(ctx, shared)
389
+ registered += 1
390
+ }
391
+ if (cfg.register.cleanup) {
392
+ registerWorktreeCleanupTool(ctx, shared)
393
+ registered += 1
394
+ }
395
+
396
+ ctx.logger?.info?.(
397
+ `dsh-worktrees: applied (${registered} tool${registered === 1 ? '' : 's'}; `
398
+ + `state ${cfg.statePath}; worktree root ${cfg.worktreeRoot})`,
399
+ )
400
+
401
+ return undefined
402
+ }