@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
package/dist/session/control.js
CHANGED
|
@@ -4,6 +4,7 @@ import { chmodSync, existsSync, readFileSync, rmSync, writeFileSync } from 'node
|
|
|
4
4
|
import { createConnection, createServer } from 'node:net';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
import { SessionControlError } from './types.js';
|
|
7
|
+
import { MAX_CONVERSATION_RECORD_BYTES, startConversationReplay } from './conversation-replay.js';
|
|
7
8
|
import { validateFleetAuditBegin, validateFleetAuditFinish, validateFleetAuditPresentations, } from '../fleet-command-audit.js';
|
|
8
9
|
import { interruptSession, queueSessionPrompt, respondSessionPermission, respondSessionPermissionV2, } from '../application/session-mutations.js';
|
|
9
10
|
const MAX_LINE_BYTES = 64 * 1024;
|
|
@@ -194,6 +195,12 @@ export class RoleControlServer {
|
|
|
194
195
|
continue;
|
|
195
196
|
void this.handle(line, socket).then(follow => {
|
|
196
197
|
if (follow) {
|
|
198
|
+
if (socket.destroyed) {
|
|
199
|
+
follow.stop();
|
|
200
|
+
if (follow.controller)
|
|
201
|
+
this.session.setControllerAttached(false);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
197
204
|
if (unsubscribe) {
|
|
198
205
|
unsubscribe();
|
|
199
206
|
if (controllerAttached)
|
|
@@ -242,6 +249,7 @@ export class RoleControlServer {
|
|
|
242
249
|
'events_since', 'observer_follow', 'retained_range',
|
|
243
250
|
...(this.correspondence ? ['agent_correspondence'] : []),
|
|
244
251
|
...(this.session.conversationPage ? ['conversation_v3'] : []),
|
|
252
|
+
...this.observationFeatures(),
|
|
245
253
|
...(this.session.submitPromptBrowser ? ['generation_bound_prompts'] : []),
|
|
246
254
|
...(this.session.liveVoiceSupported ? ['idle_bound_prompts', 'targeted_voice_interrupt'] : []),
|
|
247
255
|
...(this.session.capabilities?.steering ? ['steering'] : []),
|
|
@@ -427,20 +435,98 @@ export class RoleControlServer {
|
|
|
427
435
|
}
|
|
428
436
|
case 'conversation_page': {
|
|
429
437
|
this.requireConversation(request);
|
|
430
|
-
this.
|
|
431
|
-
|
|
432
|
-
result: this.session.conversationPage({
|
|
433
|
-
after: request.after, limit: request.limit,
|
|
434
|
-
}),
|
|
438
|
+
const page = this.session.conversationPage({
|
|
439
|
+
after: request.after === undefined ? undefined : String(request.after), limit: request.limit,
|
|
435
440
|
});
|
|
441
|
+
if (this.session.observeConversation) {
|
|
442
|
+
try {
|
|
443
|
+
const observation = this.session.observeConversation(() => { });
|
|
444
|
+
try {
|
|
445
|
+
if (observation.snapshot.observationFailure) {
|
|
446
|
+
page.snapshot = { ...page.snapshot, historyDegraded: true, observationFailure: 'capture_failed' };
|
|
447
|
+
}
|
|
448
|
+
else
|
|
449
|
+
page.snapshot = observation.snapshot;
|
|
450
|
+
}
|
|
451
|
+
finally {
|
|
452
|
+
observation.unsubscribe();
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
catch {
|
|
456
|
+
// Canonical REST recovery remains usable without a live observation.
|
|
457
|
+
// Never substitute a frozen capture/barrier for the current store page.
|
|
458
|
+
page.snapshot = { ...page.snapshot, historyDegraded: true, observationFailure: 'capture_failed' };
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
this.write(socket, { version: 1, id: request.id, ok: true, result: page });
|
|
436
462
|
return;
|
|
437
463
|
}
|
|
438
464
|
case 'conversation_follow': {
|
|
439
465
|
this.requireConversation(request);
|
|
466
|
+
if (request.controller === false && request.expectedGeneration !== undefined) {
|
|
467
|
+
let stopped = false;
|
|
468
|
+
let heartbeat;
|
|
469
|
+
let writing = Promise.resolve();
|
|
470
|
+
const send = (message) => {
|
|
471
|
+
const result = writing.then(() => writeMessage(message));
|
|
472
|
+
writing = result.catch(() => { });
|
|
473
|
+
return result;
|
|
474
|
+
};
|
|
475
|
+
const writeMessage = async (message) => {
|
|
476
|
+
// Stream one replay record progressively; don't queue a valid 16 MiB record
|
|
477
|
+
// into the 8 MiB pending-output budget. Framing is owned by the transport.
|
|
478
|
+
const bytes = Buffer.from(JSON.stringify({ version: 1, ...message }) + '\n');
|
|
479
|
+
for (let offset = 0; offset < bytes.length; offset += 64 * 1024) {
|
|
480
|
+
if (stopped || socket.destroyed)
|
|
481
|
+
throw new Error('conversation socket closed');
|
|
482
|
+
if (!socket.write(bytes.subarray(offset, offset + 64 * 1024))) {
|
|
483
|
+
await new Promise((resolve, reject) => {
|
|
484
|
+
const cleanup = () => { socket.off('drain', drained); socket.off('close', ended); socket.off('error', failed); };
|
|
485
|
+
const drained = () => { cleanup(); resolve(); };
|
|
486
|
+
const ended = () => { cleanup(); reject(new Error('conversation socket closed')); };
|
|
487
|
+
const failed = (error) => { cleanup(); reject(error); };
|
|
488
|
+
socket.once('drain', drained);
|
|
489
|
+
socket.once('close', ended);
|
|
490
|
+
socket.once('error', failed);
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
};
|
|
495
|
+
const follow = startConversationReplay(this.session, {
|
|
496
|
+
expectedGeneration: request.expectedGeneration, after: request.after === undefined ? 0 : Number(request.after), controller: false,
|
|
497
|
+
}, {
|
|
498
|
+
replay: page => send({ conversationReplay: page }),
|
|
499
|
+
ready: ready => send({ conversationReady: ready }),
|
|
500
|
+
event: event => send({ conversationEvent: event }),
|
|
501
|
+
state: state => send({ conversationState: state }),
|
|
502
|
+
close: reason => {
|
|
503
|
+
stopped = true;
|
|
504
|
+
if (heartbeat)
|
|
505
|
+
clearInterval(heartbeat);
|
|
506
|
+
if (reason && !socket.destroyed)
|
|
507
|
+
socket.end(JSON.stringify({ version: 1, id: request.id, ok: false, error: reason }) + '\n');
|
|
508
|
+
},
|
|
509
|
+
});
|
|
510
|
+
const stop = () => { stopped = true; if (heartbeat)
|
|
511
|
+
clearInterval(heartbeat); follow.close(); };
|
|
512
|
+
let heartbeatPending = false;
|
|
513
|
+
if (!stopped) {
|
|
514
|
+
heartbeat = setInterval(() => {
|
|
515
|
+
if (heartbeatPending || stopped)
|
|
516
|
+
return;
|
|
517
|
+
heartbeatPending = true;
|
|
518
|
+
void send({ controlHeartbeat: true }).catch(() => { stop(); socket.destroy(); })
|
|
519
|
+
.finally(() => { heartbeatPending = false; });
|
|
520
|
+
}, 2_000);
|
|
521
|
+
heartbeat.unref();
|
|
522
|
+
}
|
|
523
|
+
socket.once('close', stop);
|
|
524
|
+
return { controller: false, stop: () => { socket.off('close', stop); stop(); } };
|
|
525
|
+
}
|
|
440
526
|
this.write(socket, {
|
|
441
527
|
version: 1, id: request.id, ok: true,
|
|
442
528
|
result: this.session.conversationPage({
|
|
443
|
-
after: request.after, limit: request.limit,
|
|
529
|
+
after: request.after === undefined ? undefined : String(request.after), limit: request.limit,
|
|
444
530
|
}),
|
|
445
531
|
});
|
|
446
532
|
// A live conversation view is an attached controller: permission
|
|
@@ -560,6 +646,23 @@ export class RoleControlServer {
|
|
|
560
646
|
if (!this.session.conversationPage || !this.session.subscribeConversation)
|
|
561
647
|
throw new SessionControlError('rejected', 'this role has no conversation ledger');
|
|
562
648
|
}
|
|
649
|
+
observationFeatures() {
|
|
650
|
+
if (!this.session.observeConversation || !this.session.replayConversation || !this.session.subscribeConversation || !this.session.subscribeConversationFailure)
|
|
651
|
+
return [];
|
|
652
|
+
try {
|
|
653
|
+
const observation = this.session.observeConversation(() => { });
|
|
654
|
+
try {
|
|
655
|
+
return observation.snapshot.observationFailure || !this.session.conversationReplayAvailable?.(observation.snapshot.sessionGeneration)
|
|
656
|
+
? [] : ['conversation_observation', 'control_heartbeat'];
|
|
657
|
+
}
|
|
658
|
+
finally {
|
|
659
|
+
observation.unsubscribe();
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
catch {
|
|
663
|
+
return [];
|
|
664
|
+
}
|
|
665
|
+
}
|
|
563
666
|
write(socket, response) {
|
|
564
667
|
if (!socket.destroyed)
|
|
565
668
|
socket.write(JSON.stringify(response) + '\n');
|
|
@@ -652,32 +755,54 @@ export async function followControl(stateDir, onMessage) {
|
|
|
652
755
|
* controller-presence this connection contributed.
|
|
653
756
|
*/
|
|
654
757
|
export async function followConversation(stateDir, after, onMessage) {
|
|
758
|
+
const options = typeof after === 'object' ? after : undefined;
|
|
759
|
+
options?.signal?.throwIfAborted();
|
|
655
760
|
const token = readFileSync(controlTokenPath(stateDir), 'utf8').trim();
|
|
656
761
|
const socket = createConnection(controlSocketPath(stateDir));
|
|
657
762
|
socket.setEncoding('utf8');
|
|
658
|
-
|
|
763
|
+
// Admit one maximum-size logical record plus its actual private wrapper.
|
|
764
|
+
// coveredThrough is a safe integer. JSON brackets/envelope are outside the record budget.
|
|
765
|
+
const privateEnvelopeBytes = Math.max(Buffer.byteLength(JSON.stringify({ version: 1, conversationReplay: { events: [], coveredThrough: Number.MAX_SAFE_INTEGER } })), Buffer.byteLength(JSON.stringify({ version: 1, conversationReady: { snapshot: null, coveredThrough: Number.MAX_SAFE_INTEGER } })) - 4, Buffer.byteLength(JSON.stringify({ version: 1, conversationState: null })) - 4, Buffer.byteLength(JSON.stringify({ version: 1, conversationEvent: null })) - 4);
|
|
766
|
+
const limit = MAX_CONVERSATION_RECORD_BYTES + privateEnvelopeBytes;
|
|
767
|
+
let parts = [], bytes = 0;
|
|
659
768
|
socket.on('data', chunk => {
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
769
|
+
for (const [index, part] of String(chunk).split('\n').entries()) {
|
|
770
|
+
if (index) {
|
|
771
|
+
const line = parts.join('');
|
|
772
|
+
parts = [];
|
|
773
|
+
bytes = 0;
|
|
774
|
+
if (line.trim()) {
|
|
775
|
+
try {
|
|
776
|
+
onMessage(JSON.parse(line));
|
|
777
|
+
}
|
|
778
|
+
catch {
|
|
779
|
+
if (options) {
|
|
780
|
+
socket.destroy(new Error('invalid conversation private frame'));
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
bytes += Buffer.byteLength(part);
|
|
787
|
+
if (options && bytes > limit) {
|
|
788
|
+
socket.destroy(new Error('conversation private frame exceeds assembly limit'));
|
|
789
|
+
return;
|
|
671
790
|
}
|
|
672
|
-
|
|
791
|
+
parts.push(part);
|
|
673
792
|
}
|
|
674
793
|
});
|
|
675
794
|
await new Promise((resolve, reject) => {
|
|
795
|
+
const onAbort = () => { socket.destroy(); reject(new Error('conversation follow aborted')); };
|
|
796
|
+
options?.signal?.addEventListener('abort', onAbort, { once: true });
|
|
797
|
+
socket.once('close', () => options?.signal?.removeEventListener('abort', onAbort));
|
|
676
798
|
socket.once('connect', resolve);
|
|
677
799
|
socket.once('error', reject);
|
|
800
|
+
if (options?.signal?.aborted)
|
|
801
|
+
onAbort();
|
|
678
802
|
});
|
|
679
803
|
socket.write(JSON.stringify({
|
|
680
|
-
version: 3, id: randomUUID(), token, command: 'conversation_follow',
|
|
804
|
+
version: 3, id: randomUUID(), token, command: 'conversation_follow',
|
|
805
|
+
...(options ? { expectedGeneration: options.expectedGeneration, after: options.after ?? 0, controller: false } : { after }),
|
|
681
806
|
}) + '\n');
|
|
682
807
|
return { socket, close: () => socket.destroy() };
|
|
683
808
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { ConversationObservation as Observation, ConversationObservationState } from './conversation-types.js';
|
|
2
|
+
type Capture = () => Omit<ConversationObservationState, 'stateRevision'>;
|
|
3
|
+
/** Revision/publication only: the backend remains the sole owner of snapshot construction. */
|
|
4
|
+
export declare class ConversationObservation {
|
|
5
|
+
private readonly capture;
|
|
6
|
+
private revision;
|
|
7
|
+
private fingerprint?;
|
|
8
|
+
private current?;
|
|
9
|
+
private scheduled;
|
|
10
|
+
private checkpointDepth;
|
|
11
|
+
private failed;
|
|
12
|
+
private readonly listeners;
|
|
13
|
+
constructor(capture: Capture);
|
|
14
|
+
observe(listener: (state: ConversationObservationState) => void): Observation;
|
|
15
|
+
/** Keep reentrant registration on the preceding coherent snapshot until the
|
|
16
|
+
* owner's synchronous durable append and inventory mutation finish together.
|
|
17
|
+
*/
|
|
18
|
+
checkpoint<T>(mutation: () => T): T;
|
|
19
|
+
publish(): void;
|
|
20
|
+
private failCapture;
|
|
21
|
+
private deliver;
|
|
22
|
+
/** Fresh capture after a durable append's synchronous owner mutation completes. */
|
|
23
|
+
scheduleCapture(): void;
|
|
24
|
+
}
|
|
25
|
+
export {};
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/** Revision/publication only: the backend remains the sole owner of snapshot construction. */
|
|
2
|
+
export class ConversationObservation {
|
|
3
|
+
capture;
|
|
4
|
+
revision = 0;
|
|
5
|
+
fingerprint;
|
|
6
|
+
current;
|
|
7
|
+
scheduled = false;
|
|
8
|
+
checkpointDepth = 0;
|
|
9
|
+
failed = false;
|
|
10
|
+
listeners = new Set();
|
|
11
|
+
constructor(capture) {
|
|
12
|
+
this.capture = capture;
|
|
13
|
+
}
|
|
14
|
+
observe(listener) {
|
|
15
|
+
// Registration and synchronous stable capture have no await gap. Inside an
|
|
16
|
+
// owner checkpoint, publish retains the preceding snapshot and its barrier.
|
|
17
|
+
// A reconnect never resets this generation's revision or implies control.
|
|
18
|
+
this.publish();
|
|
19
|
+
if (!this.current)
|
|
20
|
+
throw new Error('conversation observation unavailable: capture_failed');
|
|
21
|
+
this.listeners.add(listener);
|
|
22
|
+
return {
|
|
23
|
+
snapshot: structuredClone(this.current),
|
|
24
|
+
unsubscribe: () => { this.listeners.delete(listener); },
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
/** Keep reentrant registration on the preceding coherent snapshot until the
|
|
28
|
+
* owner's synchronous durable append and inventory mutation finish together.
|
|
29
|
+
*/
|
|
30
|
+
checkpoint(mutation) {
|
|
31
|
+
if (this.checkpointDepth === 0)
|
|
32
|
+
this.publish();
|
|
33
|
+
this.checkpointDepth++;
|
|
34
|
+
try {
|
|
35
|
+
return mutation();
|
|
36
|
+
}
|
|
37
|
+
finally {
|
|
38
|
+
this.checkpointDepth--;
|
|
39
|
+
if (this.checkpointDepth === 0)
|
|
40
|
+
this.publish();
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
publish() {
|
|
44
|
+
if (this.checkpointDepth > 0 || this.failed)
|
|
45
|
+
return;
|
|
46
|
+
try {
|
|
47
|
+
const captured = this.capture();
|
|
48
|
+
const fingerprint = JSON.stringify(captured);
|
|
49
|
+
if (fingerprint === this.fingerprint)
|
|
50
|
+
return;
|
|
51
|
+
const current = structuredClone({ ...captured, stateRevision: this.revision + 1 });
|
|
52
|
+
this.fingerprint = fingerprint;
|
|
53
|
+
this.current = current;
|
|
54
|
+
this.revision++;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
this.failCapture();
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
this.deliver();
|
|
61
|
+
}
|
|
62
|
+
failCapture() {
|
|
63
|
+
if (this.failed)
|
|
64
|
+
return;
|
|
65
|
+
this.failed = true;
|
|
66
|
+
// Retain the entire preceding coherent state and its barrier. Consumers
|
|
67
|
+
// must stop healthy coverage; owner admission/receipt processing continues.
|
|
68
|
+
if (this.current) {
|
|
69
|
+
this.current = { ...this.current, stateRevision: ++this.revision, observationFailure: 'capture_failed' };
|
|
70
|
+
this.deliver();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
deliver() {
|
|
74
|
+
for (const listener of this.listeners) {
|
|
75
|
+
let state;
|
|
76
|
+
try {
|
|
77
|
+
state = structuredClone(this.current);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
this.failCapture();
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
// An observer callback cannot break the owning backend's transition.
|
|
84
|
+
try {
|
|
85
|
+
listener(state);
|
|
86
|
+
}
|
|
87
|
+
catch { /* passive observer */ }
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/** Fresh capture after a durable append's synchronous owner mutation completes. */
|
|
91
|
+
scheduleCapture() {
|
|
92
|
+
if (this.scheduled)
|
|
93
|
+
return;
|
|
94
|
+
this.scheduled = true;
|
|
95
|
+
queueMicrotask(() => {
|
|
96
|
+
this.scheduled = false;
|
|
97
|
+
this.publish();
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { ConversationEventV1 } from './conversation-types.js';
|
|
2
|
+
export declare const MAX_REPLAY_BUFFER_BYTES: number;
|
|
3
|
+
export declare const MAX_CONVERSATION_RECORD_BYTES: number;
|
|
4
|
+
export interface ConversationReplayRequest {
|
|
5
|
+
expectedGeneration: string;
|
|
6
|
+
after: number;
|
|
7
|
+
through: number;
|
|
8
|
+
pageSize?: number;
|
|
9
|
+
signal?: AbortSignal;
|
|
10
|
+
}
|
|
11
|
+
export interface ConversationReplayPage {
|
|
12
|
+
events: ConversationEventV1[];
|
|
13
|
+
/** Storage coverage, including invisible events; never a visible-event cursor. */
|
|
14
|
+
coveredThrough: number;
|
|
15
|
+
}
|
|
16
|
+
export declare class ReplayCoverageError extends Error {
|
|
17
|
+
constructor(detail: string);
|
|
18
|
+
}
|
|
19
|
+
/** Bounded reads and explicit scheduling even when a whole segment is cached by the OS. */
|
|
20
|
+
export declare function replayLines(path: string, signal?: AbortSignal): AsyncGenerator<string>;
|
|
21
|
+
import type { ConversationObservation, ConversationObservationState } from './conversation-types.js';
|
|
22
|
+
export interface PassiveConversationFollowRequest {
|
|
23
|
+
expectedGeneration: string;
|
|
24
|
+
after?: number;
|
|
25
|
+
controller: false;
|
|
26
|
+
signal?: AbortSignal;
|
|
27
|
+
}
|
|
28
|
+
export interface ConversationReplayReady {
|
|
29
|
+
snapshot: ConversationObservationState;
|
|
30
|
+
coveredThrough: number;
|
|
31
|
+
}
|
|
32
|
+
interface ReplayOwner {
|
|
33
|
+
observeConversation?(listener: (state: ConversationObservationState) => void): ConversationObservation;
|
|
34
|
+
subscribeConversationFailure?(listener: (generation: string) => void): () => void;
|
|
35
|
+
subscribeConversation?(listener: (event: ConversationEventV1) => void): () => void;
|
|
36
|
+
replayConversation?(request: ConversationReplayRequest): AsyncIterable<ConversationReplayPage>;
|
|
37
|
+
}
|
|
38
|
+
export interface ConversationReplaySink {
|
|
39
|
+
replay(page: ConversationReplayPage): void | Promise<void>;
|
|
40
|
+
ready(ready: ConversationReplayReady): void | Promise<void>;
|
|
41
|
+
event(event: ConversationEventV1): void | Promise<void>;
|
|
42
|
+
state(state: ConversationObservationState): void | Promise<void>;
|
|
43
|
+
close(reason?: string): void;
|
|
44
|
+
}
|
|
45
|
+
/** Passive owner-turn registration, complete replay, ready, then buffered post-barrier delivery. */
|
|
46
|
+
export declare function startConversationReplay(owner: ReplayOwner, request: PassiveConversationFollowRequest, sink: ConversationReplaySink): {
|
|
47
|
+
ready: Promise<void>;
|
|
48
|
+
close: () => void;
|
|
49
|
+
};
|
|
50
|
+
export {};
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { open } from 'node:fs/promises';
|
|
2
|
+
import { setImmediate as yieldTurn } from 'node:timers/promises';
|
|
3
|
+
export const MAX_REPLAY_BUFFER_BYTES = 8 * 1024 * 1024;
|
|
4
|
+
export const MAX_CONVERSATION_RECORD_BYTES = 16 * 1024 * 1024;
|
|
5
|
+
export class ReplayCoverageError extends Error {
|
|
6
|
+
constructor(detail) { super(`replay coverage unavailable: ${detail}`); }
|
|
7
|
+
}
|
|
8
|
+
/** Bounded reads and explicit scheduling even when a whole segment is cached by the OS. */
|
|
9
|
+
export async function* replayLines(path, signal) {
|
|
10
|
+
const file = await open(path, 'r');
|
|
11
|
+
try {
|
|
12
|
+
const chunk = Buffer.alloc(64 * 1024);
|
|
13
|
+
let pending = Buffer.alloc(0);
|
|
14
|
+
for (;;) {
|
|
15
|
+
signal?.throwIfAborted();
|
|
16
|
+
const { bytesRead } = await file.read(chunk, 0, chunk.length, null);
|
|
17
|
+
if (!bytesRead)
|
|
18
|
+
break;
|
|
19
|
+
const data = Buffer.concat([pending, chunk.subarray(0, bytesRead)]);
|
|
20
|
+
let start = 0;
|
|
21
|
+
for (let i = 0; i < data.length; i++) {
|
|
22
|
+
if (data[i] !== 10)
|
|
23
|
+
continue;
|
|
24
|
+
if (i - start > MAX_CONVERSATION_RECORD_BYTES)
|
|
25
|
+
throw new ReplayCoverageError('record exceeds assembly limit');
|
|
26
|
+
yield data.toString('utf8', start, i);
|
|
27
|
+
start = i + 1;
|
|
28
|
+
}
|
|
29
|
+
pending = Buffer.from(data.subarray(start));
|
|
30
|
+
if (pending.length > MAX_CONVERSATION_RECORD_BYTES)
|
|
31
|
+
throw new ReplayCoverageError('record exceeds assembly limit');
|
|
32
|
+
await yieldTurn();
|
|
33
|
+
}
|
|
34
|
+
if (pending.length)
|
|
35
|
+
throw new ReplayCoverageError('unterminated record');
|
|
36
|
+
}
|
|
37
|
+
finally {
|
|
38
|
+
await file.close();
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** Passive owner-turn registration, complete replay, ready, then buffered post-barrier delivery. */
|
|
42
|
+
export function startConversationReplay(owner, request, sink) {
|
|
43
|
+
if (request.controller !== false || !owner.observeConversation || !owner.subscribeConversation || !owner.subscribeConversationFailure || !owner.replayConversation)
|
|
44
|
+
throw new ReplayCoverageError('passive observation is unavailable');
|
|
45
|
+
const after = request.after ?? 0;
|
|
46
|
+
if (!request.expectedGeneration || !Number.isSafeInteger(after) || after < 0)
|
|
47
|
+
throw new ReplayCoverageError('invalid cursor or generation');
|
|
48
|
+
const abort = new AbortController();
|
|
49
|
+
const disposers = [];
|
|
50
|
+
const pending = [];
|
|
51
|
+
let pendingBytes = 0;
|
|
52
|
+
let closed = false;
|
|
53
|
+
let committed = false;
|
|
54
|
+
let draining = false;
|
|
55
|
+
let snapshot;
|
|
56
|
+
let resolveReady;
|
|
57
|
+
let rejectReady;
|
|
58
|
+
const ready = new Promise((resolve, reject) => { resolveReady = resolve; rejectReady = reject; });
|
|
59
|
+
// Consumers can attach later without an unhandled rejection during synchronous registration.
|
|
60
|
+
void ready.catch(() => { });
|
|
61
|
+
const close = (reason) => {
|
|
62
|
+
if (closed)
|
|
63
|
+
return;
|
|
64
|
+
closed = true;
|
|
65
|
+
abort.abort(new Error(reason ?? 'conversation follow closed'));
|
|
66
|
+
for (const dispose of disposers.splice(0))
|
|
67
|
+
dispose();
|
|
68
|
+
pending.length = 0;
|
|
69
|
+
pendingBytes = 0;
|
|
70
|
+
rejectReady(new Error(reason ?? 'conversation follow closed'));
|
|
71
|
+
sink.close(reason);
|
|
72
|
+
};
|
|
73
|
+
const own = (dispose) => { if (closed)
|
|
74
|
+
dispose();
|
|
75
|
+
else
|
|
76
|
+
disposers.push(dispose); };
|
|
77
|
+
const drain = async () => {
|
|
78
|
+
if (!committed || draining || closed)
|
|
79
|
+
return;
|
|
80
|
+
draining = true;
|
|
81
|
+
try {
|
|
82
|
+
while (pending.length && !closed) {
|
|
83
|
+
const next = pending[0];
|
|
84
|
+
if (next.value.event) {
|
|
85
|
+
if (next.value.event.seq > snapshot.ledgerBarrier)
|
|
86
|
+
await sink.event(next.value.event);
|
|
87
|
+
}
|
|
88
|
+
else if (next.value.state.stateRevision > snapshot.stateRevision)
|
|
89
|
+
await sink.state(next.value.state);
|
|
90
|
+
if (!closed) {
|
|
91
|
+
pending.shift();
|
|
92
|
+
pendingBytes -= next.bytes;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
close(error.message);
|
|
98
|
+
}
|
|
99
|
+
finally {
|
|
100
|
+
draining = false;
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
const enqueue = (value) => {
|
|
104
|
+
if (closed)
|
|
105
|
+
return;
|
|
106
|
+
if (value.state?.observationFailure) {
|
|
107
|
+
close('conversation observation failed');
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if ((value.event ?? value.state).sessionGeneration !== request.expectedGeneration) {
|
|
111
|
+
close('session generation changed');
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const recordBytes = Buffer.byteLength(JSON.stringify(value.event ?? value.state));
|
|
115
|
+
const bytes = recordBytes + 10; // {"event":...} or {"state":...} queued envelope
|
|
116
|
+
if (recordBytes > MAX_CONVERSATION_RECORD_BYTES) {
|
|
117
|
+
close('conversation record exceeds assembly limit');
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
// One active record goes straight to the progressive writer. Only waiting
|
|
121
|
+
// records count against the 8 MiB queue; the active assembly is bounded separately.
|
|
122
|
+
if (committed && !draining && pending.length === 0) {
|
|
123
|
+
draining = true;
|
|
124
|
+
void (async () => {
|
|
125
|
+
try {
|
|
126
|
+
if (value.event) {
|
|
127
|
+
if (value.event.seq > snapshot.ledgerBarrier)
|
|
128
|
+
await sink.event(value.event);
|
|
129
|
+
}
|
|
130
|
+
else if (value.state.stateRevision > snapshot.stateRevision)
|
|
131
|
+
await sink.state(value.state);
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
close(error.message);
|
|
135
|
+
}
|
|
136
|
+
finally {
|
|
137
|
+
draining = false;
|
|
138
|
+
void drain();
|
|
139
|
+
}
|
|
140
|
+
})();
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if (pendingBytes + bytes > MAX_REPLAY_BUFFER_BYTES) {
|
|
144
|
+
close('conversation live replay buffer exceeded');
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
pending.push({ value, bytes });
|
|
148
|
+
pendingBytes += bytes;
|
|
149
|
+
void drain();
|
|
150
|
+
};
|
|
151
|
+
try {
|
|
152
|
+
// No await between registration and the owner's coherent snapshot/barrier capture.
|
|
153
|
+
own(owner.subscribeConversation(event => enqueue({ event })));
|
|
154
|
+
own(owner.subscribeConversationFailure(generation => {
|
|
155
|
+
if (generation === request.expectedGeneration)
|
|
156
|
+
close('conversation generation has non-durable events');
|
|
157
|
+
}));
|
|
158
|
+
const observation = owner.observeConversation(state => enqueue({ state }));
|
|
159
|
+
own(observation.unsubscribe);
|
|
160
|
+
snapshot = observation.snapshot;
|
|
161
|
+
if (snapshot.observationFailure)
|
|
162
|
+
throw new ReplayCoverageError('conversation observation failed');
|
|
163
|
+
if (snapshot.sessionGeneration !== request.expectedGeneration)
|
|
164
|
+
throw new ReplayCoverageError('session generation changed');
|
|
165
|
+
if (after > snapshot.ledgerBarrier)
|
|
166
|
+
throw new ReplayCoverageError('cursor exceeds barrier');
|
|
167
|
+
if (request.signal) {
|
|
168
|
+
const onAbort = () => close('conversation follow aborted');
|
|
169
|
+
request.signal.addEventListener('abort', onAbort, { once: true });
|
|
170
|
+
own(() => request.signal.removeEventListener('abort', onAbort));
|
|
171
|
+
if (request.signal.aborted)
|
|
172
|
+
onAbort();
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
close(error.message);
|
|
177
|
+
}
|
|
178
|
+
if (!closed)
|
|
179
|
+
void (async () => {
|
|
180
|
+
try {
|
|
181
|
+
let coveredThrough = after;
|
|
182
|
+
for await (const page of owner.replayConversation({ expectedGeneration: request.expectedGeneration, after, through: snapshot.ledgerBarrier, signal: abort.signal })) {
|
|
183
|
+
abort.signal.throwIfAborted();
|
|
184
|
+
if (page.coveredThrough <= coveredThrough || page.coveredThrough > snapshot.ledgerBarrier)
|
|
185
|
+
throw new ReplayCoverageError('non-progressing page');
|
|
186
|
+
await sink.replay(page);
|
|
187
|
+
coveredThrough = page.coveredThrough;
|
|
188
|
+
}
|
|
189
|
+
abort.signal.throwIfAborted();
|
|
190
|
+
if (coveredThrough !== snapshot.ledgerBarrier)
|
|
191
|
+
throw new ReplayCoverageError('incomplete replay');
|
|
192
|
+
await sink.ready({ snapshot, coveredThrough });
|
|
193
|
+
abort.signal.throwIfAborted();
|
|
194
|
+
committed = true;
|
|
195
|
+
resolveReady();
|
|
196
|
+
await drain();
|
|
197
|
+
}
|
|
198
|
+
catch (error) {
|
|
199
|
+
close(error.message);
|
|
200
|
+
}
|
|
201
|
+
})();
|
|
202
|
+
return { ready, close: () => close() };
|
|
203
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type ConversationReplayRequest, type ConversationReplayPage } from './conversation-replay.js';
|
|
1
2
|
import type { ConversationEventV1, PromptReceipt } from './conversation-types.js';
|
|
2
3
|
export interface ConversationStoreOptions {
|
|
3
4
|
roleId: string;
|
|
@@ -29,6 +30,13 @@ type EventDraft = Omit<ConversationEventV1, 'schemaVersion' | 'roleId' | 'eventI
|
|
|
29
30
|
export declare class ConversationEventStore {
|
|
30
31
|
private readonly dir;
|
|
31
32
|
private nextSeq;
|
|
33
|
+
private lastDurableSeq;
|
|
34
|
+
private readonly segmentRanges;
|
|
35
|
+
private readonly generationFloors;
|
|
36
|
+
private readonly failedGenerations;
|
|
37
|
+
private readonly failureListeners;
|
|
38
|
+
private unavailable;
|
|
39
|
+
private rotateBeforeAppend;
|
|
32
40
|
private firstAvailableSeq?;
|
|
33
41
|
private segments;
|
|
34
42
|
private tail;
|
|
@@ -45,6 +53,8 @@ export declare class ConversationEventStore {
|
|
|
45
53
|
constructor(dir: string, options: ConversationStoreOptions);
|
|
46
54
|
/** First 24 hex chars of sha-256; the idempotency body-digest convention. */
|
|
47
55
|
static bodyDigest(body: string): string;
|
|
56
|
+
/** Current delivery eligibility only; requested historical ranges still need replay verification. */
|
|
57
|
+
generationDurable(generation: string): boolean;
|
|
48
58
|
get degraded(): boolean;
|
|
49
59
|
get degradedDetail(): string | undefined;
|
|
50
60
|
/**
|
|
@@ -59,6 +69,8 @@ export declare class ConversationEventStore {
|
|
|
59
69
|
appendSafe(draft: EventDraft): ConversationEventV1 | undefined;
|
|
60
70
|
page(request?: ConversationPageRequest): ConversationStorePage;
|
|
61
71
|
subscribe(listener: (event: ConversationEventV1) => void): () => void;
|
|
72
|
+
/** A current-generation failed append invalidates even a follow already past ready. */
|
|
73
|
+
subscribeFailure(listener: (generation: string) => void): () => void;
|
|
62
74
|
/** Store the receipt a repeated command must get back. */
|
|
63
75
|
recordReceipt(commandId: string, receipt: PromptReceipt, bodyDigest: string): void;
|
|
64
76
|
/**
|
|
@@ -82,6 +94,8 @@ export declare class ConversationEventStore {
|
|
|
82
94
|
private trackPromptState;
|
|
83
95
|
private segmentFd;
|
|
84
96
|
private writeManifest;
|
|
97
|
+
/** One forward iterator owns the scan; each segment is opened at most once. */
|
|
98
|
+
replay(request: ConversationReplayRequest): AsyncGenerator<ConversationReplayPage>;
|
|
85
99
|
private eventsAfter;
|
|
86
100
|
private markDegraded;
|
|
87
101
|
}
|