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
package/app/provision.js
CHANGED
|
@@ -1,20 +1,30 @@
|
|
|
1
1
|
// `tower_provision` use case, split into three phases (#51):
|
|
2
2
|
//
|
|
3
3
|
// prepare — mission/base commit/worktree identity, all lock-free.
|
|
4
|
-
// act — revalidate the prepared revision, then
|
|
5
|
-
//
|
|
4
|
+
// act — revalidate the prepared revision, then the git mutation (and its
|
|
5
|
+
// supporting exclude/canonical reads) under the git mutex.
|
|
6
6
|
// commit — one short state lock revalidates the revision and that the
|
|
7
7
|
// mission still has no worktree, then records it. A rejected
|
|
8
|
-
// commit rolls the created worktree/branch
|
|
8
|
+
// commit rolls the created worktree/branch (or survey sandbox)
|
|
9
|
+
// back outside the lock.
|
|
10
|
+
//
|
|
11
|
+
// #148 W2: a survey resolves its placement through its shape. `shared` keeps the
|
|
12
|
+
// old no-provisioning path; `detached` creates a disposable detached worktree at
|
|
13
|
+
// the recorded readCommit (no branch, no baseCommit, never merged).
|
|
9
14
|
import path from 'node:path'
|
|
10
15
|
import { validateState } from '../domain/state.js'
|
|
11
16
|
import { TowerToolError } from '../domain/errors.js'
|
|
17
|
+
import { provisioningFor } from '../domain/shapes/index.js'
|
|
12
18
|
import {
|
|
13
19
|
baseCommitChoice,
|
|
14
20
|
createdWorktreeResult,
|
|
21
|
+
detachedSandboxResult,
|
|
15
22
|
existingWorktreeResult,
|
|
16
23
|
hasMissionId,
|
|
17
24
|
provisionActivityText,
|
|
25
|
+
sandboxActivityText,
|
|
26
|
+
sandboxPath,
|
|
27
|
+
sharedProvisionResult,
|
|
18
28
|
worktreeBranch,
|
|
19
29
|
worktreePath,
|
|
20
30
|
worktreeRecord,
|
|
@@ -25,11 +35,76 @@ function fail(message, code = 'invalid-argument') {
|
|
|
25
35
|
throw new TowerToolError(code, message)
|
|
26
36
|
}
|
|
27
37
|
|
|
28
|
-
/**
|
|
38
|
+
/**
|
|
39
|
+
* Resolve the parent directory for mission checkouts and refuse an unsafe one.
|
|
40
|
+
* @param {any} tower
|
|
41
|
+
* @param {any} input
|
|
42
|
+
* @returns {string}
|
|
43
|
+
*/
|
|
44
|
+
export function resolveCheckoutRoot(tower, input) {
|
|
45
|
+
const root = worktreeRoot({
|
|
46
|
+
worktreeRoot: input.worktreeRoot,
|
|
47
|
+
configuredRoot: tower.config.worktrees.root,
|
|
48
|
+
repoRoot: tower.root,
|
|
49
|
+
commonDirHash: tower.deps.ids.shortHash(tower.commonDir),
|
|
50
|
+
})
|
|
51
|
+
if (!path.isAbsolute(root)) fail('worktreeRoot must be an absolute path')
|
|
52
|
+
tower.assertOutside(root)
|
|
53
|
+
return root
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* True when `mission.sandbox` is a worktree registered with git and still at the
|
|
58
|
+
* recorded readCommit. A recorded path that git no longer knows is treated as
|
|
59
|
+
* gone so a failed cleanup does not wedge a re-provision; a registered sandbox
|
|
60
|
+
* whose HEAD moved is a refusal (its contents are the worker's work, not ours
|
|
61
|
+
* to discard silently).
|
|
62
|
+
* @param {any} tower
|
|
63
|
+
* @param {import('../types/index.js').Mission} mission
|
|
64
|
+
* @returns {Promise<boolean>}
|
|
65
|
+
*/
|
|
66
|
+
async function sandboxIsCurrent(tower, mission) {
|
|
67
|
+
const sandbox = /** @type {any} */ (mission).sandbox
|
|
68
|
+
if (typeof sandbox !== 'string' || sandbox.length === 0) return false
|
|
69
|
+
const entries = await tower.deps.vcs.listWorktrees(tower.root)
|
|
70
|
+
const registered = entries.some((entry) => path.resolve(entry.path) === path.resolve(sandbox))
|
|
71
|
+
if (!registered) return false
|
|
72
|
+
const head = await tower.revParse(sandbox, 'HEAD')
|
|
73
|
+
if (head !== mission.readCommit) {
|
|
74
|
+
fail(
|
|
75
|
+
`detached sandbox ${sandbox} is at ${String(head).slice(0, 12)}, not the recorded readCommit ${String(mission.readCommit).slice(0, 12)}; inspect it or remove it before provisioning again`,
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
return true
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Phase 1 of `Tower.provision` (#51). An existing checkout returns here. */
|
|
29
82
|
async function prepareProvision(tower, input) {
|
|
30
83
|
const state = await tower.load()
|
|
31
84
|
const mission = state.missions.find((entry) => entry.id === input.missionId)
|
|
32
85
|
if (!mission) fail(`unknown mission "${input.missionId}"`, 'mission-not-found')
|
|
86
|
+
const provisioning = provisioningFor(mission.kind, tower.config, mission)
|
|
87
|
+
// A shared-checkout shape reads one commit in the main checkout: no branch,
|
|
88
|
+
// no directory, no recorded worktree (#148 D1/D2).
|
|
89
|
+
if (provisioning === 'shared') {
|
|
90
|
+
return { early: sharedProvisionResult(mission, tower.root) }
|
|
91
|
+
}
|
|
92
|
+
// #148 W2: a detached survey owns a disposable checkout, not a mission
|
|
93
|
+
// branch. The recorded readCommit is the only base it has.
|
|
94
|
+
if (provisioning === 'detached') {
|
|
95
|
+
const readCommit = mission.readCommit
|
|
96
|
+
if (typeof readCommit !== 'string' || readCommit.length === 0) {
|
|
97
|
+
fail('a detached survey needs the commit it reads (readCommit); re-plan the survey from a resolved base', 'invalid-argument')
|
|
98
|
+
}
|
|
99
|
+
if (await sandboxIsCurrent(tower, mission)) {
|
|
100
|
+
return { early: detachedSandboxResult(mission, /** @type {string} */ (mission.sandbox), false) }
|
|
101
|
+
}
|
|
102
|
+
const root = resolveCheckoutRoot(tower, input)
|
|
103
|
+
await tower.deps.store.ensureDir(root)
|
|
104
|
+
const wtPath = sandboxPath(root, mission.id)
|
|
105
|
+
if (await tower.deps.store.exists(wtPath)) fail(`sandbox path already exists: ${wtPath}`)
|
|
106
|
+
return { stateRevision: state.revision, missionId: mission.id, mode: 'detached', readCommit, wtPath }
|
|
107
|
+
}
|
|
33
108
|
if (mission.worktree) {
|
|
34
109
|
await tower.assertWorktree(mission.worktree)
|
|
35
110
|
return { early: existingWorktreeResult(mission, mission.worktree) }
|
|
@@ -47,14 +122,7 @@ async function prepareProvision(tower, input) {
|
|
|
47
122
|
})
|
|
48
123
|
const baseCommit = 'commit' in choice ? choice.commit : await tower.revParse(tower.root, state.base)
|
|
49
124
|
const branch = worktreeBranch(mission)
|
|
50
|
-
const root =
|
|
51
|
-
worktreeRoot: input.worktreeRoot,
|
|
52
|
-
configuredRoot: tower.config.worktrees.root,
|
|
53
|
-
repoRoot: tower.root,
|
|
54
|
-
commonDirHash: tower.deps.ids.shortHash(tower.commonDir),
|
|
55
|
-
})
|
|
56
|
-
if (!path.isAbsolute(root)) fail('worktreeRoot must be an absolute path')
|
|
57
|
-
tower.assertOutside(root)
|
|
125
|
+
const root = resolveCheckoutRoot(tower, input)
|
|
58
126
|
await tower.deps.store.ensureDir(root)
|
|
59
127
|
const wtPath = worktreePath(root, mission.id)
|
|
60
128
|
if (await tower.deps.store.exists(wtPath)) fail(`worktree path already exists: ${wtPath}`)
|
|
@@ -64,7 +132,7 @@ async function prepareProvision(tower, input) {
|
|
|
64
132
|
return { stateRevision: state.revision, missionId: mission.id, baseCommit, branch, wtPath }
|
|
65
133
|
}
|
|
66
134
|
|
|
67
|
-
/** Phase 2 of `Tower.provision` (#51): no state lock,
|
|
135
|
+
/** Phase 2 of `Tower.provision` (#51): no state lock, git work under the mutex. */
|
|
68
136
|
async function actProvision(tower, prepared) {
|
|
69
137
|
return tower.deps.store.withGitLock(tower.paths, async () => {
|
|
70
138
|
let fresh
|
|
@@ -78,6 +146,17 @@ async function actProvision(tower, prepared) {
|
|
|
78
146
|
}
|
|
79
147
|
const mission = fresh.missions.find((entry) => entry.id === prepared.missionId)
|
|
80
148
|
if (!mission) return { phase: 'missing' }
|
|
149
|
+
if (prepared.mode === 'detached') {
|
|
150
|
+
if (await sandboxIsCurrent(tower, mission)) {
|
|
151
|
+
return { phase: 'existing-sandbox', mission, sandbox: /** @type {any} */ (mission).sandbox }
|
|
152
|
+
}
|
|
153
|
+
if (await tower.deps.store.exists(prepared.wtPath)) fail(`sandbox path already exists: ${prepared.wtPath}`)
|
|
154
|
+
// `--detach` creates no branch: the sandbox is a read-only commit checkout
|
|
155
|
+
// that the agent may dirty, and it never participates in merge/stale-base.
|
|
156
|
+
await tower.git(tower.root, ['worktree', 'add', '--detach', prepared.wtPath, prepared.readCommit])
|
|
157
|
+
await tower.ensureExcluded(prepared.wtPath)
|
|
158
|
+
return { phase: 'created-sandbox', sandbox: prepared.wtPath }
|
|
159
|
+
}
|
|
81
160
|
if (mission.worktree) {
|
|
82
161
|
await tower.assertWorktree(mission.worktree)
|
|
83
162
|
return { phase: 'existing', mission, worktree: mission.worktree }
|
|
@@ -99,7 +178,7 @@ async function actProvision(tower, prepared) {
|
|
|
99
178
|
}, tower.gitMutexOptions({ phase: 'provision' }))
|
|
100
179
|
}
|
|
101
180
|
|
|
102
|
-
/** Phase 3 of `Tower.provision` (#51): record the
|
|
181
|
+
/** Phase 3 of `Tower.provision` (#51): record the checkout under a short lock. */
|
|
103
182
|
async function commitProvision(tower, prepared, acted) {
|
|
104
183
|
return tower.deps.store.withLock(tower.paths, async () => {
|
|
105
184
|
const state = await tower.load()
|
|
@@ -107,9 +186,30 @@ async function commitProvision(tower, prepared, acted) {
|
|
|
107
186
|
if (!mission) throw new TowerToolError('mission-not-found', `unknown mission "${prepared.missionId}"`)
|
|
108
187
|
// Revision revalidation: the plan was built from prepared.stateRevision.
|
|
109
188
|
if (state.revision !== prepared.stateRevision) return { phase: 'stale', revision: state.revision }
|
|
189
|
+
const now = Date.now()
|
|
190
|
+
if (prepared.mode === 'detached') {
|
|
191
|
+
// Never overwrite a sandbox another writer recorded while git ran.
|
|
192
|
+
if (mission.sandbox) return { phase: 'existing-sandbox', mission, sandbox: mission.sandbox }
|
|
193
|
+
mission.sandbox = acted.sandbox
|
|
194
|
+
mission.updatedAt = now
|
|
195
|
+
state.revision += 1
|
|
196
|
+
state.updatedAt = now
|
|
197
|
+
try {
|
|
198
|
+
validateState(state, tower.paths.state)
|
|
199
|
+
await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
|
|
200
|
+
} catch (error) {
|
|
201
|
+
return { phase: 'persist-error', error }
|
|
202
|
+
}
|
|
203
|
+
await tower.deps.store.appendJsonl(tower.paths.activity, {
|
|
204
|
+
at: now,
|
|
205
|
+
type: 'sandbox',
|
|
206
|
+
missionId: prepared.missionId,
|
|
207
|
+
text: sandboxActivityText(acted.sandbox, prepared.readCommit),
|
|
208
|
+
}).catch(() => {})
|
|
209
|
+
return { phase: 'committed-sandbox', mission, sandbox: acted.sandbox }
|
|
210
|
+
}
|
|
110
211
|
// Never overwrite a worktree another writer recorded while git ran.
|
|
111
212
|
if (mission.worktree) return { phase: 'existing', mission, worktree: mission.worktree }
|
|
112
|
-
const now = acted.worktree.createdAt
|
|
113
213
|
mission.worktree = acted.worktree
|
|
114
214
|
mission.baseCommit = prepared.baseCommit
|
|
115
215
|
mission.updatedAt = now
|
|
@@ -131,11 +231,13 @@ async function commitProvision(tower, prepared, acted) {
|
|
|
131
231
|
})
|
|
132
232
|
}
|
|
133
233
|
|
|
134
|
-
/** Undo a created
|
|
234
|
+
/** Undo a created checkout outside the state lock (git mutex only). */
|
|
135
235
|
async function rollbackProvision(tower, prepared) {
|
|
136
236
|
await tower.deps.store.withGitLock(tower.paths, async () => {
|
|
137
237
|
await tower.git(tower.root, ['worktree', 'remove', '--force', prepared.wtPath]).catch(() => {})
|
|
138
|
-
|
|
238
|
+
if (prepared.mode !== 'detached' && prepared.branch) {
|
|
239
|
+
await tower.git(tower.root, ['branch', '-D', prepared.branch]).catch(() => {})
|
|
240
|
+
}
|
|
139
241
|
}, tower.gitMutexOptions({ phase: 'provision' }))
|
|
140
242
|
}
|
|
141
243
|
|
|
@@ -150,6 +252,9 @@ export async function provision(tower, input = {}) {
|
|
|
150
252
|
|
|
151
253
|
const acted = await actProvision(tower, prepared)
|
|
152
254
|
if (acted.phase === 'existing') return existingWorktreeResult(acted.mission, acted.worktree)
|
|
255
|
+
if (acted.phase === 'existing-sandbox') {
|
|
256
|
+
return detachedSandboxResult(acted.mission, acted.sandbox, false)
|
|
257
|
+
}
|
|
153
258
|
if (acted.phase === 'missing') fail(`unknown mission "${prepared.missionId}"`, 'mission-not-found')
|
|
154
259
|
if (acted.phase === 'stale') {
|
|
155
260
|
throw new TowerToolError(
|
|
@@ -162,8 +267,8 @@ export async function provision(tower, input = {}) {
|
|
|
162
267
|
try {
|
|
163
268
|
committed = await commitProvision(tower, prepared, acted)
|
|
164
269
|
} catch (error) {
|
|
165
|
-
// The
|
|
166
|
-
// lock must not leave the mission wedged with an unrecorded
|
|
270
|
+
// The checkout already exists. A commit that cannot even take the state
|
|
271
|
+
// lock must not leave the mission wedged with an unrecorded checkout.
|
|
167
272
|
await rollbackProvision(tower, prepared)
|
|
168
273
|
throw error
|
|
169
274
|
}
|
|
@@ -174,18 +279,24 @@ export async function provision(tower, input = {}) {
|
|
|
174
279
|
`tower state changed while the worktree was being created (${prepared.stateRevision} -> ${committed.revision}); the new worktree was rolled back — retry`,
|
|
175
280
|
)
|
|
176
281
|
}
|
|
177
|
-
if (committed.phase === 'existing') {
|
|
178
|
-
// Another writer recorded a
|
|
282
|
+
if (committed.phase === 'existing' || committed.phase === 'existing-sandbox') {
|
|
283
|
+
// Another writer recorded a checkout for this mission while git ran; the
|
|
179
284
|
// one created here is orphaned, so remove it and report the recorded one.
|
|
180
285
|
await rollbackProvision(tower, prepared)
|
|
286
|
+
if (committed.phase === 'existing-sandbox') {
|
|
287
|
+
return detachedSandboxResult(committed.mission, committed.sandbox, false)
|
|
288
|
+
}
|
|
181
289
|
await tower.assertWorktree(committed.worktree)
|
|
182
290
|
return existingWorktreeResult(committed.mission, committed.worktree)
|
|
183
291
|
}
|
|
184
292
|
if (committed.phase === 'persist-error') {
|
|
185
293
|
// Preserve the single-phase rollback: a failed state write must not leave
|
|
186
|
-
// an untracked worktree/branch behind.
|
|
294
|
+
// an untracked worktree/branch (or survey sandbox) behind.
|
|
187
295
|
await rollbackProvision(tower, prepared)
|
|
188
296
|
throw committed.error
|
|
189
297
|
}
|
|
298
|
+
if (committed.phase === 'committed-sandbox') {
|
|
299
|
+
return detachedSandboxResult(committed.mission, committed.sandbox, true)
|
|
300
|
+
}
|
|
190
301
|
return createdWorktreeResult(committed.mission, committed.worktree)
|
|
191
302
|
}
|
package/app/read.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// Read-only dashboard/detail use cases.
|
|
2
2
|
import { TowerToolError } from '../domain/errors.js'
|
|
3
|
+
import { isReviewerRole } from '../domain/roles.js'
|
|
4
|
+
import { assertActionSupported } from '../domain/shapes/index.js'
|
|
3
5
|
import { diffView, missionDetailView, statusView } from '../domain/read/views.js'
|
|
4
6
|
import { archiveView, archivedSummary } from './archive.js'
|
|
5
7
|
|
|
@@ -7,6 +9,33 @@ function fail(message, code = 'invalid-argument') {
|
|
|
7
9
|
throw new TowerToolError(code, message)
|
|
8
10
|
}
|
|
9
11
|
|
|
12
|
+
/**
|
|
13
|
+
* #149 W3: a reviewer diffs the commit recorded on its roster row — the same
|
|
14
|
+
* commit its verdict is stamped with — never the worker's live worktree HEAD.
|
|
15
|
+
* The checkout is only trusted while its HEAD still equals that commit.
|
|
16
|
+
* @returns {Promise<{ commit: string, cwd: string } | null>}
|
|
17
|
+
*/
|
|
18
|
+
async function reviewerBinding(tower, state, mission, actor) {
|
|
19
|
+
if (!actor || !isReviewerRole(actor.role) || !actor.agentId) return null
|
|
20
|
+
const row = (state.roster ?? []).find((entry) => entry.agentId === actor.agentId)
|
|
21
|
+
if (!row || typeof row.reviewCommit !== 'string' || row.reviewCommit.length === 0) {
|
|
22
|
+
fail(
|
|
23
|
+
`reviewer ${row?.name ?? actor.name} has no recorded review commit; spawn reviewers through tower_spawn so a verdict is bound to a commit`,
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
if (typeof row.reviewCheckout === 'string' && row.reviewCheckout.length > 0) {
|
|
27
|
+
const head = await tower.revParse(row.reviewCheckout, 'HEAD').catch(() => null)
|
|
28
|
+
if (head !== row.reviewCommit) {
|
|
29
|
+
const where = head ? head.slice(0, 12) : 'a missing checkout'
|
|
30
|
+
fail(
|
|
31
|
+
`reviewer ${row.name}'s checkout is at ${where} instead of the recorded review commit ${row.reviewCommit.slice(0, 12)}; spawn a fresh reviewer`,
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
return { commit: row.reviewCommit, cwd: row.reviewCheckout }
|
|
35
|
+
}
|
|
36
|
+
return { commit: row.reviewCommit, cwd: mission.worktree.path }
|
|
37
|
+
}
|
|
38
|
+
|
|
10
39
|
/**
|
|
11
40
|
* Full detail for one mission. A mission that was archived is reconstructed
|
|
12
41
|
* from its year file, including the reviews, findings and activity that left
|
|
@@ -91,22 +120,26 @@ export async function diff(tower, input = {}) {
|
|
|
91
120
|
const state = await tower.load()
|
|
92
121
|
const mission = state.missions.find((entry) => entry.id === input.missionId)
|
|
93
122
|
if (!mission) fail(`unknown mission "${input.missionId}"`, 'mission-not-found')
|
|
123
|
+
assertActionSupported(mission, 'diff')
|
|
94
124
|
if (!mission.worktree) fail(`mission ${mission.id} has no worktree to diff`)
|
|
95
125
|
await tower.assertWorktree(mission.worktree)
|
|
126
|
+
const binding = await reviewerBinding(tower, state, mission, input.actor)
|
|
127
|
+
const cwd = binding?.cwd ?? mission.worktree.path
|
|
96
128
|
const baseTip = await tower.revParse(tower.root, state.base)
|
|
97
|
-
const branchTip = await tower.
|
|
98
|
-
const mergeBase = (await tower.git(
|
|
99
|
-
const files = (await tower.git(
|
|
129
|
+
const branchTip = binding?.commit ?? (await tower.branchTip(mission))
|
|
130
|
+
const mergeBase = (await tower.git(cwd, ['merge-base', baseTip, branchTip])).trim()
|
|
131
|
+
const files = (await tower.git(cwd, ['diff', '--name-status', `${mergeBase}..${branchTip}`]))
|
|
100
132
|
.split('\n')
|
|
101
133
|
.filter(Boolean)
|
|
102
|
-
const commits = (await tower.git(
|
|
134
|
+
const commits = (await tower.git(cwd, ['log', '--oneline', `${mergeBase}..${branchTip}`]))
|
|
103
135
|
.split('\n')
|
|
104
136
|
.filter(Boolean)
|
|
105
|
-
const fullPatch = await tower.git(
|
|
137
|
+
const fullPatch = await tower.git(cwd, ['diff', `${mergeBase}..${branchTip}`])
|
|
106
138
|
return diffView({
|
|
107
139
|
mission,
|
|
108
140
|
baseTip,
|
|
109
141
|
branchTip,
|
|
142
|
+
reviewedCommit: binding?.commit ?? null,
|
|
110
143
|
mergeBase,
|
|
111
144
|
files,
|
|
112
145
|
commits,
|
package/app/rebase.js
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
// reviewed-tip waiver semantics the merge gate depends on.
|
|
9
9
|
import { validateState } from '../domain/state.js'
|
|
10
10
|
import { TowerToolError } from '../domain/errors.js'
|
|
11
|
+
import { assertActionSupported } from '../domain/shapes/index.js'
|
|
12
|
+
import { markBlocked, resolveBlockers } from '../domain/missions/lifecycle.js'
|
|
11
13
|
import { liveAgentFor } from '../domain/integration/liveness.js'
|
|
12
14
|
import {
|
|
13
15
|
alreadyMergedRebaseResult,
|
|
@@ -17,6 +19,7 @@ import {
|
|
|
17
19
|
classifyRebaseIntent,
|
|
18
20
|
clearedRebaseIntentActivityText,
|
|
19
21
|
dirtyWorktreeResult,
|
|
22
|
+
isRebaseConflictBlocker,
|
|
20
23
|
malformedRebaseIntent,
|
|
21
24
|
rebaseActivityText,
|
|
22
25
|
rebaseConflictNote,
|
|
@@ -55,6 +58,7 @@ async function prepareRebase(tower, input) {
|
|
|
55
58
|
const state = await tower.load()
|
|
56
59
|
const mission = findMission(state, input.missionId)
|
|
57
60
|
if (!mission) fail(`unknown mission "${input.missionId}"`)
|
|
61
|
+
assertActionSupported(mission, 'rebase')
|
|
58
62
|
if (!mission.worktree) fail(`mission ${mission.id} has no worktree to rebase`)
|
|
59
63
|
const activity = input.activity ?? {}
|
|
60
64
|
const busy = liveAgentFor(state, mission.id, activity)
|
|
@@ -65,7 +69,7 @@ async function prepareRebase(tower, input) {
|
|
|
65
69
|
if (!(await tower.isClean(mission.worktree.path))) {
|
|
66
70
|
return { early: dirtyWorktreeResult(mission.worktree.path) }
|
|
67
71
|
}
|
|
68
|
-
const oldTip = await tower.
|
|
72
|
+
const oldTip = await tower.branchTip(mission)
|
|
69
73
|
const baseTip = await tower.revParse(tower.root, state.base)
|
|
70
74
|
if (await tower.isAncestor(tower.root, oldTip, baseTip)) {
|
|
71
75
|
// The branch is already contained in the base (manual merge, crashed
|
|
@@ -161,7 +165,7 @@ async function reconcileRebaseIntent(tower) {
|
|
|
161
165
|
if (inspection.action === 'record' && mission) {
|
|
162
166
|
const tip = /** @type {string} */ (inspection.tip)
|
|
163
167
|
mission.rebase = { at: now, oldTip: intent.oldTip, newTip: tip, baseTip: intent.baseTip }
|
|
164
|
-
|
|
168
|
+
resolveBlockers(mission, isRebaseConflictBlocker, now)
|
|
165
169
|
mission.notes.push(rebaseReconcileNote({ oldTip: intent.oldTip, newTip: tip, baseTip: intent.baseTip }))
|
|
166
170
|
mission.updatedAt = now
|
|
167
171
|
}
|
|
@@ -221,7 +225,7 @@ export async function rebase(tower, input) {
|
|
|
221
225
|
// Phase 2 (#51): the long git work runs under the git lock and never under
|
|
222
226
|
// the state lock. Re-verify the snapshot right before rewriting the branch.
|
|
223
227
|
const outcome = await tower.deps.store.withGitLock(tower.paths, async () => {
|
|
224
|
-
const currentTip = await tower.revParse(
|
|
228
|
+
const currentTip = await tower.revParse(tower.root, `refs/heads/${worktree.branch}`)
|
|
225
229
|
const currentBase = await tower.revParse(tower.root, prepared.base)
|
|
226
230
|
if (rebaseTipsMoved({ oldTip: prepared.oldTip, baseTip: prepared.baseTip, currentTip, currentBase })) {
|
|
227
231
|
return {
|
|
@@ -240,7 +244,7 @@ export async function rebase(tower, input) {
|
|
|
240
244
|
await tower.git(worktree.path, ['rebase', '--abort']).catch(() => {})
|
|
241
245
|
return { phase: 'conflict', detail: String(error?.message ?? error) }
|
|
242
246
|
}
|
|
243
|
-
const newTip = await tower.revParse(
|
|
247
|
+
const newTip = await tower.revParse(tower.root, `refs/heads/${worktree.branch}`)
|
|
244
248
|
return { phase: 'done', newTip }
|
|
245
249
|
}, tower.gitMutexOptions({ phase: 'rebase' }))
|
|
246
250
|
|
|
@@ -266,9 +270,7 @@ export async function rebase(tower, input) {
|
|
|
266
270
|
|
|
267
271
|
if (outcome.phase === 'conflict') {
|
|
268
272
|
delete state.rebaseIntent
|
|
269
|
-
mission.
|
|
270
|
-
mission.blockers.push(rebaseConflictNote(prepared.base, prepared.baseTip))
|
|
271
|
-
mission.updatedAt = now
|
|
273
|
+
markBlocked(mission, rebaseConflictNote(prepared.base, prepared.baseTip), now)
|
|
272
274
|
state.revision += 1
|
|
273
275
|
state.updatedAt = now
|
|
274
276
|
validateState(state, tower.paths.state)
|
|
@@ -280,7 +282,7 @@ export async function rebase(tower, input) {
|
|
|
280
282
|
const { newTip } = outcome
|
|
281
283
|
delete state.rebaseIntent
|
|
282
284
|
mission.rebase = { at: now, oldTip: prepared.oldTip, newTip, baseTip: prepared.baseTip }
|
|
283
|
-
|
|
285
|
+
resolveBlockers(mission, isRebaseConflictBlocker, now)
|
|
284
286
|
mission.notes.push(
|
|
285
287
|
rebaseSuccessNote({ oldTip: prepared.oldTip, newTip, baseTip: prepared.baseTip }),
|
|
286
288
|
)
|
package/app/review.js
CHANGED
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
import { sweepArchive } from './archive.js'
|
|
3
3
|
import { validateState } from '../domain/state.js'
|
|
4
4
|
import { TowerToolError } from '../domain/errors.js'
|
|
5
|
+
import { reviewUnavailable } from '../domain/integration/review.js'
|
|
6
|
+
import { cleanupReviewCheckout } from './reviewCheckout.js'
|
|
5
7
|
import {
|
|
6
8
|
assertReviewableMission,
|
|
7
9
|
resolveReviewActor,
|
|
8
10
|
normalizeReviewVerdict,
|
|
9
|
-
assertReviewerSpawnTip,
|
|
10
11
|
reviewAdmission,
|
|
11
12
|
reviewRound,
|
|
12
13
|
reviewOutcome,
|
|
@@ -18,6 +19,37 @@ function fail(message, code = 'invalid-argument') {
|
|
|
18
19
|
throw new TowerToolError(code, message)
|
|
19
20
|
}
|
|
20
21
|
|
|
22
|
+
/**
|
|
23
|
+
* The commit a verdict is stamped with (#149 W3).
|
|
24
|
+
*
|
|
25
|
+
* A spawned reviewer is bound to the review commit recorded on its roster row
|
|
26
|
+
* and reads it in a disposable detached checkout; that checkout must still be
|
|
27
|
+
* at the recorded commit, so a verdict can never review a moving target. The
|
|
28
|
+
* tower's break-glass review has no checkout and stamps the current branch tip.
|
|
29
|
+
* @param {any} tower
|
|
30
|
+
* @param {{ mission: any, caller: any, reviewerRole: string }} input
|
|
31
|
+
* @returns {Promise<string>}
|
|
32
|
+
*/
|
|
33
|
+
async function reviewCommitFor(tower, { mission, caller, reviewerRole }) {
|
|
34
|
+
if (reviewerRole === 'tower') return tower.branchTip(mission)
|
|
35
|
+
const recorded = caller?.reviewCommit
|
|
36
|
+
if (typeof recorded !== 'string' || recorded.length === 0) {
|
|
37
|
+
fail(
|
|
38
|
+
`reviewer ${caller?.name ?? '?'} has no recorded review commit; spawn reviewers through tower_spawn (a verdict is stamped with a commit, not a live branch tip)`,
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
if (typeof caller.reviewCheckout === 'string' && caller.reviewCheckout.length > 0) {
|
|
42
|
+
const head = await tower.revParse(caller.reviewCheckout, 'HEAD').catch(() => null)
|
|
43
|
+
if (head !== recorded) {
|
|
44
|
+
const where = head ? head.slice(0, 12) : 'a missing checkout'
|
|
45
|
+
fail(
|
|
46
|
+
`reviewer ${caller.name}'s checkout is at ${where} instead of the recorded review commit ${recorded.slice(0, 12)}; spawn a fresh reviewer`,
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return recorded
|
|
51
|
+
}
|
|
52
|
+
|
|
21
53
|
/**
|
|
22
54
|
* Phase 1 (#51): load state and capture everything the review needs — actor
|
|
23
55
|
* checks, worktree ownership, branch tip, and round counters — without holding
|
|
@@ -44,9 +76,8 @@ async function prepareReview(tower, input) {
|
|
|
44
76
|
const verdict = normalizeReviewVerdict(input)
|
|
45
77
|
|
|
46
78
|
await tower.assertWorktree(mission.worktree)
|
|
47
|
-
const tip = await tower
|
|
79
|
+
const tip = await reviewCommitFor(tower, { mission, caller, reviewerRole })
|
|
48
80
|
const branch = mission.worktree.branch
|
|
49
|
-
assertReviewerSpawnTip(caller, tip)
|
|
50
81
|
// #108: a later route may append to the round that returned the mission to
|
|
51
82
|
// active, but only for the tip that round reviewed.
|
|
52
83
|
const admission = reviewAdmission({ mission, reviews: state.reviews, branch, tip })
|
|
@@ -78,73 +109,85 @@ async function prepareReview(tower, input) {
|
|
|
78
109
|
}
|
|
79
110
|
|
|
80
111
|
export async function submitReview(tower, input) {
|
|
112
|
+
// #M39: `review.mode:off` means review is intentionally unavailable. The tool
|
|
113
|
+
// is not registered in that profile, but the repository layer can also turn it
|
|
114
|
+
// off, so the app layer refuses before any state is read.
|
|
115
|
+
if (reviewUnavailable(tower.config)) {
|
|
116
|
+
throw new TowerToolError('review-disabled', 'review.mode is off, so tower_review cannot record a verdict')
|
|
117
|
+
}
|
|
81
118
|
// Two-phase (#51): the lock is held only for the state reload, revision
|
|
82
119
|
// revalidation, and write; every git call happens in prepareReview.
|
|
83
120
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
84
121
|
const prepared = await prepareReview(tower, input)
|
|
85
122
|
try {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
123
|
+
const result = await tower.deps.store.withLock(tower.paths, async () => {
|
|
124
|
+
const state = await tower.load()
|
|
125
|
+
if (state.revision !== prepared.stateRevision) {
|
|
126
|
+
throw new TowerToolError('stale-base', 'tower state changed while the review was being prepared; retry')
|
|
127
|
+
}
|
|
128
|
+
const mission = state.missions.find((entry) => entry.id === input.missionId)
|
|
129
|
+
if (!mission) fail(`unknown mission "${input.missionId}"`)
|
|
93
130
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
131
|
+
const now = Date.now()
|
|
132
|
+
const seq = (state.reviewSeq ?? 0) + 1
|
|
133
|
+
state.reviewSeq = seq
|
|
134
|
+
const review = reviewRecord({
|
|
135
|
+
seq,
|
|
136
|
+
missionId: mission.id,
|
|
137
|
+
branch: prepared.branch,
|
|
138
|
+
tip: prepared.tip,
|
|
139
|
+
verdict: {
|
|
140
|
+
status: prepared.status,
|
|
141
|
+
merge: prepared.merge,
|
|
142
|
+
findings: prepared.findings,
|
|
143
|
+
checks: prepared.checks,
|
|
144
|
+
decision: prepared.decision,
|
|
145
|
+
},
|
|
146
|
+
reviewerName: prepared.reviewerName,
|
|
147
|
+
reviewerRole: prepared.reviewerRole,
|
|
148
|
+
round: prepared.round,
|
|
149
|
+
now,
|
|
150
|
+
breakGlassReason: prepared.breakGlassReason,
|
|
151
|
+
})
|
|
152
|
+
if (!Array.isArray(state.reviews)) state.reviews = []
|
|
153
|
+
state.reviews.push(review)
|
|
117
154
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
155
|
+
const outcome = reviewOutcome({
|
|
156
|
+
status: prepared.status,
|
|
157
|
+
merge: prepared.merge,
|
|
158
|
+
round: prepared.round,
|
|
159
|
+
reviewerName: prepared.reviewerName,
|
|
160
|
+
appended: prepared.appended,
|
|
161
|
+
})
|
|
162
|
+
const applied = applyReviewOutcome({ mission, outcome, now })
|
|
163
|
+
await sweepArchive(tower, state, { now })
|
|
164
|
+
state.revision += 1
|
|
165
|
+
state.updatedAt = now
|
|
129
166
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
167
|
+
validateState(state, tower.paths.state)
|
|
168
|
+
await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
|
|
169
|
+
await tower.deps.store.appendJsonl(tower.paths.activity, {
|
|
170
|
+
at: now,
|
|
171
|
+
type: 'review',
|
|
172
|
+
missionId: mission.id,
|
|
173
|
+
text: `round ${prepared.round} ${prepared.reviewerName}: ${prepared.status} / ${prepared.merge} @ ${prepared.tip.slice(0, 12)}`,
|
|
174
|
+
})
|
|
175
|
+
tower.emitEvents(applied.events, { revision: state.revision, at: now })
|
|
176
|
+
return {
|
|
177
|
+
review,
|
|
178
|
+
branchTip: prepared.tip,
|
|
179
|
+
missionStatus: mission.status,
|
|
180
|
+
round: prepared.round,
|
|
181
|
+
appended: prepared.appended,
|
|
182
|
+
reviewerRounds: prepared.reviewerRounds + 1,
|
|
183
|
+
}
|
|
147
184
|
})
|
|
185
|
+
// The verdict is recorded, so the reviewer's evidence checkout has served
|
|
186
|
+
// its purpose. Removal is advisory: a failed cleanup is retried by teardown.
|
|
187
|
+
if (prepared.reviewerRole === 'reviewer') {
|
|
188
|
+
await cleanupReviewCheckout(tower, { name: prepared.reviewerName, missionId: prepared.missionId }).catch(() => {})
|
|
189
|
+
}
|
|
190
|
+
return result
|
|
148
191
|
} catch (error) {
|
|
149
192
|
if (error.code === 'stale-base' && attempt === 0) continue
|
|
150
193
|
throw error
|