dsh-tower 2.5.0 → 2.7.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 +114 -0
- package/COMPATIBILITY.md +2 -0
- package/CONTRIBUTING.md +1 -1
- package/GLOSSARY.md +6 -0
- package/PRINCIPLES.md +15 -7
- package/README.en.md +18 -16
- package/README.md +18 -11
- package/adapters/leases.js +20 -6
- package/adapters/legacy.js +149 -37
- package/adapters/stateFile.js +11 -1
- package/app/archive.js +30 -5
- package/app/briefing.js +95 -12
- package/app/checkCompletion.js +107 -18
- package/app/enrichedMissions.js +1 -1
- package/app/freshness.js +77 -0
- package/app/gate.js +26 -12
- package/app/merge.js +7 -4
- package/app/mission.js +24 -1
- package/app/missionContext.js +2 -1
- package/app/plan.js +64 -1
- package/app/provision.js +133 -22
- package/app/read.js +38 -5
- package/app/rebase.js +10 -8
- package/app/review.js +104 -61
- package/app/reviewCheckout.js +133 -0
- package/app/sandbox.js +121 -0
- package/app/sharedCheckout.js +90 -0
- package/app/spawn-prechecks.js +31 -20
- package/app/spawn-support.js +59 -8
- package/app/spawn.js +3 -0
- package/app/teardown.js +119 -2
- package/app/tower.js +34 -6
- package/client.js +321 -0
- package/cordis.patch.yml +55 -3
- package/docs/adr/0005-component-surface.md +58 -13
- package/docs/adr/0006-mission-shapes.md +306 -0
- package/docs/architecture/overview.md +18 -10
- package/docs/examples/first-mission.md +3 -2
- package/docs/faq.md +9 -7
- package/docs/guide/install-upgrade-migration.md +58 -7
- package/docs/guide/quickstart.md +20 -10
- package/docs/guide/scenarios-and-philosophy.md +35 -28
- package/docs/operations/runbook.md +1 -1
- package/docs/operations/security.md +11 -10
- package/docs/reference/blockers.md +20 -11
- package/docs/reference/component-surface.md +20 -8
- package/docs/reference/config.generated.md +8 -5
- package/docs/reference/config.md +30 -5
- package/docs/reference/doctor.md +7 -7
- package/docs/reference/errors.generated.md +1 -0
- package/docs/reference/errors.md +2 -1
- package/docs/reference/operations.md +1 -1
- package/docs/reference/shapes.md +123 -0
- package/docs/reference/surface-budget.md +53 -4
- package/docs/reference/testing.md +5 -3
- package/docs/reference/tools.generated.md +7 -7
- package/docs/reference/tools.md +5 -5
- package/docs/zh/faq.md +7 -5
- package/docs/zh/guide/install-upgrade-migration.md +25 -6
- package/docs/zh/guide/quickstart.md +17 -10
- package/docs/zh/guide/scenarios-and-philosophy.md +23 -15
- package/docs/zh/operations/security.md +9 -8
- package/docs/zh/reference/blockers.md +13 -9
- package/docs/zh/reference/config.md +22 -5
- package/docs/zh/reference/errors.md +2 -1
- package/docs/zh/reference/testing.md +5 -4
- package/docs/zh/reference/tools.md +3 -3
- package/domain/batch.js +75 -105
- package/domain/collab/batch.js +30 -10
- package/domain/collab/spawn.js +53 -16
- package/domain/components.js +12 -4
- package/domain/config/schema.js +87 -22
- package/domain/contracts/outputs.js +2 -1
- package/domain/contracts/tool-inputs/missions.js +10 -2
- package/domain/errors.js +6 -0
- package/domain/evidence.js +283 -0
- package/domain/gate.js +17 -34
- package/domain/integration/gate.js +17 -43
- package/domain/integration/init.js +3 -2
- package/domain/integration/provision.js +51 -0
- package/domain/integration/rebase.js +16 -1
- package/domain/integration/review.js +203 -9
- package/domain/mission.js +16 -33
- package/domain/missions/completion.js +46 -8
- package/domain/missions/findings.js +2 -1
- package/domain/missions/lifecycle.js +131 -35
- package/domain/missions/plan.js +30 -6
- package/domain/missions/review.js +2 -18
- package/domain/planning.js +5 -5
- package/domain/read/missionContext.js +17 -3
- package/domain/read/views.js +5 -1
- package/domain/roles.js +222 -0
- package/domain/roster.js +9 -3
- package/domain/scope.js +51 -0
- package/domain/shapes/build.js +254 -0
- package/domain/shapes/index.js +171 -0
- package/domain/shapes/survey.js +445 -0
- package/domain/state/archive.js +28 -4
- package/domain/state/migrations.js +111 -4
- package/domain/state/schema.js +69 -7
- package/domain/state/semantics.js +93 -13
- package/domain/state.js +5 -1
- package/domain/summary.js +5 -11
- package/domain/vocabulary.js +15 -33
- package/host/capability-registry.js +25 -9
- package/host/config.js +40 -3
- package/host/doctor.js +20 -8
- package/host/preset-survey-sandbox.js +19 -0
- package/host/preset-survey.js +18 -0
- package/host/presets.js +17 -11
- package/host/tool-registry.js +23 -13
- package/locale/en.json +16 -1
- package/locale/preset-survey/en.json +6 -0
- package/locale/preset-survey/zh.json +6 -0
- package/locale/preset-survey-sandbox/en.json +6 -0
- package/locale/preset-survey-sandbox/zh.json +6 -0
- package/locale/zh.json +16 -1
- package/package.json +19 -2
- package/ports/state.js +8 -1
- package/scripts/architecture-waivers.json +3 -3
- package/scripts/check-components.mjs +120 -6
- package/scripts/check-docs.mjs +1 -1
- package/scripts/check-pack.mjs +5 -0
- package/scripts/component-surface.json +83 -29
- package/scripts/generate-capability-locale.mjs +5 -0
- package/scripts/generate-shapes-reference.mjs +171 -0
- package/scripts/mount-smoke/surface.mjs +3 -0
- package/scripts/pack-manifest.txt +22 -0
- package/scripts/shape-audit.mjs +361 -0
- package/scripts/surface-baseline.json +75 -4
- package/scripts/types-coverage.mjs +7 -0
- package/survey-sandbox.js +48 -0
- package/survey.js +40 -0
- package/tools/complete.js +5 -5
- package/tools/diff.js +5 -3
- package/tools/plan.js +1 -1
- package/tools/preflight.js +3 -2
- package/tools/review.js +1 -1
- package/tools/spawn.js +48 -50
- package/tools/status.js +8 -1
- package/tools/test.js +2 -1
- package/types/index.d.ts +76 -5
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
// The survey shape (#148/#149): a read of one commit, not a write of a branch.
|
|
2
|
+
//
|
|
3
|
+
// A survey has no worktree, no branch and no commit of its own. By default it
|
|
4
|
+
// runs in the shared checkout with a read-only preset allow-list. With
|
|
5
|
+
// `survey.sandbox: 'detached'` the read moves into a disposable detached git
|
|
6
|
+
// worktree at the recorded commit (#148 W2): the sandbox preset may write and
|
|
7
|
+
// run commands there, but the sandbox has no branch, never merges, is removed
|
|
8
|
+
// when the mission completes or is abandoned, and must still end at the
|
|
9
|
+
// recorded commit with zero commits. Both placements record the commit read
|
|
10
|
+
// plus the tracked-status fingerprint captured at plan time; only the shared
|
|
11
|
+
// placement enforces the fingerprint as a completion invariant.
|
|
12
|
+
// @ts-check
|
|
13
|
+
import { TowerToolError } from '../errors.js'
|
|
14
|
+
import { hasCheckEvidence } from '../evidence.js'
|
|
15
|
+
|
|
16
|
+
/** @typedef {import('../../types/index.js').Mission} Mission */
|
|
17
|
+
/** @typedef {import('../../types/index.js').Blocker} Blocker */
|
|
18
|
+
/** @typedef {import('../../types/index.js').TowerConfig} TowerConfig */
|
|
19
|
+
|
|
20
|
+
/** @type {readonly string[]} */
|
|
21
|
+
export const SURVEY_STATUSES = Object.freeze(['planned', 'active', 'completed', 'blocked', 'paused', 'abandoned'])
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The evidence this shape accepts (#149 W4). A survey has no branch and no
|
|
25
|
+
* tree tip, so a discovery receipt cannot appertain; its completion checks are
|
|
26
|
+
* still accepted through the same one reader.
|
|
27
|
+
* @type {import('../evidence.js').EvidenceContract}
|
|
28
|
+
*/
|
|
29
|
+
export const EVIDENCE = Object.freeze({
|
|
30
|
+
completionChecks: true,
|
|
31
|
+
testReceipts: /** @type {const} */ (false),
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The survey shape's path reach (#149 W4): a survey owns no paths. Its scope is
|
|
36
|
+
* empty by construction and any changed path is a fence violation; the
|
|
37
|
+
* placement (shared read-only checkout or detached disposable sandbox) changes
|
|
38
|
+
* where it runs, not what it may change.
|
|
39
|
+
* @type {{ ownsPaths: boolean, fence: 'scope' | 'read-only' }}
|
|
40
|
+
*/
|
|
41
|
+
export const REACH = Object.freeze({
|
|
42
|
+
ownsPaths: false,
|
|
43
|
+
fence: /** @type {const} */ ('read-only'),
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A survey has no `merged` row or column: it is never merged, only completed.
|
|
48
|
+
* @type {Readonly<Record<string, readonly string[]>>}
|
|
49
|
+
*/
|
|
50
|
+
export const SURVEY_TRANSITIONS = Object.freeze({
|
|
51
|
+
planned: Object.freeze(['active', 'blocked', 'abandoned']),
|
|
52
|
+
active: Object.freeze(['completed', 'blocked', 'paused', 'abandoned']),
|
|
53
|
+
// W3: `blocked` is derived from open blockers; clearing them is the only way
|
|
54
|
+
// out, and a blocker always wins over a pause (hence paused -> blocked).
|
|
55
|
+
blocked: Object.freeze(['active', 'abandoned']),
|
|
56
|
+
paused: Object.freeze(['active', 'blocked', 'abandoned']),
|
|
57
|
+
completed: Object.freeze([]),
|
|
58
|
+
abandoned: Object.freeze([]),
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @param {string} status
|
|
63
|
+
* @returns {boolean}
|
|
64
|
+
*/
|
|
65
|
+
export function isTerminal(status) {
|
|
66
|
+
return status === 'completed' || status === 'abandoned'
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* @param {string} from
|
|
71
|
+
* @param {string} to
|
|
72
|
+
* @returns {boolean}
|
|
73
|
+
*/
|
|
74
|
+
export function canTransition(from, to) {
|
|
75
|
+
return (SURVEY_TRANSITIONS[from] ?? []).includes(to)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Survey planning records what is being read, not what will be written: the
|
|
80
|
+
* commit and the tracked-status fingerprint at plan time. Scope is empty.
|
|
81
|
+
* @param {{ readCommit?: unknown, readDirty?: unknown }} context
|
|
82
|
+
* @returns {{ scope: string[], readCommit: string, readDirty: string[] }}
|
|
83
|
+
*/
|
|
84
|
+
export function planFields(context) {
|
|
85
|
+
const readCommit = context.readCommit
|
|
86
|
+
if (typeof readCommit !== 'string' || readCommit.length === 0) {
|
|
87
|
+
throw new TowerToolError('invalid-argument', 'a survey needs the commit it reads (state.base did not resolve)', {
|
|
88
|
+
nextAction: 'run tower_init so the base branch resolves to a commit, then plan the survey again',
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
const readDirty = Array.isArray(context.readDirty)
|
|
92
|
+
? context.readDirty.filter((entry) => typeof entry === 'string')
|
|
93
|
+
: []
|
|
94
|
+
return { scope: [], readCommit, readDirty }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Completion invariants for a survey that reads the shared checkout: zero
|
|
99
|
+
* commits, same HEAD, same tracked files. The app layer supplies the observed
|
|
100
|
+
* primitives.
|
|
101
|
+
* @param {{ mission: Mission, head: string, readCommit: string, trackedChanged: string[] }} context
|
|
102
|
+
* @returns {{ commits: number }}
|
|
103
|
+
*/
|
|
104
|
+
export function checkCompletion({ mission, head, readCommit, trackedChanged }) {
|
|
105
|
+
if (head !== readCommit) {
|
|
106
|
+
throw new TowerToolError(
|
|
107
|
+
'invalid-argument',
|
|
108
|
+
`survey ${mission.id} moved HEAD from ${String(readCommit).slice(0, 12)} to ${String(head).slice(0, 12)}; a survey reads one commit and makes no commits`,
|
|
109
|
+
{ nextAction: 'reset the shared checkout to the recorded readCommit; do not commit survey work' },
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
if (trackedChanged.length > 0) {
|
|
113
|
+
throw new TowerToolError(
|
|
114
|
+
'invalid-argument',
|
|
115
|
+
`survey ${mission.id} changed tracked files: ${trackedChanged.join(', ')}; a survey is read-only`,
|
|
116
|
+
{ nextAction: 'restore the shared checkout tracked files to the status captured at plan time' },
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
return { commits: 0 }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Completion invariants for a detached survey sandbox: the disposable checkout
|
|
124
|
+
* is allowed to contain write/shell side effects, but it may not commit and it
|
|
125
|
+
* must still be at the recorded commit. The sandbox itself is removed after a
|
|
126
|
+
* successful completion.
|
|
127
|
+
* @param {{ mission: Mission, head: string, readCommit: string, commits: number, sandbox: string }} context
|
|
128
|
+
* @returns {{ commits: number }}
|
|
129
|
+
*/
|
|
130
|
+
export function checkSandboxCompletion({ mission, head, readCommit, commits, sandbox }) {
|
|
131
|
+
if (!sandbox || sandbox !== mission.sandbox) {
|
|
132
|
+
throw new TowerToolError(
|
|
133
|
+
'invalid-argument',
|
|
134
|
+
`detached survey ${mission.id} has no recorded sandbox; re-provision the survey or abandon it`,
|
|
135
|
+
{ nextAction: 'run tower_spawn to create the detached sandbox again, then complete' },
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
if (commits !== 0) {
|
|
139
|
+
throw new TowerToolError(
|
|
140
|
+
'invalid-argument',
|
|
141
|
+
`survey ${mission.id} made ${commits} commit(s) in its detached sandbox; a survey reads one commit and records no commit of its own`,
|
|
142
|
+
{ nextAction: `reset --hard ${String(readCommit).slice(0, 12)} in ${sandbox}; the sandbox is disposable but its HEAD must stay at the recorded commit` },
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
if (head !== readCommit) {
|
|
146
|
+
throw new TowerToolError(
|
|
147
|
+
'invalid-argument',
|
|
148
|
+
`survey ${mission.id} moved the detached sandbox HEAD from ${String(readCommit).slice(0, 12)} to ${String(head).slice(0, 12)}; a survey reads one commit and makes no commits`,
|
|
149
|
+
{ nextAction: `reset --hard ${String(readCommit).slice(0, 12)} in ${sandbox}; the sandbox is removed on completion` },
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
return { commits: 0 }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Resolve the survey placement (#148 W2). The shape owns the decision so
|
|
157
|
+
* generic layers never compare `mission.kind` or read the raw key.
|
|
158
|
+
*
|
|
159
|
+
* A sandbox already recorded on the mission is authoritative: provisioned
|
|
160
|
+
* state must win over current config, otherwise flipping `survey.sandbox`
|
|
161
|
+
* between provision and completion would silently move the mission to the
|
|
162
|
+
* shared checkout (or make the completion invariants disagree with the
|
|
163
|
+
* checkout that actually exists). Config only decides initial provisioning.
|
|
164
|
+
* @param {Mission | null | undefined} [mission]
|
|
165
|
+
* @param {TowerConfig} [config]
|
|
166
|
+
* @returns {'shared' | 'detached'}
|
|
167
|
+
*/
|
|
168
|
+
export function resolveProvisioning(mission, config = /** @type {TowerConfig} */ ({})) {
|
|
169
|
+
const recorded = mission && typeof mission.sandbox === 'string' ? mission.sandbox.trim() : ''
|
|
170
|
+
if (recorded.length > 0) return 'detached'
|
|
171
|
+
return config.survey?.sandbox === 'detached' ? 'detached' : 'shared'
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The preset that executes a survey in the resolved placement. Shared surveys
|
|
176
|
+
* stay strictly read-only; a detached sandbox gets a preset that may write and
|
|
177
|
+
* run commands inside the disposable checkout.
|
|
178
|
+
* @param {Mission | null | undefined} [mission]
|
|
179
|
+
* @param {TowerConfig} [config]
|
|
180
|
+
* @returns {'tower-survey' | 'tower-survey-sandbox'}
|
|
181
|
+
*/
|
|
182
|
+
export function resolvePreset(mission, config = /** @type {TowerConfig} */ ({})) {
|
|
183
|
+
return resolveProvisioning(mission, config) === 'detached' ? 'tower-survey-sandbox' : 'tower-survey'
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Shared-checkout completion invariants (the default placement). */
|
|
187
|
+
const SHARED_COMPLETION = Object.freeze({
|
|
188
|
+
requiresWorktree: false,
|
|
189
|
+
requiresSandbox: false,
|
|
190
|
+
requiresCommits: false,
|
|
191
|
+
requiresClean: false,
|
|
192
|
+
requiresHeadMatch: true,
|
|
193
|
+
requiresTrackedStatus: true,
|
|
194
|
+
check: /** @type {(ctx: any) => { commits: number }} */ (checkCompletion),
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
/** Detached-sandbox completion invariants. */
|
|
198
|
+
const SANDBOX_COMPLETION = Object.freeze({
|
|
199
|
+
requiresWorktree: false,
|
|
200
|
+
requiresSandbox: true,
|
|
201
|
+
requiresCommits: true,
|
|
202
|
+
requiresClean: false,
|
|
203
|
+
requiresHeadMatch: true,
|
|
204
|
+
requiresTrackedStatus: false,
|
|
205
|
+
check: /** @type {(ctx: any) => { commits: number }} */ (checkSandboxCompletion),
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* The completion invariants for the checkout the mission was provisioned in.
|
|
210
|
+
* Placement is read from the recorded `mission.sandbox`, never from current
|
|
211
|
+
* config: a detached sandbox still enforces its HEAD/zero-commit invariants and
|
|
212
|
+
* is removed after a config flip to `none`, and a survey that never got a
|
|
213
|
+
* sandbox keeps the shared invariants even if config now asks for `detached`.
|
|
214
|
+
* The config parameter stays in the shape contract for symmetry with other
|
|
215
|
+
* hooks, but is intentionally not consulted here.
|
|
216
|
+
* @param {Mission} mission
|
|
217
|
+
* @param {TowerConfig} [_config]
|
|
218
|
+
* @returns {typeof SHARED_COMPLETION | typeof SANDBOX_COMPLETION}
|
|
219
|
+
*/
|
|
220
|
+
export function resolveCompletion(mission, _config = /** @type {TowerConfig} */ ({})) {
|
|
221
|
+
return resolveProvisioning(mission) === 'detached' ? SANDBOX_COMPLETION : SHARED_COMPLETION
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Completion blockers for a survey. A survey is never merged, so the build-only
|
|
226
|
+
* "closed mission owns a branch" blocker does not exist for it.
|
|
227
|
+
* @param {Mission} mission
|
|
228
|
+
* @param {TowerConfig} [config]
|
|
229
|
+
* @returns {Blocker[]}
|
|
230
|
+
*/
|
|
231
|
+
export function completionBlockers(mission, config = /** @type {TowerConfig} */ ({})) {
|
|
232
|
+
/** @type {Blocker[]} */
|
|
233
|
+
const blockers = []
|
|
234
|
+
if (mission.status !== 'completed') {
|
|
235
|
+
blockers.push({
|
|
236
|
+
code: 'mission-not-completed',
|
|
237
|
+
detail:
|
|
238
|
+
mission.status === 'blocked'
|
|
239
|
+
? `mission ${mission.id} is blocked: ${(mission.blockers ?? []).join('; ')}; clear the blockers to resume it`
|
|
240
|
+
: `mission ${mission.id} is ${mission.status}; completion is required`,
|
|
241
|
+
})
|
|
242
|
+
}
|
|
243
|
+
const open = (mission.tasks ?? []).filter((task) => task.status === 'open')
|
|
244
|
+
if (open.length > 0) {
|
|
245
|
+
blockers.push({ code: 'tasks-open', detail: `${open.length} open task(s): ${open.map((task) => task.text).join('; ')}` })
|
|
246
|
+
}
|
|
247
|
+
if ((mission.blockers ?? []).length > 0) {
|
|
248
|
+
blockers.push({ code: 'blockers-present', detail: (mission.blockers ?? []).join('; ') })
|
|
249
|
+
}
|
|
250
|
+
if (config.gates?.requireChecks && !hasCheckEvidence(EVIDENCE, mission)) {
|
|
251
|
+
blockers.push({
|
|
252
|
+
code: 'no-check-evidence',
|
|
253
|
+
detail: `mission ${mission.id} has no completion checks; gates.requireChecks is enabled`,
|
|
254
|
+
})
|
|
255
|
+
}
|
|
256
|
+
return blockers
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* A completed survey satisfies its dependents; its read commit is part of the
|
|
261
|
+
* evidence, but the shape contract only needs the terminal status.
|
|
262
|
+
* @param {Pick<Mission, 'status'>} dep
|
|
263
|
+
* @returns {boolean}
|
|
264
|
+
*/
|
|
265
|
+
export function dependencySatisfied(dep) {
|
|
266
|
+
return dep.status === 'completed' || dep.status === 'abandoned'
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Worker spawn constraints: a survey worker runs in the shared checkout (or,
|
|
271
|
+
* with `survey.sandbox: 'detached'`, in a disposable detached worktree) and
|
|
272
|
+
* never provisions a branch. The concrete placement/preset is resolved by
|
|
273
|
+
* `resolveProvisioning`/`resolvePreset`.
|
|
274
|
+
* @type {{ provisioning: 'shared', missionStatuses: readonly string[], worktree: false, defaultPreset: string, countsTowardWorkerBudget: boolean, model: string }}
|
|
275
|
+
*/
|
|
276
|
+
export const spawn = Object.freeze({
|
|
277
|
+
provisioning: /** @type {const} */ ('shared'),
|
|
278
|
+
missionStatuses: Object.freeze(['planned', 'active', 'blocked', 'paused']),
|
|
279
|
+
worktree: /** @type {const} */ (false),
|
|
280
|
+
defaultPreset: 'tower-survey',
|
|
281
|
+
countsTowardWorkerBudget: false,
|
|
282
|
+
model: 'survey',
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Mission briefing lines that are specific to the survey shape. A detached
|
|
287
|
+
* sandbox is named explicitly so the worker never edits the main checkout and
|
|
288
|
+
* knows the checkout is disposable but the recorded commit is pinned.
|
|
289
|
+
* @param {Mission} mission
|
|
290
|
+
* @param {{ repoRoot: string, worktree?: unknown }} context
|
|
291
|
+
* @returns {string[]}
|
|
292
|
+
*/
|
|
293
|
+
export function briefing(mission, context) {
|
|
294
|
+
const short = String(mission.readCommit ?? '').slice(0, 12)
|
|
295
|
+
const fingerprint = (mission.readDirty ?? []).length
|
|
296
|
+
if (mission.sandbox) {
|
|
297
|
+
return [
|
|
298
|
+
`Read and work in the disposable detached sandbox: ${mission.sandbox} (no branch, no commit of your own; the sandbox is removed when the mission completes)`,
|
|
299
|
+
`The sandbox HEAD is pinned to ${short}. Write and shell side effects stay inside the sandbox; completion requires HEAD to still be ${short} and zero new commits.`,
|
|
300
|
+
`Pass workdir="${mission.sandbox}" on every shell command; never modify the main checkout ${context.repoRoot}.`,
|
|
301
|
+
]
|
|
302
|
+
}
|
|
303
|
+
return [
|
|
304
|
+
`Read only: commit ${short} in the shared checkout ${context.repoRoot} (no branch, no worktree, no commits)`,
|
|
305
|
+
`Completion invariants: HEAD must still be ${short} and the tracked-status set captured at plan time (${fingerprint} path(s)) must be unchanged. Complete with zero commits.`,
|
|
306
|
+
]
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Projection of the survey-specific mission fields for summaries/board rows.
|
|
311
|
+
* @param {Mission} mission
|
|
312
|
+
* @returns {Record<string, any>}
|
|
313
|
+
*/
|
|
314
|
+
export function summary(mission) {
|
|
315
|
+
return {
|
|
316
|
+
worktree: null,
|
|
317
|
+
sandbox: mission.sandbox ?? null,
|
|
318
|
+
readCommit: mission.readCommit ?? null,
|
|
319
|
+
readDirty: [...(mission.readDirty ?? [])],
|
|
320
|
+
mergedCommit: null,
|
|
321
|
+
mergedAt: null,
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* One-line human description of where the mission reads, used by reports.
|
|
327
|
+
* @param {Mission} mission
|
|
328
|
+
* @returns {string | null}
|
|
329
|
+
*/
|
|
330
|
+
export function describe(mission) {
|
|
331
|
+
return mission.readCommit ? `read at ${String(mission.readCommit).slice(0, 12)}` : null
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* v3 -> v4 migration hook (#148). A legacy survey reads the commit its
|
|
336
|
+
* branch-shaped worktree was based on, keeps its completion report, and loses
|
|
337
|
+
* the worktree/branch/merge projection. A legacy survey that was merged becomes
|
|
338
|
+
* completed: a survey is never merged in v4.
|
|
339
|
+
* @param {Record<string, any>} mission
|
|
340
|
+
* @returns {boolean}
|
|
341
|
+
*/
|
|
342
|
+
export function isLegacy(mission) {
|
|
343
|
+
return mission.kind === 'survey'
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* The commit a legacy (v3, branch-shaped) survey recorded, in fallback order:
|
|
348
|
+
* the v4 field, the worktree it was based on, then the mission-level base
|
|
349
|
+
* commit. A survey planned and never provisioned (or whose spawn failed before
|
|
350
|
+
* provisioning) has none of the three.
|
|
351
|
+
* @param {Record<string, any>} mission
|
|
352
|
+
* @returns {string | null}
|
|
353
|
+
*/
|
|
354
|
+
export function legacyReadCommit(mission) {
|
|
355
|
+
const recorded = mission.readCommit ?? mission.worktree?.baseCommit ?? mission.baseCommit
|
|
356
|
+
return typeof recorded === 'string' && recorded.length > 0 ? recorded : null
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* True when a legacy survey has no base of its own: the state loader must
|
|
361
|
+
* resolve one from the repository before migration can anchor the survey.
|
|
362
|
+
* @param {Record<string, any>} mission
|
|
363
|
+
* @returns {boolean}
|
|
364
|
+
*/
|
|
365
|
+
export function needsLegacyRead(mission) {
|
|
366
|
+
return legacyReadCommit(mission) === null
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* @param {Record<string, any>} mission
|
|
371
|
+
* @param {{ legacySurveyRead?: { readCommit?: unknown, readDirty?: unknown } | null }} [options]
|
|
372
|
+
* @returns {Record<string, any>}
|
|
373
|
+
*/
|
|
374
|
+
export function migrateLegacy(mission, options = {}) {
|
|
375
|
+
const recorded = legacyReadCommit(mission)
|
|
376
|
+
// The loader resolves the state base once for the whole document; only a
|
|
377
|
+
// survey that recorded no base of its own may consume that resolution. A
|
|
378
|
+
// recorded commit always wins, so re-running the step is a fixed point.
|
|
379
|
+
const resolved = recorded === null ? options.legacySurveyRead : null
|
|
380
|
+
const readCommit = recorded ?? resolved?.readCommit
|
|
381
|
+
if (typeof readCommit !== 'string' || readCommit.length === 0) {
|
|
382
|
+
throw new Error(
|
|
383
|
+
`survey ${String(mission.id)} has no recorded base commit to read; the state loader must resolve the base ref for a planned-but-never-provisioned survey`,
|
|
384
|
+
)
|
|
385
|
+
}
|
|
386
|
+
const readDirty =
|
|
387
|
+
recorded === null
|
|
388
|
+
? Array.isArray(resolved?.readDirty)
|
|
389
|
+
? resolved.readDirty.filter((entry) => typeof entry === 'string')
|
|
390
|
+
: []
|
|
391
|
+
: Array.isArray(mission.readDirty)
|
|
392
|
+
? [...mission.readDirty]
|
|
393
|
+
: []
|
|
394
|
+
const next = /** @type {Record<string, any>} */ ({
|
|
395
|
+
...mission,
|
|
396
|
+
kind: 'survey',
|
|
397
|
+
scope: [],
|
|
398
|
+
readCommit,
|
|
399
|
+
readDirty,
|
|
400
|
+
})
|
|
401
|
+
if (next.status === 'merged') next.status = 'completed'
|
|
402
|
+
delete next.worktree
|
|
403
|
+
delete next.worktreeRemovedAt
|
|
404
|
+
delete next.sandbox
|
|
405
|
+
delete next.sandboxCleanupError
|
|
406
|
+
delete next.sandboxRemovedAt
|
|
407
|
+
delete next.branch
|
|
408
|
+
delete next.baseCommit
|
|
409
|
+
delete next.mergedCommit
|
|
410
|
+
delete next.mergedAt
|
|
411
|
+
return next
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export const surveyShape = Object.freeze({
|
|
415
|
+
kind: /** @type {const} */ ('survey'),
|
|
416
|
+
statuses: SURVEY_STATUSES,
|
|
417
|
+
transitions: SURVEY_TRANSITIONS,
|
|
418
|
+
isTerminal,
|
|
419
|
+
canTransition,
|
|
420
|
+
isLegacy,
|
|
421
|
+
migrateLegacy,
|
|
422
|
+
needsLegacyRead,
|
|
423
|
+
canMerge: false,
|
|
424
|
+
canRebase: false,
|
|
425
|
+
provisioning: /** @type {const} */ ('shared'),
|
|
426
|
+
reach: REACH,
|
|
427
|
+
// Document dimensions this shape owns; the state validator reads these.
|
|
428
|
+
fields: Object.freeze({ worktree: false, readCommit: true, merge: false, sandbox: true }),
|
|
429
|
+
// A survey needs its optional component row; a build does not.
|
|
430
|
+
requiredComponent: 'tower-survey',
|
|
431
|
+
// Only a survey records `result` evidence on completion.
|
|
432
|
+
acceptsResult: true,
|
|
433
|
+
evidence: EVIDENCE,
|
|
434
|
+
planFields,
|
|
435
|
+
resolveProvisioning,
|
|
436
|
+
resolvePreset,
|
|
437
|
+
resolveCompletion,
|
|
438
|
+
completion: SHARED_COMPLETION,
|
|
439
|
+
completionBlockers,
|
|
440
|
+
dependencySatisfied,
|
|
441
|
+
spawn,
|
|
442
|
+
briefing,
|
|
443
|
+
summary,
|
|
444
|
+
describe,
|
|
445
|
+
})
|
package/domain/state/archive.js
CHANGED
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
// writing the files, and the lock that makes them atomic with state.json, live
|
|
12
12
|
// in `adapters/archive.js` and `app/archive.js`.
|
|
13
13
|
// @ts-check
|
|
14
|
-
import {
|
|
14
|
+
import { normalizeResult } from '../missions/completion.js'
|
|
15
|
+
import { shapeFor } from '../shapes/index.js'
|
|
15
16
|
|
|
16
17
|
/** @typedef {import('../../types/index.js').Finding} Finding */
|
|
17
18
|
/** @typedef {import('../../types/index.js').Mission} Mission */
|
|
@@ -63,7 +64,8 @@ export function terminalTimestamp(mission) {
|
|
|
63
64
|
* Select the terminal missions the policy wants moved out of state.json.
|
|
64
65
|
*
|
|
65
66
|
* Eligibility:
|
|
66
|
-
* - status
|
|
67
|
+
* - the mission's shape reports its status as terminal (`merged`/`abandoned`
|
|
68
|
+
* for a build, `completed`/`abandoned` for a survey);
|
|
67
69
|
* - the worktree is gone (never provisioned, or removed by teardown);
|
|
68
70
|
* - the caller did not protect it (open batch members, live agents).
|
|
69
71
|
*
|
|
@@ -85,7 +87,7 @@ export function selectForArchive(missions, policy = {}, options = {}) {
|
|
|
85
87
|
const afterDays = Number.isFinite(policy?.afterDays) ? /** @type {number} */ (policy.afterDays) : 30
|
|
86
88
|
const maxMissions = Number.isFinite(policy?.maxMissions) ? /** @type {number} */ (policy.maxMissions) : 100
|
|
87
89
|
const eligible = missions.filter(
|
|
88
|
-
(mission) => isTerminal(mission.status) && !hasLiveWorktree(mission) && !protectedIds.has(mission.id),
|
|
90
|
+
(mission) => shapeFor(mission.kind).isTerminal(mission.status) && !hasLiveWorktree(mission) && !protectedIds.has(mission.id),
|
|
89
91
|
)
|
|
90
92
|
if (eligible.length === 0) return []
|
|
91
93
|
if (options.force === true) return eligible
|
|
@@ -140,10 +142,30 @@ export function archiveYearFor(now) {
|
|
|
140
142
|
return new Date(now).getUTCFullYear()
|
|
141
143
|
}
|
|
142
144
|
|
|
145
|
+
/**
|
|
146
|
+
* Keep the bounded survey evidence on the index entry. The value was already
|
|
147
|
+
* normalized when the survey completed, but re-normalizing is cheap and makes
|
|
148
|
+
* a hand-edited or legacy document safe to archive.
|
|
149
|
+
* @param {Mission} mission
|
|
150
|
+
* @returns {Record<string, any> | null}
|
|
151
|
+
*/
|
|
152
|
+
function boundedResult(mission) {
|
|
153
|
+
const record = /** @type {Record<string, any>} */ (mission)
|
|
154
|
+
if (record.result === undefined || record.result === null) return null
|
|
155
|
+
try {
|
|
156
|
+
return normalizeResult(record.result)
|
|
157
|
+
} catch {
|
|
158
|
+
return null
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
143
162
|
/**
|
|
144
163
|
* The compact entry written to `archive/index.json` (and referenced by
|
|
145
164
|
* `tower_status`). It carries enough fields to keep ordering, dependency
|
|
146
|
-
* lookups and mission-id allocation working without loading a
|
|
165
|
+
* lookups, freshness checks and mission-id allocation working without loading a
|
|
166
|
+
* year file: `readCommit` and the bounded survey `result` stay on the index so
|
|
167
|
+
* an archived survey dependency keeps its evidence and cannot fail the
|
|
168
|
+
* `gates.requireFreshSurvey` check open.
|
|
147
169
|
* @param {Mission} mission
|
|
148
170
|
* @param {ArchiveMeta} meta
|
|
149
171
|
* @returns {Record<string, any>}
|
|
@@ -158,6 +180,8 @@ export function archiveIndexEntry(mission, meta) {
|
|
|
158
180
|
kind: record.kind ?? 'build',
|
|
159
181
|
scope: Array.isArray(record.scope) ? record.scope : [],
|
|
160
182
|
deps: Array.isArray(record.deps) ? record.deps : [],
|
|
183
|
+
readCommit: record.readCommit ?? null,
|
|
184
|
+
result: boundedResult(mission),
|
|
161
185
|
owner: record.owner ?? null,
|
|
162
186
|
branch,
|
|
163
187
|
mergedCommit: record.mergedCommit ?? null,
|
|
@@ -12,8 +12,12 @@
|
|
|
12
12
|
// @ts-check
|
|
13
13
|
import { TowerToolError } from '../errors.js'
|
|
14
14
|
import { SCHEMA_VERSION } from '../mission.js'
|
|
15
|
+
import { SHAPE_KINDS, shapeFor } from '../shapes/index.js'
|
|
16
|
+
import { normalizeBatchStatus } from '../batch.js'
|
|
15
17
|
|
|
16
|
-
/** @typedef {{
|
|
18
|
+
/** @typedef {{ readCommit: string, readDirty: string[] }} LegacySurveyRead */
|
|
19
|
+
/** @typedef {{ legacySurveyRead?: LegacySurveyRead | null }} MigrationContext */
|
|
20
|
+
/** @typedef {{ from: number, to: number, migrate: (state: Record<string, any>, context?: MigrationContext) => Record<string, any> }} StateMigration */
|
|
17
21
|
|
|
18
22
|
/**
|
|
19
23
|
* @param {unknown} value
|
|
@@ -57,10 +61,109 @@ export function migrateV2ToV3(state) {
|
|
|
57
61
|
return { ...state, caseInsensitive: null }
|
|
58
62
|
}
|
|
59
63
|
|
|
64
|
+
/**
|
|
65
|
+
* v3 -> v4. Mission state becomes per-shape (#149). Every mission is stamped with
|
|
66
|
+
* its kind; a legacy branch-shaped survey keeps the commit it read (the
|
|
67
|
+
* worktree's base commit) and drops the worktree/branch/merge fields it no
|
|
68
|
+
* longer owns. Build missions keep their document unchanged.
|
|
69
|
+
*
|
|
70
|
+
* The step is pure and one-way. Re-running it on a v4 document is a fixed point:
|
|
71
|
+
* every mission already has a kind, so the same shape migration rewrites it to
|
|
72
|
+
* the same values.
|
|
73
|
+
*
|
|
74
|
+
* A legacy survey planned but never provisioned records no base commit at all
|
|
75
|
+
* (2.6.0 wrote neither `baseCommit` nor a worktree at plan time). The loader
|
|
76
|
+
* resolves the state base once and passes it as `context.legacySurveyRead`; the
|
|
77
|
+
* survey shape consumes it only when it has no recorded base of its own.
|
|
78
|
+
* @param {Record<string, any>} state
|
|
79
|
+
* @param {MigrationContext} [context]
|
|
80
|
+
* @returns {Record<string, any>}
|
|
81
|
+
*/
|
|
82
|
+
export function migrateV3ToV4(state, context = {}) {
|
|
83
|
+
const next = { ...state }
|
|
84
|
+
next.missions = (Array.isArray(next.missions) ? next.missions : []).map((mission) => {
|
|
85
|
+
if (!isPlainObject(mission)) return mission
|
|
86
|
+
const shape = SHAPE_KINDS.map((kind) => shapeFor(kind)).find((candidate) => candidate.isLegacy(mission))
|
|
87
|
+
return shape ? shape.migrateLegacy(mission, context) : mission
|
|
88
|
+
})
|
|
89
|
+
return next
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* v4 -> v5. Two contracts that used to overlap are made exact (#149 W3):
|
|
94
|
+
*
|
|
95
|
+
* - `blocked` is derived from open blockers, not an independent stop state. A
|
|
96
|
+
* non-terminal mission that says `blocked` without a blocker gets one explicit
|
|
97
|
+
* legacy blocker (it stays blocked; `clear_blockers` resumes it); a
|
|
98
|
+
* non-terminal mission that carries blockers under any other status becomes
|
|
99
|
+
* `blocked` because a blocker wins over a pause or a fresh status. Terminal
|
|
100
|
+
* missions keep their blockers as history and are never reopened.
|
|
101
|
+
* - A batch's status vocabulary is normalized to `open|closed|abandoned`;
|
|
102
|
+
* pre-v5 documents used `created|active|pending` as synonyms for `open`.
|
|
103
|
+
*
|
|
104
|
+
* The step is pure and one-way. Re-running it on a v5 document is a fixed point.
|
|
105
|
+
* @param {Record<string, any>} state
|
|
106
|
+
* @returns {Record<string, any>}
|
|
107
|
+
*/
|
|
108
|
+
export function migrateV4ToV5(state) {
|
|
109
|
+
const next = { ...state }
|
|
110
|
+
next.missions = (Array.isArray(next.missions) ? next.missions : []).map((mission) => {
|
|
111
|
+
if (!isPlainObject(mission)) return mission
|
|
112
|
+
const shape = shapeFor(mission.kind)
|
|
113
|
+
const blockers = Array.isArray(mission.blockers) ? [...mission.blockers] : []
|
|
114
|
+
if (shape.isTerminal(mission.status)) return { ...mission, blockers }
|
|
115
|
+
if (mission.status === 'blocked' && blockers.length === 0) {
|
|
116
|
+
return {
|
|
117
|
+
...mission,
|
|
118
|
+
blockers: ['(legacy blocked state: no blocker was recorded; clear_blockers resumes the mission)'],
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (mission.status !== 'blocked' && blockers.length > 0) {
|
|
122
|
+
const migrated = { ...mission, status: 'blocked', blockers }
|
|
123
|
+
// A blocker supersedes a pause: the pause cause no longer describes why
|
|
124
|
+
// the mission is stopped.
|
|
125
|
+
delete migrated.pauseCause
|
|
126
|
+
return migrated
|
|
127
|
+
}
|
|
128
|
+
return mission
|
|
129
|
+
})
|
|
130
|
+
if (Array.isArray(next.batches)) {
|
|
131
|
+
next.batches = next.batches.map((batch) => {
|
|
132
|
+
if (!isPlainObject(batch)) return batch
|
|
133
|
+
const status = normalizeBatchStatus(batch.status)
|
|
134
|
+
return status === batch.status ? batch : { ...batch, status }
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
return next
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* True when a pre-v4 document contains at least one legacy mission whose shape
|
|
142
|
+
* cannot anchor its read from the document alone (a survey planned but never
|
|
143
|
+
* provisioned). The load path must resolve the repository base before migration
|
|
144
|
+
* runs; a document that recorded its own commit needs no resolver, and a
|
|
145
|
+
* current-version document is never re-migrated.
|
|
146
|
+
* @param {unknown} state
|
|
147
|
+
* @returns {boolean}
|
|
148
|
+
*/export function legacySurveyNeedsRead(state) {
|
|
149
|
+
if (!isPlainObject(state)) return false
|
|
150
|
+
const document = /** @type {Record<string, any>} */ (state)
|
|
151
|
+
const version = document.schemaVersion
|
|
152
|
+
if (!Number.isInteger(version) || version < 1 || version >= SCHEMA_VERSION) return false
|
|
153
|
+
const missions = Array.isArray(document.missions) ? document.missions : []
|
|
154
|
+
return missions.some((mission) => {
|
|
155
|
+
if (!isPlainObject(mission)) return false
|
|
156
|
+
const shape = SHAPE_KINDS.map((kind) => shapeFor(kind)).find((candidate) => candidate.isLegacy(mission))
|
|
157
|
+
return typeof shape?.needsLegacyRead === 'function' && shape.needsLegacyRead(mission)
|
|
158
|
+
})
|
|
159
|
+
}
|
|
160
|
+
|
|
60
161
|
/** Ordered registry: entry 0 moves 1 -> 2, entry 1 moves 2 -> 3, and so on. */
|
|
61
162
|
export const STATE_MIGRATIONS = Object.freeze([
|
|
62
163
|
Object.freeze({ from: 1, to: 2, migrate: migrateV1ToV2 }),
|
|
63
164
|
Object.freeze({ from: 2, to: 3, migrate: migrateV2ToV3 }),
|
|
165
|
+
Object.freeze({ from: 3, to: 4, migrate: migrateV3ToV4 }),
|
|
166
|
+
Object.freeze({ from: 4, to: 5, migrate: migrateV4ToV5 }),
|
|
64
167
|
])
|
|
65
168
|
|
|
66
169
|
/**
|
|
@@ -91,16 +194,20 @@ export function migrationChain(from, to = SCHEMA_VERSION, migrations = STATE_MIG
|
|
|
91
194
|
|
|
92
195
|
/**
|
|
93
196
|
* Apply the registry to a parsed document. Pure: the input object is never
|
|
94
|
-
* touched and the returned document has the target `schemaVersion`.
|
|
197
|
+
* touched and the returned document has the target `schemaVersion`. The same
|
|
198
|
+
* `MigrationContext` object reaches every step, so a step may consume a value
|
|
199
|
+
* the loader resolved once (the legacy survey read).
|
|
95
200
|
*
|
|
96
201
|
* @param {Record<string, any>} state
|
|
97
|
-
* @param {{ statePath?: string, to?: number, migrations?: readonly StateMigration[] }} [options]
|
|
202
|
+
* @param {{ statePath?: string, to?: number, migrations?: readonly StateMigration[] } & MigrationContext} [options]
|
|
98
203
|
* @returns {{ state: Record<string, any>, from: number, to: number, steps: Array<{ from: number, to: number }> }}
|
|
99
204
|
*/
|
|
100
205
|
export function migrateState(state, options = {}) {
|
|
101
206
|
const where = options.statePath ? ` at ${options.statePath}` : ''
|
|
102
207
|
const to = options.to ?? SCHEMA_VERSION
|
|
103
208
|
const migrations = options.migrations ?? STATE_MIGRATIONS
|
|
209
|
+
/** @type {MigrationContext} */
|
|
210
|
+
const context = { legacySurveyRead: options.legacySurveyRead }
|
|
104
211
|
const from = state?.schemaVersion
|
|
105
212
|
const chain = migrationChain(from, to, migrations)
|
|
106
213
|
if (!chain || chain.length === 0) {
|
|
@@ -120,7 +227,7 @@ export function migrateState(state, options = {}) {
|
|
|
120
227
|
/** @type {Record<string, any>} */
|
|
121
228
|
let next
|
|
122
229
|
try {
|
|
123
|
-
next = step.migrate(current)
|
|
230
|
+
next = step.migrate(current, context)
|
|
124
231
|
} catch (error) {
|
|
125
232
|
throw new TowerToolError(
|
|
126
233
|
'state-migration-failed',
|