@springbrand/agent-runtime 0.3.0-alpha.2 → 0.3.0-alpha.21

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.
Files changed (131) hide show
  1. package/package.json +11 -9
  2. package/src/adapter/cloudflare/agent.ts +233 -179
  3. package/src/adapter/cloudflare/define-agent.ts +252 -104
  4. package/src/adapter/cloudflare/index.ts +18 -0
  5. package/src/adapter/cloudflare/scheduling/schedule-rule.ts +322 -0
  6. package/src/adapter/cloudflare/scheduling/zoned-cron.ts +11 -6
  7. package/src/adapter/cloudflare/schema.ts +21 -2
  8. package/src/adapter/cloudflare/subagent/definition.ts +25 -13
  9. package/src/adapter/cloudflare/subagent/runner.ts +43 -50
  10. package/src/adapter/cloudflare/subagent/tools.ts +4 -4
  11. package/src/adapter/cloudflare/universal-agent/preparation.ts +3 -3
  12. package/src/adapter/cloudflare/universal-agent/tools.ts +4 -6
  13. package/src/adapter/cloudflare/workspace/version-control.ts +1 -1
  14. package/src/config/activation.ts +13 -15
  15. package/src/config/agent-context.ts +1 -1
  16. package/src/config/assembler.ts +48 -31
  17. package/src/config/assembly-snapshot.ts +1 -1
  18. package/src/config/definition.ts +1 -1
  19. package/src/config/harness-runtime.ts +77 -49
  20. package/src/config/index.ts +1 -1
  21. package/src/config/model-guidance.ts +20 -0
  22. package/src/config/ports.ts +57 -7
  23. package/src/config/{pinning.ts → prepared-runtime.ts} +59 -123
  24. package/src/config/public-contracts.ts +2 -2
  25. package/src/config/runtime-config.ts +2 -1
  26. package/src/context/assembly.ts +1 -1
  27. package/src/context/extension/assembly.ts +2 -2
  28. package/src/context/memory/budget/gate.ts +19 -3
  29. package/src/conversation/harness-resume.ts +89 -238
  30. package/src/conversation/harness-stream.ts +87 -71
  31. package/src/conversation/history.ts +154 -152
  32. package/src/conversation/message/contract.ts +28 -4
  33. package/src/conversation/message/conversion.ts +46 -5
  34. package/src/conversation/message/projection.ts +61 -21
  35. package/src/conversation/session-entry.ts +29 -0
  36. package/src/conversation/state.ts +23 -2
  37. package/src/harness/governance.ts +206 -0
  38. package/src/harness/index.ts +3 -0
  39. package/src/harness/session-harness.ts +269 -445
  40. package/src/harness/submission-operation.ts +15 -40
  41. package/src/index.ts +18 -8
  42. package/src/lib/artifacts.ts +18 -0
  43. package/src/lib/execution-level.ts +13 -0
  44. package/src/lib/prompt.ts +9 -10
  45. package/src/lib/token-estimate.ts +8 -0
  46. package/src/model-protocol/openrouter.ts +41 -0
  47. package/src/models/index.ts +200 -35
  48. package/src/models/openrouter/messages.ts +13 -8
  49. package/src/models/openrouter/openai.ts +200 -0
  50. package/src/models/openrouter/protocol-response.ts +85 -0
  51. package/src/models/provider-streams.ts +45 -0
  52. package/src/session/cloudflare/migration.ts +382 -864
  53. package/src/session/cloudflare/storage.ts +13 -536
  54. package/src/session/index.ts +22 -35
  55. package/src/skills/springbrand-worker-website/index.ts +1 -1
  56. package/src/submission/admission.ts +20 -30
  57. package/src/submission/contracts.ts +42 -2
  58. package/src/submission/delivery.ts +1 -1
  59. package/src/submission/finalization.ts +36 -15
  60. package/src/submission/lifecycle.ts +28 -15
  61. package/src/submission/repository.ts +97 -49
  62. package/src/submission/steer-delivery.ts +32 -76
  63. package/src/submission/unattended.ts +40 -0
  64. package/src/telemetry/contract.ts +0 -1
  65. package/src/telemetry/coordinator.ts +1 -1
  66. package/src/telemetry/runtime-telemetry.ts +0 -4
  67. package/src/telemetry/subagent-lifecycle.ts +1 -1
  68. package/src/telemetry/turn-events.ts +35 -25
  69. package/src/tools/README.md +44 -0
  70. package/src/tools/{ai-adapter.ts → adapters/ai-to-pi.ts} +6 -9
  71. package/src/tools/{declared.ts → adapters/declared.ts} +6 -2
  72. package/src/tools/{nested-tools.ts → adapters/pi-to-ai.ts} +4 -4
  73. package/src/tools/assistant/ask-user.ts +319 -0
  74. package/src/tools/assistant/candidate.ts +15 -0
  75. package/src/tools/assistant/followups.ts +34 -0
  76. package/src/tools/assistant/index.ts +19 -0
  77. package/src/tools/assistant/memory.ts +73 -0
  78. package/src/tools/assistant/plan.ts +98 -0
  79. package/src/tools/{time.ts → assistant/time.ts} +1 -1
  80. package/src/tools/{core-host.ts → codemode/adapter.ts} +50 -53
  81. package/src/tools/{core.ts → codemode/index.ts} +36 -92
  82. package/src/tools/codemode/workspace-connector.ts +198 -0
  83. package/src/tools/extension/index.ts +42 -0
  84. package/src/tools/index.ts +29 -26
  85. package/src/tools/{gateway.ts → mcp/gateway.ts} +19 -19
  86. package/src/tools/{mcp.ts → mcp/index.ts} +5 -5
  87. package/src/tools/runtime/activation.ts +40 -0
  88. package/src/tools/{approval → runtime/approval}/repository.ts +13 -12
  89. package/src/tools/runtime/compiler.ts +246 -0
  90. package/src/tools/runtime/contracts.ts +140 -0
  91. package/src/tools/{decision-gate.ts → runtime/decision-gate.ts} +149 -39
  92. package/src/tools/{human-decisions.ts → runtime/human-decisions.ts} +4 -4
  93. package/src/tools/{interaction → runtime/interaction}/repository.ts +1 -7
  94. package/src/tools/runtime/result.ts +22 -0
  95. package/src/tools/runtime/unattended-error.ts +4 -0
  96. package/src/tools/sandbox/index.ts +228 -0
  97. package/src/tools/schedule/index.ts +301 -0
  98. package/src/tools/skill/contracts.ts +20 -0
  99. package/src/tools/skill/index.ts +99 -0
  100. package/src/tools/skill/resources.ts +322 -0
  101. package/src/tools/skill/scripts.ts +168 -0
  102. package/src/tools/{subagent-authority.ts → subagent/authority.ts} +31 -31
  103. package/src/tools/{subagent.ts → subagent/index.ts} +34 -37
  104. package/src/tools/{subagent-run-repository.ts → subagent/repository.ts} +1 -1
  105. package/src/tools/{subagent-runtime.ts → subagent/runtime.ts} +1 -1
  106. package/src/tools/{temporary-agent → subagent/temporary}/approvals.ts +4 -7
  107. package/src/tools/{temporary-agent → subagent/temporary}/core.ts +2 -3
  108. package/src/tools/{temporary-agent → subagent/temporary}/runner.ts +1 -1
  109. package/src/tools/{temporary-agent → subagent/temporary}/workspace.ts +1 -4
  110. package/src/tools/{web-fetch.ts → web/fetch.ts} +4 -13
  111. package/src/tools/web/index.ts +2 -0
  112. package/src/tools/web/search/api.ts +1322 -0
  113. package/src/tools/web/search/index.ts +2 -0
  114. package/src/tools/{web-search/web-search.ts → web/search/search.ts} +5 -7
  115. package/src/tools/{workspace-sandbox.ts → workspace/index.ts} +12 -272
  116. package/src/tools/{workspace-revision.ts → workspace/revision.ts} +3 -3
  117. package/src/tools/base.ts +0 -534
  118. package/src/tools/compiler.ts +0 -327
  119. package/src/tools/schedule.ts +0 -261
  120. package/src/tools/skill.ts +0 -565
  121. package/src/tools/web-search/api.ts +0 -1318
  122. package/src/tools/web-search/index.ts +0 -2
  123. package/src/tools/workspace-state-connector.ts +0 -146
  124. package/src/tools/{approval → runtime/approval}/index.ts +0 -0
  125. package/src/tools/{interaction → runtime/interaction}/index.ts +0 -0
  126. package/src/tools/{temporary-agent → subagent/temporary}/coordinator.ts +0 -0
  127. package/src/tools/{subagent-types → subagent/types}/contract.ts +0 -0
  128. package/src/tools/{subagent-types → subagent/types}/extract/index.ts +0 -0
  129. package/src/tools/{subagent-types → subagent/types}/fanout/index.ts +0 -0
  130. package/src/tools/{subagent-types → subagent/types}/registry.ts +1 -1
  131. /package/src/tools/{workspace-versioning.ts → workspace/versioning.ts} +0 -0
@@ -1,109 +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;
11
+ const Migration = defineDoc({
12
+ kind: "springbrand.pi-upgrade", version: 1, scope: "session",
13
+ initial: (): { completed: boolean } => ({ completed: false }),
14
+ });
45
15
 
46
- type LegacyRecord = Record<string, unknown> & {
47
- id: string;
48
- parentId: string | null;
49
- type: string;
50
- 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;
51
20
  };
52
- export interface PiSessionMigrationReport {
53
- sourceEntries: number;
54
- entries: number;
55
- tip: string | null;
56
- alreadyMigrated: boolean;
57
- }
58
-
59
- function object(input: unknown): Record<string, unknown> {
60
- if (!input || typeof input !== "object" || Array.isArray(input))
61
- throw new Error("Invalid legacy object");
62
- return input as Record<string, unknown>;
63
- }
64
- function text(input: unknown): string {
65
- if (typeof input !== "string") throw new Error("Invalid legacy string");
66
- return input;
67
- }
68
- function time(input: unknown): number {
69
- const timestamp = Date.parse(text(input));
70
- if (!Number.isSafeInteger(timestamp) || timestamp < 0)
71
- throw new Error("Invalid legacy timestamp");
72
- return timestamp;
73
- }
74
- function finite(input: unknown): number {
75
- if (typeof input !== "number" || !Number.isFinite(input) || input < 0)
76
- throw new Error("Invalid legacy number");
77
- return input;
78
- }
79
- function message(input: unknown): AgentMessage {
80
- const record = object(input);
81
- if (
82
- ![
83
- "user",
84
- "assistant",
85
- "toolResult",
86
- "custom",
87
- "bashExecution",
88
- "branchSummary",
89
- "compactionSummary",
90
- ].includes(text(record.role))
91
- )
92
- throw new Error("Unknown legacy message role");
93
- finite(record.timestamp);
94
- if (record.role === "assistant" && record.stopReason === "pending")
95
- throw new Error("Unsettled legacy assistant message");
96
- return normalizeLegacyAskUserMessage(record) as AgentMessage;
97
- }
98
21
  function hasTable(sql: SqlStorage, name: string): boolean {
99
- return (
100
- sql
101
- .exec(
102
- "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?",
103
- name,
104
- )
105
- .toArray().length > 0
106
- );
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;
107
28
  }
