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/tests.js
ADDED
|
@@ -0,0 +1,628 @@
|
|
|
1
|
+
// Tower test discovery (2.1): fail-closed admission evidence from the
|
|
2
|
+
// repository's own `package.json` `tower.testDiscovery` block.
|
|
3
|
+
//
|
|
4
|
+
// Trust boundary: every configured command executes repository-controlled code
|
|
5
|
+
// inside the mission worktree. Only enable discovery for repositories you
|
|
6
|
+
// trust (`tests.allowPackageDiscovery`, documented in the security guide).
|
|
7
|
+
// This module never touches the network and never mutates the state store.
|
|
8
|
+
//
|
|
9
|
+
// Contract (implemented exactly by the 2.1 plan):
|
|
10
|
+
// readDiscoveryConfig(pkgJsonText) -> { configured, config, error }
|
|
11
|
+
// normalizeDiscovery(config) -> { version, ok, commands, error }
|
|
12
|
+
// parseTap(stdout) -> { tests, ok, count, failed, topLevel, plan, error }
|
|
13
|
+
// parseJsonArray(stdout) -> { tests, ok, count, failed, error }
|
|
14
|
+
// runDiscovery(options) -> receipt (see below)
|
|
15
|
+
//
|
|
16
|
+
// Exit paths are intentionally split:
|
|
17
|
+
// - a test failure (nonzero exit, malformed output, timeout, oversize,
|
|
18
|
+
// minCount/required shortfall) returns `ok: false` in the receipt;
|
|
19
|
+
// - an invalid discovery configuration (or invalid call arguments) throws a
|
|
20
|
+
// plain `Error`, which the tool layer wraps as `tower:invalid-argument`.
|
|
21
|
+
import { spawn } from 'node:child_process'
|
|
22
|
+
import { createWriteStream } from 'node:fs'
|
|
23
|
+
import crypto from 'node:crypto'
|
|
24
|
+
import fsp from 'node:fs/promises'
|
|
25
|
+
import path from 'node:path'
|
|
26
|
+
|
|
27
|
+
import { tryGit } from './git.js'
|
|
28
|
+
|
|
29
|
+
export const DISCOVERY_FORMATS = ['node-tap', 'json-array']
|
|
30
|
+
export const DEFAULT_TIMEOUT_MS = 60000
|
|
31
|
+
export const DEFAULT_MAX_BYTES = 16 * 1024 * 1024
|
|
32
|
+
|
|
33
|
+
const MAX_COMMANDS = 20
|
|
34
|
+
const MAX_COMMAND_LENGTH = 4000
|
|
35
|
+
const COMMAND_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/
|
|
36
|
+
const PASS_STATUSES = new Set(['passed', 'pass', 'ok', 'success', 'succeeded', 'done', 'green'])
|
|
37
|
+
const FAIL_STATUSES = new Set(['failed', 'fail', 'error', 'errored', 'timeout', 'timed-out', 'blocked'])
|
|
38
|
+
|
|
39
|
+
function isPlainObject(value) {
|
|
40
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function nonEmptyString(value) {
|
|
44
|
+
return typeof value === 'string' && value.trim().length > 0
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function normalizePositiveInteger(value, name) {
|
|
48
|
+
if (!Number.isInteger(value) || value <= 0) throw new Error(`${name} must be an integer > 0`)
|
|
49
|
+
return value
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function safeSegment(value) {
|
|
53
|
+
return String(value).replace(/[^A-Za-z0-9._-]+/g, '_').slice(0, 60) || 'command'
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function stamp(date) {
|
|
57
|
+
return date.toISOString().replace(/[-:.]/g, '')
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Read `tower.testDiscovery` from package.json text.
|
|
62
|
+
* Returns `{ configured, config, error }`; `configured:false` only means the
|
|
63
|
+
* repository did not opt in (compat), never that discovery succeeded.
|
|
64
|
+
*/
|
|
65
|
+
export function readDiscoveryConfig(pkgJsonText) {
|
|
66
|
+
if (typeof pkgJsonText !== 'string' || pkgJsonText.trim().length === 0) {
|
|
67
|
+
return { configured: false, config: null, error: 'package.json is empty' }
|
|
68
|
+
}
|
|
69
|
+
let pkg
|
|
70
|
+
try {
|
|
71
|
+
pkg = JSON.parse(pkgJsonText)
|
|
72
|
+
} catch (error) {
|
|
73
|
+
return { configured: false, config: null, error: `package.json is not valid JSON: ${error.message}` }
|
|
74
|
+
}
|
|
75
|
+
if (!isPlainObject(pkg)) {
|
|
76
|
+
return { configured: false, config: null, error: 'package.json must contain a JSON object' }
|
|
77
|
+
}
|
|
78
|
+
const tower = isPlainObject(pkg.tower) ? pkg.tower : null
|
|
79
|
+
const discovery = tower ? tower.testDiscovery : undefined
|
|
80
|
+
if (discovery === undefined || discovery === null) {
|
|
81
|
+
return { configured: false, config: null, error: null }
|
|
82
|
+
}
|
|
83
|
+
return { configured: true, config: discovery, error: null }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Validate and normalize a `tower.testDiscovery` value (Tower Codex version 1):
|
|
88
|
+
* `{ version: 1, commands: [{ id, command, format, minCount?, required? }] }`.
|
|
89
|
+
* Returns `{ version, ok, commands, error }`; never throws.
|
|
90
|
+
* Defaults: `minCount: 1` (zero discovery is rejected unless explicitly relaxed),
|
|
91
|
+
* `required: []`. The normalized value is accepted again by this function.
|
|
92
|
+
*/
|
|
93
|
+
export function normalizeDiscovery(config) {
|
|
94
|
+
const fail = (message) => ({ version: 1, ok: false, commands: [], error: message })
|
|
95
|
+
if (!isPlainObject(config)) return fail('tower.testDiscovery must be an object')
|
|
96
|
+
if (config.version !== 1) return fail('tower.testDiscovery.version must be one of: 1')
|
|
97
|
+
if (!Array.isArray(config.commands)) return fail('tower.testDiscovery.commands must be a non-empty array')
|
|
98
|
+
if (config.commands.length === 0) return fail('tower.testDiscovery.commands must be a non-empty array')
|
|
99
|
+
if (config.commands.length > MAX_COMMANDS) {
|
|
100
|
+
return fail(`tower.testDiscovery.commands must have at most ${MAX_COMMANDS} entries`)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const commands = []
|
|
104
|
+
const ids = new Set()
|
|
105
|
+
for (const [index, raw] of config.commands.entries()) {
|
|
106
|
+
const where = `tower.testDiscovery.commands[${index}]`
|
|
107
|
+
if (!isPlainObject(raw)) return fail(`${where} must be an object`)
|
|
108
|
+
if (!nonEmptyString(raw.id)) return fail(`${where}.id must be a non-empty string`)
|
|
109
|
+
const id = raw.id.trim()
|
|
110
|
+
if (!COMMAND_ID_PATTERN.test(id)) {
|
|
111
|
+
return fail(`${where}.id must match ${COMMAND_ID_PATTERN} (letters, digits, dot, dash, underscore)`)
|
|
112
|
+
}
|
|
113
|
+
if (ids.has(id)) return fail(`${where}.id must not duplicate command id "${id}"`)
|
|
114
|
+
ids.add(id)
|
|
115
|
+
if (!nonEmptyString(raw.command)) return fail(`${where}.command must be a non-empty string`)
|
|
116
|
+
const command = raw.command.trim()
|
|
117
|
+
if (command.length > MAX_COMMAND_LENGTH) {
|
|
118
|
+
return fail(`${where}.command must be at most ${MAX_COMMAND_LENGTH} characters`)
|
|
119
|
+
}
|
|
120
|
+
if (!DISCOVERY_FORMATS.includes(raw.format)) {
|
|
121
|
+
return fail(`${where}.format must be one of: ${DISCOVERY_FORMATS.join(', ')}`)
|
|
122
|
+
}
|
|
123
|
+
let minCount = 1
|
|
124
|
+
if (raw.minCount !== undefined) {
|
|
125
|
+
if (!Number.isInteger(raw.minCount) || raw.minCount < 0) {
|
|
126
|
+
return fail(`${where}.minCount must be an integer >= 0`)
|
|
127
|
+
}
|
|
128
|
+
minCount = raw.minCount
|
|
129
|
+
}
|
|
130
|
+
const required = []
|
|
131
|
+
if (raw.required !== undefined) {
|
|
132
|
+
if (!Array.isArray(raw.required)) return fail(`${where}.required must be an array of test names`)
|
|
133
|
+
for (const [requiredIndex, name] of raw.required.entries()) {
|
|
134
|
+
if (!nonEmptyString(name)) {
|
|
135
|
+
return fail(`${where}.required[${requiredIndex}] must be a non-empty string`)
|
|
136
|
+
}
|
|
137
|
+
const trimmed = name.trim()
|
|
138
|
+
if (required.includes(trimmed)) {
|
|
139
|
+
return fail(`${where}.required must not duplicate test name "${trimmed}"`)
|
|
140
|
+
}
|
|
141
|
+
required.push(trimmed)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
commands.push({ id, command, format: raw.format, minCount, required })
|
|
145
|
+
}
|
|
146
|
+
return { version: 1, ok: true, commands, error: null }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Parse node-tap output. Counts every test point (nested `node --test` subtests
|
|
151
|
+
* are included) while validating the top-level TAP plan against top-level
|
|
152
|
+
* points only. Failures return `ok:false` plus an `error`; skip/TODO points are
|
|
153
|
+
* tolerated the way TAP defines them.
|
|
154
|
+
*/
|
|
155
|
+
export function parseTap(stdout) {
|
|
156
|
+
const text = typeof stdout === 'string' ? stdout : ''
|
|
157
|
+
const points = []
|
|
158
|
+
let plan = null
|
|
159
|
+
let sawHeader = false
|
|
160
|
+
let bailout = false
|
|
161
|
+
|
|
162
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
163
|
+
const line = rawLine.replace(/\r$/, '')
|
|
164
|
+
if (/^TAP version \d+\s*$/i.test(line)) {
|
|
165
|
+
sawHeader = true
|
|
166
|
+
continue
|
|
167
|
+
}
|
|
168
|
+
if (/^Bail out!/.test(line)) {
|
|
169
|
+
bailout = true
|
|
170
|
+
continue
|
|
171
|
+
}
|
|
172
|
+
// The `(?=\s|$)` lookahead keeps YAML diagnostics lines such as
|
|
173
|
+
// ` ok: true` from being mistaken for test points.
|
|
174
|
+
const point = /^(\s*)(not ok|ok)\b(?=\s|$)(?:\s+(\d+))?(?:\s*-\s*)?(.*)$/.exec(line)
|
|
175
|
+
if (point) {
|
|
176
|
+
const [, indent, marker, , rest] = point
|
|
177
|
+
let name = rest.trim()
|
|
178
|
+
let directive = null
|
|
179
|
+
const directiveMatch = /\s+#\s*(SKIP|TODO)\b.*$/i.exec(name)
|
|
180
|
+
if (directiveMatch) {
|
|
181
|
+
directive = directiveMatch[1].toUpperCase()
|
|
182
|
+
name = name.slice(0, directiveMatch.index).trim()
|
|
183
|
+
}
|
|
184
|
+
const rawOk = marker === 'ok'
|
|
185
|
+
points.push({ name, ok: rawOk || directive !== null, rawOk, topLevel: indent.length === 0, directive })
|
|
186
|
+
continue
|
|
187
|
+
}
|
|
188
|
+
const planMatch = /^(\s*)1\.\.(\d+)\s*(?:#.*)?$/.exec(line)
|
|
189
|
+
if (planMatch && planMatch[1].length === 0) plan = Number(planMatch[2])
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const tests = points.map((point) => {
|
|
193
|
+
const entry = { name: point.name, ok: point.ok }
|
|
194
|
+
if (point.directive) entry.directive = point.directive
|
|
195
|
+
return entry
|
|
196
|
+
})
|
|
197
|
+
const topLevel = points.filter((point) => point.topLevel).length
|
|
198
|
+
const failed = tests.filter((test) => !test.ok).length
|
|
199
|
+
let error = null
|
|
200
|
+
if (bailout) error = 'TAP bail out'
|
|
201
|
+
else if (plan === null && points.length === 0) error = 'no TAP test points found'
|
|
202
|
+
else if (plan !== null && plan !== topLevel) {
|
|
203
|
+
error = `TAP plan says ${plan} but ${topLevel} top-level test point(s) were found`
|
|
204
|
+
}
|
|
205
|
+
return { tests, ok: error === null && failed === 0, count: tests.length, failed, topLevel, plan, error }
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Parse the `json-array` format: a JSON array of test entries. Entries may be
|
|
210
|
+
* plain name strings or objects carrying `{ name|title|test, ok|pass|success|
|
|
211
|
+
* status|exitCode }`.
|
|
212
|
+
*/
|
|
213
|
+
export function parseJsonArray(stdout) {
|
|
214
|
+
const text = typeof stdout === 'string' ? stdout.trim() : ''
|
|
215
|
+
const fail = (error) => ({ tests: [], ok: false, count: 0, failed: 0, error })
|
|
216
|
+
if (text.length === 0) return fail('empty output')
|
|
217
|
+
let parsed
|
|
218
|
+
try {
|
|
219
|
+
parsed = JSON.parse(text)
|
|
220
|
+
} catch (error) {
|
|
221
|
+
return fail(`output is not valid JSON: ${error.message}`)
|
|
222
|
+
}
|
|
223
|
+
if (!Array.isArray(parsed)) return fail('output must be a JSON array of tests')
|
|
224
|
+
|
|
225
|
+
const tests = []
|
|
226
|
+
for (const [index, entry] of parsed.entries()) {
|
|
227
|
+
if (typeof entry === 'string') {
|
|
228
|
+
const name = entry.trim()
|
|
229
|
+
if (name.length === 0) return fail(`entries[${index}] must be a non-empty test name`)
|
|
230
|
+
tests.push({ name, ok: true })
|
|
231
|
+
continue
|
|
232
|
+
}
|
|
233
|
+
if (!isPlainObject(entry)) return fail(`entries[${index}] must be an object or a test name string`)
|
|
234
|
+
const name = [entry.name, entry.title, entry.test].find(nonEmptyString)
|
|
235
|
+
if (!name) return fail(`entries[${index}] has no non-empty name`)
|
|
236
|
+
let ok
|
|
237
|
+
if (typeof entry.ok === 'boolean') ok = entry.ok
|
|
238
|
+
else if (typeof entry.pass === 'boolean') ok = entry.pass
|
|
239
|
+
else if (typeof entry.success === 'boolean') ok = entry.success
|
|
240
|
+
else if (typeof entry.status === 'string') {
|
|
241
|
+
const status = entry.status.trim().toLowerCase()
|
|
242
|
+
if (PASS_STATUSES.has(status)) ok = true
|
|
243
|
+
else if (FAIL_STATUSES.has(status)) ok = false
|
|
244
|
+
} else if (entry.exitCode !== undefined && entry.exitCode !== null) {
|
|
245
|
+
ok = Number(entry.exitCode) === 0
|
|
246
|
+
}
|
|
247
|
+
if (ok === undefined) {
|
|
248
|
+
return fail(`entries[${index}] must have a boolean ok/pass field or a pass/fail status`)
|
|
249
|
+
}
|
|
250
|
+
tests.push({ name: name.trim(), ok })
|
|
251
|
+
}
|
|
252
|
+
const failed = tests.filter((test) => !test.ok).length
|
|
253
|
+
return { tests, ok: failed === 0, count: tests.length, failed, error: null }
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Platform shell for discovery commands (PowerShell 7 on Windows, bash on POSIX). */
|
|
257
|
+
function shellSpec(platform = process.platform) {
|
|
258
|
+
if (platform === 'win32') {
|
|
259
|
+
const args = (command) => ['-NoProfile', '-NonInteractive', '-Command', command]
|
|
260
|
+
return {
|
|
261
|
+
file: 'pwsh',
|
|
262
|
+
args,
|
|
263
|
+
fallback: { file: 'powershell', args },
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return { file: 'bash', args: (command) => ['-lc', command], fallback: null }
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function killTree(child) {
|
|
270
|
+
if (!child || !child.pid) return
|
|
271
|
+
if (process.platform === 'win32') {
|
|
272
|
+
try {
|
|
273
|
+
child.kill('SIGKILL')
|
|
274
|
+
} catch {
|
|
275
|
+
// already gone
|
|
276
|
+
}
|
|
277
|
+
try {
|
|
278
|
+
const killer = spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], {
|
|
279
|
+
stdio: 'ignore',
|
|
280
|
+
windowsHide: true,
|
|
281
|
+
})
|
|
282
|
+
killer.on('error', () => {})
|
|
283
|
+
killer.unref()
|
|
284
|
+
} catch {
|
|
285
|
+
// best effort: the direct kill above already ended the shell
|
|
286
|
+
}
|
|
287
|
+
return
|
|
288
|
+
}
|
|
289
|
+
try {
|
|
290
|
+
process.kill(-child.pid, 'SIGKILL')
|
|
291
|
+
} catch {
|
|
292
|
+
try {
|
|
293
|
+
child.kill('SIGKILL')
|
|
294
|
+
} catch {
|
|
295
|
+
// already gone
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function closeStreams(streams) {
|
|
301
|
+
return Promise.all(
|
|
302
|
+
streams.map(
|
|
303
|
+
(stream) =>
|
|
304
|
+
new Promise((resolve) => {
|
|
305
|
+
if (stream.closed || stream.destroyed) {
|
|
306
|
+
resolve()
|
|
307
|
+
return
|
|
308
|
+
}
|
|
309
|
+
stream.once('close', resolve)
|
|
310
|
+
stream.once('error', resolve)
|
|
311
|
+
stream.end()
|
|
312
|
+
}),
|
|
313
|
+
),
|
|
314
|
+
)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Default runner: execute one command with the platform shell, streaming both
|
|
319
|
+
* outputs to the given files under a combined `maxBytes` cap. Resolves with
|
|
320
|
+
* `{ exitCode, signal, timedOut, overflow, spawnError, durationMs }`.
|
|
321
|
+
* Tests may inject their own `runner` with the same input/result shape.
|
|
322
|
+
*/
|
|
323
|
+
function spawnToFiles({ command, cwd, timeoutMs, maxBytes, stdoutPath, stderrPath, shell }) {
|
|
324
|
+
const started = Date.now()
|
|
325
|
+
const stdout = createWriteStream(stdoutPath, { flags: 'w' })
|
|
326
|
+
const stderr = createWriteStream(stderrPath, { flags: 'w' })
|
|
327
|
+
for (const stream of [stdout, stderr]) stream.on('error', () => {})
|
|
328
|
+
|
|
329
|
+
return new Promise((resolve) => {
|
|
330
|
+
let settled = false
|
|
331
|
+
let child = null
|
|
332
|
+
let timer = null
|
|
333
|
+
let stdoutBytes = 0
|
|
334
|
+
let stderrBytes = 0
|
|
335
|
+
let overflow = false
|
|
336
|
+
let timedOut = false
|
|
337
|
+
let spawnError = null
|
|
338
|
+
let lastCode = null
|
|
339
|
+
let lastSignal = null
|
|
340
|
+
|
|
341
|
+
const result = () => ({ exitCode: lastCode, signal: lastSignal, timedOut, overflow, spawnError, durationMs: Date.now() - started })
|
|
342
|
+
const settle = () => {
|
|
343
|
+
if (settled) return
|
|
344
|
+
settled = true
|
|
345
|
+
if (timer) clearTimeout(timer)
|
|
346
|
+
closeStreams([stdout, stderr]).then(() => resolve(result()))
|
|
347
|
+
}
|
|
348
|
+
const stop = (reason) => {
|
|
349
|
+
if (reason === 'timeout') timedOut = true
|
|
350
|
+
else overflow = true
|
|
351
|
+
killTree(child)
|
|
352
|
+
}
|
|
353
|
+
// Each stream is capped independently at maxBytes (the configured
|
|
354
|
+
// "captured stdout/stderr bytes per command" budget).
|
|
355
|
+
const writeChunk = (stream, chunk, kind) => {
|
|
356
|
+
if (overflow || settled) return
|
|
357
|
+
const used = kind === 'stdout' ? stdoutBytes : stderrBytes
|
|
358
|
+
const remaining = maxBytes - used
|
|
359
|
+
if (remaining <= 0) {
|
|
360
|
+
stop('overflow')
|
|
361
|
+
return
|
|
362
|
+
}
|
|
363
|
+
if (chunk.length > remaining) {
|
|
364
|
+
stream.write(chunk.subarray(0, remaining))
|
|
365
|
+
if (kind === 'stdout') stdoutBytes = maxBytes
|
|
366
|
+
else stderrBytes = maxBytes
|
|
367
|
+
stop('overflow')
|
|
368
|
+
return
|
|
369
|
+
}
|
|
370
|
+
stream.write(chunk)
|
|
371
|
+
if (kind === 'stdout') stdoutBytes += chunk.length
|
|
372
|
+
else stderrBytes += chunk.length
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
try {
|
|
376
|
+
child = spawn(shell.file, shell.args(command), {
|
|
377
|
+
cwd,
|
|
378
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
379
|
+
windowsHide: true,
|
|
380
|
+
// POSIX: a process group lets a timeout kill grandchildren too.
|
|
381
|
+
detached: process.platform !== 'win32',
|
|
382
|
+
env: process.env,
|
|
383
|
+
})
|
|
384
|
+
} catch (error) {
|
|
385
|
+
spawnError = error
|
|
386
|
+
settle()
|
|
387
|
+
return
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
child.stdout?.on('data', (chunk) => writeChunk(stdout, chunk, 'stdout'))
|
|
391
|
+
child.stderr?.on('data', (chunk) => writeChunk(stderr, chunk, 'stderr'))
|
|
392
|
+
child.once('error', (error) => {
|
|
393
|
+
spawnError = error
|
|
394
|
+
settle()
|
|
395
|
+
})
|
|
396
|
+
child.once('close', (code, signal) => {
|
|
397
|
+
lastCode = typeof code === 'number' ? code : null
|
|
398
|
+
lastSignal = signal ?? null
|
|
399
|
+
settle()
|
|
400
|
+
})
|
|
401
|
+
timer = setTimeout(() => stop('timeout'), timeoutMs)
|
|
402
|
+
})
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
async function readFileOrEmpty(file) {
|
|
406
|
+
try {
|
|
407
|
+
return await fsp.readFile(file)
|
|
408
|
+
} catch {
|
|
409
|
+
return Buffer.alloc(0)
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function sha256(buffer) {
|
|
414
|
+
return crypto.createHash('sha256').update(buffer).digest('hex')
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
async function readTreeTip(worktreePath) {
|
|
418
|
+
const tip = await tryGit(worktreePath, ['rev-parse', 'HEAD'])
|
|
419
|
+
return tip && tip.trim() ? tip.trim() : null
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
async function resolveCommonDir(worktreePath) {
|
|
423
|
+
const raw = await tryGit(worktreePath, ['rev-parse', '--git-common-dir'])
|
|
424
|
+
if (!raw || !raw.trim()) return null
|
|
425
|
+
return path.isAbsolute(raw.trim()) ? path.resolve(raw.trim()) : path.resolve(worktreePath, raw.trim())
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
async function resolveDiagnosticsDir({ worktreePath, diagnosticsDir, missionId }) {
|
|
429
|
+
if (diagnosticsDir) {
|
|
430
|
+
await fsp.mkdir(diagnosticsDir, { recursive: true })
|
|
431
|
+
return diagnosticsDir
|
|
432
|
+
}
|
|
433
|
+
const now = new Date()
|
|
434
|
+
const suffix = stamp(now)
|
|
435
|
+
const mission = safeSegment(missionId ?? path.basename(worktreePath))
|
|
436
|
+
const commonDir = await resolveCommonDir(worktreePath)
|
|
437
|
+
if (commonDir) {
|
|
438
|
+
const dir = path.join(commonDir, 'dsh-tower', 'test-runs', `${mission}-${suffix}`)
|
|
439
|
+
await fsp.mkdir(dir, { recursive: true })
|
|
440
|
+
return dir
|
|
441
|
+
}
|
|
442
|
+
const dir = path.join(worktreePath, '.tower-discovery-diagnostics', `${mission}-${suffix}`)
|
|
443
|
+
await fsp.mkdir(dir, { recursive: true })
|
|
444
|
+
return dir
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function normalizeDiscoveryInput(config) {
|
|
448
|
+
if (isPlainObject(config) && Object.prototype.hasOwnProperty.call(config, 'configured')) {
|
|
449
|
+
if (config.error) throw new Error(`test discovery configuration is invalid: ${config.error}`)
|
|
450
|
+
if (config.configured === false) return { configured: false, config: null, error: null }
|
|
451
|
+
return { configured: true, config: config.config, error: null }
|
|
452
|
+
}
|
|
453
|
+
if (config === undefined || config === null) return { configured: false, config: null, error: null }
|
|
454
|
+
return { configured: true, config, error: null }
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Run the declared discovery commands inside `worktreePath` and return a
|
|
459
|
+
* fail-closed receipt:
|
|
460
|
+
*
|
|
461
|
+
* {
|
|
462
|
+
* configured, ok, commands: [{
|
|
463
|
+
* id, format, command, exitCode,
|
|
464
|
+
* stdoutPath, stderrPath, stdoutSha256, stderrSha256,
|
|
465
|
+
* count, requiredMissing, error, ok, durationMs
|
|
466
|
+
* }],
|
|
467
|
+
* tests: [{ name, ok }], treeTip, treeTipAfter,
|
|
468
|
+
* startedAt, finishedAt, diagnosticsPath, receiptPath, error
|
|
469
|
+
* }
|
|
470
|
+
*
|
|
471
|
+
* Options:
|
|
472
|
+
* - `diagnosticsDir` overrides where the receipt/stdout/stderr are written.
|
|
473
|
+
* When omitted, diagnostics go under
|
|
474
|
+
* `<git-common-dir>/dsh-tower/test-runs/<worktree>-<stamp>/` (2.1 spec) and
|
|
475
|
+
* fall back to `<worktree>/.tower-discovery-diagnostics/<stamp>/` when the
|
|
476
|
+
* worktree is not a Git repository.
|
|
477
|
+
* - `runner` replaces the default shell runner (same input/result shape);
|
|
478
|
+
* used by tests and by callers that already own process execution.
|
|
479
|
+
* - `missionId` only labels the fallback diagnostics path.
|
|
480
|
+
*
|
|
481
|
+
* Invalid call arguments and invalid discovery configurations throw; test
|
|
482
|
+
* failures never throw and are reported in the receipt.
|
|
483
|
+
*/
|
|
484
|
+
export async function runDiscovery(options = {}) {
|
|
485
|
+
const {
|
|
486
|
+
worktreePath,
|
|
487
|
+
config,
|
|
488
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
489
|
+
maxBytes = DEFAULT_MAX_BYTES,
|
|
490
|
+
runner,
|
|
491
|
+
diagnosticsDir,
|
|
492
|
+
missionId,
|
|
493
|
+
} = options
|
|
494
|
+
const startedAt = new Date().toISOString()
|
|
495
|
+
|
|
496
|
+
if (!nonEmptyString(worktreePath)) throw new Error('worktreePath is required')
|
|
497
|
+
if (!path.isAbsolute(worktreePath)) throw new Error(`worktreePath must be an absolute path: ${worktreePath}`)
|
|
498
|
+
const effectiveTimeout = normalizePositiveInteger(timeoutMs, 'timeoutMs')
|
|
499
|
+
const effectiveMaxBytes = normalizePositiveInteger(maxBytes, 'maxBytes')
|
|
500
|
+
if (runner !== undefined && runner !== null && typeof runner !== 'function') {
|
|
501
|
+
throw new Error('runner must be a function')
|
|
502
|
+
}
|
|
503
|
+
if (diagnosticsDir !== undefined && diagnosticsDir !== null) {
|
|
504
|
+
if (!nonEmptyString(diagnosticsDir) || !path.isAbsolute(diagnosticsDir)) {
|
|
505
|
+
throw new Error(`diagnosticsDir must be an absolute path: ${diagnosticsDir}`)
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const discovery = normalizeDiscoveryInput(config)
|
|
510
|
+
const treeTip = await readTreeTip(worktreePath)
|
|
511
|
+
const finishUnconfigured = (error) => ({
|
|
512
|
+
configured: false,
|
|
513
|
+
ok: false,
|
|
514
|
+
commands: [],
|
|
515
|
+
tests: [],
|
|
516
|
+
treeTip,
|
|
517
|
+
treeTipAfter: treeTip,
|
|
518
|
+
startedAt,
|
|
519
|
+
finishedAt: new Date().toISOString(),
|
|
520
|
+
diagnosticsPath: null,
|
|
521
|
+
receiptPath: null,
|
|
522
|
+
error: error ?? 'test discovery is not configured',
|
|
523
|
+
})
|
|
524
|
+
|
|
525
|
+
if (!discovery.configured) return finishUnconfigured(discovery.error)
|
|
526
|
+
const normalized = normalizeDiscovery(discovery.config)
|
|
527
|
+
if (!normalized.ok) throw new Error(`invalid tower.testDiscovery config: ${normalized.error}`)
|
|
528
|
+
|
|
529
|
+
const dir = await resolveDiagnosticsDir({ worktreePath, diagnosticsDir, missionId })
|
|
530
|
+
const commands = []
|
|
531
|
+
const tests = []
|
|
532
|
+
const failures = []
|
|
533
|
+
const shell = shellSpec()
|
|
534
|
+
|
|
535
|
+
for (const [index, command] of normalized.commands.entries()) {
|
|
536
|
+
const base = `${String(index + 1).padStart(2, '0')}-${safeSegment(command.id)}`
|
|
537
|
+
const stdoutPath = path.join(dir, `${base}.stdout.log`)
|
|
538
|
+
const stderrPath = path.join(dir, `${base}.stderr.log`)
|
|
539
|
+
const runStarted = Date.now()
|
|
540
|
+
|
|
541
|
+
let result
|
|
542
|
+
try {
|
|
543
|
+
const payload = {
|
|
544
|
+
command: command.command,
|
|
545
|
+
cwd: worktreePath,
|
|
546
|
+
timeoutMs: effectiveTimeout,
|
|
547
|
+
maxBytes: effectiveMaxBytes,
|
|
548
|
+
stdoutPath,
|
|
549
|
+
stderrPath,
|
|
550
|
+
shell,
|
|
551
|
+
}
|
|
552
|
+
if (runner) {
|
|
553
|
+
result = await runner(payload)
|
|
554
|
+
} else {
|
|
555
|
+
result = await spawnToFiles(payload)
|
|
556
|
+
if (result.spawnError?.code === 'ENOENT' && shell.fallback) {
|
|
557
|
+
result = await spawnToFiles({ ...payload, shell: shell.fallback })
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
} catch (error) {
|
|
561
|
+
result = { exitCode: null, signal: null, timedOut: false, overflow: false, spawnError: error, durationMs: Date.now() - runStarted }
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const stdout = await readFileOrEmpty(stdoutPath)
|
|
565
|
+
const stderr = await readFileOrEmpty(stderrPath)
|
|
566
|
+
let parsed = null
|
|
567
|
+
let error = null
|
|
568
|
+
let requiredMissing = []
|
|
569
|
+
|
|
570
|
+
if (result.spawnError) {
|
|
571
|
+
error = `spawn failed: ${result.spawnError.message}`
|
|
572
|
+
} else if (result.timedOut) {
|
|
573
|
+
error = `timed out after ${effectiveTimeout}ms`
|
|
574
|
+
} else if (result.overflow) {
|
|
575
|
+
error = `output exceeded ${effectiveMaxBytes} bytes`
|
|
576
|
+
} else if (result.exitCode !== 0) {
|
|
577
|
+
error = `exit code ${result.exitCode ?? 'unknown'}`
|
|
578
|
+
} else {
|
|
579
|
+
parsed = command.format === 'node-tap' ? parseTap(stdout.toString('utf8')) : parseJsonArray(stdout.toString('utf8'))
|
|
580
|
+
requiredMissing = command.required.filter((name) => !parsed.tests.some((test) => test.name === name))
|
|
581
|
+
if (parsed.error) error = `malformed-output: ${parsed.error}`
|
|
582
|
+
else if (parsed.failed > 0) error = `${parsed.failed} test(s) failed`
|
|
583
|
+
else if (parsed.count < command.minCount) error = `found ${parsed.count} test(s), below minCount ${command.minCount}`
|
|
584
|
+
else if (requiredMissing.length > 0) error = `missing required test(s): ${requiredMissing.join(', ')}`
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
if (parsed) tests.push(...parsed.tests)
|
|
588
|
+
commands.push({
|
|
589
|
+
id: command.id,
|
|
590
|
+
format: command.format,
|
|
591
|
+
command: command.command,
|
|
592
|
+
exitCode: result.exitCode ?? null,
|
|
593
|
+
stdoutPath,
|
|
594
|
+
stderrPath,
|
|
595
|
+
stdoutSha256: sha256(stdout),
|
|
596
|
+
stderrSha256: sha256(stderr),
|
|
597
|
+
count: parsed ? parsed.count : 0,
|
|
598
|
+
requiredMissing,
|
|
599
|
+
error,
|
|
600
|
+
ok: error === null,
|
|
601
|
+
durationMs: Date.now() - runStarted,
|
|
602
|
+
})
|
|
603
|
+
if (error) failures.push(`${command.id}: ${error}`)
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
const treeTipAfter = await readTreeTip(worktreePath)
|
|
607
|
+
if (treeTip && treeTipAfter && treeTip !== treeTipAfter) {
|
|
608
|
+
failures.push(`worktree HEAD moved during test discovery (${treeTip} -> ${treeTipAfter})`)
|
|
609
|
+
}
|
|
610
|
+
if (tests.length === 0) failures.push('test discovery produced zero tests')
|
|
611
|
+
|
|
612
|
+
const receiptPath = path.join(dir, 'receipt.json')
|
|
613
|
+
const receipt = {
|
|
614
|
+
configured: true,
|
|
615
|
+
ok: failures.length === 0,
|
|
616
|
+
commands,
|
|
617
|
+
tests,
|
|
618
|
+
treeTip,
|
|
619
|
+
treeTipAfter,
|
|
620
|
+
startedAt,
|
|
621
|
+
finishedAt: new Date().toISOString(),
|
|
622
|
+
diagnosticsPath: dir,
|
|
623
|
+
receiptPath,
|
|
624
|
+
error: failures.length > 0 ? failures.join('; ') : null,
|
|
625
|
+
}
|
|
626
|
+
await fsp.writeFile(receiptPath, JSON.stringify(receipt, null, 2), 'utf8')
|
|
627
|
+
return receipt
|
|
628
|
+
}
|