@frockbot/kernel-do 0.0.0 → 0.1.1

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.
@@ -0,0 +1,163 @@
1
+ import {
2
+ Session,
3
+ type SessionEvent,
4
+ toolCallOccurrences,
5
+ validateSettledToolOccurrenceJournal,
6
+ validateToolOccurrenceJournal,
7
+ turnFailureMessage,
8
+ } from "@frockbot/kernel-contracts";
9
+ import { BotTurnExecutionError } from "./turn-errors.js";
10
+ import type { StoredRunCodecV1, StoredRunV1 } from "./run-records.js";
11
+
12
+ export type BotRunRecoveryPlan =
13
+ | { kind: "complete"; responseText: string }
14
+ | { kind: "fail"; failure: string }
15
+ | { kind: "restart"; previous: SessionEvent[] }
16
+ | { kind: "resume" }
17
+ | { kind: "reconcile"; repairs: SessionEvent[] };
18
+
19
+ export type ModelRequestJournalState =
20
+ | { status: "none" }
21
+ | {
22
+ status: "unresolved" | "completed";
23
+ request: Extract<SessionEvent, { type: "model/request" }>;
24
+ }
25
+ | {
26
+ status: "no-effect";
27
+ request: Extract<SessionEvent, { type: "model/request" }>;
28
+ outcome: Extract<SessionEvent, { type: "model/effect-not-started" }>;
29
+ };
30
+
31
+ function invalidToolJournal(error: unknown): BotRunRecoveryPlan {
32
+ return {
33
+ kind: "fail",
34
+ failure: `Invalid durable tool journal: ${
35
+ error instanceof Error ? error.message : "unknown structural error"
36
+ }`,
37
+ };
38
+ }
39
+
40
+ export function latestModelRequestJournalState(
41
+ events: readonly SessionEvent[],
42
+ ): ModelRequestJournalState {
43
+ let state: ModelRequestJournalState = { status: "none" };
44
+ for (const event of events) {
45
+ if (event.type === "model/request") {
46
+ state = { status: "unresolved", request: event };
47
+ } else if (
48
+ event.type === "model/effect-not-started" &&
49
+ state.status === "unresolved" &&
50
+ event.requestId === state.request.request.requestId
51
+ ) {
52
+ state = { status: "no-effect", request: state.request, outcome: event };
53
+ } else if (
54
+ event.type === "assistant/message" &&
55
+ state.status !== "none" &&
56
+ event.requestId === state.request.request.requestId
57
+ ) {
58
+ state = { status: "completed", request: state.request };
59
+ }
60
+ }
61
+ return state;
62
+ }
63
+
64
+ export function planBotRunRecovery<Snapshot>(
65
+ run: StoredRunV1<Snapshot>,
66
+ latest: readonly SessionEvent[],
67
+ codec: StoredRunCodecV1<Snapshot>,
68
+ ): BotRunRecoveryPlan {
69
+ codec.require(run);
70
+ let toolJournal: ReturnType<typeof validateToolOccurrenceJournal>;
71
+ try {
72
+ toolJournal = validateToolOccurrenceJournal(run.events);
73
+ } catch (error) {
74
+ return invalidToolJournal(error);
75
+ }
76
+ const terminalTurn = run.events.findLast(
77
+ (event) => event.type === "turn/end",
78
+ );
79
+ const lastAssistant = run.events.findLast(
80
+ (event) => event.type === "assistant/message",
81
+ );
82
+ if (terminalTurn?.type === "turn/end") {
83
+ try {
84
+ validateSettledToolOccurrenceJournal(run.events);
85
+ } catch (error) {
86
+ return invalidToolJournal(error);
87
+ }
88
+ if (terminalTurn.outcome !== "completed") {
89
+ return {
90
+ kind: "fail",
91
+ failure: turnFailureMessage(terminalTurn.outcome, terminalTurn.reason),
92
+ };
93
+ }
94
+ return {
95
+ kind: "complete",
96
+ responseText:
97
+ lastAssistant?.type === "assistant/message" ? lastAssistant.text : "",
98
+ };
99
+ }
100
+ const modelState = latestModelRequestJournalState(run.events);
101
+ if (modelState.status === "no-effect") {
102
+ try {
103
+ validateSettledToolOccurrenceJournal(run.events);
104
+ } catch (error) {
105
+ return invalidToolJournal(error);
106
+ }
107
+ return { kind: "resume" };
108
+ }
109
+ if (modelState.status === "completed") {
110
+ const resumableOccurrences = new Set(
111
+ lastAssistant?.type === "assistant/message"
112
+ ? toolCallOccurrences(
113
+ lastAssistant.turn,
114
+ lastAssistant.step,
115
+ lastAssistant.toolCalls,
116
+ ).map((occurrence) => occurrence.occurrenceId)
117
+ : [],
118
+ );
119
+ const skippedOccurrence = [...toolJournal.values()].find(
120
+ (entry) =>
121
+ !entry.intent &&
122
+ !entry.result &&
123
+ !resumableOccurrences.has(entry.occurrence.occurrenceId),
124
+ );
125
+ if (skippedOccurrence) {
126
+ return invalidToolJournal(
127
+ new Error(
128
+ `tool occurrence "${skippedOccurrence.occurrence.occurrenceId}" was skipped before recovery`,
129
+ ),
130
+ );
131
+ }
132
+ const unresolvedIntent = [...toolJournal.values()].some(
133
+ (entry) => entry.intent && !entry.result,
134
+ );
135
+ if (!unresolvedIntent) return { kind: "resume" };
136
+ }
137
+ const hasExternalIntent = run.events.some(
138
+ (event) => event.type === "model/request" || event.type === "tool/call",
139
+ );
140
+ if (!hasExternalIntent) {
141
+ if (toolJournal.size > 0) {
142
+ return invalidToolJournal(
143
+ new Error("assistant tool occurrences have no durable model request"),
144
+ );
145
+ }
146
+ return {
147
+ kind: "restart",
148
+ previous: [...latest.slice(0, run.previousEventCount)],
149
+ };
150
+ }
151
+ const session = new Session(run.sessionId, () => {}, latest);
152
+ return { kind: "reconcile", repairs: session.reconcileForResume() };
153
+ }
154
+
155
+ export function eventsForFailedRun(
156
+ durableRun: { events: SessionEvent[] } | undefined,
157
+ error: unknown,
158
+ ): SessionEvent[] {
159
+ if (durableRun) return structuredClone(durableRun.events);
160
+ return error instanceof BotTurnExecutionError
161
+ ? structuredClone(error.events)
162
+ : [];
163
+ }
@@ -0,0 +1,220 @@
1
+ import {
2
+ decodeSessionEvent,
3
+ type SessionEvent,
4
+ } from "@frockbot/kernel-contracts";
5
+ import type {
6
+ BotTurnCompletion,
7
+ StoredRunCodecV1,
8
+ StoredRunV1,
9
+ } from "./run-records.js";
10
+
11
+ export interface RunTerminalStorage {
12
+ get<T>(key: string): Promise<T | undefined>;
13
+ put(entries: Record<string, unknown>): Promise<void>;
14
+ delete(key: string): Promise<boolean>;
15
+ }
16
+
17
+ export interface RunTerminalKeys {
18
+ run: string;
19
+ activeRun: string;
20
+ latestEvents: string;
21
+ notificationPrefix: string;
22
+ }
23
+
24
+ /**
25
+ * Records a Package writes in the same transaction that settles a Turn. The
26
+ * kernel never reads them: it is handed opaque key/value pairs and a reader
27
+ * bound to the settling transaction, exactly as it is handed notification
28
+ * content, so a Package can make a durable decision atomic with the settlement
29
+ * without the kernel holding the policy that produced it.
30
+ */
31
+ export type TerminalPackageRecords<Snapshot> = (input: {
32
+ run: StoredRunV1<Snapshot>;
33
+ read<T>(key: string): Promise<T | undefined>;
34
+ }) => Promise<Record<string, unknown>>;
35
+
36
+ /** The kernel owns these; a Package record may never land on one. */
37
+ function assertPackageRecordKeys(
38
+ records: Record<string, unknown>,
39
+ keys: RunTerminalKeys,
40
+ ): void {
41
+ for (const key of Object.keys(records)) {
42
+ if (
43
+ key === keys.run ||
44
+ key === keys.activeRun ||
45
+ key === keys.latestEvents ||
46
+ key.startsWith(keys.notificationPrefix)
47
+ ) {
48
+ throw new Error(`terminal record "${key}" is a kernel key`);
49
+ }
50
+ }
51
+ }
52
+
53
+ export async function completeStoredRun<Snapshot>(
54
+ codec: StoredRunCodecV1<Snapshot>,
55
+ storage: RunTerminalStorage,
56
+ keys: RunTerminalKeys,
57
+ runId: string,
58
+ previous: readonly SessionEvent[],
59
+ result: BotTurnCompletion,
60
+ packageRecords?: TerminalPackageRecords<Snapshot>,
61
+ ): Promise<"completed" | "cancelled"> {
62
+ const activeRunId = await storage.get<string>(keys.activeRun);
63
+ if (activeRunId !== runId) throw new Error(`run "${runId}" is not active`);
64
+ const stored = await storage.get<StoredRunV1<Snapshot>>(keys.run);
65
+ if (!stored) throw new Error(`run "${runId}" was not accepted`);
66
+ const run = codec.require(stored);
67
+ const events = result.events.map(decodeSessionEvent);
68
+ const latestEvents = [...previous, ...events].map(decodeSessionEvent);
69
+ // Durable Stop intent recorded before this settlement wins: the run becomes
70
+ // terminal `cancelled` with no response text, failure, or notification.
71
+ if (run.stopRequestedAt) {
72
+ const { responseText: _text, failure: _failure, ...settled } = run;
73
+ const cancelled = codec.require({
74
+ ...settled,
75
+ events,
76
+ status: "cancelled",
77
+ phase:
78
+ settled.phase === "reconciliation-required"
79
+ ? "executing"
80
+ : settled.phase,
81
+ } satisfies StoredRunV1<Snapshot>);
82
+ await storage.put({
83
+ [keys.run]: structuredClone(cancelled),
84
+ [keys.latestEvents]: structuredClone(latestEvents),
85
+ });
86
+ await storage.delete(keys.activeRun);
87
+ return "cancelled";
88
+ }
89
+ const completed = codec.require({
90
+ ...run,
91
+ events,
92
+ status: "completed",
93
+ responseText: result.text,
94
+ } satisfies StoredRunV1<Snapshot>);
95
+ const records: Record<string, unknown> = {
96
+ [keys.run]: structuredClone(completed),
97
+ [keys.latestEvents]: structuredClone(latestEvents),
98
+ };
99
+ if (result.notification) {
100
+ records[`${keys.notificationPrefix}${result.notification.notificationId}`] =
101
+ structuredClone(result.notification);
102
+ }
103
+ if (packageRecords) {
104
+ const contributed = await packageRecords({
105
+ run: completed,
106
+ read: <T>(key: string) => storage.get<T>(key),
107
+ });
108
+ assertPackageRecordKeys(contributed, keys);
109
+ for (const [key, value] of Object.entries(contributed)) {
110
+ records[key] = structuredClone(value);
111
+ }
112
+ }
113
+ await storage.put(records);
114
+ await storage.delete(keys.activeRun);
115
+ return "completed";
116
+ }
117
+
118
+ /**
119
+ * Settles a stopped run as terminal `cancelled` and clears its active marker.
120
+ * A cancelled run produces no response text, no failure, and no notification.
121
+ */
122
+ export async function cancelStoredRun<Snapshot>(
123
+ codec: StoredRunCodecV1<Snapshot>,
124
+ storage: RunTerminalStorage,
125
+ keys: RunTerminalKeys,
126
+ runId: string,
127
+ previous: readonly SessionEvent[],
128
+ events: readonly SessionEvent[],
129
+ ): Promise<"cancelled" | "preserved-completion" | "missing"> {
130
+ const stored = await storage.get<StoredRunV1<Snapshot>>(keys.run);
131
+ if (!stored) return "missing";
132
+ const run = codec.require(stored);
133
+ if (run.status === "completed") return "preserved-completion";
134
+ if (!run.stopRequestedAt) {
135
+ throw new Error(`run "${runId}" has no durable stop intent`);
136
+ }
137
+ const decodedEvents = events.map(decodeSessionEvent);
138
+ const latestEvents = [...previous, ...decodedEvents].map(decodeSessionEvent);
139
+ const { responseText: _text, failure: _failure, ...settled } = run;
140
+ const cancelled = codec.require({
141
+ ...settled,
142
+ events: decodedEvents,
143
+ status: "cancelled",
144
+ phase:
145
+ settled.phase === "reconciliation-required" ? "executing" : settled.phase,
146
+ } satisfies StoredRunV1<Snapshot>);
147
+ await storage.put({
148
+ [keys.run]: structuredClone(cancelled),
149
+ [keys.latestEvents]: structuredClone(latestEvents),
150
+ });
151
+ if ((await storage.get<string>(keys.activeRun)) === runId) {
152
+ await storage.delete(keys.activeRun);
153
+ }
154
+ return "cancelled";
155
+ }
156
+
157
+ export async function failStoredRun<Snapshot>(
158
+ codec: StoredRunCodecV1<Snapshot>,
159
+ storage: RunTerminalStorage,
160
+ keys: RunTerminalKeys,
161
+ runId: string,
162
+ previous: readonly SessionEvent[],
163
+ events: readonly SessionEvent[],
164
+ failure: string,
165
+ ): Promise<"failed" | "cancelled" | "preserved-completion" | "missing"> {
166
+ const stored = await storage.get<StoredRunV1<Snapshot>>(keys.run);
167
+ if (!stored) return "missing";
168
+ const run = codec.require(stored);
169
+ if (run.status === "completed") return "preserved-completion";
170
+ // A stopped run never becomes `failed`: Stop is the durable outcome.
171
+ if (run.stopRequestedAt) {
172
+ return cancelStoredRun(codec, storage, keys, runId, previous, events);
173
+ }
174
+ const decodedEvents = events.map(decodeSessionEvent);
175
+ const latestEvents = [...previous, ...decodedEvents].map(decodeSessionEvent);
176
+ const failed = codec.require({
177
+ ...run,
178
+ events: decodedEvents,
179
+ status: "failed",
180
+ phase: run.phase === "reconciliation-required" ? "executing" : run.phase,
181
+ failure,
182
+ } satisfies StoredRunV1<Snapshot>);
183
+ await storage.put({
184
+ [keys.run]: structuredClone(failed),
185
+ [keys.latestEvents]: structuredClone(latestEvents),
186
+ });
187
+ if ((await storage.get<string>(keys.activeRun)) === runId) {
188
+ await storage.delete(keys.activeRun);
189
+ }
190
+ return "failed";
191
+ }
192
+
193
+ export async function requireStoredRunReconciliation<Snapshot>(
194
+ codec: StoredRunCodecV1<Snapshot>,
195
+ storage: RunTerminalStorage,
196
+ keys: RunTerminalKeys,
197
+ runId: string,
198
+ previous: readonly SessionEvent[],
199
+ events: readonly SessionEvent[],
200
+ failure: string,
201
+ ): Promise<void> {
202
+ const activeRunId = await storage.get<string>(keys.activeRun);
203
+ if (activeRunId !== runId) throw new Error(`run "${runId}" is not active`);
204
+ const stored = await storage.get<StoredRunV1<Snapshot>>(keys.run);
205
+ if (!stored) throw new Error(`run "${runId}" was not accepted`);
206
+ const run = codec.require(stored);
207
+ const decodedEvents = events.map(decodeSessionEvent);
208
+ const latestEvents = [...previous, ...decodedEvents].map(decodeSessionEvent);
209
+ const reconciliation = codec.require({
210
+ ...run,
211
+ events: decodedEvents,
212
+ status: "reconciliation-required",
213
+ phase: "reconciliation-required",
214
+ failure,
215
+ } satisfies StoredRunV1<Snapshot>);
216
+ await storage.put({
217
+ [keys.run]: structuredClone(reconciliation),
218
+ [keys.latestEvents]: structuredClone(latestEvents),
219
+ });
220
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Durable keys the Bot Durable Object authority owns. Packages that share the
3
+ * object's storage read them through the authority, never by key.
4
+ */
5
+ export const RUN_PREFIX = "run:";
6
+ export const RUN_INDEX_PREFIX = "run-index:";
7
+ export const RUN_ADMISSION_FENCE_PREFIX = "run-admission-fence:";
8
+ export const RUN_ADMISSION_FENCE_INDEX_KEY = "run-admission-fences";
9
+ export const MAX_RUN_ADMISSION_FENCES = 256;
10
+ export const ACTIVE_RUN_KEY = "active-run";
11
+ export const LATEST_EVENTS_KEY = "latest-events";
12
+ export const IDENTITY_KEY = "identity";
13
+ export const NOTIFICATION_PREFIX = "notification:";
14
+ export const COMPOSITION_CURRENT_KEY = "composition:current";
15
+ export const COMPOSITION_GENERATION_PREFIX = "composition:generation:";
16
+ export const COMPOSITION_INDEX_PREFIX = "composition:index:";
17
+ export const COMPOSITION_LAST_KNOWN_GOOD_KEY = "composition:last-known-good";
18
+ export const COMPOSITION_FAILURE_PREFIX = "composition:failure:";
19
+ export const COMPOSITION_FAILURE_COUNT_PREFIX = "composition:failure-count:";
20
+ export const COMPOSITION_QUARANTINE_PREFIX = "composition:quarantine:";
21
+ /** Attempts are zero-padded so the prefix listing is attempt-ordered. */
22
+ export const COMPOSITION_FAILURE_ATTEMPT_DIGITS = 4;
23
+ export const RECOVERY_ALARM_DELAY_MS = 60_000;
24
+ /** The current generation of one durable-root file. */
25
+ export const WORKSPACE_GENERATION_PREFIX = "workspace:generation:";
26
+ /** Preserved losing writes for one durable-root file. */
27
+ export const WORKSPACE_CONFLICT_PREFIX = "workspace:conflict:";
28
+ /** One unsettled durable-root sync push intent, by effect id (ADR 0013). */
29
+ export const WORKSPACE_SYNC_EFFECT_PREFIX = "workspace:sync-effect:";
30
+ /** The monotonic cursor every minted Workspace generation id advances. */
31
+ export const WORKSPACE_GENERATION_CURSOR_KEY = "workspace:generation-cursor";
32
+ /** Longest readable key tail before it is fingerprinted; Durable Object keys are bounded. */
33
+ const WORKSPACE_KEY_TAIL_LIMIT = 900;
34
+
35
+ function fingerprint(value: string): string {
36
+ let hash = 0xcbf29ce484222325n;
37
+ for (const byte of new TextEncoder().encode(value)) {
38
+ hash ^= BigInt(byte);
39
+ hash = BigInt.asUintN(64, hash * 0x100000001b3n);
40
+ }
41
+ return hash.toString(16).padStart(16, "0");
42
+ }
43
+
44
+ /**
45
+ * The key tail identifying one file in one durable root. Readable while it
46
+ * fits — a root key plus a relative path — and fingerprinted past that, so a
47
+ * long path can never push a Durable Object key over its bound.
48
+ */
49
+ export function workspaceFileKeyTail(rootKey: string, path: string): string {
50
+ const tail = `${rootKey}:${path}`;
51
+ if (tail.length <= WORKSPACE_KEY_TAIL_LIMIT) return tail;
52
+ return `${tail.slice(0, WORKSPACE_KEY_TAIL_LIMIT - 20)}#${fingerprint(tail)}`;
53
+ }
54
+
55
+ export function workspaceGenerationKey(rootKey: string, path: string): string {
56
+ return `${WORKSPACE_GENERATION_PREFIX}${workspaceFileKeyTail(rootKey, path)}`;
57
+ }
58
+
59
+ export function workspaceConflictPrefix(rootKey: string, path: string): string {
60
+ return `${WORKSPACE_CONFLICT_PREFIX}${workspaceFileKeyTail(rootKey, path)}:`;
61
+ }
62
+
63
+ export function workspaceConflictKey(
64
+ rootKey: string,
65
+ path: string,
66
+ generationId: string,
67
+ ): string {
68
+ return `${workspaceConflictPrefix(rootKey, path)}${generationId}`;
69
+ }
70
+
71
+ export function runIndexKey(acceptedAt: string, runId: string): string {
72
+ return `${RUN_INDEX_PREFIX}${acceptedAt}:${runId}`;
73
+ }
74
+
75
+ export function compositionGenerationKey(generationId: string): string {
76
+ return `${COMPOSITION_GENERATION_PREFIX}${generationId}`;
77
+ }
78
+
79
+ export function compositionIndexKey(
80
+ createdAt: string,
81
+ generationId: string,
82
+ ): string {
83
+ return `${COMPOSITION_INDEX_PREFIX}${createdAt}:${generationId}`;
84
+ }
85
+
86
+ export function compositionFailurePrefix(generationId: string): string {
87
+ return `${COMPOSITION_FAILURE_PREFIX}${generationId}:`;
88
+ }
89
+
90
+ export function compositionFailureKey(
91
+ generationId: string,
92
+ attempt: number,
93
+ ): string {
94
+ return `${compositionFailurePrefix(generationId)}${String(attempt).padStart(
95
+ COMPOSITION_FAILURE_ATTEMPT_DIGITS,
96
+ "0",
97
+ )}`;
98
+ }
99
+
100
+ export function compositionFailureCountKey(generationId: string): string {
101
+ return `${COMPOSITION_FAILURE_COUNT_PREFIX}${generationId}`;
102
+ }
103
+
104
+ export function compositionQuarantineKey(generationId: string): string {
105
+ return `${COMPOSITION_QUARANTINE_PREFIX}${generationId}`;
106
+ }
107
+
108
+ export function storedRunAdmissionFences(input: unknown): string[] {
109
+ if (input === undefined) return [];
110
+ if (
111
+ !Array.isArray(input) ||
112
+ input.length > MAX_RUN_ADMISSION_FENCES ||
113
+ input.some(
114
+ (runId) =>
115
+ typeof runId !== "string" || runId.length < 1 || runId.length > 128,
116
+ )
117
+ ) {
118
+ throw new Error("Stored run admission fences are invalid");
119
+ }
120
+ return [...new Set(input)];
121
+ }
122
+
123
+ /**
124
+ * The key one sync push intent is recorded under. The effect id is already a
125
+ * bounded digest minted by the sync, so it is used verbatim.
126
+ */
127
+ export function workspaceSyncEffectKey(effectId: string): string {
128
+ return `${WORKSPACE_SYNC_EFFECT_PREFIX}${effectId}`;
129
+ }