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,82 @@
1
+ // dsh-tower-ui 0.2.2 — the toolview card surface (one keyed view per tool).
2
+ //
3
+ // The chat-driven card publishes lifecycle transitions into the notice store
4
+ // the root-scoped overlay injects. Package-local chunk (#71).
5
+ window.__ModuleLoader__.load({
6
+ id: 'dsh-tower-ui',
7
+ chunk: 'client.toolview.js',
8
+ factory() {
9
+ function createToolview(env) {
10
+ const { React, h, translate, toolLabel, seatT, ST, dotStyle, helpers, cards, notice } = env
11
+ const { isTowerTool, parsePayload, isPlainObject, truncate } = helpers
12
+ const { rowElement, cardModel } = cards
13
+ const publishNotice = notice.publish
14
+
15
+ // Mounting an already-settled result (history) stays silent; a live
16
+ // preparing/start row and a start→result transition publish.
17
+ function useToolviewLifecycle(props) {
18
+ const phase = props ? props.phase : undefined
19
+ const toolName = props ? props.toolName : undefined
20
+ const callId = props ? props.callId : undefined
21
+ const failed = Boolean(props && props.block && (props.block.isError === true || isPlainObject(props.block.error)))
22
+ const previous = React.useRef ? React.useRef(null) : { current: null }
23
+ React.useEffect(
24
+ function () {
25
+ const before = previous.current
26
+ previous.current = { phase, callId }
27
+ if (!isTowerTool(toolName)) return
28
+ const sameCall = before !== null && before.callId === callId
29
+ if (phase !== 'result') {
30
+ if (!sameCall || before.phase !== phase) publishNotice({ tool: toolName, running: true })
31
+ } else if (sameCall && before.phase !== 'result') {
32
+ publishNotice({ tool: toolName, running: false, ok: failed !== true })
33
+ }
34
+ },
35
+ [phase, callId, toolName, failed],
36
+ )
37
+ }
38
+
39
+ function TowerToolCard(props) {
40
+ const t = seatT(props)
41
+ const toolName = props && typeof props.toolName === 'string' ? props.toolName : ''
42
+ const phase = props && typeof props.phase === 'string' ? props.phase : 'start'
43
+ const running = phase !== 'result'
44
+ useToolviewLifecycle(props)
45
+ let payload = null
46
+ if (!running) payload = parsePayload(props ? props.block : undefined)
47
+ const model = running
48
+ ? { tone: 'idle', headline: translate(t, 'card.running'), rows: [] }
49
+ : cardModel(toolName, payload, t)
50
+ const title =
51
+ toolLabel(t, toolName) +
52
+ ' · ' +
53
+ (running ? translate(t, 'card.running') : model.headline)
54
+ return h(
55
+ 'div',
56
+ {
57
+ 'data-tower-toolview': toolName === '' ? 'tower_unknown' : toolName,
58
+ 'data-tower-phase': phase,
59
+ style: ST.card,
60
+ title,
61
+ },
62
+ h(
63
+ 'div',
64
+ { style: ST.header },
65
+ h('span', { 'aria-hidden': true, style: dotStyle(running ? 'idle' : model.tone) }),
66
+ h('span', { style: ST.title }, translate(t, 'card.title')),
67
+ h('span', { style: ST.dim }, '·'),
68
+ h('span', { style: ST.title }, toolLabel(t, toolName)),
69
+ h('span', { style: running ? ST.dim : model.tone === 'error' ? ST.rowError : ST.dim }, truncate(model.headline, 100)),
70
+ ),
71
+ model.rows.slice(0, 6).map(function (row, index) {
72
+ return rowElement(t, index, row)
73
+ }),
74
+ )
75
+ }
76
+
77
+ return { TowerToolCard }
78
+ }
79
+
80
+ return { createToolview }
81
+ },
82
+ })
@@ -13,6 +13,7 @@
13
13
  "files": [
14
14
  "index.js",
15
15
  "client.js",
16
+ "client.*.js",
16
17
  "cordis.patch.yml",
17
18
  "locale/*.json",
18
19
  "icon.svg",
@@ -1,356 +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
- // Exported with the retry loop so a test can assert the budget and the exact
24
- // transient set instead of copying them (#86).
25
- export const RENAME_ATTEMPTS = 12
26
- export const TRANSIENT_RENAME_ERRORS = new Set(['EPERM', 'EBUSY', 'EACCES'])
27
- const PROCESS_STARTED_AT = Date.now() - Math.round(process.uptime() * 1000)
28
-
29
- export function sleep(ms) {
30
- return new Promise((resolve) => setTimeout(resolve, ms))
31
- }
32
-
33
- function sleepSync(ms) {
34
- try {
35
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)
36
- } catch {
37
- // best effort; a busy loop would be worse
38
- }
39
- }
40
-
41
- // ---------------------------------------------------------------------------
42
- // JSON document I/O
43
- // ---------------------------------------------------------------------------
44
-
45
- function parseJson(file, text) {
46
- try {
47
- return JSON.parse(text)
48
- } catch (error) {
49
- throw new TowerToolError('state-corrupt', `corrupt tower state at ${file}: ${error.message}`)
50
- }
51
- }
52
-
53
- export async function readJson(file) {
54
- return parseJson(file, await fsp.readFile(file, 'utf8'))
55
- }
56
-
57
- /** Synchronous counterpart for the mode store's short critical section. */
58
- export function readJsonSync(file) {
59
- return parseJson(file, fs.readFileSync(file, 'utf8'))
60
- }
61
-
62
- export async function writeJsonAtomic(file, value) {
63
- const dir = path.dirname(file)
64
- const tmp = path.join(dir, `.${path.basename(file)}.tmp-${process.pid}-${crypto.randomUUID()}`)
65
- const handle = await fsp.open(tmp, 'w')
66
- try {
67
- await handle.writeFile(JSON.stringify(value, null, 2), 'utf8')
68
- await handle.sync()
69
- } finally {
70
- await handle.close()
71
- }
72
- await renameWithRetry(tmp, file)
73
- }
74
-
75
- /**
76
- * Windows can reject a rename while an indexer, antivirus, or a concurrent
77
- * reader still holds the destination; retry briefly instead of failing a
78
- * successful mutation. The temp file is removed when the rename never lands.
79
- *
80
- * `options.rename` and `options.sleep` inject the syscall and the backoff so
81
- * both outcomes are testable on any OS (#86); production callers omit them.
82
- */
83
- export async function renameWithRetry(tmp, file, options = {}) {
84
- const rename = options.rename ?? fsp.rename
85
- const wait = options.sleep ?? sleep
86
- for (let attempt = 0; ; attempt += 1) {
87
- try {
88
- await rename(tmp, file)
89
- return
90
- } catch (error) {
91
- if (!TRANSIENT_RENAME_ERRORS.has(error?.code) || attempt >= RENAME_ATTEMPTS) {
92
- await fsp.unlink(tmp).catch(() => {})
93
- throw error
94
- }
95
- await wait(10 + attempt * 15)
96
- }
97
- }
98
- }
99
-
100
- /** Synchronous counterpart used by the mode store's short critical section. */
101
- export function writeJsonAtomicSync(file, value) {
102
- const dir = path.dirname(file)
103
- const tmp = path.join(dir, `.${path.basename(file)}.tmp-${process.pid}-${crypto.randomUUID()}`)
104
- fs.writeFileSync(tmp, JSON.stringify(value, null, 2), 'utf8')
105
- for (let attempt = 0; ; attempt += 1) {
106
- try {
107
- fs.renameSync(tmp, file)
108
- return
109
- } catch (error) {
110
- if (!TRANSIENT_RENAME_ERRORS.has(error?.code) || attempt >= RENAME_ATTEMPTS) {
111
- try {
112
- fs.unlinkSync(tmp)
113
- } catch {
114
- // best effort
115
- }
116
- throw error
117
- }
118
- sleepSync(10 + attempt * 15)
119
- }
120
- }
121
- }
122
-
123
- export async function appendJsonl(file, value) {
124
- await fsp.appendFile(file, `${JSON.stringify(value)}\n`, 'utf8')
125
- }
126
-
127
- // ---------------------------------------------------------------------------
128
- // Lock records
129
- // ---------------------------------------------------------------------------
130
-
131
- /**
132
- * true = process exists, false = provably gone, null = the record carries no
133
- * usable PID. The tri-state matters: a malformed state lock must be reported,
134
- * not silently reclaimed as if its owner were dead.
135
- */
136
- export function processAlive(pid) {
137
- const value = Number(pid)
138
- if (!Number.isInteger(value) || value <= 0) return null
139
- try {
140
- process.kill(value, 0)
141
- return true
142
- } catch (error) {
143
- if (error?.code === 'ESRCH') return false
144
- // EPERM means the process exists but belongs to another user.
145
- return true
146
- }
147
- }
148
-
149
- export function lockRecord(token, extra = {}) {
150
- return { token, pid: process.pid, ...extra, at: Date.now() }
151
- }
152
-
153
- function isDeadLock(record, options) {
154
- const alive = processAlive(record?.pid)
155
- if (alive === false) return true
156
- // The mode store historically treated a lock without a usable PID as dead;
157
- // preserve that where callers opt in, while the state lock stays defensive.
158
- return alive === null && options.invalidPidIsDead === true
159
- }
160
-
161
- // ---------------------------------------------------------------------------
162
- // Lock inspection / reclamation
163
- // ---------------------------------------------------------------------------
164
-
165
- export async function readLock(lockPath) {
166
- try {
167
- const raw = await fsp.readFile(lockPath, 'utf8')
168
- const record = JSON.parse(raw)
169
- return { path: lockPath, record, alive: processAlive(record?.pid), raw }
170
- } catch (error) {
171
- if (error?.code === 'ENOENT') return { path: lockPath, record: null, alive: null, raw: null }
172
- return { path: lockPath, record: null, alive: null, raw: null, error: String(error?.message ?? error) }
173
- }
174
- }
175
-
176
- export function readLockSync(lockPath) {
177
- try {
178
- const raw = fs.readFileSync(lockPath, 'utf8')
179
- const record = JSON.parse(raw)
180
- return { path: lockPath, record, alive: processAlive(record?.pid), raw }
181
- } catch (error) {
182
- if (error?.code === 'ENOENT') return { path: lockPath, record: null, alive: null, raw: null }
183
- return { path: lockPath, record: null, alive: null, raw: null, error: String(error?.message ?? error) }
184
- }
185
- }
186
-
187
- export async function reclaimDeadLock(lockPath, options = {}) {
188
- let original
189
- try {
190
- original = await fsp.readFile(lockPath, 'utf8')
191
- } catch {
192
- return false
193
- }
194
- let parsed
195
- try {
196
- parsed = JSON.parse(original)
197
- } catch {
198
- return false
199
- }
200
- if (!isDeadLock(parsed, options)) return false
201
- // Move, never delete: a concurrent writer may have replaced the record
202
- // between the read and the rename, so verify the quarantined bytes first.
203
- const quarantine = `${lockPath}.stale-${process.pid}-${crypto.randomUUID()}`
204
- try {
205
- await fsp.rename(lockPath, quarantine)
206
- } catch {
207
- return false
208
- }
209
- const moved = await fsp.readFile(quarantine, 'utf8').catch(() => null)
210
- if (moved === null || moved !== original) {
211
- if (moved !== null) await fsp.rename(quarantine, lockPath).catch(() => {})
212
- return false
213
- }
214
- await fsp.unlink(quarantine).catch(() => {})
215
- return true
216
- }
217
-
218
- export function reclaimDeadLockSync(lockPath, options = {}) {
219
- let original
220
- try {
221
- original = fs.readFileSync(lockPath, 'utf8')
222
- } catch {
223
- return false
224
- }
225
- let parsed
226
- try {
227
- parsed = JSON.parse(original)
228
- } catch {
229
- return false
230
- }
231
- if (!isDeadLock(parsed, options)) return false
232
- const quarantine = `${lockPath}.stale-${process.pid}-${crypto.randomUUID()}`
233
- try {
234
- fs.renameSync(lockPath, quarantine)
235
- } catch {
236
- return false
237
- }
238
- let moved = null
239
- try {
240
- moved = fs.readFileSync(quarantine, 'utf8')
241
- } catch {
242
- moved = null
243
- }
244
- if (moved === null || moved !== original) {
245
- if (moved !== null) {
246
- try {
247
- fs.renameSync(quarantine, lockPath)
248
- } catch {
249
- // the newer record could not be restored; never delete it silently
250
- }
251
- }
252
- return false
253
- }
254
- try {
255
- fs.unlinkSync(quarantine)
256
- } catch {
257
- // the lock is already reclaimed; a leftover quarantine is inert
258
- }
259
- return true
260
- }
261
-
262
- // ---------------------------------------------------------------------------
263
- // Lock acquire / token-checked release
264
- // ---------------------------------------------------------------------------
265
-
266
- function handleFor(lockPath, token, record) {
267
- return {
268
- path: lockPath,
269
- token,
270
- record,
271
- async release() {
272
- return releaseLock(lockPath, token)
273
- },
274
- }
275
- }
276
-
277
- function syncHandleFor(lockPath, token, record) {
278
- return {
279
- path: lockPath,
280
- token,
281
- record,
282
- release() {
283
- return releaseLockSync(lockPath, token)
284
- },
285
- }
286
- }
287
-
288
- /**
289
- * Acquire a token-owned lock with a bounded wait. Returns null when another
290
- * writer still holds a live lock at the deadline; callers translate that into
291
- * their own [tower:state-locked] message (or a false return for modes).
292
- */
293
- export async function acquireLock(lockPath, options = {}) {
294
- const token = options.token ?? crypto.randomUUID()
295
- const record = options.record ?? lockRecord(token)
296
- const timeoutMs = Number(options.timeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS)
297
- const pollMs = Number(options.pollMs ?? DEFAULT_LOCK_POLL_MS)
298
- const deadline = Date.now() + Math.max(0, timeoutMs)
299
- for (;;) {
300
- try {
301
- await fsp.writeFile(lockPath, JSON.stringify(record), { flag: 'wx' })
302
- return handleFor(lockPath, token, record)
303
- } catch (error) {
304
- if (error?.code !== 'EEXIST') throw error
305
- if (await reclaimDeadLock(lockPath, options)) continue
306
- if (Date.now() > deadline) return null
307
- await sleep(pollMs)
308
- }
309
- }
310
- }
311
-
312
- export function acquireLockSync(lockPath, options = {}) {
313
- const token = options.token ?? crypto.randomUUID()
314
- const record = options.record ?? lockRecord(token)
315
- const timeoutMs = Number(options.timeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS)
316
- const pollMs = Number(options.pollMs ?? DEFAULT_LOCK_POLL_MS)
317
- const deadline = Date.now() + Math.max(0, timeoutMs)
318
- for (;;) {
319
- try {
320
- fs.writeFileSync(lockPath, JSON.stringify(record), { flag: 'wx' })
321
- return syncHandleFor(lockPath, token, record)
322
- } catch (error) {
323
- if (error?.code !== 'EEXIST') throw error
324
- if (reclaimDeadLockSync(lockPath, options)) continue
325
- if (Date.now() > deadline) return null
326
- sleepSync(pollMs)
327
- }
328
- }
329
- }
330
-
331
- export async function releaseLock(lockPath, token) {
332
- try {
333
- const current = await fsp.readFile(lockPath, 'utf8').catch(() => null)
334
- if (current === null) return false
335
- const parsed = JSON.parse(current)
336
- if (!parsed || parsed.token !== token) return false
337
- await fsp.unlink(lockPath)
338
- return true
339
- } catch {
340
- // a foreign or unreadable lock is not ours to remove
341
- return false
342
- }
343
- }
344
-
345
- export function releaseLockSync(lockPath, token) {
346
- try {
347
- const parsed = JSON.parse(fs.readFileSync(lockPath, 'utf8'))
348
- if (!parsed || parsed.token !== token) return false
349
- fs.unlinkSync(lockPath)
350
- return true
351
- } catch {
352
- return false
353
- }
354
- }
355
-
356
- export { PROCESS_STARTED_AT }
package/lib/store.js DELETED
@@ -1,133 +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
- * Paths objects whose state lock this process currently holds. A state
71
- * migration may be persisted only inside `withLock`: the loader reads this to
72
- * tell a locked mutation (which may write) from a read-only load (which must
73
- * migrate in memory and leave the file alone).
74
- */
75
- const HELD_STATE_LOCKS = new WeakSet()
76
-
77
- /** @param {object} [paths] @returns {boolean} */
78
- export function isStateLockHeld(paths) {
79
- return Boolean(paths) && HELD_STATE_LOCKS.has(paths)
80
- }
81
-
82
- /**
83
- * Hold the repo-wide tower lock for one mutation. A lock whose recorded PID is
84
- * provably dead is reclaimed atomically; a live lock is reported, never stolen.
85
- */
86
- export async function withLock(paths, fn, options = {}) {
87
- const timeoutMs = options.timeoutMs ?? 5000
88
- const pollMs = options.pollMs ?? 25
89
- const token = crypto.randomUUID()
90
- const record = lockRecord(token, { startedAt: PROCESS_STARTED_AT })
91
- const held = await acquireLock(paths.lock, { token, record, timeoutMs, pollMs })
92
- if (!held) {
93
- const owner = await readStateLock(paths)
94
- const ownerRecord = owner.record
95
- const payload = owner.raw ?? (ownerRecord ? JSON.stringify(ownerRecord) : null)
96
- throw new TowerToolError(
97
- 'state-locked',
98
- `tower state is locked by another writer${payload ? ` (${payload})` : ''}; retry after it finishes`,
99
- )
100
- }
101
- const startedAt = Date.now()
102
- HELD_STATE_LOCKS.add(paths)
103
- try {
104
- return await fn(paths)
105
- } finally {
106
- HELD_STATE_LOCKS.delete(paths)
107
- await held.release()
108
- await fsp
109
- .appendFile(path.join(paths.dir, 'lock-holds.jsonl'), `${JSON.stringify({ at: Date.now(), ms: Date.now() - startedAt, pid: process.pid })}\n`, 'utf8')
110
- .catch(() => {})
111
- }
112
- }
113
-
114
- /** Serialize git mutations without holding the state lock. */
115
- export async function withGitLock(paths, fn, options = {}) {
116
- const timeoutMs = options.timeoutMs ?? 60000
117
- const pollMs = options.pollMs ?? 25
118
- const lock = paths.gitLock
119
- const token = crypto.randomUUID()
120
- const startedAt = Date.now()
121
- const held = await acquireLock(lock, { token, record: lockRecord(token, { kind: 'git' }), timeoutMs, pollMs })
122
- if (!held) {
123
- throw new TowerToolError('state-locked', 'another git mutation is in progress for this repository; retry after it finishes')
124
- }
125
- try {
126
- return await fn(paths)
127
- } finally {
128
- await held.release()
129
- await fsp
130
- .appendFile(path.join(paths.dir, 'lock-holds.jsonl'), `${JSON.stringify({ at: Date.now(), ms: Date.now() - startedAt, pid: process.pid, phase: 'git' })}\n`, 'utf8')
131
- .catch(() => {})
132
- }
133
- }
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes