dsh-tower 1.3.0 → 2.0.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.
Files changed (57) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/CONTRIBUTING.md +1 -1
  3. package/README.en.md +22 -8
  4. package/README.md +20 -8
  5. package/cordis.patch.yml +10 -0
  6. package/docs/README.md +8 -4
  7. package/docs/architecture/overview.md +13 -9
  8. package/docs/faq.md +33 -2
  9. package/docs/guide/hot-reload.md +189 -0
  10. package/docs/guide/install-upgrade-migration.md +11 -0
  11. package/docs/guide/quickstart.md +33 -1
  12. package/docs/operations/runbook.md +23 -0
  13. package/docs/operations/security.md +23 -0
  14. package/docs/reference/blockers.md +10 -2
  15. package/docs/reference/config.md +65 -2
  16. package/docs/reference/doctor.md +31 -0
  17. package/docs/reference/errors.md +50 -0
  18. package/docs/reference/events.md +45 -0
  19. package/docs/reference/operations.md +52 -0
  20. package/docs/reference/testing.md +61 -0
  21. package/docs/reference/tools.md +65 -23
  22. package/docs/zh/README.md +58 -0
  23. package/docs/zh/faq.md +72 -0
  24. package/docs/zh/guide/hot-reload.md +178 -0
  25. package/docs/zh/guide/install-upgrade-migration.md +57 -0
  26. package/docs/zh/guide/quickstart.md +96 -0
  27. package/docs/zh/operations/security.md +42 -0
  28. package/docs/zh/reference/blockers.md +62 -0
  29. package/docs/zh/reference/config.md +113 -0
  30. package/docs/zh/reference/doctor.md +30 -0
  31. package/docs/zh/reference/errors.md +52 -0
  32. package/docs/zh/reference/events.md +46 -0
  33. package/docs/zh/reference/operations.md +48 -0
  34. package/docs/zh/reference/testing.md +60 -0
  35. package/docs/zh/reference/tools.md +75 -0
  36. package/index.js +261 -13
  37. package/lib/batch.js +578 -0
  38. package/lib/config.js +270 -8
  39. package/lib/doctor.js +165 -0
  40. package/lib/errors.js +111 -0
  41. package/lib/hmr.js +196 -0
  42. package/lib/leases.js +317 -0
  43. package/lib/legacy.js +413 -0
  44. package/lib/modeStore.js +28 -1
  45. package/lib/store.js +24 -1
  46. package/lib/tests.js +628 -0
  47. package/lib/tools.js +1554 -182
  48. package/lib/tower.js +957 -102
  49. package/lib/workspaceLock.js +117 -0
  50. package/lib/worktreeOwnership.js +166 -0
  51. package/package.json +4 -1
  52. package/reviewer.js +1 -0
  53. package/scripts/check-docs.mjs +135 -0
  54. package/scripts/enable-dev-hmr.mjs +347 -0
  55. package/skills/tower-review.md +4 -2
  56. package/skills/tower-worker-protocol.md +3 -1
  57. package/worker.js +4 -2
package/lib/hmr.js ADDED
@@ -0,0 +1,196 @@
1
+ // dsh-tower — HMR bridge.
2
+ //
3
+ // Wraps the optional `@deepseek-ai/dsh-hmr` profile service so Tower can ask
4
+ // DSH to reconcile its profile (re-read the patch layers, replace changed
5
+ // linked-package modules and report inactive required entries) without
6
+ // importing any DSH package. A profile without the HMR service simply reports
7
+ // `available: false`; nothing here throws at construction time.
8
+ //
9
+ // The bridge is intentionally dependency-free apart from the stable tool error
10
+ // type, so `tests/hmr.test.mjs` can drive it with a fake `ctx.get('hmr')`.
11
+ //
12
+ // DSH facts this relies on:
13
+ // - `hmr.runExclusive(operation)` serializes the operation with all automatic
14
+ // reload paths; `hmr.reconcileProfile(requiredIds)` must run inside it and
15
+ // throws `HMR profile reconciliation must run inside runExclusive` otherwise.
16
+ // - `reconcileProfile` returns `{ warnings, pending[, restartReason] }`:
17
+ // `pending` modules keep running their previous code until a restart, and
18
+ // `restartReason` means the whole process must be restarted.
19
+ // - `runExclusive` rejects nested transactions; a tool call is never inside
20
+ // one, so a tool-triggered reload is safe.
21
+ import { TowerToolError } from './errors.js'
22
+
23
+ /** Entry ids Tower expects to be active after a reconciliation. */
24
+ export const DEFAULT_REQUIRED_IDS = Object.freeze([
25
+ 'tower',
26
+ 'tower-skills',
27
+ 'preset-tower-worker',
28
+ 'preset-tower-reviewer',
29
+ ])
30
+
31
+ /** Debounce window for the dev hot-reload hook (milliseconds). */
32
+ export const DEFAULT_DELAY_MS = 50
33
+
34
+ const RESTART_HINT = 'Restart DSH or check the profile hmr configuration.'
35
+
36
+ /** Read the HMR service without assuming anything about the Cordis context. */
37
+ function hmrService(ctx) {
38
+ if (!ctx || typeof ctx.get !== 'function') return undefined
39
+ try {
40
+ return ctx.get('hmr')
41
+ } catch {
42
+ return undefined
43
+ }
44
+ }
45
+
46
+ /** True when `candidate` exposes both HMR methods Tower needs. */
47
+ export function isHmrAvailable(candidate) {
48
+ return Boolean(
49
+ candidate &&
50
+ typeof candidate.runExclusive === 'function' &&
51
+ typeof candidate.reconcileProfile === 'function',
52
+ )
53
+ }
54
+
55
+ function normalizeRequiredIds(value) {
56
+ // An explicit array wins (including an empty one); anything else falls back
57
+ // to the Tower entry ids. Trim and drop duplicates so a config typo does not
58
+ // make `reconcileProfile` treat the same entry twice.
59
+ const source = Array.isArray(value) ? value : DEFAULT_REQUIRED_IDS
60
+ const ids = []
61
+ for (const entry of source) {
62
+ if (typeof entry !== 'string') continue
63
+ const id = entry.trim()
64
+ if (id.length === 0 || ids.includes(id)) continue
65
+ ids.push(id)
66
+ }
67
+ return ids
68
+ }
69
+
70
+ function normalizeDelayMs(value) {
71
+ if (value === undefined || value === null) return DEFAULT_DELAY_MS
72
+ const delay = Number(value)
73
+ if (!Number.isFinite(delay) || delay < 0) return DEFAULT_DELAY_MS
74
+ return Math.max(1, Math.floor(delay))
75
+ }
76
+
77
+ function messageOf(error) {
78
+ return String(error?.message ?? error)
79
+ }
80
+
81
+ function warn(logger, message) {
82
+ try {
83
+ logger?.warn?.(message)
84
+ } catch {
85
+ // Diagnostics must never break a reload path.
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Create the Tower → DSH HMR bridge.
91
+ *
92
+ * @param {object} ctx Cordis context (only `ctx.get` is used).
93
+ * @param {object} [options]
94
+ * @param {string[]} [options.requiredIds] Entry ids whose inactivity is reported as a failure.
95
+ * @param {number} [options.delayMs] Delay before a scheduled reload runs.
96
+ * @param {{ warn?: Function }} [options.logger] Optional logger for failed scheduled reloads.
97
+ * @returns {{ available: boolean, requiredIds: string[], delayMs: number, status: Function, schedule: Function, runNow: Function }}
98
+ */
99
+ export function createHmrBridge(ctx, options = {}) {
100
+ const requiredIds = Object.freeze(normalizeRequiredIds(options.requiredIds))
101
+ const delayMs = normalizeDelayMs(options.delayMs)
102
+ const logger = options.logger
103
+
104
+ let timer = null
105
+ let lastRunAt = null
106
+ let lastError = null
107
+
108
+ const available = () => isHmrAvailable(hmrService(ctx))
109
+
110
+ /**
111
+ * Reconcile the profile now. Throws a stable TowerToolError:
112
+ * `reload-unavailable` when the HMR service is absent, `reload-failed` when
113
+ * the reconciliation itself throws (its message carries the cause).
114
+ */
115
+ async function runNow() {
116
+ const hmr = hmrService(ctx)
117
+ if (!isHmrAvailable(hmr)) {
118
+ const message = `HMR profile reload is unavailable; ${RESTART_HINT}`
119
+ lastError = message
120
+ throw new TowerToolError('reload-unavailable', message)
121
+ }
122
+ try {
123
+ const result = await hmr.runExclusive(() => hmr.reconcileProfile(requiredIds))
124
+ lastRunAt = Date.now()
125
+ lastError = null
126
+ return result
127
+ } catch (error) {
128
+ if (error instanceof TowerToolError) {
129
+ lastError = messageOf(error)
130
+ throw error
131
+ }
132
+ const cause = messageOf(error)
133
+ lastError = cause
134
+ throw new TowerToolError(
135
+ 'reload-failed',
136
+ `HMR profile reload failed: ${cause}. ${RESTART_HINT}`,
137
+ { cause: error },
138
+ )
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Schedule `runNow()` after `delayMs` and return immediately. A failure is
144
+ * recorded in `status().lastError` and passed to `logger.warn`; it is never
145
+ * surfaced as an unhandled rejection. Calling `schedule()` again replaces a
146
+ * still-pending timer, so a burst of edits results in one reconciliation.
147
+ */
148
+ function schedule() {
149
+ if (timer !== null) {
150
+ clearTimeout(timer)
151
+ timer = null
152
+ }
153
+ timer = setTimeout(() => {
154
+ timer = null
155
+ runNow().catch((error) => {
156
+ lastError = messageOf(error)
157
+ warn(logger, `[dsh-tower] scheduled HMR reload failed: ${lastError}`)
158
+ })
159
+ }, delayMs)
160
+ const outcome = { scheduled: true, delayMs, requiredIds: [...requiredIds] }
161
+ if (lastError !== null) outcome.lastError = lastError
162
+ return outcome
163
+ }
164
+
165
+ /** Snapshot for tools and diagnostics. `available` is re-read on every call. */
166
+ function status() {
167
+ return {
168
+ available: available(),
169
+ requiredIds: [...requiredIds],
170
+ delayMs,
171
+ scheduled: timer !== null,
172
+ lastRunAt,
173
+ lastError,
174
+ }
175
+ }
176
+
177
+ /** Clear a pending scheduled reload (plugin disposal / HMR reload). */
178
+ function dispose() {
179
+ if (timer !== null) {
180
+ clearTimeout(timer)
181
+ timer = null
182
+ }
183
+ }
184
+
185
+ return {
186
+ get available() {
187
+ return available()
188
+ },
189
+ requiredIds,
190
+ delayMs,
191
+ status,
192
+ schedule,
193
+ runNow,
194
+ dispose,
195
+ }
196
+ }
package/lib/leases.js ADDED
@@ -0,0 +1,317 @@
1
+ // Repo-scoped coordination leases.
2
+ //
3
+ // One `<common>/dsh-tower/leases.json` per repository (shared by all
4
+ // worktrees) plus a short-lived writer lock `<common>/dsh-tower/leases.json.lock`.
5
+ // A lease records that a named resource (for example `plugin-load` or
6
+ // `heavy-ci`) is claimed by one holder with a token and an absolute TTL.
7
+ // Leases are advisory coordination only: they never grant Git, process, or
8
+ // filesystem authority, and this module never kills, adopts, or hands over
9
+ // work.
10
+ //
11
+ // Fail-closed protocol: a held, expired, or token-mismatched lease is reported,
12
+ // never silently taken over. Only the token holder may release a lease, and
13
+ // releasing an expired lease requires an explicit `force: true`.
14
+ import fsp from 'node:fs/promises'
15
+ import path from 'node:path'
16
+ import crypto from 'node:crypto'
17
+
18
+ import { ensureDir, exists, writeJsonAtomic } from './store.js'
19
+
20
+ export const LEASE_SCHEMA_VERSION = 1
21
+ // Matches the `leases.ttlMs` config default (900000); callers normally pass the
22
+ // configured value explicitly.
23
+ export const DEFAULT_LEASE_TTL_MS = 15 * 60 * 1000
24
+
25
+ const LOCK_TIMEOUT_MS = 5000
26
+ const LOCK_STALE_MS = 10000
27
+ const LOCK_POLL_MS = 20
28
+ const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/
29
+
30
+ function fail(code, message) {
31
+ const error = new Error(message)
32
+ error.code = code
33
+ throw error
34
+ }
35
+
36
+ function isPlainObject(value) {
37
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
38
+ }
39
+
40
+ function sleep(ms) {
41
+ return new Promise((resolve) => setTimeout(resolve, ms))
42
+ }
43
+
44
+ /** Path of the repository's lease ledger (validated, absolute commonDir). */
45
+ export function leasesPath(commonDir) {
46
+ if (typeof commonDir !== 'string' || commonDir.trim().length === 0) fail('invalid-argument', 'commonDir is required')
47
+ if (!path.isAbsolute(commonDir)) fail('invalid-argument', `commonDir must be an absolute path: ${commonDir}`)
48
+ return path.join(commonDir, 'dsh-tower', 'leases.json')
49
+ }
50
+
51
+ /** Path of the short-lived lease-ledger writer lock. */
52
+ export function leasesLockPath(commonDir) {
53
+ return `${leasesPath(commonDir)}.lock`
54
+ }
55
+
56
+ /**
57
+ * Windows can reject unlink while an indexer, antivirus, or a concurrent
58
+ * reader still holds the file; retry briefly instead of failing the mutation.
59
+ */
60
+ async function unlinkWithRetry(file) {
61
+ for (let attempt = 0; ; attempt += 1) {
62
+ try {
63
+ await fsp.unlink(file)
64
+ return true
65
+ } catch (error) {
66
+ if (error?.code === 'ENOENT') return false
67
+ const transient = error?.code === 'EPERM' || error?.code === 'EBUSY' || error?.code === 'EACCES'
68
+ if (!transient || attempt >= 12) throw error
69
+ await sleep(10 + attempt * 15)
70
+ }
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Read and validate the lease ledger. A missing file is an empty ledger; a
76
+ * corrupt or unsupported file is an error (never silently rewritten).
77
+ */
78
+ async function readLeaseDocument(commonDir) {
79
+ const file = leasesPath(commonDir)
80
+ let text
81
+ try {
82
+ text = await fsp.readFile(file, 'utf8')
83
+ } catch (error) {
84
+ if (error?.code === 'ENOENT') {
85
+ return { file, doc: { schemaVersion: LEASE_SCHEMA_VERSION, updatedAt: null, leases: {} }, exists: false }
86
+ }
87
+ throw error
88
+ }
89
+ let parsed
90
+ try {
91
+ parsed = JSON.parse(text)
92
+ } catch (error) {
93
+ fail('state-corrupt', `corrupt tower leases at ${file}: ${error.message}`)
94
+ }
95
+ if (!isPlainObject(parsed) || !isPlainObject(parsed.leases)) {
96
+ fail('state-corrupt', `corrupt tower leases at ${file}: expected {"schemaVersion":1,"leases":{...}}`)
97
+ }
98
+ if (parsed.schemaVersion !== LEASE_SCHEMA_VERSION) {
99
+ fail('state-corrupt', `unsupported tower leases schemaVersion ${String(parsed.schemaVersion)} at ${file}`)
100
+ }
101
+ const leases = {}
102
+ for (const [name, record] of Object.entries(parsed.leases)) {
103
+ if (
104
+ !isPlainObject(record) ||
105
+ record.name !== name ||
106
+ typeof record.holder !== 'string' ||
107
+ record.holder.length === 0 ||
108
+ typeof record.token !== 'string' ||
109
+ record.token.length === 0 ||
110
+ !Number.isInteger(record.pid) ||
111
+ record.pid <= 0 ||
112
+ !Number.isFinite(record.createdAt) ||
113
+ !Number.isFinite(record.expiresAt)
114
+ ) {
115
+ fail('state-corrupt', `corrupt lease entry "${name}" at ${file}`)
116
+ }
117
+ leases[name] = {
118
+ name,
119
+ holder: record.holder,
120
+ token: record.token,
121
+ pid: record.pid,
122
+ createdAt: record.createdAt,
123
+ expiresAt: record.expiresAt,
124
+ }
125
+ }
126
+ return {
127
+ file,
128
+ doc: {
129
+ schemaVersion: LEASE_SCHEMA_VERSION,
130
+ updatedAt: Number.isFinite(parsed.updatedAt) ? parsed.updatedAt : null,
131
+ leases,
132
+ },
133
+ exists: true,
134
+ }
135
+ }
136
+
137
+ async function lockIsStale(lockFile) {
138
+ let stat
139
+ try {
140
+ stat = await fsp.stat(lockFile)
141
+ } catch (error) {
142
+ return error?.code === 'ENOENT'
143
+ }
144
+ let at = null
145
+ try {
146
+ at = Number(JSON.parse(await fsp.readFile(lockFile, 'utf8'))?.at)
147
+ } catch {
148
+ at = null
149
+ }
150
+ const age = Number.isFinite(at) ? Date.now() - at : Date.now() - stat.mtimeMs
151
+ return age > LOCK_STALE_MS
152
+ }
153
+
154
+ /**
155
+ * Serialize one lease mutation behind the `.lock` file (wx create). Waits
156
+ * briefly for a live writer, removes only a demonstrably stale lock, and never
157
+ * steals a fresh one. The lock is released only when the token still matches.
158
+ */
159
+ async function withLeaseLock(commonDir, fn) {
160
+ const lockFile = leasesLockPath(commonDir)
161
+ await ensureDir(path.dirname(lockFile))
162
+ const token = crypto.randomUUID()
163
+ const record = JSON.stringify({ token, pid: process.pid, at: Date.now() })
164
+ const deadline = Date.now() + LOCK_TIMEOUT_MS
165
+
166
+ for (;;) {
167
+ try {
168
+ await fsp.writeFile(lockFile, record, { flag: 'wx' })
169
+ break
170
+ } catch (error) {
171
+ if (error?.code !== 'EEXIST') throw error
172
+ if (await lockIsStale(lockFile)) {
173
+ await unlinkWithRetry(lockFile)
174
+ continue
175
+ }
176
+ if (Date.now() > deadline) {
177
+ const owner = await fsp.readFile(lockFile, 'utf8').catch(() => null)
178
+ fail(
179
+ 'lease-busy',
180
+ `tower leases are locked by another writer${owner ? ` (${owner})` : ''}; retry after it finishes`,
181
+ )
182
+ }
183
+ await sleep(LOCK_POLL_MS)
184
+ }
185
+ }
186
+
187
+ try {
188
+ return await fn()
189
+ } finally {
190
+ try {
191
+ const current = await fsp.readFile(lockFile, 'utf8').catch(() => null)
192
+ if (current) {
193
+ const parsed = JSON.parse(current)
194
+ if (parsed && parsed.token === token) await unlinkWithRetry(lockFile)
195
+ }
196
+ } catch {
197
+ // A lock left behind by a crash is reported by the next writer, not fixed here.
198
+ }
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Claim a named resource for `holder`. Re-acquiring with the current token
204
+ * renews the expiry (idempotent refresh); any other live or expired holder is
205
+ * refused. Returns the stored lease `{name, holder, token, pid, createdAt,
206
+ * expiresAt, renewed}`.
207
+ */
208
+ export async function acquireLease(commonDir, options = {}) {
209
+ const name = options?.name
210
+ const holder = options?.holder
211
+ const token = options?.token ?? crypto.randomUUID()
212
+ const pid = options?.pid ?? process.pid
213
+ const ttlMs = options?.ttlMs ?? DEFAULT_LEASE_TTL_MS
214
+ const now = options?.now ?? Date.now()
215
+
216
+ if (typeof name !== 'string' || !NAME_RE.test(name)) {
217
+ fail('invalid-argument', `lease name must match ${NAME_RE} (for example "plugin-load"): ${JSON.stringify(name)}`)
218
+ }
219
+ if (typeof holder !== 'string' || holder.trim().length === 0) fail('invalid-argument', 'lease holder is required')
220
+ if (typeof token !== 'string' || token.length === 0) fail('invalid-argument', 'lease token must be a non-empty string')
221
+ if (!Number.isInteger(pid) || pid <= 0) fail('invalid-argument', `lease pid must be a positive integer: ${pid}`)
222
+ if (!Number.isInteger(ttlMs) || ttlMs <= 0) fail('invalid-argument', `lease ttlMs must be a positive integer: ${ttlMs}`)
223
+ if (!Number.isFinite(now)) fail('invalid-argument', 'lease now must be a finite timestamp')
224
+
225
+ const trimmedHolder = holder.trim()
226
+ const file = leasesPath(commonDir)
227
+ await ensureDir(path.dirname(file))
228
+
229
+ return withLeaseLock(commonDir, async () => {
230
+ const { doc } = await readLeaseDocument(commonDir)
231
+ const existing = doc.leases[name]
232
+ if (existing) {
233
+ if (existing.token !== token) {
234
+ const expiresAt = new Date(existing.expiresAt).toISOString()
235
+ const expired = now >= existing.expiresAt
236
+ fail(
237
+ 'lease-busy',
238
+ `lease "${name}" is held by ${existing.holder} (pid ${existing.pid}) until ${expiresAt}` +
239
+ (expired
240
+ ? `; the lease expired at ${expiresAt} but is not taken over automatically — its holder must release it explicitly (force: true)`
241
+ : ''),
242
+ )
243
+ }
244
+ if (existing.holder !== trimmedHolder) {
245
+ fail('lease-token', `lease "${name}" token belongs to holder "${existing.holder}", not "${trimmedHolder}"`)
246
+ }
247
+ const renewed = { ...existing, expiresAt: now + ttlMs }
248
+ doc.leases[name] = renewed
249
+ doc.updatedAt = now
250
+ await writeJsonAtomic(file, doc)
251
+ return { ...renewed, renewed: true }
252
+ }
253
+ const record = { name, holder: trimmedHolder, token, pid, createdAt: now, expiresAt: now + ttlMs }
254
+ doc.leases[name] = record
255
+ doc.updatedAt = now
256
+ await writeJsonAtomic(file, doc)
257
+ return { ...record, renewed: false }
258
+ })
259
+ }
260
+
261
+ /**
262
+ * Release a lease. The token must match its holder; an expired lease can only
263
+ * be released with an explicit `force: true`. Returns `true` when removed and
264
+ * `false` when no such lease exists.
265
+ */
266
+ export async function releaseLease(commonDir, options = {}) {
267
+ const name = options?.name
268
+ const token = options?.token
269
+ const force = options?.force === true
270
+ const now = options?.now ?? Date.now()
271
+
272
+ if (typeof name !== 'string' || !NAME_RE.test(name)) {
273
+ fail('invalid-argument', `lease name must match ${NAME_RE} (for example "plugin-load"): ${JSON.stringify(name)}`)
274
+ }
275
+ if (typeof token !== 'string' || token.length === 0) {
276
+ fail('invalid-argument', 'lease token is required to release a lease')
277
+ }
278
+ if (!Number.isFinite(now)) fail('invalid-argument', 'lease now must be a finite timestamp')
279
+
280
+ const file = leasesPath(commonDir)
281
+ if (!(await exists(file))) return false
282
+ await ensureDir(path.dirname(file))
283
+
284
+ return withLeaseLock(commonDir, async () => {
285
+ const { doc } = await readLeaseDocument(commonDir)
286
+ const existing = doc.leases[name]
287
+ if (!existing) return false
288
+ if (existing.token !== token) {
289
+ fail('lease-token', `lease "${name}" token does not match its holder; refusing release`)
290
+ }
291
+ if (!force && now >= existing.expiresAt) {
292
+ fail(
293
+ 'lease-busy',
294
+ `lease "${name}" expired at ${new Date(existing.expiresAt).toISOString()}; release it explicitly with force: true`,
295
+ )
296
+ }
297
+ delete doc.leases[name]
298
+ doc.updatedAt = now
299
+ await writeJsonAtomic(file, doc)
300
+ return true
301
+ })
302
+ }
303
+
304
+ /**
305
+ * Read-only view of every lease: `{path, count, leases:[{name, holder, token,
306
+ * pid, createdAt, expiresAt, expired}]}` sorted by name. A corrupt ledger
307
+ * fails closed; an absent ledger is an empty list.
308
+ */
309
+ export async function inspectLeases(commonDir, options = {}) {
310
+ const now = options?.now ?? Date.now()
311
+ if (!Number.isFinite(now)) fail('invalid-argument', 'lease now must be a finite timestamp')
312
+ const { file, doc } = await readLeaseDocument(commonDir)
313
+ const leases = Object.values(doc.leases)
314
+ .map((record) => ({ ...record, expired: now >= record.expiresAt }))
315
+ .sort((a, b) => a.name.localeCompare(b.name))
316
+ return { path: file, count: leases.length, leases }
317
+ }