108
29
 
109
30
  const ASK_USER_RESPONSE_TYPES = new Set([
@@ -171,802 +92,399 @@ function normalizeLegacyAskUserEntry(value: unknown): unknown {
171
92
  return value;
172
93
  }
173
94
 
174
- function migrateLegacyAskUserData(sql: SqlStorage): void {
175
- if (hasTable(sql, "pi_entries")) {
176
- for (const row of sql.exec<{ id: string; body: string }>(
177
- "SELECT id, body FROM pi_entries",
178
- ).toArray()) {
179
- sql.exec(
180
- "UPDATE pi_entries SET body = ? WHERE id = ?",
181
- JSON.stringify(normalizeLegacyAskUserEntry(JSON.parse(row.body))),
182
- row.id,
183
- );
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.";
101
+
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 }> };
104
+
105
+ function warn(message: string): void {
106
+ console.warn("[pi-session-migration]", message);
107
+ }
108
+
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 }] };
115
+ }
116
+
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() };
119
+ }
120
+
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)!;
184
132
  }
185
- }
186
- if (hasTable(sql, "pi_tool_interactions")) {
187
- for (const row of sql.exec<{ interaction_id: string; input_json: string }>(
188
- "SELECT interaction_id, input_json FROM pi_tool_interactions WHERE tool_name = 'ask_user'",
189
- ).toArray()) {
190
- sql.exec(
191
- "UPDATE pi_tool_interactions SET input_json = ? WHERE interaction_id = ?",
192
- JSON.stringify(normalizeLegacyAskUserInput(JSON.parse(row.input_json))),
193
- row.interaction_id,
194
- );
133
+ return id;
134
+ };
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);
141
+ };
142
+ if (source.type === "leaf") {
143
+ alias.set(source.id, resolve(typeof source.targetId === "string" ? source.targetId : source.targetId === null ? null : parentId));
144
+ continue;
195
145
  }
196
- }
197
- if (hasTable(sql, "pi_values")) {
198
- for (const row of sql.exec<{ key: string; body: string }>(
199
- "SELECT key, body FROM pi_values WHERE namespace = 'pi.op.state'",
200
- ).toArray()) {
201
- const state = JSON.parse(row.body) as {
202
- batch?: {
203
- assistantEntryId?: unknown;
204
- turnId?: unknown;
205
- };
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,
206
152
  };
207
- const assistantEntryId = state.batch?.assistantEntryId;
208
- const turnId = state.batch?.turnId;
209
- if (typeof assistantEntryId !== "string" || typeof turnId !== "string") {
210
- continue;
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; }
211
170
  }
212
- const entryRow = sql.exec<{ body: string }>(
213
- "SELECT body FROM pi_entries WHERE id = ?",
214
- assistantEntryId,
215
- ).toArray()[0];
216
- if (!entryRow) continue;
217
- const entry = JSON.parse(entryRow.body) as {
218
- type?: unknown;
219
- message?: { role?: unknown; content?: unknown };
220
- };
221
- if (
222
- entry.type !== "message" || entry.message?.role !== "assistant" ||
223
- !Array.isArray(entry.message.content)
224
- ) continue;
225
- entry.message.content.forEach((part, sourceIndex) => {
226
- if (!part || typeof part !== "object" || Array.isArray(part)) return;
227
- const block = part as Record<string, unknown>;
228
- if (block.type !== "toolCall" || block.name !== "ask_user") return;
229
- const key = `${row.key}:${turnId}:${sourceIndex}`;
230
- const args = sql.exec<{ body: string }>(
231
- "SELECT body FROM pi_values WHERE namespace = 'pi.op.tool_args' AND key = ?",
232
- key,
233
- ).toArray()[0];
234
- if (!args) return;
235
- sql.exec(
236
- "UPDATE pi_values SET body = ? WHERE namespace = 'pi.op.tool_args' AND key = ?",
237
- JSON.stringify(normalizeLegacyAskUserInput(JSON.parse(args.body))),
238
- key,
239
- );
240
- });
241
171
  }
172
+ kept.set(entry.id, entry);
173
+ entries.push(entry);
242
174
  }
175
+ return { entries, resolve };
243
176
  }
244
177
 
