@frockbot/kernel-do 0.3.15 → 0.3.16
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/package.json +3 -3
- package/src/authority.ts +176 -136
- package/src/conversations.test.ts +7 -9
- package/src/index.ts +1 -0
- package/src/model-timeout-settlement.test.ts +8 -8
- package/src/run-liveness.test.ts +2 -1
- package/src/run-records.ts +93 -2
- package/src/run-terminal-open-turn.test.ts +9 -4
- package/src/run-terminal.ts +61 -44
- package/src/session-event-log.test.ts +267 -0
- package/src/session-event-log.ts +731 -0
- package/src/session-log-size.test.ts +250 -0
- package/src/storage-keys.ts +4 -0
- package/src/turn-admission.test.ts +15 -9
- package/src/turn-supersede.test.ts +13 -8
|
@@ -31,7 +31,7 @@ import {
|
|
|
31
31
|
BotTurnExecutionError,
|
|
32
32
|
BotTurnReconciliationRequiredError,
|
|
33
33
|
} from "./turn-errors.ts";
|
|
34
|
-
import { createStoredRunCodecV1
|
|
34
|
+
import { createStoredRunCodecV1 } from "./run-records.ts";
|
|
35
35
|
|
|
36
36
|
const codec = createStoredRunCodecV1<undefined>({
|
|
37
37
|
decodeRunId: (value) => value as string,
|
|
@@ -188,11 +188,11 @@ function createAuthority(
|
|
|
188
188
|
});
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
-
function storedRun(
|
|
192
|
-
|
|
191
|
+
async function storedRun(
|
|
192
|
+
authority: BotDurableAuthority<undefined>,
|
|
193
193
|
runId: string,
|
|
194
|
-
)
|
|
195
|
-
return
|
|
194
|
+
) {
|
|
195
|
+
return (await authority.readRun(runId))!;
|
|
196
196
|
}
|
|
197
197
|
|
|
198
198
|
describe("a model request that ran out of time", () => {
|
|
@@ -205,7 +205,7 @@ describe("a model request that ran out of time", () => {
|
|
|
205
205
|
const completion = await authority.run(command("run-1", "build me one"));
|
|
206
206
|
|
|
207
207
|
expect(completion.runId).toBe("run-1");
|
|
208
|
-
const run = storedRun(
|
|
208
|
+
const run = await storedRun(authority, "run-1");
|
|
209
209
|
expect(run.status).toBe("failed");
|
|
210
210
|
// The ordinary run-terminal path: the open Turn is closed rather than left
|
|
211
211
|
// for the next message to trip over.
|
|
@@ -233,7 +233,7 @@ describe("a model request that ran out of time", () => {
|
|
|
233
233
|
authority.run(command("run-1", "build me one")),
|
|
234
234
|
).rejects.toThrow();
|
|
235
235
|
|
|
236
|
-
const run = storedRun(
|
|
236
|
+
const run = await storedRun(authority, "run-1");
|
|
237
237
|
expect(run.status).toBe("reconciliation-required");
|
|
238
238
|
expect(run.events.some((event) => event.type === "turn/end")).toBe(false);
|
|
239
239
|
});
|
|
@@ -252,7 +252,7 @@ describe("a model request that ran out of time", () => {
|
|
|
252
252
|
const completion = await authority.run(command("run-1", "hello"));
|
|
253
253
|
|
|
254
254
|
expect(completion.runId).toBe("run-1");
|
|
255
|
-
const run = storedRun(
|
|
255
|
+
const run = await storedRun(authority, "run-1");
|
|
256
256
|
expect(run.status).toBe("failed");
|
|
257
257
|
expect(
|
|
258
258
|
run.events.findLast((event) => event.type === "turn/end"),
|
package/src/run-liveness.test.ts
CHANGED
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
STALE_RUNNING_RUN_FAILURE_V1,
|
|
29
29
|
STALE_RUNNING_RUN_GRACE_MS_V1,
|
|
30
30
|
} from "./run-liveness.ts";
|
|
31
|
+
import { SessionEventLog } from "./session-event-log.ts";
|
|
31
32
|
import {
|
|
32
33
|
ACTIVE_RUN_KEY,
|
|
33
34
|
IDENTITY_KEY,
|
|
@@ -255,7 +256,7 @@ describe("the read that repairs what it finds", () => {
|
|
|
255
256
|
// The Bot is free: nothing holds the object, and the next Turn admits
|
|
256
257
|
// against a log that reads as a complete history.
|
|
257
258
|
expect(await storage.get<string>(ACTIVE_RUN_KEY)).toBeUndefined();
|
|
258
|
-
const log =
|
|
259
|
+
const log = await new SessionEventLog(storage).read("user-1:primary");
|
|
259
260
|
expect(log.some((entry) => entry.type === "turn/end")).toBe(true);
|
|
260
261
|
});
|
|
261
262
|
|
package/src/run-records.ts
CHANGED
|
@@ -133,6 +133,12 @@ export interface StoredRunV1<Snapshot = unknown> {
|
|
|
133
133
|
acceptedAt: string;
|
|
134
134
|
input: string;
|
|
135
135
|
events: SessionEvent[];
|
|
136
|
+
/**
|
|
137
|
+
* Inclusive/exclusive coordinates of this Turn in the authoritative Session
|
|
138
|
+
* log. New durable records carry this instead of embedding `events`; the
|
|
139
|
+
* in-memory record is hydrated through the Session log accessor.
|
|
140
|
+
*/
|
|
141
|
+
eventRange?: StoredRunEventRangeV1;
|
|
136
142
|
effectAdmissions: StoredEffectAdmission[];
|
|
137
143
|
status: StoredRunStatus;
|
|
138
144
|
responseText?: string;
|
|
@@ -159,6 +165,44 @@ export interface StoredRunV1<Snapshot = unknown> {
|
|
|
159
165
|
directTool?: DirectToolCommandV1;
|
|
160
166
|
}
|
|
161
167
|
|
|
168
|
+
export interface StoredRunEventRangeV1 {
|
|
169
|
+
startSeq: number;
|
|
170
|
+
endSeq: number;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Keeps a hydrated journal and its durable coordinates in lockstep. */
|
|
174
|
+
export function storedRunEventFieldsV2(
|
|
175
|
+
previousEventCount: number,
|
|
176
|
+
events: SessionEvent[],
|
|
177
|
+
): { events: SessionEvent[]; eventRange: StoredRunEventRangeV1 } {
|
|
178
|
+
return {
|
|
179
|
+
events,
|
|
180
|
+
eventRange: {
|
|
181
|
+
startSeq: previousEventCount,
|
|
182
|
+
endSeq: previousEventCount + events.length,
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The compact durable run shape. Keeping this encoder beside the strict
|
|
189
|
+
* decoder makes it difficult for a metadata update to accidentally put a
|
|
190
|
+
* hydrated multi-megabyte journal back into one SQLite value.
|
|
191
|
+
*/
|
|
192
|
+
export function storedRunRecordV2<Snapshot>(
|
|
193
|
+
run: StoredRunV1<Snapshot>,
|
|
194
|
+
): Omit<StoredRunV1<Snapshot>, "events"> {
|
|
195
|
+
const { events, ...record } = run;
|
|
196
|
+
const eventRange =
|
|
197
|
+
events.length === 0 && run.eventRange
|
|
198
|
+
? run.eventRange
|
|
199
|
+
: {
|
|
200
|
+
startSeq: run.previousEventCount,
|
|
201
|
+
endSeq: run.previousEventCount + events.length,
|
|
202
|
+
};
|
|
203
|
+
return { ...record, eventRange };
|
|
204
|
+
}
|
|
205
|
+
|
|
162
206
|
export interface DirectToolCommandV1 {
|
|
163
207
|
generationId: string;
|
|
164
208
|
packageId: string;
|
|
@@ -246,7 +290,6 @@ const STORED_RUN_REQUIRED_KEYS = [
|
|
|
246
290
|
"sessionId",
|
|
247
291
|
"acceptedAt",
|
|
248
292
|
"input",
|
|
249
|
-
"events",
|
|
250
293
|
"effectAdmissions",
|
|
251
294
|
"status",
|
|
252
295
|
"phase",
|
|
@@ -255,6 +298,8 @@ const STORED_RUN_REQUIRED_KEYS = [
|
|
|
255
298
|
"previousEventCount",
|
|
256
299
|
] as const;
|
|
257
300
|
const STORED_RUN_OPTIONAL_KEYS = [
|
|
301
|
+
"events",
|
|
302
|
+
"eventRange",
|
|
258
303
|
"responseText",
|
|
259
304
|
"failure",
|
|
260
305
|
"stopRequestedAt",
|
|
@@ -483,6 +528,32 @@ function decodeStoredRunEvents(value: unknown): SessionEvent[] {
|
|
|
483
528
|
return value.map(decodeSessionEvent);
|
|
484
529
|
}
|
|
485
530
|
|
|
531
|
+
function decodeStoredRunEventRange(
|
|
532
|
+
value: unknown,
|
|
533
|
+
runId: string,
|
|
534
|
+
): StoredRunEventRangeV1 {
|
|
535
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
536
|
+
throw new Error(`run "${runId}" has an invalid event range`);
|
|
537
|
+
}
|
|
538
|
+
const candidate = value as Record<PropertyKey, unknown>;
|
|
539
|
+
if (
|
|
540
|
+
Reflect.ownKeys(candidate).length !== 2 ||
|
|
541
|
+
Object.keys(candidate).length !== 2 ||
|
|
542
|
+
!Object.hasOwn(candidate, "startSeq") ||
|
|
543
|
+
!Object.hasOwn(candidate, "endSeq") ||
|
|
544
|
+
!Number.isSafeInteger(candidate.startSeq) ||
|
|
545
|
+
!Number.isSafeInteger(candidate.endSeq) ||
|
|
546
|
+
(candidate.startSeq as number) < 0 ||
|
|
547
|
+
(candidate.endSeq as number) < (candidate.startSeq as number)
|
|
548
|
+
) {
|
|
549
|
+
throw new Error(`run "${runId}" has an invalid event range`);
|
|
550
|
+
}
|
|
551
|
+
return {
|
|
552
|
+
startSeq: candidate.startSeq as number,
|
|
553
|
+
endSeq: candidate.endSeq as number,
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
|
|
486
557
|
const STORED_EFFECT_ADMISSIONS_MAX = 256;
|
|
487
558
|
const STORED_EFFECT_ID_MAX_BYTES = 512;
|
|
488
559
|
|
|
@@ -582,7 +653,17 @@ function requireStoredRunRecordV1<Snapshot>(
|
|
|
582
653
|
if (!boundedString(candidate.input, 32_000)) {
|
|
583
654
|
throw new Error(`run "${runId}" has no valid input`);
|
|
584
655
|
}
|
|
585
|
-
|
|
656
|
+
if (candidate.events === undefined && candidate.eventRange === undefined) {
|
|
657
|
+
throw new Error(`run "${runId}" has no event journal reference`);
|
|
658
|
+
}
|
|
659
|
+
const events =
|
|
660
|
+
candidate.events === undefined
|
|
661
|
+
? []
|
|
662
|
+
: decodeStoredRunEvents(candidate.events);
|
|
663
|
+
const eventRange =
|
|
664
|
+
candidate.eventRange === undefined
|
|
665
|
+
? undefined
|
|
666
|
+
: decodeStoredRunEventRange(candidate.eventRange, runId);
|
|
586
667
|
const effectAdmissions = decodeStoredEffectAdmissions(
|
|
587
668
|
candidate.effectAdmissions,
|
|
588
669
|
);
|
|
@@ -605,6 +686,15 @@ function requireStoredRunRecordV1<Snapshot>(
|
|
|
605
686
|
) {
|
|
606
687
|
throw new Error(`run "${runId}" has no valid previous event count`);
|
|
607
688
|
}
|
|
689
|
+
if (
|
|
690
|
+
eventRange &&
|
|
691
|
+
(eventRange.startSeq !== candidate.previousEventCount ||
|
|
692
|
+
(events.length > 0 &&
|
|
693
|
+
(events[0]?.seq !== eventRange.startSeq ||
|
|
694
|
+
events.at(-1)!.seq + 1 !== eventRange.endSeq)))
|
|
695
|
+
) {
|
|
696
|
+
throw new Error(`run "${runId}" has an inconsistent event range`);
|
|
697
|
+
}
|
|
608
698
|
const configurationSnapshot = options.decodeConfigurationSnapshot(
|
|
609
699
|
candidate.configurationSnapshot,
|
|
610
700
|
);
|
|
@@ -679,6 +769,7 @@ function requireStoredRunRecordV1<Snapshot>(
|
|
|
679
769
|
acceptedAt: candidate.acceptedAt,
|
|
680
770
|
input: candidate.input,
|
|
681
771
|
events,
|
|
772
|
+
...(eventRange ? { eventRange } : {}),
|
|
682
773
|
effectAdmissions,
|
|
683
774
|
status,
|
|
684
775
|
phase,
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
validateToolOccurrenceJournal,
|
|
15
15
|
} from "@frockbot/kernel-contracts";
|
|
16
16
|
import { MemoryStorage } from "./memory-storage.fixture.ts";
|
|
17
|
+
import { SessionEventLog } from "./session-event-log.ts";
|
|
17
18
|
import { createStoredRunCodecV1, type StoredRunV1 } from "./run-records.ts";
|
|
18
19
|
import {
|
|
19
20
|
cancelStoredRun,
|
|
@@ -117,7 +118,7 @@ async function settled(
|
|
|
117
118
|
|
|
118
119
|
return {
|
|
119
120
|
storage,
|
|
120
|
-
latest: storage.
|
|
121
|
+
latest: await new SessionEventLog(storage).read(SESSION_ID),
|
|
121
122
|
};
|
|
122
123
|
}
|
|
123
124
|
|
|
@@ -141,9 +142,13 @@ describe("settling a Turn interrupted mid-answer", () => {
|
|
|
141
142
|
expect(() => admitNextTurn(latest)).not.toThrow();
|
|
142
143
|
expect(latest.at(-1)).toMatchObject({ type: "turn/end", turn: 1 });
|
|
143
144
|
// The settled record carries the same closed account, not a different one.
|
|
144
|
-
const record = storage.values.get(KEYS.run) as
|
|
145
|
+
const record = storage.values.get(KEYS.run) as Omit<
|
|
146
|
+
StoredRunV1<null>,
|
|
147
|
+
"events"
|
|
148
|
+
>;
|
|
145
149
|
expect(record.status).toBe("superseded");
|
|
146
|
-
expect(record
|
|
150
|
+
expect(Object.hasOwn(record, "events")).toBe(false);
|
|
151
|
+
expect(record.eventRange).toEqual({ startSeq: 0, endSeq: latest.length });
|
|
147
152
|
});
|
|
148
153
|
|
|
149
154
|
test("a stopped run leaves a log the next Turn can start on", async () => {
|
|
@@ -196,7 +201,7 @@ describe("settling a Turn interrupted mid-answer", () => {
|
|
|
196
201
|
|
|
197
202
|
await cancelStoredRun(codec, storage, KEYS, "run-1", [], events);
|
|
198
203
|
|
|
199
|
-
const latest = storage.
|
|
204
|
+
const latest = await new SessionEventLog(storage).read(SESSION_ID);
|
|
200
205
|
expect(latest.filter((event) => event.type === "turn/end")).toHaveLength(1);
|
|
201
206
|
expect(() => admitNextTurn(latest)).not.toThrow();
|
|
202
207
|
});
|
package/src/run-terminal.ts
CHANGED
|
@@ -8,7 +8,13 @@ import type {
|
|
|
8
8
|
StoredRunCodecV1,
|
|
9
9
|
StoredRunV1,
|
|
10
10
|
} from "./run-records.js";
|
|
11
|
+
import { storedRunRecordV2 } from "./run-records.js";
|
|
12
|
+
import { storedRunEventFieldsV2 } from "./run-records.js";
|
|
11
13
|
import { repairedSessionLogV1 } from "./run-recovery.js";
|
|
14
|
+
import {
|
|
15
|
+
SessionEventLog,
|
|
16
|
+
type SessionEventLogStorage,
|
|
17
|
+
} from "./session-event-log.js";
|
|
12
18
|
|
|
13
19
|
/**
|
|
14
20
|
* The events a terminal settlement commits, with any Turn they were left
|
|
@@ -57,11 +63,7 @@ function settledEventsV1(
|
|
|
57
63
|
};
|
|
58
64
|
}
|
|
59
65
|
|
|
60
|
-
export interface RunTerminalStorage {
|
|
61
|
-
get<T>(key: string): Promise<T | undefined>;
|
|
62
|
-
put(entries: Record<string, unknown>): Promise<void>;
|
|
63
|
-
delete(key: string): Promise<boolean>;
|
|
64
|
-
}
|
|
66
|
+
export interface RunTerminalStorage extends SessionEventLogStorage {}
|
|
65
67
|
|
|
66
68
|
export interface RunTerminalKeys {
|
|
67
69
|
run: string;
|
|
@@ -70,6 +72,24 @@ export interface RunTerminalKeys {
|
|
|
70
72
|
notificationPrefix: string;
|
|
71
73
|
}
|
|
72
74
|
|
|
75
|
+
async function hydratedRun<Snapshot>(
|
|
76
|
+
codec: StoredRunCodecV1<Snapshot>,
|
|
77
|
+
storage: RunTerminalStorage,
|
|
78
|
+
key: string,
|
|
79
|
+
): Promise<StoredRunV1<Snapshot> | undefined> {
|
|
80
|
+
const stored = codec.optional(await storage.get<unknown>(key));
|
|
81
|
+
if (!stored?.eventRange) return stored;
|
|
82
|
+
const events = await new SessionEventLog(storage).readRange(
|
|
83
|
+
stored.sessionId,
|
|
84
|
+
stored.eventRange.startSeq,
|
|
85
|
+
stored.eventRange.endSeq,
|
|
86
|
+
);
|
|
87
|
+
if (events.length !== stored.eventRange.endSeq - stored.eventRange.startSeq) {
|
|
88
|
+
throw new Error(`run "${stored.runId}" has an incomplete event range`);
|
|
89
|
+
}
|
|
90
|
+
return codec.require({ ...stored, events });
|
|
91
|
+
}
|
|
92
|
+
|
|
73
93
|
/**
|
|
74
94
|
* Records a Package writes in the same transaction that settles a Turn. The
|
|
75
95
|
* kernel never reads them: it is handed opaque key/value pairs and a reader
|
|
@@ -126,9 +146,8 @@ export async function supersedeStoredRun<Snapshot>(
|
|
|
126
146
|
events: readonly SessionEvent[],
|
|
127
147
|
packageRecords?: SupersededPackageRecords<Snapshot>,
|
|
128
148
|
): Promise<"superseded"> {
|
|
129
|
-
const
|
|
130
|
-
if (!
|
|
131
|
-
const run = codec.require(stored);
|
|
149
|
+
const run = await hydratedRun(codec, storage, keys.run);
|
|
150
|
+
if (!run) throw new Error(`run "${runId}" was not accepted`);
|
|
132
151
|
if (!run.supersededAt) {
|
|
133
152
|
throw new Error(`run "${runId}" has no durable supersede intent`);
|
|
134
153
|
}
|
|
@@ -141,7 +160,10 @@ export async function supersedeStoredRun<Snapshot>(
|
|
|
141
160
|
const queued = settled.phase === "queued";
|
|
142
161
|
const superseded = codec.require({
|
|
143
162
|
...settled,
|
|
144
|
-
|
|
163
|
+
...storedRunEventFieldsV2(
|
|
164
|
+
run.previousEventCount,
|
|
165
|
+
queued ? [] : decodedEvents,
|
|
166
|
+
),
|
|
145
167
|
status: "superseded",
|
|
146
168
|
phase:
|
|
147
169
|
settled.phase === "reconciliation-required"
|
|
@@ -151,12 +173,7 @@ export async function supersedeStoredRun<Snapshot>(
|
|
|
151
173
|
: settled.phase,
|
|
152
174
|
} satisfies StoredRunV1<Snapshot>);
|
|
153
175
|
const records: Record<string, unknown> = {
|
|
154
|
-
[keys.run]: structuredClone(superseded),
|
|
155
|
-
...(queued
|
|
156
|
-
? {}
|
|
157
|
-
: {
|
|
158
|
-
[keys.latestEvents]: structuredClone(settledEvents.latestEvents),
|
|
159
|
-
}),
|
|
176
|
+
[keys.run]: structuredClone(storedRunRecordV2(superseded)),
|
|
160
177
|
};
|
|
161
178
|
if (packageRecords && !queued) {
|
|
162
179
|
const contributed = await packageRecords({
|
|
@@ -168,6 +185,12 @@ export async function supersedeStoredRun<Snapshot>(
|
|
|
168
185
|
records[key] = structuredClone(value);
|
|
169
186
|
}
|
|
170
187
|
}
|
|
188
|
+
if (!queued) {
|
|
189
|
+
await new SessionEventLog(storage).rewrite(
|
|
190
|
+
run.sessionId,
|
|
191
|
+
settledEvents.latestEvents,
|
|
192
|
+
);
|
|
193
|
+
}
|
|
171
194
|
await storage.put(records);
|
|
172
195
|
if ((await storage.get<string>(keys.activeRun)) === runId) {
|
|
173
196
|
await storage.delete(keys.activeRun);
|
|
@@ -187,9 +210,8 @@ export async function completeStoredRun<Snapshot>(
|
|
|
187
210
|
): Promise<"completed" | "cancelled" | "superseded"> {
|
|
188
211
|
const activeRunId = await storage.get<string>(keys.activeRun);
|
|
189
212
|
if (activeRunId !== runId) throw new Error(`run "${runId}" is not active`);
|
|
190
|
-
const
|
|
191
|
-
if (!
|
|
192
|
-
const run = codec.require(stored);
|
|
213
|
+
const run = await hydratedRun(codec, storage, keys.run);
|
|
214
|
+
if (!run) throw new Error(`run "${runId}" was not accepted`);
|
|
193
215
|
const events = result.events.map(decodeSessionEvent);
|
|
194
216
|
const latestEvents = [...previous, ...events].map(decodeSessionEvent);
|
|
195
217
|
// Stop outranks supersede: the User asked for this Turn to stop, and a
|
|
@@ -212,29 +234,28 @@ export async function completeStoredRun<Snapshot>(
|
|
|
212
234
|
const { responseText: _text, failure: _failure, ...settled } = run;
|
|
213
235
|
const cancelled = codec.require({
|
|
214
236
|
...settled,
|
|
215
|
-
events,
|
|
237
|
+
...storedRunEventFieldsV2(run.previousEventCount, events),
|
|
216
238
|
status: "cancelled",
|
|
217
239
|
phase:
|
|
218
240
|
settled.phase === "reconciliation-required"
|
|
219
241
|
? "executing"
|
|
220
242
|
: settled.phase,
|
|
221
243
|
} satisfies StoredRunV1<Snapshot>);
|
|
244
|
+
await new SessionEventLog(storage).rewrite(run.sessionId, latestEvents);
|
|
222
245
|
await storage.put({
|
|
223
|
-
[keys.run]: structuredClone(cancelled),
|
|
224
|
-
[keys.latestEvents]: structuredClone(latestEvents),
|
|
246
|
+
[keys.run]: structuredClone(storedRunRecordV2(cancelled)),
|
|
225
247
|
});
|
|
226
248
|
await storage.delete(keys.activeRun);
|
|
227
249
|
return "cancelled";
|
|
228
250
|
}
|
|
229
251
|
const completed = codec.require({
|
|
230
252
|
...run,
|
|
231
|
-
events,
|
|
253
|
+
...storedRunEventFieldsV2(run.previousEventCount, events),
|
|
232
254
|
status: "completed",
|
|
233
255
|
responseText: result.text,
|
|
234
256
|
} satisfies StoredRunV1<Snapshot>);
|
|
235
257
|
const records: Record<string, unknown> = {
|
|
236
|
-
[keys.run]: structuredClone(completed),
|
|
237
|
-
[keys.latestEvents]: structuredClone(latestEvents),
|
|
258
|
+
[keys.run]: structuredClone(storedRunRecordV2(completed)),
|
|
238
259
|
};
|
|
239
260
|
if (result.notification) {
|
|
240
261
|
records[`${keys.notificationPrefix}${result.notification.notificationId}`] =
|
|
@@ -250,6 +271,7 @@ export async function completeStoredRun<Snapshot>(
|
|
|
250
271
|
records[key] = structuredClone(value);
|
|
251
272
|
}
|
|
252
273
|
}
|
|
274
|
+
await new SessionEventLog(storage).rewrite(run.sessionId, latestEvents);
|
|
253
275
|
await storage.put(records);
|
|
254
276
|
await storage.delete(keys.activeRun);
|
|
255
277
|
return "completed";
|
|
@@ -267,9 +289,8 @@ export async function cancelStoredRun<Snapshot>(
|
|
|
267
289
|
previous: readonly SessionEvent[],
|
|
268
290
|
events: readonly SessionEvent[],
|
|
269
291
|
): Promise<"cancelled" | "preserved-completion" | "missing"> {
|
|
270
|
-
const
|
|
271
|
-
if (!
|
|
272
|
-
const run = codec.require(stored);
|
|
292
|
+
const run = await hydratedRun(codec, storage, keys.run);
|
|
293
|
+
if (!run) return "missing";
|
|
273
294
|
if (run.status === "completed") return "preserved-completion";
|
|
274
295
|
if (!run.stopRequestedAt) {
|
|
275
296
|
throw new Error(`run "${runId}" has no durable stop intent`);
|
|
@@ -280,14 +301,14 @@ export async function cancelStoredRun<Snapshot>(
|
|
|
280
301
|
const { responseText: _text, failure: _failure, ...settled } = run;
|
|
281
302
|
const cancelled = codec.require({
|
|
282
303
|
...settled,
|
|
283
|
-
|
|
304
|
+
...storedRunEventFieldsV2(run.previousEventCount, decodedEvents),
|
|
284
305
|
status: "cancelled",
|
|
285
306
|
phase:
|
|
286
307
|
settled.phase === "reconciliation-required" ? "executing" : settled.phase,
|
|
287
308
|
} satisfies StoredRunV1<Snapshot>);
|
|
309
|
+
await new SessionEventLog(storage).rewrite(run.sessionId, latestEvents);
|
|
288
310
|
await storage.put({
|
|
289
|
-
[keys.run]: structuredClone(cancelled),
|
|
290
|
-
[keys.latestEvents]: structuredClone(latestEvents),
|
|
311
|
+
[keys.run]: structuredClone(storedRunRecordV2(cancelled)),
|
|
291
312
|
});
|
|
292
313
|
if ((await storage.get<string>(keys.activeRun)) === runId) {
|
|
293
314
|
await storage.delete(keys.activeRun);
|
|
@@ -307,9 +328,8 @@ export async function failStoredRun<Snapshot>(
|
|
|
307
328
|
): Promise<
|
|
308
329
|
"failed" | "cancelled" | "superseded" | "preserved-completion" | "missing"
|
|
309
330
|
> {
|
|
310
|
-
const
|
|
311
|
-
if (!
|
|
312
|
-
const run = codec.require(stored);
|
|
331
|
+
const run = await hydratedRun(codec, storage, keys.run);
|
|
332
|
+
if (!run) return "missing";
|
|
313
333
|
if (run.status === "completed") return "preserved-completion";
|
|
314
334
|
// A stopped run never becomes `failed`: Stop is the durable outcome.
|
|
315
335
|
if (run.stopRequestedAt) {
|
|
@@ -332,15 +352,13 @@ export async function failStoredRun<Snapshot>(
|
|
|
332
352
|
const latestEvents = settledEvents.latestEvents;
|
|
333
353
|
const failed = codec.require({
|
|
334
354
|
...run,
|
|
335
|
-
|
|
355
|
+
...storedRunEventFieldsV2(run.previousEventCount, decodedEvents),
|
|
336
356
|
status: "failed",
|
|
337
357
|
phase: run.phase === "reconciliation-required" ? "executing" : run.phase,
|
|
338
358
|
failure,
|
|
339
359
|
} satisfies StoredRunV1<Snapshot>);
|
|
340
|
-
await storage.
|
|
341
|
-
|
|
342
|
-
[keys.latestEvents]: structuredClone(latestEvents),
|
|
343
|
-
});
|
|
360
|
+
await new SessionEventLog(storage).rewrite(run.sessionId, latestEvents);
|
|
361
|
+
await storage.put({ [keys.run]: structuredClone(storedRunRecordV2(failed)) });
|
|
344
362
|
if ((await storage.get<string>(keys.activeRun)) === runId) {
|
|
345
363
|
await storage.delete(keys.activeRun);
|
|
346
364
|
}
|
|
@@ -358,20 +376,19 @@ export async function requireStoredRunReconciliation<Snapshot>(
|
|
|
358
376
|
): Promise<void> {
|
|
359
377
|
const activeRunId = await storage.get<string>(keys.activeRun);
|
|
360
378
|
if (activeRunId !== runId) throw new Error(`run "${runId}" is not active`);
|
|
361
|
-
const
|
|
362
|
-
if (!
|
|
363
|
-
const run = codec.require(stored);
|
|
379
|
+
const run = await hydratedRun(codec, storage, keys.run);
|
|
380
|
+
if (!run) throw new Error(`run "${runId}" was not accepted`);
|
|
364
381
|
const decodedEvents = events.map(decodeSessionEvent);
|
|
365
382
|
const latestEvents = [...previous, ...decodedEvents].map(decodeSessionEvent);
|
|
366
383
|
const reconciliation = codec.require({
|
|
367
384
|
...run,
|
|
368
|
-
|
|
385
|
+
...storedRunEventFieldsV2(run.previousEventCount, decodedEvents),
|
|
369
386
|
status: "reconciliation-required",
|
|
370
387
|
phase: "reconciliation-required",
|
|
371
388
|
failure,
|
|
372
389
|
} satisfies StoredRunV1<Snapshot>);
|
|
390
|
+
await new SessionEventLog(storage).rewrite(run.sessionId, latestEvents);
|
|
373
391
|
await storage.put({
|
|
374
|
-
[keys.run]: structuredClone(reconciliation),
|
|
375
|
-
[keys.latestEvents]: structuredClone(latestEvents),
|
|
392
|
+
[keys.run]: structuredClone(storedRunRecordV2(reconciliation)),
|
|
376
393
|
});
|
|
377
394
|
}
|