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
|
@@ -1,11 +1,197 @@
|
|
|
1
|
-
// Pure review-selection and exemption decisions
|
|
2
|
-
// projections. No repository
|
|
1
|
+
// Pure review-selection, review-policy resolution and exemption decisions
|
|
2
|
+
// shared by the merge/gate/read projections and the config layer. No repository
|
|
3
|
+
// I/O and no process state.
|
|
4
|
+
//
|
|
5
|
+
// REVIEW POLICY (#M39)
|
|
6
|
+
// --------------------
|
|
7
|
+
// `review.mode` is the single policy with exactly three independent values:
|
|
8
|
+
//
|
|
9
|
+
// off review is intentionally unavailable: recording a review is
|
|
10
|
+
// refused, `tower_spawn` refuses the reviewer role, and the merge
|
|
11
|
+
// gate ignores review blockers. Registration is profile-time: a
|
|
12
|
+
// profile-level off does not mount `tower_review`, while a
|
|
13
|
+
// repository-level off over a profile that mounted it refuses the
|
|
14
|
+
// submission with `review-disabled`;
|
|
15
|
+
// optional review is available but never blocks a merge (the default, and the
|
|
16
|
+
// 2.0.3+ behavior of `review.requireReview:false`);
|
|
17
|
+
// required a clean review bound to the branch tip is a merge blocker (the
|
|
18
|
+
// old `review.requireReview:true` behavior).
|
|
19
|
+
//
|
|
20
|
+
// `review.requireReview` stays accepted for one release as a normalized alias:
|
|
21
|
+
// true -> required, false -> optional. Precedence for one raw config object is
|
|
22
|
+
// deterministic:
|
|
23
|
+
//
|
|
24
|
+
// * only the alias set: the alias decides;
|
|
25
|
+
// * only the mode set: the mode decides;
|
|
26
|
+
// * both set: an alias of `true` next to any mode other than `required` is a
|
|
27
|
+
// contradiction and is rejected naming both keys. An alias of `false` stays
|
|
28
|
+
// compatible with `off` because both mean "not required by merge" and the
|
|
29
|
+
// mode is the more specific policy. That asymmetry is also what keeps the
|
|
30
|
+
// resolver idempotent: it materializes the alias from the mode it resolved,
|
|
31
|
+
// and `required` is the only mode that mirrors back to `true`.
|
|
3
32
|
// @ts-check
|
|
33
|
+
import { TowerToolError } from '../errors.js'
|
|
4
34
|
|
|
5
35
|
/** @typedef {import('../../types/index.js').Mission} Mission */
|
|
6
36
|
/** @typedef {import('../../types/index.js').Review} Review */
|
|
7
37
|
/** @typedef {import('../../types/index.js').TowerConfig} TowerConfig */
|
|
8
38
|
|
|
39
|
+
export const REVIEW_MODES = Object.freeze(['off', 'optional', 'required'])
|
|
40
|
+
export const DEFAULT_REVIEW_MODE = 'optional'
|
|
41
|
+
|
|
42
|
+
// Cordis materializes a `.volatile()` schema field as a live reference object
|
|
43
|
+
// (`{ get() }` plus a non-enumerable `Symbol.for('cosmokit.volatile.write')`).
|
|
44
|
+
// Settings forms hand the runtime these references, not plain values, so every
|
|
45
|
+
// consumer below unwraps them before validating or comparing. Keeping the
|
|
46
|
+
// helpers here (instead of a new config module) lets the schema import them
|
|
47
|
+
// without a cycle: review.js has no dependency on schema.js.
|
|
48
|
+
const VOLATILE_WRITE = Symbol.for('cosmokit.volatile.write')
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* True for the live reference Cordis/Schemastery creates for a volatile field.
|
|
52
|
+
* @param {unknown} value
|
|
53
|
+
* @returns {value is { get: () => unknown }}
|
|
54
|
+
*/
|
|
55
|
+
export function isVolatileRef(value) {
|
|
56
|
+
return (
|
|
57
|
+
value !== null &&
|
|
58
|
+
typeof value === 'object' &&
|
|
59
|
+
typeof /** @type {{ get?: unknown }} */ (value).get === 'function' &&
|
|
60
|
+
Object.getOwnPropertySymbols(value).includes(VOLATILE_WRITE)
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Read a volatile reference once; other values pass through unchanged.
|
|
66
|
+
* @template T
|
|
67
|
+
* @param {T | { get: () => T }} value
|
|
68
|
+
* @returns {T}
|
|
69
|
+
*/
|
|
70
|
+
export function unwrapVolatile(value) {
|
|
71
|
+
return /** @type {any} */ (isVolatileRef(value) ? value.get() : value)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Deep-copy a raw config object, replacing every volatile reference with its
|
|
76
|
+
* current value. Non-plain objects (for example a class instance) are returned
|
|
77
|
+
* as-is; Tower config is JSON-shaped plain data.
|
|
78
|
+
* @param {unknown} value
|
|
79
|
+
* @returns {any}
|
|
80
|
+
*/
|
|
81
|
+
export function materializeVolatile(value) {
|
|
82
|
+
const plain = unwrapVolatile(value)
|
|
83
|
+
if (Array.isArray(plain)) return plain.map((entry) => materializeVolatile(entry))
|
|
84
|
+
if (plain === null || typeof plain !== 'object') return plain
|
|
85
|
+
const proto = Object.getPrototypeOf(plain)
|
|
86
|
+
if (proto !== Object.prototype && proto !== null) return plain
|
|
87
|
+
/** @type {Record<string, unknown>} */
|
|
88
|
+
const copy = {}
|
|
89
|
+
for (const [key, entry] of Object.entries(plain)) copy[key] = materializeVolatile(entry)
|
|
90
|
+
return copy
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* @param {unknown} value
|
|
95
|
+
* @returns {value is 'off' | 'optional' | 'required'}
|
|
96
|
+
*/
|
|
97
|
+
export function isReviewMode(value) {
|
|
98
|
+
return typeof value === 'string' && REVIEW_MODES.includes(value)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The mode the deprecated boolean alias means.
|
|
103
|
+
* @param {boolean} requireReview
|
|
104
|
+
*/
|
|
105
|
+
export function legacyReviewMode(requireReview) {
|
|
106
|
+
return requireReview === true ? 'required' : DEFAULT_REVIEW_MODE
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The resolved mode for any config object the runtime may hold: a resolved
|
|
111
|
+
* config, a raw profile/repo layer, or a hand-built test fixture. An explicit
|
|
112
|
+
* `mode` always wins; otherwise the deprecated alias decides; otherwise the
|
|
113
|
+
* documented default applies.
|
|
114
|
+
* @param {{ review?: any } | null | undefined} [config]
|
|
115
|
+
* @returns {'off' | 'optional' | 'required'}
|
|
116
|
+
*/
|
|
117
|
+
export function reviewModeOf(config) {
|
|
118
|
+
const review = config?.review ?? {}
|
|
119
|
+
const mode = unwrapVolatile(review.mode)
|
|
120
|
+
if (isReviewMode(mode)) return mode
|
|
121
|
+
const alias = unwrapVolatile(review.requireReview)
|
|
122
|
+
if (typeof alias === 'boolean') return legacyReviewMode(alias)
|
|
123
|
+
return DEFAULT_REVIEW_MODE
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The review gate is enforced only in `required`. `off` and `optional` both
|
|
128
|
+
* ignore review at the merge gate; they differ in availability, not strictness.
|
|
129
|
+
* @param {{ review?: any } | null | undefined} [config]
|
|
130
|
+
*/
|
|
131
|
+
export function reviewRequired(config) {
|
|
132
|
+
return reviewModeOf(config) === 'required'
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* True when review is intentionally switched off (`tower_review` absent,
|
|
137
|
+
* reviewer spawn refused, merge gate ignores review).
|
|
138
|
+
* @param {{ review?: any } | null | undefined} [config]
|
|
139
|
+
*/
|
|
140
|
+
export function reviewUnavailable(config) {
|
|
141
|
+
return reviewModeOf(config) === 'off'
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Resolve one raw `review` object (a profile, a repository layer, or a single
|
|
146
|
+
* written config) into its mode. Rejects contradictory explicit values and an
|
|
147
|
+
* unknown mode with an actionable `config-invalid` error.
|
|
148
|
+
*
|
|
149
|
+
* @param {any} rawReview
|
|
150
|
+
* @returns {{ mode: 'off' | 'optional' | 'required', explicit: boolean, legacy: boolean }}
|
|
151
|
+
*/
|
|
152
|
+
export function resolveReviewPolicy(rawReview) {
|
|
153
|
+
const review = rawReview && typeof rawReview === 'object' && !Array.isArray(rawReview) ? rawReview : {}
|
|
154
|
+
const mode = unwrapVolatile(review.mode)
|
|
155
|
+
const alias = unwrapVolatile(review.requireReview)
|
|
156
|
+
if (mode !== undefined && !isReviewMode(mode)) {
|
|
157
|
+
throw new TowerToolError(
|
|
158
|
+
'config-invalid',
|
|
159
|
+
`review.mode must be one of ${REVIEW_MODES.join('|')}, got ${JSON.stringify(mode)}`,
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
if (alias !== undefined && typeof alias !== 'boolean') {
|
|
163
|
+
throw new TowerToolError(
|
|
164
|
+
'config-invalid',
|
|
165
|
+
'review.requireReview must be a boolean (deprecated alias: true -> required, false -> optional)',
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
if (mode !== undefined && alias === true && mode !== 'required') {
|
|
169
|
+
throw new TowerToolError(
|
|
170
|
+
'config-invalid',
|
|
171
|
+
`review.mode is ${JSON.stringify(mode)} but review.requireReview is true; set only review.mode (off|optional|required) — review.requireReview is a deprecated alias (true -> required, false -> optional) and may not contradict it`,
|
|
172
|
+
)
|
|
173
|
+
}
|
|
174
|
+
if (mode !== undefined) return { mode, explicit: true, legacy: alias !== undefined }
|
|
175
|
+
if (alias !== undefined) return { mode: legacyReviewMode(alias), explicit: true, legacy: true }
|
|
176
|
+
return { mode: DEFAULT_REVIEW_MODE, explicit: false, legacy: false }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The small read-only projection reported by `tower_status` and the doctor's
|
|
181
|
+
* `review` check. `detail` is the one sentence both surfaces share.
|
|
182
|
+
* @param {{ review?: any } | null | undefined} [config]
|
|
183
|
+
*/
|
|
184
|
+
export function reviewPolicyView(config) {
|
|
185
|
+
const mode = reviewModeOf(config)
|
|
186
|
+
const detail =
|
|
187
|
+
mode === 'off'
|
|
188
|
+
? 'review.mode:off — review is intentionally unavailable: recording a review is refused, reviewer spawn is refused, and the merge gate ignores review; a profile-level off does not register tower_review'
|
|
189
|
+
: mode === 'required'
|
|
190
|
+
? 'review.mode:required — a clean review bound to the branch tip is a merge blocker'
|
|
191
|
+
: 'review.mode:optional — review is available but never blocks a merge'
|
|
192
|
+
return { mode, available: mode !== 'off', required: mode === 'required', detail }
|
|
193
|
+
}
|
|
194
|
+
|
|
9
195
|
/**
|
|
10
196
|
* Reviews are branch-scoped; a missing branch has no reviews.
|
|
11
197
|
* @param {Review[] | null | undefined} reviews
|
|
@@ -26,27 +212,35 @@ export function latestReview(reviews) {
|
|
|
26
212
|
}
|
|
27
213
|
|
|
28
214
|
/**
|
|
29
|
-
* A mission
|
|
215
|
+
* A mission skips the review gate unless `review.mode` is `required`. In
|
|
216
|
+
* `required`, it may still skip with both the exemption gate and a reason.
|
|
217
|
+
* `off` and `optional` both bypass the gate; they differ only in whether the
|
|
218
|
+
* review tools/spawn role exist.
|
|
30
219
|
* @param {Mission} mission
|
|
31
220
|
* @param {TowerConfig} [config]
|
|
32
221
|
* @returns {boolean}
|
|
33
222
|
*/
|
|
34
223
|
export function isReviewExempt(mission, config = {}) {
|
|
224
|
+
if (!reviewRequired(config)) return true
|
|
35
225
|
return (
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
String(mission.reviewExemptionReason ?? '').trim().length > 0)
|
|
226
|
+
mission.reviewPolicy === 'none' &&
|
|
227
|
+
config.gates?.allowReviewExemption === true &&
|
|
228
|
+
String(mission.reviewExemptionReason ?? '').trim().length > 0
|
|
40
229
|
)
|
|
41
230
|
}
|
|
42
231
|
|
|
43
232
|
/**
|
|
44
233
|
* `reviewPolicy: none` is invalid when the reviewer gate does not allow it.
|
|
234
|
+
* Only `review.mode:required` can make the request invalid; with review off or
|
|
235
|
+
* optional no review blocker is produced at all.
|
|
45
236
|
* @param {Mission} mission
|
|
46
237
|
* @param {TowerConfig} [config]
|
|
47
238
|
* @returns {boolean}
|
|
48
239
|
*/
|
|
49
240
|
export function reviewExemptionInvalid(mission, config = {}) {
|
|
50
|
-
|
|
51
|
-
|
|
241
|
+
return (
|
|
242
|
+
reviewRequired(config) &&
|
|
243
|
+
mission.reviewPolicy === 'none' &&
|
|
244
|
+
config.gates?.allowReviewExemption !== true
|
|
245
|
+
)
|
|
52
246
|
}
|
package/domain/mission.js
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
// Pure mission vocabulary
|
|
1
|
+
// Pure mission vocabulary (#49). No fs, git, or config.
|
|
2
|
+
//
|
|
3
|
+
// Per-kind status policy lives in `./shapes/` (#149): this module keeps only the
|
|
4
|
+
// vocabulary every kind shares. `isTerminal(kind, status)` and
|
|
5
|
+
// `canTransition(kind, from, to)` are exported from `./shapes/index.js`.
|
|
2
6
|
// @ts-check
|
|
3
7
|
|
|
4
8
|
/** @typedef {import('../types/index.js').FindingSeverity} FindingSeverity */
|
|
@@ -7,9 +11,17 @@
|
|
|
7
11
|
/** @typedef {import('../types/index.js').MergeVerdict} MergeVerdict */
|
|
8
12
|
/** @typedef {import('../types/index.js').MissionStatus} MissionStatus */
|
|
9
13
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
14
|
+
import { SHAPE_KINDS, shapeFor } from './shapes/index.js'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The union of every shape's status vocabulary, in registry order. A build
|
|
18
|
+
* mission can be `merged`; a survey cannot, but the union names the word.
|
|
19
|
+
* @type {readonly MissionStatus[]}
|
|
20
|
+
*/
|
|
21
|
+
export const MISSION_STATUSES = /** @type {import('../types/index.js').MissionStatus[]} */ (
|
|
22
|
+
[...new Set(SHAPE_KINDS.flatMap((kind) => shapeFor(kind).statuses))]
|
|
23
|
+
)
|
|
24
|
+
export const SCHEMA_VERSION = 5
|
|
13
25
|
/** @type {readonly MergeVerdict[]} */
|
|
14
26
|
export const MERGE_VERDICTS = ['merge', 'fix-then-merge', 'hold']
|
|
15
27
|
// #115: the review status vocabulary is one runtime source. The validator
|
|
@@ -24,8 +36,6 @@ export const REVIEW_STATUSES = [
|
|
|
24
36
|
...REVIEW_STATUS_PRIORITIES.flatMap((priority) => REVIEW_STATUS_ITEMS.map((count) => `${priority}-${count}items`)),
|
|
25
37
|
]
|
|
26
38
|
export const REVIEW_STATUS_RE = new RegExp(`^(?:clean|(?:${REVIEW_STATUS_PRIORITIES.join('|')})-\\d+items)$`)
|
|
27
|
-
/** @type {ReadonlySet<string>} */
|
|
28
|
-
export const TERMINAL_STATUSES = new Set(['merged', 'abandoned'])
|
|
29
39
|
/** @type {readonly FindingType[]} */
|
|
30
40
|
export const FINDING_TYPES = ['bug', 'improve', 'vuln', 'idea']
|
|
31
41
|
/** @type {readonly FindingSeverity[]} */
|
|
@@ -41,33 +51,6 @@ export const FINDING_TRANSITIONS = {
|
|
|
41
51
|
}
|
|
42
52
|
/** @type {readonly string[]} */
|
|
43
53
|
export const PAUSE_CAUSES = ['user', 'failure', 'resource', 'dependency']
|
|
44
|
-
/** @type {Record<string, readonly string[] | undefined>} */
|
|
45
|
-
export const TRANSITIONS = {
|
|
46
|
-
planned: ['active', 'abandoned'],
|
|
47
|
-
active: ['completed', 'blocked', 'paused', 'abandoned'],
|
|
48
|
-
blocked: ['active', 'paused', 'completed', 'abandoned'],
|
|
49
|
-
paused: ['active', 'abandoned'],
|
|
50
|
-
completed: ['active', 'abandoned'],
|
|
51
|
-
merged: [],
|
|
52
|
-
abandoned: [],
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* @param {string} status
|
|
57
|
-
* @returns {boolean}
|
|
58
|
-
*/
|
|
59
|
-
export function isTerminal(status) {
|
|
60
|
-
return TERMINAL_STATUSES.has(status)
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* @param {string} from
|
|
65
|
-
* @param {string} to
|
|
66
|
-
* @returns {boolean}
|
|
67
|
-
*/
|
|
68
|
-
export function canTransition(from, to) {
|
|
69
|
-
return (TRANSITIONS[from] ?? []).includes(to)
|
|
70
|
-
}
|
|
71
54
|
|
|
72
55
|
/**
|
|
73
56
|
* @param {string | null | undefined} [value]
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
// @ts-check
|
|
6
6
|
import { TowerToolError } from '../errors.js'
|
|
7
7
|
import { completedEvent } from '../events.js'
|
|
8
|
+
import { shapeFor } from '../shapes/index.js'
|
|
9
|
+
import { isWorkerRole } from '../vocabulary.js'
|
|
8
10
|
|
|
9
11
|
/** @typedef {import('../../types/index.js').Mission} Mission */
|
|
10
12
|
/** @typedef {import('../../types/index.js').RosterEntry} RosterEntry */
|
|
@@ -113,7 +115,7 @@ export function assertCompletionRequest({ mission, roster, input, maxReportBytes
|
|
|
113
115
|
if (!worker || worker.missionId !== mission.id) {
|
|
114
116
|
fail(`agent ${input.agentId} is not assigned to mission ${mission.id}`)
|
|
115
117
|
}
|
|
116
|
-
if (worker.role
|
|
118
|
+
if (!isWorkerRole(worker.role)) fail(`agent ${worker.name} is a ${worker.role}, not a worker`)
|
|
117
119
|
if (['retired', 'failed', 'interrupted', 'missing', 'stale'].includes(worker.state)) {
|
|
118
120
|
fail(`agent ${worker.name} is ${worker.state}; a stopped worker cannot report completion`)
|
|
119
121
|
}
|
|
@@ -121,6 +123,12 @@ export function assertCompletionRequest({ mission, roster, input, maxReportBytes
|
|
|
121
123
|
fail(`agent ${worker.name} is not the current owner of mission ${mission.id} (owner: ${mission.owner})`)
|
|
122
124
|
}
|
|
123
125
|
if (mission.status === 'completed') fail(`mission ${mission.id} is already completed`)
|
|
126
|
+
// `blocked` is derived from open blockers, so name them instead of a generic
|
|
127
|
+
// status error: clearing them is the one recovery path (#149 W3).
|
|
128
|
+
const blockers = mission.blockers ?? []
|
|
129
|
+
if (blockers.length > 0) {
|
|
130
|
+
fail(`mission ${mission.id} has unresolved blockers: ${blockers.join('; ')}; clear the blockers to resume it`)
|
|
131
|
+
}
|
|
124
132
|
if (!['active', 'planned'].includes(mission.status)) {
|
|
125
133
|
fail(`mission ${mission.id} is ${mission.status}; it cannot be completed from that state`)
|
|
126
134
|
}
|
|
@@ -136,11 +144,6 @@ export function assertCompletionRequest({ mission, roster, input, maxReportBytes
|
|
|
136
144
|
.join('; ')}`,
|
|
137
145
|
)
|
|
138
146
|
}
|
|
139
|
-
const blockers = mission.blockers ?? []
|
|
140
|
-
if (blockers.length > 0) {
|
|
141
|
-
fail(`mission ${mission.id} has unresolved blockers: ${blockers.join('; ')}`)
|
|
142
|
-
}
|
|
143
|
-
if (!mission.worktree) fail(`mission ${mission.id} has no worktree`)
|
|
144
147
|
return worker
|
|
145
148
|
}
|
|
146
149
|
|
|
@@ -167,13 +170,48 @@ export function completionReport({ report, worker, checks, now, maxReportBytes }
|
|
|
167
170
|
return { report: text, reportBytes: assertReportSize(text, maxReportBytes), by: worker.name, at: now, checks }
|
|
168
171
|
}
|
|
169
172
|
|
|
173
|
+
/**
|
|
174
|
+
* Normalize the optional survey result (#148 D6). Only a shape that accepts a
|
|
175
|
+
* result may carry one; the value stays small so the state document does not
|
|
176
|
+
* become an evidence dump.
|
|
177
|
+
* @param {unknown} result
|
|
178
|
+
* @returns {any}
|
|
179
|
+
*/
|
|
180
|
+
export function normalizeResult(result) {
|
|
181
|
+
if (result === undefined || result === null) return null
|
|
182
|
+
if (!isPlainObject(result)) fail('result must be an object')
|
|
183
|
+
const value = /** @type {Record<string, any>} */ (result)
|
|
184
|
+
/** @type {Record<string, any>} */
|
|
185
|
+
const normalized = {}
|
|
186
|
+
for (const key of ['paths', 'symbols', 'plan']) {
|
|
187
|
+
if (value[key] === undefined) continue
|
|
188
|
+
if (!Array.isArray(value[key])) fail(`result.${key} must be an array of strings`)
|
|
189
|
+
if (value[key].length > 200) fail(`result.${key} supports at most 200 entries`)
|
|
190
|
+
normalized[key] = value[key].map((entry) => {
|
|
191
|
+
if (typeof entry !== 'string' || entry.trim().length === 0) fail(`result.${key} entries must be non-empty strings`)
|
|
192
|
+
return entry.trim()
|
|
193
|
+
})
|
|
194
|
+
}
|
|
195
|
+
if (value.notes !== undefined) {
|
|
196
|
+
if (typeof value.notes !== 'string') fail('result.notes must be a string')
|
|
197
|
+
normalized.notes = value.notes.trim().slice(0, 4000)
|
|
198
|
+
}
|
|
199
|
+
return normalized
|
|
200
|
+
}
|
|
201
|
+
|
|
170
202
|
/**
|
|
171
203
|
* Apply the completion transition to the mission draft and describe the domain
|
|
172
204
|
* fact. Persistence stays in app/checkCompletion.js.
|
|
173
|
-
* @param {{ mission: Mission, report: any, now: number }} params
|
|
205
|
+
* @param {{ mission: Mission, report: any, now: number, result?: any }} params
|
|
174
206
|
* @returns {{ state: Mission, events: Array<{ type: string, missionId: string }> }}
|
|
175
207
|
*/
|
|
176
|
-
export function applyCompletion({ mission, report, now }) {
|
|
208
|
+
export function applyCompletion({ mission, report, now, result }) {
|
|
209
|
+
const shape = shapeFor(mission.kind)
|
|
210
|
+
const normalized = normalizeResult(result)
|
|
211
|
+
if (normalized !== null) {
|
|
212
|
+
if (!shape.acceptsResult) fail(`mission ${mission.id} is a ${shape.kind}; a completion result is survey evidence only`)
|
|
213
|
+
mission.result = normalized
|
|
214
|
+
}
|
|
177
215
|
mission.status = 'completed'
|
|
178
216
|
mission.completionReport = report
|
|
179
217
|
mission.updatedAt = now
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// @ts-check
|
|
6
6
|
import { TowerToolError } from '../errors.js'
|
|
7
7
|
import { FINDING_SEVERITIES, FINDING_STATUSES, FINDING_TRANSITIONS, FINDING_TYPES } from '../mission.js'
|
|
8
|
+
import { isReviewerRole } from '../roles.js'
|
|
8
9
|
import { countFindings, findingSummary } from '../summary.js'
|
|
9
10
|
|
|
10
11
|
/** @typedef {import('../../types/index.js').Finding} Finding */
|
|
@@ -158,7 +159,7 @@ export function readFindingsView(findings, input) {
|
|
|
158
159
|
* @returns {{ noteAdded: boolean }}
|
|
159
160
|
*/
|
|
160
161
|
export function applyFindingUpdate({ finding, input, actor, now }) {
|
|
161
|
-
if (actor.role
|
|
162
|
+
if (isReviewerRole(actor.role)) {
|
|
162
163
|
fail('reviewers may list and read findings, but only the tower or a worker may change finding status', 'not-authorized')
|
|
163
164
|
}
|
|
164
165
|
let noteAdded = false
|
|
@@ -6,14 +6,14 @@
|
|
|
6
6
|
// @ts-check
|
|
7
7
|
import { TowerToolError } from '../errors.js'
|
|
8
8
|
import { abandonedEvent, completedEvent } from '../events.js'
|
|
9
|
-
import {
|
|
9
|
+
import { pauseCauseError } from '../mission.js'
|
|
10
10
|
import { normalizeScope, scopesOverlap } from '../scope.js'
|
|
11
|
+
import { shapeFor } from '../shapes/index.js'
|
|
12
|
+
import { isReviewerRole, isWorkerRole } from '../vocabulary.js'
|
|
11
13
|
|
|
12
14
|
/** @typedef {import('../../types/index.js').Mission} Mission */
|
|
13
15
|
/** @typedef {import('../../types/index.js').TowerState} TowerState */
|
|
14
16
|
|
|
15
|
-
const TERMINAL = TERMINAL_STATUSES
|
|
16
|
-
|
|
17
17
|
/**
|
|
18
18
|
* @param {string} message
|
|
19
19
|
* @param {string} [code]
|
|
@@ -42,8 +42,8 @@ export function normalizePauseCause(value) {
|
|
|
42
42
|
* @returns {void}
|
|
43
43
|
*/
|
|
44
44
|
export function assertMissionActor(mission, actor, input) {
|
|
45
|
-
const workerRequest = actor.role
|
|
46
|
-
if (actor.role
|
|
45
|
+
const workerRequest = isWorkerRole(actor.role)
|
|
46
|
+
if (isReviewerRole(actor.role)) {
|
|
47
47
|
fail('only the tower or a worker may update a mission; reviewers submit tower_review', 'not-authorized')
|
|
48
48
|
}
|
|
49
49
|
if (workerRequest && mission.owner && mission.owner !== actor.name) {
|
|
@@ -61,6 +61,48 @@ export function assertMissionActor(mission, actor, input) {
|
|
|
61
61
|
}
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
/**
|
|
65
|
+
* Mark a mission blocked from a machine-derived failure (a merge or rebase
|
|
66
|
+
* conflict). `blocked` is derived from open blockers, so the blocker is recorded
|
|
67
|
+
* with the status in one write path; no caller assigns the status directly
|
|
68
|
+
* (#149 W3). Repeating the same failure does not duplicate the blocker.
|
|
69
|
+
* @param {Mission} mission
|
|
70
|
+
* @param {string} blocker
|
|
71
|
+
* @param {number} [now]
|
|
72
|
+
* @returns {Mission}
|
|
73
|
+
*/
|
|
74
|
+
export function markBlocked(mission, blocker, now = Date.now()) {
|
|
75
|
+
if (typeof blocker !== 'string' || blocker.trim().length === 0) fail('a blocker must be a non-empty string')
|
|
76
|
+
const text = blocker.trim()
|
|
77
|
+
const blockers = /** @type {string[]} */ (mission.blockers)
|
|
78
|
+
if (!blockers.includes(text)) blockers.push(text)
|
|
79
|
+
mission.status = 'blocked'
|
|
80
|
+
// A blocker wins over a pause: the human cause is superseded by the failure.
|
|
81
|
+
delete mission.pauseCause
|
|
82
|
+
mission.updatedAt = now
|
|
83
|
+
return mission
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Resolve blockers that a successful machine operation has cleared (a rebase
|
|
88
|
+
* conflict that the retried rebase resolved). Blockers that do not match stay,
|
|
89
|
+
* and the mission stays blocked while any remain — recovery is always "the
|
|
90
|
+
* recorded blockers are gone", never a status assignment.
|
|
91
|
+
* @param {Mission} mission
|
|
92
|
+
* @param {(blocker: string) => boolean} matches
|
|
93
|
+
* @param {number} [now]
|
|
94
|
+
* @returns {boolean} whether any blocker was removed
|
|
95
|
+
*/
|
|
96
|
+
export function resolveBlockers(mission, matches, now = Date.now()) {
|
|
97
|
+
const blockers = /** @type {string[]} */ (mission.blockers ?? [])
|
|
98
|
+
const kept = blockers.filter((blocker) => !matches(blocker))
|
|
99
|
+
if (kept.length === blockers.length) return false
|
|
100
|
+
mission.blockers = kept
|
|
101
|
+
if (kept.length === 0 && mission.status === 'blocked') mission.status = 'active'
|
|
102
|
+
mission.updatedAt = now
|
|
103
|
+
return true
|
|
104
|
+
}
|
|
105
|
+
|
|
64
106
|
/**
|
|
65
107
|
* Apply one validated `tower_mission` patch to the mission draft and return
|
|
66
108
|
* the human-readable change list used by the activity log plus the domain
|
|
@@ -82,34 +124,80 @@ export function applyMissionUpdate({ mission, state, input, config }) {
|
|
|
82
124
|
/** @type {Array<{ type: string, missionId: string }>} */
|
|
83
125
|
const events = []
|
|
84
126
|
|
|
127
|
+
const shape = shapeFor(mission.kind)
|
|
128
|
+
// W3: `blocked` is derived state, `paused` is a deliberate stop. Clearing the
|
|
129
|
+
// blockers is what resumes a blocked mission, so it is applied before any
|
|
130
|
+
// status patch: `{clear_blockers:true,status:"active"}` and
|
|
131
|
+
// `{clear_blockers:true,status:"completed"}` both describe one coherent step.
|
|
132
|
+
if (input.clear_blockers === true) {
|
|
133
|
+
if ((mission.blockers ?? []).length > 0) {
|
|
134
|
+
mission.blockers = []
|
|
135
|
+
changes.push('blockers cleared')
|
|
136
|
+
}
|
|
137
|
+
if (mission.status === 'blocked') {
|
|
138
|
+
mission.status = 'active'
|
|
139
|
+
changes.push('status=active (blocked until the blockers were cleared)')
|
|
140
|
+
}
|
|
141
|
+
}
|
|
85
142
|
if (input.status !== undefined) {
|
|
86
|
-
if (typeof input.status !== 'string' || !
|
|
87
|
-
fail(`unknown mission status "${input.status}"`)
|
|
143
|
+
if (typeof input.status !== 'string' || !shape.statuses.includes(input.status)) {
|
|
144
|
+
fail(`unknown mission status "${input.status}" for a ${shape.kind} mission`)
|
|
88
145
|
}
|
|
89
146
|
if (input.status === 'merged') {
|
|
90
147
|
fail('merged is recorded only by tower_merge, not by a generic mission patch', 'invalid-transition')
|
|
91
148
|
}
|
|
92
|
-
if (
|
|
93
|
-
fail(
|
|
149
|
+
if (input.status === 'blocked') {
|
|
150
|
+
fail(
|
|
151
|
+
'blocked is derived from open blockers: record blocker:"..." instead of setting status "blocked"',
|
|
152
|
+
'invalid-transition',
|
|
153
|
+
)
|
|
94
154
|
}
|
|
95
|
-
|
|
96
|
-
|
|
155
|
+
const openBlockers = mission.blockers ?? []
|
|
156
|
+
if (mission.status === 'blocked' && input.status !== 'abandoned') {
|
|
157
|
+
// A blocked mission is resumed by clearing its blockers, never by a
|
|
158
|
+
// status patch that skips the blocker record (#149 W3).
|
|
159
|
+
fail(
|
|
160
|
+
`mission ${mission.id} is blocked (${openBlockers.join('; ')}); clear_blockers resumes it — it cannot move to ${input.status} directly`,
|
|
161
|
+
'invalid-transition',
|
|
162
|
+
)
|
|
97
163
|
}
|
|
98
|
-
if (
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
164
|
+
if (openBlockers.length > 0 && ['active', 'completed', 'paused'].includes(input.status)) {
|
|
165
|
+
fail(
|
|
166
|
+
`mission ${mission.id} has open blockers (${openBlockers.join('; ')}); clear_blockers resumes it — do not move it to ${input.status}`,
|
|
167
|
+
'invalid-transition',
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
// A status patch is a real move: the target must differ and be a legal edge.
|
|
171
|
+
// `paused` is re-entered only from a different status (a pause cause is
|
|
172
|
+
// meaningful when the pause begins, not as an in-place metadata edit).
|
|
173
|
+
// `{clear_blockers:true,status:"active"}` is one coherent resume step: the
|
|
174
|
+
// clear already moved the mission, so the redundant patch is not a second
|
|
175
|
+
// transition.
|
|
176
|
+
const redundantResume =
|
|
177
|
+
input.clear_blockers === true && input.status === 'active' && mission.status === 'active'
|
|
178
|
+
if (!redundantResume) {
|
|
179
|
+
if (!shape.canTransition(mission.status, input.status)) {
|
|
180
|
+
fail(`mission ${mission.id} cannot move from ${mission.status} to ${input.status}`, 'invalid-transition')
|
|
104
181
|
}
|
|
105
|
-
if (
|
|
106
|
-
mission.
|
|
182
|
+
if (input.status === 'paused') {
|
|
183
|
+
mission.pauseCause = normalizePauseCause(input.pause_cause)
|
|
107
184
|
}
|
|
185
|
+
if (mission.status === 'paused' && input.status === 'active') {
|
|
186
|
+
const requireAuthorization = config.mode.requireResumeAuthorization === true
|
|
187
|
+
if (requireAuthorization && (mission.pauseCause ?? 'user') === 'user') {
|
|
188
|
+
if (typeof input.authorization !== 'string' || input.authorization.trim().length === 0) {
|
|
189
|
+
fail('resuming a user-paused mission requires a non-empty authorization reference')
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (typeof input.authorization === 'string' && input.authorization.trim()) {
|
|
193
|
+
mission.authorizationRef = input.authorization.trim()
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
mission.status = input.status
|
|
197
|
+
changes.push(`status=${input.status}`)
|
|
198
|
+
if (input.status === 'completed') events.push(completedEvent(mission))
|
|
199
|
+
if (input.status === 'abandoned') events.push(abandonedEvent(mission))
|
|
108
200
|
}
|
|
109
|
-
mission.status = input.status
|
|
110
|
-
changes.push(`status=${input.status}`)
|
|
111
|
-
if (input.status === 'completed') events.push(completedEvent(mission))
|
|
112
|
-
if (input.status === 'abandoned') events.push(abandonedEvent(mission))
|
|
113
201
|
}
|
|
114
202
|
|
|
115
203
|
if (input.task_done !== undefined) {
|
|
@@ -144,20 +232,23 @@ export function applyMissionUpdate({ mission, state, input, config }) {
|
|
|
144
232
|
if (typeof input.blocker !== 'string' || input.blocker.trim().length === 0) {
|
|
145
233
|
fail('blocker must be a non-empty string')
|
|
146
234
|
}
|
|
147
|
-
if (
|
|
235
|
+
if (shape.isTerminal(mission.status)) {
|
|
148
236
|
fail(`mission ${mission.id} is ${mission.status}; a blocker cannot reopen a terminal mission`)
|
|
149
237
|
}
|
|
150
238
|
const blockers = /** @type {string[]} */ (mission.blockers)
|
|
151
239
|
blockers.push(input.blocker.trim())
|
|
152
|
-
|
|
240
|
+
// W3: a blocker always wins over a pause; the paused cause is superseded and
|
|
241
|
+
// the mission is blocked until the blockers are cleared.
|
|
242
|
+
if (mission.status !== 'blocked') {
|
|
243
|
+
if (!shape.canTransition(mission.status, 'blocked')) {
|
|
244
|
+
fail(`mission ${mission.id} cannot move from ${mission.status} to blocked`, 'invalid-transition')
|
|
245
|
+
}
|
|
246
|
+
if (mission.status === 'paused') delete mission.pauseCause
|
|
247
|
+
mission.status = 'blocked'
|
|
248
|
+
}
|
|
153
249
|
changes.push('blocker added')
|
|
154
250
|
}
|
|
155
251
|
|
|
156
|
-
if (input.clear_blockers === true) {
|
|
157
|
-
mission.blockers = []
|
|
158
|
-
changes.push('blockers cleared')
|
|
159
|
-
}
|
|
160
|
-
|
|
161
252
|
if (input.note !== undefined) {
|
|
162
253
|
if (typeof input.note !== 'string' || input.note.trim().length === 0) {
|
|
163
254
|
fail('note must be a non-empty string')
|
|
@@ -168,16 +259,21 @@ export function applyMissionUpdate({ mission, state, input, config }) {
|
|
|
168
259
|
}
|
|
169
260
|
|
|
170
261
|
if (input.scope !== undefined) {
|
|
262
|
+
if (!shape.reach.ownsPaths) {
|
|
263
|
+
fail(`mission ${mission.id} is a ${shape.kind}: it reads a commit and does not own scope`)
|
|
264
|
+
}
|
|
171
265
|
if (!['active', 'completed'].includes(mission.status)) {
|
|
172
266
|
fail(`scope can only be widened for active or completed missions, not ${mission.status}`)
|
|
173
267
|
}
|
|
174
|
-
const scope = normalizeScope(input.scope
|
|
175
|
-
const others = state.missions.filter(
|
|
176
|
-
(entry
|
|
177
|
-
|
|
268
|
+
const scope = normalizeScope(input.scope)
|
|
269
|
+
const others = state.missions.filter((entry) => {
|
|
270
|
+
if (entry.id === mission.id) return false
|
|
271
|
+
const other = shapeFor(entry.kind)
|
|
272
|
+
return other.reach.ownsPaths && !other.isTerminal(entry.status)
|
|
273
|
+
})
|
|
178
274
|
const caseInsensitive = state.caseInsensitive === true
|
|
179
275
|
for (const other of others) {
|
|
180
|
-
if (
|
|
276
|
+
if (scopesOverlap(scope, other.scope, { caseInsensitive })) {
|
|
181
277
|
fail(`widened scope overlaps mission ${other.id}; retire it or add an explicit ordering`)
|
|
182
278
|
}
|
|
183
279
|
}
|