@springbrand/agent-runtime 0.2.0-alpha.68 → 0.2.0-alpha.70
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/adapter/cloudflare/subagent/runner.ts +23 -22
- package/src/adapter/cloudflare/subagent/tools.ts +1 -1
- package/src/adapter/cloudflare/universal-agent/preparation.ts +0 -7
- package/src/db/schema.ts +0 -12
- package/src/db/submission-admission.repo.ts +5 -20
- package/src/db/submission.repo.ts +5 -21
- package/src/kernel/bindings.ts +8 -7
- package/src/kernel/durable-lifecycle.ts +0 -9
- package/src/kernel/profile.ts +3 -0
- package/src/kernel/public-contracts.ts +0 -1
- package/src/kernel/subagent-runtime.ts +2 -12
- package/src/kernel/submission-authority.ts +4 -24
- package/src/model-usage.ts +52 -0
- package/src/pi/assembly/context.ts +26 -60
- package/src/pi/runtime-adapter/assembly.ts +6 -2
- package/src/pi/runtime-adapter/execution.ts +78 -114
- package/src/pi/runtime-adapter/index.ts +3 -4
- package/src/pi/runtime-adapter/transcript.ts +31 -46
- package/src/pi/session/index.ts +22 -22
- package/src/pi/session/migration.ts +646 -0
- package/src/pi/session/storage.ts +523 -311
- package/src/pi/tool/compiler.ts +2 -2
- package/src/pi/tool/core-host.ts +6 -0
- package/src/pi/tool/mcp.ts +23 -15
- package/src/pi/tool/subagent.ts +0 -9
- package/src/pi/tool/workspace-state-connector.ts +7 -6
- package/src/runtime-agent-context.ts +2 -0
- package/src/runtime-agent.ts +24 -0
- package/src/runtime-assembler.ts +13 -2
- package/src/runtime-definition.ts +2 -0
- package/src/runtime.ts +30 -83
|
@@ -0,0 +1,646 @@
|
|
|
1
|
+
import { isEqual } from "lodash-es";
|
|
2
|
+
import {
|
|
3
|
+
decodePiToolRecoveryMilestone,
|
|
4
|
+
replayPiToolRecovery,
|
|
5
|
+
} from "../turn/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
|
+
sessionEntryToContextMessages,
|
|
16
|
+
value,
|
|
17
|
+
type Entry,
|
|
18
|
+
type JsonValue,
|
|
19
|
+
type Write,
|
|
20
|
+
} from "@earendil-works/pi-agent-core/harness/session";
|
|
21
|
+
import {
|
|
22
|
+
createToolResultMessage,
|
|
23
|
+
type AgentToolResult,
|
|
24
|
+
type AgentMessage,
|
|
25
|
+
} from "@earendil-works/pi-agent-core";
|
|
26
|
+
import type { Usage, ToolResultMessage } from "@earendil-works/pi-ai";
|
|
27
|
+
import { CloudflareSessionStorage } from "./storage";
|
|
28
|
+
|
|
29
|
+
type LegacyRecord = Record<string, unknown> & {
|
|
30
|
+
id: string;
|
|
31
|
+
parentId: string | null;
|
|
32
|
+
type: string;
|
|
33
|
+
timestamp: string;
|
|
34
|
+
};
|
|
35
|
+
export interface PiSessionMigrationReport {
|
|
36
|
+
sourceEntries: number;
|
|
37
|
+
entries: number;
|
|
38
|
+
tip: string | null;
|
|
39
|
+
alreadyMigrated: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function object(input: unknown): Record<string, unknown> {
|
|
43
|
+
if (!input || typeof input !== "object" || Array.isArray(input))
|
|
44
|
+
throw new Error("Invalid legacy object");
|
|
45
|
+
return input as Record<string, unknown>;
|
|
46
|
+
}
|
|
47
|
+
function text(input: unknown): string {
|
|
48
|
+
if (typeof input !== "string") throw new Error("Invalid legacy string");
|
|
49
|
+
return input;
|
|
50
|
+
}
|
|
51
|
+
function time(input: unknown): number {
|
|
52
|
+
const timestamp = Date.parse(text(input));
|
|
53
|
+
if (!Number.isSafeInteger(timestamp) || timestamp < 0)
|
|
54
|
+
throw new Error("Invalid legacy timestamp");
|
|
55
|
+
return timestamp;
|
|
56
|
+
}
|
|
57
|
+
function finite(input: unknown): number {
|
|
58
|
+
if (typeof input !== "number" || !Number.isFinite(input) || input < 0)
|
|
59
|
+
throw new Error("Invalid legacy number");
|
|
60
|
+
return input;
|
|
61
|
+
}
|
|
62
|
+
function message(input: unknown): AgentMessage {
|
|
63
|
+
const record = object(input);
|
|
64
|
+
if (
|
|
65
|
+
![
|
|
66
|
+
"user",
|
|
67
|
+
"assistant",
|
|
68
|
+
"toolResult",
|
|
69
|
+
"custom",
|
|
70
|
+
"bashExecution",
|
|
71
|
+
"branchSummary",
|
|
72
|
+
"compactionSummary",
|
|
73
|
+
].includes(text(record.role))
|
|
74
|
+
)
|
|
75
|
+
throw new Error("Unknown legacy message role");
|
|
76
|
+
finite(record.timestamp);
|
|
77
|
+
if (record.role === "assistant" && record.stopReason === "pending")
|
|
78
|
+
throw new Error("Unsettled legacy assistant message");
|
|
79
|
+
return record as unknown as AgentMessage;
|
|
80
|
+
}
|
|
81
|
+
function hasTable(sql: SqlStorage, name: string): boolean {
|
|
82
|
+
return (
|
|
83
|
+
sql
|
|
84
|
+
.exec(
|
|
85
|
+
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?",
|
|
86
|
+
name,
|
|
87
|
+
)
|
|
88
|
+
.toArray().length > 0
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function assertSameToolResult(
|
|
93
|
+
left: ToolResultMessage,
|
|
94
|
+
right: ToolResultMessage,
|
|
95
|
+
): void {
|
|
96
|
+
for (const key of [
|
|
97
|
+
"toolCallId",
|
|
98
|
+
"toolName",
|
|
99
|
+
"content",
|
|
100
|
+
"details",
|
|
101
|
+
"usage",
|
|
102
|
+
"addedToolNames",
|
|
103
|
+
"isError",
|
|
104
|
+
] as const) {
|
|
105
|
+
if (!isEqual(left[key], right[key]))
|
|
106
|
+
throw new Error(`Conflicting legacy Tool result: ${left.toolCallId}`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Called synchronously during DO startup, before admission or recovery can execute.
|
|
112
|
+
* Durable pending work keeps its IDs and state; no old executor runs across this boundary.
|
|
113
|
+
* A dry run exercises the real import then rolls back DDL, records, and the format switch together.
|
|
114
|
+
*/
|
|
115
|
+
export function migrateLegacyPiSession(
|
|
116
|
+
storage: DurableObjectStorage,
|
|
117
|
+
options: { dryRun: boolean },
|
|
118
|
+
): PiSessionMigrationReport {
|
|
119
|
+
const sql = storage.sql;
|
|
120
|
+
let report!: PiSessionMigrationReport;
|
|
121
|
+
const rollbackDryRun = new Error("rollback migration dry run");
|
|
122
|
+
try {
|
|
123
|
+
storage.transactionSync(() => {
|
|
124
|
+
if (hasTable(sql, "pi_storage_state")) {
|
|
125
|
+
const store = new CloudflareSessionStorage(storage);
|
|
126
|
+
report = {
|
|
127
|
+
sourceEntries: 0,
|
|
128
|
+
entries: store.entries().length,
|
|
129
|
+
tip: store.branch().at(-1)?.id ?? null,
|
|
130
|
+
alreadyMigrated: true,
|
|
131
|
+
};
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (
|
|
135
|
+
!hasTable(sql, "pi_session_entries") ||
|
|
136
|
+
!hasTable(sql, "pi_session_metadata")
|
|
137
|
+
)
|
|
138
|
+
throw new Error("Missing legacy session snapshot");
|
|
139
|
+
const metadata = sql
|
|
140
|
+
.exec<{
|
|
141
|
+
id: string;
|
|
142
|
+
created_at: string;
|
|
143
|
+
}>("SELECT id, created_at FROM pi_session_metadata WHERE singleton = 1")
|
|
144
|
+
.one();
|
|
145
|
+
const createdAt = time(metadata.created_at);
|
|
146
|
+
const rows = sql
|
|
147
|
+
.exec<{
|
|
148
|
+
seq: number;
|
|
149
|
+
id: string;
|
|
150
|
+
body: string;
|
|
151
|
+
}>("SELECT seq, id, body FROM pi_session_entries ORDER BY seq")
|
|
152
|
+
.toArray();
|
|
153
|
+
const records = new Map<string, LegacyRecord>();
|
|
154
|
+
for (const row of rows) {
|
|
155
|
+
const raw = object(JSON.parse(row.body));
|
|
156
|
+
if (raw.id !== row.id || !row.id || records.has(row.id))
|
|
157
|
+
throw new Error("Invalid or duplicate legacy entry ID");
|
|
158
|
+
if (
|
|
159
|
+
raw.parentId !== null &&
|
|
160
|
+
(typeof raw.parentId !== "string" || !records.has(raw.parentId))
|
|
161
|
+
)
|
|
162
|
+
throw new Error(`Missing or forward legacy parent: ${row.id}`);
|
|
163
|
+
time(raw.timestamp);
|
|
164
|
+
text(raw.type);
|
|
165
|
+
records.set(row.id, raw as LegacyRecord);
|
|
166
|
+
}
|
|
167
|
+
const converted = new Map<string, Entry>();
|
|
168
|
+
const resolved = new Map<string, string | null>();
|
|
169
|
+
const values: Write[] = [];
|
|
170
|
+
const resolve = (id: unknown): string | null => {
|
|
171
|
+
if (id === null) return null;
|
|
172
|
+
const key = text(id);
|
|
173
|
+
if (converted.has(key)) return key;
|
|
174
|
+
if (!resolved.has(key))
|
|
175
|
+
throw new Error(`Unknown legacy reference: ${key}`);
|
|
176
|
+
return resolved.get(key)!;
|
|
177
|
+
};
|
|
178
|
+
for (const [index, row] of rows.entries()) {
|
|
179
|
+
const raw = records.get(row.id)!;
|
|
180
|
+
const parentId = resolve(raw.parentId);
|
|
181
|
+
const timestamp = time(raw.timestamp);
|
|
182
|
+
const base = { id: raw.id, parentId, timestamp, seq: index + 1 };
|
|
183
|
+
let entry: Entry | undefined;
|
|
184
|
+
switch (raw.type) {
|
|
185
|
+
case "message":
|
|
186
|
+
entry = { ...base, type: "message", message: message(raw.message) };
|
|
187
|
+
break;
|
|
188
|
+
case "custom_message":
|
|
189
|
+
entry = {
|
|
190
|
+
...base,
|
|
191
|
+
type: "message",
|
|
192
|
+
message: {
|
|
193
|
+
role: "custom",
|
|
194
|
+
customType: text(raw.customType),
|
|
195
|
+
content: raw.content,
|
|
196
|
+
details: raw.details,
|
|
197
|
+
display: raw.display,
|
|
198
|
+
timestamp,
|
|
199
|
+
} as AgentMessage,
|
|
200
|
+
};
|
|
201
|
+
break;
|
|
202
|
+
case "custom":
|
|
203
|
+
entry = {
|
|
204
|
+
...base,
|
|
205
|
+
type: "custom",
|
|
206
|
+
customType: text(raw.customType),
|
|
207
|
+
...(raw.data === undefined
|
|
208
|
+
? {}
|
|
209
|
+
: { data: raw.data as JsonValue }),
|
|
210
|
+
};
|
|
211
|
+
break;
|
|
212
|
+
case "branch_summary":
|
|
213
|
+
entry = {
|
|
214
|
+
...base,
|
|
215
|
+
type: "branch_summary",
|
|
216
|
+
fromId: raw.fromId === "root" ? null : resolve(raw.fromId),
|
|
217
|
+
summary: text(raw.summary),
|
|
218
|
+
fromHook: raw.fromHook === true,
|
|
219
|
+
...(raw.details === undefined
|
|
220
|
+
? {}
|
|
221
|
+
: { details: raw.details as JsonValue }),
|
|
222
|
+
...(raw.usage === undefined ? {} : { usage: raw.usage as Usage }),
|
|
223
|
+
};
|
|
224
|
+
break;
|
|
225
|
+
case "compaction": {
|
|
226
|
+
let retainedTail: AgentMessage[];
|
|
227
|
+
if (Array.isArray(raw.retainedTail))
|
|
228
|
+
retainedTail = raw.retainedTail.map(message);
|
|
229
|
+
else {
|
|
230
|
+
const kept = text(raw.firstKeptEntryId);
|
|
231
|
+
const tail: Entry[] = [];
|
|
232
|
+
let cursor = raw.parentId;
|
|
233
|
+
let found = false;
|
|
234
|
+
while (cursor !== null) {
|
|
235
|
+
const ancestor = records.get(cursor);
|
|
236
|
+
if (!ancestor)
|
|
237
|
+
throw new Error(`Missing compaction ancestor: ${cursor}`);
|
|
238
|
+
const convertedEntry = converted.get(cursor);
|
|
239
|
+
if (convertedEntry) tail.unshift(convertedEntry);
|
|
240
|
+
if (cursor === kept) {
|
|
241
|
+
found = true;
|
|
242
|
+
break;
|
|
243
|
+
}
|
|
244
|
+
cursor = ancestor.parentId;
|
|
245
|
+
}
|
|
246
|
+
if (!found)
|
|
247
|
+
throw new Error(
|
|
248
|
+
`Compaction retained tail is not on its historical branch: ${raw.id}`,
|
|
249
|
+
);
|
|
250
|
+
retainedTail = tail.flatMap(sessionEntryToContextMessages);
|
|
251
|
+
}
|
|
252
|
+
entry = {
|
|
253
|
+
...base,
|
|
254
|
+
type: "compaction",
|
|
255
|
+
summary: text(raw.summary),
|
|
256
|
+
retainedTail,
|
|
257
|
+
tokensBefore: finite(raw.tokensBefore),
|
|
258
|
+
fromHook: raw.fromHook === true,
|
|
259
|
+
...(raw.details === undefined
|
|
260
|
+
? {}
|
|
261
|
+
: { details: raw.details as JsonValue }),
|
|
262
|
+
...(raw.usage === undefined ? {} : { usage: raw.usage as Usage }),
|
|
263
|
+
};
|
|
264
|
+
break;
|
|
265
|
+
}
|
|
266
|
+
case "leaf":
|
|
267
|
+
resolved.set(raw.id, resolve(raw.targetId));
|
|
268
|
+
break;
|
|
269
|
+
case "label": {
|
|
270
|
+
const target = resolve(raw.targetId);
|
|
271
|
+
if (!target)
|
|
272
|
+
throw new Error(`Label has no content target: ${raw.id}`);
|
|
273
|
+
values.push(
|
|
274
|
+
raw.label === undefined
|
|
275
|
+
? deleteValue(entryLabel(target))
|
|
276
|
+
: setValue(entryLabel(target), text(raw.label)),
|
|
277
|
+
);
|
|
278
|
+
resolved.set(raw.id, parentId);
|
|
279
|
+
break;
|
|
280
|
+
}
|
|
281
|
+
case "session_info":
|
|
282
|
+
if (raw.name !== undefined)
|
|
283
|
+
values.push(setValue(sessionName, text(raw.name)));
|
|
284
|
+
resolved.set(raw.id, parentId);
|
|
285
|
+
break;
|
|
286
|
+
case "model_change":
|
|
287
|
+
case "thinking_level_change":
|
|
288
|
+
case "active_tools_change": {
|
|
289
|
+
// Historical settings are values, not conversation entries or a second execution lane.
|
|
290
|
+
const setting =
|
|
291
|
+
raw.type === "model_change"
|
|
292
|
+
? { provider: text(raw.provider), modelId: text(raw.modelId) }
|
|
293
|
+
: raw.type === "thinking_level_change"
|
|
294
|
+
? text(raw.thinkingLevel)
|
|
295
|
+
: raw.activeToolNames;
|
|
296
|
+
if (setting === undefined)
|
|
297
|
+
throw new Error(`Missing legacy configuration: ${raw.id}`);
|
|
298
|
+
values.push(
|
|
299
|
+
setValue(value(`springbrand.history.${raw.type}`, raw.id), {
|
|
300
|
+
at: parentId,
|
|
301
|
+
timestamp,
|
|
302
|
+
setting,
|
|
303
|
+
}),
|
|
304
|
+
);
|
|
305
|
+
resolved.set(raw.id, parentId);
|
|
306
|
+
break;
|
|
307
|
+
}
|
|
308
|
+
default:
|
|
309
|
+
throw new Error(`Unsupported legacy entry type: ${raw.type}`);
|
|
310
|
+
}
|
|
311
|
+
if (entry) converted.set(raw.id, entry);
|
|
312
|
+
}
|
|
313
|
+
const tip = rows.length ? resolve(rows.at(-1)!.id) : null;
|
|
314
|
+
if (hasTable(sql, "pi_session_message_submission")) {
|
|
315
|
+
for (const link of sql
|
|
316
|
+
.exec<{
|
|
317
|
+
message_id: string;
|
|
318
|
+
submission_id: string;
|
|
319
|
+
}>(
|
|
320
|
+
"SELECT message_id, submission_id FROM pi_session_message_submission",
|
|
321
|
+
)
|
|
322
|
+
.toArray()) {
|
|
323
|
+
if (converted.get(link.message_id)?.type !== "message")
|
|
324
|
+
throw new Error(`Dangling message association: ${link.message_id}`);
|
|
325
|
+
const persistedSubmission =
|
|
326
|
+
hasTable(sql, "pi_submissions") &&
|
|
327
|
+
sql
|
|
328
|
+
.exec(
|
|
329
|
+
"SELECT 1 FROM pi_submissions WHERE submission_id = ?",
|
|
330
|
+
link.submission_id,
|
|
331
|
+
)
|
|
332
|
+
.toArray().length > 0;
|
|
333
|
+
if (!persistedSubmission) {
|
|
334
|
+
let ancestor = converted.get(link.message_id);
|
|
335
|
+
while (
|
|
336
|
+
ancestor &&
|
|
337
|
+
!(ancestor.type === "message" && ancestor.message.role === "user")
|
|
338
|
+
)
|
|
339
|
+
ancestor = ancestor.parentId
|
|
340
|
+
? converted.get(ancestor.parentId)
|
|
341
|
+
: undefined;
|
|
342
|
+
if (!ancestor || link.submission_id !== `import:${ancestor.id}`)
|
|
343
|
+
throw new Error(
|
|
344
|
+
`Dangling submission association: ${link.submission_id}`,
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
const messageOwners = new Map(
|
|
350
|
+
hasTable(sql, "pi_session_message_submission")
|
|
351
|
+
? sql
|
|
352
|
+
.exec<{ message_id: string; submission_id: string }>(
|
|
353
|
+
"SELECT message_id, submission_id FROM pi_session_message_submission",
|
|
354
|
+
)
|
|
355
|
+
.toArray()
|
|
356
|
+
.map((row) => [row.message_id, row.submission_id] as const)
|
|
357
|
+
: [],
|
|
358
|
+
);
|
|
359
|
+
const results = new Map<string, ToolResultMessage>();
|
|
360
|
+
for (const entry of converted.values()) {
|
|
361
|
+
if (entry.type !== "message" || entry.message.role !== "toolResult")
|
|
362
|
+
continue;
|
|
363
|
+
const result = entry.message;
|
|
364
|
+
let ancestor = entry.parentId
|
|
365
|
+
? converted.get(entry.parentId)
|
|
366
|
+
: undefined;
|
|
367
|
+
let found = false;
|
|
368
|
+
while (ancestor) {
|
|
369
|
+
if (
|
|
370
|
+
ancestor.type === "message" &&
|
|
371
|
+
ancestor.message.role === "assistant"
|
|
372
|
+
) {
|
|
373
|
+
const call = ancestor.message.content.find(
|
|
374
|
+
(block) =>
|
|
375
|
+
block.type === "toolCall" && block.id === result.toolCallId,
|
|
376
|
+
);
|
|
377
|
+
if (call?.type === "toolCall") {
|
|
378
|
+
if (call.name !== result.toolName)
|
|
379
|
+
throw new Error("Mismatched legacy Tool name");
|
|
380
|
+
found = true;
|
|
381
|
+
break;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
ancestor = ancestor.parentId
|
|
385
|
+
? converted.get(ancestor.parentId)
|
|
386
|
+
: undefined;
|
|
387
|
+
}
|
|
388
|
+
if (!found)
|
|
389
|
+
throw new Error(`Orphan legacy Tool result: ${result.toolCallId}`);
|
|
390
|
+
const owner = messageOwners.get(entry.id);
|
|
391
|
+
if (owner) {
|
|
392
|
+
const key = `${owner}:${result.toolCallId}`;
|
|
393
|
+
const previous = results.get(key);
|
|
394
|
+
if (previous) assertSameToolResult(previous, result);
|
|
395
|
+
results.set(key, result);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
if (hasTable(sql, "pi_pending_steers")) {
|
|
399
|
+
for (const steer of sql
|
|
400
|
+
.exec<{
|
|
401
|
+
submission_id: string;
|
|
402
|
+
message_id: string;
|
|
403
|
+
canonical_json: string;
|
|
404
|
+
}>(
|
|
405
|
+
"SELECT submission_id, message_id, canonical_json FROM pi_pending_steers",
|
|
406
|
+
)
|
|
407
|
+
.toArray()) {
|
|
408
|
+
if (
|
|
409
|
+
!hasTable(sql, "pi_submissions") ||
|
|
410
|
+
!sql
|
|
411
|
+
.exec(
|
|
412
|
+
"SELECT 1 FROM pi_submissions WHERE submission_id = ?",
|
|
413
|
+
steer.submission_id,
|
|
414
|
+
)
|
|
415
|
+
.toArray().length
|
|
416
|
+
) {
|
|
417
|
+
throw new Error("Dangling pending steer submission");
|
|
418
|
+
}
|
|
419
|
+
const pending = message(JSON.parse(steer.canonical_json));
|
|
420
|
+
if (pending.role !== "user" || converted.has(steer.message_id)) {
|
|
421
|
+
throw new Error("Invalid or already committed pending steer");
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
// Business records keep their stable IDs and payloads. Validate ownership before switching.
|
|
426
|
+
if (hasTable(sql, "pi_submissions")) {
|
|
427
|
+
const submissions = sql
|
|
428
|
+
.exec<{
|
|
429
|
+
submission_id: string;
|
|
430
|
+
status: string;
|
|
431
|
+
queued_input_json: string | null;
|
|
432
|
+
queued_ui_message_json: string | null;
|
|
433
|
+
user_message_id: string | null;
|
|
434
|
+
regenerate_message_id: string | null;
|
|
435
|
+
}>("SELECT * FROM pi_submissions")
|
|
436
|
+
.toArray();
|
|
437
|
+
const knownSubmissions = new Set(
|
|
438
|
+
submissions.map((row) => row.submission_id),
|
|
439
|
+
);
|
|
440
|
+
for (const submission of submissions) {
|
|
441
|
+
if (submission.status !== "pending" || !submission.queued_input_json)
|
|
442
|
+
continue;
|
|
443
|
+
const queued = message(JSON.parse(submission.queued_input_json));
|
|
444
|
+
if (queued.role !== "user" || !submission.user_message_id) {
|
|
445
|
+
throw new Error("Invalid queued user message");
|
|
446
|
+
}
|
|
447
|
+
if (
|
|
448
|
+
submission.queued_ui_message_json &&
|
|
449
|
+
object(JSON.parse(submission.queued_ui_message_json)).role !==
|
|
450
|
+
"user"
|
|
451
|
+
) {
|
|
452
|
+
throw new Error("Invalid queued UI user message");
|
|
453
|
+
}
|
|
454
|
+
if (submission.regenerate_message_id) {
|
|
455
|
+
const target = converted.get(submission.regenerate_message_id);
|
|
456
|
+
if (
|
|
457
|
+
target?.type !== "message" ||
|
|
458
|
+
target.message.role !== "user" ||
|
|
459
|
+
!isEqual(
|
|
460
|
+
typeof target.message.content === "string"
|
|
461
|
+
? [{ type: "text", text: target.message.content }]
|
|
462
|
+
: target.message.content,
|
|
463
|
+
typeof queued.content === "string"
|
|
464
|
+
? [{ type: "text", text: queued.content }]
|
|
465
|
+
: queued.content,
|
|
466
|
+
)
|
|
467
|
+
) {
|
|
468
|
+
throw new Error("Invalid queued regeneration target");
|
|
469
|
+
}
|
|
470
|
+
} else if (converted.has(submission.user_message_id)) {
|
|
471
|
+
throw new Error("Queued user message is already committed");
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
const toolInputs = new Set<string>();
|
|
475
|
+
if (hasTable(sql, "pi_recovery_milestones")) {
|
|
476
|
+
const bySubmission = new Map<string, string[]>();
|
|
477
|
+
for (const row of sql
|
|
478
|
+
.exec<{
|
|
479
|
+
submission_id: string;
|
|
480
|
+
body: string;
|
|
481
|
+
}>(
|
|
482
|
+
"SELECT submission_id, body FROM pi_recovery_milestones ORDER BY submission_id, seq",
|
|
483
|
+
)
|
|
484
|
+
.toArray()) {
|
|
485
|
+
if (!knownSubmissions.has(row.submission_id))
|
|
486
|
+
throw new Error("Dangling recovery submission");
|
|
487
|
+
const record = decodePiToolRecoveryMilestone(row.body);
|
|
488
|
+
if (!record || record.turnId !== row.submission_id)
|
|
489
|
+
throw new Error("Invalid legacy recovery milestone");
|
|
490
|
+
const bodies = bySubmission.get(row.submission_id) ?? [];
|
|
491
|
+
bodies.push(row.body);
|
|
492
|
+
bySubmission.set(row.submission_id, bodies);
|
|
493
|
+
if (record.type === "tool-result" || record.type === "tool-error") {
|
|
494
|
+
for (const result of replayPiToolRecovery([row.body])
|
|
495
|
+
.canonicalToolResults) {
|
|
496
|
+
const key = `${row.submission_id}:${result.toolCallId}`;
|
|
497
|
+
const previous = results.get(key);
|
|
498
|
+
if (previous) assertSameToolResult(previous, result);
|
|
499
|
+
results.set(key, result);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
for (const [submissionId, bodies] of bySubmission) {
|
|
504
|
+
const recovered = replayPiToolRecovery(bodies);
|
|
505
|
+
for (const call of Object.values(recovered.toolCalls)) {
|
|
506
|
+
if (call.input !== undefined || call.retry !== undefined)
|
|
507
|
+
toolInputs.add(`${submissionId}:${call.toolCallId}`);
|
|
508
|
+
if (call.result) {
|
|
509
|
+
const key = `${submissionId}:${call.toolCallId}`;
|
|
510
|
+
const previous = results.get(key);
|
|
511
|
+
if (previous) assertSameToolResult(previous, call.result);
|
|
512
|
+
results.set(key, call.result);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
if (hasTable(sql, "pi_session_message_submission")) {
|
|
518
|
+
for (const row of sql
|
|
519
|
+
.exec<{
|
|
520
|
+
message_id: string;
|
|
521
|
+
submission_id: string;
|
|
522
|
+
}>(
|
|
523
|
+
"SELECT message_id, submission_id FROM pi_session_message_submission",
|
|
524
|
+
)
|
|
525
|
+
.toArray()) {
|
|
526
|
+
const entry = converted.get(row.message_id);
|
|
527
|
+
if (
|
|
528
|
+
entry?.type === "message" &&
|
|
529
|
+
entry.message.role === "assistant"
|
|
530
|
+
) {
|
|
531
|
+
for (const block of entry.message.content)
|
|
532
|
+
if (block.type === "toolCall")
|
|
533
|
+
toolInputs.add(`${row.submission_id}:${block.id}`);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
for (const table of [
|
|
538
|
+
"pi_tool_settlements",
|
|
539
|
+
"pi_approvals",
|
|
540
|
+
"pi_tool_interactions",
|
|
541
|
+
]) {
|
|
542
|
+
if (!hasTable(sql, table)) continue;
|
|
543
|
+
for (const row of sql
|
|
544
|
+
.exec<{
|
|
545
|
+
submission_id: string;
|
|
546
|
+
tool_call_id: string;
|
|
547
|
+
}>(`SELECT submission_id, tool_call_id FROM ${table}`)
|
|
548
|
+
.toArray()) {
|
|
549
|
+
if (
|
|
550
|
+
!knownSubmissions.has(row.submission_id) ||
|
|
551
|
+
!toolInputs.has(`${row.submission_id}:${row.tool_call_id}`)
|
|
552
|
+
)
|
|
553
|
+
throw new Error(`Dangling Tool association in ${table}`);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
if (hasTable(sql, "pi_tool_settlements")) {
|
|
558
|
+
for (const row of sql
|
|
559
|
+
.exec<{
|
|
560
|
+
submission_id: string;
|
|
561
|
+
tool_call_id: string;
|
|
562
|
+
tool_name: string;
|
|
563
|
+
result: string;
|
|
564
|
+
is_error: number;
|
|
565
|
+
created_at: number;
|
|
566
|
+
}>(
|
|
567
|
+
"SELECT submission_id, tool_call_id, tool_name, result, is_error, created_at FROM pi_tool_settlements",
|
|
568
|
+
)
|
|
569
|
+
.toArray()) {
|
|
570
|
+
const expected = createToolResultMessage({
|
|
571
|
+
toolCallId: row.tool_call_id,
|
|
572
|
+
toolName: row.tool_name,
|
|
573
|
+
result: JSON.parse(row.result) as AgentToolResult<unknown>,
|
|
574
|
+
isError: row.is_error === 1,
|
|
575
|
+
timestamp: row.created_at,
|
|
576
|
+
});
|
|
577
|
+
const recorded = results.get(
|
|
578
|
+
`${row.submission_id}:${row.tool_call_id}`,
|
|
579
|
+
);
|
|
580
|
+
if (recorded) assertSameToolResult(recorded, expected);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
// The old table remains an inert backup. Failure rolls back DDL, data and the version together.
|
|
584
|
+
sql.exec(
|
|
585
|
+
"ALTER TABLE pi_session_entries RENAME TO pi_legacy_entries_backup",
|
|
586
|
+
);
|
|
587
|
+
const store = new CloudflareSessionStorage(storage);
|
|
588
|
+
for (const entry of converted.values()) {
|
|
589
|
+
const { seq: _seq, timestamp, ...next } = entry;
|
|
590
|
+
const writes: Write[] = [insertEntry(next)];
|
|
591
|
+
const usage =
|
|
592
|
+
entry.type === "message" &&
|
|
593
|
+
(entry.message.role === "assistant" ||
|
|
594
|
+
entry.message.role === "toolResult")
|
|
595
|
+
? entry.message.usage
|
|
596
|
+
: entry.type === "compaction" || entry.type === "branch_summary"
|
|
597
|
+
? entry.usage
|
|
598
|
+
: undefined;
|
|
599
|
+
if (usage) {
|
|
600
|
+
let usageId = `migration:usage:${entry.id}`;
|
|
601
|
+
while (records.has(usageId)) usageId = `migration:${usageId}`;
|
|
602
|
+
writes.push(
|
|
603
|
+
insertUsage({
|
|
604
|
+
id: usageId,
|
|
605
|
+
entryId: entry.id,
|
|
606
|
+
usage,
|
|
607
|
+
adjustment: false,
|
|
608
|
+
}),
|
|
609
|
+
);
|
|
610
|
+
}
|
|
611
|
+
const result = store.commitSync(writes);
|
|
612
|
+
// Import timestamps belong to the historical snapshot, not the time of the migration.
|
|
613
|
+
sql.exec(
|
|
614
|
+
"UPDATE pi_entries SET timestamp = ?, body = json_set(body, '$.timestamp', ?) WHERE id = ?",
|
|
615
|
+
timestamp,
|
|
616
|
+
timestamp,
|
|
617
|
+
entry.id,
|
|
618
|
+
);
|
|
619
|
+
entry.seq = result.firstSeq;
|
|
620
|
+
}
|
|
621
|
+
const parents = new Set(
|
|
622
|
+
[...converted.values()].map((entry) => entry.parentId),
|
|
623
|
+
);
|
|
624
|
+
for (const entry of converted.values()) {
|
|
625
|
+
if (entry.id !== tip && !parents.has(entry.id))
|
|
626
|
+
values.push(setValue(branchTip(`history:${entry.id}`), entry.id));
|
|
627
|
+
}
|
|
628
|
+
store.commitSync([...values, setValue(branchTip("main"), tip)]);
|
|
629
|
+
sql.exec(
|
|
630
|
+
"UPDATE pi_storage_state SET session_id = ?, created_at = ? WHERE singleton = 1",
|
|
631
|
+
metadata.id,
|
|
632
|
+
createdAt,
|
|
633
|
+
);
|
|
634
|
+
report = {
|
|
635
|
+
sourceEntries: rows.length,
|
|
636
|
+
entries: converted.size,
|
|
637
|
+
tip,
|
|
638
|
+
alreadyMigrated: false,
|
|
639
|
+
};
|
|
640
|
+
if (options.dryRun) throw rollbackDryRun;
|
|
641
|
+
});
|
|
642
|
+
} catch (error) {
|
|
643
|
+
if (error !== rollbackDryRun) throw error;
|
|
644
|
+
}
|
|
645
|
+
return report;
|
|
646
|
+
}
|