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.
- package/CHANGELOG.md +103 -0
- package/COMPATIBILITY.md +5 -1
- package/CONTRIBUTING.md +64 -5
- package/PRINCIPLES.md +145 -0
- package/README.en.md +7 -4
- package/README.md +7 -4
- package/SECURITY.md +6 -0
- package/{lib → adapters}/archive.js +1 -1
- package/{lib → adapters}/caseSensitivity.js +1 -1
- package/adapters/default.js +51 -10
- package/{lib → adapters}/git.js +1 -1
- package/{lib → adapters}/leases.js +1 -1
- package/{lib → adapters}/legacy.js +1 -1
- package/adapters/persistence.js +543 -0
- package/adapters/store-fs.js +28 -1
- package/adapters/store.js +204 -0
- package/{lib → adapters}/tests.js +9 -5
- package/adapters/vcs-git.js +15 -1
- package/{lib → adapters}/workspaceLock.js +1 -1
- package/{lib → adapters}/worktreeOwnership.js +1 -1
- package/app/archive.js +4 -6
- package/app/batch.js +9 -10
- package/app/board.js +2 -4
- package/app/checkCompletion.js +9 -10
- package/app/enrichedMissions.js +2 -4
- package/app/findings.js +6 -7
- package/app/gate.js +1 -2
- package/app/init.js +24 -29
- package/app/merge.js +58 -51
- package/app/messages.js +7 -8
- package/app/mission.js +5 -5
- package/app/plan.js +8 -5
- package/app/provision.js +12 -14
- package/app/read.js +6 -3
- package/app/rebase.js +14 -16
- package/app/recording.js +7 -8
- package/app/review.js +15 -8
- package/app/roster.js +14 -15
- package/{lib → app}/spawn-prechecks.js +4 -3
- package/app/spawn.js +12 -13
- package/app/teardown.js +8 -9
- package/app/tower.js +89 -35
- package/docs/README.md +6 -2
- package/docs/adr/0001-ports-adapters-as-data.md +49 -0
- package/docs/adr/0002-single-state-json.md +50 -0
- package/docs/adr/0003-allow-agent-control-default-true.md +48 -0
- package/docs/adr/0004-surface-budget.md +50 -0
- package/docs/adr/README.md +51 -0
- package/docs/agents/workflow.md +51 -0
- package/docs/architecture/overview.md +167 -36
- package/docs/guide/hot-reload.md +1 -1
- package/docs/guide/release.md +1 -1
- package/docs/operations/incident.md +82 -0
- package/docs/operations/runbook.md +12 -3
- package/docs/reference/blockers.md +2 -2
- package/docs/reference/config.generated.md +1 -0
- package/docs/reference/doctor.md +48 -2
- package/docs/reference/errors.generated.md +41 -37
- package/docs/reference/errors.md +53 -41
- package/docs/reference/events.md +38 -6
- package/docs/reference/surface-budget.md +128 -0
- package/docs/reference/tools.generated.md +5 -4
- package/docs/reference/tools.md +4 -4
- package/docs/zh/guide/hot-reload.md +1 -1
- package/docs/zh/reference/blockers.md +2 -2
- package/docs/zh/reference/doctor.md +29 -1
- package/docs/zh/reference/tools.md +4 -4
- package/domain/batch.js +2 -2
- package/domain/config/schema.js +25 -1
- package/domain/contracts/outputs.js +6 -2
- package/domain/contracts/tool-inputs/ops.js +1 -0
- package/domain/errors.js +297 -42
- package/domain/events.js +207 -0
- package/domain/integration/gate.js +17 -2
- package/domain/integration/merge.js +35 -0
- package/domain/lockBudget.js +30 -0
- package/domain/missions/completion.js +14 -0
- package/domain/missions/lifecycle.js +9 -3
- package/domain/missions/plan.js +14 -0
- package/domain/missions/review.js +90 -7
- package/domain/read/views.js +3 -0
- package/domain/state/archive.js +1 -1
- package/domain/state.js +1 -1
- package/domain/surface.js +248 -0
- package/{lib → host}/doctor.js +116 -9
- package/{lib → host}/modeStore.js +1 -1
- package/{lib → host}/tool-registry.js +106 -31
- package/host/tower.js +32 -0
- package/index.js +12 -9
- package/package.json +10 -3
- package/ports/archive.js +18 -0
- package/ports/caseSensitivity.js +15 -0
- package/ports/clock.js +10 -2
- package/ports/config.js +19 -0
- package/ports/contract.js +38 -0
- package/ports/events.js +24 -0
- package/ports/ids.js +10 -2
- package/ports/index.js +54 -2
- package/ports/ownership.js +17 -0
- package/ports/state.js +16 -0
- package/ports/store.js +52 -3
- package/ports/vcs.js +25 -4
- package/scripts/architecture-waivers.json +19 -0
- package/scripts/architecture.mjs +634 -23
- package/scripts/check-docs.mjs +8 -35
- package/scripts/check-pack.mjs +68 -39
- package/scripts/check-philosophy.mjs +266 -0
- package/scripts/domain-metrics.mjs +13 -4
- package/scripts/generate-config-reference.mjs +14 -1
- package/scripts/generate-error-reference.mjs +68 -14
- package/scripts/generate-events-reference.mjs +83 -0
- package/scripts/generate-tools-reference.mjs +11 -0
- package/scripts/issue-audit.mjs +2 -2
- package/scripts/lib/surface.mjs +87 -0
- package/scripts/pack-manifest.txt +58 -22
- package/scripts/regression-audit.mjs +164 -0
- package/scripts/release-check.mjs +1 -1
- package/scripts/surface-baseline.json +21 -0
- package/scripts/surface-baseline.mjs +120 -0
- package/tools/adopt.js +2 -3
- package/tools/batch.js +2 -3
- package/tools/complete.js +2 -3
- package/tools/diff.js +3 -3
- package/tools/doctor.js +7 -10
- package/tools/finding.js +2 -3
- package/tools/findings.js +3 -3
- package/tools/import-legacy.js +3 -5
- package/tools/inbox.js +3 -3
- package/tools/index.js +7 -7
- package/tools/init.js +3 -6
- package/tools/interrupt.js +2 -3
- package/tools/legacy-map.js +3 -5
- package/tools/merge.js +3 -3
- package/tools/mission.js +2 -3
- package/tools/plan.js +6 -5
- package/tools/preflight.js +2 -3
- package/tools/rebase.js +3 -3
- package/tools/recover.js +38 -13
- package/tools/reload.js +3 -6
- package/tools/resource.js +3 -5
- package/tools/review.js +3 -4
- package/tools/roster.js +2 -3
- package/tools/send.js +2 -2
- package/tools/spawn.js +8 -5
- package/tools/status.js +3 -3
- package/tools/teardown.js +2 -3
- package/tools/test.js +2 -2
- package/types/index.d.ts +1 -1
- package/ui-bundle/README.md +37 -3
- package/ui-bundle/client.cards.js +356 -0
- package/ui-bundle/client.denied.js +142 -0
- package/ui-bundle/client.dock.js +333 -0
- package/ui-bundle/client.helpers.js +271 -0
- package/ui-bundle/client.js +79 -1747
- package/ui-bundle/client.locales.js +247 -0
- package/ui-bundle/client.mode.js +206 -0
- package/ui-bundle/client.overlay.js +64 -0
- package/ui-bundle/client.styles.js +151 -0
- package/ui-bundle/client.toolview.js +82 -0
- package/ui-bundle/package.json +1 -0
- package/lib/persistence.js +0 -356
- package/lib/store.js +0 -133
- /package/{lib → adapters}/stateFile.js +0 -0
- /package/{lib → app}/briefing.js +0 -0
- /package/{lib → app}/spawn-support.js +0 -0
- /package/{lib → host}/compatibility.js +0 -0
- /package/{lib → host}/config.js +0 -0
- /package/{lib → host}/hmr.js +0 -0
- /package/{lib → host}/modeState.js +0 -0
- /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 }
|
package/adapters/store-fs.js
CHANGED
|
@@ -1,6 +1,33 @@
|
|
|
1
|
-
// The only
|
|
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
|
+
}
|