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