dsh-tower 2.6.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 +82 -0
- package/COMPATIBILITY.md +1 -0
- package/CONTRIBUTING.md +1 -1
- package/README.en.md +2 -1
- package/README.md +6 -1
- 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 +97 -61
- package/app/reviewCheckout.js +133 -0
- package/app/sandbox.js +121 -0
- package/app/sharedCheckout.js +90 -0
- package/app/spawn-prechecks.js +25 -24
- package/app/spawn-support.js +59 -8
- package/app/spawn.js +3 -0
- package/app/teardown.js +119 -2
- package/app/tower.js +29 -1
- package/cordis.patch.yml +50 -0
- package/docs/adr/0006-mission-shapes.md +306 -0
- package/docs/architecture/overview.md +6 -4
- package/docs/guide/install-upgrade-migration.md +26 -0
- package/docs/operations/runbook.md +1 -1
- package/docs/reference/blockers.md +8 -4
- package/docs/reference/component-surface.md +7 -2
- package/docs/reference/config.generated.md +6 -4
- package/docs/reference/config.md +1 -2
- package/docs/reference/doctor.md +7 -7
- package/docs/reference/operations.md +1 -1
- package/docs/reference/shapes.md +123 -0
- package/docs/reference/surface-budget.md +33 -1
- package/docs/reference/testing.md +4 -2
- package/docs/reference/tools.generated.md +5 -5
- package/docs/reference/tools.md +2 -2
- package/docs/zh/reference/blockers.md +2 -2
- package/docs/zh/reference/config.md +1 -2
- package/docs/zh/reference/testing.md +3 -2
- package/domain/batch.js +75 -105
- package/domain/collab/batch.js +30 -10
- package/domain/collab/spawn.js +53 -16
- package/domain/config/schema.js +47 -17
- package/domain/contracts/outputs.js +0 -1
- package/domain/contracts/tool-inputs/missions.js +9 -1
- package/domain/evidence.js +283 -0
- package/domain/gate.js +11 -31
- 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/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/doctor.js +2 -1
- package/host/preset-survey-sandbox.js +19 -0
- package/host/preset-survey.js +18 -0
- package/host/presets.js +8 -3
- package/host/tool-registry.js +14 -12
- 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 +13 -2
- package/ports/state.js +8 -1
- package/scripts/architecture-waivers.json +3 -3
- package/scripts/check-docs.mjs +1 -1
- package/scripts/check-pack.mjs +2 -0
- package/scripts/component-surface.json +68 -20
- 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 +21 -0
- package/scripts/shape-audit.mjs +361 -0
- package/scripts/surface-baseline.json +68 -4
- 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/spawn.js +47 -49
- package/tools/test.js +2 -1
- package/types/index.d.ts +66 -5
|
@@ -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',
|
package/domain/state/schema.js
CHANGED
|
@@ -1,15 +1,21 @@
|
|
|
1
|
-
// Declarative state-file schema (#56 P4).
|
|
1
|
+
// Declarative state-file schema (#56 P4, #149).
|
|
2
2
|
//
|
|
3
3
|
// `StateSchema` describes the structural contract of
|
|
4
|
-
// `<git-common-dir>/dsh-tower/state.json`.
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
4
|
+
// `<git-common-dir>/dsh-tower/state.json`. Schema v4 splits a mission into a
|
|
5
|
+
// per-shape union: a build mission owns a branch/worktree/merge projection, a
|
|
6
|
+
// survey owns `readCommit`/`readDirty` and may *not* carry the branch-shaped
|
|
7
|
+
// fields. Object schemas allow additional properties on purpose: state files
|
|
8
|
+
// are forward/backward compatible and legacy rows carry extra fields, while the
|
|
9
|
+
// union's forbidden keys are declared `Type.Never()` so a survey carrying a
|
|
10
|
+
// worktree record fails the shape check. Semantic policy (uniqueness,
|
|
11
|
+
// status/verdict vocabularies, review exemption, worktree records and the
|
|
12
|
+
// revision counter) lives in `./semantics.js`, so this module remains a pure
|
|
13
|
+
// declaration.
|
|
9
14
|
// @ts-check
|
|
10
15
|
import { Type } from '@sinclair/typebox'
|
|
11
16
|
import { Value } from '@sinclair/typebox/value'
|
|
12
17
|
import { SCHEMA_VERSION } from '../mission.js'
|
|
18
|
+
import { isShapeKind, shapeFor } from '../shapes/index.js'
|
|
13
19
|
|
|
14
20
|
const optional = { additionalProperties: true }
|
|
15
21
|
|
|
@@ -17,9 +23,10 @@ const NonNegativeInteger = Type.Integer({ minimum: 0 })
|
|
|
17
23
|
|
|
18
24
|
const WorktreeSchema = Type.Object({}, optional)
|
|
19
25
|
|
|
20
|
-
const
|
|
26
|
+
const BuildMissionSchema = Type.Object(
|
|
21
27
|
{
|
|
22
28
|
id: Type.String(),
|
|
29
|
+
kind: Type.Literal('build'),
|
|
23
30
|
status: Type.Optional(Type.String()),
|
|
24
31
|
tasks: Type.Array(Type.Unknown()),
|
|
25
32
|
deps: Type.Array(Type.Unknown()),
|
|
@@ -27,10 +34,41 @@ const MissionSchema = Type.Object(
|
|
|
27
34
|
reviewPolicy: Type.Optional(Type.String()),
|
|
28
35
|
testRuns: Type.Optional(Type.Array(Type.Unknown())),
|
|
29
36
|
worktree: Type.Optional(Type.Union([WorktreeSchema, Type.Null()])),
|
|
37
|
+
// A build mission reads a base and writes a branch; it never carries the
|
|
38
|
+
// survey fingerprint or a detached survey sandbox.
|
|
39
|
+
readCommit: Type.Optional(Type.Never()),
|
|
40
|
+
readDirty: Type.Optional(Type.Never()),
|
|
41
|
+
sandbox: Type.Optional(Type.Never()),
|
|
30
42
|
},
|
|
31
43
|
optional,
|
|
32
44
|
)
|
|
33
45
|
|
|
46
|
+
const SurveyMissionSchema = Type.Object(
|
|
47
|
+
{
|
|
48
|
+
id: Type.String(),
|
|
49
|
+
kind: Type.Literal('survey'),
|
|
50
|
+
status: Type.Optional(Type.String()),
|
|
51
|
+
tasks: Type.Array(Type.Unknown()),
|
|
52
|
+
deps: Type.Array(Type.Unknown()),
|
|
53
|
+
references: Type.Optional(Type.Array(Type.Unknown())),
|
|
54
|
+
reviewPolicy: Type.Optional(Type.String()),
|
|
55
|
+
readCommit: Type.String(),
|
|
56
|
+
readDirty: Type.Optional(Type.Array(Type.String())),
|
|
57
|
+
// #148 W2: an optional path to the disposable detached sandbox. Absent for
|
|
58
|
+
// a shared-checkout survey and for every build.
|
|
59
|
+
sandbox: Type.Optional(Type.String()),
|
|
60
|
+
// A survey reads one commit in the shared checkout: no branch shape.
|
|
61
|
+
worktree: Type.Optional(Type.Never()),
|
|
62
|
+
branch: Type.Optional(Type.Never()),
|
|
63
|
+
baseCommit: Type.Optional(Type.Never()),
|
|
64
|
+
mergedCommit: Type.Optional(Type.Never()),
|
|
65
|
+
mergedAt: Type.Optional(Type.Never()),
|
|
66
|
+
},
|
|
67
|
+
optional,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
const MissionSchema = Type.Union([BuildMissionSchema, SurveyMissionSchema])
|
|
71
|
+
|
|
34
72
|
const RosterEntrySchema = Type.Object(
|
|
35
73
|
{
|
|
36
74
|
name: Type.String(),
|
|
@@ -131,6 +169,9 @@ function firstShapeMessage(state, where) {
|
|
|
131
169
|
for (const mission of state.missions) {
|
|
132
170
|
if (!isPlainObject(mission) || typeof mission.id !== 'string') return `mission entry without id${where}`
|
|
133
171
|
const id = mission.id
|
|
172
|
+
if (!isShapeKind(mission.kind)) {
|
|
173
|
+
return `mission ${id} has unknown kind ${String(mission.kind)}${where}`
|
|
174
|
+
}
|
|
134
175
|
if (!Array.isArray(mission.tasks)) return `mission ${id} has no tasks array${where}`
|
|
135
176
|
if (!Array.isArray(mission.deps)) return `mission ${id} has no deps array${where}`
|
|
136
177
|
if (mission.references !== undefined && !Array.isArray(mission.references)) {
|
|
@@ -142,6 +183,27 @@ function firstShapeMessage(state, where) {
|
|
|
142
183
|
if (mission.testRuns !== undefined && !Array.isArray(mission.testRuns)) {
|
|
143
184
|
return `mission ${id} has no testRuns array${where}`
|
|
144
185
|
}
|
|
186
|
+
if (isShapeKind(mission.kind)) {
|
|
187
|
+
const shape = shapeFor(mission.kind)
|
|
188
|
+
if (shape.fields.readCommit) {
|
|
189
|
+
if (typeof mission.readCommit !== 'string' || mission.readCommit.length === 0) {
|
|
190
|
+
return `mission ${id} is a survey and needs a readCommit${where}`
|
|
191
|
+
}
|
|
192
|
+
if (mission.readDirty !== undefined && !Array.isArray(mission.readDirty)) {
|
|
193
|
+
return `mission ${id} has an invalid readDirty fingerprint${where}`
|
|
194
|
+
}
|
|
195
|
+
if (mission.sandbox !== undefined && (typeof mission.sandbox !== 'string' || mission.sandbox.length === 0)) {
|
|
196
|
+
return `mission ${id} has an invalid sandbox path${where}`
|
|
197
|
+
}
|
|
198
|
+
if (mission.worktree !== undefined && mission.worktree !== null) {
|
|
199
|
+
return `mission ${id} is a survey and may not have a worktree record${where}`
|
|
200
|
+
}
|
|
201
|
+
} else if (mission.readCommit !== undefined || mission.readDirty !== undefined) {
|
|
202
|
+
return `mission ${id} is a build and may not have readCommit/readDirty${where}`
|
|
203
|
+
} else if (mission.sandbox !== undefined) {
|
|
204
|
+
return `mission ${id} is a build and may not have a sandbox${where}`
|
|
205
|
+
}
|
|
206
|
+
}
|
|
145
207
|
if (mission.worktree !== undefined && mission.worktree !== null && !isPlainObject(mission.worktree)) {
|
|
146
208
|
return `mission ${id} has an invalid worktree record${where}`
|
|
147
209
|
}
|
|
@@ -1,12 +1,10 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
// `stateShapeError` proves the structural contract; this module owns the policy
|
|
4
|
-
// the shape cannot express: unique ids, status/verdict vocabularies, the review
|
|
5
|
-
// exemption, roster/agent uniqueness, worktree records and the revision
|
|
6
|
-
// counter. Messages keep the historical `[tower:state-corrupt]` wording.
|
|
1
|
+
// State semantics that a structural schema cannot express: uniqueness,
|
|
2
|
+
// status vocabularies, review exemption and per-kind mission fields (#149).
|
|
7
3
|
// @ts-check
|
|
4
|
+
import { FINDING_STATUSES, MERGE_VERDICTS, REVIEW_STATUS_RE } from '../mission.js'
|
|
5
|
+
import { BATCH_STATUSES } from '../batch.js'
|
|
8
6
|
import { TowerToolError } from '../errors.js'
|
|
9
|
-
import {
|
|
7
|
+
import { isShapeKind, shapeFor } from '../shapes/index.js'
|
|
10
8
|
|
|
11
9
|
/** @typedef {import('../../types/index.js').TowerState} TowerState */
|
|
12
10
|
|
|
@@ -19,11 +17,52 @@ function fail(message) {
|
|
|
19
17
|
}
|
|
20
18
|
|
|
21
19
|
/**
|
|
22
|
-
*
|
|
23
|
-
*
|
|
20
|
+
* A build mission may carry a worktree/branch/merge projection; a survey may
|
|
21
|
+
* not, and carries the read fingerprint instead. The shape declares which
|
|
22
|
+
* dimensions its document owns, so this module never compares kinds.
|
|
23
|
+
* @param {Record<string, any>} mission
|
|
24
|
+
* @param {string} where
|
|
25
|
+
* @returns {void}
|
|
24
26
|
*/
|
|
25
|
-
function
|
|
26
|
-
|
|
27
|
+
function assertMissionShapeFields(mission, where) {
|
|
28
|
+
if (!isShapeKind(mission.kind)) {
|
|
29
|
+
fail(`mission ${mission.id} has unknown kind ${String(mission.kind)}${where}`)
|
|
30
|
+
}
|
|
31
|
+
const shape = shapeFor(mission.kind)
|
|
32
|
+
if (!shape.fields.worktree) {
|
|
33
|
+
if (mission.worktree !== undefined && mission.worktree !== null) {
|
|
34
|
+
fail(`mission ${mission.id} is a ${shape.kind} and may not have a worktree record${where}`)
|
|
35
|
+
}
|
|
36
|
+
for (const key of ['branch', 'baseCommit', 'mergedCommit', 'mergedAt']) {
|
|
37
|
+
if (mission[key] !== undefined && mission[key] !== null) {
|
|
38
|
+
fail(`mission ${mission.id} is a ${shape.kind} and may not have ${key}${where}`)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (shape.fields.worktree) {
|
|
43
|
+
if (mission.sandbox !== undefined) {
|
|
44
|
+
fail(`mission ${mission.id} is a ${shape.kind} and may not have a sandbox${where}`)
|
|
45
|
+
}
|
|
46
|
+
} else if (shape.fields.sandbox) {
|
|
47
|
+
// A detached survey sandbox is a recorded path, never a worktree record: it
|
|
48
|
+
// has no branch, base commit or merge projection.
|
|
49
|
+
if (mission.sandbox !== undefined && (typeof mission.sandbox !== 'string' || mission.sandbox.length === 0)) {
|
|
50
|
+
fail(`mission ${mission.id} has an invalid sandbox path${where}`)
|
|
51
|
+
}
|
|
52
|
+
} else if (mission.sandbox !== undefined) {
|
|
53
|
+
fail(`mission ${mission.id} is a ${shape.kind} and may not have a sandbox${where}`)
|
|
54
|
+
}
|
|
55
|
+
if (shape.fields.readCommit) {
|
|
56
|
+
if (typeof mission.readCommit !== 'string' || mission.readCommit.length === 0) {
|
|
57
|
+
fail(`mission ${mission.id} is a ${shape.kind} and needs a readCommit${where}`)
|
|
58
|
+
}
|
|
59
|
+
if (mission.readDirty === undefined) return
|
|
60
|
+
if (!Array.isArray(mission.readDirty) || mission.readDirty.some((entry) => typeof entry !== 'string')) {
|
|
61
|
+
fail(`mission ${mission.id} has an invalid readDirty fingerprint${where}`)
|
|
62
|
+
}
|
|
63
|
+
} else if (mission.readCommit !== undefined || mission.readDirty !== undefined) {
|
|
64
|
+
fail(`mission ${mission.id} is a ${shape.kind} and may not have readCommit/readDirty${where}`)
|
|
65
|
+
}
|
|
27
66
|
}
|
|
28
67
|
|
|
29
68
|
/**
|
|
@@ -42,6 +81,14 @@ function assertWorktreeRecord(mission, where) {
|
|
|
42
81
|
if (invalid) fail(`mission ${mission.id} has an invalid worktree record${where}`)
|
|
43
82
|
}
|
|
44
83
|
|
|
84
|
+
/**
|
|
85
|
+
* @param {unknown} value
|
|
86
|
+
* @returns {boolean}
|
|
87
|
+
*/
|
|
88
|
+
function isPlainObject(value) {
|
|
89
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
90
|
+
}
|
|
91
|
+
|
|
45
92
|
/**
|
|
46
93
|
* @param {unknown} state
|
|
47
94
|
* @param {string} where
|
|
@@ -56,8 +103,9 @@ export function assertStateSemantics(state, where) {
|
|
|
56
103
|
for (const mission of record.missions) {
|
|
57
104
|
if (missionIds.has(mission.id)) fail(`duplicate mission id ${mission.id}${where}`)
|
|
58
105
|
missionIds.add(mission.id)
|
|
59
|
-
|
|
60
|
-
|
|
106
|
+
const shape = shapeFor(mission.kind)
|
|
107
|
+
if (!shape.statuses.includes(mission.status)) {
|
|
108
|
+
fail(`mission ${mission.id} has unknown status ${mission.status} for a ${shape.kind} mission${where}`)
|
|
61
109
|
}
|
|
62
110
|
if (mission.reviewPolicy !== undefined && !['required-clean', 'none'].includes(mission.reviewPolicy)) {
|
|
63
111
|
fail(`mission ${mission.id} has unknown reviewPolicy ${mission.reviewPolicy}${where}`)
|
|
@@ -65,7 +113,24 @@ export function assertStateSemantics(state, where) {
|
|
|
65
113
|
if (mission.reviewPolicy === 'none' && !String(mission.reviewExemptionReason ?? '').trim()) {
|
|
66
114
|
fail(`mission ${mission.id} has reviewPolicy none without a reason${where}`)
|
|
67
115
|
}
|
|
116
|
+
assertMissionShapeFields(mission, where)
|
|
68
117
|
assertWorktreeRecord(mission, where)
|
|
118
|
+
// W3: `blocked` is derived from open blockers, never an independent stop
|
|
119
|
+
// state. A non-terminal mission is blocked exactly when it records a
|
|
120
|
+
// blocker, so the two can never disagree (a paused mission has no blockers;
|
|
121
|
+
// a blocker supersedes a pause). Terminal missions keep blockers as history.
|
|
122
|
+
const blockers = mission.blockers ?? []
|
|
123
|
+
if (!Array.isArray(blockers) || blockers.some((entry) => typeof entry !== 'string')) {
|
|
124
|
+
fail(`mission ${mission.id} has invalid blockers${where}`)
|
|
125
|
+
}
|
|
126
|
+
if (!shape.isTerminal(mission.status)) {
|
|
127
|
+
if (mission.status === 'blocked' && blockers.length === 0) {
|
|
128
|
+
fail(`mission ${mission.id} is blocked but records no blocker; blocked is derived from open blockers${where}`)
|
|
129
|
+
}
|
|
130
|
+
if (mission.status !== 'blocked' && blockers.length > 0) {
|
|
131
|
+
fail(`mission ${mission.id} has open blockers but is ${mission.status}; blocked is derived from open blockers${where}`)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
69
134
|
}
|
|
70
135
|
if (record.roster !== undefined) {
|
|
71
136
|
const names = new Set()
|
|
@@ -113,5 +178,20 @@ export function assertStateSemantics(state, where) {
|
|
|
113
178
|
}
|
|
114
179
|
}
|
|
115
180
|
}
|
|
181
|
+
// Batches have their own generic lifecycle (#149 W3), not a mission shape. The
|
|
182
|
+
// ledger may only carry canonical statuses; legacy synonyms are normalized by
|
|
183
|
+
// the v5 state migration, never accepted at rest.
|
|
184
|
+
if (record.batches !== undefined) {
|
|
185
|
+
const ids = new Set()
|
|
186
|
+
for (const batch of record.batches) {
|
|
187
|
+
if (!isPlainObject(batch)) fail(`invalid batch record${where}`)
|
|
188
|
+
if (ids.has(batch.id)) fail(`duplicate batch id ${batch.id}${where}`)
|
|
189
|
+
ids.add(batch.id)
|
|
190
|
+
const status = typeof batch.status === 'string' ? batch.status : ''
|
|
191
|
+
if (!BATCH_STATUSES.includes(status)) {
|
|
192
|
+
fail(`batch ${batch.id} has unknown status ${batch.status}${where}`)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
116
196
|
return /** @type {TowerState} */ (record)
|
|
117
197
|
}
|
package/domain/state.js
CHANGED
|
@@ -150,7 +150,10 @@ export function validateState(state, statePath) {
|
|
|
150
150
|
*
|
|
151
151
|
* @param {unknown} state
|
|
152
152
|
* @param {string} [statePath]
|
|
153
|
-
* @param {{
|
|
153
|
+
* @param {{
|
|
154
|
+
* migrations?: readonly StateMigration[],
|
|
155
|
+
* legacySurveyRead?: import('./state/migrations.js').LegacySurveyRead | null,
|
|
156
|
+
* }} [options]
|
|
154
157
|
* @returns {{ state: TowerState, from: number, to: number, migrated: MigrationStep[], changed: boolean }}
|
|
155
158
|
*/
|
|
156
159
|
export function prepareState(state, statePath, options = {}) {
|
|
@@ -169,6 +172,7 @@ export function prepareState(state, statePath, options = {}) {
|
|
|
169
172
|
statePath,
|
|
170
173
|
to: info.supportedVersion,
|
|
171
174
|
migrations: options.migrations,
|
|
175
|
+
legacySurveyRead: options.legacySurveyRead ?? null,
|
|
172
176
|
})
|
|
173
177
|
// Validation runs on the *target* document: a shape that was invalid under
|
|
174
178
|
// the old schema (a missing `tasks` array, say) may be valid after a backfill,
|
package/domain/summary.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// @ts-check
|
|
3
3
|
import { FINDING_STATUSES } from './mission.js'
|
|
4
4
|
import { completionReportBytes } from './missions/completion.js'
|
|
5
|
+
import { shapeFor } from './shapes/index.js'
|
|
5
6
|
|
|
6
7
|
/** @typedef {import('../types/index.js').Finding} Finding */
|
|
7
8
|
/** @typedef {import('../types/index.js').FindingSummary} FindingSummary */
|
|
@@ -31,7 +32,8 @@ function taskCounts(tasks) {
|
|
|
31
32
|
* @returns {MissionSummary}
|
|
32
33
|
*/
|
|
33
34
|
export function missionSummary(mission) {
|
|
34
|
-
|
|
35
|
+
const shape = shapeFor(mission.kind)
|
|
36
|
+
return /** @type {MissionSummary} */ ({
|
|
35
37
|
id: mission.id,
|
|
36
38
|
title: mission.title,
|
|
37
39
|
kind: mission.kind,
|
|
@@ -39,24 +41,16 @@ export function missionSummary(mission) {
|
|
|
39
41
|
scope: mission.scope,
|
|
40
42
|
deps: mission.deps,
|
|
41
43
|
owner: mission.owner ?? null,
|
|
42
|
-
|
|
43
|
-
? {
|
|
44
|
-
path: mission.worktree.path,
|
|
45
|
-
branch: mission.worktree.branch,
|
|
46
|
-
baseCommit: mission.worktree.baseCommit,
|
|
47
|
-
}
|
|
48
|
-
: null,
|
|
44
|
+
...shape.summary(mission),
|
|
49
45
|
blockers: mission.blockers ?? [],
|
|
50
46
|
tasks: taskCounts(mission.tasks ?? []),
|
|
51
47
|
scopeGeneration: mission.scopeGeneration ?? 1,
|
|
52
48
|
ownerGeneration: mission.ownerGeneration ?? 0,
|
|
53
49
|
pauseCause: mission.pauseCause ?? null,
|
|
54
50
|
reviewPolicy: mission.reviewPolicy ?? 'required-clean',
|
|
55
|
-
mergedCommit: mission.mergedCommit ?? null,
|
|
56
|
-
mergedAt: mission.mergedAt ?? null,
|
|
57
51
|
reportBytes: completionReportBytes(mission.completionReport),
|
|
58
52
|
updatedAt: mission.updatedAt,
|
|
59
|
-
}
|
|
53
|
+
})
|
|
60
54
|
}
|
|
61
55
|
|
|
62
56
|
/**
|
package/domain/vocabulary.js
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
PAUSE_CAUSES,
|
|
14
14
|
} from './mission.js'
|
|
15
15
|
import { ROSTER_INACTIVE_STATES } from './roster.js'
|
|
16
|
+
import { SHAPE_KINDS } from './shapes/index.js'
|
|
16
17
|
|
|
17
18
|
export {
|
|
18
19
|
FINDING_SEVERITIES,
|
|
@@ -24,44 +25,25 @@ export {
|
|
|
24
25
|
ROSTER_INACTIVE_STATES,
|
|
25
26
|
}
|
|
26
27
|
|
|
27
|
-
// Mission inputs accepted by tower_plan.
|
|
28
|
+
// Mission inputs accepted by tower_plan: the registry's kinds, in order.
|
|
28
29
|
/** @type {readonly string[]} */
|
|
29
|
-
export const MISSION_KINDS =
|
|
30
|
+
export const MISSION_KINDS = SHAPE_KINDS
|
|
30
31
|
/** @type {readonly string[]} */
|
|
31
32
|
export const REVIEW_POLICIES = ['required-clean', 'none']
|
|
32
33
|
|
|
33
34
|
// Roster actors (the caller resolved by actorFor) and createable agent roles.
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
/** @param {string | null | undefined} role */
|
|
47
|
-
export function isTowerRole(role) {
|
|
48
|
-
return role === ROLE.tower
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/** @param {string | null | undefined} role */
|
|
52
|
-
export function isWorkerRole(role) {
|
|
53
|
-
return role === ROLE.worker
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/** @param {string | null | undefined} role */
|
|
57
|
-
export function isReviewerRole(role) {
|
|
58
|
-
return role === ROLE.reviewer
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/** @param {string | null | undefined} role */
|
|
62
|
-
export function isUnknownRole(role) {
|
|
63
|
-
return role === ROLE.unknown
|
|
64
|
-
}
|
|
35
|
+
// One definition lives in domain/roles.js so roster/vocabulary have no cycle.
|
|
36
|
+
import { ROLE } from './roles.js'
|
|
37
|
+
export {
|
|
38
|
+
ACTOR_ROLES,
|
|
39
|
+
DEFAULT_ROLE,
|
|
40
|
+
ROLE,
|
|
41
|
+
ROLES,
|
|
42
|
+
isReviewerRole,
|
|
43
|
+
isTowerRole,
|
|
44
|
+
isUnknownRole,
|
|
45
|
+
isWorkerRole,
|
|
46
|
+
} from './roles.js'
|
|
65
47
|
|
|
66
48
|
// Inbox recipients: a roster name, the tower, or a broadcast.
|
|
67
49
|
export const RECIPIENT = Object.freeze({ tower: ROLE.tower, all: 'all' })
|
|
@@ -25,6 +25,7 @@ export const TOWER_COMPONENT_IDS = Object.freeze([
|
|
|
25
25
|
'tower-archive',
|
|
26
26
|
'tower-hmr',
|
|
27
27
|
'preset-tower-reviewer',
|
|
28
|
+
'tower-survey',
|
|
28
29
|
])
|
|
29
30
|
|
|
30
31
|
/** Tool ids each code component owns; the preset row owns no tool. */
|
|
@@ -38,6 +39,7 @@ export const COMPONENT_TOOLS = Object.freeze({
|
|
|
38
39
|
'tower-archive': Object.freeze([]),
|
|
39
40
|
'tower-hmr': Object.freeze(['tower_reload']),
|
|
40
41
|
'preset-tower-reviewer': Object.freeze([]),
|
|
42
|
+
'tower-survey': Object.freeze([]),
|
|
41
43
|
})
|
|
42
44
|
|
|
43
45
|
/** id -> number of live registrations (redelivery/HMR can overlap briefly). */
|
|
@@ -86,23 +88,37 @@ export function disabledReason(id) {
|
|
|
86
88
|
return `disabled by component ${id}`
|
|
87
89
|
}
|
|
88
90
|
|
|
89
|
-
/**
|
|
90
|
-
|
|
91
|
+
/**
|
|
92
|
+
* Availability of one declarative preset row from the optional agent-presets
|
|
93
|
+
* service. Preset rows (`preset-tower-reviewer`, `tower-survey`) mount an agent
|
|
94
|
+
* preset instead of a code entry point, so the process-local component registry
|
|
95
|
+
* cannot see them.
|
|
96
|
+
* @param {any} ctx
|
|
97
|
+
* @param {string} presetId the agent-preset id the row registers
|
|
98
|
+
* @returns {Promise<boolean | null>} null when the service cannot answer
|
|
99
|
+
*/
|
|
100
|
+
export async function presetAvailable(ctx, presetId) {
|
|
91
101
|
const service = ctx?.get?.('agentPresets')
|
|
92
102
|
if (!service || typeof service.list !== 'function') return null
|
|
93
103
|
try {
|
|
94
104
|
const presets = await service.list()
|
|
95
|
-
return Array.isArray(presets) && presets.some((entry) => String(entry?.id) ===
|
|
105
|
+
return Array.isArray(presets) && presets.some((entry) => String(entry?.id) === presetId)
|
|
96
106
|
} catch {
|
|
97
107
|
return null
|
|
98
108
|
}
|
|
99
109
|
}
|
|
100
110
|
|
|
111
|
+
/** Component rows whose switch is a declarative agent-preset registration. */
|
|
112
|
+
export const PRESET_ROW_COMPONENTS = Object.freeze({
|
|
113
|
+
'preset-tower-reviewer': 'tower-reviewer',
|
|
114
|
+
'tower-survey': 'tower-survey',
|
|
115
|
+
})
|
|
116
|
+
|
|
101
117
|
/**
|
|
102
|
-
* Report every optional component as enabled or disabled.
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
118
|
+
* Report every optional component as enabled or disabled. A preset row is not a
|
|
119
|
+
* code entry point: its availability is read from `agentPresets` and reported as
|
|
120
|
+
* unknown (omitted) when that service is absent, exactly as the doctor's preset
|
|
121
|
+
* check does.
|
|
106
122
|
*
|
|
107
123
|
* @param {any} ctx
|
|
108
124
|
* @returns {Promise<{ enabled: string[], disabled: Array<{ id: string, reason: string }> }>}
|
|
@@ -111,8 +127,8 @@ export async function componentReport(ctx) {
|
|
|
111
127
|
const enabled = []
|
|
112
128
|
const disabled = []
|
|
113
129
|
for (const id of TOWER_COMPONENT_IDS) {
|
|
114
|
-
if (id
|
|
115
|
-
const available = await
|
|
130
|
+
if (PRESET_ROW_COMPONENTS[id]) {
|
|
131
|
+
const available = await presetAvailable(ctx, PRESET_ROW_COMPONENTS[id])
|
|
116
132
|
if (available === false) disabled.push({ id, reason: disabledReason(id) })
|
|
117
133
|
else if (available === true) enabled.push(id)
|
|
118
134
|
continue
|
package/host/doctor.js
CHANGED
|
@@ -12,6 +12,7 @@ import { inspectStateVersion } from '../domain/state.js'
|
|
|
12
12
|
import { SCHEMA_VERSION } from '../domain/mission.js'
|
|
13
13
|
import { DEFAULT_MAX_REPORT_BYTES, completionReportBytes } from '../domain/missions/completion.js'
|
|
14
14
|
import { comparisonMode } from '../domain/scope.js'
|
|
15
|
+
import { hasTestReceipts } from '../domain/evidence.js'
|
|
15
16
|
import { configSurface } from '../domain/config/schema.js'
|
|
16
17
|
import { reviewPolicyView, reviewRequired } from '../domain/integration/review.js'
|
|
17
18
|
import { parseBaseline, surfaceLine, SURFACE_BASELINE_FILE } from '../domain/surface.js'
|
|
@@ -454,7 +455,7 @@ export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityF
|
|
|
454
455
|
: check('worktrees', 'pass', `all registered worktrees are valid${removedNote}`),
|
|
455
456
|
)
|
|
456
457
|
|
|
457
|
-
const testConfigured = state.missions?.some((mission) => mission
|
|
458
|
+
const testConfigured = state.missions?.some((mission) => hasTestReceipts(mission))
|
|
458
459
|
// #136: the `tower-tests` component row is the only switch (there is no
|
|
459
460
|
// tests.enabled), so this check reads the registry and nothing else.
|
|
460
461
|
const testsDisabled = components.disabled.some((entry) => entry.id === 'tower-tests')
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Tower detached-survey preset declaration (#148 W2): the sandbox preset's own
|
|
2
|
+
// Plugins-page label. This is the writable twin of `host/preset-survey.js`: the
|
|
3
|
+
// row config stays in `cordis.patch.yml` and this module forwards it to the
|
|
4
|
+
// injected `agentPresets` service, which owns mount/unmount. A Tower-owned
|
|
5
|
+
// specifier gives the row a distinct locale
|
|
6
|
+
// (`dsh-tower/preset-survey-sandbox/locale/<lang>.json`).
|
|
7
|
+
export const name = 'dsh-tower/preset-survey-sandbox'
|
|
8
|
+
|
|
9
|
+
export const inject = ['agentPresets']
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Register the detached-survey preset with the profile's preset registry.
|
|
13
|
+
* @param {{ agentPresets: { register: (config: any) => Promise<() => Promise<void> | void> } }} ctx
|
|
14
|
+
* @param {any} config the `cordis.patch.yml` row config (id/name/description/order/plugins)
|
|
15
|
+
* @returns {Promise<() => Promise<void> | void>} the registry's unregister disposer
|
|
16
|
+
*/
|
|
17
|
+
export async function apply(ctx, config) {
|
|
18
|
+
return ctx.agentPresets.register(config)
|
|
19
|
+
}
|