dsh-tower 2.0.4 → 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 +152 -0
- package/COMPATIBILITY.md +39 -0
- package/CONTRIBUTING.md +157 -2
- package/README.en.md +46 -3
- package/README.md +44 -3
- package/SECURITY.md +27 -0
- package/adapters/default.js +12 -0
- package/adapters/store-fs.js +6 -0
- package/adapters/vcs-git.js +4 -0
- package/app/archive.js +190 -0
- package/app/batch.js +79 -0
- package/app/board.js +27 -0
- package/app/checkCompletion.js +89 -0
- package/app/enrichedMissions.js +26 -0
- package/app/findings.js +78 -0
- package/app/gate.js +126 -0
- package/app/init.js +291 -0
- package/app/merge.js +573 -0
- package/app/messages.js +66 -0
- package/app/mission.js +37 -0
- package/app/missionContext.js +14 -0
- package/app/plan.js +50 -0
- package/app/provision.js +193 -0
- package/app/read.js +113 -0
- package/app/rebase.js +302 -0
- package/app/recording.js +72 -0
- package/app/review.js +147 -0
- package/app/roster.js +123 -0
- package/app/spawn.js +125 -0
- package/app/teardown.js +205 -0
- package/app/tower.js +552 -0
- package/cordis.patch.yml +61 -53
- package/docs/README.md +3 -1
- 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 +114 -13
- package/docs/board.md +72 -0
- package/docs/guide/hot-reload.md +1 -1
- package/docs/guide/install-upgrade-migration.md +25 -1
- package/docs/guide/release.md +125 -0
- package/docs/operations/security.md +47 -2
- package/docs/reference/blockers.md +1 -1
- package/docs/reference/config.generated.md +70 -0
- package/docs/reference/config.md +8 -1
- package/docs/reference/doctor.md +58 -8
- package/docs/reference/errors.generated.md +41 -0
- package/docs/reference/errors.md +7 -3
- package/docs/reference/tools.generated.md +33 -0
- package/docs/reference/tools.md +7 -6
- package/docs/zh/README.md +3 -2
- package/docs/zh/guide/hot-reload.md +1 -1
- package/docs/zh/guide/install-upgrade-migration.md +18 -1
- package/docs/zh/operations/security.md +30 -1
- package/docs/zh/reference/blockers.md +1 -1
- package/docs/zh/reference/config.md +8 -1
- package/docs/zh/reference/doctor.md +35 -2
- package/docs/zh/reference/errors.md +6 -2
- package/docs/zh/reference/tools.md +3 -3
- package/domain/collab/batch.js +112 -0
- package/domain/collab/messages.js +127 -0
- package/domain/collab/roster.js +158 -0
- package/domain/collab/spawn.js +188 -0
- package/domain/config/schema.js +427 -0
- package/domain/contracts/outputs.js +305 -0
- package/domain/contracts/tool-inputs/collaboration.js +58 -0
- package/domain/contracts/tool-inputs/findings.js +29 -0
- package/domain/contracts/tool-inputs/index.js +94 -0
- package/domain/contracts/tool-inputs/integration.js +37 -0
- package/domain/contracts/tool-inputs/missions.js +95 -0
- package/domain/contracts/tool-inputs/ops.js +75 -0
- package/domain/contracts/tool-inputs/shared.js +25 -0
- package/domain/errors.js +98 -0
- package/domain/gate.js +125 -0
- package/domain/integration/gate.js +190 -0
- package/domain/integration/init.js +261 -0
- package/domain/integration/liveness.js +31 -0
- package/domain/integration/merge.js +377 -0
- package/domain/integration/provision.js +124 -0
- package/domain/integration/rebase.js +236 -0
- package/domain/integration/review.js +52 -0
- package/domain/integration/teardown.js +118 -0
- package/domain/mission.js +68 -0
- package/domain/missions/completion.js +167 -0
- package/domain/missions/findings.js +201 -0
- package/domain/missions/lifecycle.js +186 -0
- package/domain/missions/plan.js +149 -0
- package/domain/missions/review.js +179 -0
- package/domain/planning.js +121 -0
- package/domain/read/enrichedMissions.js +58 -0
- package/domain/read/missionContext.js +53 -0
- package/domain/read/views.js +141 -0
- package/domain/report.js +67 -0
- package/domain/roster.js +40 -0
- package/domain/scope.js +177 -0
- package/domain/state/archive.js +257 -0
- package/domain/state/migrations.js +145 -0
- package/domain/state/schema.js +239 -0
- package/domain/state/semantics.js +117 -0
- package/domain/state.js +183 -0
- package/domain/summary.js +116 -0
- package/domain/vocabulary.js +179 -0
- package/index.js +63 -20
- package/lib/archive.js +279 -0
- package/lib/briefing.js +176 -0
- package/lib/caseSensitivity.js +66 -0
- package/lib/compatibility.js +108 -0
- package/lib/config.js +33 -375
- package/lib/doctor.js +275 -4
- package/lib/git.js +60 -16
- package/lib/hmr.js +4 -3
- package/lib/leases.js +17 -7
- package/lib/legacy.js +39 -14
- package/lib/modeState.js +39 -0
- package/lib/modeStore.js +11 -43
- package/lib/persistence.js +356 -0
- package/lib/presets.js +35 -0
- package/lib/spawn-prechecks.js +168 -0
- package/lib/spawn-support.js +79 -0
- package/lib/stateFile.js +99 -0
- package/lib/store.js +67 -135
- package/lib/tests.js +17 -12
- package/lib/tool-registry.js +544 -0
- package/lib/workspaceLock.js +2 -2
- package/lib/worktreeOwnership.js +22 -10
- package/locale/en.json +23 -1
- package/locale/zh.json +22 -0
- package/package.json +32 -4
- package/ports/clock.js +6 -0
- package/ports/ids.js +7 -0
- package/ports/index.js +2 -0
- package/ports/store.js +11 -0
- package/ports/vcs.js +11 -0
- package/scripts/architecture.mjs +94 -0
- package/scripts/check-docs.mjs +9 -3
- package/scripts/check-pack.mjs +49 -0
- package/scripts/domain-metrics.mjs +48 -0
- package/scripts/generate-config-reference.mjs +46 -0
- package/scripts/generate-error-reference.mjs +29 -0
- package/scripts/generate-tools-reference.mjs +56 -0
- package/scripts/issue-audit.mjs +259 -0
- package/scripts/lib/cli.mjs +76 -0
- package/scripts/lib/tmp.mjs +20 -0
- package/scripts/mount-smoke/fixtures.mjs +162 -0
- package/scripts/mount-smoke/host.json +4 -0
- package/scripts/mount-smoke/probe.mjs +125 -0
- package/scripts/mount-smoke/surface.mjs +141 -0
- package/scripts/mount-smoke.mjs +270 -0
- package/scripts/pack-manifest.txt +212 -0
- package/scripts/release-check.mjs +303 -0
- package/tools/adopt.js +72 -0
- package/tools/batch.js +78 -0
- package/tools/complete.js +53 -0
- package/tools/diff.js +39 -0
- package/tools/doctor.js +50 -0
- package/tools/finding.js +40 -0
- package/tools/findings.js +39 -0
- package/tools/import-legacy.js +42 -0
- package/tools/inbox.js +39 -0
- package/tools/index.js +39 -0
- package/tools/init.js +63 -0
- package/tools/interrupt.js +42 -0
- package/tools/legacy-map.js +44 -0
- package/tools/merge.js +39 -0
- package/tools/mission.js +41 -0
- package/tools/plan.js +39 -0
- package/tools/preflight.js +45 -0
- package/tools/rebase.js +39 -0
- package/tools/recover.js +45 -0
- package/tools/reload.js +52 -0
- package/tools/resource.js +54 -0
- package/tools/review.js +45 -0
- package/tools/roster.js +60 -0
- package/tools/send.js +147 -0
- package/tools/spawn.js +196 -0
- package/tools/status.js +102 -0
- package/tools/teardown.js +42 -0
- package/tools/test.js +61 -0
- package/types/cordis.d.ts +6 -0
- package/types/index.d.ts +373 -0
- package/lib/errors.js +0 -123
- package/lib/tools.js +0 -2460
- package/lib/tower.js +0 -2912
- /package/{lib → domain}/batch.js +0 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// tower_spawn prechecks (#56 P3c): roster naming, mission preconditions, and
|
|
2
|
+
// service/provider/lease validation. Split out of tools/spawn.js; no I/O.
|
|
3
|
+
import { TowerToolError } from '../domain/errors.js'
|
|
4
|
+
import { ROSTER_INACTIVE_STATES } from '../domain/roster.js'
|
|
5
|
+
import { inspectLeases } from './leases.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Pick a unique roster name before any activation.
|
|
9
|
+
* @param {{ role: string, missionId: string | undefined, requested: string | undefined, roster: any[] }} input
|
|
10
|
+
*/
|
|
11
|
+
export function resolveRosterName({ role, missionId, requested, roster }) {
|
|
12
|
+
if (typeof requested === 'string' && requested.trim()) {
|
|
13
|
+
const name = requested.trim()
|
|
14
|
+
if (roster.some((entry) => entry.name === name)) {
|
|
15
|
+
throw new TowerToolError('invalid-argument', 'roster name already used: ' + name)
|
|
16
|
+
}
|
|
17
|
+
return name
|
|
18
|
+
}
|
|
19
|
+
const base = role + '-' + String(missionId).toLowerCase()
|
|
20
|
+
let candidate = base
|
|
21
|
+
let suffix = 2
|
|
22
|
+
while (roster.some((entry) => entry.name === candidate)) {
|
|
23
|
+
candidate = base + '-' + suffix
|
|
24
|
+
suffix += 1
|
|
25
|
+
}
|
|
26
|
+
return candidate
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Role/mission preconditions checked before any provisioning.
|
|
31
|
+
* @param {{ role: string, context: any }} input
|
|
32
|
+
*/
|
|
33
|
+
export function assertSpawnableMission({ role, context }) {
|
|
34
|
+
if (role === 'reviewer') {
|
|
35
|
+
if (!context.mission.worktree) {
|
|
36
|
+
throw new TowerToolError('reviewer-prerequisite', 'reviewer needs an existing mission worktree; spawn a worker first')
|
|
37
|
+
}
|
|
38
|
+
if (context.mission.status !== 'completed') {
|
|
39
|
+
throw new TowerToolError('reviewer-prerequisite', `reviewer requires a completed mission; ${context.mission.id} is ${context.mission.status}`)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (role === 'worker' && ['merged', 'abandoned'].includes(context.mission.status)) {
|
|
43
|
+
throw new TowerToolError(
|
|
44
|
+
'invalid-transition',
|
|
45
|
+
`mission ${context.mission.id} is ${context.mission.status}; a worker can only be spawned for a planned or active mission`,
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Single-owner and live-worker rules, checked before provisioning or starting
|
|
52
|
+
* a child so a rejection cannot leak an activation or worktree.
|
|
53
|
+
* @param {{
|
|
54
|
+
* role: string,
|
|
55
|
+
* tower: any,
|
|
56
|
+
* ctx: any,
|
|
57
|
+
* view: any,
|
|
58
|
+
* missionId: string | undefined,
|
|
59
|
+
* takeover: boolean | undefined,
|
|
60
|
+
* maxLiveWorkers: number | undefined,
|
|
61
|
+
* budget: number,
|
|
62
|
+
* observeActivity: any,
|
|
63
|
+
* }} input
|
|
64
|
+
*/
|
|
65
|
+
export async function precheckWorkerOwnership({
|
|
66
|
+
role,
|
|
67
|
+
tower,
|
|
68
|
+
ctx,
|
|
69
|
+
view,
|
|
70
|
+
missionId,
|
|
71
|
+
takeover,
|
|
72
|
+
maxLiveWorkers,
|
|
73
|
+
budget,
|
|
74
|
+
observeActivity,
|
|
75
|
+
}) {
|
|
76
|
+
let effectiveTakeover = takeover === true
|
|
77
|
+
if (role !== 'worker') return effectiveTakeover
|
|
78
|
+
const liveWorkers = view.roster.filter(
|
|
79
|
+
(entry) => entry.role === 'worker' && !ROSTER_INACTIVE_STATES.includes(entry.state),
|
|
80
|
+
)
|
|
81
|
+
const sameMission = liveWorkers.filter((entry) => entry.missionId === missionId)
|
|
82
|
+
if (sameMission.length > 1) {
|
|
83
|
+
throw new TowerToolError(
|
|
84
|
+
'invalid-transition',
|
|
85
|
+
'mission ' + missionId + ' has ' + sameMission.length + ' live worker entries; inspect tower_roster before spawning',
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
if (sameMission.length === 1 && !effectiveTakeover) {
|
|
89
|
+
throw new TowerToolError(
|
|
90
|
+
'invalid-transition',
|
|
91
|
+
'mission ' + missionId + ' already has a live worker "' + sameMission[0].name + '"; stop it with tower_interrupt, or pass takeover:true after it is no longer running',
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
if (effectiveTakeover) {
|
|
95
|
+
if (sameMission.length === 1) {
|
|
96
|
+
const worker = sameMission[0]
|
|
97
|
+
const observed = await observeActivity(ctx, tower)
|
|
98
|
+
if (observed.parentStatus[worker.parentSessionId] !== 'ok') {
|
|
99
|
+
throw new TowerToolError(
|
|
100
|
+
'takeover-refused',
|
|
101
|
+
'cannot verify that worker ' + worker.name + ' stopped: its parent catalog is unavailable',
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
const activity = observed.activity[worker.agentId]
|
|
105
|
+
if (activity === 'running') {
|
|
106
|
+
throw new TowerToolError(
|
|
107
|
+
'takeover-refused',
|
|
108
|
+
'worker ' + worker.name + ' is still running; call tower_interrupt (or wait) before takeover',
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
if (activity === undefined) {
|
|
112
|
+
throw new TowerToolError(
|
|
113
|
+
'takeover-refused',
|
|
114
|
+
'worker ' + worker.name + ' is not in the live catalog; run tower_adopt before takeover',
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
} else {
|
|
118
|
+
effectiveTakeover = false
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
const replacing = effectiveTakeover ? sameMission : []
|
|
122
|
+
const remaining = liveWorkers.filter((entry) => !replacing.includes(entry))
|
|
123
|
+
const max = Math.min(Number(maxLiveWorkers) || 4, budget)
|
|
124
|
+
if (remaining.length >= max) {
|
|
125
|
+
throw new TowerToolError(
|
|
126
|
+
'worker-limit',
|
|
127
|
+
'live worker limit reached (' + max + '); stop a worker with tower_interrupt or raise spawn.maxLiveWorkers',
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
return effectiveTakeover
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Service/provider/preset/lease checks, run before provisioning.
|
|
135
|
+
* @param {{ tower: any, ctx: any, config: any, preset: string, actor: any, exec: any }} input
|
|
136
|
+
*/
|
|
137
|
+
export async function precheckSpawnServices({ tower, ctx, config, preset, actor, exec }) {
|
|
138
|
+
const subagents = ctx.get('subagents')
|
|
139
|
+
if (!subagents) throw new TowerToolError('spawn-unavailable', 'subagents service is unavailable; cannot spawn')
|
|
140
|
+
const providers = subagents.list()
|
|
141
|
+
const provider = config.spawn.provider || (providers.includes('spawn') ? 'spawn' : providers[0])
|
|
142
|
+
if (!provider) throw new TowerToolError('provider-missing', 'no subagent provider is registered')
|
|
143
|
+
const presets = ctx.get('agentPresets')
|
|
144
|
+
if (presets && typeof presets.list === 'function') {
|
|
145
|
+
const available = await presets.list()
|
|
146
|
+
if (!available.some((entry) => String(entry.id) === preset)) {
|
|
147
|
+
throw new TowerToolError('preset-missing', 'agent preset "' + preset + '" is not registered; check the Tower bundle preset rows and restart DSH')
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (typeof config.spawn.requireLease === 'string' && config.spawn.requireLease.trim()) {
|
|
151
|
+
const requiredLease = config.spawn.requireLease.trim()
|
|
152
|
+
const info = await inspectLeases(tower.commonDir)
|
|
153
|
+
const lease = info.leases.find((entry) => entry.name === requiredLease && entry.expired !== true)
|
|
154
|
+
if (!lease) {
|
|
155
|
+
throw new TowerToolError(
|
|
156
|
+
'lease-busy',
|
|
157
|
+
`spawn.requireLease="${requiredLease}" is not held; acquire it with tower_resource before spawning`,
|
|
158
|
+
)
|
|
159
|
+
}
|
|
160
|
+
if (lease.holder !== actor.name && lease.holder !== String(exec.agent.id)) {
|
|
161
|
+
throw new TowerToolError(
|
|
162
|
+
'lease-busy',
|
|
163
|
+
`spawn.requireLease="${requiredLease}" is held by "${lease.holder}", not by this tower`,
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return { subagents, provider }
|
|
168
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// tower_spawn support (#56 P3c): briefing assembly, agent options, and the
|
|
2
|
+
// wait/result settling path. Split out of tools/spawn.js; no I/O of its own.
|
|
3
|
+
import { workerBriefing, reviewerBriefing } from './briefing.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @param {any} model
|
|
7
|
+
*/
|
|
8
|
+
export function buildAgentOptions(model) {
|
|
9
|
+
const agentOptions = {}
|
|
10
|
+
for (const key of ['provider', 'model', 'reasoningEffort', 'maxTokens']) {
|
|
11
|
+
if (model && model[key] !== undefined && model[key] !== null && model[key] !== '') agentOptions[key] = model[key]
|
|
12
|
+
}
|
|
13
|
+
return agentOptions
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {{ role: string, towerRoot: string, context: any, worktree: any, instructions: string | undefined }} input
|
|
18
|
+
*/
|
|
19
|
+
export function buildSpawnBriefing({ role, towerRoot, context, worktree, instructions }) {
|
|
20
|
+
const mission = {
|
|
21
|
+
...context.mission,
|
|
22
|
+
worktree: { path: worktree.path, branch: worktree.branch, baseCommit: worktree.baseCommit },
|
|
23
|
+
}
|
|
24
|
+
const briefing = {
|
|
25
|
+
repoRoot: towerRoot,
|
|
26
|
+
base: context.base,
|
|
27
|
+
mission,
|
|
28
|
+
deps: context.deps,
|
|
29
|
+
worktree,
|
|
30
|
+
instructions,
|
|
31
|
+
reviews: context.reviews,
|
|
32
|
+
findings: context.findings,
|
|
33
|
+
completionReport: context.completionReport,
|
|
34
|
+
}
|
|
35
|
+
return role === 'worker' ? workerBriefing(briefing) : reviewerBriefing(briefing)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Wait for the activation result when wait=true, or forward the eventual
|
|
40
|
+
* outcome when wait=false.
|
|
41
|
+
* @param {{
|
|
42
|
+
* activation: any,
|
|
43
|
+
* report: any,
|
|
44
|
+
* recordOutcome: (state: string, detail: any) => Promise<any>,
|
|
45
|
+
* maxWaitMs: number | undefined,
|
|
46
|
+
* requestedWaitMs: number | undefined,
|
|
47
|
+
* textOf: any,
|
|
48
|
+
* }} input
|
|
49
|
+
*/
|
|
50
|
+
export async function waitForSpawnResult({ activation, report, recordOutcome, maxWaitMs, requestedWaitMs, textOf }) {
|
|
51
|
+
const waitMs = Math.max(1000, Math.min(maxWaitMs ?? 90000, requestedWaitMs ?? 180000))
|
|
52
|
+
let timer
|
|
53
|
+
const timeout = new Promise((resolve) => {
|
|
54
|
+
timer = setTimeout(() => resolve({ __timeout: true }), waitMs)
|
|
55
|
+
})
|
|
56
|
+
let result
|
|
57
|
+
try {
|
|
58
|
+
result = await Promise.race([activation.result, timeout])
|
|
59
|
+
} catch (error) {
|
|
60
|
+
report.stopReason = 'failed'
|
|
61
|
+
report.diagnostic = String(error?.message ?? error)
|
|
62
|
+
await recordOutcome('failed', report.diagnostic)
|
|
63
|
+
throw error
|
|
64
|
+
} finally {
|
|
65
|
+
clearTimeout(timer)
|
|
66
|
+
}
|
|
67
|
+
if (result && result.__timeout) {
|
|
68
|
+
report.timedOut = true
|
|
69
|
+
recordOutcome('unknown', 'spawn wait timed out; check tower_roster')
|
|
70
|
+
} else {
|
|
71
|
+
report.stopReason = result?.stopReason ?? null
|
|
72
|
+
report.output = textOf(result?.output)
|
|
73
|
+
report.diagnostic = result?.diagnostic ?? null
|
|
74
|
+
const state = report.stopReason === 'completed' ? 'inactive' : 'failed'
|
|
75
|
+
if (state === 'failed') report.diagnostic = report.diagnostic ?? report.stopReason
|
|
76
|
+
await recordOutcome(state, report.diagnostic ?? null)
|
|
77
|
+
}
|
|
78
|
+
return report
|
|
79
|
+
}
|
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
|
@@ -1,14 +1,31 @@
|
|
|
1
|
+
import { TowerToolError } from '../domain/errors.js'
|
|
1
2
|
// Durable repo-scoped store: exclusive lock + atomic JSON + append-only activity.
|
|
2
|
-
|
|
3
|
+
// The atomic/lock/JSON primitives are shared with the mode store (#56 P2b).
|
|
4
|
+
import { fsp } from '../adapters/store-fs.js'
|
|
5
|
+
import {
|
|
6
|
+
PROCESS_STARTED_AT,
|
|
7
|
+
acquireLock,
|
|
8
|
+
lockRecord,
|
|
9
|
+
readLock,
|
|
10
|
+
} from './persistence.js'
|
|
3
11
|
import path from 'node:path'
|
|
4
12
|
import crypto from 'node:crypto'
|
|
5
13
|
|
|
14
|
+
export {
|
|
15
|
+
readJson,
|
|
16
|
+
writeJsonAtomic,
|
|
17
|
+
writeJsonAtomicSync,
|
|
18
|
+
renameWithRetry,
|
|
19
|
+
appendJsonl,
|
|
20
|
+
} from './persistence.js'
|
|
21
|
+
|
|
6
22
|
export function pathsFor(commonDir) {
|
|
7
23
|
const dir = path.join(commonDir, 'dsh-tower')
|
|
8
24
|
return {
|
|
9
25
|
dir,
|
|
10
26
|
state: path.join(dir, 'state.json'),
|
|
11
27
|
lock: path.join(dir, 'state.lock'),
|
|
28
|
+
gitLock: path.join(dir, 'git.lock'),
|
|
12
29
|
activity: path.join(dir, 'activity.jsonl'),
|
|
13
30
|
}
|
|
14
31
|
}
|
|
@@ -26,55 +43,6 @@ export async function ensureDir(dir) {
|
|
|
26
43
|
await fsp.mkdir(dir, { recursive: true })
|
|
27
44
|
}
|
|
28
45
|
|
|
29
|
-
export async function readJson(file) {
|
|
30
|
-
const text = await fsp.readFile(file, 'utf8')
|
|
31
|
-
try {
|
|
32
|
-
return JSON.parse(text)
|
|
33
|
-
} catch (error) {
|
|
34
|
-
throw new Error(`corrupt tower state at ${file}: ${error.message}`)
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export async function writeJsonAtomic(file, value) {
|
|
39
|
-
const dir = path.dirname(file)
|
|
40
|
-
const tmp = path.join(dir, `.${path.basename(file)}.tmp-${process.pid}-${crypto.randomUUID()}`)
|
|
41
|
-
const handle = await fsp.open(tmp, 'w')
|
|
42
|
-
try {
|
|
43
|
-
await handle.writeFile(JSON.stringify(value, null, 2), 'utf8')
|
|
44
|
-
await handle.sync()
|
|
45
|
-
} finally {
|
|
46
|
-
await handle.close()
|
|
47
|
-
}
|
|
48
|
-
await renameWithRetry(tmp, file)
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Windows can reject a rename while an indexer, antivirus, or a concurrent
|
|
53
|
-
* reader still holds the destination; retry briefly instead of failing a
|
|
54
|
-
* successful mutation. The temp file is removed when the rename never lands.
|
|
55
|
-
*/
|
|
56
|
-
async function renameWithRetry(tmp, file) {
|
|
57
|
-
for (let attempt = 0; ; attempt += 1) {
|
|
58
|
-
try {
|
|
59
|
-
await fsp.rename(tmp, file)
|
|
60
|
-
return
|
|
61
|
-
} catch (error) {
|
|
62
|
-
const transient = error?.code === 'EPERM' || error?.code === 'EBUSY' || error?.code === 'EACCES'
|
|
63
|
-
if (!transient || attempt >= 12) {
|
|
64
|
-
await fsp.unlink(tmp).catch(() => {})
|
|
65
|
-
throw error
|
|
66
|
-
}
|
|
67
|
-
await sleep(10 + attempt * 15)
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
export { renameWithRetry }
|
|
73
|
-
|
|
74
|
-
export async function appendJsonl(file, value) {
|
|
75
|
-
await fsp.appendFile(file, `${JSON.stringify(value)}\n`, 'utf8')
|
|
76
|
-
}
|
|
77
|
-
|
|
78
46
|
export function shortHash(text) {
|
|
79
47
|
return crypto.createHash('sha256').update(String(text)).digest('hex').slice(0, 12)
|
|
80
48
|
}
|
|
@@ -94,66 +62,21 @@ export async function readActivityTail(file, limit = 12) {
|
|
|
94
62
|
return out
|
|
95
63
|
}
|
|
96
64
|
|
|
97
|
-
function sleep(ms) {
|
|
98
|
-
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
const PROCESS_STARTED_AT = Date.now() - Math.round(process.uptime() * 1000)
|
|
102
|
-
|
|
103
|
-
function processAlive(pid) {
|
|
104
|
-
if (!Number.isInteger(Number(pid)) || Number(pid) <= 0) return null
|
|
105
|
-
try {
|
|
106
|
-
process.kill(Number(pid), 0)
|
|
107
|
-
return true
|
|
108
|
-
} catch (error) {
|
|
109
|
-
if (error?.code === 'ESRCH') return false
|
|
110
|
-
// EPERM means the process exists but belongs to another user.
|
|
111
|
-
return true
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
/** Read the current lock record without acquiring the lock. */
|
|
116
65
|
export async function readStateLock(paths) {
|
|
117
|
-
|
|
118
|
-
const text = await fsp.readFile(paths.lock, 'utf8')
|
|
119
|
-
const record = JSON.parse(text)
|
|
120
|
-
const alive = processAlive(record?.pid)
|
|
121
|
-
return { path: paths.lock, record, alive, raw: text }
|
|
122
|
-
} catch (error) {
|
|
123
|
-
if (error?.code === 'ENOENT') return { path: paths.lock, record: null, alive: null, raw: null }
|
|
124
|
-
return { path: paths.lock, record: null, alive: null, raw: null, error: String(error?.message ?? error) }
|
|
125
|
-
}
|
|
66
|
+
return readLock(paths.lock)
|
|
126
67
|
}
|
|
127
68
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
return false
|
|
140
|
-
}
|
|
141
|
-
if (processAlive(parsed?.pid) !== false) return false
|
|
142
|
-
const quarantine = `${paths.lock}.stale-${process.pid}-${crypto.randomUUID()}`
|
|
143
|
-
try {
|
|
144
|
-
await fsp.rename(paths.lock, quarantine)
|
|
145
|
-
} catch {
|
|
146
|
-
return false
|
|
147
|
-
}
|
|
148
|
-
const moved = await fsp.readFile(quarantine, 'utf8').catch(() => null)
|
|
149
|
-
if (moved === null || moved !== original) {
|
|
150
|
-
// A concurrent writer replaced the lock between read and rename. Put the
|
|
151
|
-
// newer record back rather than deleting it.
|
|
152
|
-
if (moved !== null) await fsp.rename(quarantine, paths.lock).catch(() => {})
|
|
153
|
-
return false
|
|
154
|
-
}
|
|
155
|
-
await fsp.unlink(quarantine).catch(() => {})
|
|
156
|
-
return true
|
|
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)
|
|
157
80
|
}
|
|
158
81
|
|
|
159
82
|
/**
|
|
@@ -164,38 +87,47 @@ export async function withLock(paths, fn, options = {}) {
|
|
|
164
87
|
const timeoutMs = options.timeoutMs ?? 5000
|
|
165
88
|
const pollMs = options.pollMs ?? 25
|
|
166
89
|
const token = crypto.randomUUID()
|
|
167
|
-
const record =
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
90
|
+
const record = lockRecord(token, { startedAt: PROCESS_STARTED_AT })
|
|
91
|
+
const held = await acquireLock(paths.lock, { token, record, timeoutMs, pollMs })
|
|
92
|
+
if (!held) {
|
|
93
|
+
const owner = await readStateLock(paths)
|
|
94
|
+
const ownerRecord = owner.record
|
|
95
|
+
const payload = owner.raw ?? (ownerRecord ? JSON.stringify(ownerRecord) : null)
|
|
96
|
+
throw new TowerToolError(
|
|
97
|
+
'state-locked',
|
|
98
|
+
`tower state is locked by another writer${payload ? ` (${payload})` : ''}; retry after it finishes`,
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
const startedAt = Date.now()
|
|
102
|
+
HELD_STATE_LOCKS.add(paths)
|
|
103
|
+
try {
|
|
104
|
+
return await fn(paths)
|
|
105
|
+
} finally {
|
|
106
|
+
HELD_STATE_LOCKS.delete(paths)
|
|
107
|
+
await held.release()
|
|
108
|
+
await fsp
|
|
109
|
+
.appendFile(path.join(paths.dir, 'lock-holds.jsonl'), `${JSON.stringify({ at: Date.now(), ms: Date.now() - startedAt, pid: process.pid })}\n`, 'utf8')
|
|
110
|
+
.catch(() => {})
|
|
186
111
|
}
|
|
112
|
+
}
|
|
187
113
|
|
|
114
|
+
/** Serialize git mutations without holding the state lock. */
|
|
115
|
+
export async function withGitLock(paths, fn, options = {}) {
|
|
116
|
+
const timeoutMs = options.timeoutMs ?? 60000
|
|
117
|
+
const pollMs = options.pollMs ?? 25
|
|
118
|
+
const lock = paths.gitLock
|
|
119
|
+
const token = crypto.randomUUID()
|
|
120
|
+
const startedAt = Date.now()
|
|
121
|
+
const held = await acquireLock(lock, { token, record: lockRecord(token, { kind: 'git' }), timeoutMs, pollMs })
|
|
122
|
+
if (!held) {
|
|
123
|
+
throw new TowerToolError('state-locked', 'another git mutation is in progress for this repository; retry after it finishes')
|
|
124
|
+
}
|
|
188
125
|
try {
|
|
189
126
|
return await fn(paths)
|
|
190
127
|
} finally {
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
if (parsed && parsed.token === token) await fsp.unlink(paths.lock)
|
|
196
|
-
}
|
|
197
|
-
} catch {
|
|
198
|
-
// a lock left behind by a crash is reported by the next writer, not fixed here
|
|
199
|
-
}
|
|
128
|
+
await held.release()
|
|
129
|
+
await fsp
|
|
130
|
+
.appendFile(path.join(paths.dir, 'lock-holds.jsonl'), `${JSON.stringify({ at: Date.now(), ms: Date.now() - startedAt, pid: process.pid, phase: 'git' })}\n`, 'utf8')
|
|
131
|
+
.catch(() => {})
|
|
200
132
|
}
|
|
201
133
|
}
|
package/lib/tests.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { TowerToolError } from '../domain/errors.js'
|
|
1
2
|
// Tower test discovery (2.1): fail-closed admission evidence from the
|
|
2
3
|
// repository's own `package.json` `tower.testDiscovery` block.
|
|
3
4
|
//
|
|
@@ -18,10 +19,10 @@
|
|
|
18
19
|
// minCount/required shortfall) returns `ok: false` in the receipt;
|
|
19
20
|
// - an invalid discovery configuration (or invalid call arguments) throws a
|
|
20
21
|
// plain `Error`, which the tool layer wraps as `tower:invalid-argument`.
|
|
21
|
-
import { spawn } from '
|
|
22
|
-
import { createWriteStream } from '
|
|
22
|
+
import { spawn } from '../adapters/vcs-git.js'
|
|
23
|
+
import { createWriteStream } from '../adapters/store-fs.js'
|
|
23
24
|
import crypto from 'node:crypto'
|
|
24
|
-
import fsp from '
|
|
25
|
+
import { fsp } from '../adapters/store-fs.js'
|
|
25
26
|
import path from 'node:path'
|
|
26
27
|
|
|
27
28
|
import { tryGit } from './git.js'
|
|
@@ -45,7 +46,7 @@ function nonEmptyString(value) {
|
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
function normalizePositiveInteger(value, name) {
|
|
48
|
-
if (!Number.isInteger(value) || value <= 0) throw new
|
|
49
|
+
if (!Number.isInteger(value) || value <= 0) throw new TowerToolError('invalid-argument', `${name} must be an integer > 0`)
|
|
49
50
|
return value
|
|
50
51
|
}
|
|
51
52
|
|
|
@@ -287,8 +288,12 @@ export function testProcessEnv(source = process.env) {
|
|
|
287
288
|
return env
|
|
288
289
|
}
|
|
289
290
|
|
|
290
|
-
/**
|
|
291
|
-
|
|
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) {
|
|
292
297
|
if (platform === 'win32') {
|
|
293
298
|
const args = (command) => ['-NoProfile', '-NonInteractive', '-Command', command]
|
|
294
299
|
return {
|
|
@@ -480,7 +485,7 @@ async function resolveDiagnosticsDir({ worktreePath, diagnosticsDir, missionId }
|
|
|
480
485
|
|
|
481
486
|
function normalizeDiscoveryInput(config) {
|
|
482
487
|
if (isPlainObject(config) && Object.prototype.hasOwnProperty.call(config, 'configured')) {
|
|
483
|
-
if (config.error) throw new
|
|
488
|
+
if (config.error) throw new TowerToolError('invalid-argument', `test discovery configuration is invalid: ${config.error}`)
|
|
484
489
|
if (config.configured === false) return { configured: false, config: null, error: null }
|
|
485
490
|
return { configured: true, config: config.config, error: null }
|
|
486
491
|
}
|
|
@@ -527,16 +532,16 @@ export async function runDiscovery(options = {}) {
|
|
|
527
532
|
} = options
|
|
528
533
|
const startedAt = new Date().toISOString()
|
|
529
534
|
|
|
530
|
-
if (!nonEmptyString(worktreePath)) throw new
|
|
531
|
-
if (!path.isAbsolute(worktreePath)) throw new
|
|
535
|
+
if (!nonEmptyString(worktreePath)) throw new TowerToolError('invalid-argument', 'worktreePath is required')
|
|
536
|
+
if (!path.isAbsolute(worktreePath)) throw new TowerToolError('invalid-argument', `worktreePath must be an absolute path: ${worktreePath}`)
|
|
532
537
|
const effectiveTimeout = normalizePositiveInteger(timeoutMs, 'timeoutMs')
|
|
533
538
|
const effectiveMaxBytes = normalizePositiveInteger(maxBytes, 'maxBytes')
|
|
534
539
|
if (runner !== undefined && runner !== null && typeof runner !== 'function') {
|
|
535
|
-
throw new
|
|
540
|
+
throw new TowerToolError('invalid-argument', 'runner must be a function')
|
|
536
541
|
}
|
|
537
542
|
if (diagnosticsDir !== undefined && diagnosticsDir !== null) {
|
|
538
543
|
if (!nonEmptyString(diagnosticsDir) || !path.isAbsolute(diagnosticsDir)) {
|
|
539
|
-
throw new
|
|
544
|
+
throw new TowerToolError('invalid-argument', `diagnosticsDir must be an absolute path: ${diagnosticsDir}`)
|
|
540
545
|
}
|
|
541
546
|
}
|
|
542
547
|
|
|
@@ -558,7 +563,7 @@ export async function runDiscovery(options = {}) {
|
|
|
558
563
|
|
|
559
564
|
if (!discovery.configured) return finishUnconfigured(discovery.error)
|
|
560
565
|
const normalized = normalizeDiscovery(discovery.config)
|
|
561
|
-
if (!normalized.ok) throw new
|
|
566
|
+
if (!normalized.ok) throw new TowerToolError('invalid-argument', `invalid tower.testDiscovery config: ${normalized.error}`)
|
|
562
567
|
|
|
563
568
|
const dir = await resolveDiagnosticsDir({ worktreePath, diagnosticsDir, missionId })
|
|
564
569
|
const commands = []
|