@ours.network/fleet 1.2.0-nightly.62 → 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 +6 -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/role-removal-service.js +1 -1
- 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/notifications/fleet-producer.js +4 -4
- package/dist/notifications/target-cleanup.d.ts +2 -0
- package/dist/notifications/target-cleanup.js +24 -2
- package/dist/ops.js +1 -1
- 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/subscriptions/login.d.ts +1 -0
- package/dist/subscriptions/login.js +30 -5
- 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,3 +1,4 @@
|
|
|
1
|
+
import { ConversationObservation } from './conversation-observation.js';
|
|
1
2
|
import { appendTaskCreated } from './task-notice.js';
|
|
2
3
|
import { createHash, randomUUID } from 'node:crypto';
|
|
3
4
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
@@ -148,6 +149,7 @@ export class CodexAppServerSession {
|
|
|
148
149
|
lastError;
|
|
149
150
|
exit = null;
|
|
150
151
|
queueDepth = 0;
|
|
152
|
+
observation = new ConversationObservation(() => this.captureObservation());
|
|
151
153
|
promptTail = Promise.resolve();
|
|
152
154
|
/** Admitted prompts not yet handed to the app-server, in admission order. */
|
|
153
155
|
pendingPrompts = [];
|
|
@@ -170,6 +172,9 @@ export class CodexAppServerSession {
|
|
|
170
172
|
roleId: options.name, log: line => options.log(`[${options.name}] ${line}`),
|
|
171
173
|
});
|
|
172
174
|
this.conversationStartCursor = this.conversation.lastCursor();
|
|
175
|
+
// A durable append can precede its inventory mutation. Capture only after
|
|
176
|
+
// the synchronous owner checkpoint, never the state at append notification.
|
|
177
|
+
this.conversation.subscribe(() => this.observation.scheduleCapture());
|
|
173
178
|
// Use Fleet's provider-neutral resume marker so runner recovery rotates the
|
|
174
179
|
// exact native thread id after a poisoned fast resume.
|
|
175
180
|
this.threadFile = join(options.stateDir, '.session-id');
|
|
@@ -234,14 +239,18 @@ export class CodexAppServerSession {
|
|
|
234
239
|
}
|
|
235
240
|
const promptId = randomUUID();
|
|
236
241
|
const queuedBehind = this.queueDepth;
|
|
237
|
-
this.
|
|
238
|
-
|
|
239
|
-
|
|
242
|
+
const run = this.observation.checkpoint(() => {
|
|
243
|
+
this.admit(promptId, text, queuedBehind, options);
|
|
244
|
+
this.queueDepth++;
|
|
245
|
+
return this.enqueue(promptId, text, options.origin);
|
|
246
|
+
});
|
|
240
247
|
const completion = run.then(result => {
|
|
241
248
|
this.queueDepth = Math.max(0, this.queueDepth - 1);
|
|
249
|
+
this.observation.publish();
|
|
242
250
|
return result;
|
|
243
251
|
}, error => {
|
|
244
252
|
this.queueDepth = Math.max(0, this.queueDepth - 1);
|
|
253
|
+
this.observation.publish();
|
|
245
254
|
return turnResult(false, 'failed', error instanceof Error ? error.message : String(error));
|
|
246
255
|
});
|
|
247
256
|
return {
|
|
@@ -413,6 +422,7 @@ export class CodexAppServerSession {
|
|
|
413
422
|
});
|
|
414
423
|
this.conversation.close();
|
|
415
424
|
await this.transport.close();
|
|
425
|
+
this.observation.publish();
|
|
416
426
|
}
|
|
417
427
|
taskNoticeBinding() {
|
|
418
428
|
return this.activeTurn && this.threadId ? { sessionGeneration: this.sessionGeneration, acpSessionId: this.threadId, promptId: this.activeTurn.promptId } : undefined;
|
|
@@ -432,6 +442,36 @@ export class CodexAppServerSession {
|
|
|
432
442
|
}
|
|
433
443
|
return { ...page, events: visible, snapshot: this.conversationSnapshot() };
|
|
434
444
|
}
|
|
445
|
+
subscribeConversationFailure(listener) {
|
|
446
|
+
return this.conversation.subscribeFailure(listener);
|
|
447
|
+
}
|
|
448
|
+
conversationReplayAvailable(expectedGeneration) {
|
|
449
|
+
return expectedGeneration === this.sessionGeneration && this.conversation.generationDurable(expectedGeneration);
|
|
450
|
+
}
|
|
451
|
+
replayConversation(request) {
|
|
452
|
+
if (request.expectedGeneration !== this.sessionGeneration)
|
|
453
|
+
throw new Error('session generation changed');
|
|
454
|
+
return this.conversation.replay(request);
|
|
455
|
+
}
|
|
456
|
+
observeConversation(listener) {
|
|
457
|
+
return this.observation.observe(listener);
|
|
458
|
+
}
|
|
459
|
+
captureObservation() {
|
|
460
|
+
const snapshot = this.snapshot();
|
|
461
|
+
return {
|
|
462
|
+
...this.conversationSnapshot(),
|
|
463
|
+
...pendingInventory(this.pendingPrompts),
|
|
464
|
+
ledgerBarrier: Number(this.conversation.lastCursor() ?? 0),
|
|
465
|
+
alive: snapshot.alive,
|
|
466
|
+
readiness: snapshot.alive ? snapshot.readiness : 'offline',
|
|
467
|
+
activePromptIds: [...(this.activeTurn?.promptIds ?? [])],
|
|
468
|
+
pendingPermissions: [...this.pendingPermissions].flatMap(([permissionId, pending]) => pending.presented ? [{ permissionId, ...pending.presented }] : []),
|
|
469
|
+
commands: [{ name: 'compact', description: textBlock('Compact session context') }],
|
|
470
|
+
runtimeModel: snapshot.runtimeModel,
|
|
471
|
+
reasoningEffort: snapshot.reasoningEffort,
|
|
472
|
+
permissionMode: snapshot.permissionMode,
|
|
473
|
+
};
|
|
474
|
+
}
|
|
435
475
|
conversationSnapshot() {
|
|
436
476
|
return {
|
|
437
477
|
sessionGeneration: this.sessionGeneration,
|
|
@@ -460,6 +500,7 @@ export class CodexAppServerSession {
|
|
|
460
500
|
actor: { browserSession: command.actorBrowserSession },
|
|
461
501
|
});
|
|
462
502
|
const receipt = {
|
|
503
|
+
sessionGeneration: this.sessionGeneration,
|
|
463
504
|
commandId: command.commandId, promptId: queued.promptId,
|
|
464
505
|
state: queued.queuedBehind ? 'queued' : 'starting', queuedBehind: queued.queuedBehind,
|
|
465
506
|
acceptedAt: new Date().toISOString(), eventCursor: this.conversation.lastCursor() ?? '0',
|
|
@@ -515,6 +556,7 @@ export class CodexAppServerSession {
|
|
|
515
556
|
kind: 'session.state', sessionGeneration: this.sessionGeneration,
|
|
516
557
|
payload: { status: 'idle' },
|
|
517
558
|
});
|
|
559
|
+
this.observation.publish();
|
|
518
560
|
}
|
|
519
561
|
recoverOpenPrompts() {
|
|
520
562
|
for (const open of this.conversation.openPrompts()) {
|
|
@@ -534,7 +576,8 @@ export class CodexAppServerSession {
|
|
|
534
576
|
? { kind: 'owner-admin-console', commandId: open.commandId }
|
|
535
577
|
: undefined;
|
|
536
578
|
const run = this.enqueue(open.promptId, open.text, origin);
|
|
537
|
-
|
|
579
|
+
this.observation.publish();
|
|
580
|
+
void run.finally(() => { this.queueDepth = Math.max(0, this.queueDepth - 1); this.observation.publish(); });
|
|
538
581
|
}
|
|
539
582
|
}
|
|
540
583
|
admit(promptId, text, queuedBehind, options) {
|
|
@@ -580,6 +623,7 @@ export class CodexAppServerSession {
|
|
|
580
623
|
this.finalAnswerObserved = false;
|
|
581
624
|
this.activeItems.clear();
|
|
582
625
|
this.readiness = 'running';
|
|
626
|
+
this.observation.publish();
|
|
583
627
|
this.events.emit('state', { turnId: promptId, status: 'running', origin });
|
|
584
628
|
members.forEach((member, index) => this.conversation.appendSafe({
|
|
585
629
|
kind: 'prompt.started', sessionGeneration: this.sessionGeneration,
|
|
@@ -890,6 +934,7 @@ export class CodexAppServerSession {
|
|
|
890
934
|
...(active.cancellationSource ? { cancellationSource: active.cancellationSource } : {}),
|
|
891
935
|
},
|
|
892
936
|
});
|
|
937
|
+
this.observation.publish();
|
|
893
938
|
active.settle(result);
|
|
894
939
|
}
|
|
895
940
|
/**
|
|
@@ -1053,7 +1098,6 @@ export class CodexAppServerSession {
|
|
|
1053
1098
|
this.resolvePermission(permissionId, pending, 'denied', 'automatic', 'deny_once', unattended ? 'permissions.unattended=deny' : 'permissions.approval=deny', unattended ? 'no controller is attached' : 'the role denies permission requests');
|
|
1054
1099
|
return;
|
|
1055
1100
|
}
|
|
1056
|
-
this.pendingPermissions.set(permissionId, pending);
|
|
1057
1101
|
this.readiness = 'awaiting_permission';
|
|
1058
1102
|
const timeoutMs = this.options.permissionTimeoutMs ?? PERMISSION_TIMEOUT_MS;
|
|
1059
1103
|
const expiresAt = new Date(Date.now() + timeoutMs).toISOString();
|
|
@@ -1063,19 +1107,23 @@ export class CodexAppServerSession {
|
|
|
1063
1107
|
?? (method === 'item/fileChange/requestApproval' ? 'Apply file changes'
|
|
1064
1108
|
: method === 'item/permissions/requestApproval' ? 'Grant additional permissions'
|
|
1065
1109
|
: 'Run command');
|
|
1110
|
+
const presented = { toolCallId, title, options, expiresAt };
|
|
1111
|
+
pending.presented = presented;
|
|
1112
|
+
this.pendingPermissions.set(permissionId, pending);
|
|
1066
1113
|
this.events.emit('permission', {
|
|
1067
1114
|
turnId: this.activeTurn?.promptId, origin: this.activeTurn?.origin,
|
|
1068
|
-
permissionId,
|
|
1115
|
+
permissionId, ...presented, status: 'pending',
|
|
1069
1116
|
});
|
|
1070
1117
|
this.conversation.appendSafe({
|
|
1071
1118
|
kind: 'permission.requested', sessionGeneration: this.sessionGeneration,
|
|
1072
1119
|
permissionId, promptId: this.activeTurn?.promptId, turnId: this.activeTurn?.promptId,
|
|
1073
|
-
toolCallId, payload:
|
|
1120
|
+
toolCallId, payload: presented,
|
|
1074
1121
|
});
|
|
1075
1122
|
pending.expiry = setTimeout(() => {
|
|
1076
1123
|
this.autoResolvePermission(permissionId, pending, undefined, `no decision arrived within ${Math.round(timeoutMs / 1000)}s`);
|
|
1077
1124
|
}, timeoutMs);
|
|
1078
1125
|
pending.expiry.unref?.();
|
|
1126
|
+
this.observation.publish();
|
|
1079
1127
|
}
|
|
1080
1128
|
serverRequestResolved(params) {
|
|
1081
1129
|
const requestId = typeof params.requestId === 'string' || typeof params.requestId === 'number'
|
|
@@ -1101,6 +1149,8 @@ export class CodexAppServerSession {
|
|
|
1101
1149
|
this.resolvePermission(permissionId, pending, 'denied', 'automatic', 'deny_once', policy, reason);
|
|
1102
1150
|
}
|
|
1103
1151
|
resolvePermission(permissionId, pending, decision, decisionSource, optionId, policy, reason) {
|
|
1152
|
+
if (!this.pendingPermissions.size && this.readiness === 'awaiting_permission')
|
|
1153
|
+
this.readiness = 'running';
|
|
1104
1154
|
this.events.emit('permission', {
|
|
1105
1155
|
turnId: this.activeTurn?.promptId, origin: this.activeTurn?.origin,
|
|
1106
1156
|
permissionId, toolCallId: pending.toolCallId, status: 'completed', decision,
|
|
@@ -1113,8 +1163,7 @@ export class CodexAppServerSession {
|
|
|
1113
1163
|
payload: { decision, decisionSource, ...(optionId ? { optionId } : {}),
|
|
1114
1164
|
...(policy ? { policy } : {}), ...(reason ? { reason } : {}) },
|
|
1115
1165
|
});
|
|
1116
|
-
|
|
1117
|
-
this.readiness = 'running';
|
|
1166
|
+
this.observation.publish();
|
|
1118
1167
|
this.scheduleTerminalReconciliation();
|
|
1119
1168
|
}
|
|
1120
1169
|
currentEvent(event) {
|
|
@@ -1162,10 +1211,12 @@ export class CodexAppServerSession {
|
|
|
1162
1211
|
if (this.activeTurn)
|
|
1163
1212
|
this.finishTurn('failed', this.lastError);
|
|
1164
1213
|
}
|
|
1214
|
+
this.observation.publish();
|
|
1165
1215
|
}
|
|
1166
1216
|
fail(error) {
|
|
1167
1217
|
this.lastError = error instanceof Error ? error.message : String(error);
|
|
1168
1218
|
this.readiness = 'failed';
|
|
1169
1219
|
this.events.emit('error', { text: this.lastError });
|
|
1220
|
+
this.observation.publish();
|
|
1170
1221
|
}
|
|
1171
1222
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { LayoutControlRequest } from '../rooms-tasks/layout-control.js';
|
|
2
2
|
import type { SupervisorCorrespondence, AgentHistoryQuery } from '../agent-ours/correspondence.js';
|
|
3
3
|
import { type Socket } from 'node:net';
|
|
4
|
+
import { type PassiveConversationFollowRequest } from './conversation-replay.js';
|
|
4
5
|
import type { AgentSession, ControlFailureKind, SessionEvent, SessionSnapshot } from './types.js';
|
|
5
6
|
import type { OwnerChannelHandle, OwnerChannelManagementRequest } from '../owner-channel/channel.js';
|
|
6
7
|
import type { ScheduledLoopManagerHandle } from '../loops/manager.js';
|
|
@@ -26,7 +27,8 @@ export interface ControlRequest {
|
|
|
26
27
|
ownerChannel?: OwnerChannelManagementRequest;
|
|
27
28
|
loop?: string;
|
|
28
29
|
/** Conversation cursor: replay strictly after this durable seq. */
|
|
29
|
-
after?: string;
|
|
30
|
+
after?: string | number;
|
|
31
|
+
expectedGeneration?: string;
|
|
30
32
|
limit?: number;
|
|
31
33
|
/** Idempotency key for v3 mutations. */
|
|
32
34
|
commandId?: string;
|
|
@@ -141,6 +143,7 @@ export declare class RoleControlServer {
|
|
|
141
143
|
private handle;
|
|
142
144
|
/** Version and capability gate shared by every conversation v3 command. */
|
|
143
145
|
private requireConversation;
|
|
146
|
+
private observationFeatures;
|
|
144
147
|
private write;
|
|
145
148
|
}
|
|
146
149
|
/**
|
|
@@ -160,7 +163,7 @@ export declare function followControl(stateDir: string, onMessage: (message: Rec
|
|
|
160
163
|
* `{ version, conversationEvent }`. Closing the socket detaches the
|
|
161
164
|
* controller-presence this connection contributed.
|
|
162
165
|
*/
|
|
163
|
-
export declare function followConversation(stateDir: string, after: string | undefined, onMessage: (message: Record<string, unknown>) => void): Promise<{
|
|
166
|
+
export declare function followConversation(stateDir: string, after: string | undefined | PassiveConversationFollowRequest, onMessage: (message: Record<string, unknown>) => void): Promise<{
|
|
164
167
|
socket: Socket;
|
|
165
168
|
close(): void;
|
|
166
169
|
}>;
|
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 {};
|