@springbrand/agent-runtime 0.3.0-alpha.16 → 0.3.0-alpha.18

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.
@@ -1,116 +1,30 @@
1
- import { isEqual } from "lodash-es";
2
1
  import {
3
- decodePiToolRecoveryMilestone,
4
- replayPiToolRecovery,
5
- } from "./legacy-tool-recovery";
6
- /** Versioned SQL data migration, called before the Runtime opens its transcript. */
7
- import {
8
- branchTip,
9
- entryLabel,
10
- insertEntry,
11
- insertUsage,
12
- setValue,
13
- deleteValue,
14
- sessionName,
15
- laneState,
16
- operationMeta,
17
- operationState,
18
- pendingEntry,
19
- sessionEntryToContextMessages,
20
- value,
21
- type Entry,
22
- type JsonValue,
23
- type Write,
24
- type Value,
25
- type LaneState,
26
- type ToolExecution,
27
- } from "@earendil-works/pi-agent-core/harness/session";
28
- import type {
29
- AgentToolResult,
30
- AgentMessage,
31
- } from "@earendil-works/pi-agent-core";
32
- import type { Usage, ToolResultMessage } from "@earendil-works/pi-ai";
33
- import {
34
- CloudflareSessionStorage,
35
- CURRENT_PI_STORAGE_VERSION,
36
- } from "./storage";
2
+ AgentDoc, GenerationTask, LiveDoc,
3
+ defineDoc, type ConversationId, type EntryRecord,
4
+ type GenerationCheckpoint, type Session,
5
+ } from "@earendil-works/pi-durable";
6
+ import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
7
+ import type { Message } from "@earendil-works/pi-ai";
8
+ import { replayPiToolRecovery } from "./legacy-tool-recovery";
9
+ import { RuntimeConversation } from "./storage";
37
10
 
38
- const RETIRED_SCHEDULE_CALLBACKS = [
39
- "_cleanupStreamBuffers",
40
- "_piPlannedContinuation",
41
- "_piContinuationWatchdog",
42
- "_piApprovalContinuation",
43
- "_chatRecoveryRetry",
44
- ] as const;
45
-
46
- const LEGACY_TERMINAL_SUBMISSION_STATUSES = new Set([
47
- "completed",
48
- "failed",
49
- "aborted",
50
- "skipped",
51
- ]);
11
+ const Migration = defineDoc({
12
+ kind: "springbrand.pi-upgrade", version: 1, scope: "session",
13
+ initial: (): { completed: boolean } => ({ completed: false }),
14
+ });
52
15
 