245
- function removeRetiredRuntimeArtifacts(sql: SqlStorage): void {
246
- sql.exec(
247
- "CREATE TABLE IF NOT EXISTS pi_session_entry_submission (entry_id TEXT PRIMARY KEY, submission_id TEXT NOT NULL)",
248
- );
249
- if (hasTable(sql, "pi_session_message_submission")) {
250
- sql.exec(`
251
- INSERT OR IGNORE INTO pi_session_entry_submission (entry_id, submission_id)
252
- SELECT message_id, submission_id FROM pi_session_message_submission;
253
- DROP TABLE pi_session_message_submission;
254
- `);
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}`);
255
185
  }
256
- if (hasTable(sql, "cf_agents_schedules")) {
257
- const placeholders = RETIRED_SCHEDULE_CALLBACKS.map(() => "?").join(", ");
258
- sql.exec(
259
- `DELETE FROM cf_agents_schedules WHERE callback IN (${placeholders})`,
260
- ...RETIRED_SCHEDULE_CALLBACKS,
261
- );
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;
202
+ };
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;
262
222
  }
263
- migrateLegacyAskUserData(sql);
264
- }
265
-
266
- /** Older Harness adapters copied the initial user sidecar onto consumed Steers. */
267
- function repairDuplicatedUserSidecars(sql: SqlStorage): void {
268
- if (!hasTable(sql, "pi_message_ui") || !hasTable(sql, "pi_submissions")) return;
269
- const entries = new Map(sql.exec<{
270
- id: string;
271
- parent_id: string | null;
272
- body: string;
273
- submission_id: string | null;
274
- }>(`
275
- SELECT entry.id, entry.parent_id, entry.body, link.submission_id
276
- FROM pi_entries AS entry
277
- LEFT JOIN pi_session_entry_submission AS link ON link.entry_id = entry.id
278
- `).toArray().map((row) => [row.id, { ...row, entry: JSON.parse(row.body) as Entry }]));
279
- const candidates = sql.exec<{ message_id: string }>(`
280
- SELECT ui.message_id
281
- FROM pi_message_ui AS ui
282
- JOIN pi_session_entry_submission AS link ON link.entry_id = ui.message_id
283
- JOIN pi_submissions AS submission ON submission.submission_id = link.submission_id
284
- WHERE json_extract(ui.body, '$.id') = submission.user_message_id
285
- `).toArray();
286
- for (const candidate of candidates) {
287
- const current = entries.get(candidate.message_id);
288
- if (current?.entry.type !== "message" || current.entry.message.role !== "user") continue;
289
- let parentId = current.parent_id;
290
- const visited = new Set<string>();
291
- while (parentId !== null) {
292
- if (visited.has(parentId)) throw new Error(`Cyclic Pi ancestry: ${parentId}`);
293
- visited.add(parentId);
294
- const parent = entries.get(parentId);
295
- if (!parent) throw new Error(`Missing Pi ancestor: ${parentId}`);
296
- if (parent.submission_id === current.submission_id &&
297
- parent.entry.type === "message" && parent.entry.message.role === "user") {
298
- // The copied parts are also wrong: canonical Steer content is authoritative.
299
- sql.exec("DELETE FROM pi_message_ui WHERE message_id = ?", candidate.message_id);
300
- break;
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;
247
+ }
301
248
  }
302
- parentId = parent.parent_id;
303
249
  }
304
250
  }
305
- }
306
-
307
- function hasToolExecution(value: ToolExecution | undefined): boolean {
308
- return value?.status === "blocked" ||
309
- (value?.status === "executed" && value.args !== null &&
310
- typeof value.args === "object" && !Array.isArray(value.args));
311
- }
312
-
313
- /** Old active tool outcomes cannot be reconstructed from error text or approval facts. */
314
- function assertActiveToolProvenance(sql: SqlStorage): void {
315
- const read = <T>(address: Value<T>): T | undefined => {
316
- const row = sql.exec<{ body: string }>(
317
- "SELECT body FROM pi_values WHERE namespace = ? AND key = ?",
318
- address.namespace, address.key,
319
- ).toArray()[0];
320
- return row ? JSON.parse(row.body) as T : undefined;
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;
321
254
  };
322
- const lanes = sql.exec<{ key: string; body: string }>(
323
- "SELECT key, body FROM pi_values WHERE namespace = ?",
324
- laneState("").namespace,
325
- ).toArray();
326
- for (const lane of lanes) {
327
- const operationId = (JSON.parse(lane.body) as LaneState).currentOperationId;
328
- if (operationId === null) continue;
329
- const reject: (reason: string) => never = (reason) => {
330
- throw new Error(
331
- `Cannot upgrade Pi storage while operation ${operationId} is active: ${reason}. ` +
332
- "Complete this operation with the previous runtime version, then retry the upgrade.",
333
- );
334
- };
335
- const meta = read(operationMeta(operationId));
336
- const state = read(operationState(operationId));
337
- if (!meta || !state) reject("its durable operation metadata is incomplete");
338
- let tip = read(branchTip(lane.key));
339
- if (tip === undefined) reject("its lane branch tip is missing");
340
- const visited = new Set<string>();
341
- while (tip !== meta.sourceTipId) {
342
- if (tip === null || visited.has(tip)) reject("its operation branch cannot be verified");
343
- visited.add(tip);
344
- const row = sql.exec<{ body: string }>("SELECT body FROM pi_entries WHERE id = ?", tip).toArray()[0];
345
- if (!row) reject(`its canonical entry ${tip} is missing`);
346
- const entry = JSON.parse(row.body) as Entry;
347
- if (entry.type === "message" && entry.message.role === "toolResult" &&
348
- !hasToolExecution(entry.toolExecution)) {
349
- reject(`ToolResult ${entry.id} has no reliable execution provenance`);
350
- }
351
- tip = entry.parentId;
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);
352
264
  }
353
- if (state.at !== "tools") continue;
354
- for (const call of state.batch.calls) {
355
- if (call.status === "planned" || call.status === "completed") continue;
356
- if (call.status === "effect_pending") {
357
- reject(`Tool invocation ${call.resultEntryId} has an unresolved execution outcome`);
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);
358
282
  }
359
- const pending = read(pendingEntry(call.resultEntryId));
360
- if (pending?.type !== "message" || pending.payload.role !== "toolResult" ||
361
- !hasToolExecution(pending.toolExecution)) {
362
- reject(`pending ToolResult ${call.resultEntryId} has no reliable execution provenance`);
283
+ if (call.status === "outcome_ready" && outcome?.role !== "toolResult") {
284
+ warn(`Historical Tool outcome ${call.resultEntryId} is missing`);
285
+ outcome = unknownOutcome(tool);
363
286
  }
287
+ if (call.status === "effect_pending" && call.replay !== "safe") outcome = unknownOutcome(tool);
288
+ if (outcome) commitResult(call.resultEntryId, outcome);
364
289
  }
290
+ } else if (active && operation && !RESUMABLE_PHASES.has(operation.at)) {
291
+ interrupted = `Historical operation ${operation.at} cannot resume natively`;
365
292
  }
366
- }
367
-
368
- /** Upgrade an already-Harness-backed Session before normal reads begin. */
369
- export function migrateCurrentPiSession(
370
- storage: DurableObjectStorage,
371
- ): boolean {
372
- return storage.transactionSync(() => {
373
- const sql = storage.sql;
374
- if (!hasTable(sql, "pi_storage_state")) return false;
375
- const version = sql.exec<{ storage_version: number }>(
376
- "SELECT storage_version FROM pi_storage_state WHERE singleton = 1",
377
- ).one().storage_version;
378
- if (version === CURRENT_PI_STORAGE_VERSION) return false;
379
- if (version !== 1 && version !== 2) {
380
- throw new Error(`Unsupported Cloudflare Pi storage version: ${version}`);
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);
381
299
  }
382
- assertActiveToolProvenance(sql);
383
- if (version === 1) removeRetiredRuntimeArtifacts(sql);
384
- repairDuplicatedUserSidecars(sql);
385
- sql.exec(
386
- "UPDATE pi_storage_state SET storage_version = ? WHERE singleton = 1",
387
- CURRENT_PI_STORAGE_VERSION,
388
- );
389
- return true;
390
- });
391
- }
392
-
393
- function assertSameToolResult(
394
- left: ToolResultMessage,
395
- right: ToolResultMessage,
396
- ): void {
397
- for (const key of [
398
- "toolCallId",
399
- "toolName",
400
- "content",
401
- "details",
402
- "usage",
403
- "addedToolNames",
404
- "isError",
405
- ] as const) {
406
- if (!isEqual(left[key], right[key]))
407
- throw new Error(`Conflicting legacy Tool result: ${left.toolCallId}`);
408
- }
409
- }
410
-
411
- function legacyToolResultMessage(input: {
412
- toolCallId: string;
413
- toolName: string;
414
- result: AgentToolResult<unknown>;
415
- isError: boolean;
416
- timestamp: number;
417
- }): ToolResultMessage {
418
- return {
419
- role: "toolResult",
420
- toolCallId: input.toolCallId,
421
- toolName: input.toolName,
422
- content: input.result.content ?? [],
423
- details: input.result.details,
424
- ...(input.result.usage ? { usage: input.result.usage } : {}),
425
- ...(input.result.addedToolNames?.length
426
- ? { addedToolNames: input.result.addedToolNames }
427
- : {}),
428
- isError: input.isError,
429
- timestamp: input.timestamp,
300
+ return chain;
430
301
  };
431
- }
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 }> = [];
432
328
 
433
- /**
434
- * Called synchronously during DO startup, before admission or recovery can execute.
435
- * Durable pending work keeps its IDs and state; no old executor runs across this boundary.
436
- * A dry run exercises the real import then rolls back DDL, records, and the format switch together.
437
- */
438
- export function migrateLegacyPiSession(
439
- storage: DurableObjectStorage,
440
- options: { dryRun: boolean },
441
- ): PiSessionMigrationReport {
442
- const sql = storage.sql;
443
- let report!: PiSessionMigrationReport;
444
- const rollbackDryRun = new Error("rollback migration dry run");
445
- try {
446
- storage.transactionSync(() => {
447
- if (hasTable(sql, "pi_storage_state")) {
448
- migrateCurrentPiSession(storage);
449
- const store = new CloudflareSessionStorage(storage);
450
- report = {
451
- sourceEntries: 0,
452
- entries: store.entries().length,
453
- tip: store.branch().at(-1)?.id ?? null,
454
- alreadyMigrated: true,
455
- };
456
- return;
457
- }
458
- if (
459
- !hasTable(sql, "pi_session_entries") ||
460
- !hasTable(sql, "pi_session_metadata")
461
- )
462
- throw new Error("Missing legacy session snapshot");
463
- const metadata = sql
464
- .exec<{
465
- id: string;
466
- created_at: string;
467
- }>("SELECT id, created_at FROM pi_session_metadata WHERE singleton = 1")
468
- .one();
469
- const createdAt = time(metadata.created_at);
470
- const rows = sql
471
- .exec<{
472
- seq: number;
473
- id: string;
474
- body: string;
475
- }>("SELECT seq, id, body FROM pi_session_entries ORDER BY seq")
476
- .toArray();
477
- const records = new Map<string, LegacyRecord>();
478
- for (const row of rows) {
479
- const raw = object(JSON.parse(row.body));
480
- if (raw.id !== row.id || !row.id || records.has(row.id))
481
- throw new Error("Invalid or duplicate legacy entry ID");
482
- if (
483
- raw.parentId !== null &&
484
- (typeof raw.parentId !== "string" || !records.has(raw.parentId))
485
- )
486
- throw new Error(`Missing or forward legacy parent: ${row.id}`);
487
- time(raw.timestamp);
488
- text(raw.type);
489
- records.set(row.id, raw as LegacyRecord);
490
- }
491
- const converted = new Map<string, Entry>();
492
- const resolved = new Map<string, string | null>();
493
- const values: Write[] = [];
494
- const resolve = (id: unknown): string | null => {
495
- if (id === null) return null;
496
- const key = text(id);
497
- if (converted.has(key)) return key;
498
- if (!resolved.has(key))
499
- throw new Error(`Unknown legacy reference: ${key}`);
500
- return resolved.get(key)!;
501
- };
502
- for (const [index, row] of rows.entries()) {
503
- const raw = records.get(row.id)!;
504
- const parentId = resolve(raw.parentId);
505
- const timestamp = time(raw.timestamp);
506
- const base = { id: raw.id, parentId, timestamp, seq: index + 1 };
507
- let entry: Entry | undefined;
508
- switch (raw.type) {
509
- case "message":
510
- entry = { ...base, type: "message", message: message(raw.message) };
511
- break;
512
- case "custom_message":
513
- entry = {
514
- ...base,
515
- type: "message",
516
- message: {
517
- role: "custom",
518
- customType: text(raw.customType),
519
- content: raw.content,
520
- details: raw.details,
521
- display: raw.display,
522
- timestamp,
523
- } as AgentMessage,
524
- };
525
- break;
526
- case "custom":
527
- entry = {
528
- ...base,
529
- type: "custom",
530
- customType: text(raw.customType),
531
- ...(raw.data === undefined
532
- ? {}
533
- : { data: raw.data as JsonValue }),
534
- };
535
- break;
536
- case "branch_summary":
537
- entry = {
538
- ...base,
539
- type: "branch_summary",
540
- fromId: raw.fromId === "root" ? null : resolve(raw.fromId),
541
- summary: text(raw.summary),
542
- fromHook: raw.fromHook === true,
543
- ...(raw.details === undefined
544
- ? {}
545
- : { details: raw.details as JsonValue }),
546
- ...(raw.usage === undefined ? {} : { usage: raw.usage as Usage }),
547
- };
548
- break;
549
- case "compaction": {
550
- let retainedTail: AgentMessage[];
551
- if (Array.isArray(raw.retainedTail))
552
- retainedTail = raw.retainedTail.map(message);
553
- else {
554
- const kept = text(raw.firstKeptEntryId);
555
- const tail: Entry[] = [];
556
- let cursor = raw.parentId;
557
- let found = false;
558
- while (cursor !== null) {
559
- const ancestor = records.get(cursor);
560
- if (!ancestor)
561
- throw new Error(`Missing compaction ancestor: ${cursor}`);
562
- const convertedEntry = converted.get(cursor);
563
- if (convertedEntry) tail.unshift(convertedEntry);
564
- if (cursor === kept) {
565
- found = true;
566
- break;
567
- }
568
- cursor = ancestor.parentId;
569
- }
570
- if (!found)
571
- throw new Error(
572
- `Compaction retained tail is not on its historical branch: ${raw.id}`,
573
- );
574
- retainedTail = tail.flatMap(sessionEntryToContextMessages);
575
- }
576
- entry = {
577
- ...base,
578
- type: "compaction",
579
- summary: text(raw.summary),
580
- retainedTail,
581
- tokensBefore: finite(raw.tokensBefore),
582
- fromHook: raw.fromHook === true,
583
- ...(raw.details === undefined
584
- ? {}
585
- : { details: raw.details as JsonValue }),
586
- ...(raw.usage === undefined ? {} : { usage: raw.usage as Usage }),
587
- };
588
- break;
589
- }
590
- case "leaf":
591
- resolved.set(raw.id, resolve(raw.targetId));
592
- break;
593
- case "label": {
594
- const target = resolve(raw.targetId);
595
- if (!target)
596
- throw new Error(`Label has no content target: ${raw.id}`);
597
- values.push(
598
- raw.label === undefined
599
- ? deleteValue(entryLabel(target))
600
- : setValue(entryLabel(target), text(raw.label)),
601
- );
602
- resolved.set(raw.id, parentId);
603
- break;
604
- }
605
- case "session_info":
606
- if (raw.name !== undefined)
607
- values.push(setValue(sessionName, text(raw.name)));
608
- resolved.set(raw.id, parentId);
609
- break;
610
- case "model_change":
611
- case "thinking_level_change":
612
- case "active_tools_change": {
613
- // Historical settings are values, not conversation entries or a second execution lane.
614
- const setting =
615
- raw.type === "model_change"
616
- ? { provider: text(raw.provider), modelId: text(raw.modelId) }
617
- : raw.type === "thinking_level_change"
618
- ? text(raw.thinkingLevel)
619
- : raw.activeToolNames;
620
- if (setting === undefined)
621
- throw new Error(`Missing legacy configuration: ${raw.id}`);
622
- values.push(
623
- setValue(value(`springbrand.history.${raw.type}`, raw.id), {
624
- at: parentId,
625
- timestamp,
626
- setting,
627
- }),
628
- );
629
- resolved.set(raw.id, parentId);
630
- break;
631
- }
632
- default:
633
- throw new Error(`Unsupported legacy entry type: ${raw.type}`);
634
- }
635
- if (entry) converted.set(raw.id, entry);
636
- }
637
- const tip = rows.length ? resolve(rows.at(-1)!.id) : null;
638
- if (hasTable(sql, "pi_session_message_submission")) {
639
- for (const link of sql
640
- .exec<{
641
- message_id: string;
642
- submission_id: string;
643
- }>(
644
- "SELECT message_id, submission_id FROM pi_session_message_submission",
645
- )
646
- .toArray()) {
647
- if (converted.get(link.message_id)?.type !== "message")
648
- throw new Error(`Dangling message association: ${link.message_id}`);
649
- const persistedSubmission =
650
- hasTable(sql, "pi_submissions") &&
651
- sql
652
- .exec(
653
- "SELECT 1 FROM pi_submissions WHERE submission_id = ?",
654
- link.submission_id,
655
- )
656
- .toArray().length > 0;
657
- if (!persistedSubmission) {
658
- let ancestor = converted.get(link.message_id);
659
- while (
660
- ancestor &&
661
- !(ancestor.type === "message" && ancestor.message.role === "user")
662
- )
663
- ancestor = ancestor.parentId
664
- ? converted.get(ancestor.parentId)
665
- : undefined;
666
- if (!ancestor || link.submission_id !== `import:${ancestor.id}`)
667
- throw new Error(
668
- `Dangling submission association: ${link.submission_id}`,
669
- );
670
- }
671
- }
672
- }
673
- const messageOwners = new Map(
674
- hasTable(sql, "pi_session_message_submission")
675
- ? sql
676
- .exec<{ message_id: string; submission_id: string }>(
677
- "SELECT message_id, submission_id FROM pi_session_message_submission",
678
- )
679
- .toArray()
680
- .map((row) => [row.message_id, row.submission_id] as const)
681
- : [],
682
- );
683
- const results = new Map<string, ToolResultMessage>();
684
- for (const entry of converted.values()) {
685
- if (entry.type !== "message" || entry.message.role !== "toolResult")
686
- continue;
687
- const result = entry.message;
688
- let ancestor = entry.parentId
689
- ? converted.get(entry.parentId)
690
- : undefined;
691
- let found = false;
692
- while (ancestor) {
693
- if (
694
- ancestor.type === "message" &&
695
- ancestor.message.role === "assistant"
696
- ) {
697
- const call = ancestor.message.content.find(
698
- (block) =>
699
- block.type === "toolCall" && block.id === result.toolCallId,
700
- );
701
- if (call?.type === "toolCall") {
702
- if (call.name !== result.toolName)
703
- throw new Error("Mismatched legacy Tool name");
704
- found = true;
705
- break;
706
- }
707
- }
708
- ancestor = ancestor.parentId
709
- ? converted.get(ancestor.parentId)
710
- : undefined;
711
- }
712
- if (!found)
713
- throw new Error(`Orphan legacy Tool result: ${result.toolCallId}`);
714
- const owner = messageOwners.get(entry.id);
715
- if (owner) {
716
- const key = `${owner}:${result.toolCallId}`;
717
- const previous = results.get(key);
718
- if (previous) assertSameToolResult(previous, result);
719
- results.set(key, result);
720
- }
721
- }
722
- if (hasTable(sql, "pi_pending_steers")) {
723
- for (const steer of sql
724
- .exec<{
725
- submission_id: string;
726
- message_id: string;
727
- canonical_json: string;
728
- }>(
729
- "SELECT submission_id, message_id, canonical_json FROM pi_pending_steers",
730
- )
731
- .toArray()) {
732
- if (
733
- !hasTable(sql, "pi_submissions") ||
734
- !sql
735
- .exec(
736
- "SELECT 1 FROM pi_submissions WHERE submission_id = ?",
737
- steer.submission_id,
738
- )
739
- .toArray().length
740
- ) {
741
- throw new Error("Dangling pending steer submission");
742
- }
743
- const pending = message(JSON.parse(steer.canonical_json));
744
- if (pending.role !== "user" || converted.has(steer.message_id)) {
745
- throw new Error("Invalid or already committed pending steer");
746
- }
747
- }
748
- }
749
- // Business records keep their stable IDs and payloads. Validate ownership before switching.
750
- if (hasTable(sql, "pi_submissions")) {
751
- const submissions = sql
752
- .exec<{
753
- submission_id: string;
754
- status: string;
755
- queued_input_json: string | null;
756
- queued_ui_message_json: string | null;
757
- user_message_id: string | null;
758
- regenerate_message_id: string | null;
759
- }>("SELECT * FROM pi_submissions")
760
- .toArray();
761
- const knownSubmissions = new Set(
762
- submissions.map((row) => row.submission_id),
763
- );
764
- for (const submission of submissions) {
765
- if (submission.status !== "pending" || !submission.queued_input_json)
766
- continue;
767
- const queued = message(JSON.parse(submission.queued_input_json));
768
- if (queued.role !== "user" || !submission.user_message_id) {
769
- throw new Error("Invalid queued user message");
770
- }
771
- if (
772
- submission.queued_ui_message_json &&
773
- object(JSON.parse(submission.queued_ui_message_json)).role !==
774
- "user"
775
- ) {
776
- throw new Error("Invalid queued UI user message");
777
- }
778
- if (submission.regenerate_message_id) {
779
- const target = converted.get(submission.regenerate_message_id);
780
- if (
781
- target?.type !== "message" ||
782
- target.message.role !== "user" ||
783
- !isEqual(
784
- typeof target.message.content === "string"
785
- ? [{ type: "text", text: target.message.content }]
786
- : target.message.content,
787
- typeof queued.content === "string"
788
- ? [{ type: "text", text: queued.content }]
789
- : queued.content,
790
- )
791
- ) {
792
- throw new Error("Invalid queued regeneration target");
793
- }
794
- } else if (converted.has(submission.user_message_id)) {
795
- throw new Error("Queued user message is already committed");
796
- }
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}`);
797
336
  }
