@gotcos/glasses-server 6.81.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 +93 -0
- package/docs/work-orchestrator.md +184 -0
- package/package.json +6 -1
- package/server/index.ts +19 -0
- package/server/lib/claude-bridge.ts +10 -1
- package/server/lib/codex-bridge.ts +1 -1
- package/server/lib/cursor-bridge.ts +1 -1
- package/server/lib/direct-bridge.ts +1 -1
- package/server/lib/morning-brief-schedule.ts +13 -7
- package/server/lib/query-job-coordinator.ts +5 -1
- package/server/lib/query-job-runtime.ts +3 -0
- package/server/lib/routines-config.ts +421 -0
- package/server/lib/routines-registry.ts +151 -0
- package/server/lib/routines-runtime.ts +64 -0
- package/server/lib/routines-scheduler.ts +509 -0
- 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/routines.ts +101 -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,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 6.83.0: lens plans in Work chat history, and "Send this plan" (docs/work-orchestrator.md, "Lens plans").
|
|
3
|
+
*
|
|
4
|
+
* Lens (over the network, token auth from the parent /api mount):
|
|
5
|
+
* POST /api/work/chat/lens-rounds record a plan the lens made (idempotent on clientRoundId)
|
|
6
|
+
* GET /api/work/chat/lens-rounds/:roundId the round's live state, never its prompts
|
|
7
|
+
* POST /api/work/chat/lens-rounds/:roundId/send ask COS Control to send it (two-step confirm, or card Auto-send)
|
|
8
|
+
* POST /api/work/chat/lens-rounds/:roundId/stop ask COS Control to Stop assignments
|
|
9
|
+
* COS Control (this Mac only):
|
|
10
|
+
* GET /api/work/chat/lens-inbox?since=<cursor>[&autoSend=1] rounds not yet in history, send and stop requests
|
|
11
|
+
* POST /api/work/chat/lens-rounds/:roundId/history { state: "in_history", autoSendCard? }
|
|
12
|
+
* POST /api/work/chat/lens-send/:sendRequestId/result { assignments: [{ id, state, receiptId?, detail?, sessionID? }] }
|
|
13
|
+
* POST /api/work/chat/lens-rounds/:roundId/mac-send the same shape, for COS Control's own Send of this round on the Mac
|
|
14
|
+
*
|
|
15
|
+
* Nothing here sends to a session: COS Control performs every send through its own Send path and reports back.
|
|
16
|
+
* Every route answers 503 `maintenance` while admissions are closed; writes hold the api_mutation lease themselves
|
|
17
|
+
* (index.ts leaves these paths to this router so the error code is the contract's).
|
|
18
|
+
*/
|
|
19
|
+
import { Router, type Request, type Response } from 'express'
|
|
20
|
+
import { LensRoundError, LensRoundStore, LENS_ROUND_BODY_BYTES, ROUND_ID, SEND_ID, cardSessionsOf, parseLensRound, parseResultBody,
|
|
21
|
+
parseSendBody, parseStopBody, roundView, defaultLensRoundStore } from '../lib/work-lens-rounds.js'
|
|
22
|
+
import type { ControlSnapshot } from '../lib/work-orchestrator/snapshot.js'
|
|
23
|
+
import { orchestratorSnapshots } from '../lib/work-orchestrator/runtime.js'
|
|
24
|
+
import { acquireMaintenanceWork, maintenanceAdmissionsOpen } from '../lib/maintenance-lifecycle.js'
|
|
25
|
+
import { isLoopbackRequest } from './work-handoff-requests.js'
|
|
26
|
+
import { listBoard, type TaskBoardRow } from '../lib/task-store.js'
|
|
27
|
+
import { createWorkBoardReader, type WorkBoardReader } from '../lib/work-board-reader.js'
|
|
28
|
+
|
|
29
|
+
export interface WorkLensRoundsDeps {
|
|
30
|
+
store: LensRoundStore
|
|
31
|
+
/** COS Control's Work snapshot (links and receipts), for the card's sessions. */
|
|
32
|
+
snapshot: () => ControlSnapshot | null
|
|
33
|
+
/** Whether the workID is a card the server knows: in COS Control's snapshot or on the Work board. Throws when unknowable. */
|
|
34
|
+
knownWork: (workID: string) => Promise<boolean>
|
|
35
|
+
admissions: () => boolean
|
|
36
|
+
lease: () => { release(): void }
|
|
37
|
+
isLocal: (req: Request) => boolean
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A card is known when COS Control's snapshot names it, or the Work board (a bounded read, 2 s old at most) lists it. */
|
|
41
|
+
export function knownWorkFrom(snapshot: () => ControlSnapshot | null, board: WorkBoardReader<TaskBoardRow>) {
|
|
42
|
+
return async (workID: string): Promise<boolean> => {
|
|
43
|
+
const s = snapshot()
|
|
44
|
+
if (s && (s.links.some(l => l.workID === workID) || s.receipts.some(r => r.workID === workID))) return true
|
|
45
|
+
const rows = await board.read(2_000)
|
|
46
|
+
return rows.some(r => `task:${r.domain}:${r.workIdentity || r.id}` === workID)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let defaultBoard: WorkBoardReader<TaskBoardRow> | null = null
|
|
51
|
+
const DEFAULTS = (): WorkLensRoundsDeps => ({
|
|
52
|
+
store: defaultLensRoundStore(),
|
|
53
|
+
snapshot: () => orchestratorSnapshots().get(),
|
|
54
|
+
knownWork: knownWorkFrom(() => orchestratorSnapshots().get(), defaultBoard ??= createWorkBoardReader(() => listBoard())),
|
|
55
|
+
admissions: maintenanceAdmissionsOpen,
|
|
56
|
+
lease: () => acquireMaintenanceWork('api_mutation'),
|
|
57
|
+
isLocal: isLoopbackRequest,
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
const MAINTENANCE = { error: 'maintenance', message: 'The server is updating. The plan is kept on the lens; try again shortly.' }
|
|
61
|
+
|
|
62
|
+
export function createWorkLensRoundsRouter(given: Partial<WorkLensRoundsDeps> = {}) {
|
|
63
|
+
const deps: WorkLensRoundsDeps = { ...DEFAULTS(), ...given }
|
|
64
|
+
const router = Router()
|
|
65
|
+
router.use(['/work/chat/lens-rounds', '/work/chat/lens-inbox', '/work/chat/lens-send'], (_req, res, next) => {
|
|
66
|
+
res.set('Cache-Control', 'private, no-store')
|
|
67
|
+
if (!deps.admissions()) return res.status(503).json(MAINTENANCE)
|
|
68
|
+
next()
|
|
69
|
+
})
|
|
70
|
+
const fail = (res: Response, e: unknown, where: string) => {
|
|
71
|
+
if (e instanceof LensRoundError) {
|
|
72
|
+
if (e.status >= 500 || where !== 'record') console.warn(`[work-lens-rounds] ${where}: ${e.status} ${e.code}`)
|
|
73
|
+
return res.status(e.status).json({ error: e.code, message: e.message, ...e.extra })
|
|
74
|
+
}
|
|
75
|
+
console.error(`[work-lens-rounds] ${where} failed: ${e instanceof Error ? e.message : String(e)}`)
|
|
76
|
+
return res.status(503).json({ error: 'lens_rounds_unavailable', message: 'Lens plans are unavailable. Try again shortly.' })
|
|
77
|
+
}
|
|
78
|
+
/** A write holds the api_mutation lease for its own (sub-millisecond) body; a closed gate is `maintenance`. */
|
|
79
|
+
const write = <T>(res: Response, where: string, fn: () => Promise<T> | T) => {
|
|
80
|
+
let lease: { release(): void }
|
|
81
|
+
try { lease = deps.lease() } catch { return res.status(503).json(MAINTENANCE) }
|
|
82
|
+
return Promise.resolve().then(fn).catch(e => fail(res, e, where)).finally(() => lease.release())
|
|
83
|
+
}
|
|
84
|
+
const local = (req: Request) => { if (!deps.isLocal(req)) throw new LensRoundError('local_only', 403, 'Only COS Control on this Mac can do this.') }
|
|
85
|
+
const roundIdOf = (req: Request) => {
|
|
86
|
+
const id = String(req.params.roundId ?? '')
|
|
87
|
+
if (!ROUND_ID.test(id)) throw new LensRoundError('unknown_round', 404, 'This plan is not on the server any more.')
|
|
88
|
+
return id
|
|
89
|
+
}
|
|
90
|
+
const connected = () => deps.store.controlStatus().connected
|
|
91
|
+
|
|
92
|
+
router.post('/work/chat/lens-rounds', (req, res) => write(res, 'record', async () => {
|
|
93
|
+
if (Buffer.byteLength(JSON.stringify(req.body ?? null)) > LENS_ROUND_BODY_BYTES) {
|
|
94
|
+
return res.status(413).json({ error: 'lens_round_too_large', message: 'This plan is too large to save.' })
|
|
95
|
+
}
|
|
96
|
+
const input = parseLensRound(req.body)
|
|
97
|
+
// A repeat is answered from the store, even when the board has since changed or is unreachable.
|
|
98
|
+
const replay = deps.store.replay(input.clientRoundId)
|
|
99
|
+
if (!replay) {
|
|
100
|
+
let known: boolean
|
|
101
|
+
try { known = await deps.knownWork(input.workID) }
|
|
102
|
+
catch { throw new LensRoundError('work_board_unavailable', 503, 'The Work board is unavailable. The plan is kept on the lens; try again shortly.') }
|
|
103
|
+
if (!known) throw new LensRoundError('unknown_work', 404, 'This card is not on the Work board any more.')
|
|
104
|
+
}
|
|
105
|
+
const { round, created } = deps.store.record(input, cardSessionsOf(deps.snapshot(), input.workID))
|
|
106
|
+
if (created) console.log(`[work-lens-rounds] recorded ${round.roundId} work=${round.workID} assignments=${round.assignments.length}`)
|
|
107
|
+
return res.status(created ? 201 : 200).json({ roundId: round.roundId, workID: round.workID, state: roundView(round).state, control: { connected: connected() } })
|
|
108
|
+
}))
|
|
109
|
+
|
|
110
|
+
router.get('/work/chat/lens-rounds/:roundId', (req, res) => {
|
|
111
|
+
try {
|
|
112
|
+
const round = deps.store.find(roundIdOf(req))
|
|
113
|
+
if (!round) throw new LensRoundError('unknown_round', 404, 'This plan is not on the server any more.')
|
|
114
|
+
return res.json(roundView(round))
|
|
115
|
+
} catch (e) { return fail(res, e, 'status') }
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
router.post('/work/chat/lens-rounds/:roundId/send', (req, res) => write(res, 'send', () => {
|
|
119
|
+
const roundId = roundIdOf(req)
|
|
120
|
+
const body = parseSendBody(req.body)
|
|
121
|
+
const round = deps.store.find(roundId)
|
|
122
|
+
if (!round) throw new LensRoundError('unknown_round', 404, 'This plan is not on the server any more.')
|
|
123
|
+
const send = deps.store.requestSend(roundId, body, { cardSessions: cardSessionsOf(deps.snapshot(), round.workID), controlConnected: connected() })
|
|
124
|
+
console.log(`[work-lens-rounds] send requested ${send.id} round=${roundId} assignments=${send.assignmentIds.join(',')} confirm=${send.confirm ?? 'auto-send'}`)
|
|
125
|
+
return res.status(202).json({ sendRequestId: send.id, state: 'send_requested' })
|
|
126
|
+
}))
|
|
127
|
+
|
|
128
|
+
router.post('/work/chat/lens-rounds/:roundId/stop', (req, res) => write(res, 'stop', () => {
|
|
129
|
+
const roundId = roundIdOf(req)
|
|
130
|
+
const stop = deps.store.requestStop(roundId, parseStopBody(req.body), connected())
|
|
131
|
+
console.log(`[work-lens-rounds] stop requested ${stop.id} round=${roundId} assignments=${stop.assignmentIds.join(',')}`)
|
|
132
|
+
return res.status(202).json({ stopRequestId: stop.id, state: 'stop_requested' })
|
|
133
|
+
}))
|
|
134
|
+
|
|
135
|
+
// COS Control: polling this is what the capability reports as connected. Listing a send or a stop picks it up.
|
|
136
|
+
router.get('/work/chat/lens-inbox', (req, res) => {
|
|
137
|
+
try {
|
|
138
|
+
local(req)
|
|
139
|
+
const { since, autoSend } = req.query
|
|
140
|
+
if (Object.keys(req.query).some(k => k !== 'since' && k !== 'autoSend')
|
|
141
|
+
|| (since !== undefined && !(typeof since === 'string' && /^\d{1,12}$/.test(since)))
|
|
142
|
+
|| (autoSend !== undefined && autoSend !== '0' && autoSend !== '1')) {
|
|
143
|
+
throw new LensRoundError('invalid_lens_inbox', 400, 'since is a cursor and autoSend is 0 or 1.')
|
|
144
|
+
}
|
|
145
|
+
return res.json(deps.store.inbox(since === undefined ? 0 : Number(since), autoSend === '1'))
|
|
146
|
+
} catch (e) { return fail(res, e, 'inbox') }
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
router.post('/work/chat/lens-rounds/:roundId/history', (req, res) => write(res, 'history', () => {
|
|
150
|
+
local(req)
|
|
151
|
+
const roundId = roundIdOf(req)
|
|
152
|
+
const b = req.body
|
|
153
|
+
if (!b || typeof b !== 'object' || Array.isArray(b) || b.state !== 'in_history' || (b.autoSendCard !== undefined && typeof b.autoSendCard !== 'boolean')) {
|
|
154
|
+
throw new LensRoundError('invalid_lens_round', 400, 'History takes { state: "in_history" }.', { field: 'state' })
|
|
155
|
+
}
|
|
156
|
+
const round = deps.store.markInHistory(roundId, b.autoSendCard)
|
|
157
|
+
return res.json(roundView(round))
|
|
158
|
+
}))
|
|
159
|
+
|
|
160
|
+
// COS Control: it sent assignments of this round with its own Send on the Mac. Same assignments[] shape as a result.
|
|
161
|
+
router.post('/work/chat/lens-rounds/:roundId/mac-send', (req, res) => write(res, 'mac-send', () => {
|
|
162
|
+
local(req)
|
|
163
|
+
const round = deps.store.reportMac(roundIdOf(req), parseResultBody(req.body))
|
|
164
|
+
const view = roundView(round)
|
|
165
|
+
console.log(`[work-lens-rounds] mac send round=${round.roundId} state=${view.state} ${view.assignments.map(a => `${a.id}:${a.state}`).join(' ')}`)
|
|
166
|
+
return res.json(view)
|
|
167
|
+
}))
|
|
168
|
+
|
|
169
|
+
router.post('/work/chat/lens-send/:sendRequestId/result', (req, res) => write(res, 'result', () => {
|
|
170
|
+
local(req)
|
|
171
|
+
const id = String(req.params.sendRequestId ?? '')
|
|
172
|
+
if (!SEND_ID.test(id)) throw new LensRoundError('unknown_send', 404, 'No such send request.')
|
|
173
|
+
const round = deps.store.report(id, parseResultBody(req.body))
|
|
174
|
+
const view = roundView(round)
|
|
175
|
+
console.log(`[work-lens-rounds] result ${id} round=${round.roundId} state=${view.state} ${view.assignments.map(a => `${a.id}:${a.state}`).join(' ')}`)
|
|
176
|
+
return res.json(view)
|
|
177
|
+
}))
|
|
178
|
+
return router
|
|
179
|
+
}
|
|
@@ -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 }) }
|