@gotcos/glasses-server 6.99.33 → 7.0.18
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 +37 -0
- package/CHANGELOG.md +587 -0
- package/README.md +3 -1
- package/docs/web-key-verifier-contract.md +378 -0
- package/managed-runtime-contract.json +12 -1
- package/package.json +10 -3
- package/server/bootstrap.ts +10 -0
- package/server/index.ts +155 -30
- package/server/lib/agent-session-output-images.ts +627 -49
- package/server/lib/agent-session-recent.ts +321 -0
- package/server/lib/agent-session-store.ts +27 -14
- package/server/lib/agent-session-tool-images.ts +224 -0
- package/server/lib/agent-session-turns.ts +19 -2
- package/server/lib/api-auth.ts +43 -1
- package/server/lib/ask-work-plan.ts +3 -1
- package/server/lib/calendar-meetings.ts +70 -0
- package/server/lib/calendar-source.ts +536 -0
- package/server/lib/claude-hooks-installer.ts +111 -12
- package/server/lib/codex-bridge.ts +4 -1
- package/server/lib/codex-run-ledger.ts +29 -0
- package/server/lib/context-builder.ts +2 -1
- package/server/lib/conversation.ts +56 -28
- package/server/lib/cors-reject-log.ts +51 -0
- package/server/lib/disk-io-meter.ts +202 -0
- package/server/lib/drop-p2-runtime.ts +1 -1
- package/server/lib/drop-service.ts +47 -12
- package/server/lib/drop-store.ts +31 -5
- 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/learning-needs-you.ts +24 -7
- package/server/lib/listener-startup.ts +18 -0
- package/server/lib/live-events.ts +533 -0
- package/server/lib/local-path-redact.ts +272 -0
- package/server/lib/meeting-ask-orientation.ts +40 -6
- package/server/lib/meeting-ask-runner.ts +30 -10
- package/server/lib/meeting-batch-transcribe.ts +32 -5
- package/server/lib/morning-brief-runtime.ts +4 -2
- package/server/lib/native-mac-caller.ts +29 -0
- package/server/lib/permission-broker.ts +443 -11
- package/server/lib/ping-engine.ts +1 -1
- package/server/lib/ping-sources.ts +11 -6
- package/server/lib/preview-content.ts +172 -0
- package/server/lib/preview-error-code.ts +10 -0
- package/server/lib/preview-folders-setting.ts +133 -0
- package/server/lib/preview-log-throttle.ts +18 -0
- package/server/lib/preview-thumbs.ts +139 -0
- package/server/lib/secret-path-guard.ts +83 -0
- package/server/lib/session-cache-writer.ts +8 -1
- package/server/lib/session-files-setting.ts +88 -0
- package/server/lib/session-hook-events.ts +23 -1
- package/server/lib/session-hooks-runtime.ts +34 -0
- package/server/lib/session-links.ts +280 -0
- package/server/lib/session-log.ts +313 -4
- package/server/lib/session-state-derive.ts +36 -0
- package/server/lib/session-stream-events.ts +25 -1
- package/server/lib/thread-turn-queue.ts +10 -0
- package/server/lib/today-brief.ts +640 -85
- 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-memories-bridge.ts +3 -1
- 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-activity.ts +56 -6
- package/server/lib/work-orchestrator/snapshot.ts +17 -3
- package/server/lib/work-review-runtime.ts +17 -4
- package/server/lib/work-session-activity.ts +260 -0
- package/server/lib/your-move-count.ts +9 -3
- package/server/routes/agent-session-bindings.ts +29 -1
- package/server/routes/agent-session-stream.ts +23 -0
- package/server/routes/agent-sessions.ts +146 -11
- package/server/routes/ask-work-plan.ts +4 -1
- package/server/routes/claude-sessions.ts +5 -1
- package/server/routes/drops.ts +7 -2
- package/server/routes/g2-turns.ts +35 -0
- package/server/routes/health.ts +46 -1
- package/server/routes/live-events.ts +334 -0
- package/server/routes/permission-broker.ts +95 -5
- package/server/routes/query-jobs.ts +3 -0
- package/server/routes/session-preview.ts +200 -0
- package/server/routes/sessions.ts +15 -24
- package/server/routes/thread-turn-queue.ts +223 -12
- package/server/routes/today-brief.ts +134 -44
- package/server/routes/web-control.ts +37 -4
- package/server/routes/web-key.ts +284 -0
- package/server/routes/welcome-context.ts +28 -4
- package/server/routes/work-board.ts +9 -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 +76 -1
- package/server/web-control/app.js +2247 -98
- package/server/web-control/apple-touch-icon.png +0 -0
- package/server/web-control/control.css +25 -0
- package/server/web-control/index.html +21 -3
- package/server/web-control/memory-review.js +100 -0
- package/server/web-control/panes/cos-chat.css +94 -9
- package/server/web-control/panes/cos-chat.js +628 -44
- package/server/web-control/panes/meetings.css +18 -2
- package/server/web-control/panes/meetings.js +24 -8
- package/server/web-control/panes/memories.css +17 -2
- package/server/web-control/panes/memories.js +127 -42
- package/server/web-control/panes/messages.js +21 -2
- package/server/web-control/panes/preview.js +318 -0
- package/server/web-control/panes/questions.js +162 -21
- package/server/web-control/panes/reader-media.js +409 -23
- package/server/web-control/panes/sessions.css +32 -3
- package/server/web-control/panes/settings.js +97 -6
- package/server/web-control/panes/today-actions.css +46 -0
- package/server/web-control/panes/today-actions.js +119 -17
- package/server/web-control/panes/work.css +13 -0
- package/server/web-control/panes/work.js +188 -37
- package/server/web-control/shell.js +17 -14
- 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,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The preview contract's routes (preview engine phase 1, PLAN 4c), mounted inside agentSessionsRouter, so behind the
|
|
3
|
+
* shared /api gate. Neither a request nor a response carries a filesystem path: a card or file is named by an opaque
|
|
4
|
+
* id that the resolver re-authorizes on every call (agent-session-output-images.ts `previewFile`).
|
|
5
|
+
*
|
|
6
|
+
* GET /api/preview/agent-sessions/:provider/:sessionId/:id/meta
|
|
7
|
+
* GET /api/preview/agent-sessions/:provider/:sessionId/:id/content?part=raw|inline|text|table|html|html-run|thumb
|
|
8
|
+
* GET|POST /api/preview-folders/settings the Settings extra folders (lib/preview-folders-setting.ts)
|
|
9
|
+
*
|
|
10
|
+
* "Show session files, screenshots and images" (7.0.4 name) off answers 403 previews_off before any transcript or file is read.
|
|
11
|
+
* A denied secret name answers 403 secret_file_denied (as the 7.0.4 output routes do). A folder card has no content (phase 2 lists folders).
|
|
12
|
+
* Every refusal or failure but a missing id logs one line (`logPreviewRefusal`): reason code, provider, part, kind.
|
|
13
|
+
*/
|
|
14
|
+
import { Router, type Request, type Response } from 'express'
|
|
15
|
+
import { agentSessionOutputImages } from '../lib/agent-session-output-images.js'
|
|
16
|
+
import { sessionFilesSetting } from '../lib/session-files-setting.js'
|
|
17
|
+
import { previewFoldersSetting, folderDisplay, type PreviewFoldersStore } from '../lib/preview-folders-setting.js'
|
|
18
|
+
import { previewThumbnailer } from '../lib/preview-thumbs.js'
|
|
19
|
+
import {
|
|
20
|
+
HTML_PREVIEW_MAX, looksBinary, PREVIEW_PARTS, previewHeaders, tablePage, TEXT_WINDOW_MAX, textWindow, withCspMeta, type PreviewPart,
|
|
21
|
+
} from '../lib/preview-content.js'
|
|
22
|
+
import type { PreviewKind } from '../lib/session-links.js'
|
|
23
|
+
import { previewErrorCode } from '../lib/preview-error-code.js'
|
|
24
|
+
import { warnPreviewThrottled } from '../lib/preview-log-throttle.js'
|
|
25
|
+
/** The 7.0.4 code (routes/agent-sessions.ts PREVIEWS_BUSY), repeated here so this router needs nothing from that one. */
|
|
26
|
+
const PREVIEWS_BUSY = 'previews_busy'
|
|
27
|
+
|
|
28
|
+
type Previews = Pick<typeof agentSessionOutputImages, 'previewFile' | 'previewImage'>
|
|
29
|
+
type Thumbs = Pick<typeof previewThumbnailer, 'thumbnail'>
|
|
30
|
+
export interface SessionPreviewDeps {
|
|
31
|
+
previews?: Previews
|
|
32
|
+
enabled?: () => boolean
|
|
33
|
+
thumbs?: Thumbs
|
|
34
|
+
folders?: PreviewFoldersStore
|
|
35
|
+
home?: () => string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const PROVIDERS = new Set(['claude', 'codex', 'cursor'])
|
|
39
|
+
const TEXT_KINDS = new Set<PreviewKind>(['markdown', 'text', 'code', 'html', 'table'])
|
|
40
|
+
const THUMB_KINDS = new Set<PreviewKind>(['pdf', 'document', 'slides', 'sheet', 'html'])
|
|
41
|
+
const NORMALIZED = /\.(png|jpe?g|webp|hei[cf]|avif)$/i
|
|
42
|
+
|
|
43
|
+
/** Which parts a kind has. The web reads this from `meta` and never asks for another. */
|
|
44
|
+
export function partsFor(kind: PreviewKind, name: string): PreviewPart[] {
|
|
45
|
+
const parts: PreviewPart[] = []
|
|
46
|
+
if (kind === 'folder') return parts
|
|
47
|
+
parts.push('raw')
|
|
48
|
+
if (kind === 'pdf') parts.push('inline')
|
|
49
|
+
if (kind === 'image' && NORMALIZED.test(name)) parts.push('inline')
|
|
50
|
+
if (TEXT_KINDS.has(kind)) parts.push('text')
|
|
51
|
+
if (kind === 'table') parts.push('table')
|
|
52
|
+
if (kind === 'html') parts.push('html', 'html-run')
|
|
53
|
+
if (THUMB_KINDS.has(kind)) parts.push('thumb')
|
|
54
|
+
if (kind === 'image' && NORMALIZED.test(name)) parts.push('thumb')
|
|
55
|
+
return parts
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** What a refusal or failure is logged with. Only fixed words: never a path, a file name, an id, a token or content. */
|
|
59
|
+
interface LogAt { route: 'meta' | 'content'; provider: string; part?: string; kind?: PreviewKind; detail?: string }
|
|
60
|
+
const SAFE_WORD = /^[a-z0-9_-]{1,40}$/i
|
|
61
|
+
/**
|
|
62
|
+
* QA 7.0.5 (the 7.0.4 promise, for the preview engine's routes): one line per refusal or failure, with a stable reason
|
|
63
|
+
* code and the provider, part and file kind. A missing id (404 preview_not_found) is ordinary and is not logged, as in
|
|
64
|
+
* the 7.0.4 output routes. 7.0.7 (final QA N1): a burst of the same line (a page of thumbnails while another client is
|
|
65
|
+
* busy) logs once per 10 seconds with the count it held back, through the 7.0.4 routes' own limiter.
|
|
66
|
+
*/
|
|
67
|
+
export function logPreviewRefusal(at: LogAt, error: string, now = Date.now()): void {
|
|
68
|
+
if (error === 'preview_not_found') return
|
|
69
|
+
const words = [`reason=${error}`, `provider=${PROVIDERS.has(at.provider) ? at.provider : 'unknown'}`]
|
|
70
|
+
if (at.part && SAFE_WORD.test(at.part)) words.push(`part=${at.part}`)
|
|
71
|
+
if (at.kind && SAFE_WORD.test(at.kind)) words.push(`kind=${at.kind}`)
|
|
72
|
+
if (at.detail && SAFE_WORD.test(at.detail)) words.push(`detail=${at.detail}`)
|
|
73
|
+
const line = `[preview] ${at.route} refused ${words.join(' ')}`
|
|
74
|
+
warnPreviewThrottled(line, line, now)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function refuse(res: Response, status: string, at: LogAt) {
|
|
78
|
+
const map: Record<string, [number, string]> = {
|
|
79
|
+
not_found: [404, 'preview_not_found'], unavailable: [503, 'preview_unavailable'], too_large: [413, 'preview_too_large'],
|
|
80
|
+
secret: [403, 'secret_file_denied'], folder: [404, 'preview_folder'], busy: [503, PREVIEWS_BUSY],
|
|
81
|
+
}
|
|
82
|
+
const [code, error] = map[status] ?? [503, 'preview_unavailable']
|
|
83
|
+
logPreviewRefusal(at, error)
|
|
84
|
+
if (status === 'busy') res.set('Retry-After', '2')
|
|
85
|
+
res.status(code).json({ error })
|
|
86
|
+
}
|
|
87
|
+
/** A refusal the route answers itself (not through `refuse`), logged the same way. */
|
|
88
|
+
function answer(res: Response, code: number, error: string, at: LogAt) {
|
|
89
|
+
logPreviewRefusal(at, error)
|
|
90
|
+
res.status(code).json({ error })
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function createSessionPreviewRouter(deps: SessionPreviewDeps = {}): Router {
|
|
94
|
+
const router = Router()
|
|
95
|
+
const previews = deps.previews ?? agentSessionOutputImages
|
|
96
|
+
const enabled = deps.enabled ?? (() => sessionFilesSetting.read().enabled)
|
|
97
|
+
const thumbs = deps.thumbs ?? previewThumbnailer
|
|
98
|
+
const folders = deps.folders ?? previewFoldersSetting
|
|
99
|
+
|
|
100
|
+
const gate = (req: Request, res: Response, route: LogAt['route']): { provider: 'claude' | 'codex' | 'cursor'; sessionId: string; id: string } | null => {
|
|
101
|
+
res.set('Cache-Control', 'private, no-store')
|
|
102
|
+
res.set('X-Content-Type-Options', 'nosniff')
|
|
103
|
+
const provider = String(req.params.provider ?? '').toLowerCase()
|
|
104
|
+
// The switch decides before anything is read (no transcript, no file, no stat).
|
|
105
|
+
if (!enabled()) { answer(res, 403, 'previews_off', { route, provider }); return null }
|
|
106
|
+
if (!PROVIDERS.has(provider)) { res.status(404).json({ error: 'preview_not_found' }); return null }
|
|
107
|
+
return { provider: provider as 'claude' | 'codex' | 'cursor', sessionId: String(req.params.sessionId ?? ''), id: String(req.params.id ?? '') }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
router.get('/preview/agent-sessions/:provider/:sessionId/:id/meta', async (req, res) => {
|
|
111
|
+
const g = gate(req, res, 'meta'); if (!g) return
|
|
112
|
+
const at: LogAt = { route: 'meta', provider: g.provider }
|
|
113
|
+
try {
|
|
114
|
+
const found = await previews.previewFile(g.provider, g.sessionId, g.id, true)
|
|
115
|
+
if (found.status === 'folder') { res.json({ id: g.id, name: '', kind: 'folder', type: 'Folder', state: 'available', parts: [] }); return }
|
|
116
|
+
if (found.status !== 'ok') { refuse(res, found.status, { ...at, detail: found.reason }); return }
|
|
117
|
+
res.json({ id: g.id, name: found.name, kind: found.kind, type: found.type, size: found.identity.size, ...(found.line ? { line: found.line } : {}), state: 'available', parts: partsFor(found.kind, found.name) })
|
|
118
|
+
} catch (error) { refuse(res, 'unavailable', { ...at, detail: 'error_' + previewErrorCode(error) }) }
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
router.get('/preview/agent-sessions/:provider/:sessionId/:id/content', async (req, res) => {
|
|
122
|
+
const g = gate(req, res, 'content'); if (!g) return
|
|
123
|
+
const part = String(req.query.part ?? 'raw') as PreviewPart
|
|
124
|
+
const at: LogAt = { route: 'content', provider: g.provider }
|
|
125
|
+
if (!PREVIEW_PARTS.includes(part)) { answer(res, 400, 'preview_part', at); return }
|
|
126
|
+
at.part = part
|
|
127
|
+
try {
|
|
128
|
+
const found = await previews.previewFile(g.provider, g.sessionId, g.id)
|
|
129
|
+
if (found.status !== 'ok') { refuse(res, found.status, { ...at, detail: found.reason }); return }
|
|
130
|
+
at.kind = found.kind
|
|
131
|
+
if (!partsFor(found.kind, found.name).includes(part)) { answer(res, 400, 'preview_part', at); return }
|
|
132
|
+
// A picture goes through the output-image normalizer, as a JPEG, never as the bytes a file claims to be.
|
|
133
|
+
if (found.kind === 'image' && part !== 'raw') {
|
|
134
|
+
const img = await previews.previewImage(g.provider, g.sessionId, g.id, part === 'thumb' ? 'thumb' : 'phone')
|
|
135
|
+
if (img.status !== 'ok') { refuse(res, img.status, { ...at, detail: 'reason' in img ? img.reason : undefined }); return }
|
|
136
|
+
if (!('path' in img)) { refuse(res, 'unavailable', at); return }
|
|
137
|
+
res.set({ 'Content-Type': 'image/jpeg', 'Content-Disposition': 'inline', 'Content-Security-Policy': "sandbox; default-src 'none'", 'Cross-Origin-Resource-Policy': 'same-origin' })
|
|
138
|
+
res.sendFile(img.path, { dotfiles: 'allow', headers: { 'Content-Type': 'image/jpeg' } }, err => {
|
|
139
|
+
if (!err) return
|
|
140
|
+
if (!res.headersSent) refuse(res, 'unavailable', { ...at, detail: 'send_' + previewErrorCode(err) }); else { logPreviewRefusal({ ...at, detail: 'send_' + previewErrorCode(err) }, 'preview_cut'); res.destroy() }
|
|
141
|
+
})
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
const bytes = found.bytes
|
|
145
|
+
if (part === 'raw' || part === 'inline') { res.set(previewHeaders(part, found.name)); res.status(200).end(bytes); return }
|
|
146
|
+
if (part === 'thumb') {
|
|
147
|
+
const t = await thumbs.thumbnail(bytes, found.name, found.identity)
|
|
148
|
+
if (t.status !== 'ok') {
|
|
149
|
+
if (t.status === 'busy') res.set('Retry-After', '2')
|
|
150
|
+
answer(res, 503, t.status === 'busy' ? PREVIEWS_BUSY : 'thumb_unavailable', { ...at, detail: t.status === 'unavailable' ? t.reason : undefined })
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
res.set(previewHeaders('thumb', found.name))
|
|
154
|
+
res.sendFile(t.path, { dotfiles: 'allow', headers: { 'Content-Type': 'image/png' } }, err => {
|
|
155
|
+
if (!err) return
|
|
156
|
+
if (!res.headersSent) answer(res, 503, 'thumb_unavailable', { ...at, detail: 'send_' + previewErrorCode(err) }); else { logPreviewRefusal({ ...at, detail: 'send_' + previewErrorCode(err) }, 'preview_cut'); res.destroy() }
|
|
157
|
+
})
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
if (looksBinary(bytes)) { answer(res, 415, 'preview_binary', at); return }
|
|
161
|
+
if (part === 'html' || part === 'html-run') {
|
|
162
|
+
if (bytes.length > HTML_PREVIEW_MAX) { refuse(res, 'too_large', at); return }
|
|
163
|
+
// PLAN 6: the policy is in the page itself too, before anything in it can load.
|
|
164
|
+
res.set(previewHeaders(part, found.name)); res.status(200).end(withCspMeta(bytes, part)); return
|
|
165
|
+
}
|
|
166
|
+
if (part === 'table') {
|
|
167
|
+
res.set(previewHeaders('table', found.name))
|
|
168
|
+
res.json(tablePage(bytes, found.name, Number(req.query.offset ?? 0)))
|
|
169
|
+
return
|
|
170
|
+
}
|
|
171
|
+
// Every text window, an HTML page's source included, is 512 KiB a call with "load more".
|
|
172
|
+
res.set(previewHeaders('text', found.name))
|
|
173
|
+
res.json({ ...textWindow(bytes, Number(req.query.offset ?? 0), Number(req.query.limit ?? TEXT_WINDOW_MAX), TEXT_WINDOW_MAX), ...(found.line ? { line: found.line } : {}) })
|
|
174
|
+
} catch (error) {
|
|
175
|
+
refuse(res, 'unavailable', { ...at, detail: 'error_' + previewErrorCode(error) })
|
|
176
|
+
}
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
// Settings, Privacy: the named extra folders. Shown as ~/x inside home. A refusal names each folder's problem.
|
|
180
|
+
router.get('/preview-folders/settings', (_req, res) => {
|
|
181
|
+
res.set('Cache-Control', 'private, no-store')
|
|
182
|
+
try {
|
|
183
|
+
const s = folders.read()
|
|
184
|
+
res.json({ folders: s.folders.map(f => folderDisplay(f, deps.home?.())), source: s.source })
|
|
185
|
+
} catch { res.status(500).json({ error: 'preview_folders_unavailable' }) }
|
|
186
|
+
})
|
|
187
|
+
router.post('/preview-folders/settings', (req, res) => {
|
|
188
|
+
res.set('Cache-Control', 'private, no-store')
|
|
189
|
+
const body = req.body
|
|
190
|
+
if (!body || typeof body !== 'object' || Array.isArray(body) || Object.keys(body).some(k => k !== 'folders') || !Array.isArray(body.folders)) {
|
|
191
|
+
res.status(400).json({ error: { code: 'invalid_preview_folders', message: 'Send { folders: ["~/cos-worktrees"] }.' } }); return
|
|
192
|
+
}
|
|
193
|
+
try {
|
|
194
|
+
const r = folders.set(body.folders)
|
|
195
|
+
if (!r.ok) { res.status(400).json({ error: { code: 'preview_folder_refused', message: r.errors[0]?.message ?? 'That folder cannot be used.' }, errors: r.errors }); return }
|
|
196
|
+
res.json({ folders: r.setting.folders.map(f => folderDisplay(f, deps.home?.())), source: r.setting.source })
|
|
197
|
+
} catch { res.status(500).json({ error: 'preview_folders_unavailable' }) }
|
|
198
|
+
})
|
|
199
|
+
return router
|
|
200
|
+
}
|
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
// Session endpoints — recent list, full history, existence check, client-format messages, context breaks, end
|
|
2
2
|
import { Router } from 'express'
|
|
3
|
-
import { readFileSync } from 'fs'
|
|
4
|
-
import { join } from 'path'
|
|
5
3
|
import { getRecentSessions, getHistory, sessionExists, addContextBreak, endSession, getSessionRaw, getActiveSessions, resolveExchangePairModel, ensureArchiveMirrorForDay } from '../lib/conversation.js'
|
|
6
|
-
import { buildSessionLogEntry,
|
|
4
|
+
import { buildSessionLogEntry, lookupRangesFromHistory, readSessionLogRanges, writeSessionLogIfChanged } from '../lib/session-log.js'
|
|
7
5
|
import { getArchiveDayMessages, listArchiveDateStrings } from '../lib/archive.js'
|
|
8
6
|
import { recentMessagesLimit, selectRecentMessages } from '../lib/recent-messages.js'
|
|
9
7
|
import { localDay } from '../lib/local-day.js'
|
|
@@ -135,7 +133,7 @@ sessionsRouter.get('/sessions/:id/messages', (req, res) => {
|
|
|
135
133
|
// instead of waiting for the 2hr TTL expiry.
|
|
136
134
|
// POST /api/sessions/lookup — batch resolve timestamps to session IDs
|
|
137
135
|
// Used to retroactively stamp messages that predate the sessionId feature
|
|
138
|
-
sessionsRouter.post('/sessions/lookup', (req, res) => {
|
|
136
|
+
sessionsRouter.post('/sessions/lookup', async (req, res) => {
|
|
139
137
|
const { timestamps } = req.body as { timestamps: number[] }
|
|
140
138
|
if (!timestamps || !Array.isArray(timestamps)) {
|
|
141
139
|
res.status(400).json({ error: 'timestamps[] required' })
|
|
@@ -143,7 +141,6 @@ sessionsRouter.post('/sessions/lookup', (req, res) => {
|
|
|
143
141
|
}
|
|
144
142
|
|
|
145
143
|
// Build time ranges from BOTH JSONL history AND live server sessions
|
|
146
|
-
const logPath = join(process.env.COS_SCRIPTS_DIR || '', '.glasses_sessions.jsonl')
|
|
147
144
|
const sessionRanges: Array<{ sid: string; start: number; end: number }> = []
|
|
148
145
|
|
|
149
146
|
// 1. Live server sessions (always current — no snapshot lag)
|
|
@@ -155,25 +152,17 @@ sessionsRouter.post('/sessions/lookup', (req, res) => {
|
|
|
155
152
|
}
|
|
156
153
|
}
|
|
157
154
|
|
|
158
|
-
// 2. JSONL history (ended sessions + snapshots)
|
|
155
|
+
// 2. JSONL history (ended sessions + snapshots). S1 (2026-11-09): streamed and incremental
|
|
156
|
+
// (session-log.ts readSessionLogRanges); the old readFileSync().toString() threw on the 1.94 GB
|
|
157
|
+
// ledger and the catch read that as "no file". The range rule is the old one, unchanged: a
|
|
158
|
+
// session's end is widened by later lines only when it is NOT in memory (lookupRangesFromHistory).
|
|
159
159
|
try {
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
// Live sessions take priority. For JSONL, keep the widest (latest) time range per session.
|
|
167
|
-
const existing = sessionRanges.find(s => s.sid === d.session_id)
|
|
168
|
-
if (!existing) {
|
|
169
|
-
sessionRanges.push({ sid: d.session_id, start, end })
|
|
170
|
-
} else if (end > existing.end && !getSessionRaw(d.session_id)) {
|
|
171
|
-
// Widen the JSONL range (but don't overwrite live sessions which extend to NOW)
|
|
172
|
-
existing.end = end
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
} catch { /* no log file */ }
|
|
160
|
+
const live = new Set(sessionRanges.map(s => s.sid))
|
|
161
|
+
const history = await readSessionLogRanges()
|
|
162
|
+
sessionRanges.push(...lookupRangesFromHistory(history, sid => Boolean(getSessionRaw(sid)), live))
|
|
163
|
+
} catch (err) {
|
|
164
|
+
console.warn('[sessions] lookup could not read the session ledger:', err instanceof Error ? err.message : err)
|
|
165
|
+
}
|
|
177
166
|
|
|
178
167
|
// Match each timestamp to a session (live sessions checked first, then JSONL)
|
|
179
168
|
const results: Record<number, string | null> = {}
|
|
@@ -242,7 +231,9 @@ sessionsRouter.post('/sessions/:id/snapshot', (req, res) => {
|
|
|
242
231
|
slug: `[LIVE] ${(session.exchanges.find(e => e.role === 'user')?.content ?? '').slice(0, 50)}`,
|
|
243
232
|
})
|
|
244
233
|
|
|
245
|
-
|
|
234
|
+
// Logged means the ledger holds this content: written now, or already there.
|
|
235
|
+
const result = writeSessionLogIfChanged(entry, 'snapshot')
|
|
236
|
+
const logged = result === 'written' || result === 'unchanged'
|
|
246
237
|
res.json({
|
|
247
238
|
ok: true,
|
|
248
239
|
logged,
|
|
@@ -24,13 +24,13 @@ import {
|
|
|
24
24
|
admitToQueue, drainDecision, queueableRefusal, queuePosition,
|
|
25
25
|
MAX_DELIVERY_ATTEMPTS, type DrainObservation, type QueuedThreadTurn,
|
|
26
26
|
queueReconcileEnabled, queueResendEnabled, queuedTurnReasonCopy, sessionSteerAvailability, sessionSteerEnabled,
|
|
27
|
-
type SessionSteerAvailability,
|
|
27
|
+
steerMidturnEnabled, type SessionSteerAvailability,
|
|
28
28
|
} from '../lib/thread-turn-queue.js'
|
|
29
29
|
import { readQueue, writeQueue, queuedThreadKeys } from '../lib/thread-turn-queue-store.js'
|
|
30
30
|
import type { DeliveryEvidence } from '../lib/queue-turn-evidence.js'
|
|
31
31
|
import { continueLiveEnabled } from '../lib/session-peer-inbox-deps.js'
|
|
32
32
|
import { PEER_VERIFY_TIMEOUT_MS } from '../lib/session-peer-inbox.js'
|
|
33
|
-
import { attachmentsDigest, FOOTER_LINE_REFUSED_COPY, hasFooterShapedLine, stripG2TurnFooter, TURN_ID_IN_USE_COPY, unvouched, type G2TurnProvenance, type OriginVerdict } from '../lib/g2-turn-provenance.js'
|
|
33
|
+
import { attachmentsDigest, FOOTER_LINE_REFUSED_COPY, hasFooterShapedLine, sealMatches, stripG2TurnFooter, TURN_ID_IN_USE_COPY, unvouched, type G2TurnProvenance, type OriginVerdict } from '../lib/g2-turn-provenance.js'
|
|
34
34
|
import { defaultTurnAttachmentStore, keepTurnAttachments, parseTurnAttachmentIds, TURN_ATTACHMENT_COPY, turnAttachmentManifest, type TurnAttachmentStore } from '../lib/session-turn-attachments.js'
|
|
35
35
|
|
|
36
36
|
/**
|
|
@@ -38,11 +38,11 @@ import { defaultTurnAttachmentStore, keepTurnAttachments, parseTurnAttachmentIds
|
|
|
38
38
|
* the attachment digest its origin is sealed to. Files whose content cannot be read leave the turn
|
|
39
39
|
* unvouched (it still parks); no files is the empty digest and reads nothing.
|
|
40
40
|
*/
|
|
41
|
-
async function sealFiles(ids: readonly string[] | undefined, verdict: OriginVerdict, store: TurnAttachmentStore | undefined): Promise<{ verdict: OriginVerdict; attachments: string }> {
|
|
42
|
-
if (!Array.isArray(ids) || ids.length === 0) return { verdict, attachments: '' }
|
|
41
|
+
async function sealFiles(ids: readonly string[] | undefined, verdict: OriginVerdict, store: TurnAttachmentStore | undefined): Promise<{ verdict: OriginVerdict; attachments: string; manifest: ReadonlyArray<{ id: string; sha256: string }> | null }> {
|
|
42
|
+
if (!Array.isArray(ids) || ids.length === 0) return { verdict, attachments: '', manifest: [] }
|
|
43
43
|
const manifest = await turnAttachmentManifest(ids, store)
|
|
44
|
-
if (!manifest.ok) return { verdict: unvouched(verdict, 'attachments_unsealable'), attachments: '' }
|
|
45
|
-
return { verdict, attachments: attachmentsDigest(manifest.manifest) }
|
|
44
|
+
if (!manifest.ok) return { verdict: unvouched(verdict, 'attachments_unsealable'), attachments: '', manifest: null }
|
|
45
|
+
return { verdict, attachments: attachmentsDigest(manifest.manifest), manifest: manifest.manifest }
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
export interface ThreadTurnQueueDeps {
|
|
@@ -115,6 +115,23 @@ export interface ThreadTurnQueueDeps {
|
|
|
115
115
|
begin: (provider: string, threadId: string, clientTurnId: string, mode: 'turn' | 'steer', now: number) => boolean
|
|
116
116
|
settle: (provider: string, threadId: string, clientTurnId: string, exit: 'sent' | 'not_sent' | 'unknown', now: number) => boolean
|
|
117
117
|
}
|
|
118
|
+
/**
|
|
119
|
+
* Steer mid-turn (COS_STEER_MIDTURN), optional: ask for a drain pass for this thread now, so a
|
|
120
|
+
* turn parked at a busy session that can take it mid-turn goes in within seconds instead of at
|
|
121
|
+
* the next 20 s sweep. Production: the shared drain kick (one pass in flight, ever). Absent: the
|
|
122
|
+
* sweep still delivers it, only later.
|
|
123
|
+
*/
|
|
124
|
+
kickDrain?: (threadId: string) => void
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Can this thread take a parked turn mid-turn right now (COS_STEER_MIDTURN)? The same shared rule
|
|
129
|
+
* as Session steer, plus the switch and the two members the drain's steer needs. Never throws.
|
|
130
|
+
*/
|
|
131
|
+
export function midTurnAvailabilityFor(deps: Pick<ThreadTurnQueueDeps, 'steerProbe' | 'deliverLiveTurn' | 'continueLive' | 'attempts'>, provider: string, threadId: string, gateReason: string | null): SessionSteerAvailability | null {
|
|
132
|
+
if (!steerMidturnEnabled()) return null
|
|
133
|
+
if (typeof deps.deliverLiveTurn !== 'function' || !deps.attempts) return { available: false, reason: 'no_outside_channel' }
|
|
134
|
+
return steerAvailabilityFor(deps, provider, threadId, gateReason)
|
|
118
135
|
}
|
|
119
136
|
|
|
120
137
|
/**
|
|
@@ -210,6 +227,159 @@ function isRetryableDelivery(outcome: { reason?: string; serverRetryable?: boole
|
|
|
210
227
|
return queueableRefusal(outcome.reason)
|
|
211
228
|
}
|
|
212
229
|
|
|
230
|
+
/**
|
|
231
|
+
* The verdict a drain hop with no parked origin is judged by: the loopback call it is. The front
|
|
232
|
+
* door reaches the same verdict through `originOf` on the drainer's own request (local socket,
|
|
233
|
+
* token shown), so a steered turn and a front-door turn are ledgered alike. Never footed.
|
|
234
|
+
*/
|
|
235
|
+
function localDrainVerdict(at: number): OriginVerdict {
|
|
236
|
+
return {
|
|
237
|
+
origin: { remote: '127.0.0.1', loopback: true, ownAddress: true, clientInstance: null, client: 'local', tokenPresented: true, webSession: false },
|
|
238
|
+
verifiedOrigin: false,
|
|
239
|
+
reason: 'local_socket',
|
|
240
|
+
judgedAt: at,
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** What one mid-turn attempt did to its row: delivered, settled some other way, or left waiting. */
|
|
245
|
+
type MidTurnOutcome = 'delivered' | 'settled' | 'held'
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Steer mid-turn (COS_STEER_MIDTURN, Miles 2026-11-07): put ONE waiting row into the running turn
|
|
249
|
+
* of a busy Claude desk session through its peer inbox. Claude Code takes the frame at once (its
|
|
250
|
+
* `enqueue` row is the proof `deliverLiveTurn` waits for) and folds it in at its next tool step.
|
|
251
|
+
*
|
|
252
|
+
* Called only by `drainThread`, only for a row the drain would otherwise hold behind a busy gate,
|
|
253
|
+
* and only in FIFO order (the caller stops at the first row that holds).
|
|
254
|
+
*
|
|
255
|
+
* WHO MAY BE STEERED: a row never attempted (`attempts === 0`: no front-door attempt, no earlier
|
|
256
|
+
* steer, no restart requeue, so nothing about it is unknown), with no files (the peer inbox
|
|
257
|
+
* carries words only), on a thread the shared steer rule says can take it now.
|
|
258
|
+
*
|
|
259
|
+
* PROVENANCE IS CHECKED HERE AS THE TURNS ROUTE CHECKS IT, because this hop does not go through
|
|
260
|
+
* that front door: footer-shaped words are refused, the parked origin is carried (a single-use
|
|
261
|
+
* carry token, spent in this process), its seal must name this provider, thread and no files,
|
|
262
|
+
* a turn id held for another turn is refused, and only the server writes the footer. A refusal
|
|
263
|
+
* settles the row `refused` with its own reason, so it reads "Not sent" with the reason line.
|
|
264
|
+
*
|
|
265
|
+
* NEVER TWICE, in the same order as `steerTurn`: the intent row (`delivering`, `mode: 'steer'`,
|
|
266
|
+
* `journal: 1`, this boot) durably, then the attempt `pending`, then the frame, then the
|
|
267
|
+
* verdict, journal first. Unverified, a failed write or a throw: `refused`, `steer_unverified`,
|
|
268
|
+
* `deliveryState: 'unknown'`, never resent. Nothing reached the session: back to waiting, the
|
|
269
|
+
* attempt given back, so the turn-end front door sends it as before.
|
|
270
|
+
*
|
|
271
|
+
* NO AWAIT BEFORE THE INTENT ROW IS WRITTEN: the caller read this row as `waiting` in the same
|
|
272
|
+
* synchronous step, so a cancel cannot slip between that read and the `delivering` write.
|
|
273
|
+
*/
|
|
274
|
+
async function steerHeldTurn(
|
|
275
|
+
provider: string,
|
|
276
|
+
threadId: string,
|
|
277
|
+
turn: QueuedThreadTurn,
|
|
278
|
+
gateReason: string | null,
|
|
279
|
+
deps: ThreadTurnQueueDeps,
|
|
280
|
+
): Promise<MidTurnOutcome> {
|
|
281
|
+
if (turn.status !== 'waiting' || turn.attempts !== 0) return 'held'
|
|
282
|
+
if (Array.isArray(turn.attachmentIds) && turn.attachmentIds.length > 0) return 'held'
|
|
283
|
+
const availability = midTurnAvailabilityFor(deps, provider, threadId, gateReason)
|
|
284
|
+
if (!availability || !availability.available) return 'held'
|
|
285
|
+
const { clientTurnId, prompt } = turn
|
|
286
|
+
|
|
287
|
+
const rewrite = (apply: (row: QueuedThreadTurn) => void): QueuedThreadTurn | null => {
|
|
288
|
+
const latest = readQueue(provider, threadId, deps.now())
|
|
289
|
+
const row = latest.find(t => t.clientTurnId === clientTurnId)
|
|
290
|
+
if (!row) return null
|
|
291
|
+
apply(row)
|
|
292
|
+
writeQueue(provider, threadId, latest, { durable: true })
|
|
293
|
+
return row
|
|
294
|
+
}
|
|
295
|
+
const refuse = (reason: string): MidTurnOutcome => {
|
|
296
|
+
const at = deps.now()
|
|
297
|
+
rewrite(row => { row.status = 'refused'; row.reason = reason; row.settledAt = at })
|
|
298
|
+
deps.provenance?.forgetQueued(provider, threadId, clientTurnId)
|
|
299
|
+
console.warn(`[thread-turn-queue] mid-turn refused provider=${provider} clientTurnId=${clientTurnId} reason=${reason}`)
|
|
300
|
+
return 'settled'
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// The front door's provenance gates, in its order.
|
|
304
|
+
let verdict: OriginVerdict | null = null
|
|
305
|
+
if (deps.provenance) {
|
|
306
|
+
if (hasFooterShapedLine(prompt)) return refuse('footer_line_refused')
|
|
307
|
+
const token = deps.provenance.issueCarry({ provider, threadId, clientTurnId, prompt })
|
|
308
|
+
const carried = token ? deps.provenance.takeCarry(token, clientTurnId, prompt) : null
|
|
309
|
+
// No carry could be made: leave it for the front door, which decides with its own carry.
|
|
310
|
+
if (carried === null) return 'held'
|
|
311
|
+
if (carried.seal !== null && !sealMatches(carried.seal, { provider, threadId, attachments: '' })) return refuse('queued_turn_tampered')
|
|
312
|
+
verdict = carried.verdict ?? localDrainVerdict(deps.now())
|
|
313
|
+
if (deps.provenance.reusesTurnId(clientTurnId, { prompt, verdict, provider, sessionId: threadId }, { continues: true })) return refuse('turn_id_in_use')
|
|
314
|
+
}
|
|
315
|
+
const sent = deps.provenance && verdict ? deps.provenance.footed(prompt, clientTurnId, verdict, provider) : prompt
|
|
316
|
+
const ledger = (via: string): void => {
|
|
317
|
+
if (deps.provenance && verdict) deps.provenance.record({ turnId: clientTurnId, prompt, verdict, provider, sessionId: threadId, via, continues: true })
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// 1. The intent row, durably.
|
|
321
|
+
const intent = rewrite(row => {
|
|
322
|
+
row.status = 'delivering'
|
|
323
|
+
row.attempts = 1
|
|
324
|
+
row.mode = 'steer'
|
|
325
|
+
row.journal = 1
|
|
326
|
+
if (typeof deps.bootId === 'string' && deps.bootId.length > 0) row.deliveringBootId = deps.bootId
|
|
327
|
+
else delete row.deliveringBootId
|
|
328
|
+
})
|
|
329
|
+
if (!intent) return 'held'
|
|
330
|
+
const backToWaiting = (row: QueuedThreadTurn): void => {
|
|
331
|
+
row.status = 'waiting'
|
|
332
|
+
row.attempts = 0
|
|
333
|
+
delete row.mode
|
|
334
|
+
delete row.journal
|
|
335
|
+
delete row.deliveringBootId
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// 2. The attempt `pending`, durably, before any frame.
|
|
339
|
+
let begun = false
|
|
340
|
+
try { begun = deps.attempts!.begin(provider, threadId, clientTurnId, 'steer', deps.now()) === true } catch { begun = false }
|
|
341
|
+
if (!begun) {
|
|
342
|
+
rewrite(backToWaiting)
|
|
343
|
+
console.warn(`[thread-turn-queue] mid-turn not attempted provider=${provider} clientTurnId=${clientTurnId}: the attempt journal could not record it`)
|
|
344
|
+
return 'held'
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// 3. The frame.
|
|
348
|
+
const startedAt = deps.now()
|
|
349
|
+
let live: { ok: boolean; reason: string } | null = null
|
|
350
|
+
try {
|
|
351
|
+
live = await deps.deliverLiveTurn!({ provider, sessionId: threadId, prompt: sent, verifyTimeoutMs: PEER_VERIFY_TIMEOUT_MS, clientTurnId })
|
|
352
|
+
} catch (error) {
|
|
353
|
+
console.error(`[thread-turn-queue] mid-turn delivery threw: ${error instanceof Error ? error.message : error}`)
|
|
354
|
+
live = null
|
|
355
|
+
}
|
|
356
|
+
const at = deps.now()
|
|
357
|
+
const journal = (exit: 'sent' | 'not_sent' | 'unknown'): void => {
|
|
358
|
+
try { deps.attempts!.settle(provider, threadId, clientTurnId, exit, at) } catch { /* the reconcile reads `pending` as unknown */ }
|
|
359
|
+
}
|
|
360
|
+
console.log(`[thread-turn-queue] mid-turn provider=${provider} clientTurnId=${clientTurnId} hop=${live?.reason ?? 'threw'} ms=${Math.max(0, at - startedAt)} waitedMs=${Math.max(0, at - turn.queuedAt)}`)
|
|
361
|
+
|
|
362
|
+
// 4. The verdict, journal first, then the row.
|
|
363
|
+
if (live?.ok === true) {
|
|
364
|
+
journal('sent')
|
|
365
|
+
ledger('drain-steer')
|
|
366
|
+
rewrite(row => { row.status = 'delivered'; row.reason = 'steered'; row.settledAt = at })
|
|
367
|
+
deps.provenance?.forgetQueued(provider, threadId, clientTurnId)
|
|
368
|
+
return 'delivered'
|
|
369
|
+
}
|
|
370
|
+
if (live === null || live.reason === 'unverified' || live.reason === 'write_failed') {
|
|
371
|
+
journal('unknown')
|
|
372
|
+
ledger('drain-steer-unverified')
|
|
373
|
+
rewrite(row => { row.status = 'refused'; row.reason = 'steer_unverified'; row.deliveryState = 'unknown'; row.settledAt = at })
|
|
374
|
+
deps.provenance?.forgetQueued(provider, threadId, clientTurnId)
|
|
375
|
+
return 'settled'
|
|
376
|
+
}
|
|
377
|
+
// The transport's own word that nothing reached a session: the row waits for the turn end.
|
|
378
|
+
journal('not_sent')
|
|
379
|
+
rewrite(backToWaiting)
|
|
380
|
+
return 'held'
|
|
381
|
+
}
|
|
382
|
+
|
|
213
383
|
/**
|
|
214
384
|
* One drain pass over one thread.
|
|
215
385
|
*
|
|
@@ -243,9 +413,14 @@ export async function drainThread(
|
|
|
243
413
|
if (turn.status !== 'waiting') deps.provenance?.forgetQueued(provider, threadId, turn.clientTurnId)
|
|
244
414
|
}
|
|
245
415
|
|
|
416
|
+
// Steer mid-turn (COS_STEER_MIDTURN): FIFO per thread. Once a row ahead cannot go in mid-turn
|
|
417
|
+
// (or is still being delivered), nothing behind it may jump it; it waits for the turn end too.
|
|
418
|
+
let midTurnOrderHeld = false
|
|
419
|
+
|
|
246
420
|
for (const snapshot of queue) {
|
|
247
421
|
const turn = readQueue(provider, threadId, deps.now()).find(t => t.clientTurnId === snapshot.clientTurnId)
|
|
248
422
|
if (!turn) continue
|
|
423
|
+
if (turn.status === 'delivering') midTurnOrderHeld = true
|
|
249
424
|
if (turn.status !== 'waiting') continue
|
|
250
425
|
|
|
251
426
|
// Reuse only refusal within this synchronous pass. Rechecking a busy thread
|
|
@@ -284,7 +459,21 @@ export async function drainThread(
|
|
|
284
459
|
}
|
|
285
460
|
const decision = drainDecision(turn, seen, deps.now())
|
|
286
461
|
|
|
287
|
-
if (decision === 'hold') {
|
|
462
|
+
if (decision === 'hold') {
|
|
463
|
+
// Steer mid-turn: never during a cancel hold (the person said stop), only for the first
|
|
464
|
+
// rows in order, and only where the shared steer rule says this session can take it now,
|
|
465
|
+
// which also means the hold is the busy GATE: the rule answers only for a refusal that
|
|
466
|
+
// names a desk run (an attachable thread has no reason). Anything else holds as before.
|
|
467
|
+
const cancelHeld = typeof seen.cancelHoldUntil === 'number' && Number.isFinite(seen.cancelHoldUntil) && deps.now() < seen.cancelHoldUntil
|
|
468
|
+
if (!midTurnOrderHeld && !cancelHeld) {
|
|
469
|
+
const outcome = await steerHeldTurn(provider, threadId, turn, gate.reason, deps)
|
|
470
|
+
if (outcome === 'delivered') { delivered += 1; continue }
|
|
471
|
+
if (outcome === 'settled') { retired += 1; continue }
|
|
472
|
+
}
|
|
473
|
+
midTurnOrderHeld = true
|
|
474
|
+
held += 1
|
|
475
|
+
continue
|
|
476
|
+
}
|
|
288
477
|
if (decision === 'expire' || decision === 'give_up') {
|
|
289
478
|
turn.status = decision === 'expire' ? 'expired' : 'refused'
|
|
290
479
|
turn.reason = decision === 'expire' ? 'queued_turn_expired' : 'delivery_attempts_exhausted'
|
|
@@ -690,7 +879,9 @@ export function createThreadTurnQueueRouter(deps: ThreadTurnQueueDeps): Router {
|
|
|
690
879
|
// with its content hash), so the drain can carry it (QUEUED_ORIGIN_TTL_MS at most).
|
|
691
880
|
if (deps.provenance && verdict) {
|
|
692
881
|
const sealed = await sealFiles(attach.ids, verdict, deps.turnAttachmentStore)
|
|
693
|
-
|
|
882
|
+
// 6.99.31: a signed web turn is judged here, once parking is decided, against this provider and
|
|
883
|
+
// thread (where it will drain), these exact words and these files. No envelope: unchanged.
|
|
884
|
+
verdict = deps.provenance.acceptWebKey(sealed.verdict, { provider, sessionId: threadId, turnId: clientTurnId, words: prompt, attachments: sealed.manifest })
|
|
694
885
|
deps.provenance.noteQueued(provider, threadId, clientTurnId, prompt, verdict, sealed.attachments)
|
|
695
886
|
}
|
|
696
887
|
|
|
@@ -710,12 +901,27 @@ export function createThreadTurnQueueRouter(deps: ThreadTurnQueueDeps): Router {
|
|
|
710
901
|
|
|
711
902
|
writeQueue(provider, threadId, admitted.queue)
|
|
712
903
|
if (deps.provenance && verdict) deps.provenance.record({ turnId: clientTurnId, prompt, verdict, provider, sessionId: threadId, via: 'queue' })
|
|
904
|
+
// Steer mid-turn (COS_STEER_MIDTURN): the park answer stays the 202 every client reads (a
|
|
905
|
+
// lens that sees anything else says "could not queue"); the drain is asked to run now, and it
|
|
906
|
+
// puts the words into the running turn. `landsAt` (additive, only with the switch on) tells a
|
|
907
|
+
// client which line to show: `next_step` only when this row is first in line, has no files,
|
|
908
|
+
// and the session can take it now; `turn_end` otherwise.
|
|
909
|
+
const midTurn = midTurnAvailabilityFor(deps, provider, threadId, gate.reason)
|
|
910
|
+
let landsAt: 'next_step' | 'turn_end' | null = null
|
|
911
|
+
if (midTurn) {
|
|
912
|
+
const firstInLine = queuePosition(admitted.queue, clientTurnId) === 0 && !admitted.queue.some(t => t.status === 'delivering')
|
|
913
|
+
landsAt = midTurn.available && firstInLine && attach.ids.length === 0 ? 'next_step' : 'turn_end'
|
|
914
|
+
if (landsAt === 'next_step') {
|
|
915
|
+
try { deps.kickDrain?.(threadId) } catch { /* the 20 s sweep still delivers it */ }
|
|
916
|
+
}
|
|
917
|
+
}
|
|
713
918
|
return res.status(202).json({
|
|
714
919
|
queued: true,
|
|
715
920
|
clientTurnId,
|
|
716
921
|
position: queuePosition(admitted.queue, clientTurnId),
|
|
717
922
|
waitingOn: gate.reason,
|
|
718
923
|
...(steerFallback !== null ? { steerFallback } : {}),
|
|
924
|
+
...(landsAt !== null ? { landsAt } : {}),
|
|
719
925
|
})
|
|
720
926
|
})
|
|
721
927
|
|
|
@@ -786,8 +992,11 @@ export function createThreadTurnQueueRouter(deps: ThreadTurnQueueDeps): Router {
|
|
|
786
992
|
if (deps.provenance && resendVerdict) {
|
|
787
993
|
// After the write: the read-modify-write above stays one synchronous step. Sealed to the copy's files.
|
|
788
994
|
const sealed = await sealFiles(copy.attachmentIds, resendVerdict, deps.turnAttachmentStore)
|
|
789
|
-
|
|
790
|
-
|
|
995
|
+
// 6.99.31: Send again from the phone needs a fresh signature over the copy (its new id, the words,
|
|
996
|
+
// the files); without one the copy stays unvouched ('resend_not_reverified').
|
|
997
|
+
const copyVerdict = deps.provenance.acceptWebKey(sealed.verdict, { provider, sessionId: threadId, turnId: newId, words: copy.prompt, attachments: sealed.manifest })
|
|
998
|
+
deps.provenance.noteQueued(provider, threadId, newId, copy.prompt, copyVerdict, sealed.attachments)
|
|
999
|
+
deps.provenance.record({ turnId: newId, prompt: copy.prompt, verdict: copyVerdict, provider, sessionId: threadId, via: 'queue-resend', resendOf: original.clientTurnId })
|
|
791
1000
|
}
|
|
792
1001
|
console.log(`[thread-turn-queue] resend provider=${provider} from=${original.clientTurnId} as=${newId} unknown=${original.deliveryState === 'unknown'}`)
|
|
793
1002
|
return res.status(200).json(publicRow(copy, queuePosition(admitted.queue, newId)))
|
|
@@ -857,9 +1066,11 @@ export function createThreadTurnQueueRouter(deps: ThreadTurnQueueDeps): Router {
|
|
|
857
1066
|
if (deps.provenance && verdict) {
|
|
858
1067
|
// Sealed to the row's files as they are now (an edit changes only the words).
|
|
859
1068
|
const sealed = await sealFiles(row.attachmentIds, verdict, deps.turnAttachmentStore)
|
|
860
|
-
|
|
1069
|
+
// 6.99.31: an edit needs a fresh signature over the new words (same id, same files), or it is unvouched.
|
|
1070
|
+
const editVerdict = deps.provenance.acceptWebKey(sealed.verdict, { provider, sessionId: threadId, turnId: id, words: prompt, attachments: sealed.manifest })
|
|
1071
|
+
deps.provenance.noteQueued(provider, threadId, id, prompt, editVerdict, sealed.attachments)
|
|
861
1072
|
// The queue's own row, re-judged: it continues the record parked under this id.
|
|
862
|
-
deps.provenance.record({ turnId: id, prompt, verdict:
|
|
1073
|
+
deps.provenance.record({ turnId: id, prompt, verdict: editVerdict, provider, sessionId: threadId, via: 'queue-edit', continues: true })
|
|
863
1074
|
}
|
|
864
1075
|
return res.json({ updated: true })
|
|
865
1076
|
})
|