dsh-tower 2.0.4 → 2.1.0-beta.1

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 (171) hide show
  1. package/CHANGELOG.md +101 -0
  2. package/COMPATIBILITY.md +38 -0
  3. package/CONTRIBUTING.md +150 -2
  4. package/README.en.md +40 -3
  5. package/README.md +38 -3
  6. package/SECURITY.md +27 -0
  7. package/adapters/default.js +10 -0
  8. package/adapters/store-fs.js +6 -0
  9. package/adapters/vcs-git.js +4 -0
  10. package/app/batch.js +79 -0
  11. package/app/board.js +25 -0
  12. package/app/checkCompletion.js +76 -0
  13. package/app/enrichedMissions.js +26 -0
  14. package/app/findings.js +78 -0
  15. package/app/gate.js +108 -0
  16. package/app/init.js +249 -0
  17. package/app/merge.js +567 -0
  18. package/app/messages.js +66 -0
  19. package/app/mission.js +35 -0
  20. package/app/missionContext.js +14 -0
  21. package/app/plan.js +42 -0
  22. package/app/provision.js +193 -0
  23. package/app/read.js +57 -0
  24. package/app/rebase.js +302 -0
  25. package/app/recording.js +72 -0
  26. package/app/review.js +145 -0
  27. package/app/roster.js +123 -0
  28. package/app/spawn.js +125 -0
  29. package/app/teardown.js +184 -0
  30. package/app/tower.js +524 -0
  31. package/cordis.patch.yml +61 -53
  32. package/docs/README.md +3 -1
  33. package/docs/architecture/overview.md +111 -12
  34. package/docs/board.md +72 -0
  35. package/docs/guide/hot-reload.md +1 -1
  36. package/docs/guide/install-upgrade-migration.md +8 -1
  37. package/docs/guide/release.md +125 -0
  38. package/docs/operations/security.md +47 -2
  39. package/docs/reference/config.generated.md +66 -0
  40. package/docs/reference/config.md +4 -1
  41. package/docs/reference/errors.generated.md +39 -0
  42. package/docs/reference/errors.md +4 -2
  43. package/docs/reference/tools.generated.md +33 -0
  44. package/docs/reference/tools.md +1 -1
  45. package/docs/zh/README.md +3 -2
  46. package/docs/zh/guide/hot-reload.md +1 -1
  47. package/docs/zh/guide/install-upgrade-migration.md +6 -1
  48. package/docs/zh/operations/security.md +30 -1
  49. package/docs/zh/reference/config.md +4 -1
  50. package/docs/zh/reference/errors.md +3 -1
  51. package/docs/zh/reference/tools.md +1 -1
  52. package/domain/collab/batch.js +112 -0
  53. package/domain/collab/messages.js +127 -0
  54. package/domain/collab/roster.js +158 -0
  55. package/domain/collab/spawn.js +188 -0
  56. package/domain/config/schema.js +404 -0
  57. package/domain/contracts/outputs.js +279 -0
  58. package/domain/contracts/tool-inputs/collaboration.js +58 -0
  59. package/domain/contracts/tool-inputs/findings.js +29 -0
  60. package/domain/contracts/tool-inputs/index.js +94 -0
  61. package/domain/contracts/tool-inputs/integration.js +37 -0
  62. package/domain/contracts/tool-inputs/missions.js +94 -0
  63. package/domain/contracts/tool-inputs/ops.js +75 -0
  64. package/domain/contracts/tool-inputs/shared.js +25 -0
  65. package/domain/errors.js +96 -0
  66. package/domain/gate.js +125 -0
  67. package/domain/integration/gate.js +176 -0
  68. package/domain/integration/init.js +225 -0
  69. package/domain/integration/liveness.js +31 -0
  70. package/domain/integration/merge.js +377 -0
  71. package/domain/integration/provision.js +124 -0
  72. package/domain/integration/rebase.js +236 -0
  73. package/domain/integration/review.js +52 -0
  74. package/domain/integration/teardown.js +118 -0
  75. package/domain/mission.js +68 -0
  76. package/domain/missions/completion.js +114 -0
  77. package/domain/missions/findings.js +201 -0
  78. package/domain/missions/lifecycle.js +185 -0
  79. package/domain/missions/plan.js +149 -0
  80. package/domain/missions/review.js +179 -0
  81. package/domain/planning.js +118 -0
  82. package/domain/read/enrichedMissions.js +58 -0
  83. package/domain/read/missionContext.js +53 -0
  84. package/domain/read/views.js +128 -0
  85. package/domain/report.js +59 -0
  86. package/domain/roster.js +40 -0
  87. package/domain/scope.js +105 -0
  88. package/domain/state/schema.js +233 -0
  89. package/domain/state/semantics.js +117 -0
  90. package/domain/state.js +33 -0
  91. package/domain/summary.js +114 -0
  92. package/domain/vocabulary.js +179 -0
  93. package/index.js +63 -20
  94. package/lib/briefing.js +176 -0
  95. package/lib/compatibility.js +108 -0
  96. package/lib/config.js +33 -375
  97. package/lib/doctor.js +97 -4
  98. package/lib/git.js +60 -16
  99. package/lib/hmr.js +4 -3
  100. package/lib/leases.js +3 -2
  101. package/lib/legacy.js +20 -9
  102. package/lib/modeState.js +39 -0
  103. package/lib/modeStore.js +11 -43
  104. package/lib/persistence.js +349 -0
  105. package/lib/presets.js +35 -0
  106. package/lib/spawn-prechecks.js +168 -0
  107. package/lib/spawn-support.js +79 -0
  108. package/lib/store.js +54 -137
  109. package/lib/tests.js +11 -10
  110. package/lib/tool-registry.js +544 -0
  111. package/lib/workspaceLock.js +2 -2
  112. package/lib/worktreeOwnership.js +5 -4
  113. package/locale/en.json +23 -1
  114. package/locale/zh.json +22 -0
  115. package/package.json +32 -4
  116. package/ports/clock.js +6 -0
  117. package/ports/ids.js +7 -0
  118. package/ports/index.js +2 -0
  119. package/ports/store.js +11 -0
  120. package/ports/vcs.js +11 -0
  121. package/scripts/architecture.mjs +94 -0
  122. package/scripts/check-docs.mjs +9 -3
  123. package/scripts/check-pack.mjs +49 -0
  124. package/scripts/domain-metrics.mjs +48 -0
  125. package/scripts/generate-config-reference.mjs +46 -0
  126. package/scripts/generate-error-reference.mjs +29 -0
  127. package/scripts/generate-tools-reference.mjs +56 -0
  128. package/scripts/issue-audit.mjs +259 -0
  129. package/scripts/lib/cli.mjs +76 -0
  130. package/scripts/lib/tmp.mjs +20 -0
  131. package/scripts/mount-smoke/fixtures.mjs +162 -0
  132. package/scripts/mount-smoke/host.json +4 -0
  133. package/scripts/mount-smoke/probe.mjs +125 -0
  134. package/scripts/mount-smoke/surface.mjs +141 -0
  135. package/scripts/mount-smoke.mjs +270 -0
  136. package/scripts/pack-manifest.txt +203 -0
  137. package/scripts/release-check.mjs +303 -0
  138. package/tools/adopt.js +72 -0
  139. package/tools/batch.js +78 -0
  140. package/tools/complete.js +53 -0
  141. package/tools/diff.js +39 -0
  142. package/tools/doctor.js +50 -0
  143. package/tools/finding.js +40 -0
  144. package/tools/findings.js +39 -0
  145. package/tools/import-legacy.js +42 -0
  146. package/tools/inbox.js +39 -0
  147. package/tools/index.js +39 -0
  148. package/tools/init.js +63 -0
  149. package/tools/interrupt.js +42 -0
  150. package/tools/legacy-map.js +44 -0
  151. package/tools/merge.js +39 -0
  152. package/tools/mission.js +41 -0
  153. package/tools/plan.js +39 -0
  154. package/tools/preflight.js +44 -0
  155. package/tools/rebase.js +39 -0
  156. package/tools/recover.js +45 -0
  157. package/tools/reload.js +52 -0
  158. package/tools/resource.js +54 -0
  159. package/tools/review.js +45 -0
  160. package/tools/roster.js +60 -0
  161. package/tools/send.js +147 -0
  162. package/tools/spawn.js +196 -0
  163. package/tools/status.js +94 -0
  164. package/tools/teardown.js +42 -0
  165. package/tools/test.js +61 -0
  166. package/types/cordis.d.ts +6 -0
  167. package/types/index.d.ts +339 -0
  168. package/lib/errors.js +0 -123
  169. package/lib/tools.js +0 -2460
  170. package/lib/tower.js +0 -2912
  171. /package/{lib → domain}/batch.js +0 -0
