dsh-tower 2.1.0-beta.1 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +51 -0
- package/COMPATIBILITY.md +1 -0
- package/CONTRIBUTING.md +8 -1
- package/README.en.md +8 -2
- package/README.md +8 -2
- package/adapters/default.js +2 -0
- package/app/archive.js +190 -0
- package/app/board.js +3 -1
- package/app/checkCompletion.js +15 -2
- package/app/gate.js +31 -13
- package/app/init.js +45 -3
- package/app/merge.js +6 -0
- package/app/mission.js +2 -0
- package/app/plan.js +12 -4
- package/app/read.js +59 -3
- package/app/review.js +2 -0
- package/app/teardown.js +22 -1
- package/app/tower.js +34 -6
- 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/architecture/overview.md +3 -1
- package/docs/guide/install-upgrade-migration.md +18 -1
- package/docs/reference/blockers.md +1 -1
- package/docs/reference/config.generated.md +4 -0
- package/docs/reference/config.md +4 -0
- package/docs/reference/doctor.md +58 -8
- package/docs/reference/errors.generated.md +2 -0
- package/docs/reference/errors.md +4 -2
- package/docs/reference/tools.generated.md +6 -6
- package/docs/reference/tools.md +7 -6
- package/docs/zh/guide/install-upgrade-migration.md +13 -1
- package/docs/zh/reference/blockers.md +1 -1
- package/docs/zh/reference/config.md +4 -0
- package/docs/zh/reference/doctor.md +35 -2
- package/docs/zh/reference/errors.md +4 -2
- package/docs/zh/reference/tools.md +3 -3
- package/domain/config/schema.js +26 -3
- package/domain/contracts/outputs.js +27 -1
- package/domain/contracts/tool-inputs/missions.js +2 -1
- package/domain/errors.js +2 -0
- package/domain/integration/gate.js +25 -11
- package/domain/integration/init.js +40 -4
- package/domain/mission.js +1 -1
- package/domain/missions/completion.js +58 -5
- package/domain/missions/lifecycle.js +2 -1
- package/domain/planning.js +6 -3
- package/domain/read/views.js +14 -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/lib/archive.js +279 -0
- package/lib/caseSensitivity.js +66 -0
- package/lib/doctor.js +178 -0
- package/lib/leases.js +14 -5
- package/lib/legacy.js +19 -5
- package/lib/persistence.js +12 -5
- package/lib/stateFile.js +99 -0
- package/lib/store.js +15 -0
- package/lib/tests.js +6 -2
- package/lib/worktreeOwnership.js +18 -7
- package/package.json +1 -1
- package/scripts/pack-manifest.txt +9 -0
- package/tools/complete.js +1 -1
- package/tools/doctor.js +1 -1
- package/tools/init.js +1 -1
- package/tools/plan.js +1 -1
- package/tools/preflight.js +1 -0
- package/tools/status.js +14 -6
- package/tools/teardown.js +1 -1
- package/types/index.d.ts +34 -0
package/domain/state.js
CHANGED
|
@@ -1,17 +1,123 @@
|
|
|
1
|
-
// Pure state-file validation (#56 P4).
|
|
1
|
+
// Pure state-file validation and migration decisions (#56 P4, #72).
|
|
2
2
|
//
|
|
3
|
-
// The structural contract lives in `state/schema.js` (TypeBox)
|
|
4
|
-
// checks in `state/semantics.js
|
|
5
|
-
//
|
|
3
|
+
// The structural contract lives in `state/schema.js` (TypeBox), the policy
|
|
4
|
+
// checks in `state/semantics.js`, and the version registry in
|
|
5
|
+
// `state/migrations.js`. This module stays the public entry point so every
|
|
6
|
+
// app/tool import of `domain/state.js` is unchanged.
|
|
7
|
+
//
|
|
8
|
+
// `validateState` proves a document matches the *current* schema.
|
|
9
|
+
// `prepareState` is the load-path decision: it inspects the version, applies the
|
|
10
|
+
// ordered migration registry, then validates the target schema. Both are pure;
|
|
11
|
+
// the side effects (backup, write, activity) live in `lib/stateFile.js`.
|
|
6
12
|
// @ts-check
|
|
7
13
|
import { TowerToolError } from './errors.js'
|
|
14
|
+
import { SCHEMA_VERSION } from './mission.js'
|
|
15
|
+
import { migrationChain, migrateState } from './state/migrations.js'
|
|
8
16
|
import { stateShapeError } from './state/schema.js'
|
|
9
17
|
import { assertStateSemantics } from './state/semantics.js'
|
|
10
18
|
|
|
11
19
|
/** @typedef {import('../types/index.js').TowerState} TowerState */
|
|
20
|
+
/** @typedef {{ from: number, to: number }} MigrationStep */
|
|
21
|
+
/** @typedef {import('./state/migrations.js').StateMigration} StateMigration */
|
|
12
22
|
|
|
13
23
|
export { StateSchema, stateShapeError } from './state/schema.js'
|
|
14
24
|
|
|
25
|
+
/**
|
|
26
|
+
* @param {unknown} value
|
|
27
|
+
* @returns {boolean}
|
|
28
|
+
*/
|
|
29
|
+
function isPlainObject(value) {
|
|
30
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Classify a document's `schemaVersion` without throwing. This is the read path
|
|
35
|
+
* doctor uses, so it must stay usable on a repository that cannot load.
|
|
36
|
+
* @param {unknown} state
|
|
37
|
+
* @param {string} [statePath]
|
|
38
|
+
* @param {readonly StateMigration[]} [migrations] registry to classify against
|
|
39
|
+
* @returns {{
|
|
40
|
+
* status: 'current'|'migratable'|'newer'|'unmigratable'|'invalid',
|
|
41
|
+
* currentVersion: number|null,
|
|
42
|
+
* supportedVersion: number,
|
|
43
|
+
* migrations: MigrationStep[],
|
|
44
|
+
* message?: string,
|
|
45
|
+
* }}
|
|
46
|
+
*/
|
|
47
|
+
export function inspectStateVersion(state, statePath, migrations) {
|
|
48
|
+
const where = statePath ? ` at ${statePath}` : ''
|
|
49
|
+
if (!isPlainObject(state)) {
|
|
50
|
+
return {
|
|
51
|
+
status: 'invalid',
|
|
52
|
+
currentVersion: null,
|
|
53
|
+
supportedVersion: SCHEMA_VERSION,
|
|
54
|
+
migrations: [],
|
|
55
|
+
message: `tower state is not an object${where}`,
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const version = /** @type {Record<string, any>} */ (state).schemaVersion
|
|
59
|
+
if (!Number.isInteger(version) || version < 1) {
|
|
60
|
+
return {
|
|
61
|
+
status: 'invalid',
|
|
62
|
+
currentVersion: null,
|
|
63
|
+
supportedVersion: SCHEMA_VERSION,
|
|
64
|
+
migrations: [],
|
|
65
|
+
message: `tower state has no usable schemaVersion (${JSON.stringify(version)})${where}`,
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (version === SCHEMA_VERSION) {
|
|
69
|
+
return { status: 'current', currentVersion: version, supportedVersion: SCHEMA_VERSION, migrations: [] }
|
|
70
|
+
}
|
|
71
|
+
if (version > SCHEMA_VERSION) {
|
|
72
|
+
return { status: 'newer', currentVersion: version, supportedVersion: SCHEMA_VERSION, migrations: [] }
|
|
73
|
+
}
|
|
74
|
+
const chain = migrationChain(version, SCHEMA_VERSION, migrations)
|
|
75
|
+
if (!chain) {
|
|
76
|
+
return { status: 'unmigratable', currentVersion: version, supportedVersion: SCHEMA_VERSION, migrations: [] }
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
status: 'migratable',
|
|
80
|
+
currentVersion: version,
|
|
81
|
+
supportedVersion: SCHEMA_VERSION,
|
|
82
|
+
migrations: chain.map((step) => ({ from: step.from, to: step.to })),
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* @param {{ status: string, currentVersion: number|null, supportedVersion: number, message?: string }} info
|
|
88
|
+
* @param {string} [statePath]
|
|
89
|
+
* @returns {TowerToolError}
|
|
90
|
+
*/
|
|
91
|
+
function versionError(info, statePath) {
|
|
92
|
+
const where = statePath ? ` at ${statePath}` : ''
|
|
93
|
+
if (info.status === 'newer') {
|
|
94
|
+
return new TowerToolError(
|
|
95
|
+
'state-version-unsupported',
|
|
96
|
+
`tower state${where} has schemaVersion ${info.currentVersion}, but this build supports ${info.supportedVersion}: it was written by a newer dsh-tower and will not be downgraded`,
|
|
97
|
+
{
|
|
98
|
+
nextAction: `upgrade dsh-tower to a release that supports state schemaVersion ${info.currentVersion}; the file was left untouched`,
|
|
99
|
+
},
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
if (info.status === 'migratable') {
|
|
103
|
+
return new TowerToolError(
|
|
104
|
+
'state-version-unsupported',
|
|
105
|
+
`tower state${where} has schemaVersion ${info.currentVersion}; load it through the state loader to migrate it to ${info.supportedVersion}`,
|
|
106
|
+
{ nextAction: 'call any tower tool; mutations apply the migration under the state lock' },
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
if (info.status === 'unmigratable') {
|
|
110
|
+
return new TowerToolError(
|
|
111
|
+
'state-version-unsupported',
|
|
112
|
+
`tower state${where} has schemaVersion ${info.currentVersion}, which has no migration to ${info.supportedVersion}`,
|
|
113
|
+
{
|
|
114
|
+
nextAction: `restore a state.json written by dsh-tower ${info.supportedVersion}; this build will not guess at an unknown schema`,
|
|
115
|
+
},
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
return new TowerToolError('state-corrupt', info.message ?? `tower state is not usable${where}`)
|
|
119
|
+
}
|
|
120
|
+
|
|
15
121
|
/**
|
|
16
122
|
* @param {string} message
|
|
17
123
|
* @returns {never}
|
|
@@ -21,13 +127,57 @@ function fail(message) {
|
|
|
21
127
|
}
|
|
22
128
|
|
|
23
129
|
/**
|
|
130
|
+
* Prove a document matches the current schema. Any other version is rejected
|
|
131
|
+
* with a version error rather than corruption; the load path migrates first.
|
|
24
132
|
* @param {unknown} state
|
|
25
133
|
* @param {string} [statePath]
|
|
26
134
|
* @returns {TowerState}
|
|
27
135
|
*/
|
|
28
136
|
export function validateState(state, statePath) {
|
|
137
|
+
const info = inspectStateVersion(state, statePath)
|
|
138
|
+
if (info.status !== 'current') throw versionError(info, statePath)
|
|
29
139
|
const where = statePath ? ` at ${statePath}` : ''
|
|
30
140
|
const shapeError = stateShapeError(state, where)
|
|
31
141
|
if (shapeError) fail(shapeError)
|
|
32
142
|
return assertStateSemantics(state, where)
|
|
33
143
|
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The pure load-path decision: migrate an older document through the registry,
|
|
147
|
+
* validate the result against the target schema, then report what happened. The
|
|
148
|
+
* returned state is the validated migrated document; the caller decides whether
|
|
149
|
+
* to persist it (only while holding the state lock).
|
|
150
|
+
*
|
|
151
|
+
* @param {unknown} state
|
|
152
|
+
* @param {string} [statePath]
|
|
153
|
+
* @param {{ migrations?: readonly StateMigration[] }} [options]
|
|
154
|
+
* @returns {{ state: TowerState, from: number, to: number, migrated: MigrationStep[], changed: boolean }}
|
|
155
|
+
*/
|
|
156
|
+
export function prepareState(state, statePath, options = {}) {
|
|
157
|
+
const info = inspectStateVersion(state, statePath, options.migrations)
|
|
158
|
+
if (info.status === 'current') {
|
|
159
|
+
return {
|
|
160
|
+
state: validateState(state, statePath),
|
|
161
|
+
from: SCHEMA_VERSION,
|
|
162
|
+
to: SCHEMA_VERSION,
|
|
163
|
+
migrated: [],
|
|
164
|
+
changed: false,
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (info.status !== 'migratable') throw versionError(info, statePath)
|
|
168
|
+
const outcome = migrateState(/** @type {Record<string, any>} */ (state), {
|
|
169
|
+
statePath,
|
|
170
|
+
to: info.supportedVersion,
|
|
171
|
+
migrations: options.migrations,
|
|
172
|
+
})
|
|
173
|
+
// Validation runs on the *target* document: a shape that was invalid under
|
|
174
|
+
// the old schema (a missing `tasks` array, say) may be valid after a backfill,
|
|
175
|
+
// and validating the target is what proves the migration did its job.
|
|
176
|
+
return {
|
|
177
|
+
state: validateState(outcome.state, statePath),
|
|
178
|
+
from: outcome.from,
|
|
179
|
+
to: outcome.to,
|
|
180
|
+
migrated: outcome.steps,
|
|
181
|
+
changed: true,
|
|
182
|
+
}
|
|
183
|
+
}
|
package/domain/summary.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Pure mission/finding summaries.
|
|
2
2
|
// @ts-check
|
|
3
3
|
import { FINDING_STATUSES } from './mission.js'
|
|
4
|
+
import { completionReportBytes } from './missions/completion.js'
|
|
4
5
|
|
|
5
6
|
/** @typedef {import('../types/index.js').Finding} Finding */
|
|
6
7
|
/** @typedef {import('../types/index.js').FindingSummary} FindingSummary */
|
|
@@ -53,6 +54,7 @@ export function missionSummary(mission) {
|
|
|
53
54
|
reviewPolicy: mission.reviewPolicy ?? 'required-clean',
|
|
54
55
|
mergedCommit: mission.mergedCommit ?? null,
|
|
55
56
|
mergedAt: mission.mergedAt ?? null,
|
|
57
|
+
reportBytes: completionReportBytes(mission.completionReport),
|
|
56
58
|
updatedAt: mission.updatedAt,
|
|
57
59
|
}
|
|
58
60
|
}
|
package/lib/archive.js
ADDED
|
@@ -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 '../adapters/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 '../adapters/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
|
+
}
|