@gotcos/glasses-server 6.99.20 → 6.99.29
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 +257 -0
- package/docs/work-orchestrator.md +20 -0
- package/package.json +1 -1
- package/server/index.ts +48 -5
- package/server/lib/agent-session-output-images.ts +280 -0
- package/server/lib/agent-session-store.ts +3 -1
- package/server/lib/agent-session-turns.ts +18 -7
- package/server/lib/client-liveness.ts +38 -3
- package/server/lib/codex-subagent-activity.ts +36 -0
- package/server/lib/g2-turn-provenance.ts +679 -0
- package/server/lib/media-store.ts +41 -2
- package/server/lib/permission-broker.ts +94 -17
- package/server/lib/queue-turn-evidence.ts +19 -2
- package/server/lib/session-halt.ts +15 -1
- package/server/lib/session-stream-events.ts +4 -0
- package/server/lib/session-subagents.ts +155 -0
- package/server/lib/session-turn-attachments.ts +245 -0
- package/server/lib/thread-turn-queue-deliver.ts +17 -2
- package/server/lib/thread-turn-queue.ts +10 -0
- package/server/lib/today-brief.ts +731 -0
- package/server/lib/turn-file-store.ts +202 -0
- package/server/lib/turn-footer.ts +81 -0
- package/server/lib/web-control-static.ts +226 -0
- package/server/lib/web-memories-bridge.ts +9 -8
- package/server/lib/web-pet-choice.ts +225 -0
- package/server/lib/web-pet.ts +226 -0
- package/server/lib/work-lens-rounds.ts +29 -4
- package/server/routes/agent-session-bindings.ts +164 -16
- package/server/routes/agent-sessions.ts +77 -8
- package/server/routes/client-instance.ts +12 -1
- package/server/routes/cursor-stop-followup.ts +30 -1
- package/server/routes/g2-turns.ts +46 -0
- package/server/routes/health.ts +9 -0
- package/server/routes/media.ts +32 -8
- package/server/routes/permission-broker.ts +47 -4
- package/server/routes/thread-turn-queue.ts +148 -8
- package/server/routes/today-brief.ts +148 -0
- package/server/routes/web-control.ts +110 -7
- package/server/routes/work-lens-rounds.ts +19 -5
- package/server/web-control/app.css +26 -5
- package/server/web-control/app.js +1351 -177
- package/server/web-control/control.css +1 -1
- package/server/web-control/index.html +31 -13
- package/server/web-control/memories/memory-workspace.js +1 -1
- package/server/web-control/panes/cos-chat.css +22 -0
- package/server/web-control/panes/cos-chat.js +264 -48
- package/server/web-control/panes/meetings.css +15 -2
- package/server/web-control/panes/meetings.js +20 -9
- package/server/web-control/panes/memories.css +291 -0
- package/server/web-control/panes/memories.js +2208 -0
- package/server/web-control/panes/questions.css +39 -1
- package/server/web-control/panes/questions.js +419 -64
- package/server/web-control/panes/reader-media.js +154 -0
- package/server/web-control/panes/sessions.css +2 -0
- package/server/web-control/panes/settings.js +136 -6
- package/server/web-control/panes/today-actions.css +167 -0
- package/server/web-control/panes/today-actions.js +1303 -0
- package/server/web-control/panes/work.css +54 -0
- package/server/web-control/panes/work.js +1037 -18
- package/server/web-control/shell.js +6 -2
- package/server/web-control/theme.js +0 -2
- package/server/web-control/memories/memories-app.js +0 -1561
- package/server/web-control/memories/memories-theme.css +0 -73
- package/server/web-control/memories/memories.html +0 -72
- package/server/web-control/memories/memory-stewardship.js +0 -63
package/server/routes/health.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { MAX_ATTACHMENTS_PER_PROMPT } from '../../shared/media-attachment.js'
|
|
2
|
+
import { TURN_FILE_MAX_BYTES } from '../lib/turn-file-store.js'
|
|
1
3
|
import { dropItCapability } from '../lib/drop-settings.js'
|
|
2
4
|
import { PREP_CAPABILITY } from '../../shared/prep.js'
|
|
3
5
|
import { phoneMicrophoneMeetingsCapability } from '../lib/phone-recording.js'
|
|
@@ -446,6 +448,13 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
446
448
|
...checks,
|
|
447
449
|
server_version: managedServerVersion(),
|
|
448
450
|
...threadAttachHealthFields(threadAttach),
|
|
451
|
+
// 11/07 (Control Web attach): a session turn takes up to this many media-store ids as `attachmentIds`, and the turns
|
|
452
|
+
// route writes each file's path on this Mac under the words. 0 with attach off; absent on an older server. Either
|
|
453
|
+
// way a client sends words only.
|
|
454
|
+
threadTurnAttachments: threadAttach.enabled ? MAX_ATTACHMENTS_PER_PROMPT : 0,
|
|
455
|
+
// 6.99.27: a session turn also takes ANY file up to this size (lib/turn-file-store.ts), sent to /api/media/file with
|
|
456
|
+
// X-COS-Purpose: session-turn. 0 with attach off; absent on an older server, which takes only what the media store reads.
|
|
457
|
+
threadTurnFileBytes: threadAttach.enabled ? TURN_FILE_MAX_BYTES : 0,
|
|
449
458
|
...sessionHooksHealthFields(),
|
|
450
459
|
// 6.52.0: counts, the mode and two timestamps; never an id, a question, a command, or
|
|
451
460
|
// how many are held (that is on the authenticated questions route).
|
package/server/routes/media.ts
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
// /api/media/file STREAMS to disk rather than buffering: see
|
|
23
23
|
// createMediaBinaryBodyParser below.
|
|
24
24
|
|
|
25
|
+
import { getTurnFileStore } from '../lib/turn-file-store.js'
|
|
25
26
|
import { Router, json, type NextFunction, type Request, type Response } from 'express'
|
|
26
27
|
import { createReadStream, createWriteStream, openSync, statSync } from 'node:fs'
|
|
27
28
|
import {
|
|
@@ -554,6 +555,15 @@ mediaRouter.post('/media', async (req: Request, res: Response) => {
|
|
|
554
555
|
* bounded requests, but composer admission still owns the shared five-item
|
|
555
556
|
* prompt cap. Filename and MIME are hints only; the store sniffs and validates
|
|
556
557
|
* the actual staged bytes. */
|
|
558
|
+
/**
|
|
559
|
+
* 6.99.27: does a refused upload become a turn file? Only for a session turn, and only when the media store refused the
|
|
560
|
+
* FORMAT (it cannot read the type, or the text is not UTF-8). Too large, too long, or the Mac unable to process media
|
|
561
|
+
* right now are said as they always were.
|
|
562
|
+
*/
|
|
563
|
+
export function keepsAsTurnFile(purpose: string | undefined, code: unknown): boolean {
|
|
564
|
+
return purpose === 'session-turn' && (code === 'unsupported_attachment_format' || code === 'corrupt_attachment')
|
|
565
|
+
}
|
|
566
|
+
|
|
557
567
|
mediaRouter.post('/media/file', async (req: Request, res: Response) => {
|
|
558
568
|
const staged = takeStagedUploadBody(req)
|
|
559
569
|
try {
|
|
@@ -566,14 +576,28 @@ mediaRouter.post('/media/file', async (req: Request, res: Response) => {
|
|
|
566
576
|
if (rawLabel) {
|
|
567
577
|
try { label = decodeURIComponent(rawLabel).slice(0, 120) } catch { label = rawLabel.slice(0, 120) }
|
|
568
578
|
}
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
579
|
+
let attachment: unknown
|
|
580
|
+
try {
|
|
581
|
+
attachment = await getMediaStore().ingestRichMediaFromFile({
|
|
582
|
+
sourcePath: staged.path,
|
|
583
|
+
byteLength: staged.bytes,
|
|
584
|
+
label,
|
|
585
|
+
declaredMime: safeString(req.header('content-type'), 120),
|
|
586
|
+
capturedAt: safeString(req.header('x-cos-captured-at'), 40),
|
|
587
|
+
sessionId: safeString(req.header('x-cos-session-id'), 64),
|
|
588
|
+
})
|
|
589
|
+
} catch (err) {
|
|
590
|
+
// 6.99.27: a file for a Claude or Codex session turn, in a format the media store does not read (a .zip, a .docx,
|
|
591
|
+
// a log in another encoding). The session gets its path and opens it itself, so it is kept as a turn file, which
|
|
592
|
+
// only the turn and fork routes can resolve (lib/turn-file-store.ts). Anything else refuses as before.
|
|
593
|
+
if (!keepsAsTurnFile(req.header('x-cos-purpose'), (err as { code?: unknown } | null)?.code)) throw err
|
|
594
|
+
attachment = getTurnFileStore().ingestFromFile({
|
|
595
|
+
sourcePath: staged.path,
|
|
596
|
+
byteLength: staged.bytes,
|
|
597
|
+
label,
|
|
598
|
+
declaredMime: safeString(req.header('content-type'), 120),
|
|
599
|
+
})
|
|
600
|
+
}
|
|
577
601
|
res.json({ attachment })
|
|
578
602
|
} catch (err) {
|
|
579
603
|
sendMediaError(res, err)
|
|
@@ -35,8 +35,14 @@
|
|
|
35
35
|
// The hook token still opens nothing here. A browser poll counts as a live answering client
|
|
36
36
|
// only with `?client=web`, which the page sends only while it shows the cards (a visible tab);
|
|
37
37
|
// the same 30 s window as the phone. A cookie poll naming `glasses` or `phone`, or no client,
|
|
38
|
-
// never counts, and a token poll naming `web` never counts either.
|
|
39
|
-
//
|
|
38
|
+
// never counts, and a token poll naming `web` never counts either.
|
|
39
|
+
// 6.99.26 (Miles 11/07, "Away rule, like phone"): a browser poll `?client=web&hold=1&presenceAgeMs=<int>`
|
|
40
|
+
// (the ms since a touch or a key while the page was in front) records COS Control Web's presence for the
|
|
41
|
+
// away hold ONLY (lib/client-liveness noteWebPresence): never a live client, so at the desk the Mac's own
|
|
42
|
+
// pop-up still comes at once (6.99.13). Without `hold=1` or a valid `presenceAgeMs` it records nothing. A
|
|
43
|
+
// token poll naming `web`, and a browser poll naming anything else, still record nothing. QA round 1: neither
|
|
44
|
+
// does a poll from the Mac itself (loopback, or one of its own addresses, with no proxy header): Control Web
|
|
45
|
+
// open on the Mac is the desk, never away.
|
|
40
46
|
// 6.60.0: a poll with `&hold=1&presenceAgeMs=<int>` (COS Glasses 6.9.563 and later) records
|
|
41
47
|
// wearer presence for the away hold instead of the 6.59.0 liveness; a `hold=1` poll without
|
|
42
48
|
// a valid `presenceAgeMs` makes no client live and enables no hold. The response adds
|
|
@@ -53,7 +59,9 @@ import {
|
|
|
53
59
|
type HookReplyChannel,
|
|
54
60
|
type PermissionBroker,
|
|
55
61
|
} from '../lib/permission-broker.js'
|
|
56
|
-
import { noteQuestionsPoll, validPresenceAgeMs, type QuestionsPollFacts } from '../lib/client-liveness.js'
|
|
62
|
+
import { noteQuestionsPoll, noteWebPresence, validPresenceAgeMs, type QuestionsPollFacts } from '../lib/client-liveness.js'
|
|
63
|
+
import { hasProxyForwardedHeaders, isLoopbackAddress, normalizeRemoteIp, reachableIpv4Addresses } from '../lib/network-policy.js'
|
|
64
|
+
import { networkInterfaces } from 'node:os'
|
|
57
65
|
|
|
58
66
|
export const PERMISSION_BROKER_HOOK_PATH = '/api/permission-requests/ask'
|
|
59
67
|
/** The hook posts its spool envelope, capped at 1 MiB by the script itself. */
|
|
@@ -155,6 +163,10 @@ export interface SessionQuestionsRouterDeps {
|
|
|
155
163
|
webSessionAuthorized?: (req: Request) => boolean
|
|
156
164
|
/** Where a counted poll is recorded; `noteQuestionsPoll` in production. */
|
|
157
165
|
notePoll?: (at: number, poll: QuestionsPollFacts) => void
|
|
166
|
+
/** 6.99.26: where COS Control Web's presence is recorded; `noteWebPresence` in production. */
|
|
167
|
+
noteWebPresence?: (at: number, presenceAgeMs: number) => void
|
|
168
|
+
/** 6.99.26 (QA round 1): whether a request comes from this Mac itself; `requestFromThisMac` in production. */
|
|
169
|
+
fromThisMac?: (req: Request) => boolean
|
|
158
170
|
now?: () => number
|
|
159
171
|
/** 6.60.0: when this server process started (ISO); defaults to the process's own start. */
|
|
160
172
|
serverStartedAt?: string
|
|
@@ -176,9 +188,30 @@ export function questionsPollFacts(query: Record<string, unknown>): QuestionsPol
|
|
|
176
188
|
return { hold, presenceAgeMs: hold && validPresenceAgeMs(parsed) ? parsed : null }
|
|
177
189
|
}
|
|
178
190
|
|
|
191
|
+
/**
|
|
192
|
+
* 6.99.26 (QA round 1): a request from this Mac itself: its socket is loopback or one of the Mac's own addresses (its
|
|
193
|
+
* tailnet address first), and no proxy header says it was forwarded for someone else (a `tailscale serve` hop arrives on
|
|
194
|
+
* loopback for a remote peer). Control Web open on the Mac is the desk: its touch never makes the Mac away.
|
|
195
|
+
*/
|
|
196
|
+
export function requestFromThisMac(
|
|
197
|
+
req: { socket?: { remoteAddress?: string | undefined } | null; headers?: Record<string, unknown> | null },
|
|
198
|
+
selfAddresses: () => readonly string[] = () => reachableIpv4Addresses(networkInterfaces()).map(a => a.address),
|
|
199
|
+
): boolean {
|
|
200
|
+
if (hasProxyForwardedHeaders(req.headers)) return false
|
|
201
|
+
const remote = req.socket?.remoteAddress
|
|
202
|
+
if (isLoopbackAddress(remote)) return true
|
|
203
|
+
const ip = normalizeRemoteIp(remote ?? '')
|
|
204
|
+
if (!ip) return false
|
|
205
|
+
let own: readonly string[] = []
|
|
206
|
+
try { own = selfAddresses() } catch { own = [] }
|
|
207
|
+
return own.includes(ip)
|
|
208
|
+
}
|
|
209
|
+
|
|
179
210
|
export function createSessionQuestionsRouter(deps: SessionQuestionsRouterDeps): Router {
|
|
180
211
|
const router = Router()
|
|
181
212
|
const notePoll = deps.notePoll ?? noteQuestionsPoll
|
|
213
|
+
const noteWeb = deps.noteWebPresence ?? ((at: number, ageMs: number) => noteWebPresence(at, ageMs))
|
|
214
|
+
const fromThisMac = deps.fromThisMac ?? ((req: Request) => requestFromThisMac(req))
|
|
182
215
|
const now = deps.now ?? Date.now
|
|
183
216
|
router.get('/session-questions', (req: Request, res: Response) => {
|
|
184
217
|
res.set('Cache-Control', 'private, no-store')
|
|
@@ -199,6 +232,11 @@ export function createSessionQuestionsRouter(deps: SessionQuestionsRouterDeps):
|
|
|
199
232
|
if (byToken && ANSWERING_CLIENTS.has(client)) notePoll(now(), questionsPollFacts(req.query as Record<string, unknown>))
|
|
200
233
|
// 6.99.13 (Miles, option B): a browser poll never counts as live, so an open web tab never holds a question
|
|
201
234
|
// back from the Mac's own pop-up. The web still lists and answers what the glasses or phone are holding.
|
|
235
|
+
// 6.99.26: its touch counts for the away hold only, like the phone's (Miles 11/07, "Away rule, like phone").
|
|
236
|
+
if (byWebSession && client === WEB_ANSWERING_CLIENT) {
|
|
237
|
+
const facts = questionsPollFacts(req.query as Record<string, unknown>)
|
|
238
|
+
if (facts.presenceAgeMs !== null && facts.presenceAgeMs !== undefined && !fromThisMac(req)) noteWeb(now(), facts.presenceAgeMs)
|
|
239
|
+
}
|
|
202
240
|
const health = deps.broker.health()
|
|
203
241
|
// Listed first: a deadline the timer has not caught yet is settled by the listing.
|
|
204
242
|
const items = deps.broker.list()
|
|
@@ -227,7 +265,12 @@ export function createSessionQuestionsRouter(deps: SessionQuestionsRouterDeps):
|
|
|
227
265
|
})
|
|
228
266
|
router.post('/session-questions/:id/answer', (req: Request, res: Response) => {
|
|
229
267
|
res.set('Cache-Control', 'private, no-store')
|
|
230
|
-
|
|
268
|
+
// 6.99.27: label the answer by its door (the /api gate already let it in): the token is the Even app, anything else
|
|
269
|
+
// is COS Control Web's browser session.
|
|
270
|
+
let expected = ''
|
|
271
|
+
try { expected = deps.apiToken() } catch { expected = '' }
|
|
272
|
+
const by = timingSafeTokenEqual(req.headers['x-cos-token'], expected) ? 'app' : 'web'
|
|
273
|
+
const result = deps.broker.answer(String(req.params.id ?? ''), req.body, by)
|
|
231
274
|
res.status(result.status).json(result.body)
|
|
232
275
|
})
|
|
233
276
|
return router
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
//
|
|
16
16
|
// EVERY DEPENDENCY IS INJECTED so the whole path is testable without a live server.
|
|
17
17
|
|
|
18
|
+
import { randomBytes } from 'node:crypto'
|
|
18
19
|
import { Router, type Request, type Response } from 'express'
|
|
19
20
|
import { CONTINUE_NOTE_HEADER, continueNoteAcknowledged } from '../lib/continue-plan.js'
|
|
20
21
|
import { nativeQueueControl, plainQueueText, type NativeQueueControl } from '../lib/codex-queue-control.js'
|
|
@@ -29,8 +30,24 @@ import { readQueue, writeQueue, queuedThreadKeys } from '../lib/thread-turn-queu
|
|
|
29
30
|
import type { DeliveryEvidence } from '../lib/queue-turn-evidence.js'
|
|
30
31
|
import { continueLiveEnabled } from '../lib/session-peer-inbox-deps.js'
|
|
31
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'
|
|
34
|
+
import { defaultTurnAttachmentStore, keepTurnAttachments, parseTurnAttachmentIds, TURN_ATTACHMENT_COPY, turnAttachmentManifest, type TurnAttachmentStore } from '../lib/session-turn-attachments.js'
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* G2 authority Tier 1 (6.99.29, Codex audit of e4758a6): the verdict a parked turn's files allow, and
|
|
38
|
+
* the attachment digest its origin is sealed to. Files whose content cannot be read leave the turn
|
|
39
|
+
* unvouched (it still parks); no files is the empty digest and reads nothing.
|
|
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: '' }
|
|
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) }
|
|
46
|
+
}
|
|
32
47
|
|
|
33
48
|
export interface ThreadTurnQueueDeps {
|
|
49
|
+
/** 11/07: the media store a parked turn's `attachmentIds` are checked against. Absent: the real store. Tests pass a fake. */
|
|
50
|
+
turnAttachmentStore?: TurnAttachmentStore
|
|
34
51
|
/** Re-runs the FULL occupancy gate. The drainer never decides attachability itself. */
|
|
35
52
|
occupancy: (provider: string, threadId: string) => { attachable: boolean; reason: string | null }
|
|
36
53
|
/** Did the holder's last transcript record end a turn? */
|
|
@@ -66,6 +83,13 @@ export interface ThreadTurnQueueDeps {
|
|
|
66
83
|
* Stop hook. Absent: no Cursor turn is ever drained here, exactly as in 6.61.
|
|
67
84
|
*/
|
|
68
85
|
cursorCliChat?: (threadId: string) => boolean
|
|
86
|
+
/**
|
|
87
|
+
* G2 authority Tier 1, with web origin (6.99.29), optional: the origin of each parked turn is
|
|
88
|
+
* captured here (and again on an edit or a Send again, each judged by its own request) and carried to
|
|
89
|
+
* its drain, forgotten on any settle; a steer and a native Codex queue edit are ledgered and
|
|
90
|
+
* footed like a turn. Absent: the 6.99.28 behaviour.
|
|
91
|
+
*/
|
|
92
|
+
provenance?: G2TurnProvenance
|
|
69
93
|
now: () => number
|
|
70
94
|
|
|
71
95
|
// ------------------------------------------------------------- 6.78.0 (P3)
|
|
@@ -126,6 +150,8 @@ export function publicRow(turn: QueuedThreadTurn, position: number): Record<stri
|
|
|
126
150
|
preview: turn.prompt.length > 80 ? `${turn.prompt.slice(0, 79)}…` : turn.prompt,
|
|
127
151
|
...(turn.reason ? { reason: turn.reason } : {}),
|
|
128
152
|
...(turn.settledAt ? { settledAt: turn.settledAt } : {}),
|
|
153
|
+
// 11/07: how many files wait with the words (never their ids or paths). Absent without files.
|
|
154
|
+
...(Array.isArray(turn.attachmentIds) && turn.attachmentIds.length > 0 ? { attachments: turn.attachmentIds.length } : {}),
|
|
129
155
|
}
|
|
130
156
|
// 6.78.0: additive, and only while a P3 flag is on, so the row is the 6.77 bytes otherwise.
|
|
131
157
|
// `resendable` is the per-row capability: present only when Send again can act on it.
|
|
@@ -211,6 +237,11 @@ export async function drainThread(
|
|
|
211
237
|
if (index >= 0) { latest[index] = turn; writeQueue(provider, threadId, latest, durable ? { durable: true } : {}) }
|
|
212
238
|
}
|
|
213
239
|
let refusedGate: ReturnType<ThreadTurnQueueDeps['occupancy']> | null = null
|
|
240
|
+
// G2 authority Tier 1 (QA 6.99.29): a parked origin is forgotten on ANY settle (delivered,
|
|
241
|
+
// cancelled, refused, expired, given up). Only a row still `waiting` keeps it for its next attempt.
|
|
242
|
+
const forgetIfSettled = (turn: QueuedThreadTurn): void => {
|
|
243
|
+
if (turn.status !== 'waiting') deps.provenance?.forgetQueued(provider, threadId, turn.clientTurnId)
|
|
244
|
+
}
|
|
214
245
|
|
|
215
246
|
for (const snapshot of queue) {
|
|
216
247
|
const turn = readQueue(provider, threadId, deps.now()).find(t => t.clientTurnId === snapshot.clientTurnId)
|
|
@@ -259,6 +290,7 @@ export async function drainThread(
|
|
|
259
290
|
turn.reason = decision === 'expire' ? 'queued_turn_expired' : 'delivery_attempts_exhausted'
|
|
260
291
|
turn.settledAt = deps.now()
|
|
261
292
|
retired += 1; persist(turn)
|
|
293
|
+
forgetIfSettled(turn)
|
|
262
294
|
continue
|
|
263
295
|
}
|
|
264
296
|
|
|
@@ -335,6 +367,7 @@ export async function drainThread(
|
|
|
335
367
|
held += 1
|
|
336
368
|
}
|
|
337
369
|
persist(turn)
|
|
370
|
+
forgetIfSettled(turn)
|
|
338
371
|
}
|
|
339
372
|
return { delivered, held, retired }
|
|
340
373
|
}
|
|
@@ -451,8 +484,15 @@ async function steerTurn(
|
|
|
451
484
|
queue: QueuedThreadTurn[],
|
|
452
485
|
gateReason: string | null,
|
|
453
486
|
fallbackNone: boolean,
|
|
487
|
+
verdict: OriginVerdict | null = null,
|
|
454
488
|
): Promise<unknown> {
|
|
455
489
|
const { provider, threadId, clientTurnId, prompt } = turn
|
|
490
|
+
// G2 authority Tier 1 (6.99.29): a steer goes straight into the running session through its peer
|
|
491
|
+
// inbox, so it is a delivery of its own and carries the footer like a turn.
|
|
492
|
+
const steerPrompt = deps.provenance && verdict ? deps.provenance.footed(prompt, clientTurnId, verdict, provider) : prompt
|
|
493
|
+
const ledgerSteer = (via: string): void => {
|
|
494
|
+
if (deps.provenance && verdict) deps.provenance.record({ turnId: clientTurnId, prompt, verdict, provider, sessionId: threadId, via })
|
|
495
|
+
}
|
|
456
496
|
const intent: QueuedThreadTurn = {
|
|
457
497
|
...turn,
|
|
458
498
|
status: 'delivering',
|
|
@@ -462,7 +502,10 @@ async function steerTurn(
|
|
|
462
502
|
...(typeof deps.bootId === 'string' && deps.bootId.length > 0 ? { deliveringBootId: deps.bootId } : {}),
|
|
463
503
|
}
|
|
464
504
|
const admitted = admitToQueue(queue, intent)
|
|
465
|
-
if (!admitted.ok)
|
|
505
|
+
if (!admitted.ok) {
|
|
506
|
+
deps.provenance?.forgetQueued(provider, threadId, clientTurnId)
|
|
507
|
+
return res.status(409).json({ error: admitted.reason })
|
|
508
|
+
}
|
|
466
509
|
writeQueue(provider, threadId, admitted.queue, { durable: true })
|
|
467
510
|
|
|
468
511
|
/** Rewrite this row on the CURRENT file: other requests may have changed others meanwhile. */
|
|
@@ -470,6 +513,9 @@ async function steerTurn(
|
|
|
470
513
|
const latest = readQueue(provider, threadId, deps.now())
|
|
471
514
|
const row = latest.find(t => t.clientTurnId === clientTurnId)
|
|
472
515
|
if (row) { apply(row); writeQueue(provider, threadId, latest, { durable: true }) }
|
|
516
|
+
// G2 authority Tier 1 (QA 6.99.29): a steer that settled (delivered or refused) forgets its
|
|
517
|
+
// parked origin; one put back to wait keeps it for the drain.
|
|
518
|
+
if (!row || row.status !== 'waiting') deps.provenance?.forgetQueued(provider, threadId, clientTurnId)
|
|
473
519
|
return latest
|
|
474
520
|
}
|
|
475
521
|
const backToWaiting = (row: QueuedThreadTurn): void => {
|
|
@@ -502,7 +548,7 @@ async function steerTurn(
|
|
|
502
548
|
|
|
503
549
|
let live: { ok: boolean; reason: string } | null = null
|
|
504
550
|
try {
|
|
505
|
-
live = await deps.deliverLiveTurn!({ provider, sessionId: threadId, prompt, verifyTimeoutMs: PEER_VERIFY_TIMEOUT_MS, clientTurnId })
|
|
551
|
+
live = await deps.deliverLiveTurn!({ provider, sessionId: threadId, prompt: steerPrompt, verifyTimeoutMs: PEER_VERIFY_TIMEOUT_MS, clientTurnId })
|
|
506
552
|
} catch (error) {
|
|
507
553
|
console.error(`[thread-turn-queue] steer delivery threw: ${error instanceof Error ? error.message : error}`)
|
|
508
554
|
live = null
|
|
@@ -515,11 +561,13 @@ async function steerTurn(
|
|
|
515
561
|
|
|
516
562
|
if (live?.ok === true) {
|
|
517
563
|
journal('sent')
|
|
564
|
+
ledgerSteer('steer')
|
|
518
565
|
settleRow(row => { row.status = 'delivered'; row.reason = 'steered'; row.settledAt = at })
|
|
519
566
|
return res.status(200).json({ state: 'delivered', reason: 'steered', clientTurnId })
|
|
520
567
|
}
|
|
521
568
|
if (live === null || live.reason === 'unverified' || live.reason === 'write_failed') {
|
|
522
569
|
journal('unknown')
|
|
570
|
+
ledgerSteer('steer-unverified')
|
|
523
571
|
settleRow(row => { row.status = 'refused'; row.reason = 'steer_unverified'; row.deliveryState = 'unknown'; row.settledAt = at })
|
|
524
572
|
return res.status(200).json({
|
|
525
573
|
state: 'refused',
|
|
@@ -560,10 +608,21 @@ export function createThreadTurnQueueRouter(deps: ThreadTurnQueueDeps): Router {
|
|
|
560
608
|
return res.status(400).json({ error: 'invalid_request', reason: 'invalid_cos_session_id' })
|
|
561
609
|
}
|
|
562
610
|
|
|
611
|
+
// G2 authority Tier 1 (QA 6.99.29): only the server writes a verify footer. Words carrying
|
|
612
|
+
// anything footer-shaped are refused where they were typed (this is also the steer's door).
|
|
613
|
+
if (deps.provenance && hasFooterShapedLine(prompt)) {
|
|
614
|
+
return res.status(400).json({ error: 'footer_line_refused', reasonCopy: FOOTER_LINE_REFUSED_COPY })
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// 11/07 (Control Web attach): files with the words, as media-store ids. Absent is the old row, byte for byte.
|
|
618
|
+
const attach = parseTurnAttachmentIds(body.attachmentIds)
|
|
619
|
+
if (!attach.ok) return res.status(400).json({ error: 'invalid_request', reason: 'invalid_attachments' })
|
|
620
|
+
|
|
563
621
|
// 6.78.0: `mode: 'steer'` asks for the running turn itself (COS_SESSION_STEER). Off, the
|
|
564
622
|
// field is ignored exactly as a 6.77 server ignores it.
|
|
565
623
|
const steerOn = sessionSteerEnabled()
|
|
566
|
-
|
|
624
|
+
// A steer goes through the session's peer inbox, which carries words only: a turn with files always parks.
|
|
625
|
+
const wantSteer = steerOn && body.mode === 'steer' && attach.ids.length === 0
|
|
567
626
|
const fallbackNone = wantSteer && body.fallback === 'none'
|
|
568
627
|
|
|
569
628
|
// 6.78.0: REPLAY BEFORE THE GATE. A key this queue already holds answers with what that
|
|
@@ -584,6 +643,14 @@ export function createThreadTurnQueueRouter(deps: ThreadTurnQueueDeps): Router {
|
|
|
584
643
|
}
|
|
585
644
|
}
|
|
586
645
|
|
|
646
|
+
// G2 authority Tier 1 (6.99.29): who parked these words, judged on this request's own socket,
|
|
647
|
+
// token and cookie. A turn id this server already holds a record for, for a different turn, is
|
|
648
|
+
// never reused (QA 6.99.29: it would overwrite that record).
|
|
649
|
+
let verdict = deps.provenance ? deps.provenance.originOf(req) : null
|
|
650
|
+
if (deps.provenance && verdict && deps.provenance.reusesTurnId(clientTurnId, { prompt, verdict, provider, sessionId: threadId })) {
|
|
651
|
+
return res.status(409).json({ error: 'turn_id_in_use', reasonCopy: TURN_ID_IN_USE_COPY })
|
|
652
|
+
}
|
|
653
|
+
|
|
587
654
|
// THE GATE DECIDES WHETHER PARKING IS EVEN HONEST. A structural refusal can never
|
|
588
655
|
// clear, and telling someone their turn is queued when it can never run is worse
|
|
589
656
|
// than refusing it. An attachable thread is not queued either -- it is sent now,
|
|
@@ -601,6 +668,14 @@ export function createThreadTurnQueueRouter(deps: ThreadTurnQueueDeps): Router {
|
|
|
601
668
|
return res.status(423).json({ error: gate.reason ?? 'probe_failed', queueable: false })
|
|
602
669
|
}
|
|
603
670
|
|
|
671
|
+
// 11/07: every file must be readable now, and is kept past the 4 hour staged TTL while the turn waits. A file
|
|
672
|
+
// that is gone refuses the park here, where the words are still in the box, never later at the drain.
|
|
673
|
+
if (attach.ids.length > 0) {
|
|
674
|
+
const store = deps.turnAttachmentStore ?? defaultTurnAttachmentStore()
|
|
675
|
+
const kept = await keepTurnAttachments(attach.ids, `session-turn:${clientTurnId}`, store)
|
|
676
|
+
if (!kept.ok) return res.status(409).json({ error: kept.code, attachmentError: kept.code, reasonCopy: TURN_ATTACHMENT_COPY[kept.code] })
|
|
677
|
+
}
|
|
678
|
+
|
|
604
679
|
const now = deps.now()
|
|
605
680
|
const queue = readQueue(provider, threadId, now)
|
|
606
681
|
const turn: QueuedThreadTurn = {
|
|
@@ -608,20 +683,33 @@ export function createThreadTurnQueueRouter(deps: ThreadTurnQueueDeps): Router {
|
|
|
608
683
|
queuedAt: now, status: 'waiting', attempts: 0,
|
|
609
684
|
// 6.62.0 /qa (Q3): the acknowledgement travels with the row to its drain.
|
|
610
685
|
...(continueNoteAcknowledged(req.headers[CONTINUE_NOTE_HEADER]) ? { continueNoteAck: true as const } : {}),
|
|
686
|
+
...(attach.ids.length > 0 ? { attachmentIds: attach.ids } : {}),
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// Held in memory with the words' hash, sealed to this provider, thread and these files (each id
|
|
690
|
+
// with its content hash), so the drain can carry it (QUEUED_ORIGIN_TTL_MS at most).
|
|
691
|
+
if (deps.provenance && verdict) {
|
|
692
|
+
const sealed = await sealFiles(attach.ids, verdict, deps.turnAttachmentStore)
|
|
693
|
+
verdict = sealed.verdict
|
|
694
|
+
deps.provenance.noteQueued(provider, threadId, clientTurnId, prompt, verdict, sealed.attachments)
|
|
611
695
|
}
|
|
612
696
|
|
|
613
697
|
let steerFallback: string | null = null
|
|
614
698
|
if (wantSteer) {
|
|
615
699
|
const availability = steerAvailabilityFor(deps, provider, threadId, gate.reason)
|
|
616
|
-
if (availability.available) return steerTurn(req, res, deps, turn, queue, gate.reason, fallbackNone)
|
|
700
|
+
if (availability.available) return steerTurn(req, res, deps, turn, queue, gate.reason, fallbackNone, verdict)
|
|
617
701
|
if (fallbackNone) return res.status(409).json({ error: 'steer_unavailable', reason: availability.reason })
|
|
618
702
|
steerFallback = availability.reason
|
|
619
703
|
}
|
|
620
704
|
|
|
621
705
|
const admitted = admitToQueue(queue, turn)
|
|
622
|
-
if (!admitted.ok)
|
|
706
|
+
if (!admitted.ok) {
|
|
707
|
+
deps.provenance?.forgetQueued(provider, threadId, clientTurnId)
|
|
708
|
+
return res.status(409).json({ error: admitted.reason })
|
|
709
|
+
}
|
|
623
710
|
|
|
624
711
|
writeQueue(provider, threadId, admitted.queue)
|
|
712
|
+
if (deps.provenance && verdict) deps.provenance.record({ turnId: clientTurnId, prompt, verdict, provider, sessionId: threadId, via: 'queue' })
|
|
625
713
|
return res.status(202).json({
|
|
626
714
|
queued: true,
|
|
627
715
|
clientTurnId,
|
|
@@ -639,7 +727,7 @@ export function createThreadTurnQueueRouter(deps: ThreadTurnQueueDeps): Router {
|
|
|
639
727
|
// writing it, so two taps (lens and phone) in the same tick cannot both pass the check.
|
|
640
728
|
// The new row is admitted even when the thread is free: the drain sends it through the
|
|
641
729
|
// ordinary front door, with every gate.
|
|
642
|
-
router.post('/agent-sessions/:provider/:threadId/queued-turns/:clientTurnId/resend', (req: Request, res: Response, next) => {
|
|
730
|
+
router.post('/agent-sessions/:provider/:threadId/queued-turns/:clientTurnId/resend', async (req: Request, res: Response, next) => {
|
|
643
731
|
// Off: not a route at all, exactly as on 6.77 (a client reads the 404 as "update the server").
|
|
644
732
|
if (!queueResendEnabled()) return next()
|
|
645
733
|
res.set('Cache-Control', 'private, no-store')
|
|
@@ -664,6 +752,14 @@ export function createThreadTurnQueueRouter(deps: ThreadTurnQueueDeps): Router {
|
|
|
664
752
|
: { clientTurnId: original.resentAs, resendOf: original.clientTurnId, replayed: true })
|
|
665
753
|
}
|
|
666
754
|
if (original.status !== 'refused' && original.status !== 'expired') return res.status(409).json({ error: 'not_resendable' })
|
|
755
|
+
// G2 authority Tier 1 (QA 6.99.29): the copy's words go to a session, so they meet the footer
|
|
756
|
+
// rule like any typed words, and its id may not be one this server already holds a record for.
|
|
757
|
+
if (deps.provenance && hasFooterShapedLine(original.prompt)) {
|
|
758
|
+
return res.status(400).json({ error: 'footer_line_refused', reasonCopy: FOOTER_LINE_REFUSED_COPY })
|
|
759
|
+
}
|
|
760
|
+
if (deps.provenance && deps.provenance.holds(newId)) return res.status(409).json({ error: 'turn_id_in_use', reasonCopy: TURN_ID_IN_USE_COPY })
|
|
761
|
+
// Judged now, from THIS request (Codex re-audit of 910eb90, P1): never inherited from the row.
|
|
762
|
+
const resendVerdict = deps.provenance ? deps.provenance.resendVerdict(req) : null
|
|
667
763
|
// "May not have been sent": a second copy is possible, so the person has to have been told.
|
|
668
764
|
if (original.deliveryState === 'unknown' && body.acknowledge !== 'may_duplicate') {
|
|
669
765
|
return res.status(409).json({ error: 'acknowledge_required' })
|
|
@@ -673,12 +769,26 @@ export function createThreadTurnQueueRouter(deps: ThreadTurnQueueDeps): Router {
|
|
|
673
769
|
prompt: original.prompt,
|
|
674
770
|
queuedAt: now, status: 'waiting', attempts: 0,
|
|
675
771
|
...(original.continueNoteAck === true ? { continueNoteAck: true as const } : {}),
|
|
772
|
+
// 11/07: Send again sends the same files; the turns route refuses it there if one has gone.
|
|
773
|
+
...(Array.isArray(original.attachmentIds) && original.attachmentIds.length > 0 ? { attachmentIds: [...original.attachmentIds] } : {}),
|
|
676
774
|
resendOf: original.clientTurnId,
|
|
677
775
|
}
|
|
678
776
|
const admitted = admitToQueue(queue, copy)
|
|
679
777
|
if (!admitted.ok) return res.status(409).json({ error: admitted.reason })
|
|
680
778
|
original.resentAs = newId
|
|
681
779
|
writeQueue(provider, threadId, admitted.queue)
|
|
780
|
+
// G2 authority Tier 1 (Codex re-audit of 910eb90, P1): the row's words and files are mutable, so
|
|
781
|
+
// the copy NEVER inherits the original row's authority (that let a local caller change a refused
|
|
782
|
+
// G2 row and mint a new vouched turn from it). It carries the origin of the request that sent it
|
|
783
|
+
// again: a verified glasses or phone client is vouched on its own socket and instance, like any
|
|
784
|
+
// new turn; anyone else is recorded unvouched ('resend_not_reverified'), with no footer. The
|
|
785
|
+
// original id is kept only as the record's `resendOf` annotation.
|
|
786
|
+
if (deps.provenance && resendVerdict) {
|
|
787
|
+
// After the write: the read-modify-write above stays one synchronous step. Sealed to the copy's files.
|
|
788
|
+
const sealed = await sealFiles(copy.attachmentIds, resendVerdict, deps.turnAttachmentStore)
|
|
789
|
+
deps.provenance.noteQueued(provider, threadId, newId, copy.prompt, sealed.verdict, sealed.attachments)
|
|
790
|
+
deps.provenance.record({ turnId: newId, prompt: copy.prompt, verdict: sealed.verdict, provider, sessionId: threadId, via: 'queue-resend', resendOf: original.clientTurnId })
|
|
791
|
+
}
|
|
682
792
|
console.log(`[thread-turn-queue] resend provider=${provider} from=${original.clientTurnId} as=${newId} unknown=${original.deliveryState === 'unknown'}`)
|
|
683
793
|
return res.status(200).json(publicRow(copy, queuePosition(admitted.queue, newId)))
|
|
684
794
|
})
|
|
@@ -693,7 +803,9 @@ export function createThreadTurnQueueRouter(deps: ThreadTurnQueueDeps): Router {
|
|
|
693
803
|
if (provider === 'codex') {
|
|
694
804
|
try {
|
|
695
805
|
native = (await (deps.nativeQueue ?? nativeQueueControl).list(threadId)).map((row, position) => {
|
|
696
|
-
|
|
806
|
+
// G2 authority Tier 1: the lens and the web show (and edit) the words, never the verify footer.
|
|
807
|
+
const plain = plainQueueText(row)
|
|
808
|
+
const text = plain === null ? null : stripG2TurnFooter(plain).text
|
|
697
809
|
return { clientTurnId: `codex-native:${row.id}`, status: 'waiting', position, queuedAt: 0,
|
|
698
810
|
attempts: 0, preview: text?.slice(0, 80) ?? 'Message with attachments · manage in Codex',
|
|
699
811
|
...(text !== null ? { prompt: text } : {}), editable: text !== null, owner: 'codex' }
|
|
@@ -711,17 +823,44 @@ export function createThreadTurnQueueRouter(deps: ThreadTurnQueueDeps): Router {
|
|
|
711
823
|
const provider = String(req.params.provider), threadId = String(req.params.threadId), id = String(req.params.clientTurnId)
|
|
712
824
|
const prompt = req.body?.prompt
|
|
713
825
|
if (typeof prompt !== 'string' || !prompt.trim() || prompt.length > DEFAULT_MAX_PROMPT_CHARS) return res.status(400).json({ error: 'invalid_prompt' })
|
|
826
|
+
// G2 authority Tier 1 (QA 6.99.29): an edit is typed words like any other.
|
|
827
|
+
if (deps.provenance && hasFooterShapedLine(prompt)) {
|
|
828
|
+
return res.status(400).json({ error: 'footer_line_refused', reasonCopy: FOOTER_LINE_REFUSED_COPY })
|
|
829
|
+
}
|
|
714
830
|
if (provider === 'codex' && id.startsWith('codex-native:')) {
|
|
715
831
|
try {
|
|
716
|
-
|
|
832
|
+
// G2 authority Tier 1: the edit replaces the text in the Codex app's own queue, so it is a
|
|
833
|
+
// delivery of its own, under an id minted here (the native row id is not ours).
|
|
834
|
+
let sent = prompt
|
|
835
|
+
if (deps.provenance) {
|
|
836
|
+
const verdict = deps.provenance.originOf(req)
|
|
837
|
+
const editId = `cnedit-${randomBytes(12).toString('hex')}`
|
|
838
|
+
sent = deps.provenance.footed(prompt, editId, verdict, provider)
|
|
839
|
+
deps.provenance.record({ turnId: editId, prompt, verdict, provider, sessionId: threadId, via: 'codex-native-edit' })
|
|
840
|
+
}
|
|
841
|
+
await (deps.nativeQueue ?? nativeQueueControl).update(threadId, id.slice(13), sent)
|
|
717
842
|
return res.json({ updated: true })
|
|
718
843
|
} catch { return res.status(409).json({ error: 'queue_edit_unconfirmed' }) }
|
|
719
844
|
}
|
|
720
845
|
const queue = readQueue(provider, threadId, deps.now())
|
|
721
846
|
const row = queue.find(t => t.clientTurnId === id)
|
|
722
847
|
if (!row || row.status !== 'waiting') return res.status(409).json({ error: 'already_delivering' })
|
|
848
|
+
// The words are now whoever edited them: their origin replaces the parked one. Only over a record
|
|
849
|
+
// that is still parked (QA 6.99.29): a row written into the queue file under a delivered turn's id
|
|
850
|
+
// cannot be edited over that turn's record.
|
|
851
|
+
const verdict = deps.provenance ? deps.provenance.originOf(req) : null
|
|
852
|
+
if (deps.provenance && verdict && deps.provenance.reusesTurnId(id, { prompt, verdict, provider, sessionId: threadId }, { continues: true })) {
|
|
853
|
+
return res.status(409).json({ error: 'turn_id_in_use', reasonCopy: TURN_ID_IN_USE_COPY })
|
|
854
|
+
}
|
|
723
855
|
row.prompt = prompt
|
|
724
856
|
writeQueue(provider, threadId, queue)
|
|
857
|
+
if (deps.provenance && verdict) {
|
|
858
|
+
// Sealed to the row's files as they are now (an edit changes only the words).
|
|
859
|
+
const sealed = await sealFiles(row.attachmentIds, verdict, deps.turnAttachmentStore)
|
|
860
|
+
deps.provenance.noteQueued(provider, threadId, id, prompt, sealed.verdict, sealed.attachments)
|
|
861
|
+
// The queue's own row, re-judged: it continues the record parked under this id.
|
|
862
|
+
deps.provenance.record({ turnId: id, prompt, verdict: sealed.verdict, provider, sessionId: threadId, via: 'queue-edit', continues: true })
|
|
863
|
+
}
|
|
725
864
|
return res.json({ updated: true })
|
|
726
865
|
})
|
|
727
866
|
|
|
@@ -748,6 +887,7 @@ export function createThreadTurnQueueRouter(deps: ThreadTurnQueueDeps): Router {
|
|
|
748
887
|
row.status = 'cancelled'
|
|
749
888
|
row.settledAt = now
|
|
750
889
|
writeQueue(provider, threadId, queue)
|
|
890
|
+
deps.provenance?.forgetQueued(provider, threadId, clientTurnId)
|
|
751
891
|
}
|
|
752
892
|
return res.json({ cancelled: true, clientTurnId, status: row.status })
|
|
753
893
|
})
|