@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
|
@@ -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
|
}
|
|
@@ -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>;
|
|
@@ -56,6 +56,7 @@ export declare class ClaudeLogin extends BaseLogin {
|
|
|
56
56
|
private child?;
|
|
57
57
|
private output;
|
|
58
58
|
private submitted;
|
|
59
|
+
private continued;
|
|
59
60
|
/** Resolves with the CLI exit status once it exits. */
|
|
60
61
|
exited: Promise<number | null>;
|
|
61
62
|
constructor(owner: string, bins: SubscriptionBinaries, home: string, profileId: string);
|