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/lib/doctor.js
CHANGED
|
@@ -5,6 +5,11 @@ import path from 'node:path'
|
|
|
5
5
|
import { fsp } from '../adapters/store-fs.js'
|
|
6
6
|
import { git } from './git.js'
|
|
7
7
|
import { readStateLock } from './store.js'
|
|
8
|
+
import { inspectArchive } from './archive.js'
|
|
9
|
+
import { inspectStateVersion } from '../domain/state.js'
|
|
10
|
+
import { SCHEMA_VERSION } from '../domain/mission.js'
|
|
11
|
+
import { DEFAULT_MAX_REPORT_BYTES, completionReportBytes } from '../domain/missions/completion.js'
|
|
12
|
+
import { comparisonMode } from '../domain/scope.js'
|
|
8
13
|
import { assessHostVersion, detectHostVersion, readCompatibility } from './compatibility.js'
|
|
9
14
|
import { profileConfig } from './config.js'
|
|
10
15
|
import { inspectWorkspaceLock } from './workspaceLock.js'
|
|
@@ -33,9 +38,87 @@ function check(id, status, detail, action) {
|
|
|
33
38
|
return { id, status, detail, ...(action ? { action } : {}) }
|
|
34
39
|
}
|
|
35
40
|
|
|
41
|
+
/** Read the on-disk state version without loading, migrating, or locking. */
|
|
42
|
+
async function inspectStateSchema(paths) {
|
|
43
|
+
try {
|
|
44
|
+
const text = await fsp.readFile(paths.state, 'utf8')
|
|
45
|
+
return inspectStateVersion(JSON.parse(text), paths.state)
|
|
46
|
+
} catch (error) {
|
|
47
|
+
return {
|
|
48
|
+
status: 'invalid',
|
|
49
|
+
currentVersion: null,
|
|
50
|
+
supportedVersion: SCHEMA_VERSION,
|
|
51
|
+
migrations: [],
|
|
52
|
+
message: `tower state could not be inspected: ${String(error?.message ?? error)}`,
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Report the on-disk schema version and the migration that a write would apply.
|
|
59
|
+
* Doctor never migrates: a read-only call must leave the file byte-identical.
|
|
60
|
+
*/
|
|
61
|
+
function stateSchemaReport(info) {
|
|
62
|
+
const stateSchema = {
|
|
63
|
+
currentVersion: info.currentVersion,
|
|
64
|
+
supportedVersion: info.supportedVersion,
|
|
65
|
+
migrations: info.migrations ?? [],
|
|
66
|
+
status: info.status,
|
|
67
|
+
}
|
|
68
|
+
if (info.status === 'current') {
|
|
69
|
+
return {
|
|
70
|
+
stateSchema,
|
|
71
|
+
check: check('state-schema', 'pass', `schemaVersion ${info.currentVersion} is current`),
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (info.status === 'migratable') {
|
|
75
|
+
return {
|
|
76
|
+
stateSchema,
|
|
77
|
+
check: check(
|
|
78
|
+
'state-schema',
|
|
79
|
+
'warn',
|
|
80
|
+
`schemaVersion ${info.currentVersion} is older than ${info.supportedVersion}; the next locked write migrates it and keeps state.json.v${info.currentVersion}.bak`,
|
|
81
|
+
'a write tool applies the migration; a read-only tool only migrates in memory',
|
|
82
|
+
),
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (info.status === 'newer') {
|
|
86
|
+
return {
|
|
87
|
+
stateSchema,
|
|
88
|
+
check: check(
|
|
89
|
+
'state-schema',
|
|
90
|
+
'fail',
|
|
91
|
+
`schemaVersion ${info.currentVersion} is newer than this build supports (${info.supportedVersion}); state was written by a newer dsh-tower`,
|
|
92
|
+
'upgrade dsh-tower; this build refuses to downgrade state.json',
|
|
93
|
+
),
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (info.status === 'unmigratable') {
|
|
97
|
+
return {
|
|
98
|
+
stateSchema,
|
|
99
|
+
check: check(
|
|
100
|
+
'state-schema',
|
|
101
|
+
'fail',
|
|
102
|
+
`schemaVersion ${info.currentVersion} has no migration to ${info.supportedVersion}`,
|
|
103
|
+
'restore a state.json written by a supported dsh-tower; this build will not guess at an unknown schema',
|
|
104
|
+
),
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
stateSchema,
|
|
109
|
+
check: check(
|
|
110
|
+
'state-schema',
|
|
111
|
+
'fail',
|
|
112
|
+
info.message ?? 'state schemaVersion is unreadable',
|
|
113
|
+
'restore a state.json this build can read, or run tower_init for a new repository',
|
|
114
|
+
),
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
36
118
|
/** Run every read-only diagnostic; never writes state or takes the state lock. */
|
|
37
119
|
export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityFile, loaderRows }) {
|
|
38
120
|
const checks = []
|
|
121
|
+
let reportsReport = null
|
|
39
122
|
const version = await gitVersion()
|
|
40
123
|
checks.push(version ? check('git', 'pass', version) : check('git', 'fail', 'git is not available', 'install Git and retry'))
|
|
41
124
|
|
|
@@ -84,6 +167,7 @@ export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityF
|
|
|
84
167
|
}
|
|
85
168
|
|
|
86
169
|
checks.push(check('repository', 'pass', `${tower.root} (common ${tower.commonDir})`))
|
|
170
|
+
const schemaInfo = await inspectStateSchema(tower.paths)
|
|
87
171
|
let state = null
|
|
88
172
|
try {
|
|
89
173
|
state = await tower.load()
|
|
@@ -96,6 +180,29 @@ export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityF
|
|
|
96
180
|
} catch (error) {
|
|
97
181
|
checks.push(check('state', 'fail', String(error?.message ?? error), 'run tower_init or restore the state file'))
|
|
98
182
|
}
|
|
183
|
+
const schemaReport = stateSchemaReport(schemaInfo)
|
|
184
|
+
checks.push(schemaReport.check)
|
|
185
|
+
|
|
186
|
+
// Scope matching is only as good as the detected filesystem mode (#90).
|
|
187
|
+
// Doctor never probes: a read-only call must not create files. A legacy state
|
|
188
|
+
// that has not been re-initialized therefore reports the safe exact default.
|
|
189
|
+
const caseInsensitive =
|
|
190
|
+
state && (state.caseInsensitive === true || state.caseInsensitive === false) ? state.caseInsensitive : null
|
|
191
|
+
const comparison = comparisonMode(caseInsensitive)
|
|
192
|
+
checks.push(
|
|
193
|
+
caseInsensitive === null
|
|
194
|
+
? check(
|
|
195
|
+
'case-mode',
|
|
196
|
+
'warn',
|
|
197
|
+
`caseInsensitive: not detected; scope matching uses ${comparison} comparison`,
|
|
198
|
+
'run tower_init on a writable checkout to probe and record the filesystem mode',
|
|
199
|
+
)
|
|
200
|
+
: check(
|
|
201
|
+
'case-mode',
|
|
202
|
+
'pass',
|
|
203
|
+
`caseInsensitive: ${caseInsensitive}; scope matching uses ${comparison} comparison`,
|
|
204
|
+
),
|
|
205
|
+
)
|
|
99
206
|
|
|
100
207
|
const config = tower.config ?? {}
|
|
101
208
|
const profile = profileConfig()
|
|
@@ -208,6 +315,30 @@ export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityF
|
|
|
208
315
|
)
|
|
209
316
|
checks.push(check('legacy', 'pass', `${state.legacySnapshots?.length ?? 0} legacy snapshot(s)`))
|
|
210
317
|
checks.push(check('batches', 'pass', `${state.batches?.length ?? 0} batch(es)`))
|
|
318
|
+
|
|
319
|
+
// Completion reports are the largest model-supplied strings in state.json;
|
|
320
|
+
// report the biggest one so growth is visible before it becomes a problem.
|
|
321
|
+
const reportSizes = (state.missions ?? [])
|
|
322
|
+
.map((mission) => ({ missionId: mission.id, bytes: completionReportBytes(mission.completionReport) }))
|
|
323
|
+
.filter((entry) => entry.bytes !== null)
|
|
324
|
+
const largestReport = reportSizes.reduce(
|
|
325
|
+
(best, entry) => (best === null || entry.bytes > best.bytes ? entry : best),
|
|
326
|
+
null,
|
|
327
|
+
)
|
|
328
|
+
const reportCap = Number(tower.config?.gates?.maxReportBytes) || DEFAULT_MAX_REPORT_BYTES
|
|
329
|
+
reportsReport = { count: reportSizes.length, largest: largestReport, capBytes: reportCap }
|
|
330
|
+
checks.push(
|
|
331
|
+
largestReport
|
|
332
|
+
? check(
|
|
333
|
+
'reports',
|
|
334
|
+
largestReport.bytes > reportCap ? 'warn' : 'pass',
|
|
335
|
+
`${reportSizes.length} completion report(s); largest ${largestReport.bytes} B on ${largestReport.missionId} (cap ${reportCap} B)`,
|
|
336
|
+
largestReport.bytes > reportCap
|
|
337
|
+
? 'existing reports predate the cap; reads are unchanged and no rewrite happens'
|
|
338
|
+
: undefined,
|
|
339
|
+
)
|
|
340
|
+
: check('reports', 'pass', `no completion reports (cap ${reportCap} B)`),
|
|
341
|
+
)
|
|
211
342
|
}
|
|
212
343
|
|
|
213
344
|
try {
|
|
@@ -248,16 +379,63 @@ export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityF
|
|
|
248
379
|
checks.push(check('lock-holds', 'pass', 'no lock-hold samples yet'))
|
|
249
380
|
}
|
|
250
381
|
|
|
382
|
+
// Archive inventory (#73): size, count and the configured trigger policy.
|
|
383
|
+
// A missing directory is a healthy empty archive; a corrupt file is a warn.
|
|
384
|
+
const archiveInventory = await inspectArchive(tower.paths)
|
|
385
|
+
const stateBytes = await fsp.stat(tower.paths.state).then((stat) => stat.size).catch(() => null)
|
|
386
|
+
const archivePolicy = config.archive ?? {}
|
|
387
|
+
const policyText = `afterDays=${archivePolicy.afterDays ?? 30} maxMissions=${archivePolicy.maxMissions ?? 100}${archivePolicy.enabled === false ? ' disabled' : ''}`
|
|
388
|
+
if (!archiveInventory.exists) {
|
|
389
|
+
checks.push(check('archive', 'pass', `no archived missions; ${policyText}`))
|
|
390
|
+
} else if (archiveInventory.unreadable.length > 0) {
|
|
391
|
+
checks.push(
|
|
392
|
+
check(
|
|
393
|
+
'archive',
|
|
394
|
+
'warn',
|
|
395
|
+
`${archiveInventory.missions} archived mission(s), ${archiveInventory.bytes} bytes; unreadable: ${archiveInventory.unreadable[0]}`,
|
|
396
|
+
'inspect <git-common-dir>/dsh-tower/archive/ and repair or move the corrupt file; archiving is paused until it is readable',
|
|
397
|
+
),
|
|
398
|
+
)
|
|
399
|
+
} else {
|
|
400
|
+
checks.push(
|
|
401
|
+
check(
|
|
402
|
+
'archive',
|
|
403
|
+
'pass',
|
|
404
|
+
`${archiveInventory.missions} archived mission(s) in ${archiveInventory.years.length} year file(s), ${archiveInventory.bytes} bytes; ${policyText}`,
|
|
405
|
+
),
|
|
406
|
+
)
|
|
407
|
+
}
|
|
408
|
+
|
|
251
409
|
const failed = checks.filter((item) => item.status === 'fail').length
|
|
252
410
|
return {
|
|
253
411
|
ok: failed === 0,
|
|
254
412
|
checks,
|
|
413
|
+
stateSchema: schemaReport.stateSchema,
|
|
414
|
+
// reports (from #89) and archive (from #73) are both kept
|
|
415
|
+
reports: reportsReport,
|
|
416
|
+
archive: {
|
|
417
|
+
missions: state ? state.missions.length : null,
|
|
418
|
+
archivedMissions: archiveInventory.missions,
|
|
419
|
+
stateBytes,
|
|
420
|
+
archiveBytes: archiveInventory.bytes,
|
|
421
|
+
dir: archiveInventory.dir,
|
|
422
|
+
years: archiveInventory.years,
|
|
423
|
+
files: archiveInventory.files.length,
|
|
424
|
+
unreadable: archiveInventory.unreadable,
|
|
425
|
+
policy: {
|
|
426
|
+
enabled: archivePolicy.enabled !== false,
|
|
427
|
+
afterDays: archivePolicy.afterDays ?? 30,
|
|
428
|
+
maxMissions: archivePolicy.maxMissions ?? 100,
|
|
429
|
+
},
|
|
430
|
+
},
|
|
255
431
|
environmentContract: {
|
|
256
432
|
platform: process.platform,
|
|
257
433
|
gitVersion: version,
|
|
258
434
|
repoRoot: tower.root,
|
|
259
435
|
commonDir: tower.commonDir,
|
|
260
436
|
stateDir: tower.paths.dir,
|
|
437
|
+
caseInsensitive,
|
|
438
|
+
comparisonMode: comparison,
|
|
261
439
|
gitTimeoutMs: Number(tower.config.git?.timeoutMs) || 15000,
|
|
262
440
|
mergeTimeoutMs: Number(tower.config.merge?.timeoutMs) || 900000,
|
|
263
441
|
testsAdmission: tower.config.tests?.admission ?? 'off',
|
package/lib/leases.js
CHANGED
|
@@ -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
|
}
|
package/lib/legacy.js
CHANGED
|
@@ -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) {
|
package/lib/persistence.js
CHANGED
|
@@ -20,8 +20,10 @@ import { TowerToolError } from '../domain/errors.js'
|
|
|
20
20
|
export const DEFAULT_LOCK_TIMEOUT_MS = 5000
|
|
21
21
|
export const DEFAULT_LOCK_POLL_MS = 25
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
// Exported with the retry loop so a test can assert the budget and the exact
|
|
24
|
+
// transient set instead of copying them (#86).
|
|
25
|
+
export const RENAME_ATTEMPTS = 12
|
|
26
|
+
export const TRANSIENT_RENAME_ERRORS = new Set(['EPERM', 'EBUSY', 'EACCES'])
|
|
25
27
|
const PROCESS_STARTED_AT = Date.now() - Math.round(process.uptime() * 1000)
|
|
26
28
|
|
|
27
29
|
export function sleep(ms) {
|
|
@@ -74,18 +76,23 @@ export async function writeJsonAtomic(file, value) {
|
|
|
74
76
|
* Windows can reject a rename while an indexer, antivirus, or a concurrent
|
|
75
77
|
* reader still holds the destination; retry briefly instead of failing a
|
|
76
78
|
* successful mutation. The temp file is removed when the rename never lands.
|
|
79
|
+
*
|
|
80
|
+
* `options.rename` and `options.sleep` inject the syscall and the backoff so
|
|
81
|
+
* both outcomes are testable on any OS (#86); production callers omit them.
|
|
77
82
|
*/
|
|
78
|
-
export async function renameWithRetry(tmp, file) {
|
|
83
|
+
export async function renameWithRetry(tmp, file, options = {}) {
|
|
84
|
+
const rename = options.rename ?? fsp.rename
|
|
85
|
+
const wait = options.sleep ?? sleep
|
|
79
86
|
for (let attempt = 0; ; attempt += 1) {
|
|
80
87
|
try {
|
|
81
|
-
await
|
|
88
|
+
await rename(tmp, file)
|
|
82
89
|
return
|
|
83
90
|
} catch (error) {
|
|
84
91
|
if (!TRANSIENT_RENAME_ERRORS.has(error?.code) || attempt >= RENAME_ATTEMPTS) {
|
|
85
92
|
await fsp.unlink(tmp).catch(() => {})
|
|
86
93
|
throw error
|
|
87
94
|
}
|
|
88
|
-
await
|
|
95
|
+
await wait(10 + attempt * 15)
|
|
89
96
|
}
|
|
90
97
|
}
|
|
91
98
|
}
|
package/lib/stateFile.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// State-file load path: schema-version inspection, migration, and the one
|
|
2
|
+
// persisted migration result (#72).
|
|
3
|
+
//
|
|
4
|
+
// The domain stays pure: `prepareState` classifies, transforms and validates the
|
|
5
|
+
// document. This module owns the disk effects, and it persists them only while
|
|
6
|
+
// the caller holds the state lock, so a reader can never rewrite a document a
|
|
7
|
+
// concurrent writer is about to change. A read-only load still migrates in
|
|
8
|
+
// memory; the next locked write persists that migration once.
|
|
9
|
+
//
|
|
10
|
+
// Ordering mirrors the protocol contract:
|
|
11
|
+
// 1. classify the version (a newer document is refused before any write);
|
|
12
|
+
// 2. write the pre-migration backup;
|
|
13
|
+
// 3. migrate in memory and validate the target schema;
|
|
14
|
+
// 4. atomically replace state.json, bump `revision` once, append
|
|
15
|
+
// `state.migrated {from,to}` to the activity log.
|
|
16
|
+
// A migration that throws therefore leaves state.json byte-identical.
|
|
17
|
+
import { inspectStateVersion, prepareState } from '../domain/state.js'
|
|
18
|
+
import { isStateLockHeld } from './store.js'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @typedef {object} StateLoadResult
|
|
22
|
+
* @property {import('../types/index.js').TowerState} state
|
|
23
|
+
* @property {ReturnType<typeof inspectStateVersion>} version
|
|
24
|
+
* @property {null | {
|
|
25
|
+
* from: number, to: number, steps: Array<{ from: number, to: number }>,
|
|
26
|
+
* persisted: boolean, backupPath: string|null,
|
|
27
|
+
* }} migration
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Read `paths.state`, migrate it when it is older than this build, and (only
|
|
32
|
+
* while the state lock is held) persist that migration.
|
|
33
|
+
*
|
|
34
|
+
* @param {{
|
|
35
|
+
* store: any,
|
|
36
|
+
* paths: any,
|
|
37
|
+
* now?: number,
|
|
38
|
+
* persist?: boolean,
|
|
39
|
+
* migrations?: readonly import('../domain/state/migrations.js').StateMigration[],
|
|
40
|
+
* }} input
|
|
41
|
+
* @returns {Promise<StateLoadResult>}
|
|
42
|
+
*/
|
|
43
|
+
export async function loadStateFile(input) {
|
|
44
|
+
const { store, paths, migrations } = input
|
|
45
|
+
const now = input.now ?? Date.now()
|
|
46
|
+
const raw = await store.readJson(paths.state)
|
|
47
|
+
const version = inspectStateVersion(raw, paths.state, migrations)
|
|
48
|
+
const persist = input.persist ?? isStateLockHeld(paths)
|
|
49
|
+
const backupPath = `${paths.state}.v${String(version.currentVersion ?? 'unknown')}.bak`
|
|
50
|
+
|
|
51
|
+
// Backup before the original can change. `prepareState` is pure, so a
|
|
52
|
+
// throwing migration cannot touch state.json whether or not this ran.
|
|
53
|
+
if (persist && version.status === 'migratable') {
|
|
54
|
+
await store.writeJsonAtomic(backupPath, raw)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const prepared = prepareState(raw, paths.state, { migrations })
|
|
58
|
+
if (!prepared.changed) {
|
|
59
|
+
return { state: prepared.state, version, migration: null }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (!persist) {
|
|
63
|
+
return {
|
|
64
|
+
state: prepared.state,
|
|
65
|
+
version,
|
|
66
|
+
migration: {
|
|
67
|
+
from: prepared.from,
|
|
68
|
+
to: prepared.to,
|
|
69
|
+
steps: prepared.migrated,
|
|
70
|
+
persisted: false,
|
|
71
|
+
backupPath: null,
|
|
72
|
+
},
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const baseRevision =
|
|
77
|
+
Number.isInteger(prepared.state.revision) && prepared.state.revision >= 0 ? prepared.state.revision : 0
|
|
78
|
+
const migrated = { ...prepared.state, revision: baseRevision + 1, updatedAt: now }
|
|
79
|
+
await store.writeJsonAtomic(paths.state, migrated)
|
|
80
|
+
await store.appendJsonl(paths.activity, {
|
|
81
|
+
at: now,
|
|
82
|
+
type: 'state.migrated',
|
|
83
|
+
from: prepared.from,
|
|
84
|
+
to: prepared.to,
|
|
85
|
+
revision: migrated.revision,
|
|
86
|
+
text: `schema v${prepared.from} -> v${prepared.to}`,
|
|
87
|
+
})
|
|
88
|
+
return {
|
|
89
|
+
state: migrated,
|
|
90
|
+
version,
|
|
91
|
+
migration: {
|
|
92
|
+
from: prepared.from,
|
|
93
|
+
to: prepared.to,
|
|
94
|
+
steps: prepared.migrated,
|
|
95
|
+
persisted: true,
|
|
96
|
+
backupPath,
|
|
97
|
+
},
|
|
98
|
+
}
|
|
99
|
+
}
|
package/lib/store.js
CHANGED
|
@@ -66,6 +66,19 @@ export async function readStateLock(paths) {
|
|
|
66
66
|
return readLock(paths.lock)
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Paths objects whose state lock this process currently holds. A state
|
|
71
|
+
* migration may be persisted only inside `withLock`: the loader reads this to
|
|
72
|
+
* tell a locked mutation (which may write) from a read-only load (which must
|
|
73
|
+
* migrate in memory and leave the file alone).
|
|
74
|
+
*/
|
|
75
|
+
const HELD_STATE_LOCKS = new WeakSet()
|
|
76
|
+
|
|
77
|
+
/** @param {object} [paths] @returns {boolean} */
|
|
78
|
+
export function isStateLockHeld(paths) {
|
|
79
|
+
return Boolean(paths) && HELD_STATE_LOCKS.has(paths)
|
|
80
|
+
}
|
|
81
|
+
|
|
69
82
|
/**
|
|
70
83
|
* Hold the repo-wide tower lock for one mutation. A lock whose recorded PID is
|
|
71
84
|
* provably dead is reclaimed atomically; a live lock is reported, never stolen.
|
|
@@ -86,9 +99,11 @@ export async function withLock(paths, fn, options = {}) {
|
|
|
86
99
|
)
|
|
87
100
|
}
|
|
88
101
|
const startedAt = Date.now()
|
|
102
|
+
HELD_STATE_LOCKS.add(paths)
|
|
89
103
|
try {
|
|
90
104
|
return await fn(paths)
|
|
91
105
|
} finally {
|
|
106
|
+
HELD_STATE_LOCKS.delete(paths)
|
|
92
107
|
await held.release()
|
|
93
108
|
await fsp
|
|
94
109
|
.appendFile(path.join(paths.dir, 'lock-holds.jsonl'), `${JSON.stringify({ at: Date.now(), ms: Date.now() - startedAt, pid: process.pid })}\n`, 'utf8')
|
package/lib/tests.js
CHANGED
|
@@ -288,8 +288,12 @@ export function testProcessEnv(source = process.env) {
|
|
|
288
288
|
return env
|
|
289
289
|
}
|
|
290
290
|
|
|
291
|
-
/**
|
|
292
|
-
|
|
291
|
+
/**
|
|
292
|
+
* Platform shell for discovery commands (PowerShell 7 on Windows with a
|
|
293
|
+
* `powershell` fallback, bash on POSIX). Exported so both branches are asserted
|
|
294
|
+
* on every OS instead of only on the platform that happens to run CI (#86).
|
|
295
|
+
*/
|
|
296
|
+
export function shellSpec(platform = process.platform) {
|
|
293
297
|
if (platform === 'win32') {
|
|
294
298
|
const args = (command) => ['-NoProfile', '-NonInteractive', '-Command', command]
|
|
295
299
|
return {
|
package/lib/worktreeOwnership.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// and must still be the Git worktree of the recorded branch in the recorded
|
|
7
7
|
// repository. Every mismatch is rejected with an actionable message; nothing
|
|
8
8
|
// is repaired or recreated here.
|
|
9
|
-
import { fsp } from '../adapters/store-fs.js'
|
|
9
|
+
import { fs, fsp } from '../adapters/store-fs.js'
|
|
10
10
|
import path from 'node:path'
|
|
11
11
|
|
|
12
12
|
import { git } from './git.js'
|
|
@@ -22,18 +22,29 @@ function isNonEmptyString(value) {
|
|
|
22
22
|
return typeof value === 'string' && value.trim().length > 0
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
// Path identity follows the injected platform's semantics so both branches are
|
|
26
|
+
// testable on any OS (#86); on the real platform this is exactly `path`.
|
|
27
|
+
export function compareKey(target, platform = process.platform) {
|
|
28
|
+
const pathImpl = platform === 'win32' ? path.win32 : path.posix
|
|
29
|
+
const resolved = pathImpl.resolve(target)
|
|
30
|
+
return platform === 'win32' ? resolved.toLowerCase() : resolved
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function nativeRealpath(target) {
|
|
34
|
+
try {
|
|
35
|
+
return fs.realpathSync.native(target)
|
|
36
|
+
} catch {
|
|
37
|
+
return path.resolve(target)
|
|
38
|
+
}
|
|
28
39
|
}
|
|
29
40
|
|
|
30
41
|
function samePath(a, b) {
|
|
31
|
-
return compareKey(a) === compareKey(b)
|
|
42
|
+
return compareKey(nativeRealpath(a)) === compareKey(nativeRealpath(b))
|
|
32
43
|
}
|
|
33
44
|
|
|
34
45
|
async function realpathOrResolve(target) {
|
|
35
46
|
try {
|
|
36
|
-
return
|
|
47
|
+
return fs.realpathSync.native(target)
|
|
37
48
|
} catch (error) {
|
|
38
49
|
if (error?.code === 'ENOENT') return path.resolve(target)
|
|
39
50
|
throw error
|
|
@@ -48,7 +59,7 @@ export async function canonicalPath(target) {
|
|
|
48
59
|
if (!isNonEmptyString(target)) fail('invalid-argument', 'path is required')
|
|
49
60
|
if (!path.isAbsolute(target)) fail('invalid-argument', `path must be an absolute path: ${target}`)
|
|
50
61
|
try {
|
|
51
|
-
return
|
|
62
|
+
return fs.realpathSync.native(target)
|
|
52
63
|
} catch (error) {
|
|
53
64
|
if (error?.code === 'ENOENT') fail('invalid-transition', `path does not exist: ${path.resolve(target)}`)
|
|
54
65
|
throw error
|
package/package.json
CHANGED
|
@@ -8,6 +8,7 @@ SECURITY.md
|
|
|
8
8
|
adapters/default.js
|
|
9
9
|
adapters/store-fs.js
|
|
10
10
|
adapters/vcs-git.js
|
|
11
|
+
app/archive.js
|
|
11
12
|
app/batch.js
|
|
12
13
|
app/board.js
|
|
13
14
|
app/checkCompletion.js
|
|
@@ -31,6 +32,9 @@ app/teardown.js
|
|
|
31
32
|
app/tower.js
|
|
32
33
|
cordis.patch.yml
|
|
33
34
|
docs/README.md
|
|
35
|
+
docs/agents/domain.md
|
|
36
|
+
docs/agents/issue-tracker.md
|
|
37
|
+
docs/agents/triage-labels.md
|
|
34
38
|
docs/architecture/overview.md
|
|
35
39
|
docs/board.md
|
|
36
40
|
docs/examples/first-mission.md
|
|
@@ -106,13 +110,17 @@ domain/report.js
|
|
|
106
110
|
domain/roster.js
|
|
107
111
|
domain/scope.js
|
|
108
112
|
domain/state.js
|
|
113
|
+
domain/state/archive.js
|
|
114
|
+
domain/state/migrations.js
|
|
109
115
|
domain/state/schema.js
|
|
110
116
|
domain/state/semantics.js
|
|
111
117
|
domain/summary.js
|
|
112
118
|
domain/vocabulary.js
|
|
113
119
|
icon.svg
|
|
114
120
|
index.js
|
|
121
|
+
lib/archive.js
|
|
115
122
|
lib/briefing.js
|
|
123
|
+
lib/caseSensitivity.js
|
|
116
124
|
lib/compatibility.js
|
|
117
125
|
lib/config.js
|
|
118
126
|
lib/doctor.js
|
|
@@ -126,6 +134,7 @@ lib/persistence.js
|
|
|
126
134
|
lib/presets.js
|
|
127
135
|
lib/spawn-prechecks.js
|
|
128
136
|
lib/spawn-support.js
|
|
137
|
+
lib/stateFile.js
|
|
129
138
|
lib/store.js
|
|
130
139
|
lib/tests.js
|
|
131
140
|
lib/tool-registry.js
|
package/tools/complete.js
CHANGED
|
@@ -14,7 +14,7 @@ export function registerCompleteTool(ctx, deps) {
|
|
|
14
14
|
define(ctx, {
|
|
15
15
|
name: 'tower_complete',
|
|
16
16
|
description:
|
|
17
|
-
'Worker-only: report mission completion. Enforced by the store: every task done or dropped, no blockers, the current owner, the mission branch has at least one commit since its spawn base, and no unread inbox messages. checks records verifiable evidence (name/command/exitCode/evidence); gates.requireChecks can require at least one before merge. Stores the full report; completion means ready for preflight and optional review, not merged.',
|
|
17
|
+
'Worker-only: report mission completion. Enforced by the store: every task done or dropped, no blockers, the current owner, the mission branch has at least one commit since its spawn base, and no unread inbox messages. checks records verifiable evidence (name/command/exitCode/evidence); gates.requireChecks can require at least one before merge. Stores the full report, capped at gates.maxReportBytes (default 32768 UTF-8 bytes); a larger report is rejected with invalid-argument and an actionable nextAction, never truncated. Persists reportBytes on the stored report. completion means ready for preflight and optional review, not merged.',
|
|
18
18
|
parameters: CompleteInput,
|
|
19
19
|
aliases: {"missionId":["id"]},
|
|
20
20
|
requiresMissionId: true,
|
package/tools/doctor.js
CHANGED
|
@@ -15,7 +15,7 @@ export function registerDoctorTool(ctx, define) {
|
|
|
15
15
|
define(ctx, {
|
|
16
16
|
name: 'tower_doctor',
|
|
17
17
|
description:
|
|
18
|
-
|
|
18
|
+
'Read-only\ environment\ and\ repository\ health\ check:\ git,\ state\ schema,\ case-sensitivity\ detection\ \(caseInsensitive\ and\ the\ active\ scope\ comparison\ mode\),\ config,\ presets/subagents/HMR,\ workspace\ lock,\ roster\ staleness,\ worktree\ ownership,\ test\ discovery,\ leases,\ and\ the\ terminal-mission\ archive\ \(count,\ bytes,\ years,\ unreadable\ diagnostics\ and\ the\ configured\ afterDays/maxMissions\ policy\)\.\ Returns\ checks\ with\ pass/warn/fail\ and\ an\ environment\ contract\.\ Never\ writes\ state\ and\ never\ probes\ the\ filesystem\.',
|
|
19
19
|
parameters: DoctorInput,
|
|
20
20
|
output: output(SCHEMAS.doctor),
|
|
21
21
|
async execute(args, exec) {
|
package/tools/init.js
CHANGED
|
@@ -16,7 +16,7 @@ export function registerInitTool(ctx, deps) {
|
|
|
16
16
|
define(ctx, {
|
|
17
17
|
name: 'tower_init',
|
|
18
18
|
description:
|
|
19
|
-
'Initialize Tower mission state for a repository (absolute repoRoot). Creates <git-common-dir>/dsh-tower/state.json and an activity log; idempotent on an existing workspace. Options: autoGitInit (git init + empty initial commit when the directory is not a repository), snapshotDirty (snapshot uncommitted work into a dangling commit used as the mission baseline), reanchor (change base when every mission is merged/abandoned). Only the tower may initialize.',
|
|
19
|
+
'Initialize Tower mission state for a repository (absolute repoRoot). Creates <git-common-dir>/dsh-tower/state.json and an activity log; idempotent on an existing workspace. Probes whether the checkout filesystem folds case and records caseInsensitive once, so scope matching follows the real filesystem (a skipped probe records null and keeps the safe case-sensitive comparison). Options: autoGitInit (git init + empty initial commit when the directory is not a repository), snapshotDirty (snapshot uncommitted work into a dangling commit used as the mission baseline), reanchor (change base when every mission is merged/abandoned). Only the tower may initialize.',
|
|
20
20
|
parameters: InitInput,
|
|
21
21
|
aliases: { auto_git_init: ['autoGitInit'], snapshot_dirty: ['snapshotDirty'] },
|
|
22
22
|
board: true,
|
package/tools/plan.js
CHANGED
|
@@ -24,7 +24,7 @@ export function registerPlanTool(ctx, deps) {
|
|
|
24
24
|
define(ctx, {
|
|
25
25
|
name: 'tower_plan',
|
|
26
26
|
description:
|
|
27
|
-
'Plan one or more missions in an initialized repository. Each mission has a printable-
|
|
27
|
+
'Plan one or more missions in an initialized repository. Each mission has a printable-Unicode title (NFC-normalized, at most 120 characters), relative scope globs, optional task checklist, optional mission-id dependencies, and kind build|survey. Assigns M1.. ids and auto-adds a dependency when build scopes overlap (serializes merges); rejects dependency cycles.',
|
|
28
28
|
parameters: PlanInput,
|
|
29
29
|
board: true,
|
|
30
30
|
output: output(SCHEMAS.plan),
|