dsh-tower 2.1.0 → 2.3.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 (170) hide show
  1. package/CHANGELOG.md +103 -0
  2. package/COMPATIBILITY.md +5 -1
  3. package/CONTRIBUTING.md +64 -5
  4. package/PRINCIPLES.md +145 -0
  5. package/README.en.md +7 -4
  6. package/README.md +7 -4
  7. package/SECURITY.md +6 -0
  8. package/{lib → adapters}/archive.js +1 -1
  9. package/{lib → adapters}/caseSensitivity.js +1 -1
  10. package/adapters/default.js +51 -10
  11. package/{lib → adapters}/git.js +1 -1
  12. package/{lib → adapters}/leases.js +1 -1
  13. package/{lib → adapters}/legacy.js +1 -1
  14. package/adapters/persistence.js +543 -0
  15. package/adapters/store-fs.js +28 -1
  16. package/adapters/store.js +204 -0
  17. package/{lib → adapters}/tests.js +9 -5
  18. package/adapters/vcs-git.js +15 -1
  19. package/{lib → adapters}/workspaceLock.js +1 -1
  20. package/{lib → adapters}/worktreeOwnership.js +1 -1
  21. package/app/archive.js +4 -6
  22. package/app/batch.js +9 -10
  23. package/app/board.js +2 -4
  24. package/app/checkCompletion.js +9 -10
  25. package/app/enrichedMissions.js +2 -4
  26. package/app/findings.js +6 -7
  27. package/app/gate.js +1 -2
  28. package/app/init.js +24 -29
  29. package/app/merge.js +58 -51
  30. package/app/messages.js +7 -8
  31. package/app/mission.js +5 -5
  32. package/app/plan.js +8 -5
  33. package/app/provision.js +12 -14
  34. package/app/read.js +6 -3
  35. package/app/rebase.js +14 -16
  36. package/app/recording.js +7 -8
  37. package/app/review.js +15 -8
  38. package/app/roster.js +14 -15
  39. package/{lib → app}/spawn-prechecks.js +4 -3
  40. package/app/spawn.js +12 -13
  41. package/app/teardown.js +8 -9
  42. package/app/tower.js +89 -35
  43. package/docs/README.md +6 -2
  44. package/docs/adr/0001-ports-adapters-as-data.md +49 -0
  45. package/docs/adr/0002-single-state-json.md +50 -0
  46. package/docs/adr/0003-allow-agent-control-default-true.md +48 -0
  47. package/docs/adr/0004-surface-budget.md +50 -0
  48. package/docs/adr/README.md +51 -0
  49. package/docs/agents/workflow.md +51 -0
  50. package/docs/architecture/overview.md +167 -36
  51. package/docs/guide/hot-reload.md +1 -1
  52. package/docs/guide/release.md +1 -1
  53. package/docs/operations/incident.md +82 -0
  54. package/docs/operations/runbook.md +12 -3
  55. package/docs/reference/blockers.md +2 -2
  56. package/docs/reference/config.generated.md +1 -0
  57. package/docs/reference/doctor.md +48 -2
  58. package/docs/reference/errors.generated.md +41 -37
  59. package/docs/reference/errors.md +53 -41
  60. package/docs/reference/events.md +38 -6
  61. package/docs/reference/surface-budget.md +128 -0
  62. package/docs/reference/tools.generated.md +5 -4
  63. package/docs/reference/tools.md +4 -4
  64. package/docs/zh/guide/hot-reload.md +1 -1
  65. package/docs/zh/reference/blockers.md +2 -2
  66. package/docs/zh/reference/doctor.md +29 -1
  67. package/docs/zh/reference/tools.md +4 -4
  68. package/domain/batch.js +2 -2
  69. package/domain/config/schema.js +25 -1
  70. package/domain/contracts/outputs.js +6 -2
  71. package/domain/contracts/tool-inputs/ops.js +1 -0
  72. package/domain/errors.js +297 -42
  73. package/domain/events.js +207 -0
  74. package/domain/integration/gate.js +17 -2
  75. package/domain/integration/merge.js +35 -0
  76. package/domain/lockBudget.js +30 -0
  77. package/domain/missions/completion.js +14 -0
  78. package/domain/missions/lifecycle.js +9 -3
  79. package/domain/missions/plan.js +14 -0
  80. package/domain/missions/review.js +90 -7
  81. package/domain/read/views.js +3 -0
  82. package/domain/state/archive.js +1 -1
  83. package/domain/state.js +1 -1
  84. package/domain/surface.js +248 -0
  85. package/{lib → host}/doctor.js +116 -9
  86. package/{lib → host}/modeStore.js +1 -1
  87. package/{lib → host}/tool-registry.js +106 -31
  88. package/host/tower.js +32 -0
  89. package/index.js +12 -9
  90. package/package.json +10 -3
  91. package/ports/archive.js +18 -0
  92. package/ports/caseSensitivity.js +15 -0
  93. package/ports/clock.js +10 -2
  94. package/ports/config.js +19 -0
  95. package/ports/contract.js +38 -0
  96. package/ports/events.js +24 -0
  97. package/ports/ids.js +10 -2
  98. package/ports/index.js +54 -2
  99. package/ports/ownership.js +17 -0
  100. package/ports/state.js +16 -0
  101. package/ports/store.js +52 -3
  102. package/ports/vcs.js +25 -4
  103. package/scripts/architecture-waivers.json +19 -0
  104. package/scripts/architecture.mjs +634 -23
  105. package/scripts/check-docs.mjs +8 -35
  106. package/scripts/check-pack.mjs +68 -39
  107. package/scripts/check-philosophy.mjs +266 -0
  108. package/scripts/domain-metrics.mjs +13 -4
  109. package/scripts/generate-config-reference.mjs +14 -1
  110. package/scripts/generate-error-reference.mjs +68 -14
  111. package/scripts/generate-events-reference.mjs +83 -0
  112. package/scripts/generate-tools-reference.mjs +11 -0
  113. package/scripts/issue-audit.mjs +2 -2
  114. package/scripts/lib/surface.mjs +87 -0
  115. package/scripts/pack-manifest.txt +58 -22
  116. package/scripts/regression-audit.mjs +164 -0
  117. package/scripts/release-check.mjs +1 -1
  118. package/scripts/surface-baseline.json +21 -0
  119. package/scripts/surface-baseline.mjs +120 -0
  120. package/tools/adopt.js +2 -3
  121. package/tools/batch.js +2 -3
  122. package/tools/complete.js +2 -3
  123. package/tools/diff.js +3 -3
  124. package/tools/doctor.js +7 -10
  125. package/tools/finding.js +2 -3
  126. package/tools/findings.js +3 -3
  127. package/tools/import-legacy.js +3 -5
  128. package/tools/inbox.js +3 -3
  129. package/tools/index.js +7 -7
  130. package/tools/init.js +3 -6
  131. package/tools/interrupt.js +2 -3
  132. package/tools/legacy-map.js +3 -5
  133. package/tools/merge.js +3 -3
  134. package/tools/mission.js +2 -3
  135. package/tools/plan.js +6 -5
  136. package/tools/preflight.js +2 -3
  137. package/tools/rebase.js +3 -3
  138. package/tools/recover.js +38 -13
  139. package/tools/reload.js +3 -6
  140. package/tools/resource.js +3 -5
  141. package/tools/review.js +3 -4
  142. package/tools/roster.js +2 -3
  143. package/tools/send.js +2 -2
  144. package/tools/spawn.js +8 -5
  145. package/tools/status.js +3 -3
  146. package/tools/teardown.js +2 -3
  147. package/tools/test.js +2 -2
  148. package/types/index.d.ts +1 -1
  149. package/ui-bundle/README.md +37 -3
  150. package/ui-bundle/client.cards.js +356 -0
  151. package/ui-bundle/client.denied.js +142 -0
  152. package/ui-bundle/client.dock.js +333 -0
  153. package/ui-bundle/client.helpers.js +271 -0
  154. package/ui-bundle/client.js +79 -1747
  155. package/ui-bundle/client.locales.js +247 -0
  156. package/ui-bundle/client.mode.js +206 -0
  157. package/ui-bundle/client.overlay.js +64 -0
  158. package/ui-bundle/client.styles.js +151 -0
  159. package/ui-bundle/client.toolview.js +82 -0
  160. package/ui-bundle/package.json +1 -0
  161. package/lib/persistence.js +0 -356
  162. package/lib/store.js +0 -133
  163. /package/{lib → adapters}/stateFile.js +0 -0
  164. /package/{lib → app}/briefing.js +0 -0
  165. /package/{lib → app}/spawn-support.js +0 -0
  166. /package/{lib → host}/compatibility.js +0 -0
  167. /package/{lib → host}/config.js +0 -0
  168. /package/{lib → host}/hmr.js +0 -0
  169. /package/{lib → host}/modeState.js +0 -0
  170. /package/{lib → host}/presets.js +0 -0
