dsh-tower 2.1.0-beta.1 → 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 +154 -0
- package/COMPATIBILITY.md +6 -1
- package/CONTRIBUTING.md +71 -5
- package/PRINCIPLES.md +145 -0
- package/README.en.md +14 -5
- package/README.md +14 -5
- package/SECURITY.md +6 -0
- package/adapters/archive.js +279 -0
- package/adapters/caseSensitivity.js +66 -0
- package/adapters/default.js +51 -8
- package/{lib → adapters}/git.js +1 -1
- package/{lib → adapters}/leases.js +15 -6
- package/{lib → adapters}/legacy.js +20 -6
- package/adapters/persistence.js +543 -0
- package/adapters/stateFile.js +99 -0
- package/adapters/store-fs.js +28 -1
- package/adapters/store.js +204 -0
- package/{lib → adapters}/tests.js +15 -7
- package/adapters/vcs-git.js +15 -1
- package/{lib → adapters}/workspaceLock.js +1 -1
- package/{lib → adapters}/worktreeOwnership.js +18 -7
- package/app/archive.js +188 -0
- package/app/batch.js +9 -10
- package/app/board.js +4 -4
- package/app/checkCompletion.js +23 -11
- package/app/enrichedMissions.js +2 -4
- package/app/findings.js +6 -7
- package/app/gate.js +32 -15
- package/app/init.js +59 -22
- package/app/merge.js +64 -51
- package/app/messages.js +7 -8
- package/app/mission.js +7 -5
- package/app/plan.js +20 -9
- package/app/provision.js +12 -14
- package/app/read.js +65 -6
- package/app/rebase.js +14 -16
- package/app/recording.js +7 -8
- package/app/review.js +17 -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 +28 -8
- package/app/tower.js +121 -39
- 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/domain.md +38 -0
- package/docs/agents/issue-tracker.md +46 -0
- package/docs/agents/triage-labels.md +15 -0
- package/docs/agents/workflow.md +51 -0
- package/docs/architecture/overview.md +170 -37
- package/docs/guide/hot-reload.md +1 -1
- package/docs/guide/install-upgrade-migration.md +18 -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 +3 -3
- package/docs/reference/config.generated.md +5 -0
- package/docs/reference/config.md +4 -0
- package/docs/reference/doctor.md +104 -8
- package/docs/reference/errors.generated.md +41 -35
- package/docs/reference/errors.md +54 -40
- package/docs/reference/events.md +38 -6
- package/docs/reference/surface-budget.md +128 -0
- package/docs/reference/tools.generated.md +9 -8
- package/docs/reference/tools.md +9 -8
- package/docs/zh/guide/hot-reload.md +1 -1
- package/docs/zh/guide/install-upgrade-migration.md +13 -1
- package/docs/zh/reference/blockers.md +3 -3
- package/docs/zh/reference/config.md +4 -0
- package/docs/zh/reference/doctor.md +63 -2
- package/docs/zh/reference/errors.md +4 -2
- package/docs/zh/reference/tools.md +6 -6
- package/domain/batch.js +2 -2
- package/domain/config/schema.js +51 -4
- package/domain/contracts/outputs.js +33 -3
- package/domain/contracts/tool-inputs/missions.js +2 -1
- package/domain/contracts/tool-inputs/ops.js +1 -0
- package/domain/errors.js +297 -40
- package/domain/events.js +207 -0
- package/domain/integration/gate.js +41 -12
- package/domain/integration/init.js +40 -4
- package/domain/integration/merge.js +35 -0
- package/domain/lockBudget.js +30 -0
- package/domain/mission.js +1 -1
- package/domain/missions/completion.js +72 -5
- package/domain/missions/lifecycle.js +11 -4
- package/domain/missions/plan.js +14 -0
- package/domain/missions/review.js +90 -7
- package/domain/planning.js +6 -3
- package/domain/read/views.js +17 -1
- package/domain/report.js +9 -1
- package/domain/scope.js +83 -11
- package/domain/state/archive.js +257 -0
- package/domain/state/migrations.js +145 -0
- package/domain/state/schema.js +6 -0
- package/domain/state.js +154 -4
- package/domain/summary.js +2 -0
- package/domain/surface.js +248 -0
- package/host/doctor.js +555 -0
- 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 +64 -19
- 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 +3 -4
- 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 +4 -7
- 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 +7 -6
- package/tools/preflight.js +3 -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 +16 -8
- package/tools/teardown.js +3 -4
- package/tools/test.js +2 -2
- package/types/index.d.ts +35 -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/doctor.js +0 -270
- package/lib/persistence.js +0 -349
- package/lib/store.js +0 -118
- /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
package/lib/persistence.js
DELETED
|
@@ -1,349 +0,0 @@
|
|
|
1
|
-
// Shared durable-persistence contract (#56 P2b).
|
|
2
|
-
//
|
|
3
|
-
// state.json (lib/store.js) and modes.json (lib/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 lib/ file besides store.js that talks to
|
|
14
|
-
// the fs adapter; it never imports node:fs directly (#50).
|
|
15
|
-
import { fs, fsp } from '../adapters/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
|
-
const RENAME_ATTEMPTS = 12
|
|
24
|
-
const TRANSIENT_RENAME_ERRORS = new Set(['EPERM', 'EBUSY', 'EACCES'])
|
|
25
|
-
const PROCESS_STARTED_AT = Date.now() - Math.round(process.uptime() * 1000)
|
|
26
|
-
|
|
27
|
-
export function sleep(ms) {
|
|
28
|
-
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function sleepSync(ms) {
|
|
32
|
-
try {
|
|
33
|
-
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)
|
|
34
|
-
} catch {
|
|
35
|
-
// best effort; a busy loop would be worse
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
// ---------------------------------------------------------------------------
|
|
40
|
-
// JSON document I/O
|
|
41
|
-
// ---------------------------------------------------------------------------
|
|
42
|
-
|
|
43
|
-
function parseJson(file, text) {
|
|
44
|
-
try {
|
|
45
|
-
return JSON.parse(text)
|
|
46
|
-
} catch (error) {
|
|
47
|
-
throw new TowerToolError('state-corrupt', `corrupt tower state at ${file}: ${error.message}`)
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export async function readJson(file) {
|
|
52
|
-
return parseJson(file, await fsp.readFile(file, 'utf8'))
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/** Synchronous counterpart for the mode store's short critical section. */
|
|
56
|
-
export function readJsonSync(file) {
|
|
57
|
-
return parseJson(file, fs.readFileSync(file, 'utf8'))
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
export async function writeJsonAtomic(file, value) {
|
|
61
|
-
const dir = path.dirname(file)
|
|
62
|
-
const tmp = path.join(dir, `.${path.basename(file)}.tmp-${process.pid}-${crypto.randomUUID()}`)
|
|
63
|
-
const handle = await fsp.open(tmp, 'w')
|
|
64
|
-
try {
|
|
65
|
-
await handle.writeFile(JSON.stringify(value, null, 2), 'utf8')
|
|
66
|
-
await handle.sync()
|
|
67
|
-
} finally {
|
|
68
|
-
await handle.close()
|
|
69
|
-
}
|
|
70
|
-
await renameWithRetry(tmp, file)
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* Windows can reject a rename while an indexer, antivirus, or a concurrent
|
|
75
|
-
* reader still holds the destination; retry briefly instead of failing a
|
|
76
|
-
* successful mutation. The temp file is removed when the rename never lands.
|
|
77
|
-
*/
|
|
78
|
-
export async function renameWithRetry(tmp, file) {
|
|
79
|
-
for (let attempt = 0; ; attempt += 1) {
|
|
80
|
-
try {
|
|
81
|
-
await fsp.rename(tmp, file)
|
|
82
|
-
return
|
|
83
|
-
} catch (error) {
|
|
84
|
-
if (!TRANSIENT_RENAME_ERRORS.has(error?.code) || attempt >= RENAME_ATTEMPTS) {
|
|
85
|
-
await fsp.unlink(tmp).catch(() => {})
|
|
86
|
-
throw error
|
|
87
|
-
}
|
|
88
|
-
await sleep(10 + attempt * 15)
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/** Synchronous counterpart used by the mode store's short critical section. */
|
|
94
|
-
export function writeJsonAtomicSync(file, value) {
|
|
95
|
-
const dir = path.dirname(file)
|
|
96
|
-
const tmp = path.join(dir, `.${path.basename(file)}.tmp-${process.pid}-${crypto.randomUUID()}`)
|
|
97
|
-
fs.writeFileSync(tmp, JSON.stringify(value, null, 2), 'utf8')
|
|
98
|
-
for (let attempt = 0; ; attempt += 1) {
|
|
99
|
-
try {
|
|
100
|
-
fs.renameSync(tmp, file)
|
|
101
|
-
return
|
|
102
|
-
} catch (error) {
|
|
103
|
-
if (!TRANSIENT_RENAME_ERRORS.has(error?.code) || attempt >= RENAME_ATTEMPTS) {
|
|
104
|
-
try {
|
|
105
|
-
fs.unlinkSync(tmp)
|
|
106
|
-
} catch {
|
|
107
|
-
// best effort
|
|
108
|
-
}
|
|
109
|
-
throw error
|
|
110
|
-
}
|
|
111
|
-
sleepSync(10 + attempt * 15)
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
export async function appendJsonl(file, value) {
|
|
117
|
-
await fsp.appendFile(file, `${JSON.stringify(value)}\n`, 'utf8')
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
// ---------------------------------------------------------------------------
|
|
121
|
-
// Lock records
|
|
122
|
-
// ---------------------------------------------------------------------------
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* true = process exists, false = provably gone, null = the record carries no
|
|
126
|
-
* usable PID. The tri-state matters: a malformed state lock must be reported,
|
|
127
|
-
* not silently reclaimed as if its owner were dead.
|
|
128
|
-
*/
|
|
129
|
-
export function processAlive(pid) {
|
|
130
|
-
const value = Number(pid)
|
|
131
|
-
if (!Number.isInteger(value) || value <= 0) return null
|
|
132
|
-
try {
|
|
133
|
-
process.kill(value, 0)
|
|
134
|
-
return true
|
|
135
|
-
} catch (error) {
|
|
136
|
-
if (error?.code === 'ESRCH') return false
|
|
137
|
-
// EPERM means the process exists but belongs to another user.
|
|
138
|
-
return true
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
export function lockRecord(token, extra = {}) {
|
|
143
|
-
return { token, pid: process.pid, ...extra, at: Date.now() }
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
function isDeadLock(record, options) {
|
|
147
|
-
const alive = processAlive(record?.pid)
|
|
148
|
-
if (alive === false) return true
|
|
149
|
-
// The mode store historically treated a lock without a usable PID as dead;
|
|
150
|
-
// preserve that where callers opt in, while the state lock stays defensive.
|
|
151
|
-
return alive === null && options.invalidPidIsDead === true
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
// ---------------------------------------------------------------------------
|
|
155
|
-
// Lock inspection / reclamation
|
|
156
|
-
// ---------------------------------------------------------------------------
|
|
157
|
-
|
|
158
|
-
export async function readLock(lockPath) {
|
|
159
|
-
try {
|
|
160
|
-
const raw = await fsp.readFile(lockPath, 'utf8')
|
|
161
|
-
const record = JSON.parse(raw)
|
|
162
|
-
return { path: lockPath, record, alive: processAlive(record?.pid), raw }
|
|
163
|
-
} catch (error) {
|
|
164
|
-
if (error?.code === 'ENOENT') return { path: lockPath, record: null, alive: null, raw: null }
|
|
165
|
-
return { path: lockPath, record: null, alive: null, raw: null, error: String(error?.message ?? error) }
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
export function readLockSync(lockPath) {
|
|
170
|
-
try {
|
|
171
|
-
const raw = fs.readFileSync(lockPath, 'utf8')
|
|
172
|
-
const record = JSON.parse(raw)
|
|
173
|
-
return { path: lockPath, record, alive: processAlive(record?.pid), raw }
|
|
174
|
-
} catch (error) {
|
|
175
|
-
if (error?.code === 'ENOENT') return { path: lockPath, record: null, alive: null, raw: null }
|
|
176
|
-
return { path: lockPath, record: null, alive: null, raw: null, error: String(error?.message ?? error) }
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
export async function reclaimDeadLock(lockPath, options = {}) {
|
|
181
|
-
let original
|
|
182
|
-
try {
|
|
183
|
-
original = await fsp.readFile(lockPath, 'utf8')
|
|
184
|
-
} catch {
|
|
185
|
-
return false
|
|
186
|
-
}
|
|
187
|
-
let parsed
|
|
188
|
-
try {
|
|
189
|
-
parsed = JSON.parse(original)
|
|
190
|
-
} catch {
|
|
191
|
-
return false
|
|
192
|
-
}
|
|
193
|
-
if (!isDeadLock(parsed, options)) return false
|
|
194
|
-
// Move, never delete: a concurrent writer may have replaced the record
|
|
195
|
-
// between the read and the rename, so verify the quarantined bytes first.
|
|
196
|
-
const quarantine = `${lockPath}.stale-${process.pid}-${crypto.randomUUID()}`
|
|
197
|
-
try {
|
|
198
|
-
await fsp.rename(lockPath, quarantine)
|
|
199
|
-
} catch {
|
|
200
|
-
return false
|
|
201
|
-
}
|
|
202
|
-
const moved = await fsp.readFile(quarantine, 'utf8').catch(() => null)
|
|
203
|
-
if (moved === null || moved !== original) {
|
|
204
|
-
if (moved !== null) await fsp.rename(quarantine, lockPath).catch(() => {})
|
|
205
|
-
return false
|
|
206
|
-
}
|
|
207
|
-
await fsp.unlink(quarantine).catch(() => {})
|
|
208
|
-
return true
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
export function reclaimDeadLockSync(lockPath, options = {}) {
|
|
212
|
-
let original
|
|
213
|
-
try {
|
|
214
|
-
original = fs.readFileSync(lockPath, 'utf8')
|
|
215
|
-
} catch {
|
|
216
|
-
return false
|
|
217
|
-
}
|
|
218
|
-
let parsed
|
|
219
|
-
try {
|
|
220
|
-
parsed = JSON.parse(original)
|
|
221
|
-
} catch {
|
|
222
|
-
return false
|
|
223
|
-
}
|
|
224
|
-
if (!isDeadLock(parsed, options)) return false
|
|
225
|
-
const quarantine = `${lockPath}.stale-${process.pid}-${crypto.randomUUID()}`
|
|
226
|
-
try {
|
|
227
|
-
fs.renameSync(lockPath, quarantine)
|
|
228
|
-
} catch {
|
|
229
|
-
return false
|
|
230
|
-
}
|
|
231
|
-
let moved = null
|
|
232
|
-
try {
|
|
233
|
-
moved = fs.readFileSync(quarantine, 'utf8')
|
|
234
|
-
} catch {
|
|
235
|
-
moved = null
|
|
236
|
-
}
|
|
237
|
-
if (moved === null || moved !== original) {
|
|
238
|
-
if (moved !== null) {
|
|
239
|
-
try {
|
|
240
|
-
fs.renameSync(quarantine, lockPath)
|
|
241
|
-
} catch {
|
|
242
|
-
// the newer record could not be restored; never delete it silently
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
return false
|
|
246
|
-
}
|
|
247
|
-
try {
|
|
248
|
-
fs.unlinkSync(quarantine)
|
|
249
|
-
} catch {
|
|
250
|
-
// the lock is already reclaimed; a leftover quarantine is inert
|
|
251
|
-
}
|
|
252
|
-
return true
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
// ---------------------------------------------------------------------------
|
|
256
|
-
// Lock acquire / token-checked release
|
|
257
|
-
// ---------------------------------------------------------------------------
|
|
258
|
-
|
|
259
|
-
function handleFor(lockPath, token, record) {
|
|
260
|
-
return {
|
|
261
|
-
path: lockPath,
|
|
262
|
-
token,
|
|
263
|
-
record,
|
|
264
|
-
async release() {
|
|
265
|
-
return releaseLock(lockPath, token)
|
|
266
|
-
},
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
function syncHandleFor(lockPath, token, record) {
|
|
271
|
-
return {
|
|
272
|
-
path: lockPath,
|
|
273
|
-
token,
|
|
274
|
-
record,
|
|
275
|
-
release() {
|
|
276
|
-
return releaseLockSync(lockPath, token)
|
|
277
|
-
},
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
/**
|
|
282
|
-
* Acquire a token-owned lock with a bounded wait. Returns null when another
|
|
283
|
-
* writer still holds a live lock at the deadline; callers translate that into
|
|
284
|
-
* their own [tower:state-locked] message (or a false return for modes).
|
|
285
|
-
*/
|
|
286
|
-
export async function acquireLock(lockPath, options = {}) {
|
|
287
|
-
const token = options.token ?? crypto.randomUUID()
|
|
288
|
-
const record = options.record ?? lockRecord(token)
|
|
289
|
-
const timeoutMs = Number(options.timeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS)
|
|
290
|
-
const pollMs = Number(options.pollMs ?? DEFAULT_LOCK_POLL_MS)
|
|
291
|
-
const deadline = Date.now() + Math.max(0, timeoutMs)
|
|
292
|
-
for (;;) {
|
|
293
|
-
try {
|
|
294
|
-
await fsp.writeFile(lockPath, JSON.stringify(record), { flag: 'wx' })
|
|
295
|
-
return handleFor(lockPath, token, record)
|
|
296
|
-
} catch (error) {
|
|
297
|
-
if (error?.code !== 'EEXIST') throw error
|
|
298
|
-
if (await reclaimDeadLock(lockPath, options)) continue
|
|
299
|
-
if (Date.now() > deadline) return null
|
|
300
|
-
await sleep(pollMs)
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
export function acquireLockSync(lockPath, options = {}) {
|
|
306
|
-
const token = options.token ?? crypto.randomUUID()
|
|
307
|
-
const record = options.record ?? lockRecord(token)
|
|
308
|
-
const timeoutMs = Number(options.timeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS)
|
|
309
|
-
const pollMs = Number(options.pollMs ?? DEFAULT_LOCK_POLL_MS)
|
|
310
|
-
const deadline = Date.now() + Math.max(0, timeoutMs)
|
|
311
|
-
for (;;) {
|
|
312
|
-
try {
|
|
313
|
-
fs.writeFileSync(lockPath, JSON.stringify(record), { flag: 'wx' })
|
|
314
|
-
return syncHandleFor(lockPath, token, record)
|
|
315
|
-
} catch (error) {
|
|
316
|
-
if (error?.code !== 'EEXIST') throw error
|
|
317
|
-
if (reclaimDeadLockSync(lockPath, options)) continue
|
|
318
|
-
if (Date.now() > deadline) return null
|
|
319
|
-
sleepSync(pollMs)
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
export async function releaseLock(lockPath, token) {
|
|
325
|
-
try {
|
|
326
|
-
const current = await fsp.readFile(lockPath, 'utf8').catch(() => null)
|
|
327
|
-
if (current === null) return false
|
|
328
|
-
const parsed = JSON.parse(current)
|
|
329
|
-
if (!parsed || parsed.token !== token) return false
|
|
330
|
-
await fsp.unlink(lockPath)
|
|
331
|
-
return true
|
|
332
|
-
} catch {
|
|
333
|
-
// a foreign or unreadable lock is not ours to remove
|
|
334
|
-
return false
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
export function releaseLockSync(lockPath, token) {
|
|
339
|
-
try {
|
|
340
|
-
const parsed = JSON.parse(fs.readFileSync(lockPath, 'utf8'))
|
|
341
|
-
if (!parsed || parsed.token !== token) return false
|
|
342
|
-
fs.unlinkSync(lockPath)
|
|
343
|
-
return true
|
|
344
|
-
} catch {
|
|
345
|
-
return false
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
export { PROCESS_STARTED_AT }
|
package/lib/store.js
DELETED
|
@@ -1,118 +0,0 @@
|
|
|
1
|
-
import { TowerToolError } from '../domain/errors.js'
|
|
2
|
-
// Durable repo-scoped store: exclusive lock + atomic JSON + append-only activity.
|
|
3
|
-
// The atomic/lock/JSON primitives are shared with the mode store (#56 P2b).
|
|
4
|
-
import { fsp } from '../adapters/store-fs.js'
|
|
5
|
-
import {
|
|
6
|
-
PROCESS_STARTED_AT,
|
|
7
|
-
acquireLock,
|
|
8
|
-
lockRecord,
|
|
9
|
-
readLock,
|
|
10
|
-
} from './persistence.js'
|
|
11
|
-
import path from 'node:path'
|
|
12
|
-
import crypto from 'node:crypto'
|
|
13
|
-
|
|
14
|
-
export {
|
|
15
|
-
readJson,
|
|
16
|
-
writeJsonAtomic,
|
|
17
|
-
writeJsonAtomicSync,
|
|
18
|
-
renameWithRetry,
|
|
19
|
-
appendJsonl,
|
|
20
|
-
} from './persistence.js'
|
|
21
|
-
|
|
22
|
-
export function pathsFor(commonDir) {
|
|
23
|
-
const dir = path.join(commonDir, 'dsh-tower')
|
|
24
|
-
return {
|
|
25
|
-
dir,
|
|
26
|
-
state: path.join(dir, 'state.json'),
|
|
27
|
-
lock: path.join(dir, 'state.lock'),
|
|
28
|
-
gitLock: path.join(dir, 'git.lock'),
|
|
29
|
-
activity: path.join(dir, 'activity.jsonl'),
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export async function exists(file) {
|
|
34
|
-
try {
|
|
35
|
-
await fsp.access(file)
|
|
36
|
-
return true
|
|
37
|
-
} catch {
|
|
38
|
-
return false
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export async function ensureDir(dir) {
|
|
43
|
-
await fsp.mkdir(dir, { recursive: true })
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export function shortHash(text) {
|
|
47
|
-
return crypto.createHash('sha256').update(String(text)).digest('hex').slice(0, 12)
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export async function readActivityTail(file, limit = 12) {
|
|
51
|
-
const text = await fsp.readFile(file, 'utf8').catch(() => '')
|
|
52
|
-
const lines = text.split('\n').filter(Boolean)
|
|
53
|
-
const tail = lines.slice(Math.max(0, lines.length - limit))
|
|
54
|
-
const out = []
|
|
55
|
-
for (const line of tail) {
|
|
56
|
-
try {
|
|
57
|
-
out.push(JSON.parse(line))
|
|
58
|
-
} catch {
|
|
59
|
-
// keep going: a truncated activity line must not hide the rest
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
return out
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
export async function readStateLock(paths) {
|
|
66
|
-
return readLock(paths.lock)
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* Hold the repo-wide tower lock for one mutation. A lock whose recorded PID is
|
|
71
|
-
* provably dead is reclaimed atomically; a live lock is reported, never stolen.
|
|
72
|
-
*/
|
|
73
|
-
export async function withLock(paths, fn, options = {}) {
|
|
74
|
-
const timeoutMs = options.timeoutMs ?? 5000
|
|
75
|
-
const pollMs = options.pollMs ?? 25
|
|
76
|
-
const token = crypto.randomUUID()
|
|
77
|
-
const record = lockRecord(token, { startedAt: PROCESS_STARTED_AT })
|
|
78
|
-
const held = await acquireLock(paths.lock, { token, record, timeoutMs, pollMs })
|
|
79
|
-
if (!held) {
|
|
80
|
-
const owner = await readStateLock(paths)
|
|
81
|
-
const ownerRecord = owner.record
|
|
82
|
-
const payload = owner.raw ?? (ownerRecord ? JSON.stringify(ownerRecord) : null)
|
|
83
|
-
throw new TowerToolError(
|
|
84
|
-
'state-locked',
|
|
85
|
-
`tower state is locked by another writer${payload ? ` (${payload})` : ''}; retry after it finishes`,
|
|
86
|
-
)
|
|
87
|
-
}
|
|
88
|
-
const startedAt = Date.now()
|
|
89
|
-
try {
|
|
90
|
-
return await fn(paths)
|
|
91
|
-
} finally {
|
|
92
|
-
await held.release()
|
|
93
|
-
await fsp
|
|
94
|
-
.appendFile(path.join(paths.dir, 'lock-holds.jsonl'), `${JSON.stringify({ at: Date.now(), ms: Date.now() - startedAt, pid: process.pid })}\n`, 'utf8')
|
|
95
|
-
.catch(() => {})
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/** Serialize git mutations without holding the state lock. */
|
|
100
|
-
export async function withGitLock(paths, fn, options = {}) {
|
|
101
|
-
const timeoutMs = options.timeoutMs ?? 60000
|
|
102
|
-
const pollMs = options.pollMs ?? 25
|
|
103
|
-
const lock = paths.gitLock
|
|
104
|
-
const token = crypto.randomUUID()
|
|
105
|
-
const startedAt = Date.now()
|
|
106
|
-
const held = await acquireLock(lock, { token, record: lockRecord(token, { kind: 'git' }), timeoutMs, pollMs })
|
|
107
|
-
if (!held) {
|
|
108
|
-
throw new TowerToolError('state-locked', 'another git mutation is in progress for this repository; retry after it finishes')
|
|
109
|
-
}
|
|
110
|
-
try {
|
|
111
|
-
return await fn(paths)
|
|
112
|
-
} finally {
|
|
113
|
-
await held.release()
|
|
114
|
-
await fsp
|
|
115
|
-
.appendFile(path.join(paths.dir, 'lock-holds.jsonl'), `${JSON.stringify({ at: Date.now(), ms: Date.now() - startedAt, pid: process.pid, phase: 'git' })}\n`, 'utf8')
|
|
116
|
-
.catch(() => {})
|
|
117
|
-
}
|
|
118
|
-
}
|
/package/{lib → app}/briefing.js
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
/package/{lib → host}/config.js
RENAMED
|
File without changes
|
/package/{lib → host}/hmr.js
RENAMED
|
File without changes
|
|
File without changes
|
/package/{lib → host}/presets.js
RENAMED
|
File without changes
|