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