@gotcos/glasses-server 7.0.7 → 7.0.27
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/.env.example +19 -0
- package/CHANGELOG.md +305 -0
- package/docs/web-key-verifier-contract.md +378 -0
- package/managed-runtime-contract.json +9 -1
- package/package.json +10 -3
- package/server/bootstrap.ts +10 -0
- package/server/index.ts +129 -23
- package/server/lib/agent-session-older.ts +218 -0
- package/server/lib/agent-session-recent.ts +321 -0
- package/server/lib/agent-session-store.ts +102 -20
- package/server/lib/api-auth.ts +37 -1
- package/server/lib/calendar-meetings.ts +70 -0
- package/server/lib/calendar-source.ts +54 -9
- package/server/lib/claude-hooks-installer.ts +111 -12
- package/server/lib/conversation.ts +56 -28
- package/server/lib/cors-reject-log.ts +51 -0
- package/server/lib/cos-started-sessions.ts +101 -0
- package/server/lib/disk-io-meter.ts +202 -0
- package/server/lib/drop-service.ts +47 -12
- package/server/lib/drop-store.ts +21 -1
- package/server/lib/event-loop-stall.ts +62 -0
- package/server/lib/full-hold-engine.ts +114 -0
- package/server/lib/full-hold.ts +124 -0
- package/server/lib/g2-turn-provenance.ts +128 -9
- package/server/lib/list-model-memo.ts +85 -0
- package/server/lib/listener-startup.ts +18 -0
- package/server/lib/meeting-batch-transcribe.ts +32 -5
- package/server/lib/model-efforts.ts +40 -0
- package/server/lib/permission-broker.ts +443 -11
- package/server/lib/secret-path-guard.ts +4 -1
- package/server/lib/session-cache-writer.ts +8 -1
- package/server/lib/session-hook-events.ts +23 -1
- package/server/lib/session-hooks-runtime.ts +34 -0
- package/server/lib/session-log.ts +313 -4
- package/server/lib/session-organizer.ts +305 -0
- package/server/lib/session-state-derive.ts +36 -0
- package/server/lib/session-stream-events.ts +39 -3
- package/server/lib/thread-turn-queue.ts +24 -0
- package/server/lib/today-brief.ts +522 -58
- package/server/lib/turn-end-ask.ts +320 -0
- package/server/lib/turn-footer.ts +3 -3
- package/server/lib/web-control-integrity.ts +160 -0
- package/server/lib/web-control-offnet-page.ts +58 -0
- package/server/lib/web-control-static.ts +53 -9
- package/server/lib/web-key-authority.ts +372 -0
- package/server/lib/web-key-envelope.ts +98 -0
- package/server/lib/web-key-handoff.ts +135 -0
- package/server/lib/web-key-origin.ts +21 -0
- package/server/lib/web-key-passkey.ts +155 -0
- package/server/lib/web-key-registry.ts +825 -0
- package/server/lib/web-key-signer-app.ts +152 -0
- package/server/lib/web-session-auth.ts +133 -15
- package/server/lib/whisper-local.ts +93 -7
- package/server/lib/whisper-preview.ts +5 -3
- package/server/lib/work-orchestrator/snapshot.ts +17 -3
- package/server/lib/work-review-runtime.ts +17 -4
- package/server/routes/agent-session-bindings.ts +16 -1
- package/server/routes/agent-session-stream.ts +7 -0
- package/server/routes/agent-sessions.ts +106 -2
- package/server/routes/drops.ts +7 -2
- package/server/routes/g2-turns.ts +35 -0
- package/server/routes/health.ts +14 -1
- package/server/routes/live-events.ts +6 -1
- package/server/routes/permission-broker.ts +26 -0
- package/server/routes/query-jobs.ts +3 -0
- package/server/routes/session-organizer.ts +149 -0
- package/server/routes/sessions.ts +15 -24
- package/server/routes/thread-turn-queue.ts +50 -14
- package/server/routes/today-brief.ts +91 -36
- package/server/routes/web-control.ts +37 -4
- package/server/routes/web-key.ts +284 -0
- package/server/routes/welcome-context.ts +6 -2
- package/server/routes/work-orchestrator.ts +12 -7
- package/server/scripts/write-web-control-manifest.mjs +43 -0
- package/server/web-control/.manifest.json +44 -0
- package/server/web-control/app.css +45 -0
- package/server/web-control/app.js +1680 -108
- package/server/web-control/apple-touch-icon.png +0 -0
- package/server/web-control/index.html +54 -17
- package/server/web-control/panes/cos-chat.css +52 -6
- package/server/web-control/panes/cos-chat.js +364 -32
- package/server/web-control/panes/questions.js +135 -14
- package/server/web-control/panes/sessions.css +280 -0
- package/server/web-control/panes/settings.js +1 -1
- package/server/web-control/panes/today-actions.css +35 -0
- package/server/web-control/panes/today-actions.js +121 -6
- package/server/web-control/panes/work.css +3 -0
- package/server/web-control/panes/work.js +185 -38
- package/server/web-control/theme.js +33 -0
- package/server/web-signer/.manifest.json +11 -0
- package/server/web-signer/index.html +74 -0
- package/server/web-signer/signer-proof.js +348 -0
- package/server/web-signer/signer-store.js +39 -0
- package/server/web-signer/signer.css +23 -0
- package/server/web-signer/signer.js +341 -0
- package/shared/web-approval-packets.golden.json +124 -0
- package/shared/web-turn-envelope.ts +271 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// What the 3144 signer listener serves (6.99.31, plan section 4.8, review B2 and W2). Only with
|
|
2
|
+
// COS_WEB_KEY_AUTHORITY on: index.ts builds this handler and the 3144 listener only then
|
|
3
|
+
// (lib/listener-startup.ts listenerPlan), so with the flag off none of it exists.
|
|
4
|
+
//
|
|
5
|
+
// THE ORIGIN HOLDS THE KEY AND NOTHING ELSE. The big Control Web app (transcripts, previews, the
|
|
6
|
+
// memories frame) stays on 3143. On 3144:
|
|
7
|
+
// - the page and its three scripts and one stylesheet (server/web-signer/), served ONLY from bytes
|
|
8
|
+
// that matched their pack-time manifest (lib/web-control-integrity.ts, a second instance over
|
|
9
|
+
// web-signer/). No manifest, a changed file, an extra file: a plain error page, nothing signs.
|
|
10
|
+
// - a short allowlist of /api routes the page calls (key status, nonce, enrollment, revoke, the
|
|
11
|
+
// handoff claim, and the one turn route plus its status read). Everything else is 404 here, so
|
|
12
|
+
// no transcript, meeting or memory route answers on this origin.
|
|
13
|
+
// - an API request that names any other Origin is refused, and a write must name this one, so
|
|
14
|
+
// no other page (3143 included) can drive this origin's API with the phone's cookie.
|
|
15
|
+
// - every response: a strict CSP with no inline script or style and frame-ancestors 'none',
|
|
16
|
+
// X-Frame-Options DENY, COOP and CORP same-origin, no referrer, no caching.
|
|
17
|
+
|
|
18
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
19
|
+
import type { WebControlIntegrity } from './web-control-integrity.js'
|
|
20
|
+
import type { IntegrityReport } from './web-key-authority.js'
|
|
21
|
+
|
|
22
|
+
export const WEB_SIGNER_CSP = [
|
|
23
|
+
"default-src 'none'",
|
|
24
|
+
"script-src 'self'",
|
|
25
|
+
"style-src 'self'",
|
|
26
|
+
"img-src 'self'",
|
|
27
|
+
"connect-src 'self'",
|
|
28
|
+
"base-uri 'none'",
|
|
29
|
+
"form-action 'none'",
|
|
30
|
+
"frame-ancestors 'none'",
|
|
31
|
+
"frame-src 'none'",
|
|
32
|
+
"object-src 'none'",
|
|
33
|
+
"manifest-src 'none'",
|
|
34
|
+
"worker-src 'none'",
|
|
35
|
+
"require-trusted-types-for 'script'",
|
|
36
|
+
"trusted-types 'none'",
|
|
37
|
+
].join('; ')
|
|
38
|
+
|
|
39
|
+
export const WEB_SIGNER_HEADERS: Readonly<Record<string, string>> = Object.freeze({
|
|
40
|
+
'Content-Security-Policy': WEB_SIGNER_CSP,
|
|
41
|
+
'X-Frame-Options': 'DENY',
|
|
42
|
+
'Cross-Origin-Opener-Policy': 'same-origin',
|
|
43
|
+
'Cross-Origin-Resource-Policy': 'same-origin',
|
|
44
|
+
'Referrer-Policy': 'no-referrer',
|
|
45
|
+
'X-Content-Type-Options': 'nosniff',
|
|
46
|
+
'Cache-Control': 'no-store',
|
|
47
|
+
'Permissions-Policy': 'publickey-credentials-get=(self), publickey-credentials-create=(self), camera=(), microphone=(), geolocation=(), payment=()',
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
/** The only /api routes the signer origin answers. Method plus exact path shape. */
|
|
51
|
+
export const WEB_SIGNER_API_ALLOWLIST: ReadonlyArray<{ method: 'GET' | 'POST'; path: RegExp }> = Object.freeze([
|
|
52
|
+
{ method: 'GET', path: /^\/api\/web-key\/status$/ },
|
|
53
|
+
{ method: 'GET', path: /^\/api\/web-key\/keys$/ },
|
|
54
|
+
{ method: 'POST', path: /^\/api\/web-key\/nonce$/ },
|
|
55
|
+
{ method: 'POST', path: /^\/api\/web-key\/enroll\/start$/ },
|
|
56
|
+
{ method: 'POST', path: /^\/api\/web-key\/enroll\/prove$/ },
|
|
57
|
+
{ method: 'GET', path: /^\/api\/web-key\/enroll\/status$/ },
|
|
58
|
+
{ method: 'POST', path: /^\/api\/web-key\/revoke$/ },
|
|
59
|
+
{ method: 'POST', path: /^\/api\/web-key\/handoff\/claim$/ },
|
|
60
|
+
{ method: 'POST', path: /^\/api\/agent-sessions\/bindings\/[A-Za-z0-9][A-Za-z0-9_-]{0,127}\/turns$/ },
|
|
61
|
+
{ method: 'GET', path: /^\/api\/agent-sessions\/bindings\/[A-Za-z0-9][A-Za-z0-9_-]{0,127}\/turns\/[A-Za-z0-9][A-Za-z0-9_-]{7,127}$/ },
|
|
62
|
+
])
|
|
63
|
+
|
|
64
|
+
export function signerApiAllowed(method: string | undefined, pathname: string): boolean {
|
|
65
|
+
const m = String(method ?? 'GET').toUpperCase()
|
|
66
|
+
return WEB_SIGNER_API_ALLOWLIST.some(r => r.method === m && r.path.test(pathname))
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const TYPES: Record<string, string> = {
|
|
70
|
+
'.html': 'text/html; charset=utf-8',
|
|
71
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
72
|
+
'.css': 'text/css; charset=utf-8',
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export const SIGNER_PAUSED_PAGE = '<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>COS approval paused</title></head><body><h1>COS approval is paused</h1><p>The approval page on this Mac does not match the one that shipped with this version, so it is not being served. Nothing was signed.</p><p>Open COS Control on the Mac to repair or reinstall the server.</p></body></html>'
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The integrity the authority reads (review W1), Phase 3: the signer page holds the key, so a turn is
|
|
79
|
+
* vouched only while BOTH the Control Web tree and the signer tree match their manifests. Null while
|
|
80
|
+
* Control Web integrity is off (`integrity_off`, as before). The shape is the contract's, unchanged.
|
|
81
|
+
*/
|
|
82
|
+
export function combinedIntegrityReport(webControl: Pick<WebControlIntegrity, 'state' | 'manifestSha256'> | null, signer: Pick<WebControlIntegrity, 'state'> | null): IntegrityReport | null {
|
|
83
|
+
if (!webControl) return null
|
|
84
|
+
const signerOk = signer !== null && signer.state.ok === true
|
|
85
|
+
return { enabled: true, lastCheckOk: webControl.state.ok === true && signerOk, checkedAt: webControl.state.checkedAt || null, manifestSha256: webControl.manifestSha256 }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface WebKeySignerDeps {
|
|
89
|
+
/** The integrity check over server/web-signer/. Null: nothing is served (the page is never read from disk unchecked). */
|
|
90
|
+
integrity: WebControlIntegrity | null
|
|
91
|
+
/** The main app, for the allowlisted /api routes. */
|
|
92
|
+
api: (req: IncomingMessage, res: ServerResponse) => void
|
|
93
|
+
/** `https://<MagicDNS name>:3144` (lib/web-key-origin.ts), or null when unknown. */
|
|
94
|
+
origin: () => string | null
|
|
95
|
+
/** How often the signer's files are re-checked. 0: never on a timer (tests call integrity.check()). */
|
|
96
|
+
recheckMs?: number
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function applyHeaders(res: ServerResponse): void {
|
|
100
|
+
for (const [k, v] of Object.entries(WEB_SIGNER_HEADERS)) res.setHeader(k, v)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function send(res: ServerResponse, status: number, type: string, body: string | Buffer, head = false): void {
|
|
104
|
+
res.statusCode = status
|
|
105
|
+
res.setHeader('Content-Type', type)
|
|
106
|
+
const buf = typeof body === 'string' ? Buffer.from(body, 'utf8') : body
|
|
107
|
+
res.setHeader('Content-Length', String(buf.length))
|
|
108
|
+
res.end(head ? undefined : buf)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function json(res: ServerResponse, status: number, value: Record<string, unknown>): void {
|
|
112
|
+
send(res, status, 'application/json; charset=utf-8', JSON.stringify(value))
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function createWebKeySignerHandler(deps: WebKeySignerDeps): ((req: IncomingMessage, res: ServerResponse) => void) & { stop: () => void } {
|
|
116
|
+
let timer: ReturnType<typeof setInterval> | null = null
|
|
117
|
+
const every = deps.recheckMs ?? 60_000
|
|
118
|
+
if (deps.integrity && every > 0) {
|
|
119
|
+
const integrity = deps.integrity
|
|
120
|
+
timer = setInterval(() => { try { integrity.check() } catch { /* the next tick retries */ } }, every)
|
|
121
|
+
timer.unref?.()
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const handler = (req: IncomingMessage, res: ServerResponse): void => {
|
|
125
|
+
applyHeaders(res)
|
|
126
|
+
let pathname: string
|
|
127
|
+
try { pathname = new URL(req.url ?? '/', 'https://signer.invalid').pathname } catch { json(res, 400, { ok: false, error: 'invalid_request' }); return }
|
|
128
|
+
const method = String(req.method ?? 'GET').toUpperCase()
|
|
129
|
+
|
|
130
|
+
if (pathname === '/api' || pathname.startsWith('/api/')) {
|
|
131
|
+
const expected = deps.origin()
|
|
132
|
+
const origin = typeof req.headers.origin === 'string' ? req.headers.origin : undefined
|
|
133
|
+
// Another origin's page never drives this one. A write must name this origin outright.
|
|
134
|
+
if (origin !== undefined && origin !== expected) { json(res, 403, { ok: false, error: 'origin_refused' }); return }
|
|
135
|
+
if (method !== 'GET' && method !== 'HEAD' && (expected === null || origin !== expected)) { json(res, 403, { ok: false, error: 'origin_refused' }); return }
|
|
136
|
+
if (!signerApiAllowed(method === 'HEAD' ? 'GET' : method, pathname)) { json(res, 404, { ok: false, error: 'not_found' }); return }
|
|
137
|
+
deps.api(req, res)
|
|
138
|
+
return
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (method !== 'GET' && method !== 'HEAD') { res.setHeader('Allow', 'GET, HEAD'); json(res, 405, { ok: false, error: 'method_not_allowed' }); return }
|
|
142
|
+
const head = method === 'HEAD'
|
|
143
|
+
const snapshot = deps.integrity ? deps.integrity.snapshot() : null
|
|
144
|
+
if (!snapshot) { send(res, 503, 'text/html; charset=utf-8', SIGNER_PAUSED_PAGE, head); return }
|
|
145
|
+
const file = pathname === '/' ? '/index.html' : pathname
|
|
146
|
+
const ext = file.slice(file.lastIndexOf('.')).toLowerCase()
|
|
147
|
+
const body = TYPES[ext] ? snapshot.get(file) : undefined
|
|
148
|
+
if (!body) { send(res, 404, 'text/plain; charset=utf-8', 'Not found', head); return }
|
|
149
|
+
send(res, 200, TYPES[ext], body, head)
|
|
150
|
+
}
|
|
151
|
+
return Object.assign(handler, { stop: () => { if (timer) clearInterval(timer); timer = null } })
|
|
152
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
|
|
1
|
+
import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
|
|
2
2
|
import { mkdirSync, readFileSync } from 'node:fs'
|
|
3
3
|
import { durableAtomicWriteFileSync } from './atomic-fs.js'
|
|
4
4
|
import { dirname } from 'node:path'
|
|
@@ -19,6 +19,12 @@ import { CROCKFORD_ALPHABET, PAIRING_CODE_ATTEMPT_LIMIT, PAIRING_CODE_LENGTH } f
|
|
|
19
19
|
export const WEB_SESSION_COOKIE_NAME = 'cos_web_session'
|
|
20
20
|
export const WEB_CSRF_HEADER = 'x-cos-web'
|
|
21
21
|
export const WEB_SESSION_TTL_MS = 7 * 24 * 60 * 60_000
|
|
22
|
+
/**
|
|
23
|
+
* 7.0.x sign-in renewal (Miles 2026-11-09, "Renew while in use, 30-day cap"). A browser that is used in the last
|
|
24
|
+
* RENEW_WINDOW of its cookie gets a fresh one, never running past MAX_LIFE from the time it first signed in.
|
|
25
|
+
*/
|
|
26
|
+
export const WEB_SESSION_RENEW_WINDOW_MS = 2 * 24 * 60 * 60_000
|
|
27
|
+
export const WEB_SESSION_MAX_LIFE_MS = 30 * 24 * 60 * 60_000
|
|
22
28
|
export const WEB_HANDOFF_TTL_MS = 2 * 60_000
|
|
23
29
|
export const WEB_HANDOFF_MAX_ACTIVE = 16
|
|
24
30
|
|
|
@@ -43,6 +49,17 @@ function signSession(apiToken: string, expUnixSec: number, epoch: number): strin
|
|
|
43
49
|
.digest('hex')
|
|
44
50
|
}
|
|
45
51
|
|
|
52
|
+
/**
|
|
53
|
+
* A RENEWED cookie also carries the time the browser first signed in (`orig`), so no renewal can pass the 30-day cap.
|
|
54
|
+
* Same key as above (derived from the API token: no new secret); the signed text starts with `v3:` and names `orig`,
|
|
55
|
+
* so a 3-part signature can never stand for a 4-part cookie, and `orig` cannot be edited without breaking it.
|
|
56
|
+
*/
|
|
57
|
+
function signRenewedSession(apiToken: string, expUnixSec: number, epoch: number, origUnixSec: number): string {
|
|
58
|
+
return createHmac('sha256', `cos_web_session_v2:${apiToken}`)
|
|
59
|
+
.update(`v3:${expUnixSec}:${epoch}:${origUnixSec}`)
|
|
60
|
+
.digest('hex')
|
|
61
|
+
}
|
|
62
|
+
|
|
46
63
|
// ── Revocation (review W1) ───────────────────────────────────────────────────
|
|
47
64
|
// The cookie signs a session EPOCH kept on the Mac. "Sign out every browser" bumps it, so
|
|
48
65
|
// every copy of every cookie stops working at once, without rotating the pairing token
|
|
@@ -117,19 +134,66 @@ export function verifyWebSessionCookieValue(
|
|
|
117
134
|
nowMs = Date.now(),
|
|
118
135
|
epoch = currentWebSessionEpoch(),
|
|
119
136
|
): boolean {
|
|
120
|
-
|
|
137
|
+
return readWebSessionCookie(cookieValue, apiToken, nowMs, epoch) !== null
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The verified cookie's expiry and first sign-in time (unix seconds), or null for any cookie that does not verify.
|
|
142
|
+
* Two shapes verify:
|
|
143
|
+
* `<exp>.<epoch>.<sig>` minted at sign-in (unchanged). Its first sign-in time is its issue time, exp minus 7 days.
|
|
144
|
+
* `<exp>.<epoch>.<orig>.<sig>` minted by a renewal; exp may never pass orig plus 30 days, and orig is never in the future.
|
|
145
|
+
*/
|
|
146
|
+
export function readWebSessionCookie(
|
|
147
|
+
cookieValue: string | undefined,
|
|
148
|
+
apiToken: string,
|
|
149
|
+
nowMs = Date.now(),
|
|
150
|
+
epoch = currentWebSessionEpoch(),
|
|
151
|
+
): { expUnixSec: number; origUnixSec: number; renewed: boolean } | null {
|
|
152
|
+
if (typeof cookieValue !== 'string' || !apiToken) return null
|
|
121
153
|
const parts = cookieValue.split('.')
|
|
122
|
-
if (parts.length !== 3) return
|
|
123
|
-
const
|
|
124
|
-
|
|
154
|
+
if (parts.length !== 3 && parts.length !== 4) return null
|
|
155
|
+
const renewed = parts.length === 4
|
|
156
|
+
const expPart = parts[0], epochPart = parts[1], origPart = renewed ? parts[2] : null, sigPart = parts[parts.length - 1]
|
|
157
|
+
if (!/^\d{1,15}$/.test(expPart) || !/^\d{1,12}$/.test(epochPart) || !/^[0-9a-f]{64}$/.test(sigPart)) return null
|
|
158
|
+
if (origPart !== null && !/^\d{1,15}$/.test(origPart)) return null
|
|
125
159
|
const expUnixSec = Number(expPart)
|
|
126
|
-
if (!Number.isSafeInteger(expUnixSec) || expUnixSec * 1000 <= nowMs) return
|
|
160
|
+
if (!Number.isSafeInteger(expUnixSec) || expUnixSec * 1000 <= nowMs) return null
|
|
161
|
+
const origUnixSec = origPart !== null ? Number(origPart) : expUnixSec - Math.floor(WEB_SESSION_TTL_MS / 1000)
|
|
162
|
+
if (!Number.isSafeInteger(origUnixSec)) return null
|
|
163
|
+
if (renewed && (origUnixSec * 1000 > nowMs || expUnixSec > origUnixSec + Math.floor(WEB_SESSION_MAX_LIFE_MS / 1000))) return null
|
|
127
164
|
// The signature covers the CURRENT epoch, so a cookie from before "Sign out every browser"
|
|
128
165
|
// (or one whose epoch digits were edited) fails here; epochPart is only shape-checked.
|
|
129
|
-
const expected = signSession(apiToken, expUnixSec, epoch)
|
|
166
|
+
const expected = renewed ? signRenewedSession(apiToken, expUnixSec, epoch, origUnixSec) : signSession(apiToken, expUnixSec, epoch)
|
|
130
167
|
const left = Buffer.from(sigPart, 'utf8')
|
|
131
168
|
const right = Buffer.from(expected, 'utf8')
|
|
132
|
-
|
|
169
|
+
if (left.length !== right.length || !timingSafeEqual(left, right)) return null
|
|
170
|
+
return { expUnixSec, origUnixSec, renewed }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The renewal for a cookie that ALREADY verified, or null. Only in the last 2 days of its life, and never past 30 days
|
|
175
|
+
* from its first sign-in: the new expiry is the earlier of now plus 7 days and that cap, and a renewal that would not
|
|
176
|
+
* move the expiry later is not made. The caller (the /api gate) calls this only after every other check passed.
|
|
177
|
+
*/
|
|
178
|
+
export function renewWebSessionCookieValue(
|
|
179
|
+
cookieValue: string | undefined,
|
|
180
|
+
apiToken: string,
|
|
181
|
+
nowMs = Date.now(),
|
|
182
|
+
epoch = currentWebSessionEpoch(),
|
|
183
|
+
): { value: string; expiresAtMs: number; capAtMs: number } | null {
|
|
184
|
+
const read = readWebSessionCookie(cookieValue, apiToken, nowMs, epoch)
|
|
185
|
+
if (!read) return null
|
|
186
|
+
if (read.expUnixSec * 1000 - nowMs > WEB_SESSION_RENEW_WINDOW_MS) return null
|
|
187
|
+
const capUnixSec = read.origUnixSec + Math.floor(WEB_SESSION_MAX_LIFE_MS / 1000)
|
|
188
|
+
const expUnixSec = Math.min(Math.floor((nowMs + WEB_SESSION_TTL_MS) / 1000), capUnixSec)
|
|
189
|
+
if (expUnixSec <= read.expUnixSec) return null
|
|
190
|
+
const sig = signRenewedSession(apiToken, expUnixSec, epoch, read.origUnixSec)
|
|
191
|
+
return { value: `${expUnixSec}.${epoch}.${read.origUnixSec}.${sig}`, expiresAtMs: expUnixSec * 1000, capAtMs: capUnixSec * 1000 }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** The raw session cookie on a request header, for the renewal (lib/api-auth.ts) and the status route. */
|
|
195
|
+
export function webSessionCookieFrom(cookieHeader: unknown): string | undefined {
|
|
196
|
+
return parseCookies(typeof cookieHeader === 'string' ? cookieHeader : undefined)[WEB_SESSION_COOKIE_NAME]
|
|
133
197
|
}
|
|
134
198
|
|
|
135
199
|
export function formatSetWebSessionCookie(value: string, maxAgeSec: number, secure = false): string {
|
|
@@ -249,13 +313,36 @@ interface HandoffRecord {
|
|
|
249
313
|
* open the user's browser directly into an authenticated session without putting
|
|
250
314
|
* the standing `COS_API_TOKEN` in a URL or touching `PairingState`.
|
|
251
315
|
*/
|
|
316
|
+
/**
|
|
317
|
+
* Why a handoff ticket did not sign in (S1 item 7, lead 20:11). Logged only; the person sees the same sentence for all.
|
|
318
|
+
* expired its 2 minutes ran out used it already signed a browser in
|
|
319
|
+
* evicted more than 16 were open unknown never issued here, or issued before this server started
|
|
320
|
+
*/
|
|
321
|
+
export type WebHandoffFailure = 'expired' | 'used' | 'evicted' | 'revoked' | 'unknown'
|
|
322
|
+
/** A retired ticket is remembered (as a SHA-256, never the ticket) this long and at most this many, only to say why. */
|
|
323
|
+
export const WEB_HANDOFF_RETIRED_MS = 60 * 60_000
|
|
324
|
+
export const WEB_HANDOFF_RETIRED_MAX = 256
|
|
325
|
+
|
|
252
326
|
export class WebHandoffStore {
|
|
253
327
|
private readonly tickets = new Map<string, HandoffRecord>()
|
|
328
|
+
private readonly retired = new Map<string, { why: Exclude<WebHandoffFailure, 'unknown'>; at: number }>()
|
|
254
329
|
constructor(private readonly now: () => number = () => Date.now()) {}
|
|
255
330
|
|
|
331
|
+
private static digest(ticket: string): string { return createHash('sha256').update(ticket).digest('hex') }
|
|
332
|
+
|
|
333
|
+
private retire(ticket: string, why: Exclude<WebHandoffFailure, 'unknown'>, at: number): void {
|
|
334
|
+
const key = WebHandoffStore.digest(ticket)
|
|
335
|
+
this.retired.delete(key)
|
|
336
|
+
this.retired.set(key, { why, at })
|
|
337
|
+
for (const [k, rec] of this.retired) {
|
|
338
|
+
if (this.retired.size <= WEB_HANDOFF_RETIRED_MAX && at - rec.at < WEB_HANDOFF_RETIRED_MS) break
|
|
339
|
+
this.retired.delete(k)
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
256
343
|
private sweep(at: number): void {
|
|
257
344
|
for (const [k, rec] of this.tickets) {
|
|
258
|
-
if (at >= rec.expiresAtMs) this.tickets.delete(k)
|
|
345
|
+
if (at >= rec.expiresAtMs) { this.tickets.delete(k); this.retire(k, 'expired', at) }
|
|
259
346
|
}
|
|
260
347
|
}
|
|
261
348
|
|
|
@@ -266,6 +353,7 @@ export class WebHandoffStore {
|
|
|
266
353
|
const oldest = this.tickets.keys().next().value
|
|
267
354
|
if (!oldest) break
|
|
268
355
|
this.tickets.delete(oldest)
|
|
356
|
+
this.retire(oldest, 'evicted', at)
|
|
269
357
|
}
|
|
270
358
|
const ticket = randomBytes(24).toString('base64url')
|
|
271
359
|
const expiresAtMs = at + ttlMs
|
|
@@ -274,13 +362,35 @@ export class WebHandoffStore {
|
|
|
274
362
|
}
|
|
275
363
|
|
|
276
364
|
consume(ticket: unknown): boolean {
|
|
365
|
+
return this.consumeWithReason(ticket).ok
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** As `consume`, and on failure why. Single use and expiry are unchanged. */
|
|
369
|
+
consumeWithReason(ticket: unknown): { ok: true } | { ok: false; why: WebHandoffFailure } {
|
|
277
370
|
const at = this.now()
|
|
278
371
|
this.sweep(at)
|
|
279
|
-
if (typeof ticket !== 'string' || !HANDOFF_TICKET_PATTERN.test(ticket)) return false
|
|
372
|
+
if (typeof ticket !== 'string' || !HANDOFF_TICKET_PATTERN.test(ticket)) return { ok: false, why: 'unknown' }
|
|
280
373
|
const existing = this.tickets.get(ticket)
|
|
281
|
-
if (!existing)
|
|
374
|
+
if (!existing) {
|
|
375
|
+
const past = this.retired.get(WebHandoffStore.digest(ticket))
|
|
376
|
+
return { ok: false, why: past && at - past.at < WEB_HANDOFF_RETIRED_MS ? past.why : 'unknown' }
|
|
377
|
+
}
|
|
282
378
|
this.tickets.delete(ticket)
|
|
283
|
-
|
|
379
|
+
if (at >= existing.expiresAtMs) { this.retire(ticket, 'expired', at); return { ok: false, why: 'expired' } }
|
|
380
|
+
this.retire(ticket, 'used', at)
|
|
381
|
+
return { ok: true }
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Final review V7 (lead 2026-11-10 02:18): "Sign out every browser" also ends every sign-in link not yet used, so
|
|
386
|
+
* one made just before it cannot sign a browser in after it. Each is remembered as `revoked` for the log. Returns
|
|
387
|
+
* how many it ended.
|
|
388
|
+
*/
|
|
389
|
+
revokeAll(): number {
|
|
390
|
+
const at = this.now()
|
|
391
|
+
const live = [...this.tickets.keys()]
|
|
392
|
+
for (const ticket of live) { this.tickets.delete(ticket); this.retire(ticket, 'revoked', at) }
|
|
393
|
+
return live.length
|
|
284
394
|
}
|
|
285
395
|
}
|
|
286
396
|
|
|
@@ -289,7 +399,7 @@ export type WebLoginFailure = 'wrong_token' | 'wrong_code' | 'glasses_code' | 'h
|
|
|
289
399
|
|
|
290
400
|
export type WebLoginResult =
|
|
291
401
|
| { ok: true; kind: 'token' | 'handoff' | 'code' }
|
|
292
|
-
| { ok: false; reason: WebLoginFailure; length: number }
|
|
402
|
+
| { ok: false; reason: WebLoginFailure; length: number; handoff?: WebHandoffFailure }
|
|
293
403
|
|
|
294
404
|
// What a phone paste drags along with a token: spaces and line breaks (a copy out of a
|
|
295
405
|
// terminal or a note), zero-width and no-break characters, and the quotes around a value
|
|
@@ -327,9 +437,10 @@ export function classifyWebLogin(
|
|
|
327
437
|
}
|
|
328
438
|
if (handoffStore && typeof input.handoff === 'string' && input.handoff.trim().length > 0) {
|
|
329
439
|
const ticket = input.handoff.trim()
|
|
330
|
-
|
|
440
|
+
const used = handoffStore.consumeWithReason(ticket)
|
|
441
|
+
return used.ok
|
|
331
442
|
? { ok: true, kind: 'handoff' }
|
|
332
|
-
: { ok: false, reason: 'handoff_expired', length: ticket.length }
|
|
443
|
+
: { ok: false, reason: 'handoff_expired', length: ticket.length, handoff: used.why }
|
|
333
444
|
}
|
|
334
445
|
if (codeStore && typeof input.code === 'string' && input.code.trim().length > 0) {
|
|
335
446
|
if (codeStore.consume(input.code)) return { ok: true, kind: 'code' }
|
|
@@ -437,6 +548,13 @@ export class WebDeviceCodeStore {
|
|
|
437
548
|
this.codes.delete(code)
|
|
438
549
|
return true
|
|
439
550
|
}
|
|
551
|
+
|
|
552
|
+
/** Final review V7: "Sign out every browser" also ends every sign-in code not yet typed. Returns how many it ended. */
|
|
553
|
+
revokeAll(): number {
|
|
554
|
+
const n = this.codes.size
|
|
555
|
+
this.codes.clear()
|
|
556
|
+
return n
|
|
557
|
+
}
|
|
440
558
|
}
|
|
441
559
|
|
|
442
560
|
export const WEB_LOGIN_MAX_FAILURES = 8
|
|
@@ -764,18 +764,66 @@ async function startWhisperServerAttempt(preflightCompleted = false): Promise<vo
|
|
|
764
764
|
/**
|
|
765
765
|
* Gracefully stop whisper-server (called on process exit).
|
|
766
766
|
*/
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
767
|
+
/** How whisper-server is stopped, and why it is never SIGTERM (S1, 2026-11-09).
|
|
768
|
+
* whisper-server's own SIGTERM/SIGINT handler calls exit(); exit runs static destructors,
|
|
769
|
+
* and ggml's Metal device teardown aborts in ggml_metal_rsets_free. Nine crash reports
|
|
770
|
+
* 11/03 to 11/08 show exactly that stack (signal_handler, exit, __cxa_finalize_ranges,
|
|
771
|
+
* ggml_metal_device_free, ggml_abort), each in the minute of a COS server restart, with the
|
|
772
|
+
* parent already launchd because node exited without waiting. The daemon holds no state
|
|
773
|
+
* worth flushing (the model is on disk, requests are retried by their callers), so SIGKILL
|
|
774
|
+
* stops it without running that teardown and leaves no crash report. The stop then waits
|
|
775
|
+
* for the close, so node never exits first and orphans the child. */
|
|
776
|
+
export const WHISPER_STOP_SIGNAL: NodeJS.Signals = 'SIGKILL'
|
|
777
|
+
export const WHISPER_STOP_WAIT_MS = 2_000
|
|
778
|
+
|
|
779
|
+
/** Resolve true when `child` has closed, false after `timeoutMs`. */
|
|
780
|
+
export function waitForChildClose(child: Pick<ChildProcess, 'exitCode' | 'signalCode' | 'once' | 'off'>, timeoutMs: number): Promise<boolean> {
|
|
781
|
+
if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve(true)
|
|
782
|
+
return new Promise(resolvePromise => {
|
|
783
|
+
let settled = false
|
|
784
|
+
const finish = (closed: boolean) => {
|
|
785
|
+
if (settled) return
|
|
786
|
+
settled = true
|
|
787
|
+
clearTimeout(timer)
|
|
788
|
+
child.off('close', onClose)
|
|
789
|
+
resolvePromise(closed)
|
|
790
|
+
}
|
|
791
|
+
const onClose = () => finish(true)
|
|
792
|
+
const timer = setTimeout(() => finish(false), timeoutMs)
|
|
793
|
+
timer.unref?.()
|
|
794
|
+
child.once('close', onClose)
|
|
795
|
+
})
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
export interface WhisperStopResult { stopped: boolean; exited: boolean; waitedMs: number; hqChildren: number }
|
|
799
|
+
|
|
800
|
+
export async function stopWhisperServer(options: { waitMs?: number } = {}): Promise<WhisperStopResult> {
|
|
801
|
+
const waitMs = options.waitMs ?? WHISPER_STOP_WAIT_MS
|
|
802
|
+
const started = Date.now()
|
|
803
|
+
const waits: Array<Promise<boolean>> = []
|
|
804
|
+
const child = serverProcess
|
|
805
|
+
if (child) {
|
|
770
806
|
serverProcess = null
|
|
771
807
|
serverAvailable = false
|
|
772
|
-
|
|
808
|
+
try { child.kill(WHISPER_STOP_SIGNAL) } catch { /* already exited */ }
|
|
809
|
+
waits.push(waitForChildClose(child, waitMs))
|
|
773
810
|
}
|
|
774
|
-
|
|
811
|
+
const hq = [...ownedHqChildren]
|
|
812
|
+
for (const proc of hq) {
|
|
775
813
|
try { proc.kill('SIGKILL') } catch { /* already exited */ }
|
|
814
|
+
waits.push(waitForChildClose(proc, waitMs))
|
|
776
815
|
}
|
|
777
816
|
ownedHqChildren.clear()
|
|
778
817
|
serverStartupState = 'stopped'
|
|
818
|
+
const closed = await Promise.all(waits)
|
|
819
|
+
const exited = closed.every(Boolean)
|
|
820
|
+
const waitedMs = Date.now() - started
|
|
821
|
+
if (child || hq.length) {
|
|
822
|
+
// Q1 S4: name what was actually running.
|
|
823
|
+
const what = child ? `whisper-server stopped (${WHISPER_STOP_SIGNAL}, ${hq.length} HQ child(ren))` : `${hq.length} HQ child(ren) stopped (SIGKILL; whisper-server was not running)`
|
|
824
|
+
console.log(`[whisper-local] ${what}: ` + (exited ? `exited in ${waitedMs} ms` : `did not exit within ${waitMs} ms`))
|
|
825
|
+
}
|
|
826
|
+
return { stopped: Boolean(child), exited, waitedMs, hqChildren: hq.length }
|
|
779
827
|
}
|
|
780
828
|
|
|
781
829
|
export function isWhisperLocalAvailable(): boolean {
|
|
@@ -952,6 +1000,36 @@ export function parseWhisperCliFullJson(raw: string): { text: string; words: Whi
|
|
|
952
1000
|
}
|
|
953
1001
|
}
|
|
954
1002
|
|
|
1003
|
+
/** S1 (2026-11-09): an HQ decode that hit its wall-clock ceiling. The message keeps the
|
|
1004
|
+
* historical prefix ("whisper-cli HQ timeout (240s, model=large-v3)") that log scans count,
|
|
1005
|
+
* then names what settles why: the audio length, the device and why, the thread count and
|
|
1006
|
+
* whether the child ran at background QoS (taskpolicy -b, which keeps it off the
|
|
1007
|
+
* performance cores). Field evidence 10/26 to 11/09: 290 progressive-checkpoint timeouts
|
|
1008
|
+
* against 190 successes, every one on CPU at background QoS. */
|
|
1009
|
+
export class WhisperHqTimeoutError extends Error {
|
|
1010
|
+
readonly hqTimeout = true
|
|
1011
|
+
constructor(
|
|
1012
|
+
readonly timeoutMs: number,
|
|
1013
|
+
readonly model: 'large-v3' | 'turbo',
|
|
1014
|
+
readonly details: { audioSeconds: number; device: 'metal' | 'cpu'; reason: string; threads: number; backgroundQos: boolean; elapsedMs: number },
|
|
1015
|
+
) {
|
|
1016
|
+
super(`whisper-cli HQ timeout (${timeoutMs / 1000}s, model=${model}) `
|
|
1017
|
+
+ `audio=${details.audioSeconds.toFixed(1)}s device=${details.device} reason=${details.reason} `
|
|
1018
|
+
+ `threads=${details.threads} qos=${details.backgroundQos ? 'background' : 'default'} elapsed=${details.elapsedMs}ms`)
|
|
1019
|
+
this.name = 'WhisperHqTimeoutError'
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
export function isWhisperHqTimeout(error: unknown): error is WhisperHqTimeoutError {
|
|
1024
|
+
return error instanceof WhisperHqTimeoutError
|
|
1025
|
+
|| (typeof error === 'object' && error !== null && (error as { hqTimeout?: boolean }).hqTimeout === true)
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
/** Seconds of audio in a 16 kHz mono 16-bit WAV (the only shape the HQ path decodes). */
|
|
1029
|
+
export function wavAudioSeconds(buffer: Buffer): number {
|
|
1030
|
+
return Math.max(0, buffer.length - 44) / 32_000
|
|
1031
|
+
}
|
|
1032
|
+
|
|
955
1033
|
/**
|
|
956
1034
|
* High-quality transcription for batch/post-meeting use.
|
|
957
1035
|
* Uses the full Whisper large-v3 (32-layer) decoder + Silero VAD + beam search.
|
|
@@ -1152,7 +1230,14 @@ export async function transcribeHighQuality(
|
|
|
1152
1230
|
return
|
|
1153
1231
|
}
|
|
1154
1232
|
if (timedOut) {
|
|
1155
|
-
reject(new
|
|
1233
|
+
reject(new WhisperHqTimeoutError(timeoutMs, useLargeV3 ? 'large-v3' : 'turbo', {
|
|
1234
|
+
audioSeconds: wavAudioSeconds(audioBuffer),
|
|
1235
|
+
device: decision.device,
|
|
1236
|
+
reason: decision.reason,
|
|
1237
|
+
threads: (isBatch && !useMetal) ? requestedThreads : 16,
|
|
1238
|
+
backgroundQos: useBackgroundTaskPolicy,
|
|
1239
|
+
elapsedMs: Date.now() - start,
|
|
1240
|
+
}))
|
|
1156
1241
|
return
|
|
1157
1242
|
}
|
|
1158
1243
|
if (code !== 0) {
|
|
@@ -1215,7 +1300,8 @@ export async function transcribeHighQuality(
|
|
|
1215
1300
|
const modelTag = useLargeV3 ? 'large-v3' : 'turbo'
|
|
1216
1301
|
console.log(
|
|
1217
1302
|
`[whisper-hq] Batch transcribed in ${elapsed}ms ` +
|
|
1218
|
-
|
|
1303
|
+
// S1: the audio length makes the real-time factor readable from the log.
|
|
1304
|
+
`(${modelTag}${useVad ? '+vad' : ''}${captureWords ? '+words' : ''}, audio=${wavAudioSeconds(audioBuffer).toFixed(1)}s` +
|
|
1219
1305
|
`${words ? `, ${words.length} words` : ''}` +
|
|
1220
1306
|
// Device forensics: without these, "why is polish slow today" is
|
|
1221
1307
|
// unanswerable after the fact.
|
|
@@ -446,10 +446,12 @@ export async function stopWhisperPreviewServer(options: { final?: boolean } = {}
|
|
|
446
446
|
previewStarting = false
|
|
447
447
|
previewWorkerModel = null
|
|
448
448
|
if (child) {
|
|
449
|
-
|
|
449
|
+
// S1 (2026-11-09): never SIGTERM. The sidecar is the same whisper-server binary, whose
|
|
450
|
+
// SIGTERM handler exits through ggml's Metal teardown and aborts (see whisper-local.ts
|
|
451
|
+
// WHISPER_STOP_SIGNAL). SIGKILL leaves no crash report; then wait so node never exits first.
|
|
452
|
+
try { child.kill('SIGKILL') } catch { /* already exited */ }
|
|
450
453
|
if (!await waitForPreviewClose(child, 2_000)) {
|
|
451
|
-
|
|
452
|
-
await waitForPreviewClose(child, 1_000)
|
|
454
|
+
console.warn('[whisper-preview] sidecar did not exit within 2000 ms of SIGKILL')
|
|
453
455
|
}
|
|
454
456
|
}
|
|
455
457
|
}
|
|
@@ -34,6 +34,18 @@ export const epochSeconds = (v: unknown): number | null => typeof v === 'number'
|
|
|
34
34
|
const sidOrNull = (v: unknown): string | null => typeof v === 'string' && SID.test(v.toLowerCase()) ? v.toLowerCase() : null
|
|
35
35
|
|
|
36
36
|
export function parseSnapshot(v: unknown, now = Date.now()): ControlSnapshot {
|
|
37
|
+
return parseSnapshotReport(v, now).snapshot
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 7.0.13: the snapshot plus how many live rows were left out. Live rows are evidence only (what the session list
|
|
42
|
+
* showed), so a row COS cannot name by a full session id, or whose text breaks a bound, is skipped and counted instead
|
|
43
|
+
* of refusing the whole snapshot. Control 0.5.384 sent a running Claude CLI peer as `claude:<8 hex>` (the prefix id
|
|
44
|
+
* /api/claude-sessions serves) and every Work chat plan on every card failed with 400 invalid_snapshot. A row that is
|
|
45
|
+
* not an object, and every link and receipt, stay strict: links and receipts decide which sessions belong to a card.
|
|
46
|
+
*/
|
|
47
|
+
export function parseSnapshotReport(v: unknown, now = Date.now()): { snapshot: ControlSnapshot; droppedLiveRows: number } {
|
|
48
|
+
let droppedLiveRows = 0
|
|
37
49
|
if (!obj(v) || v.version !== SNAPSHOT_VERSION || !str(v.controlVersion, 40) || !Array.isArray(v.links) || v.links.length > SNAPSHOT_MAX_LINKS
|
|
38
50
|
|| !Array.isArray(v.receipts) || v.receipts.length > SNAPSHOT_MAX_RECEIPTS) throw new Error('invalid_snapshot')
|
|
39
51
|
const links: SnapshotLink[] = []
|
|
@@ -56,15 +68,17 @@ export function parseSnapshot(v: unknown, now = Date.now()): ControlSnapshot {
|
|
|
56
68
|
if (!obj(v.live) || checkedAt === null || !Array.isArray(v.live.rows) || v.live.rows.length > SNAPSHOT_MAX_LIVE) throw new Error('invalid_live')
|
|
57
69
|
const rows: SnapshotLiveRow[] = []
|
|
58
70
|
for (const r of v.live.rows) {
|
|
59
|
-
|
|
60
|
-
|
|
71
|
+
if (!obj(r)) throw new Error('invalid_live_row')
|
|
72
|
+
const id = sidOrNull(r.id)
|
|
73
|
+
if (!id) { droppedLiveRows++; continue }
|
|
74
|
+
if (!str(r.title ?? '', 300) || !str(r.status ?? '', 100) || !str(r.summary ?? '', 2000)) { droppedLiveRows++; continue }
|
|
61
75
|
if (isGuardianRow(id, r.title ?? '', r.summary ?? '')) continue
|
|
62
76
|
rows.push({ id, title: r.title ?? '', status: r.status ?? '', updatedAt: str(r.updatedAt, 40) ? r.updatedAt : null,
|
|
63
77
|
busy: r.busy === true, waitingOnUser: r.waitingOnUser === true, appOwned: r.appOwned === true, summary: r.summary ?? '' })
|
|
64
78
|
}
|
|
65
79
|
live = { checkedAt, rows }
|
|
66
80
|
}
|
|
67
|
-
return { version: 1, controlVersion: v.controlVersion, sentAt: epochSeconds(v.sentAt) ?? now / 1000, receivedAt: now / 1000, links, receipts, live }
|
|
81
|
+
return { snapshot: { version: 1, controlVersion: v.controlVersion, sentAt: epochSeconds(v.sentAt) ?? now / 1000, receivedAt: now / 1000, links, receipts, live }, droppedLiveRows }
|
|
68
82
|
}
|
|
69
83
|
|
|
70
84
|
/** Codex guardian review threads (canary C3: 17 in the live list) are never offered as work sessions. */
|
|
@@ -112,6 +112,19 @@ export class WorkReviewRuntime {
|
|
|
112
112
|
return { review: publicReview(await this.reconcileRow(row, true)), replayed: false }
|
|
113
113
|
})
|
|
114
114
|
}
|
|
115
|
+
/** S1 (2026-11-09): write a reconciled row only when something other than updatedAt changed.
|
|
116
|
+
* The 5 s timer reconciles every row; a row whose meeting stays unavailable came back identical
|
|
117
|
+
* except for a fresh updatedAt, and each put is an fsync'd rewrite of the whole journal. On
|
|
118
|
+
* Miles's Mac three such rows rewrote the 114 KB journal three times every 5 s (about 68 KB/s,
|
|
119
|
+
* some 60% of the server's idle disk writes). An unchanged row keeps its original updatedAt. */
|
|
120
|
+
private saveIfChanged(before: StoredWorkReview, after: StoredWorkReview): StoredWorkReview {
|
|
121
|
+
const { updatedAt: _a, ...left } = before
|
|
122
|
+
const { updatedAt: _b, ...right } = after
|
|
123
|
+
if (JSON.stringify(left) === JSON.stringify(right)) return before
|
|
124
|
+
after.updatedAt = this.now()
|
|
125
|
+
this.deps.store.put(after)
|
|
126
|
+
return after
|
|
127
|
+
}
|
|
115
128
|
private async reconcileRow(row: StoredWorkReview, allowAdmission: boolean): Promise<StoredWorkReview> {
|
|
116
129
|
if (row.status === 'superseded') return row
|
|
117
130
|
const updated = structuredClone(row)
|
|
@@ -119,12 +132,12 @@ export class WorkReviewRuntime {
|
|
|
119
132
|
const current = await this.deps.resolveMeeting(row.source.descriptor); assertRecord(current, row.source.descriptor)
|
|
120
133
|
if (sourceRevision(current) !== row.source.revision) {
|
|
121
134
|
updated.status = 'superseded'; updated.error = { code: 'meeting_revision_changed', message: 'The saved meeting changed. Request a new review for its current revision.' }
|
|
122
|
-
|
|
135
|
+
return this.saveIfChanged(row, updated)
|
|
123
136
|
}
|
|
124
137
|
} catch {
|
|
125
138
|
if (row.status === 'ready' || row.status === 'failed') updated.suspendedTerminal = { status: row.status, ...(row.error ? { error: row.error } : {}) }
|
|
126
139
|
updated.status = 'source_unavailable'; updated.error = { code: 'meeting_source_unavailable', message: 'The original saved source cannot be verified. No new work was started.' }
|
|
127
|
-
|
|
140
|
+
return this.saveIfChanged(row, updated)
|
|
128
141
|
}
|
|
129
142
|
if (row.status === 'source_unavailable' && row.suspendedTerminal) {
|
|
130
143
|
// Terminal receipts outlive query-job retention. Restore only after the
|
|
@@ -132,7 +145,7 @@ export class WorkReviewRuntime {
|
|
|
132
145
|
updated.status = row.suspendedTerminal.status
|
|
133
146
|
if (row.suspendedTerminal.error) updated.error = row.suspendedTerminal.error; else delete updated.error
|
|
134
147
|
delete updated.suspendedTerminal
|
|
135
|
-
|
|
148
|
+
return this.saveIfChanged(row, updated)
|
|
136
149
|
}
|
|
137
150
|
if (row.status === 'ready' || row.status === 'failed') return row
|
|
138
151
|
try {
|
|
@@ -170,7 +183,7 @@ export class WorkReviewRuntime {
|
|
|
170
183
|
if (updated.status === 'ready') { updated.status = 'source_unavailable'; delete updated.markdown }
|
|
171
184
|
updated.error = { code: e instanceof WorkReviewError ? e.code : 'review_reconciliation_unavailable', message: 'Review reconciliation is unavailable. No automatic duplicate is submitted.' }
|
|
172
185
|
}
|
|
173
|
-
|
|
186
|
+
return this.saveIfChanged(row, updated)
|
|
174
187
|
}
|
|
175
188
|
async reconcile(allowAdmission = false): Promise<void> {
|
|
176
189
|
await this.serial(async () => { if (this.stopping) return; for (const row of this.deps.store.list()) await this.reconcileRow(row, allowAdmission) })
|