dsh-tower 2.0.4 → 2.1.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 (184) hide show
  1. package/CHANGELOG.md +152 -0
  2. package/COMPATIBILITY.md +39 -0
  3. package/CONTRIBUTING.md +157 -2
  4. package/README.en.md +46 -3
  5. package/README.md +44 -3
  6. package/SECURITY.md +27 -0
  7. package/adapters/default.js +12 -0
  8. package/adapters/store-fs.js +6 -0
  9. package/adapters/vcs-git.js +4 -0
  10. package/app/archive.js +190 -0
  11. package/app/batch.js +79 -0
  12. package/app/board.js +27 -0
  13. package/app/checkCompletion.js +89 -0
  14. package/app/enrichedMissions.js +26 -0
  15. package/app/findings.js +78 -0
  16. package/app/gate.js +126 -0
  17. package/app/init.js +291 -0
  18. package/app/merge.js +573 -0
  19. package/app/messages.js +66 -0
  20. package/app/mission.js +37 -0
  21. package/app/missionContext.js +14 -0
  22. package/app/plan.js +50 -0
  23. package/app/provision.js +193 -0
  24. package/app/read.js +113 -0
  25. package/app/rebase.js +302 -0
  26. package/app/recording.js +72 -0
  27. package/app/review.js +147 -0
  28. package/app/roster.js +123 -0
  29. package/app/spawn.js +125 -0
  30. package/app/teardown.js +205 -0
  31. package/app/tower.js +552 -0
  32. package/cordis.patch.yml +61 -53
  33. package/docs/README.md +3 -1
  34. package/docs/agents/domain.md +38 -0
  35. package/docs/agents/issue-tracker.md +46 -0
  36. package/docs/agents/triage-labels.md +15 -0
  37. package/docs/architecture/overview.md +114 -13
  38. package/docs/board.md +72 -0
  39. package/docs/guide/hot-reload.md +1 -1
  40. package/docs/guide/install-upgrade-migration.md +25 -1
  41. package/docs/guide/release.md +125 -0
  42. package/docs/operations/security.md +47 -2
  43. package/docs/reference/blockers.md +1 -1
  44. package/docs/reference/config.generated.md +70 -0
  45. package/docs/reference/config.md +8 -1
  46. package/docs/reference/doctor.md +58 -8
  47. package/docs/reference/errors.generated.md +41 -0
  48. package/docs/reference/errors.md +7 -3
  49. package/docs/reference/tools.generated.md +33 -0
  50. package/docs/reference/tools.md +7 -6
  51. package/docs/zh/README.md +3 -2
  52. package/docs/zh/guide/hot-reload.md +1 -1
  53. package/docs/zh/guide/install-upgrade-migration.md +18 -1
  54. package/docs/zh/operations/security.md +30 -1
  55. package/docs/zh/reference/blockers.md +1 -1
  56. package/docs/zh/reference/config.md +8 -1
  57. package/docs/zh/reference/doctor.md +35 -2
  58. package/docs/zh/reference/errors.md +6 -2
  59. package/docs/zh/reference/tools.md +3 -3
  60. package/domain/collab/batch.js +112 -0
  61. package/domain/collab/messages.js +127 -0
  62. package/domain/collab/roster.js +158 -0
  63. package/domain/collab/spawn.js +188 -0
  64. package/domain/config/schema.js +427 -0
  65. package/domain/contracts/outputs.js +305 -0
  66. package/domain/contracts/tool-inputs/collaboration.js +58 -0
  67. package/domain/contracts/tool-inputs/findings.js +29 -0
  68. package/domain/contracts/tool-inputs/index.js +94 -0
  69. package/domain/contracts/tool-inputs/integration.js +37 -0
  70. package/domain/contracts/tool-inputs/missions.js +95 -0
  71. package/domain/contracts/tool-inputs/ops.js +75 -0
  72. package/domain/contracts/tool-inputs/shared.js +25 -0
  73. package/domain/errors.js +98 -0
  74. package/domain/gate.js +125 -0
  75. package/domain/integration/gate.js +190 -0
  76. package/domain/integration/init.js +261 -0
  77. package/domain/integration/liveness.js +31 -0
  78. package/domain/integration/merge.js +377 -0
  79. package/domain/integration/provision.js +124 -0
  80. package/domain/integration/rebase.js +236 -0
  81. package/domain/integration/review.js +52 -0
  82. package/domain/integration/teardown.js +118 -0
  83. package/domain/mission.js +68 -0
  84. package/domain/missions/completion.js +167 -0
  85. package/domain/missions/findings.js +201 -0
  86. package/domain/missions/lifecycle.js +186 -0
  87. package/domain/missions/plan.js +149 -0
  88. package/domain/missions/review.js +179 -0
  89. package/domain/planning.js +121 -0
  90. package/domain/read/enrichedMissions.js +58 -0
  91. package/domain/read/missionContext.js +53 -0
  92. package/domain/read/views.js +141 -0
  93. package/domain/report.js +67 -0
  94. package/domain/roster.js +40 -0
  95. package/domain/scope.js +177 -0
  96. package/domain/state/archive.js +257 -0
  97. package/domain/state/migrations.js +145 -0
  98. package/domain/state/schema.js +239 -0
  99. package/domain/state/semantics.js +117 -0
  100. package/domain/state.js +183 -0
  101. package/domain/summary.js +116 -0
  102. package/domain/vocabulary.js +179 -0
  103. package/index.js +63 -20
  104. package/lib/archive.js +279 -0
  105. package/lib/briefing.js +176 -0
  106. package/lib/caseSensitivity.js +66 -0
  107. package/lib/compatibility.js +108 -0
  108. package/lib/config.js +33 -375
  109. package/lib/doctor.js +275 -4
  110. package/lib/git.js +60 -16
  111. package/lib/hmr.js +4 -3
  112. package/lib/leases.js +17 -7
  113. package/lib/legacy.js +39 -14
  114. package/lib/modeState.js +39 -0
  115. package/lib/modeStore.js +11 -43
  116. package/lib/persistence.js +356 -0
  117. package/lib/presets.js +35 -0
  118. package/lib/spawn-prechecks.js +168 -0
  119. package/lib/spawn-support.js +79 -0
  120. package/lib/stateFile.js +99 -0
  121. package/lib/store.js +67 -135
  122. package/lib/tests.js +17 -12
  123. package/lib/tool-registry.js +544 -0
  124. package/lib/workspaceLock.js +2 -2
  125. package/lib/worktreeOwnership.js +22 -10
  126. package/locale/en.json +23 -1
  127. package/locale/zh.json +22 -0
  128. package/package.json +32 -4
  129. package/ports/clock.js +6 -0
  130. package/ports/ids.js +7 -0
  131. package/ports/index.js +2 -0
  132. package/ports/store.js +11 -0
  133. package/ports/vcs.js +11 -0
  134. package/scripts/architecture.mjs +94 -0
  135. package/scripts/check-docs.mjs +9 -3
  136. package/scripts/check-pack.mjs +49 -0
  137. package/scripts/domain-metrics.mjs +48 -0
  138. package/scripts/generate-config-reference.mjs +46 -0
  139. package/scripts/generate-error-reference.mjs +29 -0
  140. package/scripts/generate-tools-reference.mjs +56 -0
  141. package/scripts/issue-audit.mjs +259 -0
  142. package/scripts/lib/cli.mjs +76 -0
  143. package/scripts/lib/tmp.mjs +20 -0
  144. package/scripts/mount-smoke/fixtures.mjs +162 -0
  145. package/scripts/mount-smoke/host.json +4 -0
  146. package/scripts/mount-smoke/probe.mjs +125 -0
  147. package/scripts/mount-smoke/surface.mjs +141 -0
  148. package/scripts/mount-smoke.mjs +270 -0
  149. package/scripts/pack-manifest.txt +212 -0
  150. package/scripts/release-check.mjs +303 -0
  151. package/tools/adopt.js +72 -0
  152. package/tools/batch.js +78 -0
  153. package/tools/complete.js +53 -0
  154. package/tools/diff.js +39 -0
  155. package/tools/doctor.js +50 -0
  156. package/tools/finding.js +40 -0
  157. package/tools/findings.js +39 -0
  158. package/tools/import-legacy.js +42 -0
  159. package/tools/inbox.js +39 -0
  160. package/tools/index.js +39 -0
  161. package/tools/init.js +63 -0
  162. package/tools/interrupt.js +42 -0
  163. package/tools/legacy-map.js +44 -0
  164. package/tools/merge.js +39 -0
  165. package/tools/mission.js +41 -0
  166. package/tools/plan.js +39 -0
  167. package/tools/preflight.js +45 -0
  168. package/tools/rebase.js +39 -0
  169. package/tools/recover.js +45 -0
  170. package/tools/reload.js +52 -0
  171. package/tools/resource.js +54 -0
  172. package/tools/review.js +45 -0
  173. package/tools/roster.js +60 -0
  174. package/tools/send.js +147 -0
  175. package/tools/spawn.js +196 -0
  176. package/tools/status.js +102 -0
  177. package/tools/teardown.js +42 -0
  178. package/tools/test.js +61 -0
  179. package/types/cordis.d.ts +6 -0
  180. package/types/index.d.ts +373 -0
  181. package/lib/errors.js +0 -123
  182. package/lib/tools.js +0 -2460
  183. package/lib/tower.js +0 -2912
  184. /package/{lib → domain}/batch.js +0 -0
