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.
- package/CHANGELOG.md +154 -0
- package/COMPATIBILITY.md +6 -1
- package/CONTRIBUTING.md +71 -5
- package/PRINCIPLES.md +145 -0
- package/README.en.md +14 -5
- package/README.md +14 -5
- package/SECURITY.md +6 -0
- package/adapters/archive.js +279 -0
- package/adapters/caseSensitivity.js +66 -0
- package/adapters/default.js +51 -8
- package/{lib → adapters}/git.js +1 -1
- package/{lib → adapters}/leases.js +15 -6
- package/{lib → adapters}/legacy.js +20 -6
- package/adapters/persistence.js +543 -0
- package/adapters/stateFile.js +99 -0
- package/adapters/store-fs.js +28 -1
- package/adapters/store.js +204 -0
- package/{lib → adapters}/tests.js +15 -7
- package/adapters/vcs-git.js +15 -1
- package/{lib → adapters}/workspaceLock.js +1 -1
- package/{lib → adapters}/worktreeOwnership.js +18 -7
- package/app/archive.js +188 -0
- package/app/batch.js +9 -10
- package/app/board.js +4 -4
- package/app/checkCompletion.js +23 -11
- package/app/enrichedMissions.js +2 -4
- package/app/findings.js +6 -7
- package/app/gate.js +32 -15
- package/app/init.js +59 -22
- package/app/merge.js +64 -51
- package/app/messages.js +7 -8
- package/app/mission.js +7 -5
- package/app/plan.js +20 -9
- package/app/provision.js +12 -14
- package/app/read.js +65 -6
- package/app/rebase.js +14 -16
- package/app/recording.js +7 -8
- package/app/review.js +17 -8
- package/app/roster.js +14 -15
- package/{lib → app}/spawn-prechecks.js +4 -3
- package/app/spawn.js +12 -13
- package/app/teardown.js +28 -8
- package/app/tower.js +121 -39
- package/docs/README.md +6 -2
- package/docs/adr/0001-ports-adapters-as-data.md +49 -0
- package/docs/adr/0002-single-state-json.md +50 -0
- package/docs/adr/0003-allow-agent-control-default-true.md +48 -0
- package/docs/adr/0004-surface-budget.md +50 -0
- package/docs/adr/README.md +51 -0
- package/docs/agents/domain.md +38 -0
- package/docs/agents/issue-tracker.md +46 -0
- package/docs/agents/triage-labels.md +15 -0
- package/docs/agents/workflow.md +51 -0
- package/docs/architecture/overview.md +170 -37
- package/docs/guide/hot-reload.md +1 -1
- package/docs/guide/install-upgrade-migration.md +18 -1
- package/docs/guide/release.md +1 -1
- package/docs/operations/incident.md +82 -0
- package/docs/operations/runbook.md +12 -3
- package/docs/reference/blockers.md +3 -3
- package/docs/reference/config.generated.md +5 -0
- package/docs/reference/config.md +4 -0
- package/docs/reference/doctor.md +104 -8
- package/docs/reference/errors.generated.md +41 -35
- package/docs/reference/errors.md +54 -40
- package/docs/reference/events.md +38 -6
- package/docs/reference/surface-budget.md +128 -0
- package/docs/reference/tools.generated.md +9 -8
- package/docs/reference/tools.md +9 -8
- package/docs/zh/guide/hot-reload.md +1 -1
- package/docs/zh/guide/install-upgrade-migration.md +13 -1
- package/docs/zh/reference/blockers.md +3 -3
- package/docs/zh/reference/config.md +4 -0
- package/docs/zh/reference/doctor.md +63 -2
- package/docs/zh/reference/errors.md +4 -2
- package/docs/zh/reference/tools.md +6 -6
- package/domain/batch.js +2 -2
- package/domain/config/schema.js +51 -4
- package/domain/contracts/outputs.js +33 -3
- package/domain/contracts/tool-inputs/missions.js +2 -1
- package/domain/contracts/tool-inputs/ops.js +1 -0
- package/domain/errors.js +297 -40
- package/domain/events.js +207 -0
- package/domain/integration/gate.js +41 -12
- package/domain/integration/init.js +40 -4
- package/domain/integration/merge.js +35 -0
- package/domain/lockBudget.js +30 -0
- package/domain/mission.js +1 -1
- package/domain/missions/completion.js +72 -5
- package/domain/missions/lifecycle.js +11 -4
- package/domain/missions/plan.js +14 -0
- package/domain/missions/review.js +90 -7
- package/domain/planning.js +6 -3
- package/domain/read/views.js +17 -1
- package/domain/report.js +9 -1
- package/domain/scope.js +83 -11
- package/domain/state/archive.js +257 -0
- package/domain/state/migrations.js +145 -0
- package/domain/state/schema.js +6 -0
- package/domain/state.js +154 -4
- package/domain/summary.js +2 -0
- package/domain/surface.js +248 -0
- package/host/doctor.js +555 -0
- package/{lib → host}/modeStore.js +1 -1
- package/{lib → host}/tool-registry.js +106 -31
- package/host/tower.js +32 -0
- package/index.js +12 -9
- package/package.json +10 -3
- package/ports/archive.js +18 -0
- package/ports/caseSensitivity.js +15 -0
- package/ports/clock.js +10 -2
- package/ports/config.js +19 -0
- package/ports/contract.js +38 -0
- package/ports/events.js +24 -0
- package/ports/ids.js +10 -2
- package/ports/index.js +54 -2
- package/ports/ownership.js +17 -0
- package/ports/state.js +16 -0
- package/ports/store.js +52 -3
- package/ports/vcs.js +25 -4
- package/scripts/architecture-waivers.json +19 -0
- package/scripts/architecture.mjs +634 -23
- package/scripts/check-docs.mjs +8 -35
- package/scripts/check-pack.mjs +68 -39
- package/scripts/check-philosophy.mjs +266 -0
- package/scripts/domain-metrics.mjs +13 -4
- package/scripts/generate-config-reference.mjs +14 -1
- package/scripts/generate-error-reference.mjs +68 -14
- package/scripts/generate-events-reference.mjs +83 -0
- package/scripts/generate-tools-reference.mjs +11 -0
- package/scripts/issue-audit.mjs +2 -2
- package/scripts/lib/surface.mjs +87 -0
- package/scripts/pack-manifest.txt +64 -19
- package/scripts/regression-audit.mjs +164 -0
- package/scripts/release-check.mjs +1 -1
- package/scripts/surface-baseline.json +21 -0
- package/scripts/surface-baseline.mjs +120 -0
- package/tools/adopt.js +2 -3
- package/tools/batch.js +2 -3
- package/tools/complete.js +3 -4
- package/tools/diff.js +3 -3
- package/tools/doctor.js +7 -10
- package/tools/finding.js +2 -3
- package/tools/findings.js +3 -3
- package/tools/import-legacy.js +3 -5
- package/tools/inbox.js +3 -3
- package/tools/index.js +7 -7
- package/tools/init.js +4 -7
- package/tools/interrupt.js +2 -3
- package/tools/legacy-map.js +3 -5
- package/tools/merge.js +3 -3
- package/tools/mission.js +2 -3
- package/tools/plan.js +7 -6
- package/tools/preflight.js +3 -3
- package/tools/rebase.js +3 -3
- package/tools/recover.js +38 -13
- package/tools/reload.js +3 -6
- package/tools/resource.js +3 -5
- package/tools/review.js +3 -4
- package/tools/roster.js +2 -3
- package/tools/send.js +2 -2
- package/tools/spawn.js +8 -5
- package/tools/status.js +16 -8
- package/tools/teardown.js +3 -4
- package/tools/test.js +2 -2
- package/types/index.d.ts +35 -1
- package/ui-bundle/README.md +37 -3
- package/ui-bundle/client.cards.js +356 -0
- package/ui-bundle/client.denied.js +142 -0
- package/ui-bundle/client.dock.js +333 -0
- package/ui-bundle/client.helpers.js +271 -0
- package/ui-bundle/client.js +79 -1747
- package/ui-bundle/client.locales.js +247 -0
- package/ui-bundle/client.mode.js +206 -0
- package/ui-bundle/client.overlay.js +64 -0
- package/ui-bundle/client.styles.js +151 -0
- package/ui-bundle/client.toolview.js +82 -0
- package/ui-bundle/package.json +1 -0
- package/lib/doctor.js +0 -270
- package/lib/persistence.js +0 -349
- package/lib/store.js +0 -118
- /package/{lib → app}/briefing.js +0 -0
- /package/{lib → app}/spawn-support.js +0 -0
- /package/{lib → host}/compatibility.js +0 -0
- /package/{lib → host}/config.js +0 -0
- /package/{lib → host}/hmr.js +0 -0
- /package/{lib → host}/modeState.js +0 -0
- /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
|
+
}
|
package/adapters/default.js
CHANGED
|
@@ -1,10 +1,53 @@
|
|
|
1
|
-
// Default composition root for Tower
|
|
2
|
-
|
|
3
|
-
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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()
|
package/{lib → adapters}/git.js
RENAMED
|
@@ -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 '
|
|
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 '
|
|
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
|
|
74
|
+
await unlink(file)
|
|
65
75
|
return true
|
|
66
76
|
} catch (error) {
|
|
67
77
|
if (error?.code === 'ENOENT') return false
|
|
68
|
-
|
|
69
|
-
|
|
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 '
|
|
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
|
-
|
|
45
|
-
|
|
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
|
|
49
|
-
|
|
50
|
-
|
|
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) {
|