@ours.network/fleet 1.2.0-nightly.63 → 1.2.0-nightly.64
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/README.md +3 -0
- package/dist/application/conversation-history.js +4 -1
- package/dist/application/conversation-role-observer.d.ts +62 -0
- package/dist/application/conversation-role-observer.js +206 -0
- package/dist/application/session-control.d.ts +31 -17
- package/dist/application/session-control.js +148 -33
- package/dist/build-info.json +4 -4
- package/dist/session/acp.d.ts +8 -1
- package/dist/session/acp.js +97 -43
- package/dist/session/arbiter.d.ts +4 -0
- package/dist/session/arbiter.js +18 -0
- package/dist/session/codex-app-server.d.ts +7 -1
- package/dist/session/codex-app-server.js +60 -9
- package/dist/session/control.d.ts +5 -2
- package/dist/session/control.js +145 -20
- package/dist/session/conversation-observation.d.ts +25 -0
- package/dist/session/conversation-observation.js +100 -0
- package/dist/session/conversation-replay.d.ts +50 -0
- package/dist/session/conversation-replay.js +203 -0
- package/dist/session/conversation-store.d.ts +14 -0
- package/dist/session/conversation-store.js +201 -17
- package/dist/session/conversation-types.d.ts +29 -1
- package/dist/session/types.d.ts +6 -0
- package/dist/web/auth.d.ts +2 -0
- package/dist/web/auth.js +10 -1
- package/dist/web/conversation-stream-codec.d.ts +23 -0
- package/dist/web/conversation-stream-codec.js +110 -0
- package/dist/web/conversation-stream.d.ts +25 -0
- package/dist/web/conversation-stream.js +231 -0
- package/dist/web/runtime.js +12 -2
- package/dist/web/server.d.ts +2 -0
- package/dist/web/server.js +2 -0
- package/docs/conversation-stream.md +176 -0
- package/package.json +2 -1
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync, writeSync, } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
+
import { setImmediate as yieldTurn } from 'node:timers/promises';
|
|
5
|
+
import { replayLines, ReplayCoverageError } from './conversation-replay.js';
|
|
4
6
|
/**
|
|
5
7
|
* Durable, append-only, single-writer conversation ledger for one role
|
|
6
8
|
* The per-role runner is the only writer; readers page by seq.
|
|
@@ -24,6 +26,13 @@ export class IdempotencyConflictError extends Error {
|
|
|
24
26
|
export class ConversationEventStore {
|
|
25
27
|
dir;
|
|
26
28
|
nextSeq = 1;
|
|
29
|
+
lastDurableSeq = 0;
|
|
30
|
+
segmentRanges = new Map();
|
|
31
|
+
generationFloors = new Map();
|
|
32
|
+
failedGenerations = new Set();
|
|
33
|
+
failureListeners = new Set();
|
|
34
|
+
unavailable = false;
|
|
35
|
+
rotateBeforeAppend = false;
|
|
27
36
|
firstAvailableSeq;
|
|
28
37
|
segments = [];
|
|
29
38
|
tail = [];
|
|
@@ -47,6 +56,7 @@ export class ConversationEventStore {
|
|
|
47
56
|
this.recover();
|
|
48
57
|
}
|
|
49
58
|
catch (error) {
|
|
59
|
+
this.unavailable = true;
|
|
50
60
|
this.markDegraded(`store unavailable: ${error.message}`);
|
|
51
61
|
}
|
|
52
62
|
}
|
|
@@ -54,6 +64,10 @@ export class ConversationEventStore {
|
|
|
54
64
|
static bodyDigest(body) {
|
|
55
65
|
return createHash('sha256').update(body).digest('hex').slice(0, 24);
|
|
56
66
|
}
|
|
67
|
+
/** Current delivery eligibility only; requested historical ranges still need replay verification. */
|
|
68
|
+
generationDurable(generation) {
|
|
69
|
+
return Boolean(generation) && !this.unavailable && !this.failedGenerations.has(generation);
|
|
70
|
+
}
|
|
57
71
|
get degraded() { return this._degraded; }
|
|
58
72
|
get degradedDetail() { return this.degradedReason; }
|
|
59
73
|
/**
|
|
@@ -69,12 +83,47 @@ export class ConversationEventStore {
|
|
|
69
83
|
at: new Date().toISOString(),
|
|
70
84
|
...draft,
|
|
71
85
|
};
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
86
|
+
let line;
|
|
87
|
+
try {
|
|
88
|
+
line = JSON.stringify(event) + '\n';
|
|
89
|
+
const fd = this.segmentFd(Buffer.byteLength(line));
|
|
90
|
+
// From this point writes may have reached disk even if write/fsync throws.
|
|
91
|
+
// Never let a later generation reuse that identity; gaps are safer than collisions.
|
|
92
|
+
this.nextSeq++;
|
|
93
|
+
const bytes = Buffer.from(line);
|
|
94
|
+
let offset = 0;
|
|
95
|
+
while (offset < bytes.length) {
|
|
96
|
+
const written = writeSync(fd, bytes, offset, bytes.length - offset);
|
|
97
|
+
if (!written)
|
|
98
|
+
throw new Error('short ledger write');
|
|
99
|
+
offset += written;
|
|
100
|
+
}
|
|
101
|
+
fsyncSync(fd);
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
const firstFailure = !this.failedGenerations.has(draft.sessionGeneration);
|
|
105
|
+
this.failedGenerations.add(draft.sessionGeneration);
|
|
106
|
+
this.rotateBeforeAppend = true;
|
|
107
|
+
this.writeManifest();
|
|
108
|
+
this.markDegraded(`event append failed: ${error.message}`);
|
|
109
|
+
if (firstFailure)
|
|
110
|
+
for (const listener of this.failureListeners) {
|
|
111
|
+
try {
|
|
112
|
+
listener(draft.sessionGeneration);
|
|
113
|
+
}
|
|
114
|
+
catch { /* observers cannot interrupt owner failure handling */ }
|
|
115
|
+
}
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
if (!this.generationFloors.has(event.sessionGeneration))
|
|
119
|
+
this.generationFloors.set(event.sessionGeneration, event.seq - 1);
|
|
120
|
+
const segment = this.segments.at(-1);
|
|
121
|
+
const range = this.segmentRanges.get(segment) ?? { damaged: false };
|
|
122
|
+
range.first ??= event.seq;
|
|
123
|
+
range.last = event.seq;
|
|
124
|
+
this.segmentRanges.set(segment, range);
|
|
76
125
|
this.firstAvailableSeq ??= event.seq;
|
|
77
|
-
this.
|
|
126
|
+
this.lastDurableSeq = event.seq;
|
|
78
127
|
this.activeBytes += Buffer.byteLength(line);
|
|
79
128
|
this.tail.push(event);
|
|
80
129
|
if (this.tail.length > TAIL_EVENTS)
|
|
@@ -118,6 +167,11 @@ export class ConversationEventStore {
|
|
|
118
167
|
this.listeners.add(listener);
|
|
119
168
|
return () => this.listeners.delete(listener);
|
|
120
169
|
}
|
|
170
|
+
/** A current-generation failed append invalidates even a follow already past ready. */
|
|
171
|
+
subscribeFailure(listener) {
|
|
172
|
+
this.failureListeners.add(listener);
|
|
173
|
+
return () => this.failureListeners.delete(listener);
|
|
174
|
+
}
|
|
121
175
|
/** Store the receipt a repeated command must get back. */
|
|
122
176
|
recordReceipt(commandId, receipt, bodyDigest) {
|
|
123
177
|
this.commands.set(commandId, { receipt, bodyDigest });
|
|
@@ -143,7 +197,7 @@ export class ConversationEventStore {
|
|
|
143
197
|
return [...this.promptStates.values()];
|
|
144
198
|
}
|
|
145
199
|
lastCursor() {
|
|
146
|
-
return this.
|
|
200
|
+
return this.lastDurableSeq > 0 ? String(this.lastDurableSeq) : undefined;
|
|
147
201
|
}
|
|
148
202
|
close() {
|
|
149
203
|
if (this.activeFd !== undefined) {
|
|
@@ -154,17 +208,27 @@ export class ConversationEventStore {
|
|
|
154
208
|
this.activeFd = undefined;
|
|
155
209
|
}
|
|
156
210
|
this.listeners.clear();
|
|
211
|
+
this.failureListeners.clear();
|
|
157
212
|
}
|
|
158
213
|
// ── recovery ───────────────────────────────────────────────────────────────
|
|
159
214
|
recover() {
|
|
160
215
|
const manifest = this.readManifest();
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
216
|
+
for (const generation of manifest?.failedGenerations ?? [])
|
|
217
|
+
this.failedGenerations.add(generation);
|
|
218
|
+
// Preserve missing manifest entries: filtering them would erase proof of a gap on reopen.
|
|
219
|
+
this.segments = [...new Set([...(manifest?.segments ?? []), ...this.discoverSegments()])].sort();
|
|
164
220
|
let maxSeq = 0;
|
|
165
221
|
for (const segment of this.segments) {
|
|
166
222
|
for (const event of this.readSegment(segment)) {
|
|
167
223
|
this.firstAvailableSeq ??= event.seq;
|
|
224
|
+
if (event.seq <= maxSeq) {
|
|
225
|
+
// Pre-fix or corrupt ledgers may already contain colliding identities.
|
|
226
|
+
// A cached generation floor must never certify the later duplicate.
|
|
227
|
+
this.failedGenerations.add(event.sessionGeneration);
|
|
228
|
+
this.markDegraded(`segment ${segment} has non-monotonic sequence ${event.seq}`);
|
|
229
|
+
}
|
|
230
|
+
if (!this.generationFloors.has(event.sessionGeneration))
|
|
231
|
+
this.generationFloors.set(event.sessionGeneration, maxSeq);
|
|
168
232
|
maxSeq = Math.max(maxSeq, event.seq);
|
|
169
233
|
this.tail.push(event);
|
|
170
234
|
if (this.tail.length > TAIL_EVENTS)
|
|
@@ -173,10 +237,12 @@ export class ConversationEventStore {
|
|
|
173
237
|
this.rebuildCommandIndex(event);
|
|
174
238
|
}
|
|
175
239
|
}
|
|
176
|
-
this.nextSeq = maxSeq + 1;
|
|
240
|
+
this.nextSeq = Math.max(maxSeq + 1, manifest?.nextSeq ?? 1);
|
|
241
|
+
this.lastDurableSeq = maxSeq;
|
|
177
242
|
if (this.segments.length) {
|
|
178
243
|
const active = join(this.dir, this.segments.at(-1));
|
|
179
244
|
this.activeBytes = existsSync(active) ? statSync(active).size : 0;
|
|
245
|
+
this.rotateBeforeAppend = this.segmentRanges.get(this.segments.at(-1))?.damaged ?? true;
|
|
180
246
|
}
|
|
181
247
|
this.writeManifest();
|
|
182
248
|
}
|
|
@@ -201,25 +267,36 @@ export class ConversationEventStore {
|
|
|
201
267
|
}
|
|
202
268
|
readSegment(name) {
|
|
203
269
|
const events = [];
|
|
270
|
+
const range = { damaged: false };
|
|
271
|
+
this.segmentRanges.set(name, range);
|
|
204
272
|
let raw;
|
|
205
273
|
try {
|
|
206
274
|
raw = readFileSync(join(this.dir, name), 'utf8');
|
|
207
275
|
}
|
|
208
276
|
catch (error) {
|
|
277
|
+
range.damaged = true;
|
|
209
278
|
this.markDegraded(`segment ${name} unreadable: ${error.message}`);
|
|
210
279
|
return events;
|
|
211
280
|
}
|
|
281
|
+
if (raw.length && !raw.endsWith('\n'))
|
|
282
|
+
range.damaged = true;
|
|
212
283
|
for (const line of raw.split('\n')) {
|
|
213
284
|
if (!line.trim())
|
|
214
285
|
continue;
|
|
215
286
|
try {
|
|
216
287
|
const event = JSON.parse(line);
|
|
217
|
-
if (event.schemaVersion === 1 &&
|
|
288
|
+
if (event.schemaVersion === 1 && Number.isSafeInteger(event.seq) && event.seq > 0) {
|
|
218
289
|
events.push(event);
|
|
219
|
-
|
|
290
|
+
range.first ??= event.seq;
|
|
291
|
+
range.last = event.seq;
|
|
292
|
+
}
|
|
293
|
+
else {
|
|
294
|
+
range.damaged = true;
|
|
220
295
|
this.markDegraded(`segment ${name} holds a record of an unknown schema`);
|
|
296
|
+
}
|
|
221
297
|
}
|
|
222
298
|
catch {
|
|
299
|
+
range.damaged = true;
|
|
223
300
|
// A torn final line after a crash: everything before it stays readable.
|
|
224
301
|
this.markDegraded(`segment ${name} ends in a torn or corrupt line`);
|
|
225
302
|
}
|
|
@@ -237,6 +314,8 @@ export class ConversationEventStore {
|
|
|
237
314
|
bodyDigest,
|
|
238
315
|
receipt: {
|
|
239
316
|
commandId: event.commandId,
|
|
317
|
+
...(typeof event.sessionGeneration === 'string' && event.sessionGeneration
|
|
318
|
+
? { sessionGeneration: event.sessionGeneration } : {}),
|
|
240
319
|
promptId: event.promptId,
|
|
241
320
|
state: 'queued',
|
|
242
321
|
queuedBehind: payload.queuedBehind ?? 0,
|
|
@@ -275,7 +354,7 @@ export class ConversationEventStore {
|
|
|
275
354
|
// ── segment management ─────────────────────────────────────────────────────
|
|
276
355
|
segmentFd(incomingBytes) {
|
|
277
356
|
if (this.activeFd !== undefined
|
|
278
|
-
&& this.activeBytes + incomingBytes > this.segmentBytes) {
|
|
357
|
+
&& (this.rotateBeforeAppend || this.activeBytes + incomingBytes > this.segmentBytes)) {
|
|
279
358
|
try {
|
|
280
359
|
closeSync(this.activeFd);
|
|
281
360
|
}
|
|
@@ -283,11 +362,12 @@ export class ConversationEventStore {
|
|
|
283
362
|
this.activeFd = undefined;
|
|
284
363
|
}
|
|
285
364
|
if (this.activeFd === undefined) {
|
|
286
|
-
const needsNew = !this.segments.length
|
|
365
|
+
const needsNew = this.rotateBeforeAppend || !this.segments.length
|
|
287
366
|
|| this.activeBytes + incomingBytes > this.segmentBytes;
|
|
288
367
|
if (needsNew) {
|
|
289
|
-
const name = `events-${String(this.segments.
|
|
368
|
+
const name = `events-${String(Number(this.segments.at(-1)?.match(/\d+/)?.[0] ?? 0) + 1).padStart(6, '0')}.jsonl`;
|
|
290
369
|
this.segments.push(name);
|
|
370
|
+
this.rotateBeforeAppend = false;
|
|
291
371
|
this.activeBytes = 0;
|
|
292
372
|
this.writeManifest();
|
|
293
373
|
}
|
|
@@ -301,6 +381,7 @@ export class ConversationEventStore {
|
|
|
301
381
|
writeManifest() {
|
|
302
382
|
const manifest = {
|
|
303
383
|
schemaVersion: 1, nextSeq: this.nextSeq, segments: this.segments,
|
|
384
|
+
...(this.failedGenerations.size ? { failedGenerations: [...this.failedGenerations] } : {}),
|
|
304
385
|
};
|
|
305
386
|
const tmp = join(this.dir, MANIFEST + '.tmp');
|
|
306
387
|
try {
|
|
@@ -308,12 +389,115 @@ export class ConversationEventStore {
|
|
|
308
389
|
renameSync(tmp, join(this.dir, MANIFEST));
|
|
309
390
|
}
|
|
310
391
|
catch (error) {
|
|
311
|
-
//
|
|
312
|
-
//
|
|
392
|
+
// Segments reconstruct durable records, but cannot reconstruct reserved
|
|
393
|
+
// identities or failed-generation latches retained only in the manifest.
|
|
313
394
|
this.log(`conversation manifest write failed: ${error.message}`);
|
|
314
395
|
}
|
|
315
396
|
}
|
|
316
397
|
// ── reads ──────────────────────────────────────────────────────────────────
|
|
398
|
+
/** One forward iterator owns the scan; each segment is opened at most once. */
|
|
399
|
+
async *replay(request) {
|
|
400
|
+
const { expectedGeneration, after, through, signal } = request;
|
|
401
|
+
const available = Number(this.lastCursor() ?? 0);
|
|
402
|
+
if (!expectedGeneration || !Number.isSafeInteger(after) || after < 0 ||
|
|
403
|
+
!Number.isSafeInteger(through) || through < after || through > available)
|
|
404
|
+
throw new ReplayCoverageError('invalid cursor or barrier');
|
|
405
|
+
const check = () => {
|
|
406
|
+
signal?.throwIfAborted();
|
|
407
|
+
if (this.unavailable || this.failedGenerations.has(expectedGeneration))
|
|
408
|
+
throw new ReplayCoverageError('generation has non-durable events');
|
|
409
|
+
};
|
|
410
|
+
check();
|
|
411
|
+
const floor = this.generationFloors.get(expectedGeneration);
|
|
412
|
+
if (floor === undefined && through > after)
|
|
413
|
+
throw new ReplayCoverageError('generation is not retained');
|
|
414
|
+
let coveredThrough = Math.max(after, Math.min(floor ?? after, through));
|
|
415
|
+
let events = [];
|
|
416
|
+
let count = 0;
|
|
417
|
+
let bytes = 0;
|
|
418
|
+
const pageSize = Math.min(1000, Math.max(1, request.pageSize ?? 128));
|
|
419
|
+
const segments = this.segments.map(name => ({ name, ...this.segmentRanges.get(name) }));
|
|
420
|
+
const nextFirst = new Array(segments.length);
|
|
421
|
+
let following;
|
|
422
|
+
for (let i = segments.length - 1; i >= 0; i--) {
|
|
423
|
+
nextFirst[i] = following;
|
|
424
|
+
following = segments[i].first ?? following;
|
|
425
|
+
}
|
|
426
|
+
let corruptPrefix = false;
|
|
427
|
+
for (let index = 0; index < segments.length && coveredThrough < through; index++) {
|
|
428
|
+
check();
|
|
429
|
+
const segment = segments[index];
|
|
430
|
+
const followingFirst = nextFirst[index];
|
|
431
|
+
// A verified later old record localizes earlier damage outside the requested range.
|
|
432
|
+
if ((segment.last !== undefined && segment.last <= coveredThrough && !segment.damaged) ||
|
|
433
|
+
(followingFirst !== undefined && followingFirst <= coveredThrough))
|
|
434
|
+
continue;
|
|
435
|
+
try {
|
|
436
|
+
for await (const line of replayLines(join(this.dir, segment.name), signal)) {
|
|
437
|
+
check();
|
|
438
|
+
if (!line.trim())
|
|
439
|
+
continue;
|
|
440
|
+
let event;
|
|
441
|
+
try {
|
|
442
|
+
event = JSON.parse(line);
|
|
443
|
+
}
|
|
444
|
+
catch {
|
|
445
|
+
corruptPrefix = true;
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
if (event.schemaVersion !== 1 || !Number.isSafeInteger(event.seq) || event.seq < 1) {
|
|
449
|
+
corruptPrefix = true;
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
if (event.seq <= Math.max(after, floor ?? after)) {
|
|
453
|
+
corruptPrefix = false;
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
if (corruptPrefix)
|
|
457
|
+
throw new ReplayCoverageError('corrupt record in requested range');
|
|
458
|
+
if (event.seq <= coveredThrough)
|
|
459
|
+
throw new ReplayCoverageError('non-monotonic sequence');
|
|
460
|
+
if (event.seq !== coveredThrough + 1)
|
|
461
|
+
throw new ReplayCoverageError('missing sequence');
|
|
462
|
+
const visible = event.sessionGeneration === expectedGeneration && event.source !== 'agent_replay';
|
|
463
|
+
const eventBytes = visible ? Buffer.byteLength(line) : 0;
|
|
464
|
+
if (count && bytes + eventBytes > 256 * 1024) {
|
|
465
|
+
yield { events, coveredThrough };
|
|
466
|
+
events = [];
|
|
467
|
+
count = 0;
|
|
468
|
+
bytes = 0;
|
|
469
|
+
await yieldTurn();
|
|
470
|
+
check();
|
|
471
|
+
}
|
|
472
|
+
coveredThrough = event.seq;
|
|
473
|
+
count++;
|
|
474
|
+
if (visible) {
|
|
475
|
+
events.push(event);
|
|
476
|
+
bytes += eventBytes;
|
|
477
|
+
}
|
|
478
|
+
if (count >= pageSize || bytes >= 256 * 1024 || coveredThrough === through) {
|
|
479
|
+
yield { events, coveredThrough };
|
|
480
|
+
events = [];
|
|
481
|
+
count = 0;
|
|
482
|
+
bytes = 0;
|
|
483
|
+
await yieldTurn();
|
|
484
|
+
}
|
|
485
|
+
if (coveredThrough === through)
|
|
486
|
+
break;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
catch (error) {
|
|
490
|
+
if (signal?.aborted)
|
|
491
|
+
throw error;
|
|
492
|
+
throw new ReplayCoverageError(error.message);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
check();
|
|
496
|
+
if (coveredThrough !== through)
|
|
497
|
+
throw new ReplayCoverageError('barrier is not retained');
|
|
498
|
+
if (count || (through > after && floor !== undefined && floor >= through))
|
|
499
|
+
yield { events, coveredThrough };
|
|
500
|
+
}
|
|
317
501
|
eventsAfter(after, limit) {
|
|
318
502
|
// Serve from the in-memory tail whenever the range allows it.
|
|
319
503
|
if (this.tail.length && after >= this.tail[0].seq - 1)
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { TaskCreatedNotice } from './task-notice.js';
|
|
2
|
-
import type { PromptOrigin, TurnCancellationSource, TurnOutcome } from './types.js';
|
|
2
|
+
import type { PromptOrigin, SessionSnapshot, TurnCancellationSource, TurnOutcome } from './types.js';
|
|
3
3
|
/**
|
|
4
4
|
* Durable conversation domain schema for managed-session consoles.
|
|
5
5
|
*
|
|
@@ -282,6 +282,8 @@ export interface SubmitPromptCommand {
|
|
|
282
282
|
}
|
|
283
283
|
export interface PromptReceipt {
|
|
284
284
|
commandId: string;
|
|
285
|
+
/** Original durable admission generation; absent when historical provenance is unknown. */
|
|
286
|
+
sessionGeneration?: string;
|
|
285
287
|
promptId: string;
|
|
286
288
|
state: 'queued' | 'starting';
|
|
287
289
|
queuedBehind: number;
|
|
@@ -311,10 +313,36 @@ export interface ConversationSnapshot {
|
|
|
311
313
|
queueDepth: number;
|
|
312
314
|
pendingPermissionIds: string[];
|
|
313
315
|
historyDegraded?: boolean;
|
|
316
|
+
/** Live observation is unavailable; REST may still serve canonical durable data. */
|
|
317
|
+
observationFailure?: 'capture_failed';
|
|
314
318
|
/** Every waiting prompt, in delivery order; `pendingPromptCount` equals its length. */
|
|
315
319
|
pendingPrompts?: PendingPromptSummary[];
|
|
316
320
|
pendingPromptCount?: number;
|
|
317
321
|
}
|
|
322
|
+
/** Position captured synchronously with the owning backend's complete live state. */
|
|
323
|
+
export interface ObservationPosition {
|
|
324
|
+
sessionGeneration: string;
|
|
325
|
+
stateRevision: number;
|
|
326
|
+
ledgerBarrier: number;
|
|
327
|
+
}
|
|
328
|
+
export interface ConversationObservationState extends ConversationSnapshot, ObservationPosition {
|
|
329
|
+
alive: boolean;
|
|
330
|
+
activePromptIds: string[];
|
|
331
|
+
pendingPrompts: PendingPromptSummary[];
|
|
332
|
+
pendingPromptCount: number;
|
|
333
|
+
/** Already presented and sanitized by the backend; never reconstructed from raw options. */
|
|
334
|
+
pendingPermissions: Array<PermissionRequestedPayload & {
|
|
335
|
+
permissionId: string;
|
|
336
|
+
}>;
|
|
337
|
+
commands: NonNullable<CapabilitiesUpdatedPayload['commands']>;
|
|
338
|
+
runtimeModel?: SessionSnapshot['runtimeModel'];
|
|
339
|
+
reasoningEffort?: SessionSnapshot['reasoningEffort'];
|
|
340
|
+
permissionMode?: SessionSnapshot['permissionMode'];
|
|
341
|
+
}
|
|
342
|
+
export interface ConversationObservation {
|
|
343
|
+
snapshot: ConversationObservationState;
|
|
344
|
+
unsubscribe(): void;
|
|
345
|
+
}
|
|
318
346
|
export interface ConversationPage {
|
|
319
347
|
events: ConversationEventV1[];
|
|
320
348
|
firstAvailableCursor?: string;
|
package/dist/session/types.d.ts
CHANGED
|
@@ -300,6 +300,12 @@ export interface AgentSession {
|
|
|
300
300
|
limit?: number;
|
|
301
301
|
}): ConversationHandlePage;
|
|
302
302
|
conversationSnapshot?(): ConversationSnapshot;
|
|
303
|
+
/** Side-effect-free current-generation durability eligibility; does not certify a historical range. */
|
|
304
|
+
conversationReplayAvailable?(expectedGeneration: string): boolean;
|
|
305
|
+
replayConversation?(request: import('./conversation-replay.js').ConversationReplayRequest): AsyncIterable<import('./conversation-replay.js').ConversationReplayPage>;
|
|
306
|
+
/** Passive: registering never attaches a controller or acknowledges activity. */
|
|
307
|
+
observeConversation?(listener: (state: import('./conversation-types.js').ConversationObservationState) => void): import('./conversation-types.js').ConversationObservation;
|
|
308
|
+
subscribeConversationFailure?(listener: (generation: string) => void): () => void;
|
|
303
309
|
subscribeConversation?(listener: (event: ConversationEventV1) => void): () => void;
|
|
304
310
|
/** Durably admit an idempotent browser prompt; resolves on admission. */
|
|
305
311
|
submitPromptBrowser?(command: SubmitPromptCommand): Promise<PromptReceipt>;
|
package/dist/web/auth.d.ts
CHANGED
|
@@ -65,6 +65,8 @@ export declare class WebAuth {
|
|
|
65
65
|
anonymous(request: FastifyRequest): BrowserSession;
|
|
66
66
|
resume(request: FastifyRequest): AuthResult;
|
|
67
67
|
authenticate(request: FastifyRequest, mutation?: boolean): BrowserSession;
|
|
68
|
+
/** Check a transport's opening lease without refreshing cookie idle activity. */
|
|
69
|
+
sessionValid(session: Pick<BrowserSession, 'id' | 'absoluteExpiresAt'>): boolean;
|
|
68
70
|
logout(request: FastifyRequest): void;
|
|
69
71
|
mintTicket(request: FastifyRequest, purpose: Ticket['purpose'], roleId?: string): {
|
|
70
72
|
ticket: string;
|
package/dist/web/auth.js
CHANGED
|
@@ -136,7 +136,7 @@ export class WebAuth {
|
|
|
136
136
|
const id = parseCookies(request.headers.cookie ?? '').ofs_session;
|
|
137
137
|
const session = id ? this.sessions.get(id) : undefined;
|
|
138
138
|
const now = this.now();
|
|
139
|
-
if (!session ||
|
|
139
|
+
if (!session || !this.sessionValid(session)) {
|
|
140
140
|
if (id)
|
|
141
141
|
this.removeSession(id);
|
|
142
142
|
throw new FleetError('unauthorized', 'browser session is missing or expired');
|
|
@@ -150,6 +150,15 @@ export class WebAuth {
|
|
|
150
150
|
session.lastSeenAt = now;
|
|
151
151
|
return session;
|
|
152
152
|
}
|
|
153
|
+
/** Check a transport's opening lease without refreshing cookie idle activity. */
|
|
154
|
+
sessionValid(session) {
|
|
155
|
+
const current = this.sessions.get(session.id), now = this.now();
|
|
156
|
+
if (!current || now >= session.absoluteExpiresAt)
|
|
157
|
+
return false;
|
|
158
|
+
if (session.id.startsWith('workspace:'))
|
|
159
|
+
return Boolean(this.workspaceDevices?.valid(session.id.slice(10)));
|
|
160
|
+
return now < current.absoluteExpiresAt && now - current.lastSeenAt < 30 * 60_000;
|
|
161
|
+
}
|
|
153
162
|
logout(request) {
|
|
154
163
|
const session = this.authenticate(request, true);
|
|
155
164
|
const deviceId = this.sessionDevices.get(session.id);
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** Public v1 wire contract. Kept free of Node APIs so browser consumers can share it. */
|
|
2
|
+
export declare const MAX_FRAME_BYTES: number;
|
|
3
|
+
export declare const MAX_ASSEMBLY_BYTES: number;
|
|
4
|
+
export declare const MAX_PENDING_BYTES: number;
|
|
5
|
+
export type ConversationRecordType = 'ready' | 'ledger' | 'state' | 'role' | 'resync' | 'keepalive';
|
|
6
|
+
export interface ConversationStreamRecord {
|
|
7
|
+
version: 1;
|
|
8
|
+
type: ConversationRecordType;
|
|
9
|
+
payload: unknown;
|
|
10
|
+
}
|
|
11
|
+
/** A payload is the logical record; SSE/JSON envelopes do not consume its assembly budget. */
|
|
12
|
+
export declare function encodeConversationRecord(type: ConversationRecordType, payload: unknown, id: string): Generator<string>;
|
|
13
|
+
/** Strict incremental decoder: one assembly, no partial publication, UTF-8 split safe. */
|
|
14
|
+
export declare class ConversationStreamDecoder {
|
|
15
|
+
private readonly publish;
|
|
16
|
+
private decoder;
|
|
17
|
+
private pending;
|
|
18
|
+
private assembly?;
|
|
19
|
+
constructor(publish: (record: ConversationStreamRecord) => void);
|
|
20
|
+
push(bytes: Uint8Array): void;
|
|
21
|
+
finish(): void;
|
|
22
|
+
private accept;
|
|
23
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/** Public v1 wire contract. Kept free of Node APIs so browser consumers can share it. */
|
|
2
|
+
export const MAX_FRAME_BYTES = 512 * 1024;
|
|
3
|
+
export const MAX_ASSEMBLY_BYTES = 16 * 1024 * 1024;
|
|
4
|
+
export const MAX_PENDING_BYTES = 8 * 1024 * 1024;
|
|
5
|
+
const utf8 = new TextEncoder();
|
|
6
|
+
const types = new Set(['ready', 'ledger', 'state', 'role', 'resync', 'keepalive']);
|
|
7
|
+
const frame = (value) => `event: conversation\ndata: ${JSON.stringify(value)}\n\n`;
|
|
8
|
+
const identity = (payload) => ({ generation: payload?.sessionGeneration ?? payload?.snapshot?.sessionGeneration,
|
|
9
|
+
revision: payload?.stateRevision ?? payload?.snapshot?.stateRevision ?? payload?.roleRevision });
|
|
10
|
+
/** A payload is the logical record; SSE/JSON envelopes do not consume its assembly budget. */
|
|
11
|
+
export function* encodeConversationRecord(type, payload, id) {
|
|
12
|
+
const json = JSON.stringify(payload), totalBytes = utf8.encode(json).byteLength;
|
|
13
|
+
if (totalBytes > MAX_ASSEMBLY_BYTES)
|
|
14
|
+
throw new Error('conversation record exceeds assembly limit');
|
|
15
|
+
const direct = frame({ version: 1, type, payload });
|
|
16
|
+
if (utf8.encode(direct).byteLength <= MAX_FRAME_BYTES) {
|
|
17
|
+
yield direct;
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const position = identity(payload);
|
|
21
|
+
yield frame({ version: 1, type: 'start', id, recordType: type, totalBytes, ...position });
|
|
22
|
+
// At most 48K UTF-16 units: even worst-case JSON escapes fit a 512 KiB frame.
|
|
23
|
+
let index = 0;
|
|
24
|
+
for (let offset = 0; offset < json.length;) {
|
|
25
|
+
let end = Math.min(json.length, offset + 48 * 1024);
|
|
26
|
+
if (end < json.length && /[\uD800-\uDBFF]/.test(json[end - 1]))
|
|
27
|
+
end--;
|
|
28
|
+
yield frame({ version: 1, type: 'chunk', id, index: index++, data: json.slice(offset, end) });
|
|
29
|
+
offset = end;
|
|
30
|
+
}
|
|
31
|
+
yield frame({ version: 1, type: 'commit', id, totalBytes, ...position });
|
|
32
|
+
}
|
|
33
|
+
/** Strict incremental decoder: one assembly, no partial publication, UTF-8 split safe. */
|
|
34
|
+
export class ConversationStreamDecoder {
|
|
35
|
+
publish;
|
|
36
|
+
decoder = new TextDecoder('utf-8', { fatal: true });
|
|
37
|
+
pending = '';
|
|
38
|
+
assembly;
|
|
39
|
+
constructor(publish) {
|
|
40
|
+
this.publish = publish;
|
|
41
|
+
}
|
|
42
|
+
push(bytes) {
|
|
43
|
+
try {
|
|
44
|
+
this.pending += this.decoder.decode(bytes, { stream: true });
|
|
45
|
+
for (;;) {
|
|
46
|
+
const end = this.pending.indexOf('\n\n');
|
|
47
|
+
if (end < 0)
|
|
48
|
+
break;
|
|
49
|
+
const text = this.pending.slice(0, end + 2);
|
|
50
|
+
this.pending = this.pending.slice(end + 2);
|
|
51
|
+
if (utf8.encode(text).byteLength > MAX_FRAME_BYTES)
|
|
52
|
+
throw new Error('frame limit exceeded');
|
|
53
|
+
if (!text.startsWith('event: conversation\ndata: '))
|
|
54
|
+
throw new Error('invalid conversation frame');
|
|
55
|
+
this.accept(JSON.parse(text.slice(26, -2)));
|
|
56
|
+
}
|
|
57
|
+
if (utf8.encode(this.pending).byteLength > MAX_FRAME_BYTES)
|
|
58
|
+
throw new Error('frame limit exceeded');
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
this.pending = '';
|
|
62
|
+
this.assembly = undefined;
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
finish() {
|
|
67
|
+
const rest = this.decoder.decode();
|
|
68
|
+
if (rest || this.pending || this.assembly)
|
|
69
|
+
throw new Error('incomplete conversation stream');
|
|
70
|
+
}
|
|
71
|
+
accept(value) {
|
|
72
|
+
if (value?.version !== 1)
|
|
73
|
+
throw new Error('unsupported conversation version');
|
|
74
|
+
if (types.has(value.type)) {
|
|
75
|
+
if (this.assembly)
|
|
76
|
+
throw new Error('overlapping assembly');
|
|
77
|
+
this.publish(value);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (value.type === 'start') {
|
|
81
|
+
if (this.assembly)
|
|
82
|
+
throw new Error('overlapping assembly');
|
|
83
|
+
if (typeof value.id !== 'string' || !types.has(value.recordType) || !Number.isSafeInteger(value.totalBytes) || value.totalBytes < 1 || value.totalBytes > MAX_ASSEMBLY_BYTES)
|
|
84
|
+
throw new Error('invalid assembly length/type');
|
|
85
|
+
this.assembly = { id: value.id, type: value.recordType, total: value.totalBytes, bytes: 0, index: 0, parts: [], generation: value.generation, revision: value.revision };
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const assembly = this.assembly;
|
|
89
|
+
if (!assembly || value.id !== assembly.id)
|
|
90
|
+
throw new Error('assembly identity mismatch');
|
|
91
|
+
if (value.type === 'chunk') {
|
|
92
|
+
if (value.index !== assembly.index++ || typeof value.data !== 'string')
|
|
93
|
+
throw new Error('assembly chunk order');
|
|
94
|
+
if (!value.data.length)
|
|
95
|
+
throw new Error('assembly empty chunk');
|
|
96
|
+
assembly.bytes += utf8.encode(value.data).byteLength;
|
|
97
|
+
if (assembly.bytes > assembly.total)
|
|
98
|
+
throw new Error('assembly length exceeded');
|
|
99
|
+
assembly.parts.push(value.data);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (value.type !== 'commit' || value.totalBytes !== assembly.total || assembly.bytes !== assembly.total)
|
|
103
|
+
throw new Error('assembly length mismatch');
|
|
104
|
+
const payload = JSON.parse(assembly.parts.join('')), position = identity(payload);
|
|
105
|
+
if (value.generation !== assembly.generation || value.revision !== assembly.revision || position.generation !== assembly.generation || position.revision !== assembly.revision)
|
|
106
|
+
throw new Error('assembly revision mismatch');
|
|
107
|
+
this.assembly = undefined;
|
|
108
|
+
this.publish({ version: 1, type: assembly.type, payload });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Writable } from 'node:stream';
|
|
2
|
+
import { type ConversationRecordType } from './conversation-stream-codec.js';
|
|
3
|
+
/** One active logical record; only waiting encoded output consumes the pending budget. */
|
|
4
|
+
export declare class ConversationStreamWriter {
|
|
5
|
+
private readonly output;
|
|
6
|
+
private readonly failed;
|
|
7
|
+
private queue;
|
|
8
|
+
private active?;
|
|
9
|
+
private waiting;
|
|
10
|
+
private ended;
|
|
11
|
+
private sequence;
|
|
12
|
+
pendingBytes: number;
|
|
13
|
+
get busy(): boolean;
|
|
14
|
+
constructor(output: Writable, failed: (reason: string) => void);
|
|
15
|
+
private error;
|
|
16
|
+
private drain;
|
|
17
|
+
send(type: ConversationRecordType, payload: unknown): boolean;
|
|
18
|
+
private flush;
|
|
19
|
+
private fail;
|
|
20
|
+
close(): void;
|
|
21
|
+
}
|
|
22
|
+
import type { FastifyInstance } from 'fastify';
|
|
23
|
+
import type { WebServices } from './server.js';
|
|
24
|
+
import type { WebAuth } from './auth.js';
|
|
25
|
+
export declare function registerConversationStream(app: FastifyInstance, services: WebServices, auth: WebAuth): void;
|