798
- const toolInputs = new Set<string>();
799
- if (hasTable(sql, "pi_recovery_milestones")) {
800
- const bySubmission = new Map<string, string[]>();
801
- for (const row of sql
802
- .exec<{
803
- submission_id: string;
804
- body: string;
805
- }>(
806
- "SELECT submission_id, body FROM pi_recovery_milestones ORDER BY submission_id, seq",
807
- )
808
- .toArray()) {
809
- if (!knownSubmissions.has(row.submission_id))
810
- throw new Error("Dangling recovery submission");
811
- const record = decodePiToolRecoveryMilestone(row.body);
812
- if (!record || record.turnId !== row.submission_id)
813
- throw new Error("Invalid legacy recovery milestone");
814
- const bodies = bySubmission.get(row.submission_id) ?? [];
815
- bodies.push(row.body);
816
- bySubmission.set(row.submission_id, bodies);
817
- if (record.type === "tool-result" || record.type === "tool-error") {
818
- for (const result of replayPiToolRecovery([row.body])
819
- .canonicalToolResults) {
820
- const key = `${row.submission_id}:${result.toolCallId}`;
821
- const previous = results.get(key);
822
- if (previous) assertSameToolResult(previous, result);
823
- results.set(key, result);
824
- }
825
- }
826
- }
827
- for (const [submissionId, bodies] of bySubmission) {
828
- const recovered = replayPiToolRecovery(bodies);
829
- for (const call of Object.values(recovered.toolCalls)) {
830
- if (call.input !== undefined || call.retry !== undefined)
831
- toolInputs.add(`${submissionId}:${call.toolCallId}`);
832
- if (call.result) {
833
- const key = `${submissionId}:${call.toolCallId}`;
834
- const previous = results.get(key);
835
- if (previous) assertSameToolResult(previous, call.result);
836
- results.set(key, call.result);
837
- }
838
- }
839
- }
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;
840
342
  }