package/lib/git.js CHANGED
@@ -1,17 +1,26 @@
1
+ import { TowerToolError } from '../domain/errors.js'
1
2
  // Minimal git port: array arguments only, bounded output, no shell.
2
- import { execFile } from 'node:child_process'
3
+ import { execFile } from '../adapters/vcs-git.js'
3
4
  import path from 'node:path'
4
5
 
5
6
  const GIT_TIMEOUT_MS = 15000
6
7
  const MAX_BUFFER = 16 * 1024 * 1024
7
8
  let configuredTimeoutMs = GIT_TIMEOUT_MS
8
9
 
10
+ /** @typedef {{ timeoutMs?: number, maxBuffer?: number }} GitOptions */
11
+ /** @typedef {{ path: string, branch?: string, head?: string }} WorktreeEntry */
12
+
9
13
  /** Override the default Git timeout from resolved Tower config. */
10
14
  export function setGitTimeout(ms) {
11
15
  const value = Number(ms)
12
16
  if (Number.isFinite(value) && value >= 1000) configuredTimeoutMs = value
13
17
  }
14
18
 
19
+ /** Process default retained for manual adapter use; Tower instances pass their own. */
20
+ export function configuredGitTimeout() {
21
+ return configuredTimeoutMs
22
+ }
23
+
15
24
  const BLOCKED_GIT_ENV = new Set([
16
25
  'GIT_DIR',
17
26
  'GIT_WORK_TREE',
@@ -29,8 +38,13 @@ const BLOCKED_GIT_ENV = new Set([
29
38
  'GIT_QUARANTINE_PATH',
30
39
  ])
31
40
 
32
- /** Git must not inherit a repository redirect from the DSH process environment. */
41
+ /**
42
+ * Git must not inherit a repository redirect from the DSH process environment.
43
+ * @param {NodeJS.ProcessEnv} [source]
44
+ * @returns {NodeJS.ProcessEnv}
45
+ */
33
46
  export function gitEnv(source = process.env) {
47
+ /** @type {NodeJS.ProcessEnv} */
34
48
  const env = {}
35
49
  for (const [key, value] of Object.entries(source)) {
36
50
  if (BLOCKED_GIT_ENV.has(key)) continue
@@ -39,17 +53,16 @@ export function gitEnv(source = process.env) {
39
53
  return env
40
54
  }
41
55
 
42
- export class GitError extends Error {
43
- constructor(args, detail, code, meta = {}) {
44
- super(`git ${args.join(' ')} failed: ${detail}`)
45
- this.name = 'GitError'
46
- this.code = code
47
- this.timedOut = meta.timedOut === true
48
- this.bufferOverflow = meta.bufferOverflow === true
49
- this.stdout = meta.stdout ?? ''
50
- }
51
- }
56
+ import { GitError } from '../domain/errors.js'
57
+ export { GitError }
52
58
 
59
+ /**
60
+ * Run one git command with bounded output and no shell.
61
+ * @param {string} cwd
62
+ * @param {string[]} args
63
+ * @param {GitOptions} [options]
64
+ * @returns {Promise<string>}
65
+ */
53
66
  export function git(cwd, args, options = {}) {
54
67
  return new Promise((resolve, reject) => {
55
68
  execFile(
@@ -82,6 +95,13 @@ export function git(cwd, args, options = {}) {
82
95
  })
83
96
  }
84
97
 
98
+ /**
99
+ * Run git when its failure is not an error (existence probes).
100
+ * @param {string} cwd
101
+ * @param {string[]} args
102
+ * @param {GitOptions} [options]
103
+ * @returns {Promise<string | null>}
104
+ */
85
105
  export async function tryGit(cwd, args, options) {
86
106
  try {
87
107
  return await git(cwd, args, options)
@@ -93,17 +113,29 @@ export async function tryGit(cwd, args, options) {
93
113
  /**
94
114
  * Resolve an absolute caller path to the repository root, its common git dir
95
115
  * (shared by all worktrees), and the current branch of that checkout.
116
+ * @param {string} repoRoot
117
+ * @returns {Promise<{ root: string, commonDir: string, branch: string | null }>}
96
118
  */
97
119
  export async function resolveRepo(repoRoot) {
98
120
  if (typeof repoRoot !== 'string' || repoRoot.trim().length === 0) {
99
- throw new Error('repoRoot is required')
121
+ throw new TowerToolError('repo-invalid', 'repoRoot is required')
100
122
  }
101
123
  if (!path.isAbsolute(repoRoot)) {
102
- throw new Error(`repoRoot must be an absolute path: ${repoRoot}`)
124
+ throw new TowerToolError('repo-invalid', `repoRoot must be an absolute path: ${repoRoot}`)
125
+ }
126
+ let top
127
+ try {
128
+ top = await git(repoRoot, ['rev-parse', '--show-toplevel'])
129
+ } catch (error) {
130
+ throw new TowerToolError('repo-invalid', `not a git repository: ${repoRoot}`, { cause: error })
103
131
  }
104
- const top = await git(repoRoot, ['rev-parse', '--show-toplevel'])
105
132
  const root = path.resolve(top)
106
- const commonRaw = await git(root, ['rev-parse', '--git-common-dir'])
133
+ let commonRaw
134
+ try {
135
+ commonRaw = await git(root, ['rev-parse', '--git-common-dir'])
136
+ } catch (error) {
137
+ throw new TowerToolError('repo-invalid', `not a git repository: ${root}`, { cause: error })
138
+ }
107
139
  const commonDir = path.isAbsolute(commonRaw)
108
140
  ? path.resolve(commonRaw)
109
141
  : path.resolve(root, commonRaw)
@@ -115,25 +147,30 @@ export async function resolveRepo(repoRoot) {
115
147
  }
116
148
  }
117
149
 
150
+ /** @param {string} root @returns {Promise<boolean>} */
118
151
  export async function hasAnyCommit(root) {
119
152
  const out = await tryGit(root, ['rev-list', '-n', '1', '--all'])
120
153
  return Boolean(out && out.trim())
121
154
  }
122
155
 
156
+ /** @param {string} root @param {string} branch @returns {Promise<boolean>} */
123
157
  export async function localBranchExists(root, branch) {
124
158
  const out = await tryGit(root, ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`])
125
159
  return out !== null
126
160
  }
127
161
 
162
+ /** @param {string} cwd @param {string} ref @returns {Promise<string>} */
128
163
  export async function revParse(cwd, ref) {
129
164
  const out = await git(cwd, ['rev-parse', ref])
130
165
  return out.trim()
131
166
  }
132
167
 
168
+ /** @param {string} cwd @param {string} branch @returns {Promise<boolean>} */
133
169
  export async function branchExists(cwd, branch) {
134
170
  return (await localBranchExists(cwd, branch)) === true
135
171
  }
136
172
 
173
+ /** @param {string} cwd @returns {Promise<string | null>} */
137
174
  export async function currentBranch(cwd) {
138
175
  const out = await tryGit(cwd, ['symbolic-ref', '--short', '-q', 'HEAD'])
139
176
  return out && out.trim() ? out.trim() : null
@@ -144,9 +181,12 @@ export async function addWorktree(cwd, { path: worktreePath, branch, commit }) {
144
181
  return worktreePath
145
182
  }
146
183
 
184
+ /** @param {string} cwd @returns {Promise<WorktreeEntry[]>} */
147
185
  export async function listWorktrees(cwd) {
148
186
  const out = await git(cwd, ['worktree', 'list', '--porcelain'])
187
+ /** @type {WorktreeEntry[]} */
149
188
  const entries = []
189
+ /** @type {WorktreeEntry | null} */
150
190
  let current = null
151
191
  for (const line of out.split('\n')) {
152
192
  if (line.startsWith('worktree ')) {
@@ -162,22 +202,26 @@ export async function listWorktrees(cwd) {
162
202
  return entries
163
203
  }
164
204
 
205
+ /** @param {string} cwd @returns {Promise<boolean>} */
165
206
  export async function isClean(cwd) {
166
207
  const out = await git(cwd, ['status', '--porcelain'])
167
208
  return out.trim().length === 0
168
209
  }
169
210
 
211
+ /** @param {string} cwd @returns {Promise<string | null>} */
170
212
  export async function currentCommonDir(cwd) {
171
213
  const raw = await tryGit(cwd, ['rev-parse', '--git-common-dir'])
172
214
  if (raw === null) return null
173
215
  return path.isAbsolute(raw) ? path.resolve(raw) : path.resolve(cwd, raw)
174
216
  }
175
217
 
218
+ /** @param {string} cwd @param {string} fromRef @returns {Promise<number>} */
176
219
  export async function countCommits(cwd, fromRef) {
177
220
  const out = await git(cwd, ['rev-list', '--count', `${fromRef}..HEAD`])
178
221
  return Number(out.trim() || 0)
179
222
  }
180
223
 
224
+ /** @param {string} cwd @param {string} ancestor @param {string} descendant @returns {Promise<boolean>} */
181
225
  export async function isAncestor(cwd, ancestor, descendant) {
182
226
  const out = await tryGit(cwd, ['merge-base', '--is-ancestor', ancestor, descendant])
183
227
  return out !== null
package/lib/hmr.js CHANGED
@@ -18,10 +18,11 @@
18
18
  // `restartReason` means the whole process must be restarted.
19
19
  // - `runExclusive` rejects nested transactions; a tool call is never inside
20
20
  // one, so a tool-triggered reload is safe.
21
- import { TowerToolError } from './errors.js'
21
+ import { TowerToolError } from '../domain/errors.js'
22
22
 
23
23
  /** Entry ids Tower expects to be active after a reconciliation. */
24
24
  export const DEFAULT_REQUIRED_IDS = Object.freeze([
25
+ 'tower-bundle',
25
26
  'tower',
26
27
  'tower-skills',
27
28
  'preset-tower-worker',
@@ -94,7 +95,7 @@ function warn(logger, message) {
94
95
  * @param {string[]} [options.requiredIds] Entry ids whose inactivity is reported as a failure.
95
96
  * @param {number} [options.delayMs] Delay before a scheduled reload runs.
96
97
  * @param {{ warn?: Function }} [options.logger] Optional logger for failed scheduled reloads.
97
- * @returns {{ available: boolean, requiredIds: string[], delayMs: number, status: Function, schedule: Function, runNow: Function }}
98
+ * @returns {{ available: boolean, requiredIds: string[], delayMs: number, status: Function, schedule: Function, runNow: Function, dispose: Function }}
98
99
  */
99
100
  export function createHmrBridge(ctx, options = {}) {
100
101
  const requiredIds = Object.freeze(normalizeRequiredIds(options.requiredIds))
@@ -186,7 +187,7 @@ export function createHmrBridge(ctx, options = {}) {
186
187
  get available() {
187
188
  return available()
188
189
  },
189
- requiredIds,
190
+ requiredIds: [...requiredIds],
190
191
  delayMs,
191
192
  status,
192
193
  schedule,
package/lib/leases.js CHANGED
@@ -11,7 +11,7 @@
11
11
  // Fail-closed protocol: a held, expired, or token-mismatched lease is reported,
12
12
  // never silently taken over. Only the token holder may release a lease, and
13
13
  // releasing an expired lease requires an explicit `force: true`.
14
- import fsp from 'node:fs/promises'
14
+ import { fsp } from '../adapters/store-fs.js'
15
15
  import path from 'node:path'
16
16
  import crypto from 'node:crypto'
17
17
 
@@ -28,6 +28,7 @@ const LOCK_POLL_MS = 20
28
28
  const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/
29
29
 
30
30
  function fail(code, message) {
31
+ /** @type {Error & {code?: string}} */
31
32
  const error = new Error(message)
32
33
  error.code = code
33
34
  throw error
@@ -147,7 +148,7 @@ async function lockIsStale(lockFile) {
147
148
  } catch {
148
149
  at = null
149
150
  }
150
- const age = Number.isFinite(at) ? Date.now() - at : Date.now() - stat.mtimeMs
151
+ const age = at !== null && Number.isFinite(at) ? Date.now() - at : Date.now() - stat.mtimeMs
151
152
  return age > LOCK_STALE_MS
152
153
  }
153
154
 
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
@@ -58,6 +59,10 @@ async function realpathOrResolve(target) {
58
59
  }
59
60
  }
60
61
 
62
+ /**
63
+ * @param {string} error
64
+ * @param {'tower-codex'|'dsh'|null} [format]
65
+ */
61
66
  function invalidResult(error, format = null) {
62
67
  return {
63
68
  ok: false,
@@ -121,6 +126,11 @@ function validateReviews(reviews, id) {
121
126
  return null
122
127
  }
123
128
 
129
+ /**
130
+ * @param {any} payload
131
+ * @param {'tower-codex'|'dsh'} format
132
+ * @returns {{error:string|null, missions:Array<{id:string,status:string,kind:string}>, missionIds:string[], paused:string[]}}
133
+ */
124
134
  function validateMissions(payload, format) {
125
135
  const statuses = format === 'tower-codex' ? CODEX_STATUSES : DSH_STATUSES
126
136
  const idRe = format === 'tower-codex' ? CODEX_ID_RE : DSH_ID_RE
@@ -130,17 +140,17 @@ function validateMissions(payload, format) {
130
140
  const seen = new Set()
131
141
  for (let index = 0; index < payload.missions.length; index += 1) {
132
142
  const mission = payload.missions[index]
133
- if (!isPlainObject(mission)) return { error: `missions[${index}] must be an object` }
143
+ if (!isPlainObject(mission)) return { error: `missions[${index}] must be an object`, missions: [], missionIds: [], paused: [] }
134
144
  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#'})` }
145
+ return { error: `missions[${index}] has unsupported id ${JSON.stringify(mission.id)} (expected ${format === 'tower-codex' ? 'T####' : 'M#'})`, missions: [], missionIds: [], paused: [] }
136
146
  }
137
- if (seen.has(mission.id)) return { error: `duplicate legacy mission id ${mission.id}` }
147
+ if (seen.has(mission.id)) return { error: `duplicate legacy mission id ${mission.id}`, missions: [], missionIds: [], paused: [] }
138
148
  seen.add(mission.id)
139
149
  if (typeof mission.status !== 'string' || !statuses.includes(mission.status)) {
140
- return { error: `mission ${mission.id} has unknown status ${JSON.stringify(mission.status)}` }
150
+ return { error: `mission ${mission.id} has unknown status ${JSON.stringify(mission.status)}`, missions: [], missionIds: [], paused: [] }
141
151
  }
142
152
  if (mission.kind !== undefined && mission.kind !== 'build' && mission.kind !== 'survey') {
143
- return { error: `mission ${mission.id} has unknown kind ${JSON.stringify(mission.kind)}` }
153
+ return { error: `mission ${mission.id} has unknown kind ${JSON.stringify(mission.kind)}`, missions: [], missionIds: [], paused: [] }
144
154
  }
145
155
  for (const problem of [
146
156
  validateScope(mission.scope, mission.id),
@@ -148,7 +158,7 @@ function validateMissions(payload, format) {
148
158
  validateDeps(mission.deps, mission.id),
149
159
  validateReviews(mission.reviews, mission.id),
150
160
  ]) {
151
- if (problem) return { error: problem }
161
+ if (problem) return { error: problem, missions: [], missionIds: [], paused: [] }
152
162
  }
153
163
  missions.push({ id: mission.id, status: mission.status, kind: mission.kind ?? 'build' })
154
164
  missionIds.push(mission.id)
@@ -180,7 +190,8 @@ export function validateLegacySnapshot(payload) {
180
190
  }
181
191
  if (!Array.isArray(payload.missions)) return invalidResult('legacy snapshot has no missions array')
182
192
 
183
- let format
193
+ /** @type {'tower-codex'|'dsh'} */
194
+ let format = 'dsh'
184
195
  if (payload.missions.length === 0) {
185
196
  format = isNonEmptyString(payload.baseBranch) ? 'tower-codex' : 'dsh'
186
197
  } else {
@@ -236,7 +247,7 @@ export function validateLegacySnapshot(payload) {
236
247
  * same bytes; it never writes state.json, never activates/adopts a mission, and
237
248
  * `paused` stays paused in the returned summary.
238
249
  *
239
- * @returns {{summary:object, snapshot:object}} where `snapshot` is the record
250
+ * @returns {Promise<{summary:object, snapshot:object}>} where `snapshot` is the record
240
251
  * the caller may append to `state.legacySnapshots`.
241
252
  */
242
253
  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