@frockbot/kernel-do 0.3.15 → 0.3.17
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/applets.test.ts +6 -1
- package/src/applets.ts +3 -1
- package/src/authority.ts +319 -159
- 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 +142 -6
- package/src/run-recovery.ts +2 -1
- 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 +12 -0
- package/src/turn-admission.test.ts +54 -9
- package/src/turn-supersede.test.ts +161 -8
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
} from "./conversations.ts";
|
|
17
17
|
import { MemoryStorage } from "./memory-storage.fixture.ts";
|
|
18
18
|
import { createStoredRunCodecV1 } from "./run-records.ts";
|
|
19
|
+
import { SessionEventLog } from "./session-event-log.ts";
|
|
19
20
|
|
|
20
21
|
const codec = createStoredRunCodecV1<undefined>({
|
|
21
22
|
decodeRunId: (value) => value as string,
|
|
@@ -108,20 +109,17 @@ describe("starting a new conversation", () => {
|
|
|
108
109
|
const probe = createAuthority(storage);
|
|
109
110
|
|
|
110
111
|
await probe.authority.run(command("run-1"));
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
);
|
|
112
|
+
const log = new SessionEventLog(storage);
|
|
113
|
+
expect((await log.read("user-1:primary")).length).toBe(1);
|
|
114
114
|
|
|
115
115
|
const started = await probe.authority.startConversation(IDENTITY);
|
|
116
116
|
expect(started.ordinal).toBe(2);
|
|
117
117
|
expect(started.sessionId).toBe("user-1:primary#2");
|
|
118
|
-
// The
|
|
119
|
-
expect(
|
|
118
|
+
// The next Turn starts from a distinct, empty paged log.
|
|
119
|
+
expect(await log.read("user-1:primary#2")).toEqual([]);
|
|
120
120
|
// The conversation just ended is still on disk, Turn for Turn.
|
|
121
|
-
expect(
|
|
122
|
-
|
|
123
|
-
.length,
|
|
124
|
-
).toBe(1);
|
|
121
|
+
expect((await probe.authority.readRun("run-1"))?.events.length).toBe(1);
|
|
122
|
+
expect(storage.values.get("run:run-1")).not.toHaveProperty("events");
|
|
125
123
|
|
|
126
124
|
await probe.authority.run(command("run-2"));
|
|
127
125
|
// The new Turn ran in the new Session, and saw none of the old history.
|
package/src/index.ts
CHANGED
|
@@ -7,6 +7,7 @@ export * from "./run-records.js";
|
|
|
7
7
|
export * from "./run-liveness.js";
|
|
8
8
|
export * from "./run-recovery.js";
|
|
9
9
|
export * from "./run-terminal.js";
|
|
10
|
+
export * from "./session-event-log.js";
|
|
10
11
|
export * from "./storage-keys.js";
|
|
11
12
|
export * from "./turn-errors.js";
|
|
12
13
|
export * from "./workspace-generations.js";
|
|
@@ -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
|
@@ -38,9 +38,9 @@ export type StoredRunStatus =
|
|
|
38
38
|
* decision to interrupt is made in the admission transaction and has to
|
|
39
39
|
* survive eviction alongside the run it interrupted.
|
|
40
40
|
*/
|
|
41
|
-
export type RunLaneV1 = "user" | "background";
|
|
41
|
+
export type RunLaneV1 = "user" | "agent" | "background";
|
|
42
42
|
|
|
43
|
-
const RUN_LANES_V1: readonly RunLaneV1[] = ["user", "background"];
|
|
43
|
+
const RUN_LANES_V1: readonly RunLaneV1[] = ["user", "agent", "background"];
|
|
44
44
|
|
|
45
45
|
/**
|
|
46
46
|
* The lane a turn type belongs to when its record names none. Chat is the
|
|
@@ -48,7 +48,8 @@ const RUN_LANES_V1: readonly RunLaneV1[] = ["user", "background"];
|
|
|
48
48
|
* Bot started for itself.
|
|
49
49
|
*/
|
|
50
50
|
export function defaultRunLaneV1(turnType: TurnTypeV1): RunLaneV1 {
|
|
51
|
-
|
|
51
|
+
if (turnType === "chat") return "user";
|
|
52
|
+
return turnType === "agent" ? "agent" : "background";
|
|
52
53
|
}
|
|
53
54
|
|
|
54
55
|
export type StoredEffectAdmissionOutcome = "admitted" | "fenced";
|
|
@@ -85,9 +86,26 @@ export interface StoredRunSubagentOriginV1 {
|
|
|
85
86
|
parentRunId: string;
|
|
86
87
|
}
|
|
87
88
|
|
|
89
|
+
/** A same-User Bot asking this Bot a question. */
|
|
90
|
+
export interface StoredRunBotOriginV1 {
|
|
91
|
+
kind: "bot";
|
|
92
|
+
fromBotId: string;
|
|
93
|
+
fromBotName: string;
|
|
94
|
+
messageId: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The Voice Package asking this Bot on behalf of its User. */
|
|
98
|
+
export interface StoredRunVoiceOriginV1 {
|
|
99
|
+
kind: "voice";
|
|
100
|
+
messageId: string;
|
|
101
|
+
}
|
|
102
|
+
|
|
88
103
|
/** What produced a Turn, when it was not a person speaking to the Bot. */
|
|
89
104
|
export type StoredRunOriginV1 =
|
|
90
|
-
|
|
105
|
+
| StoredRunRoutineOriginV1
|
|
106
|
+
| StoredRunSubagentOriginV1
|
|
107
|
+
| StoredRunBotOriginV1
|
|
108
|
+
| StoredRunVoiceOriginV1;
|
|
91
109
|
|
|
92
110
|
const STORED_RUN_ORIGIN_TRIGGERS: readonly StoredRunTriggerV1[] = [
|
|
93
111
|
"cron",
|
|
@@ -133,6 +151,12 @@ export interface StoredRunV1<Snapshot = unknown> {
|
|
|
133
151
|
acceptedAt: string;
|
|
134
152
|
input: string;
|
|
135
153
|
events: SessionEvent[];
|
|
154
|
+
/**
|
|
155
|
+
* Inclusive/exclusive coordinates of this Turn in the authoritative Session
|
|
156
|
+
* log. New durable records carry this instead of embedding `events`; the
|
|
157
|
+
* in-memory record is hydrated through the Session log accessor.
|
|
158
|
+
*/
|
|
159
|
+
eventRange?: StoredRunEventRangeV1;
|
|
136
160
|
effectAdmissions: StoredEffectAdmission[];
|
|
137
161
|
status: StoredRunStatus;
|
|
138
162
|
responseText?: string;
|
|
@@ -159,6 +183,44 @@ export interface StoredRunV1<Snapshot = unknown> {
|
|
|
159
183
|
directTool?: DirectToolCommandV1;
|
|
160
184
|
}
|
|
161
185
|
|
|
186
|
+
export interface StoredRunEventRangeV1 {
|
|
187
|
+
startSeq: number;
|
|
188
|
+
endSeq: number;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Keeps a hydrated journal and its durable coordinates in lockstep. */
|
|
192
|
+
export function storedRunEventFieldsV2(
|
|
193
|
+
previousEventCount: number,
|
|
194
|
+
events: SessionEvent[],
|
|
195
|
+
): { events: SessionEvent[]; eventRange: StoredRunEventRangeV1 } {
|
|
196
|
+
return {
|
|
197
|
+
events,
|
|
198
|
+
eventRange: {
|
|
199
|
+
startSeq: previousEventCount,
|
|
200
|
+
endSeq: previousEventCount + events.length,
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The compact durable run shape. Keeping this encoder beside the strict
|
|
207
|
+
* decoder makes it difficult for a metadata update to accidentally put a
|
|
208
|
+
* hydrated multi-megabyte journal back into one SQLite value.
|
|
209
|
+
*/
|
|
210
|
+
export function storedRunRecordV2<Snapshot>(
|
|
211
|
+
run: StoredRunV1<Snapshot>,
|
|
212
|
+
): Omit<StoredRunV1<Snapshot>, "events"> {
|
|
213
|
+
const { events, ...record } = run;
|
|
214
|
+
const eventRange =
|
|
215
|
+
events.length === 0 && run.eventRange
|
|
216
|
+
? run.eventRange
|
|
217
|
+
: {
|
|
218
|
+
startSeq: run.previousEventCount,
|
|
219
|
+
endSeq: run.previousEventCount + events.length,
|
|
220
|
+
};
|
|
221
|
+
return { ...record, eventRange };
|
|
222
|
+
}
|
|
223
|
+
|
|
162
224
|
export interface DirectToolCommandV1 {
|
|
163
225
|
generationId: string;
|
|
164
226
|
packageId: string;
|
|
@@ -246,7 +308,6 @@ const STORED_RUN_REQUIRED_KEYS = [
|
|
|
246
308
|
"sessionId",
|
|
247
309
|
"acceptedAt",
|
|
248
310
|
"input",
|
|
249
|
-
"events",
|
|
250
311
|
"effectAdmissions",
|
|
251
312
|
"status",
|
|
252
313
|
"phase",
|
|
@@ -255,6 +316,8 @@ const STORED_RUN_REQUIRED_KEYS = [
|
|
|
255
316
|
"previousEventCount",
|
|
256
317
|
] as const;
|
|
257
318
|
const STORED_RUN_OPTIONAL_KEYS = [
|
|
319
|
+
"events",
|
|
320
|
+
"eventRange",
|
|
258
321
|
"responseText",
|
|
259
322
|
"failure",
|
|
260
323
|
"stopRequestedAt",
|
|
@@ -391,6 +454,33 @@ function decodeStoredRunOrigin(
|
|
|
391
454
|
parentRunId: candidate.parentRunId,
|
|
392
455
|
};
|
|
393
456
|
}
|
|
457
|
+
if (candidate.kind === "bot") {
|
|
458
|
+
requireExactOriginFields(
|
|
459
|
+
candidate,
|
|
460
|
+
["kind", "fromBotId", "fromBotName", "messageId"],
|
|
461
|
+
runId,
|
|
462
|
+
);
|
|
463
|
+
if (
|
|
464
|
+
!boundedString(candidate.fromBotId, 128) ||
|
|
465
|
+
!boundedString(candidate.fromBotName, 100) ||
|
|
466
|
+
!boundedString(candidate.messageId, 256)
|
|
467
|
+
) {
|
|
468
|
+
throw new Error(`run "${runId}" has an invalid admission origin`);
|
|
469
|
+
}
|
|
470
|
+
return {
|
|
471
|
+
kind: "bot",
|
|
472
|
+
fromBotId: candidate.fromBotId,
|
|
473
|
+
fromBotName: candidate.fromBotName,
|
|
474
|
+
messageId: candidate.messageId,
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
if (candidate.kind === "voice") {
|
|
478
|
+
requireExactOriginFields(candidate, ["kind", "messageId"], runId);
|
|
479
|
+
if (!boundedString(candidate.messageId, 256)) {
|
|
480
|
+
throw new Error(`run "${runId}" has an invalid admission origin id`);
|
|
481
|
+
}
|
|
482
|
+
return { kind: "voice", messageId: candidate.messageId };
|
|
483
|
+
}
|
|
394
484
|
if (candidate.kind !== "routine") {
|
|
395
485
|
throw new Error(`run "${runId}" has an invalid admission origin kind`);
|
|
396
486
|
}
|
|
@@ -483,6 +573,32 @@ function decodeStoredRunEvents(value: unknown): SessionEvent[] {
|
|
|
483
573
|
return value.map(decodeSessionEvent);
|
|
484
574
|
}
|
|
485
575
|
|
|
576
|
+
function decodeStoredRunEventRange(
|
|
577
|
+
value: unknown,
|
|
578
|
+
runId: string,
|
|
579
|
+
): StoredRunEventRangeV1 {
|
|
580
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
581
|
+
throw new Error(`run "${runId}" has an invalid event range`);
|
|
582
|
+
}
|
|
583
|
+
const candidate = value as Record<PropertyKey, unknown>;
|
|
584
|
+
if (
|
|
585
|
+
Reflect.ownKeys(candidate).length !== 2 ||
|
|
586
|
+
Object.keys(candidate).length !== 2 ||
|
|
587
|
+
!Object.hasOwn(candidate, "startSeq") ||
|
|
588
|
+
!Object.hasOwn(candidate, "endSeq") ||
|
|
589
|
+
!Number.isSafeInteger(candidate.startSeq) ||
|
|
590
|
+
!Number.isSafeInteger(candidate.endSeq) ||
|
|
591
|
+
(candidate.startSeq as number) < 0 ||
|
|
592
|
+
(candidate.endSeq as number) < (candidate.startSeq as number)
|
|
593
|
+
) {
|
|
594
|
+
throw new Error(`run "${runId}" has an invalid event range`);
|
|
595
|
+
}
|
|
596
|
+
return {
|
|
597
|
+
startSeq: candidate.startSeq as number,
|
|
598
|
+
endSeq: candidate.endSeq as number,
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
|
|
486
602
|
const STORED_EFFECT_ADMISSIONS_MAX = 256;
|
|
487
603
|
const STORED_EFFECT_ID_MAX_BYTES = 512;
|
|
488
604
|
|
|
@@ -582,7 +698,17 @@ function requireStoredRunRecordV1<Snapshot>(
|
|
|
582
698
|
if (!boundedString(candidate.input, 32_000)) {
|
|
583
699
|
throw new Error(`run "${runId}" has no valid input`);
|
|
584
700
|
}
|
|
585
|
-
|
|
701
|
+
if (candidate.events === undefined && candidate.eventRange === undefined) {
|
|
702
|
+
throw new Error(`run "${runId}" has no event journal reference`);
|
|
703
|
+
}
|
|
704
|
+
const events =
|
|
705
|
+
candidate.events === undefined
|
|
706
|
+
? []
|
|
707
|
+
: decodeStoredRunEvents(candidate.events);
|
|
708
|
+
const eventRange =
|
|
709
|
+
candidate.eventRange === undefined
|
|
710
|
+
? undefined
|
|
711
|
+
: decodeStoredRunEventRange(candidate.eventRange, runId);
|
|
586
712
|
const effectAdmissions = decodeStoredEffectAdmissions(
|
|
587
713
|
candidate.effectAdmissions,
|
|
588
714
|
);
|
|
@@ -605,6 +731,15 @@ function requireStoredRunRecordV1<Snapshot>(
|
|
|
605
731
|
) {
|
|
606
732
|
throw new Error(`run "${runId}" has no valid previous event count`);
|
|
607
733
|
}
|
|
734
|
+
if (
|
|
735
|
+
eventRange &&
|
|
736
|
+
(eventRange.startSeq !== candidate.previousEventCount ||
|
|
737
|
+
(events.length > 0 &&
|
|
738
|
+
(events[0]?.seq !== eventRange.startSeq ||
|
|
739
|
+
events.at(-1)!.seq + 1 !== eventRange.endSeq)))
|
|
740
|
+
) {
|
|
741
|
+
throw new Error(`run "${runId}" has an inconsistent event range`);
|
|
742
|
+
}
|
|
608
743
|
const configurationSnapshot = options.decodeConfigurationSnapshot(
|
|
609
744
|
candidate.configurationSnapshot,
|
|
610
745
|
);
|
|
@@ -679,6 +814,7 @@ function requireStoredRunRecordV1<Snapshot>(
|
|
|
679
814
|
acceptedAt: candidate.acceptedAt,
|
|
680
815
|
input: candidate.input,
|
|
681
816
|
events,
|
|
817
|
+
...(eventRange ? { eventRange } : {}),
|
|
682
818
|
effectAdmissions,
|
|
683
819
|
status,
|
|
684
820
|
phase,
|
package/src/run-recovery.ts
CHANGED
|
@@ -82,7 +82,8 @@ export function latestModelRequestJournalState(
|
|
|
82
82
|
) {
|
|
83
83
|
state = { status: "no-effect", request: state.request, outcome: event };
|
|
84
84
|
} else if (
|
|
85
|
-
event.type === "assistant/message"
|
|
85
|
+
(event.type === "assistant/message" ||
|
|
86
|
+
event.type === "model/response-failed") &&
|
|
86
87
|
state.status !== "none" &&
|
|
87
88
|
event.requestId === state.request.request.requestId
|
|
88
89
|
) {
|
|
@@ -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
|
}
|