841
- if (hasTable(sql, "pi_session_message_submission")) {
842
- for (const row of sql
843
- .exec<{
844
- message_id: string;
845
- submission_id: string;
846
- }>(
847
- "SELECT message_id, submission_id FROM pi_session_message_submission",
848
- )
849
- .toArray()) {
850
- const entry = converted.get(row.message_id);
851
- if (
852
- entry?.type === "message" &&
853
- entry.message.role === "assistant"
854
- ) {
855
- for (const block of entry.message.content)
856
- if (block.type === "toolCall")
857
- 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;
858
368
  }
369
+ if (!cursor) throw new Error("Historical compaction tail is outside its branch");
370
+ retainedTail = tail;
859
371
  }
860
- }
861
- for (const table of [
862
- "pi_tool_settlements",
863
- "pi_approvals",
864
- "pi_tool_interactions",
865
- ]) {
866
- if (!hasTable(sql, table)) continue;
867
- for (const row of sql
868
- .exec<{
869
- submission_id: string;
870
- tool_call_id: string;
871
- }>(`SELECT submission_id, tool_call_id FROM ${table}`)
872
- .toArray()) {
873
- if (
874
- !knownSubmissions.has(row.submission_id) ||
875
- !toolInputs.has(`${row.submission_id}:${row.tool_call_id}`)
876
- )
877
- 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] });
878
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> } });
879
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[];
880
403
  }
