@gotcos/glasses-server 6.82.0 → 6.83.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 +81 -0
- package/docs/work-orchestrator.md +184 -0
- package/package.json +4 -1
- package/server/index.ts +12 -0
- package/server/lib/claude-bridge.ts +4 -0
- package/server/lib/query-job-coordinator.ts +5 -1
- package/server/lib/work-chat.ts +76 -9
- package/server/lib/work-lens-rounds.ts +445 -0
- package/server/lib/work-orchestrator/discovery.ts +99 -0
- package/server/lib/work-orchestrator/input.ts +109 -0
- package/server/lib/work-orchestrator/pickup.ts +85 -0
- package/server/lib/work-orchestrator/plan-log.ts +38 -0
- package/server/lib/work-orchestrator/redact.ts +41 -0
- package/server/lib/work-orchestrator/runtime.ts +74 -0
- package/server/lib/work-orchestrator/session-log.ts +114 -0
- package/server/lib/work-orchestrator/snapshot.ts +87 -0
- package/server/lib/work-orchestrator/tag-index.ts +272 -0
- package/server/lib/work-orchestrator/tags.ts +52 -0
- package/server/lib/work-orchestrator/usage-events.ts +30 -0
- package/server/lib/work-orchestrator/usage.ts +341 -0
- package/server/routes/work-chat.ts +54 -11
- package/server/routes/work-lens-rounds.ts +179 -0
- package/server/routes/work-orchestrator.ts +71 -0
- package/server/scripts/work-lens-rounds-mutation-gate.mjs +151 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 6.83.0 Work orchestrator routes (docs/work-orchestrator.md). Read and plan inputs only: nothing here sends to a
|
|
3
|
+
* session, changes a link or a receipt, or calls a model.
|
|
4
|
+
*
|
|
5
|
+
* POST /api/work/orchestrator/input {workID, meetingWorkIDs?, snapshot?}
|
|
6
|
+
* Stores Control's snapshot when one is sent (Phase A: Control pushes, the server keeps the copy), then answers
|
|
7
|
+
* the card's planner rows, what was left out and why, and the usage snapshot.
|
|
8
|
+
* GET /api/work/orchestrator/usage[?refresh=1]
|
|
9
|
+
* The usage snapshot per provider, read on demand: Claude and Codex are re-read when their reading is older than
|
|
10
|
+
* 10 minutes (`refresh=1`: 2 minutes). No timer reads usage unless COS_WORK_USAGE_POLL=1.
|
|
11
|
+
*/
|
|
12
|
+
import { Router } from 'express'
|
|
13
|
+
import { parseSnapshot, type ControlSnapshot } from '../lib/work-orchestrator/snapshot.js'
|
|
14
|
+
import type { OrchestratorInput } from '../lib/work-orchestrator/input.js'
|
|
15
|
+
import type { UsageMonitor, UsageView } from '../lib/work-orchestrator/usage.js'
|
|
16
|
+
import { orchestratorInputFor, orchestratorSnapshots, orchestratorUsage } from '../lib/work-orchestrator/runtime.js'
|
|
17
|
+
|
|
18
|
+
const WORK_ID = /^task:[a-z_]+:[a-f0-9]{12}$/
|
|
19
|
+
const MEETING_ID = /^meeting:[^\u0000-\u001f]{1,250}$/
|
|
20
|
+
export const ORCHESTRATOR_BODY_BYTES = 4_000_000
|
|
21
|
+
|
|
22
|
+
export interface WorkOrchestratorDeps {
|
|
23
|
+
putSnapshot: (s: ControlSnapshot) => void
|
|
24
|
+
input: (workID: string, meetingWorkIDs: string[]) => Promise<OrchestratorInput>
|
|
25
|
+
usage: (refresh: boolean) => Promise<UsageView[]>
|
|
26
|
+
now: () => number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** GET /usage reads on demand: a reading older than 10 minutes is re-read (`refresh=1`: 2 minutes). */
|
|
30
|
+
export const readUsage = (refresh: boolean, monitor: UsageMonitor = orchestratorUsage()): Promise<UsageView[]> => monitor.onDemand(refresh)
|
|
31
|
+
|
|
32
|
+
const DEFAULTS: WorkOrchestratorDeps = {
|
|
33
|
+
putSnapshot: s => orchestratorSnapshots().put(s),
|
|
34
|
+
input: orchestratorInputFor,
|
|
35
|
+
usage: refresh => readUsage(refresh),
|
|
36
|
+
now: () => Date.now(),
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function createWorkOrchestratorRouter(given: Partial<WorkOrchestratorDeps> = {}) {
|
|
40
|
+
const deps = { ...DEFAULTS, ...given }
|
|
41
|
+
const router = Router()
|
|
42
|
+
router.use('/work/orchestrator', (_req, res, next) => { res.set('Cache-Control', 'private, no-store'); next() })
|
|
43
|
+
router.post('/work/orchestrator/input', async (req, res) => {
|
|
44
|
+
const body = req.body
|
|
45
|
+
const meetings: unknown = body?.meetingWorkIDs ?? []
|
|
46
|
+
if (!body || typeof body !== 'object' || Object.keys(body).some(k => !['workID', 'meetingWorkIDs', 'snapshot'].includes(k))
|
|
47
|
+
|| typeof body.workID !== 'string' || !WORK_ID.test(body.workID)
|
|
48
|
+
|| !Array.isArray(meetings) || meetings.length > 20 || !meetings.every(m => typeof m === 'string' && MEETING_ID.test(m))) {
|
|
49
|
+
return res.status(400).json({ error: 'invalid_orchestrator_input' })
|
|
50
|
+
}
|
|
51
|
+
if ('snapshot' in body) {
|
|
52
|
+
if (Buffer.byteLength(JSON.stringify(body.snapshot)) > ORCHESTRATOR_BODY_BYTES) return res.status(413).json({ error: 'snapshot_too_large' })
|
|
53
|
+
let snapshot: ControlSnapshot
|
|
54
|
+
try { snapshot = parseSnapshot(body.snapshot, deps.now()) } catch (e) { return res.status(400).json({ error: 'invalid_snapshot', reason: (e as Error).message }) }
|
|
55
|
+
deps.putSnapshot(snapshot)
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
const input = await deps.input(body.workID, meetings as string[])
|
|
59
|
+
console.log(`[work-orchestrator] input work=${body.workID} rows=${input.rows.length} discovered=${input.discovered} leftOut=${input.leftOut.length} snapshotAge=${input.snapshotAgeSec ?? '-'}s liveAge=${input.liveListAgeSec ?? '-'}s scan=${input.logScanComplete ? 'complete' : 'partial'}`)
|
|
60
|
+
return res.json(input)
|
|
61
|
+
} catch (e) {
|
|
62
|
+
console.warn(`[work-orchestrator] input failed for ${body.workID}: ${(e as Error).message}`)
|
|
63
|
+
return res.status(503).json({ error: 'orchestrator_input_unavailable' })
|
|
64
|
+
}
|
|
65
|
+
})
|
|
66
|
+
router.get('/work/orchestrator/usage', async (req, res) => {
|
|
67
|
+
try { return res.json({ protocolVersion: 1, now: new Date(deps.now()).toISOString(), usage: await deps.usage(req.query.refresh === '1') }) }
|
|
68
|
+
catch { return res.status(503).json({ error: 'usage_unavailable' }) }
|
|
69
|
+
})
|
|
70
|
+
return router
|
|
71
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Lens plans mutation gate (6.83.0: lens rounds in Work chat history, Send this plan). Mutations run only in a disposable copy of server/ and shared/; the live
|
|
3
|
+
// checkout is never rewritten. Same proof as the autonomous-save gate:
|
|
4
|
+
// 0. The target suites are GREEN unmutated (a gate over a red suite proves nothing).
|
|
5
|
+
// 1. Each target string appears EXACTLY ONCE in its file. 2. The mutated file differs.
|
|
6
|
+
// 3. A NAMED test fails ("FAIL server/x.test.ts > name"); a build error or harness failure is not a kill.
|
|
7
|
+
// 4. The file is restored byte for byte before the next mutant.
|
|
8
|
+
// Usage: node server/scripts/work-lens-rounds-mutation-gate.mjs [--list | --check] [name…]
|
|
9
|
+
import { mkdtempSync, cpSync, readFileSync, writeFileSync, symlinkSync, rmSync } from 'node:fs'
|
|
10
|
+
import { tmpdir } from 'node:os'
|
|
11
|
+
import { resolve, join, dirname, basename } from 'node:path'
|
|
12
|
+
import { fileURLToPath } from 'node:url'
|
|
13
|
+
import { spawnSync } from 'node:child_process'
|
|
14
|
+
|
|
15
|
+
const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..')
|
|
16
|
+
const tests = [
|
|
17
|
+
'server/routes/work-lens-rounds.test.ts',
|
|
18
|
+
'server/routes/work-chat.test.ts',
|
|
19
|
+
]
|
|
20
|
+
const LR = 'server/lib/work-lens-rounds.ts', RR = 'server/routes/work-lens-rounds.ts', WR = 'server/routes/work-chat.ts'
|
|
21
|
+
const EXPECTED_SURVIVORS = new Set([])
|
|
22
|
+
const mutations = [
|
|
23
|
+
// The record body.
|
|
24
|
+
['lr-v-gate', LR, " if (v.v !== 1) throw invalid('v')\n", ''],
|
|
25
|
+
['lr-workid', LR, " if (!str(v.workID, 256) || !WORK_ID.test(v.workID)) throw invalid('workID')\n", ''],
|
|
26
|
+
['lr-question-bound', LR, " if (!str(v.question, LENS_QUESTION_CHARS) || !v.question.trim()) throw invalid('question')\n", ''],
|
|
27
|
+
['lr-answer-bound', LR, "if (!str(v.answer, LENS_ANSWER_CHARS)) throw invalid('answer')", "if (typeof v.answer !== 'string') throw invalid('answer')"],
|
|
28
|
+
['lr-source-lens', LR, " if (v.source !== 'lens') throw invalid('source')\n", ''],
|
|
29
|
+
['lr-ids-in-order', LR, "a.id !== `a${i + 1}`", "typeof a.id !== 'string'"],
|
|
30
|
+
['lr-continue-needs-session', LR, " if (mode !== 'newSession' && !(str(a.sessionID, 64) && SID.test(a.sessionID))) throw invalid('assignments.sessionID')\n", ''],
|
|
31
|
+
['lr-new-session-provider', LR, "mode === 'newSession' ? !['claude', 'codex'].includes(a.provider)", "mode === 'newSession' ? false"],
|
|
32
|
+
['lr-prompt-bound', LR, "!str(a.prompt, LENS_PROMPT_CHARS)", "typeof a.prompt !== 'string'"],
|
|
33
|
+
['lr-assignment-cap', LR, "!Array.isArray(v.assignments) || v.assignments.length > WORK_CHAT_MAX_ASSIGNMENTS) throw invalid('assignments')", "!Array.isArray(v.assignments)) throw invalid('assignments')"],
|
|
34
|
+
// Plan stale.
|
|
35
|
+
['lr-stale-size', LR, "return after.length !== before.size || after.some(s => !before.has(s))", "return after.some(s => !before.has(s))"],
|
|
36
|
+
['lr-stale-members', LR, "return after.length !== before.size || after.some(s => !before.has(s))", "return after.length !== before.size"],
|
|
37
|
+
['lr-stale-own-excluded', LR, " const after = now.filter(s => !own.has(s))\n", " const after = now\n"],
|
|
38
|
+
['lr-stale-unknown', LR, " if (!round.cardSessions || !now) return false\n", ''],
|
|
39
|
+
['lr-card-fork-source', LR, "if (r.sessionID && !(r.mode === 'fork' && r.sessionID === r.sourceSessionID)) out.add(r.sessionID)", "if (r.sessionID) out.add(r.sessionID)"],
|
|
40
|
+
['lr-card-fork-child', LR, " if (r.forkChildID) out.add(r.forkChildID)\n", ''],
|
|
41
|
+
// Outcomes, expiry and bounds.
|
|
42
|
+
['lr-outcome-partly', LR, " if (sent > 0) return 'partly_sent'\n", ''],
|
|
43
|
+
['lr-outcome-stopped', LR, " if (states.every(s => s === 'stopped')) return 'stopped'\n", ''],
|
|
44
|
+
['lr-outcome-failed', LR, "return states.every(s => s === 'refused' || s === 'stopped') ? 'refused' : 'failed'", "return 'refused'"],
|
|
45
|
+
// COS Control's own Send on the Mac (QA 6.10.651 blocker: a later lens send must never deliver twice).
|
|
46
|
+
['lr-already-held', LR, "const ALREADY: readonly AssignmentState[] = ['sent', 'delivered', 'held']", "const ALREADY: readonly AssignmentState[] = ['sent', 'delivered']"],
|
|
47
|
+
['lr-already-lists-ids', LR, "{ assignmentIds: r.assignments.map(a => a.id).filter(sentAlready) }", "{}"],
|
|
48
|
+
['lr-mac-records', LR, " r.mac = { at: now, ids: [...new Set([...(r.mac?.ids ?? []), ...results.map(x => x.id)])] }\n", ''],
|
|
49
|
+
['lr-mac-view', LR, " if (r.mac && (!send || r.mac.at >= send.requestedAt)) {", " if (false) {"],
|
|
50
|
+
['lr-mac-partial', LR, " if (state === 'sent' && r.mac.ids.length < r.assignments.length) state = 'partly_sent'\n", ''],
|
|
51
|
+
['rr-local-mac', RR, " local(req)\n const round = deps.store.reportMac", " const round = deps.store.reportMac"],
|
|
52
|
+
['lr-settle-terminal', LR, "if (states.every(s => TERMINAL.includes(s)))", "if (true)"],
|
|
53
|
+
['lr-pickup-expiry', LR, "now - send.requestedAt > LENS_SEND_PICKUP_MS", "false"],
|
|
54
|
+
['lr-report-timeout', LR, "now - send.pickedUpAt > LENS_SEND_REPORT_MS", "false"],
|
|
55
|
+
['lr-ttl', LR, "const kept = this.file.rounds.filter(r => now - r.recordedAt <= LENS_ROUND_TTL_MS)", "const kept = this.file.rounds"],
|
|
56
|
+
['lr-cap-200', LR, " while (this.file.rounds.length > LENS_ROUNDS_MAX) {", " while (false) {"],
|
|
57
|
+
['lr-cap-keeps-active', LR, "this.file.rounds.splice(idle >= 0 ? idle : 0, 1)", "this.file.rounds.splice(0, 1)"],
|
|
58
|
+
['lr-idempotent', LR, " if (existing) return { round: existing, created: false }\n", ''],
|
|
59
|
+
['lr-unreadable-aside', LR, " try { renameSync(path, aside) } catch { /* nothing to move */ }\n", ''],
|
|
60
|
+
['lr-test-store-in-memory', LR, "process.env.VITEST || process.env.NODE_ENV === 'test' ? null : ", ''],
|
|
61
|
+
// Send rules.
|
|
62
|
+
['lr-in-progress', LR, " if (this.active(r)) throw new LensRoundError('send_in_progress', 409, 'This plan is already being sent.')\n", ''],
|
|
63
|
+
['lr-already-sent', LR, "if (!ids.length || ids.some(sentAlready))", "if (!ids.length)"],
|
|
64
|
+
['lr-whole-plan-skips-sent', LR, ".map(a => a.id).filter(id => !sentAlready(id))", ".map(a => a.id)"],
|
|
65
|
+
['lr-confirm-required', LR, "if (body.confirm !== 'two-step' && !r.autoSendCard)", "if (false)"],
|
|
66
|
+
['lr-auto-send-card', LR, "if (body.confirm !== 'two-step' && !r.autoSendCard)", "if (body.confirm !== 'two-step')"],
|
|
67
|
+
['lr-control-required', LR, " if (!checks.controlConnected) throw new LensRoundError('control_not_connected', 423,", " if (false) throw new LensRoundError('control_not_connected', 423,"],
|
|
68
|
+
['lr-stop-control-required', LR, " if (!controlConnected) throw new LensRoundError('control_not_connected', 423, 'COS Control is not connected. Stop it from the Mac.')\n", ''],
|
|
69
|
+
['lr-fresh-60s', LR, "connected: age <= LENS_CONTROL_FRESH_MS", "connected: true"],
|
|
70
|
+
// The inbox.
|
|
71
|
+
['lr-pickup-once', LR, "s.state === 'send_requested') { s.state = 'sending';", "(s.state === 'send_requested' || s.state === 'sending')) { s.state = 'sending';"],
|
|
72
|
+
['lr-stop-once', LR, "if (room > 0 && s.pickedUpAt === null)", "if (room > 0)"],
|
|
73
|
+
['lr-inbox-cap', LR, "let room = LENS_INBOX_MAX, changed = false", "let room = Infinity, changed = false"],
|
|
74
|
+
['lr-inbox-since', LR, "!r.inHistory && r.seq > since", "!r.inHistory"],
|
|
75
|
+
['lr-inbox-history', LR, "!r.inHistory && r.seq > since", "r.seq > since"],
|
|
76
|
+
['lr-report-picked-up', LR, " if (send.state === 'send_requested' || send.state === 'expired') throw new LensRoundError('send_not_picked_up'", " if (false) throw new LensRoundError('send_not_picked_up'"],
|
|
77
|
+
['lr-view-hides-prompts', LR, "assignments: r.assignments.map(a => ({ id: a.id, state: a.state,", "assignments: r.assignments.map(a => ({ id: a.id, prompt: a.prompt, state: a.state,"],
|
|
78
|
+
// Routes.
|
|
79
|
+
['rr-known-work', RR, " if (!known) throw new LensRoundError('unknown_work', 404,", " if (false) throw new LensRoundError('unknown_work', 404,"],
|
|
80
|
+
['rr-known-snapshot-first', RR, " if (s && (s.links.some(l => l.workID === workID) || s.receipts.some(r => r.workID === workID))) return true\n", ''],
|
|
81
|
+
['rr-body-cap', RR, "> LENS_ROUND_BODY_BYTES) {", "> Infinity) {"],
|
|
82
|
+
['rr-201-vs-200', RR, "res.status(created ? 201 : 200)", "res.status(201)"],
|
|
83
|
+
['rr-admissions', RR, " if (!deps.admissions()) return res.status(503).json(MAINTENANCE)\n", ''],
|
|
84
|
+
['rr-lease-maintenance', RR, " try { lease = deps.lease() } catch { return res.status(503).json(MAINTENANCE) }", " lease = deps.lease()"],
|
|
85
|
+
['rr-lease-released', RR, ".finally(() => lease.release())", ""],
|
|
86
|
+
['rr-local-inbox', RR, " local(req)\n const { since, autoSend } = req.query", " const { since, autoSend } = req.query"],
|
|
87
|
+
['rr-local-history', RR, " local(req)\n const roundId = roundIdOf(req)\n const b = req.body", " const roundId = roundIdOf(req)\n const b = req.body"],
|
|
88
|
+
['rr-local-result', RR, " local(req)\n const id = String(req.params.sendRequestId", " const id = String(req.params.sendRequestId"],
|
|
89
|
+
['rr-card-sessions-at-send', RR, "cardSessions: cardSessionsOf(deps.snapshot(), round.workID), controlConnected", "cardSessions: null, controlConnected"],
|
|
90
|
+
['rr-card-sessions-at-record', RR, "deps.store.record(input, cardSessionsOf(deps.snapshot(), input.workID))", "deps.store.record(input, null)"],
|
|
91
|
+
['rr-history-autosend', RR, " const round = deps.store.markInHistory(roundId, b.autoSendCard)", " const round = deps.store.markInHistory(roundId, undefined)"],
|
|
92
|
+
['rr-inbox-autosend', RR, "deps.store.inbox(since === undefined ? 0 : Number(since), autoSend === '1')", "deps.store.inbox(since === undefined ? 0 : Number(since), false)"],
|
|
93
|
+
// The capability.
|
|
94
|
+
['wr-lens-plans-capability', WR, ",\n lensPlans:1,lensPlansControl:deps.lensPlansControl()}))", "}))"],
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
const args = process.argv.slice(2)
|
|
98
|
+
if (args.includes('--list')) { for (const [name] of mutations) console.log(name); process.exit(0) }
|
|
99
|
+
const names = args.filter(a => !a.startsWith('--'))
|
|
100
|
+
const selected = names.length ? mutations.filter(([name]) => names.includes(name)) : mutations
|
|
101
|
+
if (names.length && selected.length !== names.length) throw new Error('Unknown mutation name in: ' + args.join(' '))
|
|
102
|
+
if (args.includes('--check')) {
|
|
103
|
+
let bad = 0
|
|
104
|
+
for (const [name, file, find, replacement] of mutations) {
|
|
105
|
+
const text = readFileSync(join(root, file), 'utf8'), count = text.split(find).length - 1
|
|
106
|
+
if (count !== 1 || find === replacement) { bad++; console.log(`${name}: ${count} matches in ${file}${find === replacement ? ' (no change)' : ''}`) }
|
|
107
|
+
}
|
|
108
|
+
console.log(`${mutations.length - bad} of ${mutations.length} targets unique`); process.exit(bad ? 1 : 0)
|
|
109
|
+
}
|
|
110
|
+
const scratch = mkdtempSync(join(tmpdir(), 'work-lens-rounds-mutations-'))
|
|
111
|
+
try {
|
|
112
|
+
const skip = new Set(['data', 'models', 'certs', 'node_modules'])
|
|
113
|
+
cpSync(join(root, 'server'), join(scratch, 'server'), { recursive: true, filter: src => !(skip.has(basename(src)) && dirname(src) === join(root, 'server')) })
|
|
114
|
+
cpSync(join(root, 'shared'), join(scratch, 'shared'), { recursive: true })
|
|
115
|
+
cpSync(join(root, 'bin'), join(scratch, 'bin'), { recursive: true })
|
|
116
|
+
cpSync(join(root, 'vitest.config.ts'), join(scratch, 'vitest.config.ts'))
|
|
117
|
+
cpSync(join(root, 'package.json'), join(scratch, 'package.json'))
|
|
118
|
+
symlinkSync(join(root, 'node_modules'), join(scratch, 'node_modules'), 'dir')
|
|
119
|
+
const run = () => spawnSync(process.execPath, [join(root, 'node_modules/vitest/vitest.mjs'), 'run', '--maxWorkers=1', ...tests], {
|
|
120
|
+
cwd: scratch,
|
|
121
|
+
env: { PATH: process.env.PATH, HOME: scratch, COS_DATA_DIR: join(scratch, 'data'), COS_PROFILE_PATH: join(scratch, 'data', 'profile.json'),
|
|
122
|
+
COS_MAINTENANCE_GATE_PATH: join(scratch, 'gate', 'maintenance-gate.json') },
|
|
123
|
+
encoding: 'utf8', timeout: 300000,
|
|
124
|
+
})
|
|
125
|
+
const baseline = run()
|
|
126
|
+
if (baseline.status !== 0) throw new Error('Baseline failed; a gate over a red suite proves nothing:\n' + baseline.stdout + '\n' + baseline.stderr)
|
|
127
|
+
const totals = /Tests\s+(\d+) passed/.exec(baseline.stdout)
|
|
128
|
+
console.log(`baseline PASS (${totals ? totals[1] : '?'} tests in ${tests.length} files)`)
|
|
129
|
+
const survived = []
|
|
130
|
+
for (const [name, file, find, replacement] of selected) {
|
|
131
|
+
const target = join(scratch, file), original = readFileSync(target, 'utf8')
|
|
132
|
+
if (original.split(find).length !== 2) throw new Error(`Mutation ${name} does not match exactly once in ${file}`)
|
|
133
|
+
// A function replacement: `$&` and `$1` in a mutant are literal text, never replace() patterns.
|
|
134
|
+
const mutated = original.replace(find, () => replacement)
|
|
135
|
+
if (mutated === original) throw new Error(`Mutation ${name} did not change ${file}`)
|
|
136
|
+
writeFileSync(target, mutated)
|
|
137
|
+
const result = run(), output = result.stdout + '\n' + result.stderr
|
|
138
|
+
writeFileSync(target, original)
|
|
139
|
+
if (readFileSync(target, 'utf8') !== original) throw new Error(`Restore failed for ${file} after ${name}`)
|
|
140
|
+
const killers = [...new Set([...output.matchAll(/FAIL\s+(server\/\S+\.test\.ts > [^\n]+)/g)].map(m => m[1].trim()))]
|
|
141
|
+
if (result.status === 0 || result.error || !killers.length) {
|
|
142
|
+
const expected = EXPECTED_SURVIVORS.has(name) && result.status === 0
|
|
143
|
+
if (!expected) survived.push(name)
|
|
144
|
+
console.log(`${name} ${expected ? 'SURVIVED (expected, documented)' : 'SURVIVED'}${result.error ? ' (harness: ' + result.error.message + ')' : ''}`); continue
|
|
145
|
+
}
|
|
146
|
+
console.log(`${name} KILLED by ${killers.slice(0, 2).join(' | ')}${killers.length > 2 ? ` (+${killers.length - 2} more)` : ''}`)
|
|
147
|
+
}
|
|
148
|
+
const expectedCount = selected.filter(([n]) => EXPECTED_SURVIVORS.has(n)).length
|
|
149
|
+
console.log(`${selected.length - survived.length - expectedCount} of ${selected.length} lens-plans mutations killed by a named test; ${expectedCount} expected survivors`)
|
|
150
|
+
if (survived.length) { console.log('SURVIVED: ' + survived.join(', ')); process.exitCode = 1 }
|
|
151
|
+
} finally { rmSync(scratch, { recursive: true, force: true }) }
|