weir-harness 1.2.0 → 1.2.2
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/cordis.patch.yml +0 -2
- package/lib/client.blackboard-model.js +172 -56
- package/lib/client.capability-manager.js +0 -1
- package/lib/client.capability-model.js +378 -31
- package/lib/client.chain-model.js +80 -0
- package/lib/client.disabled-categories-editor.js +22 -0
- package/lib/client.edit-lock-maintenance.js +239 -246
- package/lib/client.edit-lock-panel.js +110 -0
- package/lib/client.hash-edit-model.js +48 -0
- package/lib/client.js +267 -1549
- package/lib/client.lsp-model.js +92 -0
- package/lib/client.lsp-panel.js +11 -16
- package/lib/client.lsp-toggle.js +18 -0
- package/lib/client.notify-permissions.js +112 -20
- package/lib/client.notify-web.js +14 -11
- package/lib/client.robash-model.js +26 -0
- package/lib/client.settings-page.js +287 -17
- package/lib/client.worktree-model.js +225 -0
- package/package.json +5 -2
- package/src/blackboard/durable.js +12 -0
- package/src/blackboard/index.js +2 -2
- package/src/blackboard/kernel.js +23 -6
- package/src/blackboard/notify.js +10 -3
- package/src/blackboard/remote.js +31 -13
- package/src/blackboard/storage.js +13 -5
- package/src/capabilities/apply-engine.js +189 -101
- package/src/capabilities/defaults-transaction.js +136 -1
- package/src/capabilities/lifecycle-snapshot.js +83 -204
- package/src/capabilities/mcp-admission.js +1 -23
- package/src/capabilities/mcp-drain.js +30 -19
- package/src/capabilities/mcp-facade.js +23 -3
- package/src/capabilities/preset-library.js +247 -0
- package/src/capabilities/preset-package.js +212 -1
- package/src/capabilities/same-name-policy.js +3 -41
- package/src/capabilities/selection-notify.js +324 -21
- package/src/capabilities/skill-office-adapter.js +7 -1
- package/src/capabilities/skill-selection-plugin.js +315 -729
- package/src/capabilities/store/checked-contract.d.ts +5 -0
- package/src/capabilities/store/fs-adapter.js +52 -0
- package/src/capabilities/store/lock.js +86 -0
- package/src/capabilities/store/paths.js +5 -0
- package/src/capabilities/store/store.js +105 -5
- package/src/creative-guide/guide.js +1 -1
- package/src/delegate/child-composition.js +258 -0
- package/src/delegate/group-coordinator.js +25 -27
- package/src/delegate/index.js +21 -2
- package/src/delegate/rehydrate.js +24 -4
- package/src/delegate/robash-guard-core.js +2 -2
- package/src/delegate/spawn-adapter.js +214 -100
- package/src/delegate/status-tool.js +9 -6
- package/src/delegate/supervision-mount.js +35 -6
- package/src/delegate/tool.js +29 -4
- package/src/{shared → delegate}/whitelist-defaults.js +2 -2
- package/src/edit-lock/admin-ledger.js +8 -11
- package/src/edit-lock/admin-recovery.js +2 -1
- package/src/edit-lock/admission.js +12 -2
- package/src/edit-lock/binding-guidance.js +28 -10
- package/src/edit-lock/binding-machine.js +312 -0
- package/src/edit-lock/derived-facts.js +67 -0
- package/src/edit-lock/index.js +44 -244
- package/src/edit-lock/inspect.js +113 -1
- package/src/edit-lock/lifecycle.js +8 -5
- package/src/edit-lock/limits.js +61 -0
- package/src/edit-lock/maintenance.js +5 -2
- package/src/edit-lock/manager.js +6 -2
- package/src/edit-lock/operation-history.js +1 -1
- package/src/edit-lock/peer-client.js +9 -2
- package/src/edit-lock/peer-transport.js +7 -1
- package/src/edit-lock/remote.js +11 -4
- package/src/edit-lock/state.js +2 -4
- package/src/edit-lock/view.js +10 -11
- package/src/lsp/admin.js +2 -24
- package/src/lsp/index.js +1 -1
- package/src/lsp/manager.js +1 -1
- package/src/notify/permissions-admin.js +1 -1
- package/src/{lsp/child-process.js → process/index.js} +23 -6
- package/src/settings/env-admin.js +1 -1
- package/src/settings/index.js +1 -1
- package/src/settings/sections.js +0 -54
- package/src/shared/audit.js +12 -0
- package/src/shared/child-scope.js +3 -1
- package/src/shared/http-json.js +28 -0
- package/src/{delegate/audit-readers.js → shared/terminal-evidence.js} +34 -4
- package/src/worktree/errors.js +3 -0
- package/src/worktree/index.js +1 -2
- package/src/worktree/lane-core.js +641 -0
- package/src/worktree/lane-views.js +147 -0
- package/src/worktree/lane-workflows.js +853 -0
- package/src/worktree/lanes.js +24 -1513
- package/src/worktree/runner.js +5 -4
- package/src/capabilities/content-pin.js +0 -88
- package/src/capabilities/content-refresh.js +0 -331
- package/src/capabilities/distribution-manifest.js +0 -78
- package/src/capabilities/isolated-exec.js +0 -106
- package/src/capabilities/publisher.js +0 -129
- package/src/capabilities/skill-admission.js +0 -181
- package/src/capabilities/update-lifecycle.js +0 -106
- package/src/edit-lock/remote-service.js +0 -28
- package/src/worktree/contract-guidance.test.js +0 -100
|
@@ -0,0 +1,641 @@
|
|
|
1
|
+
// Lane core: the ledger + check engine every lane mutation in the product
|
|
2
|
+
// funnels through. Owns the repo-resolution plumbing, this instance's
|
|
3
|
+
// watch-expiry timers, refresh/reconciliation, zombie-binding settlement
|
|
4
|
+
// (reconcileBinding / childSettled), and the single apply() that performs
|
|
5
|
+
// every state-machine transition — one-shot watch hits are collected in the
|
|
6
|
+
// same atomic ledger write (design D3), audit + delivery after the write.
|
|
7
|
+
// Workflows and views build on this module; it never imports them.
|
|
8
|
+
// ctx-free by construction: git, shell, questions, notifications, audit and
|
|
9
|
+
// the session-mode reader are injected (src/worktree/index.js wires them).
|
|
10
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'
|
|
11
|
+
import { dirname, join, sep } from 'node:path'
|
|
12
|
+
import { WORKTREE_CODES, WorktreeError } from './errors.js'
|
|
13
|
+
import { CHECKABLE, LANDABLE_FROM, nextFor, transition } from './state.js'
|
|
14
|
+
import { collectWatchHits, pruneExpiredWatches, removeWatchForExpiry } from './watches.js'
|
|
15
|
+
import { normalizeRoot, parseRepoConfig } from './rules.js'
|
|
16
|
+
import { reconcile } from './reconcile.js'
|
|
17
|
+
import { createLedger } from './ledger.js'
|
|
18
|
+
import { renderNotice, renderWatchExpired, renderWatchHit } from './prompts.js'
|
|
19
|
+
import { authorityResidue } from './authority.js'
|
|
20
|
+
|
|
21
|
+
export const CONFIG_FILE = '.config.json'
|
|
22
|
+
const LOG_TAIL_LINES = 40
|
|
23
|
+
|
|
24
|
+
/** Audit kind per state-machine event (AUDIT_SUBTYPES.worktree). */
|
|
25
|
+
const AUDIT_KIND = Object.freeze({
|
|
26
|
+
'setup-ok': 'setup', 'setup-fail': 'setup', 'setup-retry': 'setup', bind: 'bind', checked: 'checked',
|
|
27
|
+
'check-pass': 'check', 'check-fail': 'check', invalidate: 'invalidate', ask: 'ask', decline: 'decline',
|
|
28
|
+
conflict: 'conflict', land: 'land', keep: 'cleanup', clean: 'cleanup', abandon: 'abandon', missing: 'abandon',
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Comparable form of a path: realpath when it exists, '/'-separated, and
|
|
33
|
+
* case-folded on win32.
|
|
34
|
+
* @param {string} path
|
|
35
|
+
* @param {string} [platform]
|
|
36
|
+
*/
|
|
37
|
+
export function pathKey(path, platform = process.platform) {
|
|
38
|
+
let real = path
|
|
39
|
+
try {
|
|
40
|
+
real = realpathSync.native(path)
|
|
41
|
+
} catch {
|
|
42
|
+
// a path that does not exist yet keeps its lexical form
|
|
43
|
+
let parent = dirname(path)
|
|
44
|
+
const tail = [path.slice(parent.length + 1)]
|
|
45
|
+
while (parent !== dirname(parent)) {
|
|
46
|
+
try {
|
|
47
|
+
real = join(realpathSync.native(parent), ...tail.reverse())
|
|
48
|
+
break
|
|
49
|
+
} catch {
|
|
50
|
+
tail.push(parent.slice(dirname(parent).length + 1))
|
|
51
|
+
parent = dirname(parent)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const slashed = real.split(sep).join('/').replace(/\/+$/, '')
|
|
56
|
+
return platform === 'win32' ? slashed.toLowerCase() : slashed
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @typedef {object} LaneServiceDeps
|
|
61
|
+
* @property {ReturnType<typeof import('./git.js').createGit>} git
|
|
62
|
+
* @property {null | ((request: { command: string, cwd: string, timeoutMs: number, session?: any }) => Promise<{ code: number, output: string, denied: boolean, timedOut: boolean }>)} shellRun
|
|
63
|
+
* @property {() => { enabled: boolean, root: string, maxActive: number, autoSetup: boolean }} settings
|
|
64
|
+
* @property {null | ((agent: any, questions: any[], signal?: AbortSignal) => Promise<{ answers: Array<{ id: string, selected: string[], custom?: string }> }>)} ask
|
|
65
|
+
* @property {(sessionId: string, text: string) => void} notify
|
|
66
|
+
* @property {(type: string, data: any, root: string, sessionId?: string | null) => void} audit
|
|
67
|
+
* @property {(session: any) => boolean} modeOf
|
|
68
|
+
* @property {(session: any) => 'manual' | 'auto-keep' | 'auto-clean'} [approveModeOf] - the session's effective auto-approve mode (projection override ?? global setting ?? module default); absent = manual (no auto paths)
|
|
69
|
+
* @property {(session: any) => 'session' | 'global'} [approveModeSourceOf] - where the effective mode comes from; absent = global
|
|
70
|
+
* @property {() => 'manual' | 'auto-keep' | 'auto-clean'} [approveGlobal] - the resolved global default (module default when unset/invalid); absent = auto-clean
|
|
71
|
+
* @property {(sessionId: string | undefined) => string | undefined} [localeOf] - the GUI language last reported for a session
|
|
72
|
+
* @property {() => number} [now]
|
|
73
|
+
* @property {(handler: () => void, delayMs: number) => any} [setTimer] - watch-expiry scheduler (tests); defaults to an unref'd setTimeout
|
|
74
|
+
* @property {(handle: any) => void} [clearTimer]
|
|
75
|
+
* @property {number} [pid] - this process id (tests)
|
|
76
|
+
* @property {{ warn?: (message: string) => void }} [logger]
|
|
77
|
+
* @property {(root: string, sessionId: string | null | undefined) => ({ operations: number, locks: number } | null)} [authorityResidue] - read-only Edit Lock residue probe feeding the cleanup/abandon warning (tests); defaults to the bounded snapshot read in ./authority.js
|
|
78
|
+
* @property {(boundChild: string, ownerSession: string | null, context: { root: string }) => Promise<{ childAlive: boolean, ownerAlive: boolean, terminalEvidence: { source: string, detail: string } | null }>} [bindingLiveness] - read-only zombie-binding probe (design D2); absent = LANE_BUSY refusals stand unreconciled
|
|
79
|
+
*/
|
|
80
|
+
|
|
81
|
+
/** @param {LaneServiceDeps} deps */
|
|
82
|
+
export function createLaneCore(deps) {
|
|
83
|
+
const { git, settings } = deps
|
|
84
|
+
const now = deps.now ?? Date.now
|
|
85
|
+
/** Effective auto-approve mode for a session, fail-safe to `manual` so a missing dep (tests, older wiring) never auto-approves. @param {any} session */
|
|
86
|
+
const approveModeSafe = (session) => {
|
|
87
|
+
try {
|
|
88
|
+
return deps.approveModeOf?.(session) ?? 'manual'
|
|
89
|
+
} catch {
|
|
90
|
+
return 'manual'
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/** Where the effective mode comes from ('session' override vs 'global' setting). @param {any} session */
|
|
94
|
+
const approveModeSourceSafe = (session) => {
|
|
95
|
+
try {
|
|
96
|
+
return deps.approveModeSourceOf?.(session) ?? 'global'
|
|
97
|
+
} catch {
|
|
98
|
+
return 'global'
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/** @type {Map<string, Promise<any>>} cwd+root → repo */
|
|
102
|
+
const pending = new Map()
|
|
103
|
+
/** @type {Map<string, any>} cwd+root → resolved repo (sync access for the prompt board) */
|
|
104
|
+
const resolved = new Map()
|
|
105
|
+
/** @type {Promise<number[] | null> | null} */
|
|
106
|
+
let versionProbe = null
|
|
107
|
+
/** Lanes whose approval card is open in THIS process. @type {Set<string>} */
|
|
108
|
+
const asking = new Set()
|
|
109
|
+
const pid = deps.pid ?? process.pid
|
|
110
|
+
/** Whether an approval card recorded in the ledger is still open somewhere. @param {any} lane */
|
|
111
|
+
const askAlive = (lane) => {
|
|
112
|
+
const owner = lane.asking?.pid
|
|
113
|
+
if (owner === pid) return asking.has(lane.id)
|
|
114
|
+
if (typeof owner !== 'number') return false
|
|
115
|
+
try {
|
|
116
|
+
process.kill(owner, 0)
|
|
117
|
+
return true
|
|
118
|
+
} catch {
|
|
119
|
+
return false
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ─── watch-expiry timers (this instance only; the ledger owns the truth) ─
|
|
124
|
+
|
|
125
|
+
/** Watch-expiry timers armed by THIS service instance, watch id → handle.
|
|
126
|
+
* The lock arbitrates between instances: whichever instance removes the
|
|
127
|
+
* record under the ledger lock delivers that watch's single expiry notice. */
|
|
128
|
+
const watchTimers = new Map()
|
|
129
|
+
const setTimer = deps.setTimer ?? ((/** @type {() => void} */ handler, /** @type {number} */ delayMs) => {
|
|
130
|
+
const timer = setTimeout(handler, delayMs)
|
|
131
|
+
timer.unref?.()
|
|
132
|
+
return timer
|
|
133
|
+
})
|
|
134
|
+
const clearTimer = deps.clearTimer ?? clearTimeout
|
|
135
|
+
|
|
136
|
+
/** @param {string} watchId */
|
|
137
|
+
function disarmWatch(watchId) {
|
|
138
|
+
const handle = watchTimers.get(watchId)
|
|
139
|
+
if (handle !== undefined) {
|
|
140
|
+
clearTimer(handle)
|
|
141
|
+
watchTimers.delete(watchId)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** @param {any} repo @param {any} entry */
|
|
146
|
+
function armWatch(repo, entry) {
|
|
147
|
+
if (watchTimers.has(entry.id)) return
|
|
148
|
+
const delay = entry.expiresAt - now()
|
|
149
|
+
if (delay <= 0) return
|
|
150
|
+
watchTimers.set(entry.id, setTimer(() => {
|
|
151
|
+
watchTimers.delete(entry.id)
|
|
152
|
+
void expireWatch(repo, entry.id).catch((error) => deps.logger?.warn?.(`worktree: watch expiry failed: ${/** @type {any} */ (error)?.message ?? error}`))
|
|
153
|
+
}, delay))
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* An expiry timer fired: remove the watch under the ledger lock; only the
|
|
158
|
+
* instance that actually removes the record delivers the expiry notice
|
|
159
|
+
* (the watch's single delivery), so two instances can never double-deliver.
|
|
160
|
+
* @param {any} repo @param {string} watchId
|
|
161
|
+
*/
|
|
162
|
+
async function expireWatch(repo, watchId) {
|
|
163
|
+
const removed = await repo.ledger.update((/** @type {any} */ ledger) => {
|
|
164
|
+
const watch = removeWatchForExpiry(ledger, watchId, now())
|
|
165
|
+
return watch ? { ledger, result: watch } : {}
|
|
166
|
+
})
|
|
167
|
+
if (!removed) return
|
|
168
|
+
deps.audit('watch', { lane: removed.laneId, sessionId: removed.sessionId, states: removed.states, outcome: 'expired' }, repo.mainRoot, removed.sessionId)
|
|
169
|
+
try {
|
|
170
|
+
deps.notify(removed.sessionId, renderWatchExpired(removed))
|
|
171
|
+
} catch (error) {
|
|
172
|
+
deps.logger?.warn?.(`worktree: watch-expiry notification failed: ${/** @type {any} */ (error)?.message ?? error}`)
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Ledger lazy-cleanup + per-instance timer arming (design D4). Runs on every
|
|
178
|
+
* refresh — which is also the host-load path: watches whose deadline passed
|
|
179
|
+
* while the host was down are pruned silently + audited (never delivered,
|
|
180
|
+
* never hit-tested); every live watch gets this instance's expiry timer.
|
|
181
|
+
* @param {any} repo
|
|
182
|
+
*/
|
|
183
|
+
async function syncWatches(repo) {
|
|
184
|
+
const pruned = await repo.ledger.update((/** @type {any} */ ledger) => {
|
|
185
|
+
const expired = pruneExpiredWatches(ledger, now())
|
|
186
|
+
return expired.length > 0 ? { ledger, result: expired } : {}
|
|
187
|
+
})
|
|
188
|
+
for (const watch of pruned ?? []) {
|
|
189
|
+
deps.audit('watch', { lane: watch.laneId, sessionId: watch.sessionId, states: watch.states, outcome: 'pruned' }, repo.mainRoot, watch.sessionId)
|
|
190
|
+
}
|
|
191
|
+
for (const entry of repo.ledger.read().watches) armWatch(repo, entry)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const cwdOf = (/** @type {any} */ session) => {
|
|
195
|
+
const cwd = session?.header?.cwd
|
|
196
|
+
if (typeof cwd !== 'string' || !cwd) throw new WorktreeError(WORKTREE_CODES.NOT_A_REPO, 'this session has no working directory')
|
|
197
|
+
return cwd
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function config() {
|
|
201
|
+
const value = settings()
|
|
202
|
+
if (!value.enabled) throw new WorktreeError(WORKTREE_CODES.WORKTREE_DISABLED, 'worktree lanes are disabled (worktreeEnabled is off)')
|
|
203
|
+
return value
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** @param {string} cwd */
|
|
207
|
+
async function repoFor(cwd) {
|
|
208
|
+
const root = normalizeRoot(config().root)
|
|
209
|
+
const key = `${cwd}\0${root}`
|
|
210
|
+
if (!pending.has(key)) {
|
|
211
|
+
const promise = resolveRepo(cwd, root).then((repo) => {
|
|
212
|
+
resolved.set(key, repo)
|
|
213
|
+
return repo
|
|
214
|
+
})
|
|
215
|
+
promise.catch(() => pending.delete(key))
|
|
216
|
+
pending.set(key, promise)
|
|
217
|
+
}
|
|
218
|
+
return pending.get(key)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** @param {string} cwd @param {string} root */
|
|
222
|
+
async function resolveRepo(cwd, root) {
|
|
223
|
+
versionProbe ??= git.version(cwd)
|
|
224
|
+
const version = await versionProbe
|
|
225
|
+
if (!git.supported(version)) {
|
|
226
|
+
versionProbe = null
|
|
227
|
+
throw new WorktreeError(WORKTREE_CODES.GIT_TOO_OLD, version
|
|
228
|
+
? `git ${version.join('.')} is too old; worktree lanes need git 2.38 or newer`
|
|
229
|
+
: `git could not be run (${git.lastVersionError ?? 'unknown error'}); worktree lanes need git 2.38 or newer`)
|
|
230
|
+
}
|
|
231
|
+
const info = await git.repoOf(cwd)
|
|
232
|
+
if (!info) throw new WorktreeError(WORKTREE_CODES.NOT_A_REPO, `${cwd} is not inside a git repository`)
|
|
233
|
+
const mainRoot = realpathSync.native(info.mainRoot)
|
|
234
|
+
const rootPath = join(mainRoot, ...root.split('/'))
|
|
235
|
+
return { cwd, mainRoot, commonDir: info.commonDir, root, rootPath, ledger: createLedger(rootPath, { now }), version, unmanaged: /** @type {string[]} */ ([]) }
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Resolved repo for sync callers (prompt board), or null. @param {any} session */
|
|
239
|
+
function repoCached(session) {
|
|
240
|
+
try {
|
|
241
|
+
const value = settings()
|
|
242
|
+
if (!value.enabled) return null
|
|
243
|
+
const cwd = cwdOf(session)
|
|
244
|
+
const key = `${cwd}\0${normalizeRoot(value.root)}`
|
|
245
|
+
if (!resolved.has(key) && !pending.has(key)) void repoFor(cwd).catch(() => {})
|
|
246
|
+
return resolved.get(key) ?? null
|
|
247
|
+
} catch {
|
|
248
|
+
return null
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ─── ledger helpers ───────────────────────────────────────────────────
|
|
253
|
+
|
|
254
|
+
/** @param {any} repo @param {string} laneId */
|
|
255
|
+
function laneOf(repo, laneId) {
|
|
256
|
+
const lane = repo.ledger.read().lanes.find((/** @type {any} */ entry) => entry.id === laneId)
|
|
257
|
+
if (!lane) throw new WorktreeError(WORKTREE_CODES.UNKNOWN_LANE, `no lane "${laneId}" in ${repo.rootPath}`)
|
|
258
|
+
return lane
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Transition one lane under the ledger lock; `check` runs inside the lock
|
|
263
|
+
* against the current record (throw to refuse).
|
|
264
|
+
* @param {any} repo @param {string} laneId @param {any} event @param {(lane: any, ledger: any) => void} [check]
|
|
265
|
+
*/
|
|
266
|
+
async function apply(repo, laneId, event, check) {
|
|
267
|
+
/** Watches this transition consumed (removed in the same atomic write). @type {any[]} */
|
|
268
|
+
let hits = []
|
|
269
|
+
const lane = await repo.ledger.update((/** @type {any} */ ledger) => {
|
|
270
|
+
const index = ledger.lanes.findIndex((/** @type {any} */ entry) => entry.id === laneId)
|
|
271
|
+
if (index === -1) throw new WorktreeError(WORKTREE_CODES.UNKNOWN_LANE, `no lane "${laneId}"`)
|
|
272
|
+
check?.(ledger.lanes[index], ledger)
|
|
273
|
+
const next = transition(ledger.lanes[index], { at: now(), ...event })
|
|
274
|
+
ledger.lanes[index] = next
|
|
275
|
+
// One-shot watches: a hit is removed in the SAME atomic write as the
|
|
276
|
+
// state transition, so it can never be collected twice (design D3).
|
|
277
|
+
hits = collectWatchHits(ledger, next)
|
|
278
|
+
return { ledger, result: next }
|
|
279
|
+
})
|
|
280
|
+
const kind = AUDIT_KIND[/** @type {keyof typeof AUDIT_KIND} */ (event.type)]
|
|
281
|
+
// `event.audit` carries extra payload for the SAME kind (the auto-approve
|
|
282
|
+
// marker on land/abandon) without a new audit type; transition() ignores
|
|
283
|
+
// the key, so it never leaks into the lane record.
|
|
284
|
+
if (kind) deps.audit(kind, { lane: lane.id, from: lane.history.at(-1)?.from, to: lane.state, event: event.type, reason: event.reason ?? null, by: event.by ?? 'host', ...(event.audit ?? {}) }, repo.mainRoot, lane.ownerSession)
|
|
285
|
+
for (const hit of hits) {
|
|
286
|
+
disarmWatch(hit.id)
|
|
287
|
+
try {
|
|
288
|
+
deps.notify(hit.sessionId, renderWatchHit(lane))
|
|
289
|
+
} catch (error) {
|
|
290
|
+
deps.logger?.warn?.(`worktree: watch-hit notification failed: ${/** @type {any} */ (error)?.message ?? error}`)
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return lane
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Non-state field update (no transition). @param {any} repo @param {string} laneId @param {(lane: any) => any} patch */
|
|
297
|
+
async function patchLane(repo, laneId, patch) {
|
|
298
|
+
return repo.ledger.update((/** @type {any} */ ledger) => {
|
|
299
|
+
const index = ledger.lanes.findIndex((/** @type {any} */ entry) => entry.id === laneId)
|
|
300
|
+
if (index === -1) return {}
|
|
301
|
+
ledger.lanes[index] = { ...ledger.lanes[index], ...patch(ledger.lanes[index]), updatedAt: now() }
|
|
302
|
+
return { ledger, result: ledger.lanes[index] }
|
|
303
|
+
})
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Binding-commit CAS (lane-binding-authoritative-commit D1): the nonce check
|
|
308
|
+
* and the childId swap happen in ONE atomic ledger write — under the same
|
|
309
|
+
* exclusive lock the reservation clearers (force-reclaim's childSettled and
|
|
310
|
+
* reconcile --rebuild's ledger.replace) go through, so a reclaimed
|
|
311
|
+
* reservation can never be committed onto. Refuses LANE_RESERVATION_LOST
|
|
312
|
+
* when the lane no longer holds the nonce or is no longer `working`; the
|
|
313
|
+
* refusal carries the structured facts (state, reservationHeld) and
|
|
314
|
+
* lane-workflows owns the model-facing copy (D2). Deliberately separate
|
|
315
|
+
* from patchLane: a refused commit is a structured refusal, not an empty
|
|
316
|
+
* patch.
|
|
317
|
+
* @param {any} repo @param {string} laneId @param {string} nonce @param {string} childId
|
|
318
|
+
*/
|
|
319
|
+
async function commitBinding(repo, laneId, nonce, childId) {
|
|
320
|
+
return repo.ledger.update((/** @type {any} */ ledger) => {
|
|
321
|
+
const index = ledger.lanes.findIndex((/** @type {any} */ entry) => entry.id === laneId)
|
|
322
|
+
if (index === -1) {
|
|
323
|
+
throw new WorktreeError(WORKTREE_CODES.LANE_RESERVATION_LOST, `lane "${laneId}" no longer exists in ${repo.rootPath}`, {
|
|
324
|
+
lane: laneId,
|
|
325
|
+
data: { state: null, reservationHeld: false },
|
|
326
|
+
})
|
|
327
|
+
}
|
|
328
|
+
const current = ledger.lanes[index]
|
|
329
|
+
if (current.boundChild !== nonce || current.state !== 'working') {
|
|
330
|
+
throw new WorktreeError(WORKTREE_CODES.LANE_RESERVATION_LOST, `lane ${laneId} is ${current.state} and ${current.boundChild === nonce ? 'still holds' : 'no longer holds'} the reservation`, {
|
|
331
|
+
lane: laneId,
|
|
332
|
+
data: { state: current.state, reservationHeld: current.boundChild === nonce },
|
|
333
|
+
})
|
|
334
|
+
}
|
|
335
|
+
ledger.lanes[index] = { ...current, boundChild: childId, updatedAt: now() }
|
|
336
|
+
return { ledger, result: ledger.lanes[index] }
|
|
337
|
+
})
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** @param {any} lane @param {string} [detail] */
|
|
341
|
+
function notifyOwner(lane, detail) {
|
|
342
|
+
if (!lane?.ownerSession) return
|
|
343
|
+
try {
|
|
344
|
+
deps.notify(lane.ownerSession, renderNotice(lane, detail))
|
|
345
|
+
} catch (error) {
|
|
346
|
+
deps.logger?.warn?.(`worktree: notification failed: ${/** @type {any} */ (error)?.message ?? error}`)
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Best-effort Edit Lock authority-residue probe (design D5): does the
|
|
352
|
+
* lane's owner session still own unresolved operations or locks in the
|
|
353
|
+
* management domain? Warning input only — read-only, and a failed check
|
|
354
|
+
* is silent (never blocks the cleanup/abandon transition).
|
|
355
|
+
* @param {any} repo @param {any} lane @returns {{ operations: number, locks: number } | null}
|
|
356
|
+
*/
|
|
357
|
+
function residueOf(repo, lane) {
|
|
358
|
+
try {
|
|
359
|
+
return (deps.authorityResidue ?? authorityResidue)(repo.mainRoot, lane?.ownerSession) ?? null
|
|
360
|
+
} catch (error) {
|
|
361
|
+
deps.logger?.warn?.(`worktree: authority-residue check failed: ${/** @type {any} */ (error)?.message ?? error}`)
|
|
362
|
+
return null
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** @param {any} lane */
|
|
367
|
+
function result(lane, summary, extra = {}) {
|
|
368
|
+
return {
|
|
369
|
+
lane: lane.id, state: lane.state, summary, path: lane.path, branch: lane.branch, next: nextFor(lane),
|
|
370
|
+
...(lane.check?.enabled && Array.isArray(lane.check.results) && lane.check.results.length ? { check: { results: lane.check.results.map((/** @type {any} */ entry) => ({ name: entry.name, exit: entry.exit, ms: entry.ms })) } } : {}),
|
|
371
|
+
...extra,
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** @param {any} repo */
|
|
376
|
+
function readConfig(repo) {
|
|
377
|
+
const file = join(repo.rootPath, CONFIG_FILE)
|
|
378
|
+
if (!existsSync(file)) return parseRepoConfig(undefined)
|
|
379
|
+
let raw
|
|
380
|
+
try {
|
|
381
|
+
raw = JSON.parse(readFileSync(file, 'utf8'))
|
|
382
|
+
} catch (error) {
|
|
383
|
+
throw new Error(`invalid ${file}: ${/** @type {any} */ (error)?.message ?? error}`)
|
|
384
|
+
}
|
|
385
|
+
try {
|
|
386
|
+
return parseRepoConfig(raw)
|
|
387
|
+
} catch (error) {
|
|
388
|
+
throw new Error(`invalid ${file}: ${/** @type {any} */ (error)?.message ?? error}`)
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** @param {any} repo @param {string} laneId @param {string} name */
|
|
393
|
+
function logFile(repo, laneId, name) {
|
|
394
|
+
const dir = join(repo.rootPath, '.logs', laneId)
|
|
395
|
+
mkdirSync(dir, { recursive: true })
|
|
396
|
+
return join(dir, `${name}.log`)
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// ─── reconciliation ───────────────────────────────────────────────────
|
|
400
|
+
|
|
401
|
+
/** @param {any} repo */
|
|
402
|
+
async function refresh(repo) {
|
|
403
|
+
// Watch maintenance first (design D4): prune watches that expired while
|
|
404
|
+
// the host was down (silent + audited) and arm this instance's timers;
|
|
405
|
+
// the reconcile transitions below then hit only live watches.
|
|
406
|
+
await syncWatches(repo)
|
|
407
|
+
const worktrees = await git.worktreeList(repo.mainRoot)
|
|
408
|
+
const ledger = repo.ledger.read()
|
|
409
|
+
const mainBranch = await git.currentBranch(repo.mainRoot)
|
|
410
|
+
/** @type {Map<string, string | null>} */
|
|
411
|
+
const trees = new Map()
|
|
412
|
+
for (const lane of ledger.lanes) {
|
|
413
|
+
if (LANDABLE_FROM.includes(lane.state) && lane.landableTree && existsSync(lane.path)) trees.set(lane.id, await git.tree(lane.path))
|
|
414
|
+
}
|
|
415
|
+
const outcome = reconcile({ lanes: ledger.lanes, worktrees, rootPath: repo.rootPath, mainBranch, trees, normalize: (path) => pathKey(path) })
|
|
416
|
+
for (const event of outcome.events) {
|
|
417
|
+
await apply(repo, event.id, { type: event.type, reason: event.reason }).catch(() => {})
|
|
418
|
+
}
|
|
419
|
+
// An approval card dies with the tool call that raised it (restart,
|
|
420
|
+
// crash): such a lane is declined, never left waiting forever.
|
|
421
|
+
for (const lane of ledger.lanes) {
|
|
422
|
+
if (lane.state === 'awaiting-approval' && !askAlive(lane)) {
|
|
423
|
+
await apply(repo, lane.id, { type: 'decline', reason: 'the approval card was closed before an answer (the tool call ended)' }).catch(() => {})
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
const flagsChanged = ledger.lanes.some((/** @type {any} */ lane) => outcome.baseMoved.has(lane.id) && Boolean(lane.baseMoved) !== outcome.baseMoved.get(lane.id))
|
|
427
|
+
if (flagsChanged) {
|
|
428
|
+
await repo.ledger.update((/** @type {any} */ current) => {
|
|
429
|
+
for (const lane of current.lanes) if (outcome.baseMoved.has(lane.id)) lane.baseMoved = outcome.baseMoved.get(lane.id)
|
|
430
|
+
return { ledger: current }
|
|
431
|
+
})
|
|
432
|
+
}
|
|
433
|
+
if (outcome.unmanaged.length && outcome.unmanaged.join() !== repo.unmanaged.join()) {
|
|
434
|
+
deps.audit('reconcile', { unmanaged: outcome.unmanaged }, repo.mainRoot, null)
|
|
435
|
+
}
|
|
436
|
+
repo.unmanaged = outcome.unmanaged
|
|
437
|
+
return { mainBranch, unmanaged: outcome.unmanaged }
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// ─── binding reconciliation (zombie-lane settlement) ────────────────────
|
|
441
|
+
|
|
442
|
+
/** One-phrase reconciliation outcome for LANE_BUSY messages. @param {any} verdict */
|
|
443
|
+
function reconcileNote(verdict) {
|
|
444
|
+
if (verdict?.childAlive) return 'the bound worker is still live'
|
|
445
|
+
if (verdict?.ownerAlive) return 'the lane owner session is still live'
|
|
446
|
+
return 'no terminal evidence exists for the bound worker (offline is not dead)'
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Zombie-binding reconciliation (worktree-zombie-lane-reclamation D1/D2):
|
|
451
|
+
* the read-only liveness probe runs before any LANE_BUSY refusal. Only
|
|
452
|
+
* terminal evidence ∧ both sides dead settles the binding — through the
|
|
453
|
+
* exact childSettled path, so a reconciled settlement is indistinguishable
|
|
454
|
+
* from a live one downstream — and the settlement is audited
|
|
455
|
+
* (worktree/reconcile-binding), exactly once (the call that actually
|
|
456
|
+
* cleared the binding audits; a concurrent/repeat reconcile finds the
|
|
457
|
+
* binding gone and audits nothing). A refusal leaves the ledger untouched.
|
|
458
|
+
* `forced` skips the probe: the user's explicit card decision (D4).
|
|
459
|
+
* @param {any} repo @param {any} lane @param {any} session @param {{ forced?: boolean }} [options]
|
|
460
|
+
* @returns {Promise<{ settled: boolean, lane: any, verdict: any, childId: string, forced: boolean } | null>} null = no binding, or no probe available
|
|
461
|
+
*/
|
|
462
|
+
async function reconcileBinding(repo, lane, session, options = {}) {
|
|
463
|
+
if (!lane?.boundChild) return null
|
|
464
|
+
const childId = lane.boundChild
|
|
465
|
+
const forced = options.forced === true
|
|
466
|
+
/** @type {any} */
|
|
467
|
+
let verdict = null
|
|
468
|
+
if (!forced) {
|
|
469
|
+
if (typeof deps.bindingLiveness !== 'function') return null
|
|
470
|
+
try {
|
|
471
|
+
verdict = await deps.bindingLiveness(childId, lane.ownerSession ?? null, { root: repo.mainRoot })
|
|
472
|
+
} catch (error) {
|
|
473
|
+
deps.logger?.warn?.(`worktree: binding liveness probe failed for lane ${lane.id}: ${/** @type {any} */ (error)?.message ?? error}`)
|
|
474
|
+
return null
|
|
475
|
+
}
|
|
476
|
+
if (!verdict || verdict.childAlive || verdict.ownerAlive || !verdict.terminalEvidence) {
|
|
477
|
+
return { settled: false, lane, verdict, childId, forced }
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
const cleared = await childSettled(childId, session)
|
|
481
|
+
const after = laneOf(repo, lane.id)
|
|
482
|
+
if (cleared != null) {
|
|
483
|
+
deps.audit('reconcile-binding', {
|
|
484
|
+
lane: lane.id,
|
|
485
|
+
child: childId,
|
|
486
|
+
ownerSession: lane.ownerSession ?? null,
|
|
487
|
+
forced,
|
|
488
|
+
evidence: verdict?.terminalEvidence ?? null,
|
|
489
|
+
childAlive: verdict?.childAlive ?? null,
|
|
490
|
+
ownerAlive: verdict?.ownerAlive ?? null,
|
|
491
|
+
}, repo.mainRoot, lane.ownerSession)
|
|
492
|
+
}
|
|
493
|
+
return { settled: after.boundChild == null, lane: after, verdict, childId, forced }
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* A bound writer settled: free the lane and run the host check. `silent`
|
|
498
|
+
* suppresses the immediate notice when the caller reports the outcome
|
|
499
|
+
* itself (foreground result / job result); background verification still
|
|
500
|
+
* notifies when it finishes.
|
|
501
|
+
* @param {string} childId @param {any} parentSession @param {{ silent?: boolean }} [options]
|
|
502
|
+
*/
|
|
503
|
+
async function childSettled(childId, parentSession, options = {}) {
|
|
504
|
+
let repo
|
|
505
|
+
try {
|
|
506
|
+
repo = await repoFor(cwdOf(parentSession))
|
|
507
|
+
} catch {
|
|
508
|
+
return null
|
|
509
|
+
}
|
|
510
|
+
const lane = repo.ledger.read().lanes.find((/** @type {any} */ entry) => entry.boundChild === childId)
|
|
511
|
+
if (!lane) return null
|
|
512
|
+
await patchLane(repo, lane.id, () => ({ boundChild: null }))
|
|
513
|
+
const checked = await runChecks(repo, lane.id, parentSession, { trigger: 'settle', silent: options.silent })
|
|
514
|
+
return { ...checked, notice: renderNotice(checked, checked.reason ?? undefined) }
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// ─── checks ───────────────────────────────────────────────────────────
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Mandatory preconditions, then (when configured) verification in the
|
|
521
|
+
* background. Returns the lane as of the precondition outcome.
|
|
522
|
+
* @param {any} repo @param {string} laneId @param {any} session @param {{ trigger: string, awaitVerification?: boolean, silent?: boolean }} options
|
|
523
|
+
*/
|
|
524
|
+
async function runChecks(repo, laneId, session, options) {
|
|
525
|
+
const tell = (/** @type {any} */ lane, /** @type {string} */ detail) => {
|
|
526
|
+
if (!options.silent) notifyOwner(lane, detail)
|
|
527
|
+
}
|
|
528
|
+
const lane = laneOf(repo, laneId)
|
|
529
|
+
if (!CHECKABLE.includes(lane.state)) return lane
|
|
530
|
+
if (lane.boundChild) {
|
|
531
|
+
// Zombie-binding reconciliation (D1) before the refusal: only terminal
|
|
532
|
+
// evidence ∧ both sides dead clears the binding (audited); the settle
|
|
533
|
+
// path then ran this very check, so return its outcome.
|
|
534
|
+
const outcome = await reconcileBinding(repo, lane, session)
|
|
535
|
+
if (outcome?.settled) return outcome.lane
|
|
536
|
+
const detail = outcome?.verdict ? ` (binding reconciliation: ${reconcileNote(outcome.verdict)}; the abandon confirmation card offers a force-reclaim)` : ''
|
|
537
|
+
throw new WorktreeError(WORKTREE_CODES.LANE_BUSY, `lane ${laneId} has a running worker; the host checks it when the worker settles${detail}`, { lane: laneId, next: nextFor(lane) })
|
|
538
|
+
}
|
|
539
|
+
const changes = await git.status(lane.path)
|
|
540
|
+
/** @type {{ to: string, reason: string } | null} */
|
|
541
|
+
let failed = null
|
|
542
|
+
if (changes.length > 0) failed = { to: 'dirty', reason: `${changes.length} uncommitted path(s): ${changes.slice(0, 5).map((entry) => entry.path).join(', ')}` }
|
|
543
|
+
else if ((await git.currentBranch(lane.path)) !== lane.branch) failed = { to: 'branch-moved', reason: `HEAD is not on ${lane.branch}` }
|
|
544
|
+
else if ((await git.aheadBehind(repo.mainRoot, lane.base.branch, lane.branch)).ahead === 0) failed = { to: 'no-commits', reason: `no commits ahead of ${lane.base.branch}` }
|
|
545
|
+
if (failed) {
|
|
546
|
+
const next = await apply(repo, laneId, { type: 'checked', to: failed.to, reason: failed.reason })
|
|
547
|
+
tell(next, failed.reason)
|
|
548
|
+
return next
|
|
549
|
+
}
|
|
550
|
+
const tree = await git.tree(lane.path)
|
|
551
|
+
/** @type {any} */
|
|
552
|
+
let repoConfig
|
|
553
|
+
try {
|
|
554
|
+
repoConfig = readConfig(repo)
|
|
555
|
+
} catch (error) {
|
|
556
|
+
const checking = await apply(repo, laneId, { type: 'checked', to: 'checking', patch: { check: { enabled: true, tree, results: [] } } })
|
|
557
|
+
const failedLane = await apply(repo, checking.id, { type: 'check-fail', reason: /** @type {any} */ (error)?.message ?? String(error) })
|
|
558
|
+
tell(failedLane, failedLane.reason)
|
|
559
|
+
return failedLane
|
|
560
|
+
}
|
|
561
|
+
if (repoConfig.check.length === 0) {
|
|
562
|
+
const landable = await apply(repo, laneId, { type: 'checked', to: 'landable', patch: { landableTree: tree, check: { enabled: false, tree } } })
|
|
563
|
+
tell(landable, 'mandatory checks passed; verification is not enabled for this repository')
|
|
564
|
+
return landable
|
|
565
|
+
}
|
|
566
|
+
const checking = await apply(repo, laneId, { type: 'checked', to: 'checking', patch: { check: { enabled: true, tree, results: [], startedAt: now() } } })
|
|
567
|
+
const run = runVerification(repo, laneId, repoConfig.check, session, tree)
|
|
568
|
+
if (options.awaitVerification) return run
|
|
569
|
+
void run
|
|
570
|
+
return checking
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/** @param {any} repo @param {string} laneId @param {Array<{ name: string, run: string, timeoutSec: number }>} commands @param {any} session @param {string | null} tree */
|
|
574
|
+
async function runVerification(repo, laneId, commands, session, tree) {
|
|
575
|
+
const lanePath = laneOf(repo, laneId).path
|
|
576
|
+
/** @type {any[]} */
|
|
577
|
+
const results = []
|
|
578
|
+
/** @type {string | null} */
|
|
579
|
+
let failure = null
|
|
580
|
+
let tail = ''
|
|
581
|
+
if (!deps.shellRun) failure = `${WORKTREE_CODES.SHELL_UNAVAILABLE}: no shell executor is available for verification`
|
|
582
|
+
for (const [index, command] of commands.entries()) {
|
|
583
|
+
if (failure) break
|
|
584
|
+
const log = logFile(repo, laneId, `${index + 1}-${command.name.replace(/[^A-Za-z0-9_.-]+/g, '-')}`)
|
|
585
|
+
const started = now()
|
|
586
|
+
try {
|
|
587
|
+
const run = await /** @type {any} */ (deps.shellRun)({ command: command.run, cwd: lanePath, timeoutMs: command.timeoutSec * 1000, session })
|
|
588
|
+
writeFileSync(log, run.output)
|
|
589
|
+
results.push({ name: command.name, run: command.run, exit: run.code, ms: now() - started, log, denied: run.denied })
|
|
590
|
+
if (run.code !== 0) {
|
|
591
|
+
failure = `${command.name} ${run.timedOut ? 'timed out' : `exited ${run.code}`}${run.denied ? ' (sandbox denied)' : ''}`
|
|
592
|
+
tail = run.output.split(/\r?\n/).slice(-LOG_TAIL_LINES).join('\n')
|
|
593
|
+
}
|
|
594
|
+
} catch (error) {
|
|
595
|
+
results.push({ name: command.name, run: command.run, exit: -1, ms: now() - started, log })
|
|
596
|
+
failure = `${command.name} could not run: ${/** @type {any} */ (error)?.message ?? error}`
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
if (!failure) {
|
|
600
|
+
const after = await git.status(lanePath)
|
|
601
|
+
if (after.length > 0 || (await git.tree(lanePath)) !== tree) failure = 'verification modified lane content'
|
|
602
|
+
}
|
|
603
|
+
try {
|
|
604
|
+
const lane = failure
|
|
605
|
+
? await apply(repo, laneId, { type: 'check-fail', reason: failure, patch: { check: { enabled: true, tree, results, finishedAt: now() } } })
|
|
606
|
+
: await apply(repo, laneId, { type: 'check-pass', patch: { landableTree: tree, check: { enabled: true, tree, results, finishedAt: now() } } })
|
|
607
|
+
const last = results.at(-1)
|
|
608
|
+
notifyOwner(lane, failure ? `${failure}; log ${last?.log ?? 'n/a'}${tail ? `\n${tail}` : ''}` : `verification passed: ${results.map((entry) => entry.name).join(', ')}`)
|
|
609
|
+
return lane
|
|
610
|
+
} catch {
|
|
611
|
+
return null
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* worktree_check / /worktree check.
|
|
617
|
+
* @param {any} session @param {string} laneId @param {{ verificationOnly?: boolean, awaitVerification?: boolean }} [options]
|
|
618
|
+
*/
|
|
619
|
+
async function check(session, laneId, options = {}) {
|
|
620
|
+
const repo = await repoFor(cwdOf(session))
|
|
621
|
+
await refresh(repo)
|
|
622
|
+
const current = laneOf(repo, laneId)
|
|
623
|
+
if (options.verificationOnly && readConfig(repo).check.length === 0) {
|
|
624
|
+
throw new WorktreeError(WORKTREE_CODES.VERIFICATION_DISABLED, 'verification is not enabled for this repository (no "check" in the worktree config)', { lane: laneId, next: nextFor(current) })
|
|
625
|
+
}
|
|
626
|
+
if (!CHECKABLE.includes(current.state)) {
|
|
627
|
+
throw new WorktreeError(WORKTREE_CODES.ILLEGAL_TRANSITION, `lane ${laneId} is ${current.state} and cannot be checked`, { lane: laneId, next: nextFor(current) })
|
|
628
|
+
}
|
|
629
|
+
const lane = await runChecks(repo, laneId, session, { trigger: 'check', awaitVerification: options.awaitVerification })
|
|
630
|
+
return result(lane, `lane ${lane.state}${lane.reason ? `: ${lane.reason}` : ''}`)
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
return {
|
|
634
|
+
deps, git, now, pid, asking,
|
|
635
|
+
approveModeSafe, approveModeSourceSafe,
|
|
636
|
+
cwdOf, config, repoFor, repoCached,
|
|
637
|
+
armWatch, disarmWatch,
|
|
638
|
+
laneOf, apply, patchLane, commitBinding, notifyOwner, residueOf, result, readConfig, logFile,
|
|
639
|
+
refresh, reconcileNote, reconcileBinding, childSettled, check,
|
|
640
|
+
}
|
|
641
|
+
}
|