package/lib/legacy.js CHANGED
@@ -9,7 +9,7 @@
9
9
  //
10
10
  // `validateLegacyMap` is the audit-only mapping check: a map is a record that
11
11
  // legacy id X corresponds to DSH mission Y. It never adopts or restores work.
12
- import fsp from 'node:fs/promises'
12
+ import { fsp } from '../adapters/store-fs.js'
13
13
  import path from 'node:path'
14
14
  import crypto from 'node:crypto'
15
15
 
@@ -23,6 +23,7 @@ const CODEX_ID_RE = /^T\d{4,}$/
23
23
  const SHA256_RE = /^[a-f0-9]{64}$/
24
24
 
25
25
  function fail(code, message) {
26
+ /** @type {Error & {code?: string}} */
26
27
  const error = new Error(message)
27
28
  error.code = code
28
29
  throw error
@@ -40,13 +41,27 @@ function sha256(buffer) {
40
41
  return crypto.createHash('sha256').update(buffer).digest('hex')
41
42
  }
42
43
 
43
- function win32Lower(value) {
44
- return process.platform === 'win32' ? value.toLowerCase() : value
44
+ // Path semantics are selected from an injected platform so both branches are
45
+ // testable on any OS (#86). On the real platform this is exactly `path`.
46
+ function pathFor(platform) {
47
+ return platform === 'win32' ? path.win32 : path.posix
45
48
  }
46
49
 
47
- function isInside(parent, child) {
48
- const relative = path.relative(win32Lower(path.resolve(parent)), win32Lower(path.resolve(child)))
49
- return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))
50
+ export function win32Lower(value, platform = process.platform) {
51
+ return platform === 'win32' ? value.toLowerCase() : value
52
+ }
53
+
54
+ /**
55
+ * Is `child` inside `parent` (or the same path)? Windows reconciles case
56
+ * because NTFS is case-insensitive; the POSIX branch stays case-sensitive.
57
+ */
58
+ export function isInside(parent, child, platform = process.platform) {
59
+ const pathImpl = pathFor(platform)
60
+ const relative = pathImpl.relative(
61
+ win32Lower(pathImpl.resolve(parent), platform),
62
+ win32Lower(pathImpl.resolve(child), platform),
63
+ )
64
+ return relative === '' || (!relative.startsWith('..') && !pathImpl.isAbsolute(relative))
50
65
  }
51
66
 
52
67
  async function realpathOrResolve(target) {
@@ -58,6 +73,10 @@ async function realpathOrResolve(target) {
58
73
  }
59
74
  }
60
75
 
76
+ /**
77
+ * @param {string} error
78
+ * @param {'tower-codex'|'dsh'|null} [format]
79
+ */
61
80
  function invalidResult(error, format = null) {
62
81
  return {
63
82
  ok: false,
@@ -121,6 +140,11 @@ function validateReviews(reviews, id) {
121
140
  return null
122
141
  }
123
142
 
143
+ /**
144
+ * @param {any} payload
145
+ * @param {'tower-codex'|'dsh'} format
146
+ * @returns {{error:string|null, missions:Array<{id:string,status:string,kind:string}>, missionIds:string[], paused:string[]}}
147
+ */
124
148
  function validateMissions(payload, format) {
125
149
  const statuses = format === 'tower-codex' ? CODEX_STATUSES : DSH_STATUSES
126
150
  const idRe = format === 'tower-codex' ? CODEX_ID_RE : DSH_ID_RE
@@ -130,17 +154,17 @@ function validateMissions(payload, format) {
130
154
  const seen = new Set()
131
155
  for (let index = 0; index < payload.missions.length; index += 1) {
132
156
  const mission = payload.missions[index]
133
- if (!isPlainObject(mission)) return { error: `missions[${index}] must be an object` }
157
+ if (!isPlainObject(mission)) return { error: `missions[${index}] must be an object`, missions: [], missionIds: [], paused: [] }
134
158
  if (typeof mission.id !== 'string' || !idRe.test(mission.id)) {
135
- return { error: `missions[${index}] has unsupported id ${JSON.stringify(mission.id)} (expected ${format === 'tower-codex' ? 'T####' : 'M#'})` }
159
+ return { error: `missions[${index}] has unsupported id ${JSON.stringify(mission.id)} (expected ${format === 'tower-codex' ? 'T####' : 'M#'})`, missions: [], missionIds: [], paused: [] }
136
160
  }
137
- if (seen.has(mission.id)) return { error: `duplicate legacy mission id ${mission.id}` }
161
+ if (seen.has(mission.id)) return { error: `duplicate legacy mission id ${mission.id}`, missions: [], missionIds: [], paused: [] }
138
162
  seen.add(mission.id)
139
163
  if (typeof mission.status !== 'string' || !statuses.includes(mission.status)) {
140
- return { error: `mission ${mission.id} has unknown status ${JSON.stringify(mission.status)}` }
164
+ return { error: `mission ${mission.id} has unknown status ${JSON.stringify(mission.status)}`, missions: [], missionIds: [], paused: [] }
141
165
  }
142
166
  if (mission.kind !== undefined && mission.kind !== 'build' && mission.kind !== 'survey') {
143
- return { error: `mission ${mission.id} has unknown kind ${JSON.stringify(mission.kind)}` }
167
+ return { error: `mission ${mission.id} has unknown kind ${JSON.stringify(mission.kind)}`, missions: [], missionIds: [], paused: [] }
144
168
  }
145
169
  for (const problem of [
146
170
  validateScope(mission.scope, mission.id),
@@ -148,7 +172,7 @@ function validateMissions(payload, format) {
148
172
  validateDeps(mission.deps, mission.id),
149
173
  validateReviews(mission.reviews, mission.id),
150
174
  ]) {
151
- if (problem) return { error: problem }
175
+ if (problem) return { error: problem, missions: [], missionIds: [], paused: [] }
152
176
  }
153
177
  missions.push({ id: mission.id, status: mission.status, kind: mission.kind ?? 'build' })
154
178
  missionIds.push(mission.id)
@@ -180,7 +204,8 @@ export function validateLegacySnapshot(payload) {
180
204
  }
181
205
  if (!Array.isArray(payload.missions)) return invalidResult('legacy snapshot has no missions array')
182
206
 
183
- let format
207
+ /** @type {'tower-codex'|'dsh'} */
208
+ let format = 'dsh'
184
209
  if (payload.missions.length === 0) {
185
210
  format = isNonEmptyString(payload.baseBranch) ? 'tower-codex' : 'dsh'
186
211
  } else {
@@ -236,7 +261,7 @@ export function validateLegacySnapshot(payload) {
236
261
  * same bytes; it never writes state.json, never activates/adopts a mission, and
237
262
  * `paused` stays paused in the returned summary.
238
263
  *
239
- * @returns {{summary:object, snapshot:object}} where `snapshot` is the record
264
+ * @returns {Promise<{summary:object, snapshot:object}>} where `snapshot` is the record
240
265
  * the caller may append to `state.legacySnapshots`.
241
266
  */
242
267
  export async function importLegacySnapshot(input = {}) {
@@ -0,0 +1,39 @@
1
+ // Owned mode state instead of a bare module-level Map.
2
+ export class ModeState {
3
+ constructor(entries) {
4
+ this.map = new Map(entries ?? [])
5
+ }
6
+
7
+ has(key) {
8
+ return this.map.has(key)
9
+ }
10
+
11
+ get(key) {
12
+ return this.map.get(key)
13
+ }
14
+
15
+ set(key, value) {
16
+ this.map.set(key, value)
17
+ return this
18
+ }
19
+
20
+ delete(key) {
21
+ return this.map.delete(key)
22
+ }
23
+
24
+ values() {
25
+ return this.map.values()
26
+ }
27
+
28
+ entries() {
29
+ return this.map.entries()
30
+ }
31
+
32
+ replace(entries) {
33
+ this.map = new Map(entries ?? [])
34
+ }
35
+
36
+ clear() {
37
+ this.map.clear()
38
+ }
39
+ }
package/lib/modeStore.js CHANGED
@@ -3,20 +3,13 @@
3
3
  // The session-projection route needs zod schemas, which this bundle cannot
4
4
  // resolve from the DSH installation on every deployment; a small atomic JSON
5
5
  // store under DSH_HOME/tower-mode keeps the mode across restarts.
6
- import fs from 'node:fs'
6
+ import { fs } from '../adapters/store-fs.js'
7
7
  import os from 'node:os'
8
8
  import path from 'node:path'
9
+ import { acquireLockSync, readJsonSync, writeJsonAtomicSync } from './persistence.js'
9
10
 
10
11
  const DEFAULT_MAX_ENTRIES = 200
11
12
 
12
- function sleepSync(ms) {
13
- try {
14
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)
15
- } catch {
16
- // best effort; a busy loop would be worse
17
- }
18
- }
19
-
20
13
  function defaultStoreDir() {
21
14
  const home = process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
22
15
  return path.join(home, 'tower-mode')
@@ -34,8 +27,7 @@ function storeFile(baseDir) {
34
27
  export function loadModes(baseDir) {
35
28
  const modes = new Map()
36
29
  try {
37
- const raw = fs.readFileSync(storeFile(baseDir), 'utf8')
38
- const parsed = JSON.parse(raw)
30
+ const parsed = readJsonSync(storeFile(baseDir))
39
31
  const entries = parsed && typeof parsed === 'object' && parsed.modes && typeof parsed.modes === 'object'
40
32
  ? parsed.modes
41
33
  : {}
@@ -52,6 +44,7 @@ export function loadModes(baseDir) {
52
44
  lastChange: value.lastChange && typeof value.lastChange === 'object' ? value.lastChange : null,
53
45
  lock,
54
46
  baseBranch: typeof value.baseBranch === 'string' && value.baseBranch.trim() ? value.baseBranch.trim() : null,
47
+ lastDisclosure: typeof value.lastDisclosure === 'string' ? value.lastDisclosure : null,
55
48
  at: Number(value.at) || 0,
56
49
  })
57
50
  }
@@ -63,31 +56,20 @@ export function loadModes(baseDir) {
63
56
  }
64
57
 
65
58
  /** Persist the current modes atomically with a short cross-process lock. */
66
- export function saveModes(modes, baseDir, maxEntries = DEFAULT_MAX_ENTRIES) {
59
+ export function saveModes(modes, baseDir, maxEntries = DEFAULT_MAX_ENTRIES, options = {}) {
67
60
  const dir = storeDir(baseDir)
68
61
  const file = storeFile(baseDir)
69
62
  const lock = path.join(dir, 'modes.lock')
63
+ const timeoutMs = Number(options.timeoutMs) > 0 ? Number(options.timeoutMs) : 5000
70
64
  try {
71
65
  fs.mkdirSync(dir, { recursive: true })
72
- let acquired = false
73
- for (let attempt = 0; attempt < 40; attempt += 1) {
74
- try {
75
- const fd = fs.openSync(lock, 'wx')
76
- fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, at: Date.now() }))
77
- fs.closeSync(fd)
78
- acquired = true
79
- break
80
- } catch (error) {
81
- if (error?.code !== 'EEXIST') throw error
82
- sleepSync(5)
83
- }
84
- }
85
- if (!acquired) return false
66
+ const held = acquireLockSync(lock, { timeoutMs, invalidPidIsDead: true })
67
+ if (!held) return false
86
68
  try {
87
69
  const limit = Number.isInteger(maxEntries) && maxEntries > 0 ? maxEntries : DEFAULT_MAX_ENTRIES
88
70
  let disk = {}
89
71
  try {
90
- const parsed = JSON.parse(fs.readFileSync(file, 'utf8'))
72
+ const parsed = readJsonSync(file)
91
73
  if (parsed && typeof parsed === 'object' && parsed.modes && typeof parsed.modes === 'object') disk = parsed.modes
92
74
  } catch {
93
75
  disk = {}
@@ -104,24 +86,10 @@ export function saveModes(modes, baseDir, maxEntries = DEFAULT_MAX_ENTRIES) {
104
86
  .sort((a, b) => (Number(b[1]?.at) || 0) - (Number(a[1]?.at) || 0))
105
87
  .slice(0, limit)
106
88
  const payload = { version: 1, modes: Object.fromEntries(entries) }
107
- const tmp = `${file}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`
108
- fs.writeFileSync(tmp, JSON.stringify(payload, null, 2), 'utf8')
109
- let lastError = null
110
- for (let attempt = 0; attempt < 5; attempt += 1) {
111
- try {
112
- fs.renameSync(tmp, file)
113
- return true
114
- } catch (error) {
115
- lastError = error
116
- if (!['EPERM', 'EBUSY', 'EACCES'].includes(error?.code)) break
117
- sleepSync(10 * (attempt + 1))
118
- }
119
- }
120
- try { fs.unlinkSync(tmp) } catch {}
121
- if (lastError) return false
89
+ writeJsonAtomicSync(file, payload)
122
90
  return true
123
91
  } finally {
124
- try { fs.unlinkSync(lock) } catch {}
92
+ held.release()
125
93
  }
126
94
  } catch {
127
95
  return false
@@ -0,0 +1,356 @@
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/presets.js ADDED
@@ -0,0 +1,35 @@
1
+ // Cross-field preset validation (#64): fail before work starts, not at spawn.
2
+ import { TowerToolError } from '../domain/errors.js'
3
+
4
+ export function presetIds(config = {}) {
5
+ return {
6
+ worker: String(config.spawn?.workerPreset ?? 'tower-worker'),
7
+ reviewer: String(config.spawn?.reviewerPreset ?? 'tower-reviewer'),
8
+ }
9
+ }
10
+
11
+ function rowId(preset) {
12
+ return preset === 'tower-worker' ? 'preset-tower-worker' : preset === 'tower-reviewer' ? 'preset-tower-reviewer' : preset
13
+ }
14
+
15
+ /**
16
+ * Resolve both presets against the profile's agentPresets service. Fail closed
17
+ * with preset-missing before mode entry/planning when a required preset is not
18
+ * registered. If the service is unavailable, doctor reports that separately and
19
+ * this check cannot make a claim either way.
20
+ */
21
+ export async function assertPresetsAvailable(ctx, config = {}) {
22
+ const service = ctx?.get?.('agentPresets')
23
+ if (!service || typeof service.list !== 'function') return { checked: false, missing: [] }
24
+ const presets = presetIds(config)
25
+ const available = new Set((await service.list()).map((entry) => String(entry.id)))
26
+ const missing = [presets.worker, presets.reviewer].filter((id) => !available.has(id))
27
+ if (missing.length > 0) {
28
+ const rows = [...new Set(missing.map(rowId))]
29
+ throw new TowerToolError(
30
+ 'preset-missing',
31
+ `required Tower agent preset(s) missing: ${missing.join(', ')}; re-enable ${rows.join(', ')} in the plugin page, then restart DSH`,
32
+ )
33
+ }
34
+ return { checked: true, missing: [] }
35
+ }