@@ -0,0 +1,543 @@
1
+ // Shared durable-persistence contract (#56 P2b).
2
+ //
3
+ // state.json (adapters/store.js) and modes.json (host/modeStore.js) both persist a
4
+ // whole JSON document behind a token-owned writer lock. They must agree on:
5
+ //
6
+ // - atomic replacement: serialise completely, write a temp file, rename;
7
+ // - dead-PID reclamation: a provably dead owner is moved to a quarantine
8
+ // name, verified, and only then removed; a live/foreign lock is never
9
+ // stolen or deleted;
10
+ // - bounded waiting: a held lock is reported after a timeout, not stolen.
11
+ //
12
+ // Keeping the primitives here means a protocol fix applies to both contracts
13
+ // at once. This module is the only adapter besides store.js that talks to the
14
+ // fs adapter; it never imports node:fs directly (#50).
15
+ import { fs, fsp } from './store-fs.js'
16
+ import path from 'node:path'
17
+ import crypto from 'node:crypto'
18
+ import { TowerToolError } from '../domain/errors.js'
19
+
20
+ export const DEFAULT_LOCK_TIMEOUT_MS = 5000
21
+ export const DEFAULT_LOCK_POLL_MS = 25
22
+ /**
23
+ * While a lock is held its `at` field is rewritten on this interval (#92).
24
+ * A record whose heartbeat is older than `LOCK_HEARTBEAT_STALE_FACTOR`
25
+ * intervals is abandoned, whatever its PID currently points at: a PID may be
26
+ * recycled, a heartbeat cannot. The default grace (3 s) stays inside the 5 s
27
+ * state-lock wait, so the next mutation reclaims a crashed lock instead of
28
+ * failing until someone deletes the file by hand.
29
+ */
30
+ export const DEFAULT_LOCK_HEARTBEAT_MS = 1000
31
+ export const LOCK_HEARTBEAT_STALE_FACTOR = 3
32
+
33
+ // Exported with the retry loop so a test can assert the budget and the exact
34
+ // transient set instead of copying them (#86).
35
+ export const RENAME_ATTEMPTS = 12
36
+ export const TRANSIENT_RENAME_ERRORS = new Set(['EPERM', 'EBUSY', 'EACCES'])
37
+ const PROCESS_STARTED_AT = Date.now() - Math.round(process.uptime() * 1000)
38
+
39
+ export function sleep(ms) {
40
+ return new Promise((resolve) => setTimeout(resolve, ms))
41
+ }
42
+
43
+ function sleepSync(ms) {
44
+ try {
45
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)
46
+ } catch {
47
+ // best effort; a busy loop would be worse
48
+ }
49
+ }
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // JSON document I/O
53
+ // ---------------------------------------------------------------------------
54
+
55
+ function parseJson(file, text) {
56
+ try {
57
+ return JSON.parse(text)
58
+ } catch (error) {
59
+ throw new TowerToolError('state-corrupt', `corrupt tower state at ${file}: ${error.message}`)
60
+ }
61
+ }
62
+
63
+ export async function readJson(file) {
64
+ return parseJson(file, await fsp.readFile(file, 'utf8'))
65
+ }
66
+
67
+ /** Synchronous counterpart for the mode store's short critical section. */
68
+ export function readJsonSync(file) {
69
+ return parseJson(file, fs.readFileSync(file, 'utf8'))
70
+ }
71
+
72
+ export async function writeJsonAtomic(file, value) {
73
+ const dir = path.dirname(file)
74
+ const tmp = path.join(dir, `.${path.basename(file)}.tmp-${process.pid}-${crypto.randomUUID()}`)
75
+ const handle = await fsp.open(tmp, 'w')
76
+ try {
77
+ await handle.writeFile(JSON.stringify(value, null, 2), 'utf8')
78
+ await handle.sync()
79
+ } finally {
80
+ await handle.close()
81
+ }
82
+ await renameWithRetry(tmp, file)
83
+ }
84
+
85
+ /**
86
+ * Windows can reject a rename while an indexer, antivirus, or a concurrent
87
+ * reader still holds the destination; retry briefly instead of failing a
88
+ * successful mutation. The temp file is removed when the rename never lands.
89
+ *
90
+ * `options.rename` and `options.sleep` inject the syscall and the backoff so
91
+ * both outcomes are testable on any OS (#86); production callers omit them.
92
+ */
93
+ export async function renameWithRetry(tmp, file, options = {}) {
94
+ const rename = options.rename ?? fsp.rename
95
+ const wait = options.sleep ?? sleep
96
+ for (let attempt = 0; ; attempt += 1) {
97
+ try {
98
+ await rename(tmp, file)
99
+ return
100
+ } catch (error) {
101
+ if (!TRANSIENT_RENAME_ERRORS.has(error?.code) || attempt >= RENAME_ATTEMPTS) {
102
+ await fsp.unlink(tmp).catch(() => {})
103
+ throw error
104
+ }
105
+ await wait(10 + attempt * 15)
106
+ }
107
+ }
108
+ }
109
+
110
+ /** Synchronous counterpart used by the mode store's short critical section. */
111
+ export function writeJsonAtomicSync(file, value) {
112
+ const dir = path.dirname(file)
113
+ const tmp = path.join(dir, `.${path.basename(file)}.tmp-${process.pid}-${crypto.randomUUID()}`)
114
+ fs.writeFileSync(tmp, JSON.stringify(value, null, 2), 'utf8')
115
+ for (let attempt = 0; ; attempt += 1) {
116
+ try {
117
+ fs.renameSync(tmp, file)
118
+ return
119
+ } catch (error) {
120
+ if (!TRANSIENT_RENAME_ERRORS.has(error?.code) || attempt >= RENAME_ATTEMPTS) {
121
+ try {
122
+ fs.unlinkSync(tmp)
123
+ } catch {
124
+ // best effort
125
+ }
126
+ throw error
127
+ }
128
+ sleepSync(10 + attempt * 15)
129
+ }
130
+ }
131
+ }
132
+
133
+ export async function appendJsonl(file, value) {
134
+ await fsp.appendFile(file, `${JSON.stringify(value)}\n`, 'utf8')
135
+ }
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // Lock records
139
+ // ---------------------------------------------------------------------------
140
+
141
+ /**
142
+ * true = process exists, false = provably gone, null = the record carries no
143
+ * usable PID. The tri-state matters: a malformed state lock must be reported,
144
+ * not silently reclaimed as if its owner were dead.
145
+ *
146
+ * This is a fast path, not the liveness decision (#92): `true` only means the
147
+ * PID currently exists, and that PID may be a recycled one.
148
+ */
149
+ export function processAlive(pid) {
150
+ const value = Number(pid)
151
+ if (!Number.isInteger(value) || value <= 0) return null
152
+ try {
153
+ process.kill(value, 0)
154
+ return true
155
+ } catch (error) {
156
+ if (error?.code === 'ESRCH') return false
157
+ // EPERM means the process exists but belongs to another user.
158
+ return true
159
+ }
160
+ }
161
+
162
+ /** Injected clock: a function, a timestamp, or the real `Date.now`. */
163
+ function nowValue(options = {}) {
164
+ const now = typeof options.now === 'function' ? options.now() : options.now
165
+ const value = Number(now)
166
+ return Number.isFinite(value) ? value : Date.now()
167
+ }
168
+
169
+ export function lockHeartbeatMs(options = {}) {
170
+ const value = Number(options.heartbeatMs ?? DEFAULT_LOCK_HEARTBEAT_MS)
171
+ return Number.isFinite(value) && value > 0 ? value : DEFAULT_LOCK_HEARTBEAT_MS
172
+ }
173
+
174
+ export function lockStaleAfterMs(options = {}) {
175
+ const explicit = Number(options.heartbeatStaleMs)
176
+ if (Number.isFinite(explicit) && explicit > 0) return explicit
177
+ return lockHeartbeatMs(options) * LOCK_HEARTBEAT_STALE_FACTOR
178
+ }
179
+
180
+ /**
181
+ * Age the record was written (`since`) and age of its last heartbeat (`at`).
182
+ * Records written by a build before #92 carry no `since`; their creation time
183
+ * is the last (only) `at`, so both ages are equal until the first heartbeat.
184
+ */
185
+ export function lockTiming(record, options = {}) {
186
+ const now = nowValue(options)
187
+ const at = Number(record?.at)
188
+ const since = Number(record?.since ?? record?.at)
189
+ const heartbeatAgeMs = Number.isFinite(at) ? Math.max(0, now - at) : null
190
+ const ageMs = Number.isFinite(since) ? Math.max(0, now - since) : null
191
+ const staleAfterMs = lockStaleAfterMs(options)
192
+ return {
193
+ now,
194
+ ageMs,
195
+ heartbeatAgeMs,
196
+ staleAfterMs,
197
+ stale: heartbeatAgeMs !== null && heartbeatAgeMs > staleAfterMs,
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Classify a record without touching the filesystem.
203
+ *
204
+ * - `esrch`: the PID provably does not exist → reclaim immediately.
205
+ * - `invalid-pid`: no usable PID → only the caller that opts in treats it as dead.
206
+ * - `heartbeat-stale`: the PID may exist, but the holder stopped heartbeating →
207
+ * reclaim after byte verification. Immune to PID reuse by construction.
208
+ * - `held`: a live holder with a fresh heartbeat → wait, never steal.
209
+ */
210
+ export function lockLiveness(record, options = {}) {
211
+ const alive = processAlive(record?.pid)
212
+ const timing = lockTiming(record, options)
213
+ if (alive === false) return { alive, dead: true, reason: 'esrch', timing }
214
+ if (alive === null) {
215
+ // The mode store historically treated a lock without a usable PID as dead;
216
+ // preserve that where callers opt in, while the state lock stays defensive.
217
+ return { alive, dead: options.invalidPidIsDead === true, reason: 'invalid-pid', timing }
218
+ }
219
+ if (timing.stale) return { alive, dead: true, reason: 'heartbeat-stale', timing }
220
+ return { alive, dead: false, reason: 'held', timing }
221
+ }
222
+
223
+ export function lockRecord(token, extra = {}, options = {}) {
224
+ const now = nowValue(options)
225
+ return { token, pid: process.pid, ...extra, since: now, at: now }
226
+ }
227
+
228
+ // ---------------------------------------------------------------------------
229
+ // Lock inspection / reclamation
230
+ // ---------------------------------------------------------------------------
231
+
232
+ export async function readLock(lockPath, options = {}) {
233
+ try {
234
+ const raw = await fsp.readFile(lockPath, 'utf8')
235
+ const record = JSON.parse(raw)
236
+ return { path: lockPath, record, alive: processAlive(record?.pid), raw, ...lockTiming(record, options) }
237
+ } catch (error) {
238
+ if (error?.code === 'ENOENT') {
239
+ return { path: lockPath, record: null, alive: null, raw: null, ...lockTiming(null, options) }
240
+ }
241
+ return {
242
+ path: lockPath,
243
+ record: null,
244
+ alive: null,
245
+ raw: null,
246
+ error: String(error?.message ?? error),
247
+ ...lockTiming(null, options),
248
+ }
249
+ }
250
+ }
251
+
252
+ export function readLockSync(lockPath, options = {}) {
253
+ try {
254
+ const raw = fs.readFileSync(lockPath, 'utf8')
255
+ const record = JSON.parse(raw)
256
+ return { path: lockPath, record, alive: processAlive(record?.pid), raw, ...lockTiming(record, options) }
257
+ } catch (error) {
258
+ if (error?.code === 'ENOENT') {
259
+ return { path: lockPath, record: null, alive: null, raw: null, ...lockTiming(null, options) }
260
+ }
261
+ return {
262
+ path: lockPath,
263
+ record: null,
264
+ alive: null,
265
+ raw: null,
266
+ error: String(error?.message ?? error),
267
+ ...lockTiming(null, options),
268
+ }
269
+ }
270
+ }
271
+
272
+ /**
273
+ * Dispose of a quarantined record after its bytes were verified. A
274
+ * PID-reuse reclaim (`heartbeat-stale`) is kept as an audit backup because the
275
+ * PID was alive: the operator may want to see what was moved. A provably dead
276
+ * PID is unlinked as before. `options.keepReclaimBackup:false` disables the
277
+ * backup (tests inject it to assert the dead-PID path leaves nothing behind).
278
+ */
279
+ async function discardQuarantine(lockPath, quarantine, reason, options) {
280
+ if (reason !== 'heartbeat-stale' || options.keepReclaimBackup === false) {
281
+ await fsp.unlink(quarantine).catch(() => {})
282
+ return null
283
+ }
284
+ const backup = `${lockPath}.reclaimed-${nowValue(options)}-${crypto.randomUUID()}`
285
+ try {
286
+ await fsp.rename(quarantine, backup)
287
+ return backup
288
+ } catch {
289
+ // The bytes could not be preserved; leaving the verified stale record in
290
+ // place is safer than deleting it.
291
+ return null
292
+ }
293
+ }
294
+
295
+ export async function reclaimDeadLock(lockPath, options = {}) {
296
+ let original
297
+ try {
298
+ original = await fsp.readFile(lockPath, 'utf8')
299
+ } catch {
300
+ return false
301
+ }
302
+ let parsed
303
+ try {
304
+ parsed = JSON.parse(original)
305
+ } catch {
306
+ return false
307
+ }
308
+ const verdict = lockLiveness(parsed, options)
309
+ if (!verdict.dead) return false
310
+ const rename = options.rename ?? fsp.rename
311
+ // Move, never delete: a concurrent writer may have replaced the record
312
+ // between the read and the rename, so verify the quarantined bytes first.
313
+ const quarantine = `${lockPath}.stale-${process.pid}-${crypto.randomUUID()}`
314
+ try {
315
+ await rename(lockPath, quarantine)
316
+ } catch {
317
+ return false
318
+ }
319
+ const moved = await fsp.readFile(quarantine, 'utf8').catch(() => null)
320
+ if (moved === null || moved !== original) {
321
+ if (moved !== null) await rename(quarantine, lockPath).catch(() => {})
322
+ return false
323
+ }
324
+ await discardQuarantine(lockPath, quarantine, verdict.reason, options)
325
+ return true
326
+ }
327
+
328
+ export function reclaimDeadLockSync(lockPath, options = {}) {
329
+ let original
330
+ try {
331
+ original = fs.readFileSync(lockPath, 'utf8')
332
+ } catch {
333
+ return false
334
+ }
335
+ let parsed
336
+ try {
337
+ parsed = JSON.parse(original)
338
+ } catch {
339
+ return false
340
+ }
341
+ const verdict = lockLiveness(parsed, options)
342
+ if (!verdict.dead) return false
343
+ const renameSync = options.renameSync ?? fs.renameSync
344
+ const quarantine = `${lockPath}.stale-${process.pid}-${crypto.randomUUID()}`
345
+ try {
346
+ renameSync(lockPath, quarantine)
347
+ } catch {
348
+ return false
349
+ }
350
+ let moved = null
351
+ try {
352
+ moved = fs.readFileSync(quarantine, 'utf8')
353
+ } catch {
354
+ moved = null
355
+ }
356
+ if (moved === null || moved !== original) {
357
+ if (moved !== null) {
358
+ try {
359
+ renameSync(quarantine, lockPath)
360
+ } catch {
361
+ // the newer record could not be restored; never delete it silently
362
+ }
363
+ }
364
+ return false
365
+ }
366
+ if (verdict.reason === 'heartbeat-stale' && options.keepReclaimBackup !== false) {
367
+ const backup = `${lockPath}.reclaimed-${nowValue(options)}-${crypto.randomUUID()}`
368
+ try {
369
+ renameSync(quarantine, backup)
370
+ return true
371
+ } catch {
372
+ return true
373
+ }
374
+ }
375
+ try {
376
+ fs.unlinkSync(quarantine)
377
+ } catch {
378
+ // the lock is already reclaimed; a leftover quarantine is inert
379
+ }
380
+ return true
381
+ }
382
+
383
+ // ---------------------------------------------------------------------------
384
+ // Lock heartbeat
385
+ // ---------------------------------------------------------------------------
386
+
387
+ /**
388
+ * Rewrite `at` only while the file still belongs to this token. The write goes
389
+ * through a temp file + rename so a reader never sees a torn record. The temp
390
+ * name is per-process, so two holders that briefly overlap (a reclaimed record
391
+ * whose old process is still alive) never overwrite each other's heartbeat.
392
+ */
393
+ export async function touchLock(lockPath, token, options = {}) {
394
+ const tmp = `${lockPath}.heartbeat-${process.pid}-${crypto.randomUUID()}`
395
+ try {
396
+ const raw = await fsp.readFile(lockPath, 'utf8')
397
+ const record = JSON.parse(raw)
398
+ if (!record || record.token !== token) return false
399
+ const next = { ...record, at: nowValue(options) }
400
+ await fsp.writeFile(tmp, JSON.stringify(next), 'utf8')
401
+ await renameWithRetry(tmp, lockPath)
402
+ return true
403
+ } catch {
404
+ // A failed heartbeat must never throw into the lock holder; the next
405
+ // interval retries, and a genuinely stuck holder is reclaimed by a waiter.
406
+ await fsp.unlink(tmp).catch(() => {})
407
+ return false
408
+ }
409
+ }
410
+
411
+ /**
412
+ * Start the unref'd heartbeat timer for a held lock. Returns a stop function
413
+ * that also waits for an in-flight refresh, so release cannot unlink the lock
414
+ * only to have a late heartbeat recreate it.
415
+ */
416
+ function startHeartbeat(lockPath, token, options) {
417
+ const intervalMs = lockHeartbeatMs(options)
418
+ const setIntervalFn = options.setInterval ?? setInterval
419
+ const clearIntervalFn = options.clearInterval ?? clearInterval
420
+ let stopped = false
421
+ let inFlight = null
422
+ const timer = setIntervalFn(() => {
423
+ if (stopped || inFlight) return
424
+ inFlight = touchLock(lockPath, token, options)
425
+ .catch(() => false)
426
+ .finally(() => {
427
+ inFlight = null
428
+ })
429
+ }, intervalMs)
430
+ if (timer && typeof timer.unref === 'function') timer.unref()
431
+ return async function stopHeartbeat() {
432
+ stopped = true
433
+ clearIntervalFn(timer)
434
+ if (inFlight) await inFlight
435
+ }
436
+ }
437
+
438
+ // ---------------------------------------------------------------------------
439
+ // Lock acquire / token-checked release
440
+ // ---------------------------------------------------------------------------
441
+
442
+ function handleFor(lockPath, token, record, stopHeartbeat = async () => {}) {
443
+ return {
444
+ path: lockPath,
445
+ token,
446
+ record,
447
+ async release() {
448
+ await stopHeartbeat()
449
+ return releaseLock(lockPath, token)
450
+ },
451
+ }
452
+ }
453
+
454
+ function syncHandleFor(lockPath, token, record) {
455
+ return {
456
+ path: lockPath,
457
+ token,
458
+ record,
459
+ release() {
460
+ return releaseLockSync(lockPath, token)
461
+ },
462
+ }
463
+ }
464
+
465
+ /**
466
+ * Acquire a token-owned lock with a bounded wait. Returns null when another
467
+ * writer still holds a live lock at the deadline; callers translate that into
468
+ * their own [tower:state-locked] message (or a false return for modes).
469
+ * While held, the record heartbeats so waiters can distinguish a live holder
470
+ * from a PID that was recycled after a crash (#92).
471
+ */
472
+ export async function acquireLock(lockPath, options = {}) {
473
+ const token = options.token ?? crypto.randomUUID()
474
+ const record = options.record ?? lockRecord(token, {}, options)
475
+ const timeoutMs = Number(options.timeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS)
476
+ const pollMs = Number(options.pollMs ?? DEFAULT_LOCK_POLL_MS)
477
+ // Only a clock function advances the wait budget; a fixed `now` timestamp
478
+ // stays a timestamp and must not create an endless deadline.
479
+ const clock = typeof options.now === 'function' ? options.now : Date.now
480
+ const deadline = clock() + Math.max(0, timeoutMs)
481
+ for (;;) {
482
+ try {
483
+ await fsp.writeFile(lockPath, JSON.stringify(record), { flag: 'wx' })
484
+ const stopHeartbeat = startHeartbeat(lockPath, token, options)
485
+ return handleFor(lockPath, token, record, stopHeartbeat)
486
+ } catch (error) {
487
+ if (error?.code !== 'EEXIST') throw error
488
+ if (await reclaimDeadLock(lockPath, options)) continue
489
+ if (clock() > deadline) return null
490
+ await sleep(pollMs)
491
+ }
492
+ }
493
+ }
494
+
495
+ export function acquireLockSync(lockPath, options = {}) {
496
+ const token = options.token ?? crypto.randomUUID()
497
+ const record = options.record ?? lockRecord(token, {}, options)
498
+ const timeoutMs = Number(options.timeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS)
499
+ const pollMs = Number(options.pollMs ?? DEFAULT_LOCK_POLL_MS)
500
+ const clock = typeof options.now === 'function' ? options.now : Date.now
501
+ const deadline = clock() + Math.max(0, timeoutMs)
502
+ for (;;) {
503
+ try {
504
+ fs.writeFileSync(lockPath, JSON.stringify(record), { flag: 'wx' })
505
+ // Synchronous callers (the mode store) hold the lock for a short, fixed
506
+ // critical section and cannot run a timer; the record's `at` is its only
507
+ // heartbeat, which is why the stale window is three intervals wide.
508
+ return syncHandleFor(lockPath, token, record)
509
+ } catch (error) {
510
+ if (error?.code !== 'EEXIST') throw error
511
+ if (reclaimDeadLockSync(lockPath, options)) continue
512
+ if (clock() > deadline) return null
513
+ sleepSync(pollMs)
514
+ }
515
+ }
516
+ }
517
+
518
+ export async function releaseLock(lockPath, token) {
519
+ try {
520
+ const current = await fsp.readFile(lockPath, 'utf8').catch(() => null)
521
+ if (current === null) return false
522
+ const parsed = JSON.parse(current)
523
+ if (!parsed || parsed.token !== token) return false
524
+ await fsp.unlink(lockPath)
525
+ return true
526
+ } catch {
527
+ // a foreign or unreadable lock is not ours to remove
528
+ return false
529
+ }
530
+ }
531
+
532
+ export function releaseLockSync(lockPath, token) {
533
+ try {
534
+ const parsed = JSON.parse(fs.readFileSync(lockPath, 'utf8'))
535
+ if (!parsed || parsed.token !== token) return false
536
+ fs.unlinkSync(lockPath)
537
+ return true
538
+ } catch {
539
+ return false
540
+ }
541
+ }
542
+
543
+ export { PROCESS_STARTED_AT }
@@ -1,6 +1,33 @@
1
- // The only lib/ module that imports node:fs (#50).
1
+ // The only module in the plugin that imports node:fs (#50).
2
+ //
3
+ // Since #69 this is a real adapter, not a re-export shim: `createStorePort()`
4
+ // returns a runtime implementation of StorePort (ports/store.js) that the
5
+ // composition root injects into the app layer. The durable implementation still
6
+ // lives next door in adapters/store.js + adapters/persistence.js (#70). The
7
+ // raw `fs`/`fsp` exports remain for the adapter modules that are not behind
8
+ // the port yet.
2
9
  import fs from 'node:fs'
3
10
  import fsp from 'node:fs/promises'
11
+ import * as store from './store.js'
4
12
 
5
13
  const { createWriteStream } = fs
6
14
  export { fs, fsp, createWriteStream }
15
+
16
+ /**
17
+ * Build the filesystem store port (#69).
18
+ *
19
+ * The object is assembled once by the composition root (index.js /
20
+ * adapters/default.js) and injected into the app layer, so the app depends on
21
+ * the port contract and an in-memory implementation can replace it wholesale.
22
+ */
23
+ export function createStorePort() {
24
+ return Object.freeze({
25
+ ...store,
26
+ stat: (file) => fsp.stat(file),
27
+ unlink: (file) => fsp.unlink(file),
28
+ readText: (file) => fsp.readFile(file, 'utf8'),
29
+ writeText: (file, text) => fsp.writeFile(file, text, 'utf8'),
30
+ appendText: (file, text) => fsp.appendFile(file, text, 'utf8'),
31
+ rename: (from, to) => fsp.rename(from, to),
32
+ })
33
+ }