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/checkCompletion.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// Worker completion preparation and commit use cases.
|
|
2
|
+
import path from 'node:path'
|
|
2
3
|
import { TowerToolError } from '../domain/errors.js'
|
|
3
4
|
import {
|
|
4
5
|
normalizeChecks,
|
|
@@ -7,12 +8,79 @@ import {
|
|
|
7
8
|
completionReport,
|
|
8
9
|
applyCompletion,
|
|
9
10
|
} from '../domain/missions/completion.js'
|
|
11
|
+
import { shapeFor } from '../domain/shapes/index.js'
|
|
10
12
|
import { fullMission } from '../domain/summary.js'
|
|
11
13
|
import { validateState } from '../domain/state.js'
|
|
12
14
|
import { sweepArchive } from './archive.js'
|
|
15
|
+
import { cleanupSandbox } from './sandbox.js'
|
|
16
|
+
import { trackedFingerprint } from './sharedCheckout.js'
|
|
13
17
|
|
|
14
18
|
export { normalizeChecks }
|
|
15
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Read the primitives the shape's completion invariants need. Which reads happen
|
|
22
|
+
* is declared by the shape, so this layer never asks "is this a survey?"; a
|
|
23
|
+
* detached sandbox declares `requiresSandbox` and its HEAD/commit count is read
|
|
24
|
+
* from the recorded sandbox path instead of the shared checkout (#148 W2).
|
|
25
|
+
* @param {any} tower
|
|
26
|
+
* @param {import('../types/index.js').Mission} mission
|
|
27
|
+
* @param {any} invariants
|
|
28
|
+
* @returns {Promise<{ commits: number, clean: boolean, dirtyPaths: string[], head: string, trackedChanged: string[], readCommit?: string, sandbox?: string | null }>}
|
|
29
|
+
*/
|
|
30
|
+
async function completionFacts(tower, mission, invariants) {
|
|
31
|
+
const worktree = /** @type {any} */ (mission.worktree)
|
|
32
|
+
const sandbox = invariants.requiresSandbox ? String(/** @type {any} */ (mission).sandbox ?? '') : ''
|
|
33
|
+
/** @type {{ commits: number, clean: boolean, dirtyPaths: string[], head: string, trackedChanged: string[], readCommit?: string, sandbox?: string | null }} */
|
|
34
|
+
const facts = { commits: 0, clean: true, dirtyPaths: [], head: '', trackedChanged: [], sandbox: sandbox || null }
|
|
35
|
+
if (invariants.requiresSandbox) {
|
|
36
|
+
if (!sandbox) {
|
|
37
|
+
throw new TowerToolError('invalid-argument', `mission ${mission.id} has no detached sandbox recorded; re-provision it or abandon the survey`)
|
|
38
|
+
}
|
|
39
|
+
const entries = await tower.deps.vcs.listWorktrees(tower.root)
|
|
40
|
+
if (!entries.some((entry) => entry.path && path.resolve(entry.path) === path.resolve(sandbox))) {
|
|
41
|
+
throw new TowerToolError(
|
|
42
|
+
'invalid-argument',
|
|
43
|
+
`mission ${mission.id} detached sandbox ${sandbox} is not registered with git; the disposable checkout was removed — re-provision the survey`,
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (invariants.requiresWorktree) await tower.assertWorktree(worktree)
|
|
48
|
+
const cwd = sandbox || worktree?.path
|
|
49
|
+
if (invariants.requiresCommits) {
|
|
50
|
+
const base = invariants.requiresSandbox ? /** @type {string} */ (mission.readCommit) : worktree.baseCommit
|
|
51
|
+
facts.commits = await tower.deps.vcs.countCommits(cwd, base)
|
|
52
|
+
}
|
|
53
|
+
if (invariants.requiresClean) {
|
|
54
|
+
facts.clean = await tower.deps.vcs.isClean(cwd)
|
|
55
|
+
if (!facts.clean) {
|
|
56
|
+
facts.dirtyPaths = (await tower.git(cwd, ['status', '--porcelain']))
|
|
57
|
+
.split('\n')
|
|
58
|
+
.filter(Boolean)
|
|
59
|
+
.map((line) => line.slice(3).trim())
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
facts.readCommit = /** @type {string | undefined} */ (mission.readCommit)
|
|
63
|
+
if (invariants.requiresHeadMatch || invariants.requiresTrackedStatus) {
|
|
64
|
+
// A shared survey reads the shared checkout: HEAD and the tracked fingerprint
|
|
65
|
+
// must match the plan-time record exactly (a path that changed either way
|
|
66
|
+
// counts). A detached sandbox reads its own disposable checkout: only HEAD
|
|
67
|
+
// must still equal the recorded commit; writes there are expected.
|
|
68
|
+
facts.head = await tower.revParse(invariants.requiresSandbox ? sandbox : tower.root, 'HEAD')
|
|
69
|
+
if (invariants.requiresTrackedStatus) {
|
|
70
|
+
const current = trackedFingerprint(await tower.git(tower.root, ['status', '--porcelain']))
|
|
71
|
+
const planned = new Set(mission.readDirty ?? [])
|
|
72
|
+
const currentSet = new Set(current)
|
|
73
|
+
facts.trackedChanged = [
|
|
74
|
+
...new Set([
|
|
75
|
+
...current.filter((path) => !planned.has(path)),
|
|
76
|
+
...(mission.readDirty ?? []).filter((path) => !currentSet.has(path)),
|
|
77
|
+
]),
|
|
78
|
+
].sort()
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return facts
|
|
82
|
+
}
|
|
83
|
+
|
|
16
84
|
export async function checkCompletion(tower, input) {
|
|
17
85
|
const state = await tower.load()
|
|
18
86
|
const mission = state.missions.find((entry) => entry.id === input.missionId)
|
|
@@ -25,24 +93,22 @@ export async function checkCompletion(tower, input) {
|
|
|
25
93
|
input,
|
|
26
94
|
maxReportBytes: tower.config.gates?.maxReportBytes,
|
|
27
95
|
})
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
)
|
|
96
|
+
const shape = shapeFor(mission.kind)
|
|
97
|
+
const invariants = /** @type {{
|
|
98
|
+
* requiresWorktree: boolean, requiresSandbox: boolean, requiresCommits: boolean,
|
|
99
|
+
* requiresClean: boolean, requiresHeadMatch: boolean, requiresTrackedStatus: boolean,
|
|
100
|
+
* check: (ctx: any) => { commits: number },
|
|
101
|
+
* }} */ ('resolveCompletion' in shape && typeof shape.resolveCompletion === 'function'
|
|
102
|
+
? shape.resolveCompletion(mission, tower.config)
|
|
103
|
+
: shape.completion)
|
|
104
|
+
if (invariants.requiresWorktree && !mission.worktree) {
|
|
105
|
+
throw new TowerToolError('invalid-argument', `mission ${mission.id} has no worktree`)
|
|
35
106
|
}
|
|
36
|
-
if (
|
|
37
|
-
|
|
38
|
-
.split('\n')
|
|
39
|
-
.filter(Boolean)
|
|
40
|
-
.map((line) => line.slice(3).trim())
|
|
41
|
-
throw new TowerToolError(
|
|
42
|
-
'worktree-dirty',
|
|
43
|
-
`mission ${mission.id} worktree has uncommitted changes (${dirty.slice(0, 8).join(', ')}${dirty.length > 8 ? ', …' : ''}); commit, remove, or record them before tower_complete`,
|
|
44
|
-
)
|
|
107
|
+
if (invariants.requiresSandbox && !/** @type {any} */ (mission).sandbox) {
|
|
108
|
+
throw new TowerToolError('invalid-argument', `mission ${mission.id} has no detached sandbox; re-provision it or abandon the survey`)
|
|
45
109
|
}
|
|
110
|
+
const facts = await completionFacts(tower, mission, invariants)
|
|
111
|
+
const { commits } = invariants.check({ mission, ...facts })
|
|
46
112
|
const unread = tower.unreadFor(state, worker.name)
|
|
47
113
|
assertCompletionInbox({ mission, worker, unread })
|
|
48
114
|
return { state, mission, worker, commits }
|
|
@@ -52,7 +118,7 @@ export async function complete(tower, input) {
|
|
|
52
118
|
// Two-phase pilot (#51): expensive git/state checks run before the lock;
|
|
53
119
|
// the lock only revalidates the state revision and commits the transition.
|
|
54
120
|
const prepared = await tower.checkCompletion(input)
|
|
55
|
-
|
|
121
|
+
const result = await tower.deps.store.withLock(tower.paths, async () => {
|
|
56
122
|
const state = await tower.load()
|
|
57
123
|
if (state.revision !== prepared.state.revision) {
|
|
58
124
|
throw new TowerToolError('stale-base', 'tower state changed while completion was being prepared; retry')
|
|
@@ -69,7 +135,7 @@ export async function complete(tower, input) {
|
|
|
69
135
|
now,
|
|
70
136
|
maxReportBytes: tower.config.gates?.maxReportBytes,
|
|
71
137
|
})
|
|
72
|
-
const applied = applyCompletion({ mission, report: storedReport, now })
|
|
138
|
+
const applied = applyCompletion({ mission, report: storedReport, now, result: input.result })
|
|
73
139
|
await sweepArchive(tower, state, { now })
|
|
74
140
|
state.revision += 1
|
|
75
141
|
state.updatedAt = now
|
|
@@ -85,4 +151,27 @@ export async function complete(tower, input) {
|
|
|
85
151
|
tower.emitEvents(applied.events, { revision: state.revision, at: now })
|
|
86
152
|
return { mission: fullMission(mission), commits, unread: 0 }
|
|
87
153
|
})
|
|
154
|
+
// The completion transition is durable at this point. Removing the disposable
|
|
155
|
+
// sandbox is advisory: a failed cleanup is reported, never rolled back into
|
|
156
|
+
// the mission state or allowed to fail the completion.
|
|
157
|
+
let sandboxCleanup = null
|
|
158
|
+
if (result.mission.sandbox) {
|
|
159
|
+
const sandbox = result.mission.sandbox
|
|
160
|
+
try {
|
|
161
|
+
sandboxCleanup = await cleanupSandbox(tower, input.missionId)
|
|
162
|
+
} catch (error) {
|
|
163
|
+
sandboxCleanup = {
|
|
164
|
+
ok: false,
|
|
165
|
+
removed: false,
|
|
166
|
+
attempts: 0,
|
|
167
|
+
path: sandbox,
|
|
168
|
+
error: String(error?.message ?? error).slice(0, 400),
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (sandboxCleanup?.ok) {
|
|
172
|
+
delete result.mission.sandbox
|
|
173
|
+
delete result.mission.sandboxCleanupError
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return { ...result, ...(sandboxCleanup ? { sandboxCleanup } : {}) }
|
|
88
177
|
}
|
package/app/enrichedMissions.js
CHANGED
|
@@ -12,7 +12,7 @@ export async function enrichedMissions(tower, state, onlyIds) {
|
|
|
12
12
|
let tipMoved = false
|
|
13
13
|
if (latest && mission.worktree && (await tower.deps.store.exists(mission.worktree.path))) {
|
|
14
14
|
try {
|
|
15
|
-
const tip = await tower.
|
|
15
|
+
const tip = await tower.branchTip(mission)
|
|
16
16
|
tipMoved = tip !== latest.reviewedCommit
|
|
17
17
|
} catch {
|
|
18
18
|
tipMoved = false
|
package/app/freshness.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Dependency freshness (#148 W2 / D5): a build that depends on a survey wants
|
|
2
|
+
// the survey's evidence to describe the code it starts from. A survey records
|
|
3
|
+
// the exact commit it read; when that commit is not an ancestor-or-equal of the
|
|
4
|
+
// build's base, the evidence may be stale. This is advisory by default and a
|
|
5
|
+
// blocker only when `gates.requireFreshSurvey` is true.
|
|
6
|
+
// @ts-check
|
|
7
|
+
import { shapeFor } from '../domain/shapes/index.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {import('../types/index.js').Mission} Mission
|
|
11
|
+
* @typedef {import('../types/index.js').TowerState} TowerState
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* One stable, self-describing freshness warning. The same shape is promoted to a
|
|
16
|
+
* blocker (with `detail` carrying the message) when the gate asks for it.
|
|
17
|
+
* @param {{ missionId: string, dep: { id: string }, readCommit: string, baseCommit: string }} input
|
|
18
|
+
* @returns {{ code: 'survey-stale', missionId: string, depId: string, readCommit: string, baseCommit: string, message: string }}
|
|
19
|
+
*/
|
|
20
|
+
export function surveyStaleWarning({ missionId, dep, readCommit, baseCommit }) {
|
|
21
|
+
const read = String(readCommit).slice(0, 12)
|
|
22
|
+
const base = String(baseCommit).slice(0, 12)
|
|
23
|
+
return {
|
|
24
|
+
code: 'survey-stale',
|
|
25
|
+
missionId,
|
|
26
|
+
depId: dep.id,
|
|
27
|
+
readCommit,
|
|
28
|
+
baseCommit,
|
|
29
|
+
message: `survey ${dep.id} read ${read}, which is not an ancestor of ${missionId}'s base ${base}; the survey evidence may not describe the code this mission starts from — re-plan the survey against the current base, or record why the read is still valid`,
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Compute `survey-stale` warnings for one build mission's survey dependencies.
|
|
35
|
+
* A survey dependency is recognised through the shape contract (`fields.readCommit`),
|
|
36
|
+
* not a kind literal. The base is `mission.baseCommit` for a provisioned build,
|
|
37
|
+
* otherwise the current state base (resolved once by the caller or here).
|
|
38
|
+
* @param {any} tower
|
|
39
|
+
* @param {object} input
|
|
40
|
+
* @param {Mission} input.mission
|
|
41
|
+
* @param {TowerState} input.state
|
|
42
|
+
* @param {Mission[]} [input.missions]
|
|
43
|
+
* @param {Map<string, { id?: string, status: string, kind?: string, readCommit?: string }>} [input.archived]
|
|
44
|
+
* @param {string | null} [input.baseCommit] already-resolved build base
|
|
45
|
+
* @returns {Promise<Array<ReturnType<typeof surveyStaleWarning>>>}
|
|
46
|
+
*/
|
|
47
|
+
export async function surveyStaleWarnings(tower, { mission, state, missions, archived = new Map(), baseCommit = null }) {
|
|
48
|
+
if (!shapeFor(mission.kind).fields.worktree) return []
|
|
49
|
+
const deps = mission.deps ?? []
|
|
50
|
+
if (deps.length === 0) return []
|
|
51
|
+
let base = baseCommit ?? /** @type {any} */ (mission).baseCommit ?? null
|
|
52
|
+
if (typeof base !== 'string' || base.length === 0) {
|
|
53
|
+
try {
|
|
54
|
+
base = await tower.revParse(tower.root, state.base)
|
|
55
|
+
} catch {
|
|
56
|
+
return []
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const byId = new Map((missions ?? state.missions).map((entry) => [entry.id, entry]))
|
|
60
|
+
/** @type {Array<ReturnType<typeof surveyStaleWarning>>} */
|
|
61
|
+
const warnings = []
|
|
62
|
+
for (const depId of deps) {
|
|
63
|
+
const dep = byId.get(depId) ?? archived.get(depId)
|
|
64
|
+
if (!dep || !shapeFor(dep.kind).fields.readCommit) continue
|
|
65
|
+
const readCommit = typeof /** @type {any} */ (dep).readCommit === 'string' ? /** @type {any} */ (dep).readCommit : null
|
|
66
|
+
if (!readCommit) continue
|
|
67
|
+
let fresh = false
|
|
68
|
+
try {
|
|
69
|
+
fresh = await tower.isAncestor(tower.root, readCommit, base)
|
|
70
|
+
} catch {
|
|
71
|
+
fresh = false
|
|
72
|
+
}
|
|
73
|
+
if (fresh) continue
|
|
74
|
+
warnings.push(surveyStaleWarning({ missionId: mission.id, dep: /** @type {{ id: string }} */ (dep), readCommit, baseCommit: base }))
|
|
75
|
+
}
|
|
76
|
+
return warnings
|
|
77
|
+
}
|
package/app/gate.js
CHANGED
|
@@ -3,6 +3,7 @@ import { ROSTER_INACTIVE_STATES } from '../domain/roster.js'
|
|
|
3
3
|
import { completionBlockers, mergeGateBlockers } from '../domain/gate.js'
|
|
4
4
|
import { fullMission } from '../domain/summary.js'
|
|
5
5
|
import { latestReview, reviewsForBranch } from '../domain/integration/review.js'
|
|
6
|
+
import { assertActionSupported } from '../domain/shapes/index.js'
|
|
6
7
|
import {
|
|
7
8
|
baseDirtyBlocker,
|
|
8
9
|
baseWrongBranchBlocker,
|
|
@@ -14,8 +15,8 @@ import {
|
|
|
14
15
|
outOfScopeBlocker,
|
|
15
16
|
overwrittenPaths,
|
|
16
17
|
staleBaseBlocker,
|
|
17
|
-
surveyChangedFilesBlocker,
|
|
18
18
|
} from '../domain/integration/gate.js'
|
|
19
|
+
import { surveyStaleWarnings } from './freshness.js'
|
|
19
20
|
|
|
20
21
|
export async function evaluateGate(tower, input) {
|
|
21
22
|
const state = await tower.load()
|
|
@@ -31,6 +32,9 @@ export async function evaluateGate(tower, input) {
|
|
|
31
32
|
}
|
|
32
33
|
}
|
|
33
34
|
if (!mission) return missingMissionGateResult(input.missionId)
|
|
35
|
+
// A shape without a branch has no merge gate at all; refuse before doing
|
|
36
|
+
// any git work so the caller gets one actionable code (#148 D4).
|
|
37
|
+
assertActionSupported(mission, input.action === 'merge' ? 'merge' : 'preflight')
|
|
34
38
|
const archiveIndex = await tower.archiveIndex()
|
|
35
39
|
const archivedMissions = new Map(
|
|
36
40
|
(archiveIndex.ok ? archiveIndex.missions : []).map((entry) => [entry.id, entry]),
|
|
@@ -56,7 +60,7 @@ export async function evaluateGate(tower, input) {
|
|
|
56
60
|
} else {
|
|
57
61
|
try {
|
|
58
62
|
await tower.assertWorktree(worktree)
|
|
59
|
-
branchTip = await tower.
|
|
63
|
+
branchTip = await tower.branchTip(mission)
|
|
60
64
|
baseTip = await tower.revParse(tower.root, state.base)
|
|
61
65
|
const mergeBase = (await tower.git(worktree.path, ['merge-base', baseTip, branchTip])).trim()
|
|
62
66
|
changedFiles = (await tower.git(worktree.path, ['diff', '--name-only', `${mergeBase}..${branchTip}`]))
|
|
@@ -66,20 +70,14 @@ export async function evaluateGate(tower, input) {
|
|
|
66
70
|
const blocker = staleBaseBlocker({ branch: worktree.branch, base: state.base, baseTip })
|
|
67
71
|
push(blocker.code, blocker.detail)
|
|
68
72
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
if (blocker) push(blocker.code, blocker.detail)
|
|
72
|
-
}
|
|
73
|
-
if (mission.kind !== 'survey') {
|
|
74
|
-
const blocker = outOfScopeBlocker({ mission, changedFiles, caseInsensitive: state.caseInsensitive === true })
|
|
75
|
-
if (blocker) push(blocker.code, blocker.detail)
|
|
76
|
-
}
|
|
73
|
+
const scopeBlocker = outOfScopeBlocker({ mission, changedFiles, caseInsensitive: state.caseInsensitive === true })
|
|
74
|
+
if (scopeBlocker) push(scopeBlocker.code, scopeBlocker.detail)
|
|
77
75
|
const dirty = (await tower.git(tower.root, ['status', '--porcelain']))
|
|
78
76
|
.split('\n')
|
|
79
77
|
.filter(Boolean)
|
|
80
78
|
.map((line) => line.slice(3).trim())
|
|
81
|
-
const
|
|
82
|
-
if (
|
|
79
|
+
const dirtyBlocker = baseDirtyBlocker(overwrittenPaths(changedFiles, dirty))
|
|
80
|
+
if (dirtyBlocker) push(dirtyBlocker.code, dirtyBlocker.detail)
|
|
83
81
|
} catch (error) {
|
|
84
82
|
push('branch-missing', String(error?.message ?? error))
|
|
85
83
|
}
|
|
@@ -89,6 +87,21 @@ export async function evaluateGate(tower, input) {
|
|
|
89
87
|
push(blocker.code, blocker.detail)
|
|
90
88
|
}
|
|
91
89
|
|
|
90
|
+
// #148 W2: survey evidence freshness is advisory by default. The stable
|
|
91
|
+
// warning shape is what plan/preflight report; `gates.requireFreshSurvey`
|
|
92
|
+
// promotes the same code to a blocker for repositories that need the read
|
|
93
|
+
// to describe the exact base they start from.
|
|
94
|
+
const sandboxWarnings = await surveyStaleWarnings(tower, {
|
|
95
|
+
mission,
|
|
96
|
+
state,
|
|
97
|
+
missions: state.missions,
|
|
98
|
+
archived: archivedMissions,
|
|
99
|
+
baseCommit: mission.baseCommit ?? null,
|
|
100
|
+
})
|
|
101
|
+
if (tower.config.gates?.requireFreshSurvey === true) {
|
|
102
|
+
for (const warning of sandboxWarnings) blockers.push({ ...warning, detail: warning.message })
|
|
103
|
+
}
|
|
104
|
+
|
|
92
105
|
const branch = worktree ? worktree.branch : null
|
|
93
106
|
const latest = latestReview(reviewsForBranch(reviews, branch))
|
|
94
107
|
// #140: `mergeGateBlockers` is pure and may not read the component registry, so
|
|
@@ -132,6 +145,7 @@ export async function evaluateGate(tower, input) {
|
|
|
132
145
|
review: latest,
|
|
133
146
|
blockers,
|
|
134
147
|
}),
|
|
148
|
+
warnings: sandboxWarnings,
|
|
135
149
|
...(archivedEntry ? { archived: true } : {}),
|
|
136
150
|
}
|
|
137
151
|
}
|
package/app/merge.js
CHANGED
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
// prepare stale, so the whole prepare is retried, never partially applied.
|
|
19
19
|
import path from 'node:path'
|
|
20
20
|
import { validateState } from '../domain/state.js'
|
|
21
|
+
import { assertActionSupported } from '../domain/shapes/index.js'
|
|
22
|
+
import { markBlocked } from '../domain/missions/lifecycle.js'
|
|
21
23
|
import {
|
|
22
24
|
alreadyMergedResult,
|
|
23
25
|
applyLandedMerge,
|
|
@@ -134,7 +136,7 @@ async function observeLandedMerge(tower, state, mission) {
|
|
|
134
136
|
let branchTip
|
|
135
137
|
try {
|
|
136
138
|
baseTip = await tower.revParse(tower.root, state.base)
|
|
137
|
-
branchTip = await tower.
|
|
139
|
+
branchTip = await tower.branchTip(mission)
|
|
138
140
|
} catch {
|
|
139
141
|
return null
|
|
140
142
|
}
|
|
@@ -238,6 +240,8 @@ async function prepareFromState(tower, input, deps) {
|
|
|
238
240
|
if (archived.ok && archived.entry) mission = archived.entry.mission
|
|
239
241
|
}
|
|
240
242
|
if (!mission) return { result: missionMissingResult(input.missionId) }
|
|
243
|
+
// A branchless shape has no merge to prepare; refuse before any git work.
|
|
244
|
+
assertActionSupported(mission, 'merge')
|
|
241
245
|
|
|
242
246
|
// Reconcile a stale merge intent: Git runs here, outside every state lock.
|
|
243
247
|
if (state.mergeIntent && state.mergeIntent.missionId === mission.id) {
|
|
@@ -272,7 +276,7 @@ async function prepareFromState(tower, input, deps) {
|
|
|
272
276
|
// Capture the exact commits this merge is allowed to integrate. The merge
|
|
273
277
|
// command below uses the captured branch SHA, never the mutable branch name.
|
|
274
278
|
const baseTip = await tower.revParse(tower.root, 'HEAD')
|
|
275
|
-
const branchTip = await tower.
|
|
279
|
+
const branchTip = await tower.branchTip(mission)
|
|
276
280
|
const drift = gateTipDrift({
|
|
277
281
|
missionId: mission.id,
|
|
278
282
|
gateBaseTip: gate.baseTip,
|
|
@@ -522,8 +526,7 @@ async function commitMerge(tower, plan, recorded, outcome) {
|
|
|
522
526
|
const now = Date.now()
|
|
523
527
|
if (intentActive) delete state.mergeIntent
|
|
524
528
|
if (conflict) {
|
|
525
|
-
mission.
|
|
526
|
-
mission.blockers.push(mergeConflictNote(outcome.detail))
|
|
529
|
+
markBlocked(mission, mergeConflictNote(outcome.detail), now)
|
|
527
530
|
} else {
|
|
528
531
|
mission.notes.push(mergeHookFailureNote(outcome.detail, mission.status))
|
|
529
532
|
}
|
package/app/mission.js
CHANGED
|
@@ -4,6 +4,7 @@ import { applyMissionUpdate } from '../domain/missions/lifecycle.js'
|
|
|
4
4
|
import { validateState } from '../domain/state.js'
|
|
5
5
|
import { fullMission } from '../domain/summary.js'
|
|
6
6
|
import { sweepArchive } from './archive.js'
|
|
7
|
+
import { cleanupSandbox } from './sandbox.js'
|
|
7
8
|
|
|
8
9
|
function fail(message, code = 'invalid-argument') {
|
|
9
10
|
throw new TowerToolError(code, message)
|
|
@@ -12,7 +13,7 @@ function fail(message, code = 'invalid-argument') {
|
|
|
12
13
|
export async function updateMission(tower, input = {}) {
|
|
13
14
|
const missionId = typeof input.id === 'string' ? input.id.trim() : input.id
|
|
14
15
|
if (typeof missionId !== 'string' || missionId.length === 0) fail('tower_mission requires id')
|
|
15
|
-
|
|
16
|
+
const result = await tower.deps.store.withLock(tower.paths, async () => {
|
|
16
17
|
const state = await tower.load()
|
|
17
18
|
const mission = state.missions.find((entry) => entry.id === missionId)
|
|
18
19
|
if (!mission) fail(`unknown mission "${missionId}"`, 'mission-not-found')
|
|
@@ -34,4 +35,26 @@ export async function updateMission(tower, input = {}) {
|
|
|
34
35
|
tower.emitEvents(events, { revision: state.revision, at: now })
|
|
35
36
|
return { revision: state.revision, mission: fullMission(mission) }
|
|
36
37
|
})
|
|
38
|
+
// Abandoning a detached survey disposes its sandbox. The state transition is
|
|
39
|
+
// already durable; cleanup is advisory and reports rather than throws.
|
|
40
|
+
let sandboxCleanup = null
|
|
41
|
+
if (result.mission.status === 'abandoned' && result.mission.sandbox) {
|
|
42
|
+
const sandbox = result.mission.sandbox
|
|
43
|
+
try {
|
|
44
|
+
sandboxCleanup = await cleanupSandbox(tower, missionId)
|
|
45
|
+
} catch (error) {
|
|
46
|
+
sandboxCleanup = {
|
|
47
|
+
ok: false,
|
|
48
|
+
removed: false,
|
|
49
|
+
attempts: 0,
|
|
50
|
+
path: sandbox,
|
|
51
|
+
error: String(error?.message ?? error).slice(0, 400),
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (sandboxCleanup?.ok) {
|
|
55
|
+
delete result.mission.sandbox
|
|
56
|
+
delete result.mission.sandboxCleanupError
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return { ...result, ...(sandboxCleanup ? { sandboxCleanup } : {}) }
|
|
37
60
|
}
|
package/app/missionContext.js
CHANGED
|
@@ -10,5 +10,6 @@ export async function missionContext(tower, missionId) {
|
|
|
10
10
|
const state = await tower.load()
|
|
11
11
|
const mission = state.missions.find((entry) => entry.id === missionId)
|
|
12
12
|
if (!mission) fail(`unknown mission "${missionId}"`, 'mission-not-found')
|
|
13
|
-
|
|
13
|
+
const archived = await tower.archiveIndex()
|
|
14
|
+
return missionContextView({ state, mission, archived: archived.ok ? archived.missions : [] })
|
|
14
15
|
}
|
package/app/plan.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// Mission planning use case.
|
|
2
|
+
import { TowerToolError } from '../domain/errors.js'
|
|
2
3
|
import { nextMissionNumber, addAutoDeps, assertAcyclic } from '../domain/planning.js'
|
|
3
4
|
import {
|
|
4
5
|
normalizePlanMissions,
|
|
@@ -7,15 +8,58 @@ import {
|
|
|
7
8
|
newMission,
|
|
8
9
|
applyPlan,
|
|
9
10
|
} from '../domain/missions/plan.js'
|
|
11
|
+
import { shapeFor } from '../domain/shapes/index.js'
|
|
10
12
|
import { validateState } from '../domain/state.js'
|
|
11
13
|
import { fullMission } from '../domain/summary.js'
|
|
12
14
|
import { sweepArchive } from './archive.js'
|
|
15
|
+
import { surveyStaleWarnings } from './freshness.js'
|
|
16
|
+
import { readFacts } from './sharedCheckout.js'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Planning a shape whose optional component is off must fail with an actionable
|
|
20
|
+
* message, not silently plan work nothing can execute. Preset rows are only
|
|
21
|
+
* visible through the async probe: the synchronous `enabled` switch cannot see
|
|
22
|
+
* a declarative agent-preset registration.
|
|
23
|
+
* @param {any} tower
|
|
24
|
+
* @param {any} input
|
|
25
|
+
* @returns {Promise<void>}
|
|
26
|
+
*/
|
|
27
|
+
async function assertShapeComponents(tower, input) {
|
|
28
|
+
const components = tower.deps?.components
|
|
29
|
+
if (!components) return
|
|
30
|
+
for (const mission of input?.missions ?? []) {
|
|
31
|
+
const required = shapeFor(mission?.kind).requiredComponent
|
|
32
|
+
if (!required) continue
|
|
33
|
+
if (typeof components.available === 'function') {
|
|
34
|
+
// null means "cannot tell" (no agent-presets service): let the plan pass,
|
|
35
|
+
// exactly as a host without the component port did before shapes existed.
|
|
36
|
+
if ((await components.available(required)) !== false) continue
|
|
37
|
+
} else if (components.enabled(required) === true) continue
|
|
38
|
+
throw new TowerToolError(
|
|
39
|
+
'invalid-argument',
|
|
40
|
+
`the ${required} component is off: surveys cannot be planned; build missions are unaffected`,
|
|
41
|
+
{ nextAction: `enable the ${required} component and plan the survey again` },
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @param {any} input
|
|
48
|
+
* @returns {boolean}
|
|
49
|
+
*/
|
|
50
|
+
function planNeedsRead(input) {
|
|
51
|
+
return (input?.missions ?? []).some((mission) => shapeFor(mission?.kind).fields.readCommit)
|
|
52
|
+
}
|
|
13
53
|
|
|
14
54
|
export async function plan(tower, input = {}) {
|
|
15
|
-
const prepared = normalizePlanMissions(input)
|
|
16
55
|
return tower.deps.store.withLock(tower.paths, async () => {
|
|
17
56
|
const state = await tower.load()
|
|
18
57
|
const now = Date.now()
|
|
58
|
+
// A survey records the commit it reads and the tracked-status fingerprint at
|
|
59
|
+
// plan time; a build plan reads nothing extra.
|
|
60
|
+
await assertShapeComponents(tower, input)
|
|
61
|
+
const read = planNeedsRead(input) ? await readFacts(tower, state.base) : {}
|
|
62
|
+
const prepared = normalizePlanMissions(input, read)
|
|
19
63
|
// Archived ids stay in the known set: a new mission must not reuse an id
|
|
20
64
|
// and a merged dependency may live in the archive index (#73).
|
|
21
65
|
const archive = await tower.archiveIndex()
|
|
@@ -29,6 +73,24 @@ export async function plan(tower, input = {}) {
|
|
|
29
73
|
|
|
30
74
|
const created = assigned.map((mission) => newMission(mission.id, mission, now))
|
|
31
75
|
const applied = applyPlan(state, created)
|
|
76
|
+
// #148 W2: report survey evidence freshness at plan time. New builds have no
|
|
77
|
+
// provisioned base yet, so the warning compares against the state base; it
|
|
78
|
+
// is advisory here and never blocks the plan. Archived survey dependencies
|
|
79
|
+
// keep their read commit on the archive index, so they are checked too.
|
|
80
|
+
const archivedMissions = new Map(
|
|
81
|
+
(archive.ok ? archive.missions : []).map((entry) => [entry.id, entry]),
|
|
82
|
+
)
|
|
83
|
+
const warnings = []
|
|
84
|
+
for (const mission of created) {
|
|
85
|
+
warnings.push(
|
|
86
|
+
...(await surveyStaleWarnings(tower, {
|
|
87
|
+
mission,
|
|
88
|
+
state,
|
|
89
|
+
missions: state.missions,
|
|
90
|
+
archived: archivedMissions,
|
|
91
|
+
})),
|
|
92
|
+
)
|
|
93
|
+
}
|
|
32
94
|
const swept = await sweepArchive(tower, state, { now })
|
|
33
95
|
state.revision += 1
|
|
34
96
|
state.updatedAt = now
|
|
@@ -46,6 +108,7 @@ export async function plan(tower, input = {}) {
|
|
|
46
108
|
return {
|
|
47
109
|
revision: state.revision,
|
|
48
110
|
missions: created.map((mission) => fullMission(mission)),
|
|
111
|
+
...(warnings.length > 0 ? { warnings } : {}),
|
|
49
112
|
...(swept.archived.length > 0 ? { archived: swept.archived } : {}),
|
|
50
113
|
...(swept.error ? { archiveError: swept.error } : {}),
|
|
51
114
|
}
|