dsh-tower 2.0.3 → 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 (193) hide show
  1. package/CHANGELOG.md +141 -0
  2. package/COMPATIBILITY.md +38 -0
  3. package/CONTRIBUTING.md +150 -2
  4. package/README.en.md +59 -16
  5. package/README.md +57 -16
  6. package/SECURITY.md +31 -1
  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 +33 -1
  33. package/docs/architecture/overview.md +114 -14
  34. package/docs/board.md +72 -0
  35. package/docs/examples/first-mission.md +2 -1
  36. package/docs/faq.md +11 -7
  37. package/docs/guide/hot-reload.md +1 -1
  38. package/docs/guide/install-upgrade-migration.md +22 -6
  39. package/docs/guide/quickstart.md +12 -7
  40. package/docs/guide/release.md +125 -0
  41. package/docs/guide/scenarios-and-philosophy.md +86 -69
  42. package/docs/operations/security.md +65 -14
  43. package/docs/reference/blockers.md +11 -10
  44. package/docs/reference/config.generated.md +66 -0
  45. package/docs/reference/config.md +11 -7
  46. package/docs/reference/errors.generated.md +39 -0
  47. package/docs/reference/errors.md +10 -2
  48. package/docs/reference/events.md +7 -4
  49. package/docs/reference/testing.md +2 -1
  50. package/docs/reference/tools.generated.md +33 -0
  51. package/docs/reference/tools.md +8 -7
  52. package/docs/zh/README.md +4 -3
  53. package/docs/zh/faq.md +9 -6
  54. package/docs/zh/guide/hot-reload.md +1 -1
  55. package/docs/zh/guide/install-upgrade-migration.md +17 -5
  56. package/docs/zh/guide/quickstart.md +13 -8
  57. package/docs/zh/guide/scenarios-and-philosophy.md +55 -50
  58. package/docs/zh/operations/security.md +43 -7
  59. package/docs/zh/reference/blockers.md +10 -10
  60. package/docs/zh/reference/config.md +11 -7
  61. package/docs/zh/reference/errors.md +4 -1
  62. package/docs/zh/reference/events.md +6 -3
  63. package/docs/zh/reference/testing.md +3 -2
  64. package/docs/zh/reference/tools.md +8 -7
  65. package/domain/collab/batch.js +112 -0
  66. package/domain/collab/messages.js +127 -0
  67. package/domain/collab/roster.js +158 -0
  68. package/domain/collab/spawn.js +188 -0
  69. package/domain/config/schema.js +404 -0
  70. package/domain/contracts/outputs.js +279 -0
  71. package/domain/contracts/tool-inputs/collaboration.js +58 -0
  72. package/domain/contracts/tool-inputs/findings.js +29 -0
  73. package/domain/contracts/tool-inputs/index.js +94 -0
  74. package/domain/contracts/tool-inputs/integration.js +37 -0
  75. package/domain/contracts/tool-inputs/missions.js +94 -0
  76. package/domain/contracts/tool-inputs/ops.js +75 -0
  77. package/domain/contracts/tool-inputs/shared.js +25 -0
  78. package/domain/errors.js +96 -0
  79. package/domain/gate.js +125 -0
  80. package/domain/integration/gate.js +176 -0
  81. package/domain/integration/init.js +225 -0
  82. package/domain/integration/liveness.js +31 -0
  83. package/domain/integration/merge.js +377 -0
  84. package/domain/integration/provision.js +124 -0
  85. package/domain/integration/rebase.js +236 -0
  86. package/domain/integration/review.js +52 -0
  87. package/domain/integration/teardown.js +118 -0
  88. package/domain/mission.js +68 -0
  89. package/domain/missions/completion.js +114 -0
  90. package/domain/missions/findings.js +201 -0
  91. package/domain/missions/lifecycle.js +185 -0
  92. package/domain/missions/plan.js +149 -0
  93. package/domain/missions/review.js +179 -0
  94. package/domain/planning.js +118 -0
  95. package/domain/read/enrichedMissions.js +58 -0
  96. package/domain/read/missionContext.js +53 -0
  97. package/domain/read/views.js +128 -0
  98. package/domain/report.js +59 -0
  99. package/domain/roster.js +40 -0
  100. package/domain/scope.js +105 -0
  101. package/domain/state/schema.js +233 -0
  102. package/domain/state/semantics.js +117 -0
  103. package/domain/state.js +33 -0
  104. package/domain/summary.js +114 -0
  105. package/domain/vocabulary.js +179 -0
  106. package/index.js +86 -34
  107. package/lib/briefing.js +176 -0
  108. package/lib/compatibility.js +108 -0
  109. package/lib/config.js +54 -302
  110. package/lib/doctor.js +108 -9
  111. package/lib/git.js +93 -15
  112. package/lib/hmr.js +4 -3
  113. package/lib/leases.js +3 -2
  114. package/lib/legacy.js +20 -9
  115. package/lib/modeState.js +39 -0
  116. package/lib/modeStore.js +35 -33
  117. package/lib/persistence.js +349 -0
  118. package/lib/presets.js +35 -0
  119. package/lib/spawn-prechecks.js +168 -0
  120. package/lib/spawn-support.js +79 -0
  121. package/lib/store.js +57 -85
  122. package/lib/tests.js +46 -11
  123. package/lib/tool-registry.js +544 -0
  124. package/lib/workspaceLock.js +46 -22
  125. package/lib/worktreeOwnership.js +5 -4
  126. package/locale/en.json +23 -1
  127. package/locale/zh.json +22 -0
  128. package/package.json +33 -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 +44 -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 +203 -0
  150. package/scripts/release-check.mjs +303 -0
  151. package/skills/tower-recovery.md +7 -5
  152. package/tools/adopt.js +72 -0
  153. package/tools/batch.js +78 -0
  154. package/tools/complete.js +53 -0
  155. package/tools/diff.js +39 -0
  156. package/tools/doctor.js +50 -0
  157. package/tools/finding.js +40 -0
  158. package/tools/findings.js +39 -0
  159. package/tools/import-legacy.js +42 -0
  160. package/tools/inbox.js +39 -0
  161. package/tools/index.js +39 -0
  162. package/tools/init.js +63 -0
  163. package/tools/interrupt.js +42 -0
  164. package/tools/legacy-map.js +44 -0
  165. package/tools/merge.js +39 -0
  166. package/tools/mission.js +41 -0
  167. package/tools/plan.js +39 -0
  168. package/tools/preflight.js +44 -0
  169. package/tools/rebase.js +39 -0
  170. package/tools/recover.js +45 -0
  171. package/tools/reload.js +52 -0
  172. package/tools/resource.js +54 -0
  173. package/tools/review.js +45 -0
  174. package/tools/roster.js +60 -0
  175. package/tools/send.js +147 -0
  176. package/tools/spawn.js +196 -0
  177. package/tools/status.js +94 -0
  178. package/tools/teardown.js +42 -0
  179. package/tools/test.js +61 -0
  180. package/types/cordis.d.ts +6 -0
  181. package/types/index.d.ts +339 -0
  182. package/ui-bundle/README.md +111 -0
  183. package/ui-bundle/client.js +1980 -0
  184. package/ui-bundle/cordis.patch.yml +3 -0
  185. package/ui-bundle/icon.svg +6 -0
  186. package/ui-bundle/index.js +2 -0
  187. package/ui-bundle/locale/en.json +6 -0
  188. package/ui-bundle/locale/zh.json +6 -0
  189. package/ui-bundle/package.json +35 -0
  190. package/lib/errors.js +0 -111
  191. package/lib/tools.js +0 -2250
  192. package/lib/tower.js +0 -2689
  193. /package/{lib → domain}/batch.js +0 -0
