@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.
Files changed (41) hide show
  1. package/README.md +6 -0
  2. package/dist/application/conversation-history.js +4 -1
  3. package/dist/application/conversation-role-observer.d.ts +62 -0
  4. package/dist/application/conversation-role-observer.js +206 -0
  5. package/dist/application/role-removal-service.js +1 -1
  6. package/dist/application/session-control.d.ts +31 -17
  7. package/dist/application/session-control.js +148 -33
  8. package/dist/build-info.json +4 -4
  9. package/dist/notifications/fleet-producer.js +4 -4
  10. package/dist/notifications/target-cleanup.d.ts +2 -0
  11. package/dist/notifications/target-cleanup.js +24 -2
  12. package/dist/ops.js +1 -1
  13. package/dist/session/acp.d.ts +8 -1
  14. package/dist/session/acp.js +97 -43
  15. package/dist/session/arbiter.d.ts +4 -0
  16. package/dist/session/arbiter.js +18 -0
  17. package/dist/session/codex-app-server.d.ts +7 -1
  18. package/dist/session/codex-app-server.js +60 -9
  19. package/dist/session/control.d.ts +5 -2
  20. package/dist/session/control.js +145 -20
  21. package/dist/session/conversation-observation.d.ts +25 -0
  22. package/dist/session/conversation-observation.js +100 -0
  23. package/dist/session/conversation-replay.d.ts +50 -0
  24. package/dist/session/conversation-replay.js +203 -0
  25. package/dist/session/conversation-store.d.ts +14 -0
  26. package/dist/session/conversation-store.js +201 -17
  27. package/dist/session/conversation-types.d.ts +29 -1
  28. package/dist/session/types.d.ts +6 -0
  29. package/dist/subscriptions/login.d.ts +1 -0
  30. package/dist/subscriptions/login.js +30 -5
  31. package/dist/web/auth.d.ts +2 -0
  32. package/dist/web/auth.js +10 -1
  33. package/dist/web/conversation-stream-codec.d.ts +23 -0
  34. package/dist/web/conversation-stream-codec.js +110 -0
  35. package/dist/web/conversation-stream.d.ts +25 -0
  36. package/dist/web/conversation-stream.js +231 -0
  37. package/dist/web/runtime.js +12 -2
  38. package/dist/web/server.d.ts +2 -0
  39. package/dist/web/server.js +2 -0
  40. package/docs/conversation-stream.md +176 -0
  41. 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
- const line = JSON.stringify(event) + '\n';
73
- const fd = this.segmentFd(Buffer.byteLength(line));
74
- writeSync(fd, line);
75
- fsyncSync(fd);
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.nextSeq++;
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.nextSeq > 1 ? String(this.nextSeq - 1) : undefined;
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
- this.segments = manifest?.segments.filter(name => existsSync(join(this.dir, name))) ?? this.discoverSegments();
162
- if (!this.segments.length)
163
- this.segments = this.discoverSegments();
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 && typeof event.seq === 'number')
288
+ if (event.schemaVersion === 1 && Number.isSafeInteger(event.seq) && event.seq > 0) {
218
289
  events.push(event);
219
- else
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.length + 1).padStart(6, '0')}.jsonl`;
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
- // The manifest is a recovery accelerator, not the source of truth;
312
- // segments alone can always rebuild it.
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;
@@ -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);