881
- if (hasTable(sql, "pi_tool_settlements")) {
882
- for (const row of sql
883
- .exec<{
884
- submission_id: string;
885
- tool_call_id: string;
886
- tool_name: string;
887
- result: string;
888
- is_error: number;
889
- created_at: number;
890
- }>(
891
- "SELECT submission_id, tool_call_id, tool_name, result, is_error, created_at FROM pi_tool_settlements",
892
- )
893
- .toArray()) {
894
- const expected = legacyToolResultMessage({
895
- toolCallId: row.tool_call_id,
896
- toolName: row.tool_name,
897
- result: JSON.parse(row.result) as AgentToolResult<unknown>,
898
- isError: row.is_error === 1,
899
- 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 } : {}),
900
418
  });
901
- const recorded = results.get(
902
- `${row.submission_id}:${row.tool_call_id}`,
903
- );
904
- if (recorded) assertSameToolResult(recorded, expected);
905
419
  }
906
420
  }
907
- // The old table remains an inert backup. Failure rolls back DDL, data and the version together.
908
- sql.exec(
909
- "ALTER TABLE pi_session_entries RENAME TO pi_legacy_entries_backup",
910
- );
911
- const store = new CloudflareSessionStorage(storage);
912
- removeRetiredRuntimeArtifacts(sql);
913
- for (const entry of converted.values()) {
914
- const { seq: _seq, timestamp, ...next } = entry;
915
- const writes: Write[] = [insertEntry(next)];
916
- const usage =
917
- entry.type === "message" &&
918
- (entry.message.role === "assistant" ||
919
- entry.message.role === "toolResult")
920
- ? entry.message.usage
921
- : entry.type === "compaction" || entry.type === "branch_summary"
922
- ? entry.usage
923
- : undefined;
924
- if (usage) {
925
- let usageId = `migration:usage:${entry.id}`;
926
- while (records.has(usageId)) usageId = `migration:${usageId}`;
927
- writes.push(
928
- insertUsage({
929
- id: usageId,
930
- entryId: entry.id,
931
- usage,
932
- adjustment: false,
933
- }),
934
- );
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);
935
457
  }