package/lib/git.js CHANGED
@@ -1,21 +1,68 @@
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
8
+ let configuredTimeoutMs = GIT_TIMEOUT_MS
7
9
 
8
- export class GitError extends Error {
9
- constructor(args, detail, code, meta = {}) {
10
- super(`git ${args.join(' ')} failed: ${detail}`)
11
- this.name = 'GitError'
12
- this.code = code
13
- this.timedOut = meta.timedOut === true
14
- this.bufferOverflow = meta.bufferOverflow === true
15
- this.stdout = meta.stdout ?? ''
10
+ /** @typedef {{ timeoutMs?: number, maxBuffer?: number }} GitOptions */
11
+ /** @typedef {{ path: string, branch?: string, head?: string }} WorktreeEntry */
12
+
13
+ /** Override the default Git timeout from resolved Tower config. */
14
+ export function setGitTimeout(ms) {
15
+ const value = Number(ms)
16
+ if (Number.isFinite(value) && value >= 1000) configuredTimeoutMs = value
17
+ }
18
+
19
+ /** Process default retained for manual adapter use; Tower instances pass their own. */
20
+ export function configuredGitTimeout() {
21
+ return configuredTimeoutMs
22
+ }
23
+
24
+ const BLOCKED_GIT_ENV = new Set([
25
+ 'GIT_DIR',
26
+ 'GIT_WORK_TREE',
27
+ 'GIT_INDEX_FILE',
28
+ 'GIT_OBJECT_DIRECTORY',
29
+ 'GIT_ALTERNATE_OBJECT_DIRECTORIES',
30
+ 'GIT_COMMON_DIR',
31
+ 'GIT_NAMESPACE',
32
+ 'GIT_PREFIX',
33
+ 'GIT_CEILING_DIRECTORIES',
34
+ 'GIT_CONFIG_GLOBAL',
35
+ 'GIT_CONFIG_SYSTEM',
36
+ 'GIT_CONFIG_NOSYSTEM',
37
+ 'GIT_DISCOVERY_ACROSS_FILESYSTEM',
38
+ 'GIT_QUARANTINE_PATH',
39
+ ])
40
+
41
+ /**
42
+ * Git must not inherit a repository redirect from the DSH process environment.
43
+ * @param {NodeJS.ProcessEnv} [source]
44
+ * @returns {NodeJS.ProcessEnv}
45
+ */
46
+ export function gitEnv(source = process.env) {
47
+ /** @type {NodeJS.ProcessEnv} */
48
+ const env = {}
49
+ for (const [key, value] of Object.entries(source)) {
50
+ if (BLOCKED_GIT_ENV.has(key)) continue
51
+ env[key] = value
16
52
  }
53
+ return env
17
54
  }
18
55
 
56
+ import { GitError } from '../domain/errors.js'
57
+ export { GitError }
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
+ */
19
66
  export function git(cwd, args, options = {}) {
20
67
  return new Promise((resolve, reject) => {
21
68
  execFile(
@@ -23,10 +70,10 @@ export function git(cwd, args, options = {}) {
23
70
  args,
24
71
  {
25
72
  cwd,
26
- timeout: options.timeoutMs ?? GIT_TIMEOUT_MS,
73
+ timeout: options.timeoutMs ?? configuredTimeoutMs,
27
74
  maxBuffer: options.maxBuffer ?? MAX_BUFFER,
28
75
  windowsHide: true,
29
- env: process.env,
76
+ env: gitEnv(),
30
77
  },
31
78
  (error, stdout, stderr) => {
32
79
  if (error) {
@@ -48,6 +95,13 @@ export function git(cwd, args, options = {}) {
48
95
  })
49
96
  }
50
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
+ */
51
105
  export async function tryGit(cwd, args, options) {
52
106
  try {
53
107
  return await git(cwd, args, options)
@@ -59,17 +113,29 @@ export async function tryGit(cwd, args, options) {
59
113
  /**
60
114
  * Resolve an absolute caller path to the repository root, its common git dir
61
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 }>}
62
118
  */
63
119
  export async function resolveRepo(repoRoot) {
64
120
  if (typeof repoRoot !== 'string' || repoRoot.trim().length === 0) {
65
- throw new Error('repoRoot is required')
121
+ throw new TowerToolError('repo-invalid', 'repoRoot is required')
66
122
  }
67
123
  if (!path.isAbsolute(repoRoot)) {
68
- 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 })
69
131
  }
70
- const top = await git(repoRoot, ['rev-parse', '--show-toplevel'])
71
132
  const root = path.resolve(top)
72
- 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
+ }
73
139
  const commonDir = path.isAbsolute(commonRaw)
74
140
  ? path.resolve(commonRaw)
75
141
  : path.resolve(root, commonRaw)
@@ -81,25 +147,30 @@ export async function resolveRepo(repoRoot) {
81
147
  }
82
148
  }
83
149
 
150
+ /** @param {string} root @returns {Promise<boolean>} */
84
151
  export async function hasAnyCommit(root) {
85
152
  const out = await tryGit(root, ['rev-list', '-n', '1', '--all'])
86
153
  return Boolean(out && out.trim())
87
154
  }
88
155
 
156
+ /** @param {string} root @param {string} branch @returns {Promise<boolean>} */
89
157
  export async function localBranchExists(root, branch) {
90
158
  const out = await tryGit(root, ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`])
91
159
  return out !== null
92
160
  }
93
161
 
162
+ /** @param {string} cwd @param {string} ref @returns {Promise<string>} */
94
163
  export async function revParse(cwd, ref) {
95
164
  const out = await git(cwd, ['rev-parse', ref])
96
165
  return out.trim()
97
166
  }
98
167
 
168
+ /** @param {string} cwd @param {string} branch @returns {Promise<boolean>} */
99
169
  export async function branchExists(cwd, branch) {
100
170
  return (await localBranchExists(cwd, branch)) === true
101
171
  }
102
172
 
173
+ /** @param {string} cwd @returns {Promise<string | null>} */
103
174
  export async function currentBranch(cwd) {
104
175
  const out = await tryGit(cwd, ['symbolic-ref', '--short', '-q', 'HEAD'])
105
176
  return out && out.trim() ? out.trim() : null
@@ -110,9 +181,12 @@ export async function addWorktree(cwd, { path: worktreePath, branch, commit }) {
110
181
  return worktreePath
111
182
  }
112
183
 
184
+ /** @param {string} cwd @returns {Promise<WorktreeEntry[]>} */
113
185
  export async function listWorktrees(cwd) {
114
186
  const out = await git(cwd, ['worktree', 'list', '--porcelain'])
187
+ /** @type {WorktreeEntry[]} */
115
188
  const entries = []
189
+ /** @type {WorktreeEntry | null} */
116
190
  let current = null
117
191
  for (const line of out.split('\n')) {
118
192
  if (line.startsWith('worktree ')) {
@@ -128,22 +202,26 @@ export async function listWorktrees(cwd) {
128
202
  return entries
129
203
  }
130
204
 
205
+ /** @param {string} cwd @returns {Promise<boolean>} */
131
206
  export async function isClean(cwd) {
132
207
  const out = await git(cwd, ['status', '--porcelain'])
133
208
  return out.trim().length === 0
134
209
  }
135
210
 
211
+ /** @param {string} cwd @returns {Promise<string | null>} */
136
212
  export async function currentCommonDir(cwd) {
137
213
  const raw = await tryGit(cwd, ['rev-parse', '--git-common-dir'])
138
214
  if (raw === null) return null
139
215
  return path.isAbsolute(raw) ? path.resolve(raw) : path.resolve(cwd, raw)
140
216
  }
141
217
 
218
+ /** @param {string} cwd @param {string} fromRef @returns {Promise<number>} */
142
219
  export async function countCommits(cwd, fromRef) {
143
220
  const out = await git(cwd, ['rev-list', '--count', `${fromRef}..HEAD`])
144
221
  return Number(out.trim() || 0)
145
222
  }
146
223
 
224
+ /** @param {string} cwd @param {string} ancestor @param {string} descendant @returns {Promise<boolean>} */
147
225
  export async function isAncestor(cwd, ancestor, descendant) {
148
226
  const out = await tryGit(cwd, ['merge-base', '--is-ancestor', ancestor, descendant])
149
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
  }
@@ -62,33 +55,42 @@ export function loadModes(baseDir) {
62
55
  return modes
63
56
  }
64
57
 
65
- /** Persist the current modes atomically; failures never break the command. */
66
- export function saveModes(modes, baseDir, maxEntries = DEFAULT_MAX_ENTRIES) {
58
+ /** Persist the current modes atomically with a short cross-process lock. */
59
+ export function saveModes(modes, baseDir, maxEntries = DEFAULT_MAX_ENTRIES, options = {}) {
60
+ const dir = storeDir(baseDir)
61
+ const file = storeFile(baseDir)
62
+ const lock = path.join(dir, 'modes.lock')
63
+ const timeoutMs = Number(options.timeoutMs) > 0 ? Number(options.timeoutMs) : 5000
67
64
  try {
68
- const dir = storeDir(baseDir)
69
65
  fs.mkdirSync(dir, { recursive: true })
70
- const limit = Number.isInteger(maxEntries) && maxEntries > 0 ? maxEntries : DEFAULT_MAX_ENTRIES
71
- const entries = [...modes.entries()]
72
- .sort((a, b) => (Number(b[1]?.at) || 0) - (Number(a[1]?.at) || 0))
73
- .slice(0, limit)
74
- const payload = { version: 1, modes: Object.fromEntries(entries) }
75
- const file = storeFile(baseDir)
76
- const tmp = `${file}.tmp-${process.pid}`
77
- fs.writeFileSync(tmp, JSON.stringify(payload, null, 2), 'utf8')
78
- let lastError = null
79
- for (let attempt = 0; attempt < 5; attempt += 1) {
66
+ const held = acquireLockSync(lock, { timeoutMs, invalidPidIsDead: true })
67
+ if (!held) return false
68
+ try {
69
+ const limit = Number.isInteger(maxEntries) && maxEntries > 0 ? maxEntries : DEFAULT_MAX_ENTRIES
70
+ let disk = {}
80
71
  try {
81
- fs.renameSync(tmp, file)
82
- return true
83
- } catch (error) {
84
- lastError = error
85
- if (!['EPERM', 'EBUSY', 'EACCES'].includes(error?.code)) break
86
- sleepSync(10 * (attempt + 1))
72
+ const parsed = readJsonSync(file)
73
+ if (parsed && typeof parsed === 'object' && parsed.modes && typeof parsed.modes === 'object') disk = parsed.modes
74
+ } catch {
75
+ disk = {}
76
+ }
77
+ // Merge by timestamp so two sessions saving concurrently do not erase
78
+ // each other's mode changes. A disk-only entry from another process is
79
+ // preserved unless this process has a newer record for the same key.
80
+ const merged = { ...disk }
81
+ for (const [key, value] of modes.entries()) {
82
+ const previous = merged[key]
83
+ if (!previous || (Number(value?.at) || 0) >= (Number(previous?.at) || 0)) merged[key] = value
87
84
  }
85
+ const entries = Object.entries(merged)
86
+ .sort((a, b) => (Number(b[1]?.at) || 0) - (Number(a[1]?.at) || 0))
87
+ .slice(0, limit)
88
+ const payload = { version: 1, modes: Object.fromEntries(entries) }
89
+ writeJsonAtomicSync(file, payload)
90
+ return true
91
+ } finally {
92
+ held.release()
88
93
  }
89
- try { fs.unlinkSync(tmp) } catch {}
90
- if (lastError) return false
91
- return true
92
94
  } catch {
93
95
  return false
94
96
  }