@frockbot/kernel-do 0.0.0 → 0.1.0
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 +25 -6
- package/src/authority.ts +989 -0
- package/src/composition-failures.test.ts +405 -0
- package/src/composition-failures.ts +108 -0
- package/src/composition-store.test.ts +497 -0
- package/src/composition-store.ts +473 -0
- package/src/index.ts +10 -0
- package/src/memory-storage.fixture.ts +61 -0
- package/src/run-records.ts +624 -0
- package/src/run-recovery.ts +163 -0
- package/src/run-terminal.ts +220 -0
- package/src/storage-keys.ts +129 -0
- package/src/turn-admission.test.ts +450 -0
- package/src/turn-errors.ts +33 -0
- package/src/workspace-generations.test.ts +228 -0
- package/src/workspace-generations.ts +189 -0
- package/src/workspace-sync-effects.ts +103 -0
- package/tsconfig.json +15 -0
- package/README.md +0 -3
|
@@ -0,0 +1,624 @@
|
|
|
1
|
+
import {
|
|
2
|
+
decodeSessionEvent,
|
|
3
|
+
decodeTurnTypeV1,
|
|
4
|
+
formatSkillRefV1,
|
|
5
|
+
type SessionEvent,
|
|
6
|
+
type SkillRefV1,
|
|
7
|
+
type TurnTypeV1,
|
|
8
|
+
} from "@frockbot/kernel-contracts";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The kernel records the Composition/configuration snapshot a Turn was admitted
|
|
12
|
+
* under, but never interprets it: the owning Package supplies the decoder.
|
|
13
|
+
*/
|
|
14
|
+
export interface StoredRunCodecOptionsV1 {
|
|
15
|
+
decodeRunId(value: unknown): string;
|
|
16
|
+
decodeConfigurationSnapshot(value: unknown): unknown;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface StoredRunCodecV1<Snapshot> {
|
|
20
|
+
require(input: unknown): StoredRunV1<Snapshot>;
|
|
21
|
+
optional(input: unknown): StoredRunV1<Snapshot> | undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type StoredRunStatus =
|
|
25
|
+
"running" | "completed" | "failed" | "cancelled" | "reconciliation-required";
|
|
26
|
+
|
|
27
|
+
export type StoredEffectAdmissionOutcome = "admitted" | "fenced";
|
|
28
|
+
|
|
29
|
+
/** Durable linearization result for one exact provider or tool effect. */
|
|
30
|
+
export interface StoredEffectAdmission {
|
|
31
|
+
kind: "model" | "tool";
|
|
32
|
+
effectId: string;
|
|
33
|
+
outcome: StoredEffectAdmissionOutcome;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** How a Turn that no person started came to be started. */
|
|
37
|
+
export type StoredRunTriggerV1 = "cron" | "webhook" | "integration" | "manual";
|
|
38
|
+
|
|
39
|
+
/** A Turn a Routine's firing produced. */
|
|
40
|
+
export interface StoredRunRoutineOriginV1 {
|
|
41
|
+
kind: "routine";
|
|
42
|
+
routineId: string;
|
|
43
|
+
fireId: string;
|
|
44
|
+
trigger: StoredRunTriggerV1;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A Turn a parent Turn dispatched as a subagent task.
|
|
49
|
+
*
|
|
50
|
+
* It is recorded in the *child* object: the Subagent Durable Object runs the
|
|
51
|
+
* Turn, and this is how the run it wrote says whose task it was and which of
|
|
52
|
+
* the parent's runs asked for it. The parent's own authority — the task record,
|
|
53
|
+
* the bounds, the terminal outcome — lives in the parent object and never here.
|
|
54
|
+
*/
|
|
55
|
+
export interface StoredRunSubagentOriginV1 {
|
|
56
|
+
kind: "subagent";
|
|
57
|
+
taskId: string;
|
|
58
|
+
parentRunId: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** What produced a Turn, when it was not a person speaking to the Bot. */
|
|
62
|
+
export type StoredRunOriginV1 =
|
|
63
|
+
StoredRunRoutineOriginV1 | StoredRunSubagentOriginV1;
|
|
64
|
+
|
|
65
|
+
const STORED_RUN_ORIGIN_TRIGGERS: readonly StoredRunTriggerV1[] = [
|
|
66
|
+
"cron",
|
|
67
|
+
"webhook",
|
|
68
|
+
"integration",
|
|
69
|
+
"manual",
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The turn type an admitted run was accepted as, and what produced it,
|
|
74
|
+
* recorded so recovery after eviction re-mounts the same catalog and the firing
|
|
75
|
+
* stays attributable. Absent means `chat` with no recorded origin: it is
|
|
76
|
+
* written only for a Turn that has one, so a record admitted before turn
|
|
77
|
+
* admission existed and a chat record written after it are byte-for-byte the
|
|
78
|
+
* same.
|
|
79
|
+
*/
|
|
80
|
+
export interface StoredRunAdmissionV1 {
|
|
81
|
+
schemaVersion: 1;
|
|
82
|
+
turnType: TurnTypeV1;
|
|
83
|
+
/**
|
|
84
|
+
* The subagent role the Turn was admitted under, when it had one. Recorded
|
|
85
|
+
* for the same reason the turn type is: recovery after eviction has to
|
|
86
|
+
* re-mount the *same* catalog, and the role is half of what selects it.
|
|
87
|
+
*/
|
|
88
|
+
subagentRole?: string;
|
|
89
|
+
origin?: StoredRunOriginV1;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export type StoredRunPhase =
|
|
93
|
+
"admitted" | "executing" | "reconciliation-required";
|
|
94
|
+
|
|
95
|
+
export interface StoredRunV1<Snapshot = unknown> {
|
|
96
|
+
runId: string;
|
|
97
|
+
commandFingerprint: string;
|
|
98
|
+
sessionId: string;
|
|
99
|
+
acceptedAt: string;
|
|
100
|
+
input: string;
|
|
101
|
+
events: SessionEvent[];
|
|
102
|
+
effectAdmissions: StoredEffectAdmission[];
|
|
103
|
+
status: StoredRunStatus;
|
|
104
|
+
responseText?: string;
|
|
105
|
+
failure?: string;
|
|
106
|
+
phase: StoredRunPhase;
|
|
107
|
+
/** Durable Stop intent; orthogonal to status and phase. */
|
|
108
|
+
stopRequestedAt?: string;
|
|
109
|
+
/** The Composition generation pinned in the same transaction that admitted the run. */
|
|
110
|
+
compositionGenerationId: string;
|
|
111
|
+
configurationSnapshot: Snapshot;
|
|
112
|
+
previousEventCount: number;
|
|
113
|
+
/** Absent ⇒ the run was admitted as a `chat` Turn. */
|
|
114
|
+
admission?: StoredRunAdmissionV1;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The subagent role a stored run re-mounts under, if any. */
|
|
118
|
+
export function storedRunSubagentRoleV1(run: {
|
|
119
|
+
admission?: StoredRunAdmissionV1;
|
|
120
|
+
}): string | undefined {
|
|
121
|
+
return run.admission?.subagentRole;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The turn type a stored run re-mounts on. */
|
|
125
|
+
export function storedRunTurnTypeV1(run: {
|
|
126
|
+
admission?: StoredRunAdmissionV1;
|
|
127
|
+
}): TurnTypeV1 {
|
|
128
|
+
return run.admission?.turnType ?? "chat";
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The `admission` field a Turn records — nothing at all for a chat Turn with
|
|
133
|
+
* no recorded origin, so no stored bytes change for the Turn every producer
|
|
134
|
+
* writes today.
|
|
135
|
+
*/
|
|
136
|
+
export function storedRunAdmissionV1(
|
|
137
|
+
turnType: TurnTypeV1 | undefined,
|
|
138
|
+
origin?: StoredRunOriginV1,
|
|
139
|
+
subagentRole?: string,
|
|
140
|
+
): { admission?: StoredRunAdmissionV1 } {
|
|
141
|
+
const admitted = turnType ?? "chat";
|
|
142
|
+
if (admitted === "chat" && origin === undefined && subagentRole === undefined)
|
|
143
|
+
return {};
|
|
144
|
+
return {
|
|
145
|
+
admission: {
|
|
146
|
+
schemaVersion: 1,
|
|
147
|
+
turnType: admitted,
|
|
148
|
+
...(subagentRole ? { subagentRole } : {}),
|
|
149
|
+
...(origin ? { origin } : {}),
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** How long a recorded subagent role may be. It is an opaque bounded string. */
|
|
155
|
+
const STORED_RUN_SUBAGENT_ROLE_MAX = 64;
|
|
156
|
+
|
|
157
|
+
const STORED_RUN_STATUSES: readonly StoredRunStatus[] = [
|
|
158
|
+
"running",
|
|
159
|
+
"completed",
|
|
160
|
+
"failed",
|
|
161
|
+
"cancelled",
|
|
162
|
+
"reconciliation-required",
|
|
163
|
+
];
|
|
164
|
+
const STORED_RUN_PHASES: readonly StoredRunPhase[] = [
|
|
165
|
+
"admitted",
|
|
166
|
+
"executing",
|
|
167
|
+
"reconciliation-required",
|
|
168
|
+
];
|
|
169
|
+
const STORED_RUN_REQUIRED_KEYS = [
|
|
170
|
+
"runId",
|
|
171
|
+
"commandFingerprint",
|
|
172
|
+
"sessionId",
|
|
173
|
+
"acceptedAt",
|
|
174
|
+
"input",
|
|
175
|
+
"events",
|
|
176
|
+
"effectAdmissions",
|
|
177
|
+
"status",
|
|
178
|
+
"phase",
|
|
179
|
+
"compositionGenerationId",
|
|
180
|
+
"configurationSnapshot",
|
|
181
|
+
"previousEventCount",
|
|
182
|
+
] as const;
|
|
183
|
+
const STORED_RUN_OPTIONAL_KEYS = [
|
|
184
|
+
"responseText",
|
|
185
|
+
"failure",
|
|
186
|
+
"stopRequestedAt",
|
|
187
|
+
"admission",
|
|
188
|
+
] as const;
|
|
189
|
+
const UTF8_ENCODER = new TextEncoder();
|
|
190
|
+
|
|
191
|
+
function boundedString(
|
|
192
|
+
value: unknown,
|
|
193
|
+
maximum: number,
|
|
194
|
+
allowEmpty = false,
|
|
195
|
+
): value is string {
|
|
196
|
+
return (
|
|
197
|
+
typeof value === "string" &&
|
|
198
|
+
(allowEmpty || value.length > 0) &&
|
|
199
|
+
UTF8_ENCODER.encode(value).byteLength <= maximum
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Exact fields, per origin kind. Each kind gets its own branch rather than a
|
|
205
|
+
* union of optional fields: a `routine` origin carrying a `taskId` is not a
|
|
206
|
+
* record with a spare field, it is a record this codec has never written.
|
|
207
|
+
*/
|
|
208
|
+
function requireExactOriginFields(
|
|
209
|
+
candidate: Record<PropertyKey, unknown>,
|
|
210
|
+
fields: readonly string[],
|
|
211
|
+
runId: string,
|
|
212
|
+
): void {
|
|
213
|
+
const ownKeys = Reflect.ownKeys(candidate);
|
|
214
|
+
if (
|
|
215
|
+
ownKeys.length !== fields.length ||
|
|
216
|
+
Object.keys(candidate).length !== fields.length ||
|
|
217
|
+
!fields.every((key) => Object.hasOwn(candidate, key))
|
|
218
|
+
) {
|
|
219
|
+
throw new Error(`run "${runId}" has invalid admission origin fields`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function decodeStoredRunOrigin(
|
|
224
|
+
value: unknown,
|
|
225
|
+
runId: string,
|
|
226
|
+
): StoredRunOriginV1 {
|
|
227
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
228
|
+
throw new Error(`run "${runId}" has invalid admission origin`);
|
|
229
|
+
}
|
|
230
|
+
const candidate = value as Record<PropertyKey, unknown>;
|
|
231
|
+
if (candidate.kind === "subagent") {
|
|
232
|
+
requireExactOriginFields(
|
|
233
|
+
candidate,
|
|
234
|
+
["kind", "taskId", "parentRunId"],
|
|
235
|
+
runId,
|
|
236
|
+
);
|
|
237
|
+
if (
|
|
238
|
+
!boundedString(candidate.taskId, 256) ||
|
|
239
|
+
!boundedString(candidate.parentRunId, 256)
|
|
240
|
+
) {
|
|
241
|
+
throw new Error(`run "${runId}" has an invalid admission origin id`);
|
|
242
|
+
}
|
|
243
|
+
return {
|
|
244
|
+
kind: "subagent",
|
|
245
|
+
taskId: candidate.taskId,
|
|
246
|
+
parentRunId: candidate.parentRunId,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
if (candidate.kind !== "routine") {
|
|
250
|
+
throw new Error(`run "${runId}" has an invalid admission origin kind`);
|
|
251
|
+
}
|
|
252
|
+
requireExactOriginFields(
|
|
253
|
+
candidate,
|
|
254
|
+
["kind", "routineId", "fireId", "trigger"],
|
|
255
|
+
runId,
|
|
256
|
+
);
|
|
257
|
+
const trigger = STORED_RUN_ORIGIN_TRIGGERS.find(
|
|
258
|
+
(value) => value === candidate.trigger,
|
|
259
|
+
);
|
|
260
|
+
if (!trigger) {
|
|
261
|
+
throw new Error(`run "${runId}" has an invalid admission origin trigger`);
|
|
262
|
+
}
|
|
263
|
+
if (
|
|
264
|
+
!boundedString(candidate.routineId, 256) ||
|
|
265
|
+
!boundedString(candidate.fireId, 256)
|
|
266
|
+
) {
|
|
267
|
+
throw new Error(`run "${runId}" has an invalid admission origin id`);
|
|
268
|
+
}
|
|
269
|
+
return {
|
|
270
|
+
kind: "routine",
|
|
271
|
+
routineId: candidate.routineId as string,
|
|
272
|
+
fireId: candidate.fireId as string,
|
|
273
|
+
trigger,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function decodeStoredRunAdmission(
|
|
278
|
+
value: unknown,
|
|
279
|
+
runId: string,
|
|
280
|
+
): StoredRunAdmissionV1 {
|
|
281
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
282
|
+
throw new Error(`run "${runId}" has invalid admission`);
|
|
283
|
+
}
|
|
284
|
+
const candidate = value as Record<PropertyKey, unknown>;
|
|
285
|
+
const allowed = new Set([
|
|
286
|
+
"schemaVersion",
|
|
287
|
+
"turnType",
|
|
288
|
+
"subagentRole",
|
|
289
|
+
"origin",
|
|
290
|
+
]);
|
|
291
|
+
const ownKeys = Reflect.ownKeys(candidate);
|
|
292
|
+
if (
|
|
293
|
+
ownKeys.length !== Object.keys(candidate).length ||
|
|
294
|
+
ownKeys.some((key) => typeof key !== "string" || !allowed.has(key)) ||
|
|
295
|
+
!Object.hasOwn(candidate, "schemaVersion") ||
|
|
296
|
+
!Object.hasOwn(candidate, "turnType") ||
|
|
297
|
+
candidate.schemaVersion !== 1
|
|
298
|
+
) {
|
|
299
|
+
throw new Error(`run "${runId}" has invalid admission fields`);
|
|
300
|
+
}
|
|
301
|
+
let turnType: TurnTypeV1;
|
|
302
|
+
try {
|
|
303
|
+
turnType = decodeTurnTypeV1(candidate.turnType);
|
|
304
|
+
} catch {
|
|
305
|
+
throw new Error(`run "${runId}" has an invalid admission turn type`);
|
|
306
|
+
}
|
|
307
|
+
if (
|
|
308
|
+
candidate.subagentRole !== undefined &&
|
|
309
|
+
(typeof candidate.subagentRole !== "string" ||
|
|
310
|
+
candidate.subagentRole.trim().length === 0 ||
|
|
311
|
+
candidate.subagentRole.length > STORED_RUN_SUBAGENT_ROLE_MAX)
|
|
312
|
+
) {
|
|
313
|
+
throw new Error(`run "${runId}" has an invalid admission subagent role`);
|
|
314
|
+
}
|
|
315
|
+
return {
|
|
316
|
+
schemaVersion: 1,
|
|
317
|
+
turnType,
|
|
318
|
+
...(candidate.subagentRole === undefined
|
|
319
|
+
? {}
|
|
320
|
+
: { subagentRole: candidate.subagentRole as string }),
|
|
321
|
+
...(candidate.origin === undefined
|
|
322
|
+
? {}
|
|
323
|
+
: { origin: decodeStoredRunOrigin(candidate.origin, runId) }),
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function decodeStoredRunEvents(value: unknown): SessionEvent[] {
|
|
328
|
+
if (!Array.isArray(value)) throw new Error("stored run has invalid events");
|
|
329
|
+
return value.map(decodeSessionEvent);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const STORED_EFFECT_ADMISSIONS_MAX = 256;
|
|
333
|
+
const STORED_EFFECT_ID_MAX_BYTES = 512;
|
|
334
|
+
|
|
335
|
+
function decodeStoredEffectAdmissions(value: unknown): StoredEffectAdmission[] {
|
|
336
|
+
if (!Array.isArray(value) || value.length > STORED_EFFECT_ADMISSIONS_MAX) {
|
|
337
|
+
throw new Error("stored run has invalid effect admissions");
|
|
338
|
+
}
|
|
339
|
+
const effectIds = new Set<string>();
|
|
340
|
+
return value.map((entry) => {
|
|
341
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
|
342
|
+
throw new Error("stored run has invalid effect admission");
|
|
343
|
+
}
|
|
344
|
+
const candidate = entry as Record<PropertyKey, unknown>;
|
|
345
|
+
const ownKeys = Reflect.ownKeys(candidate);
|
|
346
|
+
if (
|
|
347
|
+
ownKeys.length !== 3 ||
|
|
348
|
+
Object.keys(candidate).length !== 3 ||
|
|
349
|
+
!["kind", "effectId", "outcome"].every((key) =>
|
|
350
|
+
Object.hasOwn(candidate, key),
|
|
351
|
+
)
|
|
352
|
+
) {
|
|
353
|
+
throw new Error("stored run has invalid effect admission fields");
|
|
354
|
+
}
|
|
355
|
+
if (candidate.kind !== "model" && candidate.kind !== "tool") {
|
|
356
|
+
throw new Error("stored run has invalid effect admission kind");
|
|
357
|
+
}
|
|
358
|
+
if (!boundedString(candidate.effectId, STORED_EFFECT_ID_MAX_BYTES)) {
|
|
359
|
+
throw new Error("stored run has invalid effect admission id");
|
|
360
|
+
}
|
|
361
|
+
if (candidate.outcome !== "admitted" && candidate.outcome !== "fenced") {
|
|
362
|
+
throw new Error("stored run has invalid effect admission outcome");
|
|
363
|
+
}
|
|
364
|
+
if (effectIds.has(candidate.effectId)) {
|
|
365
|
+
throw new Error("stored run has colliding effect admissions");
|
|
366
|
+
}
|
|
367
|
+
effectIds.add(candidate.effectId);
|
|
368
|
+
return {
|
|
369
|
+
kind: candidate.kind,
|
|
370
|
+
effectId: candidate.effectId,
|
|
371
|
+
outcome: candidate.outcome,
|
|
372
|
+
};
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export function createStoredRunCodecV1<Snapshot>(
|
|
377
|
+
options: StoredRunCodecOptionsV1,
|
|
378
|
+
): StoredRunCodecV1<Snapshot> {
|
|
379
|
+
const require = (input: unknown): StoredRunV1<Snapshot> =>
|
|
380
|
+
requireStoredRunRecordV1(input, options);
|
|
381
|
+
return {
|
|
382
|
+
require,
|
|
383
|
+
optional: (input) => (input === undefined ? undefined : require(input)),
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function requireStoredRunRecordV1<Snapshot>(
|
|
388
|
+
input: unknown,
|
|
389
|
+
options: StoredRunCodecOptionsV1,
|
|
390
|
+
): StoredRunV1<Snapshot> {
|
|
391
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
|
392
|
+
throw new Error("stored run is invalid");
|
|
393
|
+
}
|
|
394
|
+
const candidate = input as Record<PropertyKey, unknown>;
|
|
395
|
+
const allowed = new Set<string>([
|
|
396
|
+
...STORED_RUN_REQUIRED_KEYS,
|
|
397
|
+
...STORED_RUN_OPTIONAL_KEYS,
|
|
398
|
+
]);
|
|
399
|
+
// Exact decoding: a symbol-keyed or non-enumerable own property is a field
|
|
400
|
+
// this record does not have, so it is rejected rather than ignored.
|
|
401
|
+
const enumerableKeys = Object.keys(candidate);
|
|
402
|
+
const ownKeys = Reflect.ownKeys(candidate);
|
|
403
|
+
if (
|
|
404
|
+
ownKeys.length !== enumerableKeys.length ||
|
|
405
|
+
ownKeys.some((key) => typeof key !== "string" || !allowed.has(key)) ||
|
|
406
|
+
!STORED_RUN_REQUIRED_KEYS.every((key) => Object.hasOwn(candidate, key))
|
|
407
|
+
) {
|
|
408
|
+
throw new Error("stored run has invalid fields");
|
|
409
|
+
}
|
|
410
|
+
let runId: string;
|
|
411
|
+
try {
|
|
412
|
+
runId = options.decodeRunId(candidate.runId);
|
|
413
|
+
} catch {
|
|
414
|
+
throw new Error("stored run has invalid runId");
|
|
415
|
+
}
|
|
416
|
+
if (!boundedString(candidate.commandFingerprint, 65_536)) {
|
|
417
|
+
throw new Error(`run "${runId}" has no valid command fingerprint`);
|
|
418
|
+
}
|
|
419
|
+
if (!boundedString(candidate.sessionId, 257)) {
|
|
420
|
+
throw new Error(`run "${runId}" has no valid session id`);
|
|
421
|
+
}
|
|
422
|
+
if (
|
|
423
|
+
!boundedString(candidate.acceptedAt, 64) ||
|
|
424
|
+
!Number.isFinite(Date.parse(candidate.acceptedAt))
|
|
425
|
+
) {
|
|
426
|
+
throw new Error(`run "${runId}" has no valid acceptance time`);
|
|
427
|
+
}
|
|
428
|
+
if (!boundedString(candidate.input, 32_000)) {
|
|
429
|
+
throw new Error(`run "${runId}" has no valid input`);
|
|
430
|
+
}
|
|
431
|
+
const events = decodeStoredRunEvents(candidate.events);
|
|
432
|
+
const effectAdmissions = decodeStoredEffectAdmissions(
|
|
433
|
+
candidate.effectAdmissions,
|
|
434
|
+
);
|
|
435
|
+
const status = STORED_RUN_STATUSES.find(
|
|
436
|
+
(value) => value === candidate.status,
|
|
437
|
+
);
|
|
438
|
+
if (!status) {
|
|
439
|
+
throw new Error(`run "${runId}" has no valid status`);
|
|
440
|
+
}
|
|
441
|
+
const phase = STORED_RUN_PHASES.find((value) => value === candidate.phase);
|
|
442
|
+
if (!phase) {
|
|
443
|
+
throw new Error(`run "${runId}" has no valid phase`);
|
|
444
|
+
}
|
|
445
|
+
if (!boundedString(candidate.compositionGenerationId, 256)) {
|
|
446
|
+
throw new Error(`run "${runId}" has no valid Composition generation`);
|
|
447
|
+
}
|
|
448
|
+
if (
|
|
449
|
+
!Number.isSafeInteger(candidate.previousEventCount) ||
|
|
450
|
+
(candidate.previousEventCount as number) < 0
|
|
451
|
+
) {
|
|
452
|
+
throw new Error(`run "${runId}" has no valid previous event count`);
|
|
453
|
+
}
|
|
454
|
+
options.decodeConfigurationSnapshot(candidate.configurationSnapshot);
|
|
455
|
+
if (
|
|
456
|
+
candidate.responseText !== undefined &&
|
|
457
|
+
!boundedString(candidate.responseText, 64_000, true)
|
|
458
|
+
) {
|
|
459
|
+
throw new Error(`run "${runId}" has invalid responseText`);
|
|
460
|
+
}
|
|
461
|
+
if (
|
|
462
|
+
candidate.failure !== undefined &&
|
|
463
|
+
!boundedString(candidate.failure, 8_000)
|
|
464
|
+
) {
|
|
465
|
+
throw new Error(`run "${runId}" has invalid failure`);
|
|
466
|
+
}
|
|
467
|
+
if (
|
|
468
|
+
candidate.stopRequestedAt !== undefined &&
|
|
469
|
+
(!boundedString(candidate.stopRequestedAt, 64) ||
|
|
470
|
+
!Number.isFinite(Date.parse(candidate.stopRequestedAt as string)))
|
|
471
|
+
) {
|
|
472
|
+
throw new Error(`run "${runId}" has invalid stopRequestedAt`);
|
|
473
|
+
}
|
|
474
|
+
if (
|
|
475
|
+
status === "completed"
|
|
476
|
+
? candidate.responseText === undefined || candidate.failure !== undefined
|
|
477
|
+
: candidate.responseText !== undefined
|
|
478
|
+
) {
|
|
479
|
+
throw new Error(`run "${runId}" has invalid completion fields`);
|
|
480
|
+
}
|
|
481
|
+
if (
|
|
482
|
+
status === "failed" || status === "reconciliation-required"
|
|
483
|
+
? candidate.failure === undefined
|
|
484
|
+
: candidate.failure !== undefined
|
|
485
|
+
) {
|
|
486
|
+
throw new Error(`run "${runId}" has invalid failure fields`);
|
|
487
|
+
}
|
|
488
|
+
if (status === "cancelled" && candidate.stopRequestedAt === undefined) {
|
|
489
|
+
throw new Error(`run "${runId}" has no durable stop intent`);
|
|
490
|
+
}
|
|
491
|
+
if (
|
|
492
|
+
(status === "reconciliation-required") !==
|
|
493
|
+
(phase === "reconciliation-required")
|
|
494
|
+
) {
|
|
495
|
+
throw new Error(`run "${runId}" has inconsistent recovery state`);
|
|
496
|
+
}
|
|
497
|
+
return {
|
|
498
|
+
runId,
|
|
499
|
+
commandFingerprint: candidate.commandFingerprint,
|
|
500
|
+
sessionId: candidate.sessionId,
|
|
501
|
+
acceptedAt: candidate.acceptedAt,
|
|
502
|
+
input: candidate.input,
|
|
503
|
+
events,
|
|
504
|
+
effectAdmissions,
|
|
505
|
+
status,
|
|
506
|
+
phase,
|
|
507
|
+
compositionGenerationId: candidate.compositionGenerationId,
|
|
508
|
+
configurationSnapshot: candidate.configurationSnapshot as Snapshot,
|
|
509
|
+
previousEventCount: candidate.previousEventCount as number,
|
|
510
|
+
...(candidate.responseText === undefined
|
|
511
|
+
? {}
|
|
512
|
+
: { responseText: candidate.responseText as string }),
|
|
513
|
+
...(candidate.failure === undefined
|
|
514
|
+
? {}
|
|
515
|
+
: { failure: candidate.failure as string }),
|
|
516
|
+
...(candidate.stopRequestedAt === undefined
|
|
517
|
+
? {}
|
|
518
|
+
: { stopRequestedAt: candidate.stopRequestedAt as string }),
|
|
519
|
+
...(candidate.admission === undefined
|
|
520
|
+
? {}
|
|
521
|
+
: { admission: decodeStoredRunAdmission(candidate.admission, runId) }),
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
export interface BotTurnCommand {
|
|
526
|
+
runId: string;
|
|
527
|
+
sessionId: string;
|
|
528
|
+
acceptedAt: string;
|
|
529
|
+
text: string;
|
|
530
|
+
/**
|
|
531
|
+
* Absent ⇒ `chat`. Only an in-Durable-Object producer may name another type;
|
|
532
|
+
* the HTTP Turn path always admits `chat`.
|
|
533
|
+
*/
|
|
534
|
+
turnType?: TurnTypeV1;
|
|
535
|
+
/**
|
|
536
|
+
* The subagent role this Turn is admitted under. In-Durable-Object producers
|
|
537
|
+
* only, and only ever on a `subagent` Turn.
|
|
538
|
+
*/
|
|
539
|
+
subagentRole?: string;
|
|
540
|
+
/**
|
|
541
|
+
* What produced this Turn. In-Durable-Object producers only; the HTTP Turn
|
|
542
|
+
* path never forwards it.
|
|
543
|
+
*/
|
|
544
|
+
origin?: StoredRunOriginV1;
|
|
545
|
+
/**
|
|
546
|
+
* The Skills the User invoked with this message. Part of the command's
|
|
547
|
+
* identity: the same text with a different Skill attached is a different
|
|
548
|
+
* command, so it must not collide on an idempotency record.
|
|
549
|
+
*/
|
|
550
|
+
skills?: SkillRefV1[];
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* A chat command keeps the exact v1 fingerprint bytes, so idempotency records
|
|
555
|
+
* written before turn admission existed still match the same command after
|
|
556
|
+
* deploy. Only a Turn carrying a turn type or an origin — neither of which any
|
|
557
|
+
* producer could have written before — emits v2, where both are part of the
|
|
558
|
+
* identity of the command.
|
|
559
|
+
*/
|
|
560
|
+
export function botTurnCommandFingerprintV1(
|
|
561
|
+
command: BotTurnCommand & { userId: string; botId: string },
|
|
562
|
+
): string {
|
|
563
|
+
const turnType = command.turnType ?? "chat";
|
|
564
|
+
const skills = command.skills ?? [];
|
|
565
|
+
if (
|
|
566
|
+
turnType !== "chat" ||
|
|
567
|
+
command.origin !== undefined ||
|
|
568
|
+
command.subagentRole !== undefined ||
|
|
569
|
+
skills.length > 0
|
|
570
|
+
) {
|
|
571
|
+
return `bot-turn-command-v2:${JSON.stringify({
|
|
572
|
+
userId: command.userId,
|
|
573
|
+
botId: command.botId,
|
|
574
|
+
sessionId: command.sessionId,
|
|
575
|
+
text: command.text,
|
|
576
|
+
turnType,
|
|
577
|
+
...(command.subagentRole ? { subagentRole: command.subagentRole } : {}),
|
|
578
|
+
...(command.origin ? { origin: command.origin } : {}),
|
|
579
|
+
...(skills.length > 0 ? { skills: skills.map(formatSkillRefV1) } : {}),
|
|
580
|
+
})}`;
|
|
581
|
+
}
|
|
582
|
+
return `bot-turn-command-v1:${JSON.stringify({
|
|
583
|
+
userId: command.userId,
|
|
584
|
+
botId: command.botId,
|
|
585
|
+
sessionId: command.sessionId,
|
|
586
|
+
text: command.text,
|
|
587
|
+
})}`;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
export interface BotStopCommand {
|
|
591
|
+
commandId: string;
|
|
592
|
+
runId: string;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
export function botStopCommandFingerprintV1(
|
|
596
|
+
command: BotStopCommand & { userId: string; botId: string },
|
|
597
|
+
): string {
|
|
598
|
+
return `bot-stop-command-v1:${JSON.stringify({
|
|
599
|
+
userId: command.userId,
|
|
600
|
+
botId: command.botId,
|
|
601
|
+
runId: command.runId,
|
|
602
|
+
})}`;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
export interface BotNotificationIntent {
|
|
606
|
+
notificationId: string;
|
|
607
|
+
runId: string;
|
|
608
|
+
createdAt: string;
|
|
609
|
+
title: string;
|
|
610
|
+
body: string;
|
|
611
|
+
/**
|
|
612
|
+
* How loudly the User is told. `critical` is for an intent the Bot's own
|
|
613
|
+
* notification policy does not gate — a question that has stopped the Bot
|
|
614
|
+
* rather than an update about one that finished. Absent means `normal`.
|
|
615
|
+
*/
|
|
616
|
+
urgency?: "normal" | "critical";
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
export interface BotTurnCompletion {
|
|
620
|
+
runId: string;
|
|
621
|
+
text: string;
|
|
622
|
+
events: SessionEvent[];
|
|
623
|
+
notification?: BotNotificationIntent;
|
|
624
|
+
}
|