dsh-tower 1.2.0 → 2.0.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 +96 -0
- package/CONTRIBUTING.md +36 -0
- package/README.en.md +60 -23
- package/README.md +54 -21
- package/SECURITY.md +20 -0
- package/cordis.patch.yml +32 -0
- package/docs/README.md +17 -0
- package/docs/architecture/overview.md +70 -0
- package/docs/examples/first-mission.md +58 -0
- package/docs/faq.md +74 -0
- package/docs/guide/hot-reload.md +189 -0
- package/docs/guide/install-upgrade-migration.md +59 -0
- package/docs/guide/quickstart.md +97 -0
- package/docs/operations/runbook.md +80 -0
- package/docs/operations/security.md +62 -0
- package/docs/reference/blockers.md +58 -0
- package/docs/reference/config.md +114 -0
- package/docs/reference/doctor.md +31 -0
- package/docs/reference/errors.md +50 -0
- package/docs/reference/events.md +45 -0
- package/docs/reference/operations.md +52 -0
- package/docs/reference/testing.md +61 -0
- package/docs/reference/tools.md +75 -0
- package/docs/zh/README.md +58 -0
- package/docs/zh/faq.md +72 -0
- package/docs/zh/guide/hot-reload.md +178 -0
- package/docs/zh/guide/install-upgrade-migration.md +57 -0
- package/docs/zh/guide/quickstart.md +96 -0
- package/docs/zh/operations/security.md +42 -0
- package/docs/zh/reference/blockers.md +62 -0
- package/docs/zh/reference/config.md +113 -0
- package/docs/zh/reference/doctor.md +30 -0
- package/docs/zh/reference/errors.md +52 -0
- package/docs/zh/reference/events.md +46 -0
- package/docs/zh/reference/operations.md +48 -0
- package/docs/zh/reference/testing.md +60 -0
- package/docs/zh/reference/tools.md +75 -0
- package/index.js +293 -49
- package/lib/batch.js +578 -0
- package/lib/config.js +381 -0
- package/lib/doctor.js +165 -0
- package/lib/errors.js +111 -0
- package/lib/hmr.js +196 -0
- package/lib/leases.js +317 -0
- package/lib/legacy.js +413 -0
- package/lib/modeStore.js +47 -16
- package/lib/store.js +24 -1
- package/lib/tests.js +628 -0
- package/lib/tools.js +1594 -160
- package/lib/tower.js +1025 -101
- package/lib/workspaceLock.js +117 -0
- package/lib/worktreeOwnership.js +166 -0
- package/package.json +21 -4
- package/reviewer.js +40 -0
- package/scripts/check-docs.mjs +135 -0
- package/scripts/enable-dev-hmr.mjs +347 -0
- package/skills/tower-recovery.md +40 -0
- package/skills/tower-review.md +16 -0
- package/skills/tower-worker-protocol.md +15 -0
- package/skills.js +63 -0
- package/worker.js +7 -4
package/lib/batch.js
ADDED
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
// Batch admission packets (2.1): pure create/close/summarize validation.
|
|
2
|
+
//
|
|
3
|
+
// This module performs no filesystem, git, or subprocess I/O. The tower tool
|
|
4
|
+
// layer reads the worktree, receipt files, and test results, then passes what
|
|
5
|
+
// it observed into `validateClose`. Receipt hashes may be supplied by the
|
|
6
|
+
// caller in `input.receipts`/`input.receiptHashes` (already computed) or bound
|
|
7
|
+
// to a passing check as `{ receiptRef, receiptHash }`; both forms are checked
|
|
8
|
+
// against the batch's frozen `receiptPaths`.
|
|
9
|
+
//
|
|
10
|
+
// Expected state/batch shapes (everything else is ignored):
|
|
11
|
+
// state.missions[] -> { id, status, mergedCommit?, completionReport?, testRuns? }
|
|
12
|
+
// state.batches[] -> { id, status: 'open'|'closed'|'abandoned', missionIds,
|
|
13
|
+
// exclusions, requiredChecks, receiptPaths, receiptHashes? }
|
|
14
|
+
// state.legacySnapshots[] -> { hash, missions: [{ id, status }], pausedIds? }
|
|
15
|
+
// A batch record is the normalized validateCreate result plus `id`, `status`,
|
|
16
|
+
// `createdAt`, and the optional `receiptHashes` expected-hash map.
|
|
17
|
+
//
|
|
18
|
+
// 2.1 rule: validateCreate throws `BatchError` (code `batch-invalid`) for
|
|
19
|
+
// invalid input; validateClose returns blockers instead of throwing for
|
|
20
|
+
// verification/lifecycle failures and only throws for structurally malformed
|
|
21
|
+
// arguments. I/O-bound rules from the Codex batch contract (dirty worktree,
|
|
22
|
+
// branch identity, real HEAD comparison) stay in the tool layer; pass
|
|
23
|
+
// `input.treeTip` to bind recorded check `treeCommit` values to the current
|
|
24
|
+
// HEAD.
|
|
25
|
+
import path from 'node:path'
|
|
26
|
+
|
|
27
|
+
const SHA256_PATTERN = /^[a-f0-9]{64}$/i
|
|
28
|
+
const MAX_MISSIONS = 50
|
|
29
|
+
const MAX_CHECKS = 50
|
|
30
|
+
const MAX_RECEIPTS = 200
|
|
31
|
+
const MAX_EXCLUSIONS = 50
|
|
32
|
+
const PASS_STATUSES = new Set(['passed', 'pass', 'ok', 'success', 'succeeded', 'done', 'green'])
|
|
33
|
+
const FAIL_STATUSES = new Set(['failed', 'fail', 'error', 'errored', 'timeout', 'timed-out', 'blocked'])
|
|
34
|
+
const OPEN_BATCH_STATUSES = new Set(['open', 'created', 'active', 'pending'])
|
|
35
|
+
const CLOSED_BATCH_STATUSES = new Set(['closed', 'abandoned'])
|
|
36
|
+
|
|
37
|
+
/** Stable batch error; add `batch-invalid` to ERROR_CODES to map it directly. */
|
|
38
|
+
export class BatchError extends Error {
|
|
39
|
+
constructor(message) {
|
|
40
|
+
super(message)
|
|
41
|
+
this.name = 'BatchError'
|
|
42
|
+
this.code = 'batch-invalid'
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function fail(message) {
|
|
47
|
+
throw new BatchError(message)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function isPlainObject(value) {
|
|
51
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function nonEmptyString(value) {
|
|
55
|
+
return typeof value === 'string' && value.trim().length > 0
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function readInput(input, camel, snake) {
|
|
59
|
+
if (!isPlainObject(input)) return undefined
|
|
60
|
+
if (input[camel] !== undefined) return input[camel]
|
|
61
|
+
return input[snake]
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function trimmedName(value, where) {
|
|
65
|
+
if (!nonEmptyString(value)) fail(`${where} must be a non-empty string`)
|
|
66
|
+
return value.trim()
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function normalizeIdList(value, where, { max = MAX_MISSIONS } = {}) {
|
|
70
|
+
if (!Array.isArray(value)) fail(`${where} must be a non-empty array`)
|
|
71
|
+
if (value.length === 0) fail(`${where} must be a non-empty array`)
|
|
72
|
+
if (value.length > max) fail(`${where} must have at most ${max} entries`)
|
|
73
|
+
const ids = []
|
|
74
|
+
for (const [index, raw] of value.entries()) {
|
|
75
|
+
const id = trimmedName(raw, `${where}[${index}]`)
|
|
76
|
+
if (ids.includes(id)) fail(`${where}[${index}] must not duplicate "${id}"`)
|
|
77
|
+
ids.push(id)
|
|
78
|
+
}
|
|
79
|
+
return ids
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function normalizeNameList(value, where, { max = MAX_CHECKS, allowEmpty = false } = {}) {
|
|
83
|
+
if (!Array.isArray(value)) fail(`${where} must be an array`)
|
|
84
|
+
if (!allowEmpty && value.length === 0) fail(`${where} must be a non-empty array`)
|
|
85
|
+
if (value.length > max) fail(`${where} must have at most ${max} entries`)
|
|
86
|
+
const names = []
|
|
87
|
+
for (const [index, raw] of value.entries()) {
|
|
88
|
+
const name = trimmedName(raw, `${where}[${index}]`)
|
|
89
|
+
if (names.includes(name)) fail(`${where}[${index}] must not duplicate "${name}"`)
|
|
90
|
+
names.push(name)
|
|
91
|
+
}
|
|
92
|
+
return names
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Normalize one receipt path to `a/b/c`; rejects absolute, parent, glob, and directory paths. */
|
|
96
|
+
function normalizeReceiptPath(value, where) {
|
|
97
|
+
if (!nonEmptyString(value)) fail(`${where} must be a non-empty string`)
|
|
98
|
+
const raw = value.trim()
|
|
99
|
+
if (raw.includes('\0')) fail(`${where} must not contain a NUL byte`)
|
|
100
|
+
if (/[*?]/.test(raw) || /[[\]{}]/.test(raw)) fail(`${where} must not contain wildcard characters`)
|
|
101
|
+
if (path.isAbsolute(raw) || /^[A-Za-z]:[\\/]/.test(raw) || /^[\\/]{2}/.test(raw)) {
|
|
102
|
+
fail(`${where} must be a relative path`)
|
|
103
|
+
}
|
|
104
|
+
if (/[\\/]$/.test(raw)) fail(`${where} must name a file, not a directory`)
|
|
105
|
+
const segments = []
|
|
106
|
+
for (const segment of raw.split(/[\\/]+/)) {
|
|
107
|
+
if (segment.length === 0 || segment === '.') continue
|
|
108
|
+
if (segment === '..') fail(`${where} must not contain ".."`)
|
|
109
|
+
segments.push(segment)
|
|
110
|
+
}
|
|
111
|
+
if (segments.length === 0) fail(`${where} must be a relative file path`)
|
|
112
|
+
return segments.join('/')
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function normalizeReceiptPathOrNull(value, where) {
|
|
116
|
+
try {
|
|
117
|
+
return normalizeReceiptPath(value, where)
|
|
118
|
+
} catch {
|
|
119
|
+
return null
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Legacy snapshots imported by lib/legacy.js; only paused missions may be excluded. */
|
|
124
|
+
function pausedLegacyIds(state) {
|
|
125
|
+
const ids = new Set()
|
|
126
|
+
const collectMission = (mission) => {
|
|
127
|
+
if (isPlainObject(mission) && nonEmptyString(mission.id) && mission.status === 'paused') {
|
|
128
|
+
ids.add(mission.id.trim())
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const collectList = (value) => {
|
|
132
|
+
if (Array.isArray(value)) value.forEach(collectMission)
|
|
133
|
+
else if (isPlainObject(value)) Object.values(value).forEach(collectMission)
|
|
134
|
+
}
|
|
135
|
+
const snapshots = Array.isArray(state.legacySnapshots) ? state.legacySnapshots : []
|
|
136
|
+
for (const snapshot of snapshots) {
|
|
137
|
+
if (!isPlainObject(snapshot)) continue
|
|
138
|
+
collectList(snapshot.missions)
|
|
139
|
+
if (isPlainObject(snapshot.snapshot)) collectList(snapshot.snapshot.missions)
|
|
140
|
+
if (isPlainObject(snapshot.payload)) collectList(snapshot.payload.missions)
|
|
141
|
+
// lib/legacy.js stores imported snapshots with `pausedMissionIds`; older,
|
|
142
|
+
// hand-written fixtures may use `pausedIds`.
|
|
143
|
+
for (const key of ['pausedIds', 'pausedMissionIds']) {
|
|
144
|
+
if (Array.isArray(snapshot[key])) {
|
|
145
|
+
for (const id of snapshot[key]) if (nonEmptyString(id)) ids.add(id.trim())
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return ids
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function missionInOpenBatch(state, missionId) {
|
|
153
|
+
const batches = Array.isArray(state.batches) ? state.batches : []
|
|
154
|
+
for (const batch of batches) {
|
|
155
|
+
if (!isPlainObject(batch)) continue
|
|
156
|
+
const status = batch.status ?? 'open'
|
|
157
|
+
if (!OPEN_BATCH_STATUSES.has(status)) continue
|
|
158
|
+
if (Array.isArray(batch.missionIds) && batch.missionIds.includes(missionId)) return batch
|
|
159
|
+
}
|
|
160
|
+
return null
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function checkNames(entry) {
|
|
164
|
+
if (!isPlainObject(entry)) return []
|
|
165
|
+
for (const candidate of [entry.name, entry.id, entry.check]) {
|
|
166
|
+
if (nonEmptyString(candidate)) return [candidate.trim()]
|
|
167
|
+
}
|
|
168
|
+
// A nameless runDiscovery receipt stored in mission.testRuns is the built-in
|
|
169
|
+
// test tool evidence. It answers to `tests` (the tool-layer auto check name)
|
|
170
|
+
// and to `tower_test` (the synthetic name used by earlier integrations).
|
|
171
|
+
if (Array.isArray(entry.commands) && Array.isArray(entry.tests)) return ['tests', 'tower_test']
|
|
172
|
+
return []
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function checkOutcome(entry) {
|
|
176
|
+
if (!isPlainObject(entry)) return null
|
|
177
|
+
if (typeof entry.status === 'string') {
|
|
178
|
+
const status = entry.status.trim().toLowerCase()
|
|
179
|
+
if (PASS_STATUSES.has(status)) return true
|
|
180
|
+
if (FAIL_STATUSES.has(status)) return false
|
|
181
|
+
}
|
|
182
|
+
if (typeof entry.ok === 'boolean') return entry.ok
|
|
183
|
+
if (typeof entry.pass === 'boolean') return entry.pass
|
|
184
|
+
if (typeof entry.success === 'boolean') return entry.success
|
|
185
|
+
if (entry.exitCode !== undefined && entry.exitCode !== null) {
|
|
186
|
+
const exitCode = typeof entry.exitCode === 'number' ? entry.exitCode : Number(entry.exitCode)
|
|
187
|
+
if (Number.isFinite(exitCode)) return exitCode === 0
|
|
188
|
+
}
|
|
189
|
+
if (entry.error !== undefined && entry.error !== null && entry.error !== '') return false
|
|
190
|
+
return null
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Find the most authoritative recorded outcome for `name` on one mission:
|
|
195
|
+
* completion report first, then testRuns newest-first, then caller-supplied
|
|
196
|
+
* `extraChecks` (optionally scoped by `missionId`).
|
|
197
|
+
*/
|
|
198
|
+
function findCheck(mission, name, extraChecks, missionId) {
|
|
199
|
+
const sources = []
|
|
200
|
+
const reportChecks = Array.isArray(mission?.completionReport?.checks) ? mission.completionReport.checks : []
|
|
201
|
+
for (let index = reportChecks.length - 1; index >= 0; index -= 1) {
|
|
202
|
+
sources.push({ source: 'completionReport', entry: reportChecks[index] })
|
|
203
|
+
}
|
|
204
|
+
const testRuns = Array.isArray(mission?.testRuns) ? mission.testRuns : []
|
|
205
|
+
for (let index = testRuns.length - 1; index >= 0; index -= 1) {
|
|
206
|
+
const run = testRuns[index]
|
|
207
|
+
if (!isPlainObject(run)) continue
|
|
208
|
+
if (Array.isArray(run.checks) && run.checks.length > 0) {
|
|
209
|
+
for (let inner = run.checks.length - 1; inner >= 0; inner -= 1) {
|
|
210
|
+
sources.push({ source: 'testRuns', entry: run.checks[inner] })
|
|
211
|
+
}
|
|
212
|
+
} else {
|
|
213
|
+
sources.push({ source: 'testRuns', entry: run })
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
for (let index = extraChecks.length - 1; index >= 0; index -= 1) {
|
|
217
|
+
const entry = extraChecks[index]
|
|
218
|
+
if (!isPlainObject(entry)) continue
|
|
219
|
+
const scope = entry.missionId ?? entry.mission_id
|
|
220
|
+
if (scope !== undefined && scope !== null && String(scope) !== missionId) continue
|
|
221
|
+
sources.push({ source: 'input', entry })
|
|
222
|
+
}
|
|
223
|
+
for (const source of sources) {
|
|
224
|
+
if (!checkNames(source.entry).includes(name)) continue
|
|
225
|
+
const outcome = checkOutcome(source.entry)
|
|
226
|
+
if (outcome === null) continue
|
|
227
|
+
return { outcome, source: source.source, entry: source.entry }
|
|
228
|
+
}
|
|
229
|
+
return null
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function blocker(message, code = 'batch-invalid') {
|
|
233
|
+
return { code, message }
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Validate `tower_batch create` input and return the normalized, frozen batch
|
|
238
|
+
* payload `{ missionIds, exclusions, requiredChecks, receiptPaths }`.
|
|
239
|
+
* Throws `BatchError` (`batch-invalid`) for unknown/duplicate missions,
|
|
240
|
+
* non-paused exclusions, empty requiredChecks, or unsafe receipt paths.
|
|
241
|
+
* Accepts camelCase keys and snake_case aliases (`mission_ids`, ...).
|
|
242
|
+
*/
|
|
243
|
+
export function validateCreate(state, input) {
|
|
244
|
+
if (!isPlainObject(state)) fail('state must be an object')
|
|
245
|
+
if (!isPlainObject(input)) fail('input must be an object')
|
|
246
|
+
const missions = Array.isArray(state.missions) ? state.missions : fail('state.missions must be an array')
|
|
247
|
+
const byId = new Map()
|
|
248
|
+
for (const mission of missions) {
|
|
249
|
+
if (isPlainObject(mission) && nonEmptyString(mission.id)) byId.set(mission.id, mission)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const missionIds = normalizeIdList(readInput(input, 'missionIds', 'mission_ids'), 'missionIds')
|
|
253
|
+
for (const missionId of missionIds) {
|
|
254
|
+
if (!byId.has(missionId)) fail(`missionIds must reference known missions: "${missionId}" is unknown`)
|
|
255
|
+
const openBatch = missionInOpenBatch(state, missionId)
|
|
256
|
+
if (openBatch) fail(`mission ${missionId} already belongs to open batch ${openBatch.id ?? '(unnamed)'}`)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const exclusionsRaw = readInput(input, 'exclusions', 'exclusions') ?? []
|
|
260
|
+
if (!Array.isArray(exclusionsRaw)) fail('exclusions must be an array')
|
|
261
|
+
if (exclusionsRaw.length > MAX_EXCLUSIONS) fail(`exclusions must have at most ${MAX_EXCLUSIONS} entries`)
|
|
262
|
+
const pausedLegacy = pausedLegacyIds(state)
|
|
263
|
+
const exclusions = []
|
|
264
|
+
for (const [index, raw] of exclusionsRaw.entries()) {
|
|
265
|
+
const exclusion = trimmedName(raw, `exclusions[${index}]`)
|
|
266
|
+
if (exclusions.includes(exclusion)) fail(`exclusions[${index}] must not duplicate "${exclusion}"`)
|
|
267
|
+
if (missionIds.includes(exclusion)) fail(`exclusions[${index}] must not overlap missionIds ("${exclusion}")`)
|
|
268
|
+
if (!pausedLegacy.has(exclusion)) {
|
|
269
|
+
fail(`exclusions[${index}] "${exclusion}" is not a paused legacy mission id`)
|
|
270
|
+
}
|
|
271
|
+
exclusions.push(exclusion)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const requiredRaw = readInput(input, 'requiredChecks', 'required_checks')
|
|
275
|
+
const requiredChecks = normalizeNameList(requiredRaw === undefined ? ['check'] : requiredRaw, 'requiredChecks')
|
|
276
|
+
|
|
277
|
+
const receiptPathsRaw = readInput(input, 'receiptPaths', 'receipt_paths') ?? []
|
|
278
|
+
if (!Array.isArray(receiptPathsRaw)) fail('receiptPaths must be an array')
|
|
279
|
+
if (receiptPathsRaw.length > MAX_RECEIPTS) fail(`receiptPaths must have at most ${MAX_RECEIPTS} entries`)
|
|
280
|
+
const receiptPaths = []
|
|
281
|
+
for (const [index, raw] of receiptPathsRaw.entries()) {
|
|
282
|
+
const receiptPath = normalizeReceiptPath(raw, `receiptPaths[${index}]`)
|
|
283
|
+
if (receiptPaths.includes(receiptPath)) fail(`receiptPaths[${index}] must not duplicate "${receiptPath}"`)
|
|
284
|
+
receiptPaths.push(receiptPath)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return { missionIds, exclusions, requiredChecks, receiptPaths }
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function collectReceiptHashes(options) {
|
|
291
|
+
const hashes = new Map()
|
|
292
|
+
const problems = []
|
|
293
|
+
const record = (rawPath, rawValue, where) => {
|
|
294
|
+
const path = normalizeReceiptPathOrNull(rawPath, where)
|
|
295
|
+
if (!path) {
|
|
296
|
+
problems.push(blocker(`${where} is not a safe relative receipt path`))
|
|
297
|
+
return
|
|
298
|
+
}
|
|
299
|
+
const value = isPlainObject(rawValue) ? (rawValue.sha256 ?? rawValue.hash ?? rawValue.receiptHash) : rawValue
|
|
300
|
+
if (!nonEmptyString(value) || !SHA256_PATTERN.test(value.trim())) {
|
|
301
|
+
problems.push(blocker(`${where} hash must be a sha256 hex digest`))
|
|
302
|
+
return
|
|
303
|
+
}
|
|
304
|
+
const normalized = value.trim().toLowerCase()
|
|
305
|
+
if (hashes.has(path) && hashes.get(path) !== normalized) {
|
|
306
|
+
problems.push(blocker(`receipt "${path}" was reported with conflicting hashes`))
|
|
307
|
+
return
|
|
308
|
+
}
|
|
309
|
+
hashes.set(path, normalized)
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const receiptHashes = readInput(options, 'receiptHashes', 'receipt_hashes')
|
|
313
|
+
if (receiptHashes !== undefined) {
|
|
314
|
+
if (!isPlainObject(receiptHashes)) problems.push(blocker('receiptHashes must be an object of path -> sha256'))
|
|
315
|
+
else {
|
|
316
|
+
for (const [rawPath, rawValue] of Object.entries(receiptHashes)) {
|
|
317
|
+
record(rawPath, rawValue, `receiptHashes["${rawPath}"]`)
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
const receipts = readInput(options, 'receipts', 'receipts')
|
|
322
|
+
if (receipts !== undefined) {
|
|
323
|
+
if (!Array.isArray(receipts)) problems.push(blocker('receipts must be an array'))
|
|
324
|
+
else {
|
|
325
|
+
for (const [index, entry] of receipts.entries()) {
|
|
326
|
+
if (!isPlainObject(entry)) {
|
|
327
|
+
problems.push(blocker(`receipts[${index}] must be an object`))
|
|
328
|
+
continue
|
|
329
|
+
}
|
|
330
|
+
const rawPath = entry.path ?? entry.receiptPath ?? entry.receipt_path
|
|
331
|
+
record(rawPath, entry, `receipts[${index}]`)
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return { hashes, problems }
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function expectedReceiptHashes(batch, options) {
|
|
339
|
+
const expected = new Map()
|
|
340
|
+
const add = (value) => {
|
|
341
|
+
if (!isPlainObject(value)) return
|
|
342
|
+
for (const [rawPath, rawHash] of Object.entries(value)) {
|
|
343
|
+
const path = normalizeReceiptPathOrNull(rawPath, `receiptHashes["${rawPath}"]`)
|
|
344
|
+
if (!path || !nonEmptyString(rawHash) || !SHA256_PATTERN.test(rawHash.trim())) continue
|
|
345
|
+
expected.set(path, rawHash.trim().toLowerCase())
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
add(batch.receiptHashes)
|
|
349
|
+
add(readInput(options, 'expectedReceiptHashes', 'expected_receipt_hashes'))
|
|
350
|
+
return expected
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function bindCheckEvidence(entry, missionId, name, receiptPaths, observedHashes) {
|
|
354
|
+
const problems = []
|
|
355
|
+
const record = {}
|
|
356
|
+
const rawRef = entry.receiptRef ?? entry.receipt_ref
|
|
357
|
+
if (rawRef !== undefined && rawRef !== null) {
|
|
358
|
+
const receiptRef = normalizeReceiptPathOrNull(rawRef, `mission ${missionId} check "${name}" receiptRef`)
|
|
359
|
+
if (!receiptRef) {
|
|
360
|
+
problems.push(blocker(`mission ${missionId} check "${name}" has an unsafe receiptRef`))
|
|
361
|
+
} else if (!receiptPaths.includes(receiptRef)) {
|
|
362
|
+
problems.push(blocker(`mission ${missionId} check "${name}" receiptRef "${receiptRef}" is outside the frozen receiptPaths`))
|
|
363
|
+
} else {
|
|
364
|
+
record.receiptRef = receiptRef
|
|
365
|
+
const rawHash = entry.receiptHash ?? entry.receipt_hash
|
|
366
|
+
if (rawHash !== undefined && rawHash !== null) {
|
|
367
|
+
if (!nonEmptyString(rawHash) || !SHA256_PATTERN.test(rawHash.trim())) {
|
|
368
|
+
problems.push(blocker(`mission ${missionId} check "${name}" receiptHash must be a sha256 hex digest`))
|
|
369
|
+
} else {
|
|
370
|
+
const hash = rawHash.trim().toLowerCase()
|
|
371
|
+
record.receiptHash = hash
|
|
372
|
+
if (observedHashes.has(receiptRef) && observedHashes.get(receiptRef) !== hash) {
|
|
373
|
+
problems.push(blocker(`mission ${missionId} check "${name}" receiptHash does not match the reported receipt "${receiptRef}"`))
|
|
374
|
+
} else {
|
|
375
|
+
observedHashes.set(receiptRef, hash)
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
return { problems, record }
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function checkTreeTip(entry, missionId, name, treeTip) {
|
|
385
|
+
if (!treeTip) return []
|
|
386
|
+
const committed = entry.treeCommit ?? entry.tree_commit ?? entry.treeTip ?? entry.tree_tip
|
|
387
|
+
if (!nonEmptyString(committed)) return []
|
|
388
|
+
if (committed.trim() === treeTip) return []
|
|
389
|
+
return [blocker(`mission ${missionId} check "${name}" was recorded at tree ${committed.trim()}, current HEAD is ${treeTip}`)]
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Validate `tower_batch close`: every batch member must be completed or
|
|
394
|
+
* abandoned; every required check must have passed evidence for every
|
|
395
|
+
* non-abandoned member (completion report checks, mission testRuns, or
|
|
396
|
+
* caller-supplied `input.checks`); frozen receipt paths must be reported with
|
|
397
|
+
* a sha256 digest that matches any recorded expected hash. Returns
|
|
398
|
+
* `{ ok, blockers, checks }`; blockers use the 2.1 codes `tests-missing` and
|
|
399
|
+
* `tests-failed` for check shortfalls and `batch-invalid` for structural
|
|
400
|
+
* problems. Caller-supplied `input.treeTip` also binds check `treeCommit`s.
|
|
401
|
+
*/
|
|
402
|
+
export function validateClose(state, batch, input = {}) {
|
|
403
|
+
if (!isPlainObject(state)) fail('state must be an object')
|
|
404
|
+
if (!isPlainObject(batch)) fail('batch must be an object')
|
|
405
|
+
const options = input === undefined || input === null ? {} : input
|
|
406
|
+
if (!isPlainObject(options)) fail('input must be an object')
|
|
407
|
+
|
|
408
|
+
const missionIds = normalizeIdList(batch.missionIds, 'batch.missionIds')
|
|
409
|
+
const requiredChecks = normalizeNameList(batch.requiredChecks, 'batch.requiredChecks')
|
|
410
|
+
const exclusions = batch.exclusions ?? []
|
|
411
|
+
if (!Array.isArray(exclusions)) fail('batch.exclusions must be an array')
|
|
412
|
+
const receiptPathsRaw = batch.receiptPaths ?? []
|
|
413
|
+
if (!Array.isArray(receiptPathsRaw)) fail('batch.receiptPaths must be an array')
|
|
414
|
+
const receiptPaths = receiptPathsRaw.map((value, index) => normalizeReceiptPath(value, `batch.receiptPaths[${index}]`))
|
|
415
|
+
|
|
416
|
+
const blockers = []
|
|
417
|
+
const status = batch.status ?? 'open'
|
|
418
|
+
if (status === 'closed') blockers.push(blocker(`batch ${batch.id ?? ''} is already closed`.trim()))
|
|
419
|
+
else if (status === 'abandoned') blockers.push(blocker(`batch ${batch.id ?? ''} is abandoned`.trim()))
|
|
420
|
+
|
|
421
|
+
const missions = Array.isArray(state.missions) ? state.missions : fail('state.missions must be an array')
|
|
422
|
+
const byId = new Map()
|
|
423
|
+
for (const mission of missions) if (isPlainObject(mission) && nonEmptyString(mission.id)) byId.set(mission.id, mission)
|
|
424
|
+
|
|
425
|
+
const extraChecks = Array.isArray(options.checks) ? options.checks : []
|
|
426
|
+
const treeTip = nonEmptyString(options.treeTip)
|
|
427
|
+
? options.treeTip.trim()
|
|
428
|
+
: nonEmptyString(options.tree_tip)
|
|
429
|
+
? options.tree_tip.trim()
|
|
430
|
+
: null
|
|
431
|
+
const observed = collectReceiptHashes(options)
|
|
432
|
+
blockers.push(...observed.problems)
|
|
433
|
+
|
|
434
|
+
const results = []
|
|
435
|
+
for (const missionId of missionIds) {
|
|
436
|
+
const mission = byId.get(missionId)
|
|
437
|
+
if (!mission) {
|
|
438
|
+
blockers.push(blocker(`batch mission "${missionId}" does not exist`))
|
|
439
|
+
continue
|
|
440
|
+
}
|
|
441
|
+
if (mission.status !== 'completed' && mission.status !== 'abandoned') {
|
|
442
|
+
blockers.push(blocker(`mission ${missionId} is ${mission.status}, not completed or abandoned`))
|
|
443
|
+
continue
|
|
444
|
+
}
|
|
445
|
+
for (const name of requiredChecks) {
|
|
446
|
+
if (mission.status === 'abandoned') {
|
|
447
|
+
results.push({ missionId, name, ok: true, outcome: 'skipped', source: 'abandoned' })
|
|
448
|
+
continue
|
|
449
|
+
}
|
|
450
|
+
const found = findCheck(mission, name, extraChecks, missionId)
|
|
451
|
+
if (!found) {
|
|
452
|
+
results.push({ missionId, name, ok: false, outcome: 'missing', source: null })
|
|
453
|
+
blockers.push(blocker(`mission ${missionId} has no recorded check "${name}"`, 'tests-missing'))
|
|
454
|
+
continue
|
|
455
|
+
}
|
|
456
|
+
if (found.outcome !== true) {
|
|
457
|
+
results.push({ missionId, name, ok: false, outcome: 'failed', source: found.source })
|
|
458
|
+
blockers.push(blocker(`mission ${missionId} check "${name}" did not pass`, 'tests-failed'))
|
|
459
|
+
continue
|
|
460
|
+
}
|
|
461
|
+
const evidence = bindCheckEvidence(found.entry, missionId, name, receiptPaths, observed.hashes)
|
|
462
|
+
blockers.push(...evidence.problems)
|
|
463
|
+
blockers.push(...checkTreeTip(found.entry, missionId, name, treeTip))
|
|
464
|
+
results.push({
|
|
465
|
+
missionId,
|
|
466
|
+
name,
|
|
467
|
+
ok: evidence.problems.length === 0,
|
|
468
|
+
outcome: 'passed',
|
|
469
|
+
source: found.source,
|
|
470
|
+
...evidence.record,
|
|
471
|
+
})
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const expected = expectedReceiptHashes(batch, options)
|
|
476
|
+
for (const receiptPath of receiptPaths) {
|
|
477
|
+
const hash = observed.hashes.get(receiptPath)
|
|
478
|
+
if (!hash) {
|
|
479
|
+
blockers.push(blocker(`receipt "${receiptPath}" was not reported`))
|
|
480
|
+
continue
|
|
481
|
+
}
|
|
482
|
+
const expectedHash = expected.get(receiptPath)
|
|
483
|
+
if (expectedHash && expectedHash !== hash) {
|
|
484
|
+
blockers.push(blocker(`receipt "${receiptPath}" sha256 does not match the recorded expected hash`))
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
for (const receiptPath of observed.hashes.keys()) {
|
|
488
|
+
if (!receiptPaths.includes(receiptPath)) {
|
|
489
|
+
blockers.push(blocker(`receipt "${receiptPath}" is outside the frozen receiptPaths`))
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
return { ok: blockers.length === 0, blockers, checks: results }
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Read-only batch status payload (no I/O): member states, per-check evidence
|
|
498
|
+
* across members, and the checks that still block a close. Receipt coverage is
|
|
499
|
+
* intentionally not evaluated here because hashes are only known at close.
|
|
500
|
+
*/
|
|
501
|
+
export function summarize(state, batch) {
|
|
502
|
+
if (!isPlainObject(state)) fail('state must be an object')
|
|
503
|
+
if (!isPlainObject(batch)) fail('batch must be an object')
|
|
504
|
+
const missionIds = normalizeIdList(batch.missionIds, 'batch.missionIds')
|
|
505
|
+
const requiredChecks = normalizeNameList(batch.requiredChecks, 'batch.requiredChecks')
|
|
506
|
+
const receiptPathsRaw = batch.receiptPaths ?? []
|
|
507
|
+
if (!Array.isArray(receiptPathsRaw)) fail('batch.receiptPaths must be an array')
|
|
508
|
+
const receiptPaths = receiptPathsRaw.map((value, index) => normalizeReceiptPath(value, `batch.receiptPaths[${index}]`))
|
|
509
|
+
const exclusions = Array.isArray(batch.exclusions) ? batch.exclusions : []
|
|
510
|
+
const status = batch.status ?? 'open'
|
|
511
|
+
|
|
512
|
+
const missions = Array.isArray(state.missions) ? state.missions : fail('state.missions must be an array')
|
|
513
|
+
const byId = new Map()
|
|
514
|
+
for (const mission of missions) if (isPlainObject(mission) && nonEmptyString(mission.id)) byId.set(mission.id, mission)
|
|
515
|
+
|
|
516
|
+
const blockers = []
|
|
517
|
+
if (CLOSED_BATCH_STATUSES.has(status)) blockers.push(blocker(`batch ${batch.id ?? ''} is already ${status}`.trim()))
|
|
518
|
+
const members = []
|
|
519
|
+
const checks = []
|
|
520
|
+
for (const missionId of missionIds) {
|
|
521
|
+
const mission = byId.get(missionId)
|
|
522
|
+
members.push({
|
|
523
|
+
id: missionId,
|
|
524
|
+
found: Boolean(mission),
|
|
525
|
+
status: mission?.status ?? null,
|
|
526
|
+
mergedCommit: mission?.mergedCommit ?? null,
|
|
527
|
+
abandoned: mission?.status === 'abandoned',
|
|
528
|
+
})
|
|
529
|
+
if (!mission) {
|
|
530
|
+
blockers.push(blocker(`batch mission "${missionId}" does not exist`))
|
|
531
|
+
continue
|
|
532
|
+
}
|
|
533
|
+
if (mission.status !== 'completed' && mission.status !== 'abandoned') {
|
|
534
|
+
blockers.push(blocker(`mission ${missionId} is ${mission.status}, not completed or abandoned`))
|
|
535
|
+
continue
|
|
536
|
+
}
|
|
537
|
+
for (const name of requiredChecks) {
|
|
538
|
+
if (mission.status === 'abandoned') {
|
|
539
|
+
checks.push({ missionId, name, ok: true, outcome: 'skipped', source: 'abandoned' })
|
|
540
|
+
continue
|
|
541
|
+
}
|
|
542
|
+
const found = findCheck(mission, name, [], missionId)
|
|
543
|
+
if (!found) {
|
|
544
|
+
checks.push({ missionId, name, ok: false, outcome: 'missing', source: null })
|
|
545
|
+
continue
|
|
546
|
+
}
|
|
547
|
+
checks.push({
|
|
548
|
+
missionId,
|
|
549
|
+
name,
|
|
550
|
+
ok: found.outcome === true,
|
|
551
|
+
outcome: found.outcome === true ? 'passed' : 'failed',
|
|
552
|
+
source: found.source,
|
|
553
|
+
})
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
const pendingChecks = [...new Set(checks.filter((check) => check.outcome === 'missing').map((check) => check.name))]
|
|
558
|
+
const failedChecks = [...new Set(checks.filter((check) => check.outcome === 'failed').map((check) => check.name))]
|
|
559
|
+
return {
|
|
560
|
+
batchId: batch.id ?? null,
|
|
561
|
+
status,
|
|
562
|
+
closed: status === 'closed',
|
|
563
|
+
abandoned: status === 'abandoned',
|
|
564
|
+
createdAt: batch.createdAt ?? null,
|
|
565
|
+
closedAt: batch.closedAt ?? null,
|
|
566
|
+
missionIds,
|
|
567
|
+
exclusions,
|
|
568
|
+
requiredChecks,
|
|
569
|
+
receiptPaths,
|
|
570
|
+
receiptCount: receiptPaths.length,
|
|
571
|
+
members,
|
|
572
|
+
checks,
|
|
573
|
+
pendingChecks,
|
|
574
|
+
failedChecks,
|
|
575
|
+
blockers,
|
|
576
|
+
ok: blockers.length === 0 && pendingChecks.length === 0 && failedChecks.length === 0,
|
|
577
|
+
}
|
|
578
|
+
}
|