53
- type LegacyRecord = Record<string, unknown> & {
54
- id: string;
55
- parentId: string | null;
56
- type: string;
57
- timestamp: string;
16
+ type LegacyEntry = {
17
+ id: string; parentId: string | null; type: string; timestamp: number | string;
18
+ message?: Message; summary?: string; firstKeptEntryId?: string;
19
+ retainedTail?: Message[]; [key: string]: unknown;
58
20
  };
59
- export interface PiSessionMigrationReport {
60
- sourceEntries: number;
61
- entries: number;
62
- tip: string | null;
63
- alreadyMigrated: boolean;
64
- }
65
-
66
- function object(input: unknown): Record<string, unknown> {
67
- if (!input || typeof input !== "object" || Array.isArray(input))
68
- throw new Error("Invalid legacy object");
69
- return input as Record<string, unknown>;
70
- }
71
- function text(input: unknown): string {
72
- if (typeof input !== "string") throw new Error("Invalid legacy string");
73
- return input;
74
- }
75
- function time(input: unknown): number {
76
- const timestamp = Date.parse(text(input));
77
- if (!Number.isSafeInteger(timestamp) || timestamp < 0)
78
- throw new Error("Invalid legacy timestamp");
79
- return timestamp;
80
- }
81
- function finite(input: unknown): number {
82
- if (typeof input !== "number" || !Number.isFinite(input) || input < 0)
83
- throw new Error("Invalid legacy number");
84
- return input;
85
- }
86
- function message(input: unknown): AgentMessage {
87
- const record = object(input);
88
- if (
89
- ![
90
- "user",
91
- "assistant",
92
- "toolResult",
93
- "custom",
94
- "bashExecution",
95
- "branchSummary",
96
- "compactionSummary",
97
- ].includes(text(record.role))
98
- )
99
- throw new Error("Unknown legacy message role");
100
- finite(record.timestamp);
101
- if (record.role === "assistant" && record.stopReason === "pending")
102
- throw new Error("Unsettled legacy assistant message");
103
- return normalizeLegacyAskUserMessage(record) as AgentMessage;
104
- }
105
21
  function hasTable(sql: SqlStorage, name: string): boolean {
106
- return (
107
- sql
108
- .exec(
109
- "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?",
110
- name,
111
- )
112
- .toArray().length > 0
113
- );
22
+ return sql.exec("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", name).toArray().length > 0;
23
+ }
24
+ function timestamp(entry: LegacyEntry): number {
25
+ const value = typeof entry.timestamp === "number" ? entry.timestamp : Date.parse(entry.timestamp);
26
+ if (!Number.isFinite(value)) throw new Error(`Invalid historical entry time: ${entry.id}`);
27
+ return value;
114
28
  }
115
29
 
116
30
  const ASK_USER_RESPONSE_TYPES = new Set([
@@ -178,855 +92,399 @@ function normalizeLegacyAskUserEntry(value: unknown): unknown {
178
92
  return value;
179
93
  }
180
94
 
181
- function migrateLegacyAskUserData(sql: SqlStorage): void {
182
- if (hasTable(sql, "pi_entries")) {
183
- for (const row of sql.exec<{ id: string; body: string }>(
184
- "SELECT id, body FROM pi_entries",
185
- ).toArray()) {
186
- sql.exec(
187
- "UPDATE pi_entries SET body = ? WHERE id = ?",
188
- JSON.stringify(normalizeLegacyAskUserEntry(JSON.parse(row.body))),
189
- row.id,
190
- );
191
- }
192
- }
193
- if (hasTable(sql, "pi_tool_interactions")) {
194
- for (const row of sql.exec<{ interaction_id: string; input_json: string }>(
195
- "SELECT interaction_id, input_json FROM pi_tool_interactions WHERE tool_name = 'ask_user'",
196
- ).toArray()) {
197
- sql.exec(
198
- "UPDATE pi_tool_interactions SET input_json = ? WHERE interaction_id = ?",
199
- JSON.stringify(normalizeLegacyAskUserInput(JSON.parse(row.input_json))),
200
- row.interaction_id,
201
- );
202
- }
203
- }
204
- if (hasTable(sql, "pi_values")) {
205
- for (const row of sql.exec<{ key: string; body: string }>(
206
- "SELECT key, body FROM pi_values WHERE namespace = 'pi.op.state'",
207
- ).toArray()) {
208
- const state = JSON.parse(row.body) as {
209
- batch?: {
210
- assistantEntryId?: unknown;
211
- turnId?: unknown;
212
- };
213
- };
214
- const assistantEntryId = state.batch?.assistantEntryId;
215
- const turnId = state.batch?.turnId;
216
- if (typeof assistantEntryId !== "string" || typeof turnId !== "string") {
217
- continue;
218
- }
219
- const entryRow = sql.exec<{ body: string }>(
220
- "SELECT body FROM pi_entries WHERE id = ?",
221
- assistantEntryId,
222
- ).toArray()[0];
223
- if (!entryRow) continue;
224
- const entry = JSON.parse(entryRow.body) as {
225
- type?: unknown;
226
- message?: { role?: unknown; content?: unknown };
227
- };
228
- if (
229
- entry.type !== "message" || entry.message?.role !== "assistant" ||
230
- !Array.isArray(entry.message.content)
231
- ) continue;
232
- entry.message.content.forEach((part, sourceIndex) => {
233
- if (!part || typeof part !== "object" || Array.isArray(part)) return;
234
- const block = part as Record<string, unknown>;
235
- if (block.type !== "toolCall" || block.name !== "ask_user") return;
236
- const key = `${row.key}:${turnId}:${sourceIndex}`;
237
- const args = sql.exec<{ body: string }>(
238
- "SELECT body FROM pi_values WHERE namespace = 'pi.op.tool_args' AND key = ?",
239
- key,
240
- ).toArray()[0];
241
- if (!args) return;
242
- sql.exec(
243
- "UPDATE pi_values SET body = ? WHERE namespace = 'pi.op.tool_args' AND key = ?",
244
- JSON.stringify(normalizeLegacyAskUserInput(JSON.parse(args.body))),
245
- key,
246
- );
247
- });
248
- }
249
- }
250
- }
95
+ const TERMINAL_HOST_STATUSES = new Set(["completed", "failed", "aborted", "skipped", "error"]);
96
+ /** Legacy run phases whose state maps onto a native generation checkpoint without guessing. */
97
+ const RESUMABLE_PHASES = new Set(["starting", "checkpoint", "assistant.ready", "assistant.effect_pending", "assistant.retry_wait"]);
98
+ const MESSAGE_ROLES = new Set(["user", "assistant", "toolResult", "custom", "bashExecution", "branchSummary", "compactionSummary"]);
99
+ const UNKNOWN_EFFECT = "Tool execution was interrupted and its external outcome is unknown. Do not repeat the effect without reconciling it.";
100
+ const INTERRUPTED = "This request was interrupted by a runtime upgrade and did not finish. Send it again to continue.";
251
101
 
252
- function removeRetiredRuntimeArtifacts(sql: SqlStorage): void {
253
- sql.exec(
254
- "CREATE TABLE IF NOT EXISTS pi_session_entry_submission (entry_id TEXT PRIMARY KEY, submission_id TEXT NOT NULL)",
255
- );
256
- if (hasTable(sql, "pi_session_message_submission")) {
257
- sql.exec(`
258
- INSERT OR IGNORE INTO pi_session_entry_submission (entry_id, submission_id)
259
- SELECT message_id, submission_id FROM pi_session_message_submission;
260
- DROP TABLE pi_session_message_submission;
261
- `);
262
- }
263
- if (hasTable(sql, "cf_agents_schedules")) {
264
- const placeholders = RETIRED_SCHEDULE_CALLBACKS.map(() => "?").join(", ");
265
- sql.exec(
266
- `DELETE FROM cf_agents_schedules WHERE callback IN (${placeholders})`,
267
- ...RETIRED_SCHEDULE_CALLBACKS,
268
- );
269
- }
270
- migrateLegacyAskUserData(sql);
271
- }
102
+ type LegacyResult = { status?: string; error?: { code?: string; message?: string } };
103
+ type ToolBatch = { assistantEntryId: string; turnId: string; calls: Array<{ status: string; sourceIndex: number; resultEntryId: string; replay?: string }> };
272
104
 
273
- /** Older Harness adapters copied the initial user sidecar onto consumed Steers. */
274
- function repairDuplicatedUserSidecars(sql: SqlStorage): void {
275
- if (!hasTable(sql, "pi_message_ui") || !hasTable(sql, "pi_submissions")) return;
276
- const entries = new Map(sql.exec<{
277
- id: string;
278
- parent_id: string | null;
279
- body: string;
280
- submission_id: string | null;
281
- }>(`
282
- SELECT entry.id, entry.parent_id, entry.body, link.submission_id
283
- FROM pi_entries AS entry
284
- LEFT JOIN pi_session_entry_submission AS link ON link.entry_id = entry.id
285
- `).toArray().map((row) => [row.id, { ...row, entry: JSON.parse(row.body) as Entry }]));
286
- const candidates = sql.exec<{ message_id: string }>(`
287
- SELECT ui.message_id
288
- FROM pi_message_ui AS ui
289
- JOIN pi_session_entry_submission AS link ON link.entry_id = ui.message_id
290
- JOIN pi_submissions AS submission ON submission.submission_id = link.submission_id
291
- WHERE json_extract(ui.body, '$.id') = submission.user_message_id
292
- `).toArray();
293
- for (const candidate of candidates) {
294
- const current = entries.get(candidate.message_id);
295
- if (current?.entry.type !== "message" || current.entry.message.role !== "user") continue;
296
- let parentId = current.parent_id;
297
- const visited = new Set<string>();
298
- while (parentId !== null) {
299
- if (visited.has(parentId)) throw new Error(`Cyclic Pi ancestry: ${parentId}`);
300
- visited.add(parentId);
301
- const parent = entries.get(parentId);
302
- if (!parent) throw new Error(`Missing Pi ancestor: ${parentId}`);
303
- if (parent.submission_id === current.submission_id &&
304
- parent.entry.type === "message" && parent.entry.message.role === "user") {
305
- // The copied parts are also wrong: canonical Steer content is authoritative.
306
- sql.exec("DELETE FROM pi_message_ui WHERE message_id = ?", candidate.message_id);
307
- break;
308
- }
309
- parentId = parent.parent_id;
310
- }
311
- }
105
+ function warn(message: string): void {
106
+ console.warn("[pi-session-migration]", message);
312
107
  }
313
108
 
314
- function hasToolExecution(value: ToolExecution | undefined): boolean {
315
- return value?.status === "blocked" ||
316
- (value?.status === "executed" && value.args !== null &&
317
- typeof value.args === "object" && !Array.isArray(value.args));
109
+ /** Custom messages reach the model as user context, matching `ConversationHistory.append` (e.g. turn-aborted markers). */
110
+ function customModel(message: Message, createdAt: number): { model?: Message[] } {
111
+ const content = (message as unknown as { content?: unknown }).content;
112
+ if (typeof content === "string") return { model: [{ role: "user", content, timestamp: createdAt }] };
113
+ if (!Array.isArray(content) || !content.length) return {};
114
+ return { model: [{ role: "user", content: [...content] as Extract<Message, { role: "user" }>["content"], timestamp: createdAt }] };
318
115
  }
319
116
 
320
- /** A terminal Submission cannot resume, so its stale lane pointer is not active work. */
321
- function clearTerminalOperationPointers(sql: SqlStorage): void {
322
- if (!hasTable(sql, "pi_submissions")) return;
323
- const lanes = sql.exec<{ key: string; body: string }>(
324
- "SELECT key, body FROM pi_values WHERE namespace = ?",
325
- laneState("").namespace,
326
- ).toArray();
327
- for (const lane of lanes) {
328
- const state = JSON.parse(lane.body) as LaneState;
329
- if (state.currentOperationId === null) continue;
330
- const terminal = sql.exec(
331
- `SELECT 1 FROM pi_submissions
332
- WHERE submission_id = ?
333
- AND status IN ('completed', 'failed', 'aborted', 'skipped')
334
- LIMIT 1`,
335
- state.currentOperationId,
336
- ).toArray().length === 1;
337
- if (!terminal) continue;
338
- sql.exec(
339
- `UPDATE pi_values
340
- SET body = json_set(body, '$.currentOperationId', json('null'))
341
- WHERE namespace = ? AND key = ?`,
342
- laneState("").namespace,
343
- lane.key,
344
- );
345
- }
117
+ function unknownOutcome(call: { id: string; name: string }, text = UNKNOWN_EFFECT): Message {
118
+ return { role: "toolResult", toolCallId: call.id, toolName: call.name, content: [{ type: "text", text }], isError: true, timestamp: Date.now() };
346
119
  }
347
120
 
348
- /** Old active tool outcomes cannot be reconstructed from error text or approval facts. */
349
- function assertActiveToolProvenance(sql: SqlStorage): void {
350
- const read = <T>(address: Value<T>): T | undefined => {
351
- const row = sql.exec<{ body: string }>(
352
- "SELECT body FROM pi_values WHERE namespace = ? AND key = ?",
353
- address.namespace, address.key,
354
- ).toArray()[0];
355
- return row ? JSON.parse(row.body) as T : undefined;
121
+ /**
122
+ * Normalizes historical records onto the native shapes and drops records that can never be valid model context.
123
+ * Dropped and branch-pointer records resolve to their nearest kept ancestor, so descendants stay on their branch.
124
+ */
125
+ function repairLegacyEntries(raw: LegacyEntry[]): { entries: LegacyEntry[]; resolve: (id: string | null) => string | null } {
126
+ const kept = new Map<string, LegacyEntry>();
127
+ const alias = new Map<string, string | null>();
128
+ const resolve = (id: string | null): string | null => {
129
+ for (let seen = 0; id !== null && !kept.has(id) && alias.has(id); seen++) {
130
+ if (seen > alias.size) throw new Error(`Cyclic historical Pi reference: ${id}`);
131
+ id = alias.get(id)!;
132
+ }
133
+ return id;
356
134
  };
357
- const lanes = sql.exec<{ key: string; body: string }>(
358
- "SELECT key, body FROM pi_values WHERE namespace = ?",
359
- laneState("").namespace,
360
- ).toArray();
361
- for (const lane of lanes) {
362
- const operationId = (JSON.parse(lane.body) as LaneState).currentOperationId;
363
- if (operationId === null) continue;
364
- const reject: (reason: string) => never = (reason) => {
365
- throw new Error(
366
- `Cannot upgrade Pi storage while operation ${operationId} is active: ${reason}. ` +
367
- "Complete this operation with the previous runtime version, then retry the upgrade.",
368
- );
135
+ const entries: LegacyEntry[] = [];
136
+ for (const source of raw) {
137
+ const parentId = resolve(source.parentId);
138
+ const skip = (reason: string) => {
139
+ warn(`Skipped historical entry ${source.id}: ${reason}`);
140
+ alias.set(source.id, parentId);
369
141
  };
370
- const meta = read(operationMeta(operationId));
371
- const state = read(operationState(operationId));
372
- if (!meta || !state) reject("its durable operation metadata is incomplete");
373
- let tip = read(branchTip(lane.key));
374
- if (tip === undefined) reject("its lane branch tip is missing");
375
- const visited = new Set<string>();
376
- while (tip !== meta.sourceTipId) {
377
- if (tip === null || visited.has(tip)) reject("its operation branch cannot be verified");
378
- visited.add(tip);
379
- const row = sql.exec<{ body: string }>("SELECT body FROM pi_entries WHERE id = ?", tip).toArray()[0];
380
- if (!row) reject(`its canonical entry ${tip} is missing`);
381
- const entry = JSON.parse(row.body) as Entry;
382
- if (entry.type === "message" && entry.message.role === "toolResult" &&
383
- !hasToolExecution(entry.toolExecution)) {
384
- reject(`ToolResult ${entry.id} has no reliable execution provenance`);
385
- }
386
- tip = entry.parentId;
142
+ if (source.type === "leaf") {
143
+ alias.set(source.id, resolve(typeof source.targetId === "string" ? source.targetId : source.targetId === null ? null : parentId));
144
+ continue;
387
145
  }
388
- if (state.at !== "tools") continue;
389
- for (const call of state.batch.calls) {
390
- if (call.status === "planned" || call.status === "completed") continue;
391
- if (call.status === "effect_pending") {
392
- reject(`Tool invocation ${call.resultEntryId} has an unresolved execution outcome`);
393
- }
394
- const pending = read(pendingEntry(call.resultEntryId));
395
- if (pending?.type !== "message" || pending.payload.role !== "toolResult" ||
396
- !hasToolExecution(pending.toolExecution)) {
397
- reject(`pending ToolResult ${call.resultEntryId} has no reliable execution provenance`);
146
+ let entry: LegacyEntry = { ...source, parentId };
147
+ if (source.type === "custom_message") {
148
+ if (typeof source.customType !== "string") { skip("custom message has no type"); continue; }
149
+ entry = {
150
+ id: source.id, parentId, type: "message", timestamp: source.timestamp,
151
+ message: { role: "custom", customType: source.customType, content: source.content, details: source.details, display: source.display, timestamp: timestamp(source) } as unknown as Message,
152
+ };
153
+ }
154
+ if (entry.type === "message") {
155
+ const message = entry.message as unknown as Record<string, unknown> | undefined;
156
+ if (!message || typeof message !== "object" || !MESSAGE_ROLES.has(message.role as string)) { skip("unknown message"); continue; }
157
+ if (message.role === "assistant" && message.stopReason === "pending") { skip("unsettled assistant"); continue; }
158
+ if (message.role === "toolResult") {
159
+ let call: { name: string } | undefined;
160
+ let duplicate = false;
161
+ for (let cursor = parentId; cursor && !call && !duplicate; cursor = kept.get(cursor)?.parentId ?? null) {
162
+ const ancestor = kept.get(cursor)?.message;
163
+ if (ancestor?.role === "toolResult" && ancestor.toolCallId === message.toolCallId) duplicate = true;
164
+ if (ancestor?.role === "assistant") {
165
+ call = ancestor.content.find((part) => part.type === "toolCall" && part.id === message.toolCallId) as { name: string } | undefined;
166
+ }
167
+ }
168
+ if (duplicate) { skip(`duplicate Tool result ${String(message.toolCallId)}`); continue; }
169
+ if (!call || call.name !== message.toolName) { skip(`orphan Tool result ${String(message.toolCallId)}`); continue; }
398
170
  }
399
171
  }
172
+ kept.set(entry.id, entry);
173
+ entries.push(entry);
400
174
  }
175
+ return { entries, resolve };
401
176
  }
402
177
 
403
- /** Upgrade an already-Harness-backed Session before normal reads begin. */
404
- export function migrateCurrentPiSession(
405
- storage: DurableObjectStorage,
406
- ): boolean {
407
- return storage.transactionSync(() => {
408
- const sql = storage.sql;
409
- if (!hasTable(sql, "pi_storage_state")) return false;
410
- const version = sql.exec<{ storage_version: number }>(
411
- "SELECT storage_version FROM pi_storage_state WHERE singleton = 1",
412
- ).one().storage_version;
413
- if (version === CURRENT_PI_STORAGE_VERSION) return false;
414
- if (version !== 1 && version !== 2) {
415
- throw new Error(`Unsupported Cloudflare Pi storage version: ${version}`);
416
- }
417
- clearTerminalOperationPointers(sql);
418
- assertActiveToolProvenance(sql);
419
- if (version === 1) removeRetiredRuntimeArtifacts(sql);
420
- repairDuplicatedUserSidecars(sql);
421
- sql.exec(
422
- "UPDATE pi_storage_state SET storage_version = ? WHERE singleton = 1",
423
- CURRENT_PI_STORAGE_VERSION,
424
- );
425
- return true;
426
- });
427
- }
428
-
429
- function assertSameToolResult(
430
- left: ToolResultMessage,
431
- right: ToolResultMessage,
432
- ): void {
433
- for (const key of [
434
- "toolCallId",
435
- "toolName",
436
- "content",
437
- "details",
438
- "usage",
439
- "isError",
440
- ] as const) {
441
- if (!isEqual(left[key], right[key]))
442
- throw new Error(`Conflicting legacy Tool result: ${left.toolCallId}`);
178
+ /** Converts records once; historical tables are retained until the upgrade is accepted. */
179
+ export async function migratePiSession(storage: DurableObjectStorage, session: Session): Promise<void> {
180
+ if ((await session.snapshot(Migration, BACKGROUND_CONTEXT))?.completed) return;
181
+ const sql = storage.sql;
182
+ if (hasTable(sql, "pi_storage_state")) {
183
+ const version = sql.exec<{ storage_version: number }>("SELECT storage_version FROM pi_storage_state WHERE singleton=1").one().storage_version;
184
+ if (![1, 2, 3].includes(version)) throw new Error(`Unsupported historical Pi storage version: ${version}`);
443
185
  }
444
- }
445
-
446
- function legacyToolResultMessage(input: {
447
- toolCallId: string;
448
- toolName: string;
449
- result: AgentToolResult<unknown>;
450
- isError: boolean;
451
- timestamp: number;
452
- }): ToolResultMessage {
453
- return {
454
- role: "toolResult",
455
- toolCallId: input.toolCallId,
456
- toolName: input.toolName,
457
- content: input.result.content ?? [],
458
- details: input.result.details as JsonValue | undefined,
459
- ...(input.result.usage ? { usage: input.result.usage } : {}),
460
- isError: input.isError,
461
- timestamp: input.timestamp,
186
+ const current = hasTable(sql, "pi_entries");
187
+ const older = hasTable(sql, "pi_session_entries");
188
+ const rows = current || older
189
+ ? sql.exec<{ body: string }>(`SELECT body FROM ${current ? "pi_entries" : "pi_session_entries"} ORDER BY seq`).toArray()
190
+ : [];
191
+ const raw = rows.map(({ body }) => normalizeLegacyAskUserEntry(JSON.parse(body)) as LegacyEntry);
192
+ if (new Set(raw.map((entry) => entry.id)).size !== raw.length) throw new Error("Historical Pi entry IDs are not unique");
193
+ const repaired = repairLegacyEntries(raw);
194
+ const entries = repaired.entries;
195
+ const byId = new Map(entries.map((entry) => [entry.id, entry]));
196
+ const values = current && hasTable(sql, "pi_values")
197
+ ? sql.exec<{ namespace: string; key: string; body: string }>("SELECT namespace,key,body FROM pi_values").toArray()
198
+ : [];
199
+ const read = <T>(namespace: string, key: string): T | undefined => {
200
+ const row = values.find((row) => row.namespace === namespace && row.key === key);
201
+ return row ? JSON.parse(row.body) as T : undefined;
462
202
  };
463
- }
464
-
465
- /**
466
- * Called synchronously during DO startup, before admission or recovery can execute.
467
- * Durable pending work keeps its IDs and state; no old executor runs across this boundary.
468
- * A dry run exercises the real import then rolls back DDL, records, and the format switch together.
469
- */
470
- export function migrateLegacyPiSession(
471
- storage: DurableObjectStorage,
472
- options: { dryRun: boolean },
473
- ): PiSessionMigrationReport {
474
- const sql = storage.sql;
475
- let report!: PiSessionMigrationReport;
476
- const rollbackDryRun = new Error("rollback migration dry run");
477
- try {
478
- storage.transactionSync(() => {
479
- if (hasTable(sql, "pi_storage_state")) {
480
- migrateCurrentPiSession(storage);
481
- const store = new CloudflareSessionStorage(storage);
482
- report = {
483
- sourceEntries: 0,
484
- entries: store.entries().length,
485
- tip: store.branch().at(-1)?.id ?? null,
486
- alreadyMigrated: true,
487
- };
488
- return;
489
- }
490
- if (
491
- !hasTable(sql, "pi_session_entries") ||
492
- !hasTable(sql, "pi_session_metadata")
493
- )
494
- throw new Error("Missing legacy session snapshot");
495
- const metadata = sql
496
- .exec<{
497
- id: string;
498
- created_at: string;
499
- }>("SELECT id, created_at FROM pi_session_metadata WHERE singleton = 1")
500
- .one();
501
- const createdAt = time(metadata.created_at);
502
- const rows = sql
503
- .exec<{
504
- seq: number;
505
- id: string;
506
- body: string;
507
- }>("SELECT seq, id, body FROM pi_session_entries ORDER BY seq")
508
- .toArray();
509
- const records = new Map<string, LegacyRecord>();
510
- for (const row of rows) {
511
- const raw = object(JSON.parse(row.body));
512
- if (raw.id !== row.id || !row.id || records.has(row.id))
513
- throw new Error("Invalid or duplicate legacy entry ID");
514
- if (
515
- raw.parentId !== null &&
516
- (typeof raw.parentId !== "string" || !records.has(raw.parentId))
517
- )
518
- throw new Error(`Missing or forward legacy parent: ${row.id}`);
519
- time(raw.timestamp);
520
- text(raw.type);
521
- records.set(row.id, raw as LegacyRecord);
522
- }
523
- const converted = new Map<string, Entry>();
524
- const resolved = new Map<string, string | null>();
525
- const values: Write[] = [];
526
- const resolve = (id: unknown): string | null => {
527
- if (id === null) return null;
528
- const key = text(id);
529
- if (converted.has(key)) return key;
530
- if (!resolved.has(key))
531
- throw new Error(`Unknown legacy reference: ${key}`);
532
- return resolved.get(key)!;
533
- };
534
- for (const [index, row] of rows.entries()) {
535
- const raw = records.get(row.id)!;
536
- const parentId = resolve(raw.parentId);
537
- const timestamp = time(raw.timestamp);
538
- const base = { id: raw.id, parentId, timestamp, seq: index + 1 };
539
- let entry: Entry | undefined;
540
- switch (raw.type) {
541
- case "message":
542
- entry = { ...base, type: "message", message: message(raw.message) };
543
- break;
544
- case "custom_message":
545
- entry = {
546
- ...base,
547
- type: "message",
548
- message: {
549
- role: "custom",
550
- customType: text(raw.customType),
551
- content: raw.content,
552
- details: raw.details,
553
- display: raw.display,
554
- timestamp,
555
- } as AgentMessage,
556
- };
557
- break;
558
- case "custom":
559
- entry = {
560
- ...base,
561
- type: "custom",
562
- customType: text(raw.customType),
563
- ...(raw.data === undefined
564
- ? {}
565
- : { data: raw.data as JsonValue }),
566
- };
567
- break;
568
- case "branch_summary":
569
- entry = {
570
- ...base,
571
- type: "branch_summary",
572
- fromId: raw.fromId === "root" ? null : resolve(raw.fromId),
573
- summary: text(raw.summary),
574
- fromHook: raw.fromHook === true,
575
- ...(raw.details === undefined
576
- ? {}
577
- : { details: raw.details as JsonValue }),
578
- ...(raw.usage === undefined ? {} : { usage: raw.usage as Usage }),
579
- };
580
- break;
581
- case "compaction": {
582
- let retainedTail: AgentMessage[];
583
- if (Array.isArray(raw.retainedTail))
584
- retainedTail = raw.retainedTail.map(message);
585
- else {
586
- const kept = text(raw.firstKeptEntryId);
587
- const tail: Entry[] = [];
588
- let cursor = raw.parentId;
589
- let found = false;
590
- while (cursor !== null) {
591
- const ancestor = records.get(cursor);
592
- if (!ancestor)
593
- throw new Error(`Missing compaction ancestor: ${cursor}`);
594
- const convertedEntry = converted.get(cursor);
595
- if (convertedEntry) tail.unshift(convertedEntry);
596
- if (cursor === kept) {
597
- found = true;
598
- break;
599
- }
600
- cursor = ancestor.parentId;
601
- }
602
- if (!found)
603
- throw new Error(
604
- `Compaction retained tail is not on its historical branch: ${raw.id}`,
605
- );
606
- retainedTail = tail.flatMap(sessionEntryToContextMessages);
607
- }
608
- entry = {
609
- ...base,
610
- type: "compaction",
611
- summary: text(raw.summary),
612
- retainedTail,
613
- tokensBefore: finite(raw.tokensBefore),
614
- fromHook: raw.fromHook === true,
615
- ...(raw.details === undefined
616
- ? {}
617
- : { details: raw.details as JsonValue }),
618
- ...(raw.usage === undefined ? {} : { usage: raw.usage as Usage }),
619
- };
620
- break;
621
- }
622
- case "leaf":
623
- resolved.set(raw.id, resolve(raw.targetId));
624
- break;
625
- case "label": {
626
- const target = resolve(raw.targetId);
627
- if (!target)
628
- throw new Error(`Label has no content target: ${raw.id}`);
629
- values.push(
630
- raw.label === undefined
631
- ? deleteValue(entryLabel(target))
632
- : setValue(entryLabel(target), text(raw.label)),
633
- );
634
- resolved.set(raw.id, parentId);
635
- break;
636
- }
637
- case "session_info":
638
- if (raw.name !== undefined)
639
- values.push(setValue(sessionName, text(raw.name)));
640
- resolved.set(raw.id, parentId);
641
- break;
642
- case "model_change":
643
- case "thinking_level_change":
644
- case "active_tools_change": {
645
- // Historical settings are values, not conversation entries or a second execution lane.
646
- const setting =
647
- raw.type === "model_change"
648
- ? { provider: text(raw.provider), modelId: text(raw.modelId) }
649
- : raw.type === "thinking_level_change"
650
- ? text(raw.thinkingLevel)
651
- : raw.activeToolNames;
652
- if (setting === undefined)
653
- throw new Error(`Missing legacy configuration: ${raw.id}`);
654
- values.push(
655
- setValue(value(`springbrand.history.${raw.type}`, raw.id), {
656
- at: parentId,
657
- timestamp,
658
- setting,
659
- }),
660
- );
661
- resolved.set(raw.id, parentId);
662
- break;
663
- }
664
- default:
665
- throw new Error(`Unsupported legacy entry type: ${raw.type}`);
666
- }
667
- if (entry) converted.set(raw.id, entry);
668
- }
669
- const tip = rows.length ? resolve(rows.at(-1)!.id) : null;
670
- if (hasTable(sql, "pi_session_message_submission")) {
671
- for (const link of sql
672
- .exec<{
673
- message_id: string;
674
- submission_id: string;
675
- }>(
676
- "SELECT message_id, submission_id FROM pi_session_message_submission",
677
- )
678
- .toArray()) {
679
- if (converted.get(link.message_id)?.type !== "message")
680
- throw new Error(`Dangling message association: ${link.message_id}`);
681
- const persistedSubmission =
682
- hasTable(sql, "pi_submissions") &&
683
- sql
684
- .exec(
685
- "SELECT 1 FROM pi_submissions WHERE submission_id = ?",
686
- link.submission_id,
687
- )
688
- .toArray().length > 0;
689
- if (!persistedSubmission) {
690
- let ancestor = converted.get(link.message_id);
691
- while (
692
- ancestor &&
693
- !(ancestor.type === "message" && ancestor.message.role === "user")
694
- )
695
- ancestor = ancestor.parentId
696
- ? converted.get(ancestor.parentId)
697
- : undefined;
698
- if (!ancestor || link.submission_id !== `import:${ancestor.id}`)
699
- throw new Error(
700
- `Dangling submission association: ${link.submission_id}`,
701
- );
702
- }
203
+ const tipValue = values.find((row) => row.namespace === "pi.branch.tip" && row.key === "main");
204
+ let oldTip: string | null = repaired.resolve(tipValue ? JSON.parse(tipValue.body) as string | null : raw.at(-1)?.id ?? null);
205
+ const hostRows = hasTable(sql, "pi_submissions")
206
+ ? sql.exec<{ submission_id: string; status: string }>("SELECT submission_id,status FROM pi_submissions").toArray()
207
+ : [];
208
+ const hostStatus = new Map(hostRows.map((row) => [row.submission_id, row.status]));
209
+ const lane = read<{ currentOperationId?: string | null }>("pi.lane.state", "main");
210
+ const operationId = lane?.currentOperationId;
211
+ const legacyRunning = older ? hostRows.filter((row) => row.status === "running") : [];
212
+ if (legacyRunning.length > 1) throw new Error("Multiple legacy operations are active in one conversation");
213
+ let active: string | undefined = operationId && read<LegacyResult>("pi.result", operationId) === undefined && !TERMINAL_HOST_STATUSES.has(hostStatus.get(operationId) ?? "")
214
+ ? operationId
215
+ : legacyRunning[0]?.submission_id;
216
+ // Navigation and standalone compaction never committed their target. The Host re-admits the owning
217
+ // Submission from its queued input, so the native runtime performs the whole step itself.
218
+ const intent = active ? read<{ intent?: { kind?: string } }>("pi.op.meta", active)?.intent?.kind ?? (active.endsWith(":regenerate") ? "navigation" : "run") : undefined;
219
+ if (active && intent !== "run") {
220
+ warn(`Dropped unfinished legacy ${intent} operation ${active}`);
221
+ active = undefined;
222
+ }
223
+ const links = hasTable(sql, "pi_session_entry_submission")
224
+ ? sql.exec<{ entry_id: string; submission_id: string }>("SELECT entry_id,submission_id FROM pi_session_entry_submission").toArray()
225
+ : hasTable(sql, "pi_session_message_submission") ? sql.exec<{ entry_id: string; submission_id: string }>("SELECT message_id AS entry_id,submission_id FROM pi_session_message_submission").toArray() : [];
226
+ const owners = new Map(links.map((row) => [row.entry_id, row.submission_id]));
227
+ const sidecars = hasTable(sql, "pi_message_ui")
228
+ ? sql.exec<{ entry_id: string; body: string }>("SELECT message_id AS entry_id,body FROM pi_message_ui").toArray()
229
+ : [];
230
+ const ui = new Map(sidecars.map((row) => [row.entry_id, row.body]));
231
+ // Older versions copied the initial user's view onto later Steers. Keep their canonical input instead.
232
+ if (current && hasTable(sql, "pi_submissions")) {
233
+ const initialUsers = new Map(sql.exec<{ submission_id: string; user_message_id: string | null }>(
234
+ "SELECT submission_id,user_message_id FROM pi_submissions",
235
+ ).toArray().map((row) => [row.submission_id, row.user_message_id]));
236
+ for (const [id, body] of ui) {
237
+ const owner = owners.get(id);
238
+ const entry = byId.get(id);
239
+ if (!owner || entry?.message?.role !== "user" || JSON.parse(body).id !== initialUsers.get(owner)) continue;
240
+ const visited = new Set<string>();
241
+ for (let parent = entry.parentId; parent; parent = byId.get(parent)?.parentId ?? null) {
242
+ if (visited.has(parent)) throw new Error(`Cyclic historical Pi ancestry: ${parent}`);
243
+ visited.add(parent);
244
+ if (owners.get(parent) === owner && byId.get(parent)?.message?.role === "user") {
245
+ ui.delete(id);
246
+ break;
703
247
  }
704
248
  }
705
- const messageOwners = new Map(
706
- hasTable(sql, "pi_session_message_submission")
707
- ? sql
708
- .exec<{ message_id: string; submission_id: string }>(
709
- "SELECT message_id, submission_id FROM pi_session_message_submission",
710
- )
711
- .toArray()
712
- .map((row) => [row.message_id, row.submission_id] as const)
713
- : [],
714
- );
715
- const submissionStatuses = new Map(
716
- hasTable(sql, "pi_submissions")
717
- ? sql
718
- .exec<{ submission_id: string; status: string }>(
719
- "SELECT submission_id, status FROM pi_submissions",
720
- )
721
- .toArray()
722
- .map((row) => [row.submission_id, row.status] as const)
723
- : [],
724
- );
725
- const terminalSubmission = (submissionId: string): boolean =>
726
- LEGACY_TERMINAL_SUBMISSION_STATUSES.has(
727
- submissionStatuses.get(submissionId) ?? "",
728
- );
729
- const results = new Map<string, ToolResultMessage>();
730
- for (const entry of converted.values()) {
731
- if (entry.type !== "message" || entry.message.role !== "toolResult")
732
- continue;
733
- const result = entry.message;
734
- let ancestor = entry.parentId
735
- ? converted.get(entry.parentId)
736
- : undefined;
737
- let found = false;
738
- while (ancestor) {
739
- if (
740
- ancestor.type === "message" &&
741
- ancestor.message.role === "assistant"
742
- ) {
743
- const call = ancestor.message.content.find(
744
- (block) =>
745
- block.type === "toolCall" && block.id === result.toolCallId,
746
- );
747
- if (call?.type === "toolCall") {
748
- if (call.name !== result.toolName)
749
- throw new Error("Mismatched legacy Tool name");
750
- found = true;
751
- break;
752
- }
753
- }
754
- ancestor = ancestor.parentId
755
- ? converted.get(ancestor.parentId)
756
- : undefined;
757
- }
758
- if (!found)
759
- throw new Error(`Orphan legacy Tool result: ${result.toolCallId}`);
760
- const owner = messageOwners.get(entry.id);
761
- if (owner) {
762
- const key = `${owner}:${result.toolCallId}`;
763
- const previous = results.get(key);
764
- if (previous) assertSameToolResult(previous, result);
765
- results.set(key, result);
766
- }
249
+ }
250
+ }
251
+ const commitResult = (id: string, outcome: Message) => {
252
+ const entry: LegacyEntry = { id, parentId: oldTip, type: "message", message: outcome, timestamp: outcome.timestamp };
253
+ entries.push(entry); byId.set(entry.id, entry); owners.set(entry.id, active!); oldTip = entry.id;
254
+ };
255
+ if (older && active && hasTable(sql, "pi_recovery_milestones")) {
256
+ const milestones = sql.exec<{ body: string }>("SELECT body FROM pi_recovery_milestones WHERE submission_id=? ORDER BY seq", active).toArray();
257
+ const recovered = replayPiToolRecovery(milestones.map((row) => row.body));
258
+ const existing = new Set(entries.flatMap((entry) => entry.message?.role === "toolResult" ? [entry.message.toolCallId] : []));
259
+ for (const call of Object.values(recovered.toolCalls)) {
260
+ if (existing.has(call.toolCallId)) continue;
261
+ const pending = Object.values(recovered.approvals).some((row) => row.toolCallId === call.toolCallId && row.status === "pending") || Object.values(recovered.interactions).some((row) => row.toolCallId === call.toolCallId && row.status === "pending");
262
+ const outcome = call.result ?? (!pending && call.retry === "non-idempotent" ? unknownOutcome({ id: call.toolCallId, name: call.toolName }, "Interrupted legacy effect; its outcome is unknown. Reconcile it before repeating it.") : undefined);
263
+ if (outcome) commitResult(`legacy-result:${active}:${call.toolCallId}`, outcome);
264
+ }
265
+ }
266
+ // An operation that cannot be resumed is settled as interrupted. It never replays an uncertain effect.
267
+ let interrupted: string | undefined;
268
+ const operation = active ? read<{ at: string; batch?: ToolBatch }>("pi.op.state", active) : undefined;
269
+ if (active && operation?.at === "tools") {
270
+ const message = operation.batch ? byId.get(operation.batch.assistantEntryId)?.message : undefined;
271
+ const assistant = message?.role === "assistant" ? message : undefined;
272
+ if (!assistant) interrupted = "Historical Tool batch has no canonical assistant";
273
+ for (const call of assistant ? operation.batch!.calls : []) {
274
+ const part = assistant!.content[call.sourceIndex];
275
+ if (typeof part !== "object" || part?.type !== "toolCall") { interrupted = "Historical Tool batch index is invalid"; continue; }
276
+ const tool = part;
277
+ if (call.status === "completed") continue;
278
+ let outcome = read<{ type?: string; payload?: Message }>("pi.pending.entry", call.resultEntryId)?.payload;
279
+ if (outcome && (outcome.role !== "toolResult" || outcome.toolCallId !== tool.id || outcome.toolName !== tool.name)) {
280
+ warn(`Historical Tool outcome ${call.resultEntryId} conflicts with its call`);
281
+ outcome = unknownOutcome(tool);
767
282
  }
768
- if (hasTable(sql, "pi_pending_steers")) {
769
- for (const steer of sql
770
- .exec<{
771
- submission_id: string;
772
- message_id: string;
773
- canonical_json: string;
774
- }>(
775
- "SELECT submission_id, message_id, canonical_json FROM pi_pending_steers",
776
- )
777
- .toArray()) {
778
- if (
779
- !hasTable(sql, "pi_submissions") ||
780
- !sql
781
- .exec(
782
- "SELECT 1 FROM pi_submissions WHERE submission_id = ?",
783
- steer.submission_id,
784
- )
785
- .toArray().length
786
- ) {
787
- throw new Error("Dangling pending steer submission");
788
- }
789
- const pending = message(JSON.parse(steer.canonical_json));
790
- if (pending.role !== "user" || converted.has(steer.message_id)) {
791
- throw new Error("Invalid or already committed pending steer");
792
- }
793
- }
283
+ if (call.status === "outcome_ready" && outcome?.role !== "toolResult") {
284
+ warn(`Historical Tool outcome ${call.resultEntryId} is missing`);
285
+ outcome = unknownOutcome(tool);
794
286
  }
795
- // Business records keep their stable IDs and payloads. Validate ownership before switching.
796
- if (hasTable(sql, "pi_submissions")) {
797
- const submissions = sql
798
- .exec<{
799
- submission_id: string;
800
- status: string;
801
- queued_input_json: string | null;
802
- queued_ui_message_json: string | null;
803
- user_message_id: string | null;
804
- regenerate_message_id: string | null;
805
- }>("SELECT * FROM pi_submissions")
806
- .toArray();
807
- const knownSubmissions = new Set(
808
- submissions.map((row) => row.submission_id),
809
- );
810
- for (const submission of submissions) {
811
- if (submission.status !== "pending" || !submission.queued_input_json)
812
- continue;
813
- const queued = message(JSON.parse(submission.queued_input_json));
814
- if (queued.role !== "user" || !submission.user_message_id) {
815
- throw new Error("Invalid queued user message");
816
- }
817
- if (
818
- submission.queued_ui_message_json &&
819
- object(JSON.parse(submission.queued_ui_message_json)).role !==
820
- "user"
821
- ) {
822
- throw new Error("Invalid queued UI user message");
823
- }
824
- if (submission.regenerate_message_id) {
825
- const target = converted.get(submission.regenerate_message_id);
826
- if (
827
- target?.type !== "message" ||
828
- target.message.role !== "user" ||
829
- !isEqual(
830
- typeof target.message.content === "string"
831
- ? [{ type: "text", text: target.message.content }]
832
- : target.message.content,
833
- typeof queued.content === "string"
834
- ? [{ type: "text", text: queued.content }]
835
- : queued.content,
836
- )
837
- ) {
838
- throw new Error("Invalid queued regeneration target");
839
- }
840
- } else if (converted.has(submission.user_message_id)) {
841
- throw new Error("Queued user message is already committed");
842
- }
287
+ if (call.status === "effect_pending" && call.replay !== "safe") outcome = unknownOutcome(tool);
288
+ if (outcome) commitResult(call.resultEntryId, outcome);
289
+ }
290
+ } else if (active && operation && !RESUMABLE_PHASES.has(operation.at)) {
291
+ interrupted = `Historical operation ${operation.at} cannot resume natively`;
292
+ }
293
+ const branch = (tip: string | null): LegacyEntry[] => {
294
+ const chain: LegacyEntry[] = [];
295
+ for (let id = tip; id; id = byId.get(id)?.parentId ?? null) {
296
+ const entry = byId.get(id);
297
+ if (!entry) throw new Error("Historical branch is invalid");
298
+ chain.unshift(entry);
299
+ }
300
+ return chain;
301
+ };
302
+ let owned = active ? branch(oldTip).filter((entry) => owners.get(entry.id) === active) : [];
303
+ let prompt = owned.find((entry) => entry.message?.role === "user");
304
+ if (active && !prompt) interrupted ??= "Active historical operation has no canonical user input";
305
+ if (active && interrupted) {
306
+ warn(`Settled legacy operation ${active} as interrupted: ${interrupted}`);
307
+ const assistant = [...owned].reverse().find((entry) => entry.message?.role === "assistant")?.message;
308
+ const settled = new Set(owned.flatMap((entry) => entry.message?.role === "toolResult" ? [entry.message.toolCallId] : []));
309
+ for (const part of assistant?.role === "assistant" ? assistant.content : []) {
310
+ if (part.type === "toolCall" && !settled.has(part.id)) commitResult(`legacy-interrupted:${active}:${part.id}`, unknownOutcome(part));
311
+ }
312
+ owned = branch(oldTip).filter((entry) => owners.get(entry.id) === active);
313
+ prompt = owned.find((entry) => entry.message?.role === "user");
314
+ }
315
+ // Pi finished these operations before the Host recorded their terminal; settle them instead of rerunning input.
316
+ const finished = hostRows.flatMap((row) => {
317
+ if (row.submission_id === active || !["pending", "running"].includes(row.status)) return [];
318
+ const result = read<LegacyResult>("pi.result", row.submission_id);
319
+ return result ? [{ id: row.submission_id, result }] : [];
320
+ });
321
+ const converted = new Map<string, EntryRecord>();
322
+ // Where a child continues; a compaction's retained context belongs to every branch through it.
323
+ const anchors = new Map<string, EntryRecord>();
324
+ const conversationByEntry = new Map<string, ConversationId>();
325
+ const tips = new Map<ConversationId, string>();
326
+ const copiedUi: Array<{ id: string; body: string }> = [];
327
+ const copiedLinks: Array<{ id: string; submissionId: string }> = [];
328
+
329
+ await storage.transaction(async () => {
330
+ const initial = await session.commit((tx) => tx.createConversation({ ownership: { kind: "ownerless" } }), BACKGROUND_CONTEXT);
331
+ let selected = initial.id;
332
+ for (const entry of entries) {
333
+ await session.commit(async (tx) => {
334
+ if (!entry.id || (entry.parentId !== null && !byId.has(entry.parentId))) {
335
+ throw new Error(`Historical Pi entry has a missing parent: ${entry.id}`);
843
336
  }
844
- const toolInputs = new Set<string>();
845
- if (hasTable(sql, "pi_recovery_milestones")) {
846
- const bySubmission = new Map<string, string[]>();
847
- for (const row of sql
848
- .exec<{
849
- submission_id: string;
850
- body: string;
851
- }>(
852
- "SELECT submission_id, body FROM pi_recovery_milestones ORDER BY submission_id, seq",
853
- )
854
- .toArray()) {
855
- if (!knownSubmissions.has(row.submission_id))
856
- throw new Error("Dangling recovery submission");
857
- const record = decodePiToolRecoveryMilestone(row.body);
858
- if (!record || record.turnId !== row.submission_id) {
859
- if (LEGACY_TERMINAL_SUBMISSION_STATUSES.has(
860
- submissionStatuses.get(row.submission_id) ?? "",
861
- )) continue;
862
- throw new Error("Invalid legacy recovery milestone");
863
- }
864
- const bodies = bySubmission.get(row.submission_id) ?? [];
865
- bodies.push(row.body);
866
- bySubmission.set(row.submission_id, bodies);
867
- if (record.type === "tool-result" || record.type === "tool-error") {
868
- for (const result of replayPiToolRecovery([row.body])
869
- .canonicalToolResults) {
870
- const key = `${row.submission_id}:${result.toolCallId}`;
871
- const previous = results.get(key);
872
- if (previous && !terminalSubmission(row.submission_id)) {
873
- assertSameToolResult(previous, result);
874
- }
875
- if (!previous || !terminalSubmission(row.submission_id)) {
876
- results.set(key, result);
877
- }
878
- }
879
- }
880
- }
881
- for (const [submissionId, bodies] of bySubmission) {
882
- const recovered = replayPiToolRecovery(bodies);
883
- for (const call of Object.values(recovered.toolCalls)) {
884
- if (call.input !== undefined || call.retry !== undefined)
885
- toolInputs.add(`${submissionId}:${call.toolCallId}`);
886
- if (call.result) {
887
- const key = `${submissionId}:${call.toolCallId}`;
888
- const previous = results.get(key);
889
- if (previous && !terminalSubmission(submissionId)) {
890
- assertSameToolResult(previous, call.result);
891
- }
892
- if (!previous || !terminalSubmission(submissionId)) {
893
- results.set(key, call.result);
894
- }
895
- }
896
- }
897
- }
337
+ const parent = entry.parentId ? anchors.get(entry.parentId) : undefined;
338
+ if (entry.parentId && !parent) throw new Error(`Historical Pi entry is out of order: ${entry.id}`);
339
+ let conversationId = parent ? conversationByEntry.get(entry.parentId!)! : initial.id;
340
+ if (parent && tips.get(conversationId) !== entry.parentId) {
341
+ conversationId = (await tx.forkConversation(conversationId, parent.id, { ownership: { kind: "ownerless" } })).id;
898
342
  }
899
- if (hasTable(sql, "pi_session_message_submission")) {
900
- for (const row of sql
901
- .exec<{
902
- message_id: string;
903
- submission_id: string;
904
- }>(
905
- "SELECT message_id, submission_id FROM pi_session_message_submission",
906
- )
907
- .toArray()) {
908
- const entry = converted.get(row.message_id);
909
- if (
910
- entry?.type === "message" &&
911
- entry.message.role === "assistant"
912
- ) {
913
- for (const block of entry.message.content)
914
- if (block.type === "toolCall")
915
- toolInputs.add(`${row.submission_id}:${block.id}`);
343
+ const message = entry.message;
344
+ let record: EntryRecord;
345
+ let anchor: EntryRecord;
346
+ if (entry.type === "message" && message) {
347
+ const role = (message as { role: string }).role;
348
+ record = anchor = await tx.appendEntry(conversationId, {
349
+ kind: role === "toolResult" ? "pi.tool-result" : role === "custom" ? "springbrand.custom" : `pi.${role}`,
350
+ ...(role === "custom" ? customModel(message, timestamp(entry)) : { model: [message] }),
351
+ data: JSON.parse(JSON.stringify({ displayId: entry.id, createdAt: timestamp(entry), ...(role === "custom" ? { message } : {}), ...(role === "toolResult" ? { diagnostics: [] } : {}) })),
352
+ });
353
+ } else if (entry.type === "compaction" || entry.type === "branch_summary") {
354
+ record = anchor = await tx.appendEntry(conversationId, {
355
+ kind: "pi.compaction", model: [{ role: "user", content: `<summary>\n${entry.summary ?? ""}\n</summary>`, timestamp: timestamp(entry) }],
356
+ head: "self", data: { reason: "manual", displayId: entry.id, createdAt: timestamp(entry) },
357
+ });
358
+ let retainedTail = entry.retainedTail;
359
+ if (!retainedTail && entry.firstKeptEntryId) {
360
+ const tail: Message[] = [];
361
+ let cursor = entry.parentId;
362
+ while (cursor) {
363
+ const ancestor = byId.get(cursor);
364
+ if (!ancestor) throw new Error("Historical compaction has a missing ancestor");
365
+ if (ancestor.message) tail.unshift(ancestor.message);
366
+ if (cursor === entry.firstKeptEntryId) break;
367
+ cursor = ancestor.parentId;
916
368
  }
369
+ if (!cursor) throw new Error("Historical compaction tail is outside its branch");
370
+ retainedTail = tail;
917
371
  }
918
- }
919
- for (const table of [
920
- "pi_tool_settlements",
921
- "pi_approvals",
922
- "pi_tool_interactions",
923
- ]) {
924
- if (!hasTable(sql, table)) continue;
925
- for (const row of sql
926
- .exec<{
927
- submission_id: string;
928
- tool_call_id: string;
929
- }>(`SELECT submission_id, tool_call_id FROM ${table}`)
930
- .toArray()) {
931
- if (
932
- !knownSubmissions.has(row.submission_id) ||
933
- !toolInputs.has(`${row.submission_id}:${row.tool_call_id}`)
934
- )
935
- throw new Error(`Dangling Tool association in ${table}`);
372
+ for (const retained of retainedTail ?? []) {
373
+ anchor = await tx.appendEntry(conversationId, { kind: "springbrand.retained-context", model: [retained] });
936
374
  }
375
+ } else {
376
+ record = anchor = await tx.appendEntry(conversationId, { kind: "springbrand.history", data: { displayId: entry.id, createdAt: timestamp(entry), record: entry as unknown as Record<string, never> } });
937
377
  }
378
+ converted.set(entry.id, record);
379
+ anchors.set(entry.id, anchor);
380
+ conversationByEntry.set(entry.id, conversationId);
381
+ tips.set(conversationId, entry.id);
382
+ if (entry.id === oldTip) selected = conversationId;
383
+ if (owners.has(entry.id)) copiedLinks.push({ id: String(record.id), submissionId: owners.get(entry.id)! });
384
+ if (ui.has(entry.id)) copiedUi.push({ id: String(record.id), body: ui.get(entry.id)! });
385
+ }, BACKGROUND_CONTEXT);
386
+ }
387
+ if (oldTip === null && entries.length) {
388
+ selected = (await session.commit((tx) => tx.createConversation({ ownership: { kind: "ownerless" } }), BACKGROUND_CONTEXT)).id;
389
+ } else if (oldTip && tips.get(selected) !== oldTip) {
390
+ const tip = anchors.get(oldTip);
391
+ if (!tip) throw new Error("Historical branch tip is missing");
392
+ selected = (await session.commit((tx) => tx.forkConversation(tip.conversationId, tip.id, { ownership: { kind: "ownerless" } }), BACKGROUND_CONTEXT)).id;
393
+ }
394
+ await session.commit(async (tx) => {
395
+ const pointer = await tx.doc(RuntimeConversation);
396
+ pointer.id = selected;
397
+ const config = read<Record<string, unknown>>("pi.lane.config", "main");
398
+ if (config) {
399
+ const agent = await tx.doc(AgentDoc, selected);
400
+ if (config.model) agent.model = config.model as { provider: string; modelId: string };
401
+ if (typeof config.thinkingLevel === "string") agent.thinkingLevel = config.thinkingLevel as typeof agent.thinkingLevel;
402
+ if (Array.isArray(config.activeToolNames)) agent.tools = config.activeToolNames as string[];
938
403
  }
939
- if (hasTable(sql, "pi_tool_settlements")) {
940
- for (const row of sql
941
- .exec<{
942
- submission_id: string;
943
- tool_call_id: string;
944
- tool_name: string;
945
- result: string;
946
- is_error: number;
947
- created_at: number;
948
- }>(
949
- "SELECT submission_id, tool_call_id, tool_name, result, is_error, created_at FROM pi_tool_settlements",
950
- )
951
- .toArray()) {
952
- const expected = legacyToolResultMessage({
953
- toolCallId: row.tool_call_id,
954
- toolName: row.tool_name,
955
- result: JSON.parse(row.result) as AgentToolResult<unknown>,
956
- isError: row.is_error === 1,
957
- timestamp: row.created_at,
404
+ const selectedBranch = branch(oldTip);
405
+ for (const { id, result } of finished) {
406
+ const ownedBySubmission = selectedBranch.filter((entry) => owners.get(entry.id) === id);
407
+ const input = ownedBySubmission.find((entry) => entry.message?.role === "user");
408
+ const reply = [...ownedBySubmission].reverse().find((entry) => entry.message?.role === "assistant");
409
+ const entry = input ? converted.get(input.id)!.id : undefined;
410
+ if ((result.status === "completed" || result.status === "declined") && entry !== undefined && reply) {
411
+ await tx.createSubmission({ conversationId: selected, requestId: id, type: "input", status: "done", entry, answer: converted.get(reply.id)!.id });
412
+ } else {
413
+ await tx.createSubmission({
414
+ conversationId: selected, requestId: id, type: "input", status: "unanswered",
415
+ reason: result.status === "aborted" ? "aborted" : result.status ?? "failed",
416
+ detail: result.error?.message ?? (result.status === "aborted" ? "Cancelled" : "The legacy request ended without an answer."),
417
+ ...(entry !== undefined ? { entry } : {}),
958
418
  });
959
- const recorded = results.get(
960
- `${row.submission_id}:${row.tool_call_id}`,
961
- );
962
- if (recorded && !terminalSubmission(row.submission_id)) {
963
- assertSameToolResult(recorded, expected);
964
- }
965
419
  }
966
420
  }
967
- // The old table remains an inert backup. Failure rolls back DDL, data and the version together.
968
- sql.exec(
969
- "ALTER TABLE pi_session_entries RENAME TO pi_legacy_entries_backup",
970
- );
971
- const store = new CloudflareSessionStorage(storage);
972
- removeRetiredRuntimeArtifacts(sql);
973
- for (const entry of converted.values()) {
974
- const { seq: _seq, timestamp, ...next } = entry;
975
- const writes: Write[] = [insertEntry(next)];
976
- const usage =
977
- entry.type === "message" &&
978
- (entry.message.role === "assistant" ||
979
- entry.message.role === "toolResult")
980
- ? entry.message.usage
981
- : entry.type === "compaction" || entry.type === "branch_summary"
982
- ? entry.usage
983
- : undefined;
984
- if (usage) {
985
- let usageId = `migration:usage:${entry.id}`;
986
- while (records.has(usageId)) usageId = `migration:${usageId}`;
987
- writes.push(
988
- insertUsage({
989
- id: usageId,
990
- entryId: entry.id,
991
- usage,
992
- adjustment: false,
993
- }),
994
- );
421
+ if (active && interrupted) {
422
+ await tx.createSubmission({
423
+ conversationId: selected, requestId: active, type: "input", status: "unanswered", reason: "interrupted", detail: INTERRUPTED,
424
+ ...(prompt ? { entry: converted.get(prompt.id)!.id } : {}),
425
+ });
426
+ } else if (active && prompt) {
427
+ const promptEntry = converted.get(prompt.id)!;
428
+ const submission = await tx.createSubmission({ conversationId: selected, requestId: active, type: "input", status: "placed", entry: promptEntry.id });
429
+ const assistant = [...owned].reverse().find((entry) => entry.message?.role === "assistant");
430
+ const settledCalls = new Set(owned.flatMap((entry) => entry.message?.role === "toolResult" ? [entry.message.toolCallId] : []));
431
+ const calls = assistant?.message?.role === "assistant"
432
+ ? assistant.message.content.filter((part): part is import("@earendil-works/pi-ai").ToolCall => part.type === "toolCall").filter((part) => !settledCalls.has(part.id))
433
+ : [];
434
+ // Initialize the official generation checkpoint; only unfinished calls enter its pending list.
435
+ const checkpoint: GenerationCheckpoint = assistant && calls.length
436
+ ? { phase: "tools", assistant: converted.get(assistant.id)!.id, tools: [], pending: calls.map((call) => call.id) }
437
+ : { phase: "prepare", attempt: 0 };
438
+ const generation = await tx.createTask({ definition: { ...GenerationTask.definition, initial: () => checkpoint } }, {}, { conversationId: selected, ownership: { kind: "conversation" } });
439
+ const live = await tx.doc(LiveDoc, selected);
440
+ live.run = { taskId: generation, inputs: [submission.id] };
441
+ if (calls.length) live.tools = calls.map((call) => ({ callId: call.id, name: call.name, status: "pending" }));
442
+ }
443
+ (await tx.doc(Migration)).completed = true;
444
+ }, BACKGROUND_CONTEXT);
445
+ sql.exec("CREATE TABLE IF NOT EXISTS pi_session_entry_submission (entry_id TEXT PRIMARY KEY, submission_id TEXT NOT NULL)");
446
+ for (const link of copiedLinks) sql.exec("INSERT OR IGNORE INTO pi_session_entry_submission VALUES (?,?)", link.id, link.submissionId);
447
+ for (const sidecar of copiedUi) sql.exec("INSERT OR IGNORE INTO pi_message_ui VALUES (?,?)", sidecar.id, sidecar.body);
448
+ if (hasTable(sql, "pi_pending_steers")) {
449
+ // Old Pi committed these Steers but the Host receipt survived; a native requeue would duplicate them.
450
+ const steers = sql.exec<{ message_id: string; harness_entry_id: string | null; ui_message_json: string | null }>("SELECT message_id,harness_entry_id,ui_message_json FROM pi_pending_steers").toArray();
451
+ for (const steer of steers) {
452
+ const record = converted.get(steer.harness_entry_id ?? "") ?? converted.get(steer.message_id);
453
+ if (!record) continue;
454
+ if (steer.ui_message_json) {
455
+ sql.exec("CREATE TABLE IF NOT EXISTS pi_message_ui (message_id TEXT PRIMARY KEY, body TEXT NOT NULL)");
456
+ sql.exec("INSERT OR IGNORE INTO pi_message_ui VALUES (?,?)", String(record.id), steer.ui_message_json);
995
457
  }
996
- const result = store.commitSync(writes);
997
- // Import timestamps belong to the historical snapshot, not the time of the migration.
998
- sql.exec(
999
- "UPDATE pi_entries SET timestamp = ?, body = json_set(body, '$.timestamp', ?) WHERE id = ?",
1000
- timestamp,
1001
- timestamp,
1002
- entry.id,
1003
- );
1004
- entry.seq = result.firstSeq;
458
+ sql.exec("DELETE FROM pi_pending_steers WHERE message_id=?", steer.message_id);
1005
459
  }
1006
- const parents = new Set(
1007
- [...converted.values()].map((entry) => entry.parentId),
1008
- );
1009
- for (const entry of converted.values()) {
1010
- if (entry.id !== tip && !parents.has(entry.id))
1011
- values.push(setValue(branchTip(`history:${entry.id}`), entry.id));
460
+ }
461
+ for (const table of ["pi_approvals", "pi_tool_interactions"]) {
462
+ if (!hasTable(sql, table)) continue;
463
+ const rows = sql.exec<{ rowid: number; input_json: string }>(`SELECT rowid,input_json FROM ${table} WHERE tool_name='ask_user'`).toArray();
464
+ for (const row of rows) sql.exec(`UPDATE ${table} SET input_json=? WHERE rowid=?`, JSON.stringify(normalizeLegacyAskUserInput(JSON.parse(row.input_json))), row.rowid);
465
+ // An interrupted operation can no longer continue its parked call, so its decision must not park Host recovery.
466
+ if (active && interrupted && hasColumns(sql, table, ["submission_id", "status"])) {
467
+ sql.exec(`UPDATE ${table} SET status=? WHERE submission_id=? AND status='pending'`, table === "pi_approvals" ? "rejected" : "cancelled", active);
1012
468
  }
1013
- store.commitSync([...values, setValue(branchTip("main"), tip)]);
1014
- repairDuplicatedUserSidecars(sql);
1015
- sql.exec(
1016
- "UPDATE pi_storage_state SET session_id = ?, created_at = ? WHERE singleton = 1",
1017
- metadata.id,
1018
- createdAt,
1019
- );
1020
- report = {
1021
- sourceEntries: rows.length,
1022
- entries: converted.size,
1023
- tip,
1024
- alreadyMigrated: false,
1025
- };
1026
- if (options.dryRun) throw rollbackDryRun;
1027
- });
1028
- } catch (error) {
1029
- if (error !== rollbackDryRun) throw error;
1030
- }
1031
- return report;
469
+ }
470
+ if (hasTable(sql, "cf_agents_schedules")) {
471
+ for (const callback of ["_cleanupStreamBuffers", "_piPlannedContinuation", "_piContinuationWatchdog", "_piApprovalContinuation", "_chatRecoveryRetry"]) sql.exec("DELETE FROM cf_agents_schedules WHERE callback=?", callback);
472
+ }
473
+ });
474
+ }
475
+
476
+ function hasColumns(sql: SqlStorage, table: string, names: string[]): boolean {
477
+ const columns = new Set(sql.exec<{ name: string }>(`PRAGMA table_info(${table})`).toArray().map((row) => row.name));
478
+ return names.every((name) => columns.has(name));
479
+ }
480
+
481
+ const LEGACY_TABLES = [
482
+ "pi_entries", "pi_values", "pi_lists", "pi_usage", "pi_storage_state", "pi_session_entries", "pi_session_metadata",
483
+ "pi_legacy_entries_backup", "pi_recovery_milestones", "pi_session_message_submission", "pi_tool_settlements",
484
+ ];
485
+
486
+ /** Drops the inert pre-upgrade baseline once the migrated history it backs up is deleted. */
487
+ export async function dropLegacyPiTables(storage: DurableObjectStorage, session: Session): Promise<void> {
488
+ if (!(await session.snapshot(Migration, BACKGROUND_CONTEXT))?.completed) throw new Error("Legacy Pi tables are still the migration source");
489
+ for (const table of LEGACY_TABLES) storage.sql.exec(`DROP TABLE IF EXISTS ${table}`);
1032
490
  }