dsh-tower 2.1.0-beta.1 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (188) hide show
  1. package/CHANGELOG.md +154 -0
  2. package/COMPATIBILITY.md +6 -1
  3. package/CONTRIBUTING.md +71 -5
  4. package/PRINCIPLES.md +145 -0
  5. package/README.en.md +14 -5
  6. package/README.md +14 -5
  7. package/SECURITY.md +6 -0
  8. package/adapters/archive.js +279 -0
  9. package/adapters/caseSensitivity.js +66 -0
  10. package/adapters/default.js +51 -8
  11. package/{lib → adapters}/git.js +1 -1
  12. package/{lib → adapters}/leases.js +15 -6
  13. package/{lib → adapters}/legacy.js +20 -6
  14. package/adapters/persistence.js +543 -0
  15. package/adapters/stateFile.js +99 -0
  16. package/adapters/store-fs.js +28 -1
  17. package/adapters/store.js +204 -0
  18. package/{lib → adapters}/tests.js +15 -7
  19. package/adapters/vcs-git.js +15 -1
  20. package/{lib → adapters}/workspaceLock.js +1 -1
  21. package/{lib → adapters}/worktreeOwnership.js +18 -7
  22. package/app/archive.js +188 -0
  23. package/app/batch.js +9 -10
  24. package/app/board.js +4 -4
  25. package/app/checkCompletion.js +23 -11
  26. package/app/enrichedMissions.js +2 -4
  27. package/app/findings.js +6 -7
  28. package/app/gate.js +32 -15
  29. package/app/init.js +59 -22
  30. package/app/merge.js +64 -51
  31. package/app/messages.js +7 -8
  32. package/app/mission.js +7 -5
  33. package/app/plan.js +20 -9
  34. package/app/provision.js +12 -14
  35. package/app/read.js +65 -6
  36. package/app/rebase.js +14 -16
  37. package/app/recording.js +7 -8
  38. package/app/review.js +17 -8
  39. package/app/roster.js +14 -15
  40. package/{lib → app}/spawn-prechecks.js +4 -3
  41. package/app/spawn.js +12 -13
  42. package/app/teardown.js +28 -8
  43. package/app/tower.js +121 -39
  44. package/docs/README.md +6 -2
  45. package/docs/adr/0001-ports-adapters-as-data.md +49 -0
  46. package/docs/adr/0002-single-state-json.md +50 -0
  47. package/docs/adr/0003-allow-agent-control-default-true.md +48 -0
  48. package/docs/adr/0004-surface-budget.md +50 -0
  49. package/docs/adr/README.md +51 -0
  50. package/docs/agents/domain.md +38 -0
  51. package/docs/agents/issue-tracker.md +46 -0
  52. package/docs/agents/triage-labels.md +15 -0
  53. package/docs/agents/workflow.md +51 -0
  54. package/docs/architecture/overview.md +170 -37
  55. package/docs/guide/hot-reload.md +1 -1
  56. package/docs/guide/install-upgrade-migration.md +18 -1
  57. package/docs/guide/release.md +1 -1
  58. package/docs/operations/incident.md +82 -0
  59. package/docs/operations/runbook.md +12 -3
  60. package/docs/reference/blockers.md +3 -3
  61. package/docs/reference/config.generated.md +5 -0
  62. package/docs/reference/config.md +4 -0
  63. package/docs/reference/doctor.md +104 -8
  64. package/docs/reference/errors.generated.md +41 -35
  65. package/docs/reference/errors.md +54 -40
  66. package/docs/reference/events.md +38 -6
  67. package/docs/reference/surface-budget.md +128 -0
  68. package/docs/reference/tools.generated.md +9 -8
  69. package/docs/reference/tools.md +9 -8
  70. package/docs/zh/guide/hot-reload.md +1 -1
  71. package/docs/zh/guide/install-upgrade-migration.md +13 -1
  72. package/docs/zh/reference/blockers.md +3 -3
  73. package/docs/zh/reference/config.md +4 -0
  74. package/docs/zh/reference/doctor.md +63 -2
  75. package/docs/zh/reference/errors.md +4 -2
  76. package/docs/zh/reference/tools.md +6 -6
  77. package/domain/batch.js +2 -2
  78. package/domain/config/schema.js +51 -4
  79. package/domain/contracts/outputs.js +33 -3
  80. package/domain/contracts/tool-inputs/missions.js +2 -1
  81. package/domain/contracts/tool-inputs/ops.js +1 -0
  82. package/domain/errors.js +297 -40
  83. package/domain/events.js +207 -0
  84. package/domain/integration/gate.js +41 -12
  85. package/domain/integration/init.js +40 -4
  86. package/domain/integration/merge.js +35 -0
  87. package/domain/lockBudget.js +30 -0
  88. package/domain/mission.js +1 -1
  89. package/domain/missions/completion.js +72 -5
  90. package/domain/missions/lifecycle.js +11 -4
  91. package/domain/missions/plan.js +14 -0
  92. package/domain/missions/review.js +90 -7
  93. package/domain/planning.js +6 -3
  94. package/domain/read/views.js +17 -1
  95. package/domain/report.js +9 -1
  96. package/domain/scope.js +83 -11
  97. package/domain/state/archive.js +257 -0
  98. package/domain/state/migrations.js +145 -0
  99. package/domain/state/schema.js +6 -0
  100. package/domain/state.js +154 -4
  101. package/domain/summary.js +2 -0
  102. package/domain/surface.js +248 -0
  103. package/host/doctor.js +555 -0
  104. package/{lib → host}/modeStore.js +1 -1
  105. package/{lib → host}/tool-registry.js +106 -31
  106. package/host/tower.js +32 -0
  107. package/index.js +12 -9
  108. package/package.json +10 -3
  109. package/ports/archive.js +18 -0
  110. package/ports/caseSensitivity.js +15 -0
  111. package/ports/clock.js +10 -2
  112. package/ports/config.js +19 -0
  113. package/ports/contract.js +38 -0
  114. package/ports/events.js +24 -0
  115. package/ports/ids.js +10 -2
  116. package/ports/index.js +54 -2
  117. package/ports/ownership.js +17 -0
  118. package/ports/state.js +16 -0
  119. package/ports/store.js +52 -3
  120. package/ports/vcs.js +25 -4
  121. package/scripts/architecture-waivers.json +19 -0
  122. package/scripts/architecture.mjs +634 -23
  123. package/scripts/check-docs.mjs +8 -35
  124. package/scripts/check-pack.mjs +68 -39
  125. package/scripts/check-philosophy.mjs +266 -0
  126. package/scripts/domain-metrics.mjs +13 -4
  127. package/scripts/generate-config-reference.mjs +14 -1
  128. package/scripts/generate-error-reference.mjs +68 -14
  129. package/scripts/generate-events-reference.mjs +83 -0
  130. package/scripts/generate-tools-reference.mjs +11 -0
  131. package/scripts/issue-audit.mjs +2 -2
  132. package/scripts/lib/surface.mjs +87 -0
  133. package/scripts/pack-manifest.txt +64 -19
  134. package/scripts/regression-audit.mjs +164 -0
  135. package/scripts/release-check.mjs +1 -1
  136. package/scripts/surface-baseline.json +21 -0
  137. package/scripts/surface-baseline.mjs +120 -0
  138. package/tools/adopt.js +2 -3
  139. package/tools/batch.js +2 -3
  140. package/tools/complete.js +3 -4
  141. package/tools/diff.js +3 -3
  142. package/tools/doctor.js +7 -10
  143. package/tools/finding.js +2 -3
  144. package/tools/findings.js +3 -3
  145. package/tools/import-legacy.js +3 -5
  146. package/tools/inbox.js +3 -3
  147. package/tools/index.js +7 -7
  148. package/tools/init.js +4 -7
  149. package/tools/interrupt.js +2 -3
  150. package/tools/legacy-map.js +3 -5
  151. package/tools/merge.js +3 -3
  152. package/tools/mission.js +2 -3
  153. package/tools/plan.js +7 -6
  154. package/tools/preflight.js +3 -3
  155. package/tools/rebase.js +3 -3
  156. package/tools/recover.js +38 -13
  157. package/tools/reload.js +3 -6
  158. package/tools/resource.js +3 -5
  159. package/tools/review.js +3 -4
  160. package/tools/roster.js +2 -3
  161. package/tools/send.js +2 -2
  162. package/tools/spawn.js +8 -5
  163. package/tools/status.js +16 -8
  164. package/tools/teardown.js +3 -4
  165. package/tools/test.js +2 -2
  166. package/types/index.d.ts +35 -1
  167. package/ui-bundle/README.md +37 -3
  168. package/ui-bundle/client.cards.js +356 -0
  169. package/ui-bundle/client.denied.js +142 -0
  170. package/ui-bundle/client.dock.js +333 -0
  171. package/ui-bundle/client.helpers.js +271 -0
  172. package/ui-bundle/client.js +79 -1747
  173. package/ui-bundle/client.locales.js +247 -0
  174. package/ui-bundle/client.mode.js +206 -0
  175. package/ui-bundle/client.overlay.js +64 -0
  176. package/ui-bundle/client.styles.js +151 -0
  177. package/ui-bundle/client.toolview.js +82 -0
  178. package/ui-bundle/package.json +1 -0
  179. package/lib/doctor.js +0 -270
  180. package/lib/persistence.js +0 -349
  181. package/lib/store.js +0 -118
  182. /package/{lib → app}/briefing.js +0 -0
  183. /package/{lib → app}/spawn-support.js +0 -0
  184. /package/{lib → host}/compatibility.js +0 -0
  185. /package/{lib → host}/config.js +0 -0
  186. /package/{lib → host}/hmr.js +0 -0
  187. /package/{lib → host}/modeState.js +0 -0
  188. /package/{lib → host}/presets.js +0 -0