936
- const result = store.commitSync(writes);
937
- // Import timestamps belong to the historical snapshot, not the time of the migration.
938
- sql.exec(
939
- "UPDATE pi_entries SET timestamp = ?, body = json_set(body, '$.timestamp', ?) WHERE id = ?",
940
- timestamp,
941
- timestamp,
942
- entry.id,
943
- );
944
- entry.seq = result.firstSeq;
458
+ sql.exec("DELETE FROM pi_pending_steers WHERE message_id=?", steer.message_id);
945
459
  }
946
- const parents = new Set(
947
- [...converted.values()].map((entry) => entry.parentId),
948
- );
949
- for (const entry of converted.values()) {
950
- if (entry.id !== tip && !parents.has(entry.id))
951
- 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);
952
468
  }
953
- store.commitSync([...values, setValue(branchTip("main"), tip)]);
954
- repairDuplicatedUserSidecars(sql);
955
- sql.exec(
956
- "UPDATE pi_storage_state SET session_id = ?, created_at = ? WHERE singleton = 1",
957
- metadata.id,
958
- createdAt,
959
- );
960
- report = {
961
- sourceEntries: rows.length,
962
- entries: converted.size,
963
- tip,
964
- alreadyMigrated: false,
965
- };
966
- if (options.dryRun) throw rollbackDryRun;
967
- });
968
- } catch (error) {
969
- if (error !== rollbackDryRun) throw error;
970
- }
971
- 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}`);
972
490
  }