@gotcos/glasses-server 6.50.4 → 6.52.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +16 -0
- package/CHANGELOG.md +78 -0
- package/README.md +56 -3
- package/bin/hooks/cos-session-hook +24 -1
- package/managed-runtime-contract.json +1 -0
- package/package.json +1 -1
- package/server/index.ts +83 -5
- package/server/lib/activity-preview.ts +45 -2
- package/server/lib/agent-session-search.ts +3 -1
- package/server/lib/agent-session-store.ts +35 -5
- package/server/lib/agent-session-turns.ts +4 -1
- package/server/lib/claude-bridge.ts +9 -0
- package/server/lib/client-liveness.ts +50 -0
- package/server/lib/codex-bridge.ts +6 -0
- package/server/lib/codex-live-queue-deps.ts +135 -0
- package/server/lib/codex-live-queue.ts +232 -0
- package/server/lib/codex-turn-clock.ts +36 -0
- package/server/lib/cursor-bridge.ts +6 -0
- package/server/lib/cursor-stop-followup.ts +134 -0
- package/server/lib/job-trail.ts +758 -0
- package/server/lib/occupancy-probes.ts +52 -0
- package/server/lib/ollama-bridge.ts +17 -0
- package/server/lib/permission-broker.ts +1156 -0
- package/server/lib/query-job-coordinator.ts +17 -1
- package/server/lib/query-job-feature.ts +10 -0
- package/server/lib/query-job-runtime.ts +9 -1
- package/server/lib/query-job-store.ts +214 -5
- package/server/lib/query-job-types.ts +36 -0
- package/server/lib/session-signal-store.ts +42 -10
- package/server/lib/session-state-derive.ts +14 -4
- package/server/lib/session-stream-events.ts +600 -15
- package/server/lib/session-stream-producer.ts +25 -6
- package/server/lib/session-transcript-watcher.ts +120 -3
- package/server/lib/thread-occupancy.ts +30 -7
- package/server/routes/agent-session-bindings.ts +98 -6
- package/server/routes/agent-sessions.ts +24 -2
- package/server/routes/cursor-stop-followup.ts +74 -0
- package/server/routes/health.ts +34 -1
- package/server/routes/permission-broker.ts +176 -0
- package/server/routes/query-jobs.ts +84 -13
- package/server/routes/thread-turn-queue.ts +15 -5
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
type QueryJobStoreHealth,
|
|
19
19
|
} from './query-job-types.js'
|
|
20
20
|
import type { MaintenanceWorkLease } from './maintenance-lifecycle.js'
|
|
21
|
+
import { applyTrailToolMode, type JobTrailDraft } from './job-trail.js'
|
|
21
22
|
|
|
22
23
|
const CLIENT_JOB_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
|
23
24
|
|
|
@@ -41,6 +42,9 @@ export interface QueryJobRunnerCallbacks {
|
|
|
41
42
|
onChunk: (text: string) => void
|
|
42
43
|
onToolStatus: (text: string) => void
|
|
43
44
|
onActivityLine: (line: { kind: 'input' | 'output'; text: string }) => void
|
|
45
|
+
/** One readable step of the run (6.52.0). Filtered by the job's `activityToolMode`
|
|
46
|
+
* here, redacted by the store, journaled as a `trail` event. Never on the display bus. */
|
|
47
|
+
onTrail: (draft: JobTrailDraft) => void
|
|
44
48
|
/** True only when this callback durably won the answer-ready transition. */
|
|
45
49
|
onAnswerReady: (text: string, linkage?: QueryJobProviderLinkage) => boolean | Promise<boolean>
|
|
46
50
|
/** True only when this callback won and durably projected the terminal. */
|
|
@@ -349,6 +353,15 @@ export class QueryJobCoordinator {
|
|
|
349
353
|
await this.store.appendActivity(active.jobId, line.kind, line.text)
|
|
350
354
|
})
|
|
351
355
|
},
|
|
356
|
+
onTrail: (draft) => {
|
|
357
|
+
// The user's opt-out is decided HERE, from the job's own request: `off` keeps no
|
|
358
|
+
// tool steps, `status` keeps steps without raw output (see applyTrailToolMode).
|
|
359
|
+
const allowed = applyTrailToolMode(draft, active.request.activityToolMode)
|
|
360
|
+
if (!allowed) return
|
|
361
|
+
void this.enqueueCallback(active, async () => {
|
|
362
|
+
await this.store.appendTrail(active.jobId, allowed)
|
|
363
|
+
})
|
|
364
|
+
},
|
|
352
365
|
onAnswerReady: (text, linkage = {}) => this.answerReady(active, text, linkage),
|
|
353
366
|
onDone: completion => this.completeActive(active, completion),
|
|
354
367
|
onError: error => this.failActive(active, error),
|
|
@@ -540,8 +553,11 @@ export class QueryJobCoordinator {
|
|
|
540
553
|
generation: number,
|
|
541
554
|
after: number,
|
|
542
555
|
listener: Parameters<QueryJobStore['subscribe']>[3],
|
|
556
|
+
trail?: Parameters<QueryJobStore['subscribe']>[4],
|
|
543
557
|
): Promise<QueryJobSubscription> {
|
|
544
|
-
return
|
|
558
|
+
return trail
|
|
559
|
+
? this.store.subscribe(jobId, generation, after, listener, trail)
|
|
560
|
+
: this.store.subscribe(jobId, generation, after, listener)
|
|
545
561
|
}
|
|
546
562
|
|
|
547
563
|
async shutdown(reason = 'server_shutdown'): Promise<void> {
|
|
@@ -9,6 +9,16 @@ export function durableQueryJobsEnabled(env: NodeJS.ProcessEnv = process.env): b
|
|
|
9
9
|
return env.COS_DURABLE_QUERY_JOBS !== '0'
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* The Messages trail (6.52.0) is on by default. COS_MESSAGES_TRAIL=0 is the rollback
|
|
14
|
+
* that needs no downgrade: the job runner then hands the bridges no trail callback, so
|
|
15
|
+
* no second stdout reader is attached, no `trail` event is journaled, and every stream,
|
|
16
|
+
* snapshot and answer is exactly the 6.51.0 one. Read per job, so a restart applies it.
|
|
17
|
+
*/
|
|
18
|
+
export function messagesTrailEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
19
|
+
return env.COS_MESSAGES_TRAIL !== '0'
|
|
20
|
+
}
|
|
21
|
+
|
|
12
22
|
export function durableQueryJobsCapability(): {
|
|
13
23
|
enabled: boolean
|
|
14
24
|
protocolVersion: number
|
|
@@ -7,7 +7,7 @@ import { resolveQueryAttachments } from './query-attachments.js'
|
|
|
7
7
|
import { getMediaStore } from './media-store.js'
|
|
8
8
|
import { dataPath } from './data-dir.js'
|
|
9
9
|
import { currentMessageEra, LEGACY_MESSAGE_ERA } from './message-era.js'
|
|
10
|
-
import { durableQueryJobsEnabled } from './query-job-feature.js'
|
|
10
|
+
import { durableQueryJobsEnabled, messagesTrailEnabled } from './query-job-feature.js'
|
|
11
11
|
import { QueryJobCoordinator, type QueryJobRunner } from './query-job-coordinator.js'
|
|
12
12
|
import { QueryJobStore } from './query-job-store.js'
|
|
13
13
|
import {
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
type QueryJobSnapshot,
|
|
34
34
|
} from './query-job-types.js'
|
|
35
35
|
import { acquireMaintenanceWork } from './maintenance-lifecycle.js'
|
|
36
|
+
import type { JobTrailDraft } from './job-trail.js'
|
|
36
37
|
import { registerMessageReservationSource } from './message-reservations.js'
|
|
37
38
|
|
|
38
39
|
const TOOL_STATUS_MESSAGES: Record<string, string> = {
|
|
@@ -243,6 +244,13 @@ const runner: QueryJobRunner = async ({ jobId, turnId, request, signal, callback
|
|
|
243
244
|
...(request.activityToolMode === 'preview' ? {
|
|
244
245
|
onActivityLine: (line: { kind: 'input' | 'output'; text: string }) => callbacks.onActivityLine(line),
|
|
245
246
|
} : {}),
|
|
247
|
+
// 6.52.0, the Messages trail. Journaled only (the coordinator applies the user's
|
|
248
|
+
// tool-activity mode and the store redacts); it NEVER goes on the display bus, which
|
|
249
|
+
// any paired display may read. Absent entirely with COS_MESSAGES_TRAIL=0, so the
|
|
250
|
+
// bridges attach no second stdout reader at all.
|
|
251
|
+
...(messagesTrailEnabled() ? {
|
|
252
|
+
onTrail: (draft: JobTrailDraft) => callbacks.onTrail(draft),
|
|
253
|
+
} : {}),
|
|
246
254
|
onAnswerReady: text => callbacks.onAnswerReady(text, {
|
|
247
255
|
...(activeModel ? { provider: providerFor(activeModel), resolvedModel: activeModel } : {}),
|
|
248
256
|
}),
|
|
@@ -28,7 +28,36 @@ import {
|
|
|
28
28
|
type QueryJobSnapshot,
|
|
29
29
|
type QueryJobStatus,
|
|
30
30
|
type QueryJobStoreHealth,
|
|
31
|
+
type QueryJobTrailEntry,
|
|
31
32
|
} from './query-job-types.js'
|
|
33
|
+
import {
|
|
34
|
+
jobTrailTextChars,
|
|
35
|
+
parseJobTrailEventData,
|
|
36
|
+
sanitizeJobTrailDraft,
|
|
37
|
+
type JobTrailDraft,
|
|
38
|
+
} from './job-trail.js'
|
|
39
|
+
|
|
40
|
+
const QUERY_JOB_STATUSES: readonly QueryJobStatus[] = [
|
|
41
|
+
'accepted', 'starting', 'running', 'answer_ready', 'completed', 'failed', 'canceled', 'interrupted',
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Every event type hydration accepts. A row of any other type counts as malformed and is
|
|
46
|
+
* skipped, which is exactly what a 6.51.0 server does with the `trail` rows this build
|
|
47
|
+
* writes: those jobs still load and only `malformedRows` rises (the rollback contract, so
|
|
48
|
+
* QUERY_JOB_SCHEMA_VERSION stays 1; a bump would drop every row on rollback). THIS build
|
|
49
|
+
* must list `trail`, or it would count its own rows as malformed on every restart and the
|
|
50
|
+
* trail would vanish after a reboot.
|
|
51
|
+
*
|
|
52
|
+
* A `trail` row is NOT a job event (QA round 1). It carries the job's CURRENT eventSeq and
|
|
53
|
+
* never advances it, takes no replay-ring slot, never moves `updatedAt`, and is applied
|
|
54
|
+
* idempotently by its own dense `trailSeq`. So an old client's stream is exactly the
|
|
55
|
+
* 6.51.0 one, and a rolled-back 6.51.0 server, which skips these rows, numbers its next
|
|
56
|
+
* event after the last event it CAN read without colliding with anything written here.
|
|
57
|
+
*/
|
|
58
|
+
export const QUERY_JOB_JOURNAL_EVENT_TYPES: readonly QueryJobEventType[] = [
|
|
59
|
+
...QUERY_JOB_STATUSES, 'chunk', 'tool_status', 'activity_line', 'acknowledged', 'trail',
|
|
60
|
+
]
|
|
32
61
|
|
|
33
62
|
const PARTITION_RE = /^\d{4}-\d{2}-\d{2}\.jsonl$/
|
|
34
63
|
const MAX_JOURNAL_RECORD_BYTES = 512 * 1024
|
|
@@ -59,6 +88,9 @@ interface HydratedQueryJob {
|
|
|
59
88
|
snapshot: QueryJobSnapshot
|
|
60
89
|
events: QueryJobEvent[]
|
|
61
90
|
lastBootId: string
|
|
91
|
+
/** The last `trailSeq` this job journaled. The next trail row is this plus one,
|
|
92
|
+
* assigned inside the serialized append, so the persisted order is dense 1..n. */
|
|
93
|
+
trailSeq: number
|
|
62
94
|
}
|
|
63
95
|
|
|
64
96
|
interface QueryJobIdentity {
|
|
@@ -95,9 +127,41 @@ export interface QueryJobExecutionRecord {
|
|
|
95
127
|
|
|
96
128
|
export interface QueryJobSubscription {
|
|
97
129
|
replay: QueryJobReplay
|
|
130
|
+
/** Present only when the subscriber asked for the trail. */
|
|
131
|
+
trailReplay?: QueryJobTrailReplay
|
|
98
132
|
unsubscribe: () => void
|
|
99
133
|
}
|
|
100
134
|
|
|
135
|
+
/** One trail entry as a subscriber receives it: its own cursor, never the job eventSeq. */
|
|
136
|
+
export interface QueryJobTrailFrame {
|
|
137
|
+
type: 'trail'
|
|
138
|
+
trailSeq: number
|
|
139
|
+
jobId: string
|
|
140
|
+
clientJobId: string
|
|
141
|
+
generation: number
|
|
142
|
+
at: string
|
|
143
|
+
data: JobTrailDraft
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* What a trail subscriber missed. `gap` means entries after its cursor are no longer held
|
|
148
|
+
* (`snapshot.trail` is bounded) or the cursor is ahead: the client resets its trail to
|
|
149
|
+
* `entries` and continues from `latestTrailSeq`. Otherwise `entries` are exactly those
|
|
150
|
+
* after the cursor, in order.
|
|
151
|
+
*/
|
|
152
|
+
export interface QueryJobTrailReplay {
|
|
153
|
+
entries: QueryJobTrailEntry[]
|
|
154
|
+
gap: boolean
|
|
155
|
+
reason?: 'trail_gap' | 'cursor_ahead'
|
|
156
|
+
oldestTrailSeq: number
|
|
157
|
+
latestTrailSeq: number
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export interface QueryJobTrailSubscribe {
|
|
161
|
+
after: number
|
|
162
|
+
listener: (frame: QueryJobTrailFrame) => void
|
|
163
|
+
}
|
|
164
|
+
|
|
101
165
|
export interface QueryJobJournalStorage {
|
|
102
166
|
prepare(root: string): Promise<void>
|
|
103
167
|
listPartitions(root: string): Promise<string[]>
|
|
@@ -162,6 +226,14 @@ export interface QueryJobStoreOptions {
|
|
|
162
226
|
maxHydratedJobs?: number
|
|
163
227
|
maxReplayEvents?: number
|
|
164
228
|
maxActivityEntries?: number
|
|
229
|
+
maxTrailEntries?: number
|
|
230
|
+
maxTrailChars?: number
|
|
231
|
+
/**
|
|
232
|
+
* Replaces the hydration allowlist. Production never passes it. It exists so a test
|
|
233
|
+
* can hydrate a journal through the exact 6.51.0 list (no `trail`) and prove the
|
|
234
|
+
* rollback contract on real bytes without shipping a second copy of this class.
|
|
235
|
+
*/
|
|
236
|
+
journalEventTypes?: readonly QueryJobEventType[]
|
|
165
237
|
}
|
|
166
238
|
|
|
167
239
|
export class QueryJobStoreError extends Error {
|
|
@@ -290,10 +362,15 @@ export class QueryJobStore {
|
|
|
290
362
|
private readonly maxHydratedJobs: number
|
|
291
363
|
private readonly maxReplayEvents: number
|
|
292
364
|
private readonly maxActivityEntries: number
|
|
365
|
+
private readonly maxTrailEntries: number
|
|
366
|
+
private readonly maxTrailChars: number
|
|
367
|
+
private readonly journalEventTypes: readonly QueryJobEventType[]
|
|
293
368
|
private readonly jobs = new Map<string, HydratedQueryJob>()
|
|
294
369
|
private readonly identitiesByJobId = new Map<string, QueryJobIdentity>()
|
|
295
370
|
private readonly identitiesByKey = new Map<string, QueryJobIdentity>()
|
|
296
371
|
private readonly emitter = new EventEmitter()
|
|
372
|
+
/** Trail frames, apart from job events: only a subscriber that asked for them listens. */
|
|
373
|
+
private readonly trailEmitter = new EventEmitter()
|
|
297
374
|
private appendTail: Promise<void> = Promise.resolve()
|
|
298
375
|
private initPromise: Promise<QueryJobStoreHealth> | null = null
|
|
299
376
|
private partitions: string[] = []
|
|
@@ -307,7 +384,11 @@ export class QueryJobStore {
|
|
|
307
384
|
this.maxHydratedJobs = Math.max(1, options.maxHydratedJobs ?? QUERY_JOB_LIMITS.hydratedJobs)
|
|
308
385
|
this.maxReplayEvents = Math.max(1, options.maxReplayEvents ?? QUERY_JOB_LIMITS.replayEvents)
|
|
309
386
|
this.maxActivityEntries = Math.max(1, options.maxActivityEntries ?? QUERY_JOB_LIMITS.activityEntries)
|
|
387
|
+
this.maxTrailEntries = Math.max(1, options.maxTrailEntries ?? QUERY_JOB_LIMITS.trailEntries)
|
|
388
|
+
this.maxTrailChars = Math.max(1, options.maxTrailChars ?? QUERY_JOB_LIMITS.trailChars)
|
|
389
|
+
this.journalEventTypes = options.journalEventTypes ?? QUERY_JOB_JOURNAL_EVENT_TYPES
|
|
310
390
|
this.emitter.setMaxListeners(1_000)
|
|
391
|
+
this.trailEmitter.setMaxListeners(1_000)
|
|
311
392
|
this.health = {
|
|
312
393
|
state: 'new',
|
|
313
394
|
bootId: options.bootId,
|
|
@@ -429,9 +510,10 @@ export class QueryJobStore {
|
|
|
429
510
|
|| typeof r.status !== 'string'
|
|
430
511
|
|| !r.patch || typeof r.patch !== 'object'
|
|
431
512
|
|| !r.eventData || typeof r.eventData !== 'object') return null
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
513
|
+
if (!QUERY_JOB_STATUSES.includes(r.status as QueryJobStatus)
|
|
514
|
+
|| !this.journalEventTypes.includes(r.type as QueryJobEventType)) return null
|
|
515
|
+
// A trail row whose data is not a trail entry is malformed, not a hole in the trail.
|
|
516
|
+
if (r.type === 'trail' && !parseJobTrailEventData(r.eventData)) return null
|
|
435
517
|
return r as unknown as QueryJobJournalRecord
|
|
436
518
|
}
|
|
437
519
|
|
|
@@ -466,6 +548,7 @@ export class QueryJobStore {
|
|
|
466
548
|
request,
|
|
467
549
|
events: [],
|
|
468
550
|
lastBootId: record.bootId,
|
|
551
|
+
trailSeq: 0,
|
|
469
552
|
snapshot: {
|
|
470
553
|
schemaVersion: QUERY_JOB_SCHEMA_VERSION,
|
|
471
554
|
jobId: record.jobId,
|
|
@@ -494,6 +577,11 @@ export class QueryJobStore {
|
|
|
494
577
|
}
|
|
495
578
|
this.jobs.set(record.jobId, hydrated)
|
|
496
579
|
}
|
|
580
|
+
// A trail row is not a job event: its own cursor, nothing else of the job moves.
|
|
581
|
+
if (record.type === 'trail') {
|
|
582
|
+
this.applyTrail(hydrated, record, publish)
|
|
583
|
+
return undefined
|
|
584
|
+
}
|
|
497
585
|
if (record.eventSeq <= hydrated.snapshot.eventSeq) return undefined
|
|
498
586
|
if (record.type !== 'accepted'
|
|
499
587
|
&& record.type !== 'acknowledged'
|
|
@@ -604,6 +692,45 @@ export class QueryJobStore {
|
|
|
604
692
|
if (snapshot.activity.length > this.maxActivityEntries) snapshot.activity.shift()
|
|
605
693
|
}
|
|
606
694
|
|
|
695
|
+
/**
|
|
696
|
+
* One trail row into `snapshot.trail`, the page a client rebuilds from. Idempotent by
|
|
697
|
+
* `trailSeq` (a row at or below the job's last one is history, whichever boot wrote it),
|
|
698
|
+
* never on a terminal job, and it touches NOTHING else of the job: not eventSeq, not the
|
|
699
|
+
* replay ring, not `updatedAt`, not the identity. Bounded twice: the newest
|
|
700
|
+
* `maxTrailEntries` entries, and no more than `maxTrailChars` characters of text across
|
|
701
|
+
* them (the oldest go first; the newest entry always stays). The array exists only once a
|
|
702
|
+
* row does. A live append is published on the trail channel, which only a subscriber that
|
|
703
|
+
* asked for the trail listens to.
|
|
704
|
+
*/
|
|
705
|
+
private applyTrail(hydrated: HydratedQueryJob, record: QueryJobJournalRecord, publish: boolean): void {
|
|
706
|
+
const parsed = parseJobTrailEventData(record.eventData)
|
|
707
|
+
if (!parsed) return
|
|
708
|
+
if (record.generation !== hydrated.snapshot.generation) return
|
|
709
|
+
if (isTerminalQueryJobStatus(hydrated.snapshot.status)) return
|
|
710
|
+
if (parsed.trailSeq <= hydrated.trailSeq) return
|
|
711
|
+
hydrated.trailSeq = parsed.trailSeq
|
|
712
|
+
const trail = hydrated.snapshot.trail ?? (hydrated.snapshot.trail = [])
|
|
713
|
+
const entry: QueryJobTrailEntry = { ...parsed.draft, trailSeq: parsed.trailSeq, at: record.persistedAt }
|
|
714
|
+
trail.push(entry)
|
|
715
|
+
let chars = trail.reduce((sum, e) => sum + jobTrailTextChars(e), 0)
|
|
716
|
+
while (trail.length > 1 && (trail.length > this.maxTrailEntries || chars > this.maxTrailChars)) {
|
|
717
|
+
chars -= jobTrailTextChars(trail.shift()!)
|
|
718
|
+
}
|
|
719
|
+
if (publish) {
|
|
720
|
+
const { trailSeq: _seq, at: _at, ...draft } = entry
|
|
721
|
+
const frame: QueryJobTrailFrame = {
|
|
722
|
+
type: 'trail',
|
|
723
|
+
trailSeq: entry.trailSeq,
|
|
724
|
+
jobId: hydrated.snapshot.jobId,
|
|
725
|
+
clientJobId: hydrated.snapshot.clientJobId,
|
|
726
|
+
generation: hydrated.snapshot.generation,
|
|
727
|
+
at: entry.at,
|
|
728
|
+
data: draft as JobTrailDraft,
|
|
729
|
+
}
|
|
730
|
+
this.trailEmitter.emit(hydrated.snapshot.jobId, frame)
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
607
734
|
private async appendRecord(record: QueryJobJournalRecord): Promise<void> {
|
|
608
735
|
const serialized = JSON.stringify(record)
|
|
609
736
|
if (Buffer.byteLength(serialized, 'utf8') > MAX_JOURNAL_RECORD_BYTES) {
|
|
@@ -771,6 +898,55 @@ export class QueryJobStore {
|
|
|
771
898
|
return this.mutateSameStatus(jobId, type, {}, { kind, text: safe.text, truncated: safe.truncated })
|
|
772
899
|
}
|
|
773
900
|
|
|
901
|
+
/**
|
|
902
|
+
* One trail draft into the journal (6.52.0).
|
|
903
|
+
*
|
|
904
|
+
* Redacted and bounded here, at the journal boundary, whatever the caller already did
|
|
905
|
+
* (`sanitizeJobTrailDraft`). `trailSeq` is assigned INSIDE the serialized append from the
|
|
906
|
+
* job's own last one, so the persisted trail is dense 1..n no matter how trail, chunk and
|
|
907
|
+
* activity writes interleave. A draft with nothing to say, or a job already terminal,
|
|
908
|
+
* writes nothing and is never an error: a trail row must not be able to fail a run.
|
|
909
|
+
*
|
|
910
|
+
* NOT A JOB EVENT (QA round 1). The row carries the job's current eventSeq without
|
|
911
|
+
* advancing it and goes to the trail channel only: job subscribers, the replay ring and
|
|
912
|
+
* every snapshot field but `trail` are exactly what they would be without it.
|
|
913
|
+
*/
|
|
914
|
+
async appendTrail(jobId: string, raw: unknown): Promise<QueryJobMutationResult> {
|
|
915
|
+
const draft = sanitizeJobTrailDraft(raw)
|
|
916
|
+
await this.ensureInitialized()
|
|
917
|
+
await this.ensureHydrated(jobId)
|
|
918
|
+
return this.enqueue(async () => {
|
|
919
|
+
this.assertWritable()
|
|
920
|
+
const hydrated = this.jobs.get(jobId)
|
|
921
|
+
if (!hydrated) throw new QueryJobNotFoundError(jobId)
|
|
922
|
+
if (!draft || isTerminalQueryJobStatus(hydrated.snapshot.status)) {
|
|
923
|
+
return { applied: false, job: clone(hydrated.snapshot) }
|
|
924
|
+
}
|
|
925
|
+
const snapshot = hydrated.snapshot
|
|
926
|
+
const record: QueryJobJournalRecord = {
|
|
927
|
+
schemaVersion: QUERY_JOB_SCHEMA_VERSION,
|
|
928
|
+
recordId: randomUUID(),
|
|
929
|
+
partitionDay: localPartitionDay(this.now()),
|
|
930
|
+
persistedAt: this.now().toISOString(),
|
|
931
|
+
bootId: this.options.bootId,
|
|
932
|
+
jobId: snapshot.jobId,
|
|
933
|
+
clientJobId: snapshot.clientJobId,
|
|
934
|
+
generation: snapshot.generation,
|
|
935
|
+
turnId: snapshot.turnId,
|
|
936
|
+
requestFingerprint: snapshot.requestFingerprint,
|
|
937
|
+
// The event this row follows. Never `+ 1`: the job cursor is not the trail's.
|
|
938
|
+
eventSeq: snapshot.eventSeq,
|
|
939
|
+
type: 'trail',
|
|
940
|
+
status: snapshot.status,
|
|
941
|
+
patch: {},
|
|
942
|
+
eventData: { ...draft, trailSeq: hydrated.trailSeq + 1 },
|
|
943
|
+
}
|
|
944
|
+
await this.appendRecord(record)
|
|
945
|
+
this.applyTrail(hydrated, record, true)
|
|
946
|
+
return { applied: true, job: clone(hydrated.snapshot) }
|
|
947
|
+
})
|
|
948
|
+
}
|
|
949
|
+
|
|
774
950
|
async markAnswerReady(
|
|
775
951
|
jobId: string,
|
|
776
952
|
fullText: string,
|
|
@@ -1109,31 +1285,64 @@ export class QueryJobStore {
|
|
|
1109
1285
|
generation: number,
|
|
1110
1286
|
after: number,
|
|
1111
1287
|
listener: (event: QueryJobEvent) => void,
|
|
1288
|
+
trail?: QueryJobTrailSubscribe,
|
|
1112
1289
|
): Promise<QueryJobSubscription> {
|
|
1113
1290
|
await this.getSnapshot(jobId, generation)
|
|
1114
1291
|
let closed = false
|
|
1115
1292
|
const wrapped = (event: QueryJobEvent) => {
|
|
1116
1293
|
if (!closed && event.generation === generation && event.eventSeq > after) listener(clone(event))
|
|
1117
1294
|
}
|
|
1295
|
+
// The live trail cursor. It starts at the client's, and a rebuild moves it (below).
|
|
1296
|
+
let trailCursor = trail ? trail.after : 0
|
|
1297
|
+
const trailWrapped = trail
|
|
1298
|
+
? (frame: QueryJobTrailFrame) => {
|
|
1299
|
+
if (!closed && frame.generation === generation && frame.trailSeq > trailCursor) trail.listener(clone(frame))
|
|
1300
|
+
}
|
|
1301
|
+
: null
|
|
1118
1302
|
this.emitter.on(jobId, wrapped)
|
|
1303
|
+
if (trailWrapped) this.trailEmitter.on(jobId, trailWrapped)
|
|
1119
1304
|
this.subscriberCount++
|
|
1120
1305
|
this.refreshHealth()
|
|
1121
1306
|
// Register first, then take the synchronous replay snapshot. An append can
|
|
1122
|
-
// therefore be in replay OR arrive live (and clients dedupe by eventSeq
|
|
1123
|
-
// but can never fall into an await-sized gap between the two.
|
|
1307
|
+
// therefore be in replay OR arrive live (and clients dedupe by eventSeq, or by
|
|
1308
|
+
// trailSeq), but can never fall into an await-sized gap between the two.
|
|
1124
1309
|
const replay = this.buildReplay(jobId, after)
|
|
1310
|
+
const trailReplay = trail ? this.buildTrailReplay(jobId, trail.after) : undefined
|
|
1311
|
+
// A rebuild (`cursor_ahead` or `trail_gap`) resets the client's trail to the held entries
|
|
1312
|
+
// and continues from `latestTrailSeq`, so the live filter does too (6.52.0 app QA). A
|
|
1313
|
+
// retried Messages run keeps its queue row but starts a new job whose trail restarts at 1;
|
|
1314
|
+
// subscribed with the failed attempt's `trailAfter=5` to a trail at 2, the stale cursor
|
|
1315
|
+
// dropped entries 3, 4 and 5 as already seen. Still synchronous with the registration.
|
|
1316
|
+
if (trailReplay?.gap) trailCursor = trailReplay.latestTrailSeq
|
|
1125
1317
|
return {
|
|
1126
1318
|
replay,
|
|
1319
|
+
...(trailReplay ? { trailReplay } : {}),
|
|
1127
1320
|
unsubscribe: () => {
|
|
1128
1321
|
if (closed) return
|
|
1129
1322
|
closed = true
|
|
1130
1323
|
this.emitter.off(jobId, wrapped)
|
|
1324
|
+
if (trailWrapped) this.trailEmitter.off(jobId, trailWrapped)
|
|
1131
1325
|
this.subscriberCount = Math.max(0, this.subscriberCount - 1)
|
|
1132
1326
|
this.refreshHealth()
|
|
1133
1327
|
},
|
|
1134
1328
|
}
|
|
1135
1329
|
}
|
|
1136
1330
|
|
|
1331
|
+
/** The trail entries after `after`, or the whole held trail when some are gone. */
|
|
1332
|
+
private buildTrailReplay(jobId: string, after: number): QueryJobTrailReplay {
|
|
1333
|
+
const hydrated = this.jobs.get(jobId)!
|
|
1334
|
+
const trail = hydrated.snapshot.trail ?? []
|
|
1335
|
+
const latestTrailSeq = hydrated.trailSeq
|
|
1336
|
+
const oldestTrailSeq = trail[0]?.trailSeq ?? latestTrailSeq + 1
|
|
1337
|
+
if (after > latestTrailSeq) {
|
|
1338
|
+
return { entries: clone(trail), gap: true, reason: 'cursor_ahead', oldestTrailSeq, latestTrailSeq }
|
|
1339
|
+
}
|
|
1340
|
+
if (after < oldestTrailSeq - 1) {
|
|
1341
|
+
return { entries: clone(trail), gap: true, reason: 'trail_gap', oldestTrailSeq, latestTrailSeq }
|
|
1342
|
+
}
|
|
1343
|
+
return { entries: clone(trail.filter(entry => entry.trailSeq > after)), gap: false, oldestTrailSeq, latestTrailSeq }
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1137
1346
|
private trimHydratedJobs(protectedJobId?: string): void {
|
|
1138
1347
|
if (this.jobs.size <= this.maxHydratedJobs) return
|
|
1139
1348
|
const candidates = [...this.jobs.values()]
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
parseMediaIdList,
|
|
5
5
|
type MediaAttachmentRef,
|
|
6
6
|
} from '../../shared/media-attachment.js'
|
|
7
|
+
import type { JobTrailDraft } from './job-trail.js'
|
|
7
8
|
|
|
8
9
|
export const QUERY_JOB_SCHEMA_VERSION = 1 as const
|
|
9
10
|
export const QUERY_JOB_PROTOCOL_VERSION = 1 as const
|
|
@@ -17,6 +18,12 @@ export const QUERY_JOB_LIMITS = Object.freeze({
|
|
|
17
18
|
errorChars: 2_000,
|
|
18
19
|
activityChars: 2_000,
|
|
19
20
|
activityEntries: 64,
|
|
21
|
+
/** `snapshot.trail` keeps the newest 200 entries (6.52.0)... */
|
|
22
|
+
trailEntries: 200,
|
|
23
|
+
/** ...and at most this many characters of text across them, whichever bound
|
|
24
|
+
* bites first, so a replay-gap snapshot stays small on a phone. Each field is
|
|
25
|
+
* capped on its own too (see `sanitizeJobTrailDraft`). */
|
|
26
|
+
trailChars: 32_000,
|
|
20
27
|
replayEvents: 256,
|
|
21
28
|
retainedDays: 7,
|
|
22
29
|
hydratedJobs: 500,
|
|
@@ -43,6 +50,11 @@ export type QueryJobEventType =
|
|
|
43
50
|
| 'tool_status'
|
|
44
51
|
| 'activity_line'
|
|
45
52
|
| 'acknowledged'
|
|
53
|
+
// 6.52.0: one readable step of a Messages run (see lib/job-trail.ts), a JOURNAL row
|
|
54
|
+
// type only: it is never a job event, never takes an eventSeq or a replay-ring slot,
|
|
55
|
+
// and reaches only a subscriber that asked for the trail. Its data is one draft plus
|
|
56
|
+
// `trailSeq`, a dense 1..n per job.
|
|
57
|
+
| 'trail'
|
|
46
58
|
|
|
47
59
|
export type QueryJobActivityMode = 'off' | 'status' | 'preview'
|
|
48
60
|
export type QueryJobActivityKind = 'status' | 'input' | 'output' | 'gap'
|
|
@@ -147,6 +159,13 @@ export interface QueryJobActivity {
|
|
|
147
159
|
repeatCount?: number
|
|
148
160
|
}
|
|
149
161
|
|
|
162
|
+
/** One entry of `snapshot.trail`: the journaled draft, its dense `trailSeq` (the trail's
|
|
163
|
+
* own cursor, never the job eventSeq) and when it was written. */
|
|
164
|
+
export type QueryJobTrailEntry = JobTrailDraft & {
|
|
165
|
+
trailSeq: number
|
|
166
|
+
at: string
|
|
167
|
+
}
|
|
168
|
+
|
|
150
169
|
export interface QueryJobSnapshot extends QueryJobProviderLinkage {
|
|
151
170
|
schemaVersion: typeof QUERY_JOB_SCHEMA_VERSION
|
|
152
171
|
jobId: string
|
|
@@ -172,6 +191,11 @@ export interface QueryJobSnapshot extends QueryJobProviderLinkage {
|
|
|
172
191
|
outputImageStats?: QueryJobOutputImageStats
|
|
173
192
|
error?: QueryJobError
|
|
174
193
|
activity: QueryJobActivity[]
|
|
194
|
+
/** The readable trail (6.52.0), bounded by `QUERY_JOB_LIMITS.trailEntries` and
|
|
195
|
+
* `trailChars`, rebuilt from the journal on restart. ABSENT, never `[]`, on a job
|
|
196
|
+
* with no trail rows, and stripped from every snapshot sent to a client that did not
|
|
197
|
+
* ask for the trail (`?trail=1`), so such a client sees exactly the 6.51.0 shape. */
|
|
198
|
+
trail?: QueryJobTrailEntry[]
|
|
175
199
|
acceptedAt: string
|
|
176
200
|
startedAt?: string
|
|
177
201
|
answerReadyAt?: string
|
|
@@ -477,6 +501,18 @@ export function parseQueryJobOutputImageStats(raw: unknown): QueryJobOutputImage
|
|
|
477
501
|
return { published, attached, rejected }
|
|
478
502
|
}
|
|
479
503
|
|
|
504
|
+
/** The store's own credential and local-path patterns, applied to free text with
|
|
505
|
+
* nothing else changed: no whitespace collapsing, no bound, newlines kept. The trail
|
|
506
|
+
* sanitizer (lib/job-trail.ts) runs this after `redactSecretText`, so a trail field
|
|
507
|
+
* passes BOTH redaction layers before it reaches the journal. */
|
|
508
|
+
export function redactQueryJobText(value: string): string {
|
|
509
|
+
if (typeof value !== 'string' || value.length === 0) return ''
|
|
510
|
+
let text = value
|
|
511
|
+
for (const pattern of SECRET_PATTERNS) text = text.replace(pattern, '[redacted]')
|
|
512
|
+
for (const pattern of PATH_PATTERNS) text = text.replace(pattern, '[path]')
|
|
513
|
+
return text
|
|
514
|
+
}
|
|
515
|
+
|
|
480
516
|
/** Second-line redaction even for bridge-produced "safe" activity. This is
|
|
481
517
|
* intentionally conservative: replay never needs a credential or local path. */
|
|
482
518
|
export function sanitizeQueryJobActivity(value: unknown): { text: string; truncated: boolean } {
|
|
@@ -149,6 +149,18 @@ function turnOpenedByTool(prev: SessionSignal, p: Record<string, unknown>, at: n
|
|
|
149
149
|
/** Waiting kinds a PostToolUse of the same tool resolves without a fingerprint match. */
|
|
150
150
|
const TOOL_WAITING: Record<string, WaitingKind> = { AskUserQuestion: 'question', ExitPlanMode: 'plan' }
|
|
151
151
|
|
|
152
|
+
/** The tool whose PermissionRequest is a question card, not an approval (6.52.0). */
|
|
153
|
+
export const ASK_USER_QUESTION_TOOL = 'AskUserQuestion'
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* 6.52.0: the permission broker's id survives a re-announcement of the SAME request. The
|
|
157
|
+
* async PreToolUse can land after the PermissionRequest (and the spool after the broker
|
|
158
|
+
* parked it), and either rewrite of `waiting` would otherwise drop the id the rows carry.
|
|
159
|
+
*/
|
|
160
|
+
function keptRequestId(prev: WaitingSignal | null, fingerprint: string): string | null {
|
|
161
|
+
return prev?.requestId && fingerprint && prev.fingerprint === fingerprint ? prev.requestId : null
|
|
162
|
+
}
|
|
163
|
+
|
|
152
164
|
function resolvesWaiting(waiting: WaitingSignal, event: HookEventName, toolName: string, fingerprint: string): boolean {
|
|
153
165
|
if (waiting.fingerprint && waiting.fingerprint === fingerprint) return true
|
|
154
166
|
// Question/plan tools carry no meaningful input to fingerprint; the tool name is the key.
|
|
@@ -210,7 +222,7 @@ export function applyHookEvent(prev: SessionSignal | undefined, env: HookEnvelop
|
|
|
210
222
|
return {
|
|
211
223
|
...next,
|
|
212
224
|
turnOpen: true,
|
|
213
|
-
waiting: { kind, detail: tool.target, toolName: tool.name, fingerprint: tool.fingerprint, since: env.ts, requestId:
|
|
225
|
+
waiting: { kind, detail: tool.target, toolName: tool.name, fingerprint: tool.fingerprint, since: env.ts, requestId: keptRequestId(next.waiting, tool.fingerprint) },
|
|
214
226
|
}
|
|
215
227
|
}
|
|
216
228
|
return { ...next, ...turnOpenedByTool(next, p, env.ts), lastTool: tool.name || next.lastTool, lastToolAt: env.ts }
|
|
@@ -221,12 +233,15 @@ export function applyHookEvent(prev: SessionSignal | undefined, env: HookEnvelop
|
|
|
221
233
|
...next,
|
|
222
234
|
turnOpen: true,
|
|
223
235
|
waiting: {
|
|
224
|
-
|
|
236
|
+
// 6.52.0: an AskUserQuestion stays a QUESTION through its PermissionRequest. As
|
|
237
|
+
// `permission` it cleared only on a fingerprint match, and the answered tool's
|
|
238
|
+
// PostToolUse carries `answers` in its input, so the row could stay waiting.
|
|
239
|
+
kind: tool.name === ASK_USER_QUESTION_TOOL ? 'question' : 'permission',
|
|
225
240
|
detail: tool.target ? `${tool.name} ${tool.target}` : tool.name,
|
|
226
241
|
toolName: tool.name,
|
|
227
242
|
fingerprint: tool.fingerprint,
|
|
228
243
|
since: env.ts,
|
|
229
|
-
requestId:
|
|
244
|
+
requestId: keptRequestId(next.waiting, tool.fingerprint),
|
|
230
245
|
},
|
|
231
246
|
}
|
|
232
247
|
}
|
|
@@ -314,19 +329,36 @@ export class SessionSignalStore {
|
|
|
314
329
|
this.signals.set(sessionId, { ...current, entrypoint })
|
|
315
330
|
}
|
|
316
331
|
|
|
317
|
-
/**
|
|
318
|
-
*
|
|
319
|
-
|
|
332
|
+
/**
|
|
333
|
+
* The permission broker (6.52.0) attaches its item id so rows can carry
|
|
334
|
+
* `pending_permission_id` (an approval) or `pending_question_id` (an AskUserQuestion).
|
|
335
|
+
* Widened to `question` in 6.52.0. With a fingerprint, only the wait for THAT request
|
|
336
|
+
* takes the id: a different dialog now standing must never advertise it. True when set.
|
|
337
|
+
*/
|
|
338
|
+
attachPermissionRequestId(sessionId: string, requestId: string | null, fingerprint?: string): boolean {
|
|
320
339
|
const current = this.signals.get(sessionId)
|
|
321
|
-
if (!current?.waiting || current.waiting.kind !== 'permission') return
|
|
340
|
+
if (!current?.waiting || (current.waiting.kind !== 'permission' && current.waiting.kind !== 'question')) return false
|
|
341
|
+
if (fingerprint !== undefined && current.waiting.fingerprint !== fingerprint) return false
|
|
322
342
|
this.signals.set(sessionId, { ...current, waiting: { ...current.waiting, requestId } })
|
|
343
|
+
return true
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** The broker let go of a request without deciding it: drop its id, keep the wait. */
|
|
347
|
+
detachPermissionRequestId(sessionId: string, requestId: string): void {
|
|
348
|
+
const current = this.signals.get(sessionId)
|
|
349
|
+
if (!current?.waiting || current.waiting.requestId !== requestId) return
|
|
350
|
+
this.signals.set(sessionId, { ...current, waiting: { ...current.waiting, requestId: null } })
|
|
323
351
|
}
|
|
324
352
|
|
|
325
|
-
/**
|
|
326
|
-
*
|
|
327
|
-
|
|
353
|
+
/**
|
|
354
|
+
* The broker decided (allow or deny): that is resolution evidence. With `match` (6.52.0),
|
|
355
|
+
* only the wait that carries the broker's id, or that is the same request by fingerprint,
|
|
356
|
+
* is cleared; a different dialog standing now is left alone.
|
|
357
|
+
*/
|
|
358
|
+
resolveWaiting(sessionId: string, match?: { requestId: string; fingerprint: string }): void {
|
|
328
359
|
const current = this.signals.get(sessionId)
|
|
329
360
|
if (!current?.waiting) return
|
|
361
|
+
if (match && current.waiting.requestId !== match.requestId && current.waiting.fingerprint !== match.fingerprint) return
|
|
330
362
|
this.signals.set(sessionId, { ...current, waiting: null })
|
|
331
363
|
}
|
|
332
364
|
|
|
@@ -36,7 +36,10 @@ export interface DerivedSessionState {
|
|
|
36
36
|
waiting_detail?: string
|
|
37
37
|
failure?: string
|
|
38
38
|
last_reply?: string
|
|
39
|
+
/** 6.52.0: the permission broker holds this approval; `GET /api/session-questions` has it. */
|
|
39
40
|
pending_permission_id?: string
|
|
41
|
+
/** 6.52.0: the permission broker holds this AskUserQuestion; `GET /api/session-questions` has it. */
|
|
42
|
+
pending_question_id?: string
|
|
40
43
|
/** Consecutive derives that saw a dead registry pid; carried by the caller between polls. */
|
|
41
44
|
deadScans: number
|
|
42
45
|
/** When the pid was first seen dead, so two observations must also be DEAD_GRACE_MS apart. */
|
|
@@ -160,14 +163,20 @@ function deriveFromSignal(signal: SessionSignal, registry: RegistryFacts | undef
|
|
|
160
163
|
if (signal.ended && !(registry?.alive)) {
|
|
161
164
|
return { agent_state: 'ended', state_source: 'hook', state_since: iso(signal.ended.at), ...reply }
|
|
162
165
|
}
|
|
163
|
-
|
|
166
|
+
// 6.52.0: a wait the permission broker still holds stands whatever the registry or the
|
|
167
|
+
// transcript say. The broker is positive evidence the hook is blocked on it right now,
|
|
168
|
+
// and it drops the id on every way the request ends (answer, desk, deadline, hook gone),
|
|
169
|
+
// all bounded by its deadline.
|
|
170
|
+
if (signal.waiting && (signal.waiting.requestId || waitStillStands(signal.waiting.since, registry, transcript, now))) {
|
|
171
|
+
const requestId = signal.waiting.requestId
|
|
164
172
|
return {
|
|
165
173
|
agent_state: 'waiting',
|
|
166
174
|
state_source: 'hook',
|
|
167
175
|
state_since: iso(signal.waiting.since),
|
|
168
176
|
waiting_kind: signal.waiting.kind,
|
|
169
177
|
waiting_detail: signal.waiting.detail,
|
|
170
|
-
...(signal.waiting.
|
|
178
|
+
...(requestId && signal.waiting.kind === 'question' ? { pending_question_id: requestId } : {}),
|
|
179
|
+
...(requestId && signal.waiting.kind !== 'question' ? { pending_permission_id: requestId } : {}),
|
|
171
180
|
...reply,
|
|
172
181
|
}
|
|
173
182
|
}
|
|
@@ -208,8 +217,8 @@ function waitStillStands(since: number, registry: RegistryFacts | undefined, tra
|
|
|
208
217
|
}
|
|
209
218
|
|
|
210
219
|
/**
|
|
211
|
-
* The fields a `status` draft carries (6.48.1): the row fields minus the
|
|
212
|
-
* (
|
|
220
|
+
* The fields a `status` draft carries (6.48.1): the row fields minus the broker ids
|
|
221
|
+
* (`pending_permission_id`, `pending_question_id`: never on the stream) and with `last_reply` only on an idle state, where a
|
|
213
222
|
* feed renders "Idle, last reply: …". A typed projection, so a new row field cannot reach
|
|
214
223
|
* the wire by accident.
|
|
215
224
|
*/
|
|
@@ -245,5 +254,6 @@ export function derivedRowFields(derived: DerivedSessionState | undefined): Reco
|
|
|
245
254
|
...(derived.failure ? { failure: derived.failure } : {}),
|
|
246
255
|
...(derived.last_reply ? { last_reply: derived.last_reply } : {}),
|
|
247
256
|
...(derived.pending_permission_id ? { pending_permission_id: derived.pending_permission_id } : {}),
|
|
257
|
+
...(derived.pending_question_id ? { pending_question_id: derived.pending_question_id } : {}),
|
|
248
258
|
}
|
|
249
259
|
}
|