@@ -0,0 +1,279 @@
1
+ // Terminal-mission archive storage (#73).
2
+ //
3
+ // Layout, under the Tower state directory:
4
+ //
5
+ // archive/index.json compact entries; tower_status pages from here
6
+ // archive/<year>.json full missions plus their linked reviews/findings
7
+ //
8
+ // Both files carry `archiveVersion` (independent of the state SCHEMA_VERSION).
9
+ // Reads are deliberately tolerant: a truncated or foreign archive returns a
10
+ // diagnostic result and never throws, so `tower_status` still renders the hot
11
+ // state. Writes happen before the state document is compacted, and dedupe by
12
+ // mission id, so a crash between the two files is recoverable on the next
13
+ // mutation instead of losing a mission.
14
+ import path from 'node:path'
15
+ import { fsp } from './store-fs.js'
16
+ import {
17
+ ARCHIVE_VERSION,
18
+ archiveDocumentError,
19
+ dedupeById,
20
+ indexEntryFromArchived,
21
+ } from '../domain/state/archive.js'
22
+
23
+ /** @typedef {import('../types/index.js').Mission} Mission */
24
+
25
+ /**
26
+ * @param {{ dir: string }} paths
27
+ * @returns {{ dir: string, index: string, year: (year: number) => string }}
28
+ */
29
+ export function archivePaths(paths) {
30
+ const dir = path.join(paths.dir, 'archive')
31
+ return { dir, index: path.join(dir, 'index.json'), year: (year) => path.join(dir, `${year}.json`) }
32
+ }
33
+
34
+ /**
35
+ * @param {any} error
36
+ * @returns {boolean}
37
+ */
38
+ function isMissing(error) {
39
+ return error?.code === 'ENOENT'
40
+ }
41
+
42
+ /**
43
+ * @param {string} where
44
+ * @param {string} message
45
+ * @returns {string}
46
+ */
47
+ function diagnostic(where, message) {
48
+ return `tower archive is unreadable at ${where}: ${message}`
49
+ }
50
+
51
+ /**
52
+ * Read `archive/index.json` without throwing. A missing index means "no
53
+ * archive yet"; a corrupt one is reported as `ok:false` with the diagnostic.
54
+ * @param {{ readJson: (file: string) => Promise<any> }} store
55
+ * @param {{ dir: string }} paths
56
+ * @returns {Promise<{ ok: boolean, file: string, missions: any[], error?: string, exists: boolean }>}
57
+ */
58
+ export async function readArchiveIndex(store, paths) {
59
+ const file = archivePaths(paths).index
60
+ let document
61
+ try {
62
+ document = await store.readJson(file)
63
+ } catch (error) {
64
+ if (isMissing(error)) return { ok: true, file, missions: [], exists: false }
65
+ return { ok: false, file, missions: [], error: diagnostic(file, String(error?.message ?? error)), exists: true }
66
+ }
67
+ const error = archiveDocumentError(document, 'index', ` at ${file}`)
68
+ if (error) return { ok: false, file, missions: [], error, exists: true }
69
+ return { ok: true, file, missions: document.missions, exists: true }
70
+ }
71
+
72
+ /**
73
+ * @param {{ readJson: (file: string) => Promise<any> }} store
74
+ * @param {{ dir: string }} paths
75
+ * @param {number} year
76
+ * @returns {Promise<{ ok: boolean, file: string, missions: any[], error?: string, exists: boolean }>}
77
+ */
78
+ export async function readArchiveYear(store, paths, year) {
79
+ const file = archivePaths(paths).year(year)
80
+ let document
81
+ try {
82
+ document = await store.readJson(file)
83
+ } catch (error) {
84
+ if (isMissing(error)) return { ok: true, file, missions: [], exists: false }
85
+ return { ok: false, file, missions: [], error: diagnostic(file, String(error?.message ?? error)), exists: true }
86
+ }
87
+ const error = archiveDocumentError(document, 'year', ` at ${file}`)
88
+ if (error) return { ok: false, file, missions: [], error, exists: true }
89
+ return { ok: true, file, missions: document.missions, exists: true }
90
+ }
91
+
92
+ /**
93
+ * Append full entries to their year files, then to the index. Returns
94
+ * `ok:false` (and writes nothing for the index) when an existing archive file
95
+ * is unreadable, so a corrupt archive is never silently replaced.
96
+ * @param {{ readJson: (file: string) => Promise<any>, writeJsonAtomic: (file: string, value: unknown) => Promise<void>, ensureDir: (dir: string) => Promise<void> }} store
97
+ * @param {{ dir: string }} paths
98
+ * @param {{ entries: Array<Record<string, any>>, now: number }} input
99
+ * @returns {Promise<{ ok: boolean, count: number, files: string[], error?: string }>}
100
+ */
101
+ export async function writeArchiveEntries(store, paths, { entries, now }) {
102
+ if (entries.length === 0) return { ok: true, count: 0, files: [] }
103
+ const target = archivePaths(paths)
104
+
105
+ // Read the index first: if it is corrupt, refusing to write keeps the old
106
+ // history byte-identical and leaves the state missions intact for a retry.
107
+ const index = await readArchiveIndex(store, paths)
108
+ if (!index.ok) return { ok: false, count: 0, files: [], error: index.error }
109
+
110
+ /** @type {Map<number, Array<Record<string, any>>>} */
111
+ const byYear = new Map()
112
+ for (const entry of entries) {
113
+ const year = entry.archivedYear
114
+ if (!Number.isInteger(year)) return { ok: false, count: 0, files: [], error: `archive entry ${entry.id} has no archivedYear` }
115
+ const bucket = byYear.get(year) ?? []
116
+ bucket.push(entry)
117
+ byYear.set(year, bucket)
118
+ }
119
+
120
+ /** @type {Array<{ year: number, file: string, missions: any[] }>} */
121
+ const yearDocuments = []
122
+ for (const [year, batch] of [...byYear.entries()].sort((left, right) => left[0] - right[0])) {
123
+ const existing = await readArchiveYear(store, paths, year)
124
+ if (!existing.ok) return { ok: false, count: 0, files: [], error: existing.error }
125
+ yearDocuments.push({
126
+ year,
127
+ file: existing.file,
128
+ missions: dedupeById([...existing.missions, ...batch]),
129
+ })
130
+ }
131
+
132
+ await store.ensureDir(target.dir)
133
+ for (const document of yearDocuments) {
134
+ await store.writeJsonAtomic(document.file, {
135
+ archiveVersion: ARCHIVE_VERSION,
136
+ year: document.year,
137
+ updatedAt: now,
138
+ missions: document.missions,
139
+ })
140
+ }
141
+
142
+ const indexed = dedupeById([...index.missions, ...entries.map(indexEntryFromArchived)])
143
+ await store.writeJsonAtomic(target.index, {
144
+ archiveVersion: ARCHIVE_VERSION,
145
+ updatedAt: now,
146
+ missions: indexed,
147
+ })
148
+
149
+ return {
150
+ ok: true,
151
+ count: entries.length,
152
+ files: [...yearDocuments.map((document) => document.file), target.index],
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Load one archived mission with its linked reviews/findings.
158
+ * @param {{ readJson: (file: string) => Promise<any> }} store
159
+ * @param {{ dir: string }} paths
160
+ * @param {string} missionId
161
+ * @returns {Promise<{ ok: boolean, entry?: Record<string, any>, unreadable?: boolean, missing?: boolean, error?: string }>}
162
+ */
163
+ export async function readArchivedEntry(store, paths, missionId) {
164
+ const index = await readArchiveIndex(store, paths)
165
+ if (!index.ok) return { ok: false, unreadable: true, error: index.error }
166
+ const indexEntry = index.missions.find((entry) => entry.id === missionId)
167
+ if (!indexEntry) return { ok: false, missing: true }
168
+ const year = await readArchiveYear(store, paths, indexEntry.archivedYear)
169
+ if (!year.ok) return { ok: false, unreadable: true, error: year.error }
170
+ const entry = year.missions.find((candidate) => candidate.id === missionId)
171
+ if (!entry) {
172
+ return {
173
+ ok: false,
174
+ unreadable: true,
175
+ error: `tower archive index lists ${missionId} in ${year.file}, but the year file has no such entry`,
176
+ }
177
+ }
178
+ return { ok: true, entry }
179
+ }
180
+
181
+ /**
182
+ * @param {string} file
183
+ * @param {string} kind
184
+ * @returns {Promise<{ file: string, kind: string, ok: boolean, bytes: number, missions: number, error?: string, document?: any }>}
185
+ */
186
+ async function inspectFile(file, kind) {
187
+ let text
188
+ try {
189
+ text = await fsp.readFile(file, 'utf8')
190
+ } catch (error) {
191
+ return { file, kind, ok: false, bytes: 0, missions: 0, error: diagnostic(file, String(error?.message ?? error)) }
192
+ }
193
+ const bytes = Buffer.byteLength(text)
194
+ let document
195
+ try {
196
+ document = JSON.parse(text)
197
+ } catch (error) {
198
+ return { file, kind, ok: false, bytes, missions: 0, error: diagnostic(file, `invalid JSON (${error.message})`) }
199
+ }
200
+ const error = archiveDocumentError(document, /** @type {'index'|'year'} */ (kind), ` at ${file}`)
201
+ if (error) return { file, kind, ok: false, bytes, missions: 0, error, document }
202
+ return { file, kind, ok: true, bytes, missions: document.missions.length, document }
203
+ }
204
+
205
+ /**
206
+ * Read-only inventory for `tower_doctor`: file sizes, mission counts, year
207
+ * files and every unreadable file's diagnostic. A missing archive directory is
208
+ * a healthy empty archive, not an error.
209
+ * @param {{ dir: string, state?: string }} paths
210
+ * @returns {Promise<{
211
+ * dir: string, exists: boolean, bytes: number, missions: number, years: number[],
212
+ * files: Array<Record<string, any>>, unreadable: string[],
213
+ * index: { ok: boolean, count: number, error?: string } | null,
214
+ * }>}
215
+ */
216
+ export async function inspectArchive(paths) {
217
+ const target = archivePaths(/** @type {{ dir: string }} */ (paths))
218
+ let names
219
+ try {
220
+ names = await fsp.readdir(target.dir)
221
+ } catch (error) {
222
+ if (isMissing(error)) {
223
+ return { dir: target.dir, exists: false, bytes: 0, missions: 0, years: [], files: [], unreadable: [], index: null }
224
+ }
225
+ return {
226
+ dir: target.dir,
227
+ exists: true,
228
+ bytes: 0,
229
+ missions: 0,
230
+ years: [],
231
+ files: [],
232
+ unreadable: [diagnostic(target.dir, String(error?.message ?? error))],
233
+ index: null,
234
+ }
235
+ }
236
+
237
+ const jsonFiles = names.filter((name) => name.endsWith('.json')).sort()
238
+ const files = []
239
+ let bytes = 0
240
+ let index = null
241
+ const yearIds = new Set()
242
+ for (const name of jsonFiles) {
243
+ const file = path.join(target.dir, name)
244
+ const kind = name === 'index.json' ? 'index' : 'year'
245
+ const inspected = await inspectFile(file, kind)
246
+ files.push({
247
+ file,
248
+ kind,
249
+ ok: inspected.ok,
250
+ bytes: inspected.bytes,
251
+ missions: inspected.missions,
252
+ ...(inspected.error ? { error: inspected.error } : {}),
253
+ })
254
+ bytes += inspected.bytes
255
+ if (kind === 'index') {
256
+ index = { ok: inspected.ok, count: inspected.missions, ...(inspected.error ? { error: inspected.error } : {}) }
257
+ } else if (inspected.ok) {
258
+ for (const entry of inspected.document.missions) yearIds.add(entry.id)
259
+ }
260
+ }
261
+ const unreadable = files
262
+ .filter((file) => !file.ok && typeof file.error === 'string')
263
+ .map((file) => /** @type {string} */ (file.error))
264
+ const years = files
265
+ .filter((file) => file.kind === 'year' && file.ok)
266
+ .map((file) => Number(path.basename(file.file, '.json')))
267
+ .filter((year) => Number.isInteger(year))
268
+ .sort((left, right) => left - right)
269
+ return {
270
+ dir: target.dir,
271
+ exists: true,
272
+ bytes,
273
+ missions: index?.ok ? index.count : yearIds.size,
274
+ years,
275
+ files,
276
+ unreadable,
277
+ index,
278
+ }
279
+ }
@@ -0,0 +1,66 @@
1
+ // Case-insensitive-filesystem detection (#90).
2
+ //
3
+ // On Windows and macOS the checkout's filesystem folds case, while Git reports
4
+ // the casing stored in the index. Scope matching has to know which world it is
5
+ // in, so `tower_init` probes once and records the answer in state.
6
+ //
7
+ // The probe creates one uniquely-named mixed-case file in the checkout root
8
+ // (the filesystem scope matching actually compares) and looks its lower-cased
9
+ // name up. It returns:
10
+ // true the lookup found the file: the filesystem folds case;
11
+ // false the lookup was ENOENT: the filesystem is case-sensitive;
12
+ // null the probe could not run (read-only checkout, EPERM, an unexpected
13
+ // stat failure). "Unknown" is deliberately not "case-sensitive": init
14
+ // keeps working and `tower_doctor` reports the safe default.
15
+ import path from 'node:path'
16
+ import { fsp } from './store-fs.js'
17
+
18
+ /** Prefix is public so tests and support code can recognise a leaked probe. */
19
+ export const CASE_PROBE_PREFIX = '.tower-case-probe-'
20
+
21
+ /**
22
+ * A name that is guaranteed to differ from its lower-cased form, so the lookup
23
+ * actually distinguishes the two filesystem behaviours.
24
+ * @param {() => number} random
25
+ * @returns {string}
26
+ */
27
+ function probeName(random) {
28
+ return `${CASE_PROBE_PREFIX}Z${random().toString(36).slice(2, 10)}`
29
+ }
30
+
31
+ /**
32
+ * @param {string} dir directory on the filesystem under test
33
+ * @param {{ fs?: typeof fsp, name?: string, random?: () => number }} [options]
34
+ * `fs` is a seam for the failure branches, which cannot be produced portably
35
+ * by permissions on every OS (Windows does not honour chmod the way POSIX does).
36
+ * @returns {Promise<boolean | null>}
37
+ */
38
+ export async function detectCaseSensitivity(dir, options = {}) {
39
+ const fs = options.fs ?? fsp
40
+ const name = options.name ?? probeName(options.random ?? Math.random)
41
+ const probePath = path.join(dir, name)
42
+ const lowerPath = path.join(dir, name.toLowerCase())
43
+ if (lowerPath === probePath) return null
44
+
45
+ try {
46
+ await fs.writeFile(probePath, '')
47
+ } catch {
48
+ return null
49
+ }
50
+
51
+ try {
52
+ await fs.stat(lowerPath)
53
+ return true
54
+ } catch (error) {
55
+ const code = /** @type {{ code?: string }} */ (error)?.code
56
+ return code === 'ENOENT' ? false : null
57
+ } finally {
58
+ // Best effort: a leaked probe would dirty `git status` of the state dir and
59
+ // the detection result is already known.
60
+ try {
61
+ await fs.unlink(probePath)
62
+ } catch {
63
+ // the probe file may already be gone (or the failure branch never wrote it)
64
+ }
65
+ }
66
+ }
@@ -1,10 +1,53 @@
1
- // Default composition root for Tower dependencies (#50).
2
- import * as store from '../lib/store.js'
3
- import * as vcs from '../lib/git.js'
1
+ // Default composition root for the Tower ports (#50, #69).
2
+ //
3
+ // This module is the one place where the concrete adapters (node:fs,
4
+ // node:child_process) are turned into the plain deps object the app layer
5
+ // takes. `createDefaultDeps()` is called once by the host entry (index.js); the
6
+ // app never imports an adapter. Tests and embedders use the memoised
7
+ // `defaultDeps` (or host/tower.js) so the process has exactly one adapter set.
8
+ import crypto from 'node:crypto'
9
+ import * as archive from './archive.js'
10
+ import * as caseSensitivity from './caseSensitivity.js'
11
+ import * as config from '../host/config.js'
12
+ import { loadStateFile } from './stateFile.js'
13
+ import * as ownership from './worktreeOwnership.js'
14
+ import { createStorePort } from './store-fs.js'
15
+ import { createVcsPort } from './vcs-git.js'
16
+ import { NOOP_EVENT_PORT } from '../ports/events.js'
4
17
 
5
- export const defaultDeps = {
6
- store,
7
- vcs,
8
- clock: { now: () => Date.now() },
9
- ids: { uuid: () => crypto.randomUUID() },
18
+ /**
19
+ * Assemble a dependency object implementing every port in ports/.
20
+ * @param {Partial<Record<'store'|'vcs'|'config'|'state'|'archive'|'ownership'|'caseSensitivity'|'clock'|'ids'|'events', any>>} [overrides]
21
+ * @returns {Record<string, any>}
22
+ */
23
+ export function createDefaultDeps(overrides = {}) {
24
+ const store = createStorePort()
25
+ return {
26
+ store,
27
+ vcs: createVcsPort(),
28
+ config: {
29
+ runtimeConfig: config.runtimeConfig,
30
+ resolveRepoConfig: config.resolveRepoConfig,
31
+ },
32
+ state: { loadStateFile },
33
+ archive: {
34
+ readIndex: archive.readArchiveIndex,
35
+ readEntry: archive.readArchivedEntry,
36
+ writeEntries: archive.writeArchiveEntries,
37
+ },
38
+ ownership: {
39
+ assertOwnership: ownership.assertOwnership,
40
+ canonicalPath: ownership.canonicalPath,
41
+ },
42
+ caseSensitivity: { detect: caseSensitivity.detectCaseSensitivity },
43
+ clock: { now: () => Date.now() },
44
+ ids: { uuid: () => crypto.randomUUID(), shortHash: store.shortHash },
45
+ // The host entry replaces this with the Cordis emitter; default deps (and
46
+ // tests/embedders) keep the no-op channel so events never fail a tool.
47
+ events: NOOP_EVENT_PORT,
48
+ ...overrides,
49
+ }
10
50
  }
51
+
52
+ /** The single process-wide default adapter set. */
53
+ export const defaultDeps = createDefaultDeps()
@@ -1,6 +1,6 @@
1
1
  import { TowerToolError } from '../domain/errors.js'
2
2
  // Minimal git port: array arguments only, bounded output, no shell.
3
- import { execFile } from '../adapters/vcs-git.js'
3
+ import { execFile } from './vcs-git.js'
4
4
  import path from 'node:path'
5
5
 
6
6
  const GIT_TIMEOUT_MS = 15000
@@ -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 '../adapters/store-fs.js'
14
+ import { fsp } from './store-fs.js'
15
15
  import path from 'node:path'
16
16
  import crypto from 'node:crypto'
17
17
 
@@ -27,6 +27,11 @@ const LOCK_STALE_MS = 10000
27
27
  const LOCK_POLL_MS = 20
28
28
  const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/
29
29
 
30
+ // Exported with the retry loop so a test can assert the budget and the exact
31
+ // transient set instead of copying them (#86).
32
+ export const UNLINK_ATTEMPTS = 12
33
+ export const TRANSIENT_UNLINK_ERRORS = new Set(['EPERM', 'EBUSY', 'EACCES'])
34
+
30
35
  function fail(code, message) {
31
36
  /** @type {Error & {code?: string}} */
32
37
  const error = new Error(message)
@@ -57,17 +62,21 @@ export function leasesLockPath(commonDir) {
57
62
  /**
58
63
  * Windows can reject unlink while an indexer, antivirus, or a concurrent
59
64
  * reader still holds the file; retry briefly instead of failing the mutation.
65
+ *
66
+ * `options.unlink` and `options.sleep` inject the syscall and the backoff so
67
+ * both outcomes are testable on any OS (#86); production callers omit them.
60
68
  */
61
- async function unlinkWithRetry(file) {
69
+ export async function unlinkWithRetry(file, options = {}) {
70
+ const unlink = options.unlink ?? fsp.unlink
71
+ const wait = options.sleep ?? sleep
62
72
  for (let attempt = 0; ; attempt += 1) {
63
73
  try {
64
- await fsp.unlink(file)
74
+ await unlink(file)
65
75
  return true
66
76
  } catch (error) {
67
77
  if (error?.code === 'ENOENT') return false
68
- const transient = error?.code === 'EPERM' || error?.code === 'EBUSY' || error?.code === 'EACCES'
69
- if (!transient || attempt >= 12) throw error
70
- await sleep(10 + attempt * 15)
78
+ if (!TRANSIENT_UNLINK_ERRORS.has(error?.code) || attempt >= UNLINK_ATTEMPTS) throw error
79
+ await wait(10 + attempt * 15)
71
80
  }
72
81
  }
73
82
  }
@@ -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 '../adapters/store-fs.js'
12
+ import { fsp } from './store-fs.js'
13
13
  import path from 'node:path'
14
14
  import crypto from 'node:crypto'
15
15
 
@@ -41,13 +41,27 @@ function sha256(buffer) {
41
41
  return crypto.createHash('sha256').update(buffer).digest('hex')
42
42
  }
43
43
 
44
- function win32Lower(value) {
45
- 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
46
48
  }
47
49
 
48
- function isInside(parent, child) {
49
- const relative = path.relative(win32Lower(path.resolve(parent)), win32Lower(path.resolve(child)))
50
- 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))
51
65
  }
52
66
 
53
67
  async function realpathOrResolve(target) {