@springbrand/agent-runtime 0.2.0-alpha.72 → 0.3.0-alpha.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (172) hide show
  1. package/package.json +7 -7
  2. package/src/{agent-tool-runtime.ts → adapter/cloudflare/agent-tool.ts} +1 -1
  3. package/src/adapter/cloudflare/agent.ts +1682 -0
  4. package/src/{db/index.ts → adapter/cloudflare/database.ts} +18 -55
  5. package/src/{runtime-agent.ts → adapter/cloudflare/define-agent.ts} +37 -42
  6. package/src/adapter/cloudflare/dynamic-worker-observability.ts +1 -1
  7. package/src/adapter/cloudflare/index.ts +18 -0
  8. package/src/adapter/cloudflare/resources/runtime-resources.ts +4 -4
  9. package/src/adapter/cloudflare/sandbox/adapter.ts +1 -1
  10. package/src/adapter/cloudflare/scheduling/schedule-rule.ts +322 -0
  11. package/src/adapter/cloudflare/scheduling/zoned-cron.ts +11 -6
  12. package/src/{db → adapter/cloudflare}/schema.ts +28 -80
  13. package/src/adapter/cloudflare/subagent/definition.ts +63 -33
  14. package/src/adapter/cloudflare/subagent/runner.ts +174 -120
  15. package/src/adapter/cloudflare/subagent/tools.ts +13 -14
  16. package/src/adapter/cloudflare/universal-agent/preparation.ts +7 -7
  17. package/src/adapter/cloudflare/universal-agent/tools.ts +10 -12
  18. package/src/adapter/cloudflare/workspace/git-fs.ts +1 -1
  19. package/src/adapter/cloudflare/workspace/scoped-workspace.ts +1 -1
  20. package/src/adapter/cloudflare/workspace/version-control.ts +2 -2
  21. package/src/config/activation.ts +140 -0
  22. package/src/{runtime-agent-context.ts → config/agent-context.ts} +3 -3
  23. package/src/{runtime-assembler.ts → config/assembler.ts} +45 -46
  24. package/src/{kernel/runtime-assembly.ts → config/assembly-projection.ts} +4 -4
  25. package/src/{pi/assembly/snapshot.ts → config/assembly-snapshot.ts} +6 -6
  26. package/src/{runtime-definition.ts → config/definition.ts} +13 -13
  27. package/src/config/harness-runtime.ts +190 -0
  28. package/src/config/index.ts +14 -0
  29. package/src/config/model-guidance.ts +21 -0
  30. package/src/{pi/runtime-adapter/assembly.ts → config/pinning.ts} +60 -19
  31. package/src/{kernel/bindings.ts → config/ports.ts} +32 -16
  32. package/src/{kernel → config}/profile.ts +7 -0
  33. package/src/{kernel → config}/public-contracts.ts +6 -6
  34. package/src/{kernel → config}/runtime-load.ts +1 -1
  35. package/src/{pi/assembly/context.ts → context/assembly.ts} +5 -207
  36. package/src/{pi/assembly/extensions.ts → context/extension/assembly.ts} +5 -5
  37. package/src/context/extension/index.ts +3 -0
  38. package/src/context/index.ts +6 -0
  39. package/src/{layers/context → context/memory}/budget/gate.ts +21 -5
  40. package/src/conversation/harness-resume.ts +288 -0
  41. package/src/conversation/harness-stream.ts +99 -0
  42. package/src/{pi/runtime-adapter/transcript.ts → conversation/history.ts} +206 -233
  43. package/src/conversation/index.ts +6 -0
  44. package/src/{pi → conversation}/message/contract.ts +3 -0
  45. package/src/{pi → conversation}/message/conversion.ts +75 -0
  46. package/src/{pi → conversation}/message/index.ts +1 -0
  47. package/src/{pi → conversation}/message/model-input.ts +1 -1
  48. package/src/{pi → conversation}/message/projection.ts +113 -30
  49. package/src/{db/message-ui.repo.ts → conversation/repository.ts} +4 -0
  50. package/src/conversation/state.ts +196 -0
  51. package/src/harness/index.ts +7 -0
  52. package/src/harness/session-harness.ts +562 -0
  53. package/src/harness/submission-operation.ts +49 -0
  54. package/src/index.ts +38 -43
  55. package/src/lib/artifacts.ts +18 -0
  56. package/src/lib/execution-level.ts +13 -0
  57. package/src/lib/mcp.ts +1 -1
  58. package/src/lib/prompt.ts +7 -6
  59. package/src/lib/token-estimate.ts +8 -0
  60. package/src/{pi/runtime-adapter/models.ts → models/index.ts} +64 -17
  61. package/src/{pi/turn/tool-recovery.ts → session/cloudflare/legacy-tool-recovery.ts} +33 -525
  62. package/src/{pi/session → session/cloudflare}/migration.ts +404 -14
  63. package/src/{pi/session → session/cloudflare}/storage.ts +8 -36
  64. package/src/session/index.ts +39 -0
  65. package/src/skills/springbrand-worker-website/index.ts +1 -1
  66. package/src/submission/admission.ts +286 -0
  67. package/src/{kernel/receipts.ts → submission/contracts.ts} +56 -2
  68. package/src/submission/delivery.ts +212 -0
  69. package/src/submission/finalization.ts +111 -0
  70. package/src/submission/index.ts +9 -0
  71. package/src/{kernel/submission-lifecycle.ts → submission/lifecycle.ts} +41 -28
  72. package/src/{db/submission.repo.ts → submission/repository.ts} +117 -114
  73. package/src/submission/steer-delivery.ts +167 -0
  74. package/src/{db/steer.repo.ts → submission/steer-repository.ts} +27 -5
  75. package/src/submission/unattended.ts +40 -0
  76. package/src/telemetry/contract.ts +12 -0
  77. package/src/telemetry/coordinator.ts +65 -0
  78. package/src/telemetry/delivery.ts +2 -2
  79. package/src/telemetry/index.ts +5 -0
  80. package/src/{model-usage.ts → telemetry/model-usage.ts} +1 -1
  81. package/src/telemetry/recorder.ts +1 -1
  82. package/src/telemetry/runtime-telemetry.ts +12 -0
  83. package/src/{kernel/durable-lifecycle.ts → telemetry/subagent-lifecycle.ts} +1 -1
  84. package/src/telemetry/turn-events.ts +279 -0
  85. package/src/tools/README.md +44 -0
  86. package/src/{pi/tool/ai-adapter.ts → tools/adapters/ai-to-pi.ts} +6 -9
  87. package/src/{pi/tool → tools/adapters}/declared.ts +6 -2
  88. package/src/{pi/tool/nested-tools.ts → tools/adapters/pi-to-ai.ts} +2 -2
  89. package/src/tools/assistant/ask-user.ts +317 -0
  90. package/src/tools/assistant/candidate.ts +15 -0
  91. package/src/tools/assistant/followups.ts +34 -0
  92. package/src/tools/assistant/index.ts +19 -0
  93. package/src/tools/assistant/memory.ts +73 -0
  94. package/src/tools/assistant/plan.ts +98 -0
  95. package/src/{pi/tool → tools/assistant}/time.ts +1 -1
  96. package/src/{pi/tool/core-host.ts → tools/codemode/adapter.ts} +50 -49
  97. package/src/{pi/tool/core.ts → tools/codemode/index.ts} +35 -91
  98. package/src/tools/codemode/workspace-connector.ts +198 -0
  99. package/src/tools/extension/index.ts +42 -0
  100. package/src/tools/index.ts +32 -0
  101. package/src/{pi/tool → tools/mcp}/gateway.ts +19 -19
  102. package/src/{pi/tool/mcp.ts → tools/mcp/index.ts} +5 -5
  103. package/src/tools/runtime/approval/index.ts +1 -0
  104. package/src/{db/approval.repo.ts → tools/runtime/approval/repository.ts} +22 -41
  105. package/src/tools/runtime/compiler.ts +264 -0
  106. package/src/tools/runtime/contracts.ts +117 -0
  107. package/src/tools/runtime/decision-gate.ts +337 -0
  108. package/src/tools/runtime/human-decisions.ts +127 -0
  109. package/src/tools/runtime/interaction/index.ts +1 -0
  110. package/src/{db/interaction.repo.ts → tools/runtime/interaction/repository.ts} +1 -7
  111. package/src/tools/runtime/result.ts +22 -0
  112. package/src/tools/runtime/unattended-error.ts +4 -0
  113. package/src/tools/sandbox/index.ts +228 -0
  114. package/src/tools/schedule/index.ts +301 -0
  115. package/src/tools/skill/contracts.ts +20 -0
  116. package/src/tools/skill/index.ts +99 -0
  117. package/src/tools/skill/resources.ts +322 -0
  118. package/src/tools/skill/scripts.ts +168 -0
  119. package/src/{kernel/submission-authority.ts → tools/subagent/authority.ts} +31 -31
  120. package/src/{pi/tool/subagent.ts → tools/subagent/index.ts} +33 -36
  121. package/src/{db/agent-tool.repo.ts → tools/subagent/repository.ts} +1 -1
  122. package/src/{kernel/subagent-runtime.ts → tools/subagent/runtime.ts} +1 -1
  123. package/src/tools/subagent/temporary/approvals.ts +123 -0
  124. package/src/{layers/orchestration/temporary-agent/core.ts → tools/subagent/temporary/coordinator.ts} +41 -49
  125. package/src/tools/subagent/temporary/core.ts +53 -0
  126. package/src/{layers/orchestration/temporary-agent → tools/subagent/temporary}/runner.ts +1 -1
  127. package/src/{layers/orchestration/temporary-agent → tools/subagent/temporary}/workspace.ts +1 -4
  128. package/src/{pi/tool/web-fetch.ts → tools/web/fetch.ts} +4 -13
  129. package/src/tools/web/index.ts +2 -0
  130. package/src/tools/web/search/api.ts +1320 -0
  131. package/src/tools/web/search/index.ts +2 -0
  132. package/src/{pi/tool/web-search/web-search.ts → tools/web/search/search.ts} +5 -7
  133. package/src/{pi/tool/workspace-sandbox.ts → tools/workspace/index.ts} +12 -272
  134. package/src/{pi/tool/workspace-revision.ts → tools/workspace/revision.ts} +2 -2
  135. package/src/db/milestone.repo.ts +0 -104
  136. package/src/db/settlement.repo.ts +0 -104
  137. package/src/kernel/approval-lifecycle.ts +0 -638
  138. package/src/kernel/interaction-lifecycle.ts +0 -433
  139. package/src/kernel/recoverable-chat-agent.ts +0 -1020
  140. package/src/kernel/state.ts +0 -91
  141. package/src/pi/assembly/index.ts +0 -19
  142. package/src/pi/runtime-adapter/execution.ts +0 -1163
  143. package/src/pi/runtime-adapter/index.ts +0 -293
  144. package/src/pi/runtime-adapter/recovery.ts +0 -982
  145. package/src/pi/session/index.ts +0 -24
  146. package/src/pi/tool/base.ts +0 -550
  147. package/src/pi/tool/compiler.ts +0 -483
  148. package/src/pi/tool/index.ts +0 -38
  149. package/src/pi/tool/schedule.ts +0 -261
  150. package/src/pi/tool/skill.ts +0 -565
  151. package/src/pi/tool/web-search/api.ts +0 -1318
  152. package/src/pi/tool/web-search/index.ts +0 -2
  153. package/src/pi/tool/workspace-state-connector.ts +0 -146
  154. package/src/pi/turn/approval.ts +0 -181
  155. package/src/pi/turn/index.ts +0 -82
  156. package/src/pi/turn/interaction.ts +0 -181
  157. package/src/runtime.ts +0 -4760
  158. package/src/{kernel/runtime-assembly-view.ts → config/assembly-view.ts} +0 -0
  159. package/src/{kernel → config}/degradation.ts +0 -0
  160. package/src/{kernel → config}/runtime-config.ts +0 -0
  161. package/src/{kernel/extensions.ts → context/extension/contracts.ts} +0 -0
  162. package/src/{db/ext-context.repo.ts → context/extension/repository.ts} +0 -0
  163. package/src/{pi/runtime-adapter/openrouter-messages.ts → models/openrouter/messages.ts} +0 -0
  164. package/src/{pi/runtime-adapter → models/openrouter}/test-fixtures/openrouter-tool-search.sse +0 -0
  165. package/src/{db/submission-admission.repo.ts → submission/admission-repository.ts} +0 -0
  166. package/src/{db/telemetry-outbox.repo.ts → telemetry/repository.ts} +0 -0
  167. package/src/{db/runtime-event-outbox.repo.ts → telemetry/runtime-event-repository.ts} +0 -0
  168. package/src/{layers/orchestration/subagents/agent-types → tools/subagent/types}/contract.ts +0 -0
  169. package/src/{layers/orchestration/subagents/agent-types → tools/subagent/types}/extract/index.ts +0 -0
  170. package/src/{layers/orchestration/subagents/agent-types → tools/subagent/types}/fanout/index.ts +0 -0
  171. package/src/{layers/orchestration/subagents/agent-types → tools/subagent/types}/registry.ts +1 -1
  172. /package/src/{workspace-versioning.ts → tools/workspace/versioning.ts} +0 -0
@@ -2,7 +2,7 @@ import { isEqual } from "lodash-es";
2
2
  import {
3
3
  decodePiToolRecoveryMilestone,
4
4
  replayPiToolRecovery,
5
- } from "../turn/tool-recovery";
5
+ } from "./legacy-tool-recovery";
6
6
  /** Versioned SQL data migration, called before the Runtime opens its transcript. */
7
7
  import {
8
8
  branchTip,
@@ -12,19 +12,43 @@ import {
12
12
  setValue,
13
13
  deleteValue,
14
14
  sessionName,
15
+ laneState,
16
+ operationMeta,
17
+ operationState,
18
+ pendingEntry,
15
19
  sessionEntryToContextMessages,
16
20
  value,
17
21
  type Entry,
18
22
  type JsonValue,
19
23
  type Write,
24
+ type Value,
25
+ type LaneState,
26
+ type ToolExecution,
20
27
  } from "@earendil-works/pi-agent-core/harness/session";
21
- import {
22
- createToolResultMessage,
23
- type AgentToolResult,
24
- type AgentMessage,
28
+ import type {
29
+ AgentToolResult,
30
+ AgentMessage,
25
31
  } from "@earendil-works/pi-agent-core";
26
32
  import type { Usage, ToolResultMessage } from "@earendil-works/pi-ai";
27
- import { CloudflareSessionStorage } from "./storage";
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;
45
+
46
+ const LEGACY_TERMINAL_SUBMISSION_STATUSES = new Set([
47
+ "completed",
48
+ "failed",
49
+ "aborted",
50
+ "skipped",
51
+ ]);
28
52
 
29
53
  type LegacyRecord = Record<string, unknown> & {
30
54
  id: string;
@@ -76,7 +100,7 @@ function message(input: unknown): AgentMessage {
76
100
  finite(record.timestamp);
77
101
  if (record.role === "assistant" && record.stopReason === "pending")
78
102
  throw new Error("Unsettled legacy assistant message");
79
- return record as unknown as AgentMessage;
103
+ return normalizeLegacyAskUserMessage(record) as AgentMessage;
80
104
  }
81
105
  function hasTable(sql: SqlStorage, name: string): boolean {
82
106
  return (
@@ -89,6 +113,319 @@ function hasTable(sql: SqlStorage, name: string): boolean {
89
113
  );
90
114
  }
91
115
 
116
+ const ASK_USER_RESPONSE_TYPES = new Set([
117
+ "text",
118
+ "single_select",
119
+ "multi_select",
120
+ "attachment",
121
+ ]);
122
+
123
+ function normalizeLegacyAskUserInput(input: unknown): unknown {
124
+ if (!input || typeof input !== "object" || Array.isArray(input)) return input;
125
+ const record = input as Record<string, unknown>;
126
+ if (!Array.isArray(record.questions)) return input;
127
+ return {
128
+ ...record,
129
+ questions: record.questions.map((value) => {
130
+ if (!value || typeof value !== "object" || Array.isArray(value)) return value;
131
+ const question = value as Record<string, unknown>;
132
+ const { kind, multiSelect, ...current } = question;
133
+ if (ASK_USER_RESPONSE_TYPES.has(question.responseType as string)) {
134
+ return current;
135
+ }
136
+ if (kind !== undefined && kind !== "attachment") return value;
137
+ const responseType = kind === "attachment"
138
+ ? "attachment"
139
+ : Array.isArray(question.options)
140
+ ? multiSelect === true ? "multi_select" : "single_select"
141
+ : "text";
142
+ return { ...current, responseType };
143
+ }),
144
+ };
145
+ }
146
+
147
+ function normalizeLegacyAskUserMessage(value: unknown): unknown {
148
+ if (!value || typeof value !== "object" || Array.isArray(value)) return value;
149
+ const message = value as Record<string, unknown>;
150
+ if (message.role !== "assistant" || !Array.isArray(message.content)) return value;
151
+ return {
152
+ ...message,
153
+ content: message.content.map((part) => {
154
+ if (!part || typeof part !== "object" || Array.isArray(part)) return part;
155
+ const block = part as Record<string, unknown>;
156
+ return block.type === "toolCall" && block.name === "ask_user"
157
+ ? {
158
+ ...block,
159
+ arguments: normalizeLegacyAskUserInput(block.arguments),
160
+ }
161
+ : part;
162
+ }),
163
+ };
164
+ }
165
+
166
+ function normalizeLegacyAskUserEntry(value: unknown): unknown {
167
+ if (!value || typeof value !== "object" || Array.isArray(value)) return value;
168
+ const entry = value as Record<string, unknown>;
169
+ if (entry.type === "message") {
170
+ return { ...entry, message: normalizeLegacyAskUserMessage(entry.message) };
171
+ }
172
+ if (entry.type === "compaction" && Array.isArray(entry.retainedTail)) {
173
+ return {
174
+ ...entry,
175
+ retainedTail: entry.retainedTail.map(normalizeLegacyAskUserMessage),
176
+ };
177
+ }
178
+ return value;
179
+ }
180
+
181
+ function migrateLegacyAskUserData(sql: SqlStorage): void {
182
+ if (hasTable(sql, "pi_entries")) {
183
+ for (const row of sql.exec<{ id: string; body: string }>(
184
+ "SELECT id, body FROM pi_entries",
185
+ ).toArray()) {
186
+ sql.exec(
187
+ "UPDATE pi_entries SET body = ? WHERE id = ?",
188
+ JSON.stringify(normalizeLegacyAskUserEntry(JSON.parse(row.body))),
189
+ row.id,
190
+ );
191
+ }
192
+ }
193
+ if (hasTable(sql, "pi_tool_interactions")) {
194
+ for (const row of sql.exec<{ interaction_id: string; input_json: string }>(
195
+ "SELECT interaction_id, input_json FROM pi_tool_interactions WHERE tool_name = 'ask_user'",
196
+ ).toArray()) {
197
+ sql.exec(
198
+ "UPDATE pi_tool_interactions SET input_json = ? WHERE interaction_id = ?",
199
+ JSON.stringify(normalizeLegacyAskUserInput(JSON.parse(row.input_json))),
200
+ row.interaction_id,
201
+ );
202
+ }
203
+ }
204
+ if (hasTable(sql, "pi_values")) {
205
+ for (const row of sql.exec<{ key: string; body: string }>(
206
+ "SELECT key, body FROM pi_values WHERE namespace = 'pi.op.state'",
207
+ ).toArray()) {
208
+ const state = JSON.parse(row.body) as {
209
+ batch?: {
210
+ assistantEntryId?: unknown;
211
+ turnId?: unknown;
212
+ };
213
+ };
214
+ const assistantEntryId = state.batch?.assistantEntryId;
215
+ const turnId = state.batch?.turnId;
216
+ if (typeof assistantEntryId !== "string" || typeof turnId !== "string") {
217
+ continue;
218
+ }
219
+ const entryRow = sql.exec<{ body: string }>(
220
+ "SELECT body FROM pi_entries WHERE id = ?",
221
+ assistantEntryId,
222
+ ).toArray()[0];
223
+ if (!entryRow) continue;
224
+ const entry = JSON.parse(entryRow.body) as {
225
+ type?: unknown;
226
+ message?: { role?: unknown; content?: unknown };
227
+ };
228
+ if (
229
+ entry.type !== "message" || entry.message?.role !== "assistant" ||
230
+ !Array.isArray(entry.message.content)
231
+ ) continue;
232
+ entry.message.content.forEach((part, sourceIndex) => {
233
+ if (!part || typeof part !== "object" || Array.isArray(part)) return;
234
+ const block = part as Record<string, unknown>;
235
+ if (block.type !== "toolCall" || block.name !== "ask_user") return;
236
+ const key = `${row.key}:${turnId}:${sourceIndex}`;
237
+ const args = sql.exec<{ body: string }>(
238
+ "SELECT body FROM pi_values WHERE namespace = 'pi.op.tool_args' AND key = ?",
239
+ key,
240
+ ).toArray()[0];
241
+ if (!args) return;
242
+ sql.exec(
243
+ "UPDATE pi_values SET body = ? WHERE namespace = 'pi.op.tool_args' AND key = ?",
244
+ JSON.stringify(normalizeLegacyAskUserInput(JSON.parse(args.body))),
245
+ key,
246
+ );
247
+ });
248
+ }
249
+ }
250
+ }
251
+
252
+ function removeRetiredRuntimeArtifacts(sql: SqlStorage): void {
253
+ sql.exec(
254
+ "CREATE TABLE IF NOT EXISTS pi_session_entry_submission (entry_id TEXT PRIMARY KEY, submission_id TEXT NOT NULL)",
255
+ );
256
+ if (hasTable(sql, "pi_session_message_submission")) {
257
+ sql.exec(`
258
+ INSERT OR IGNORE INTO pi_session_entry_submission (entry_id, submission_id)
259
+ SELECT message_id, submission_id FROM pi_session_message_submission;
260
+ DROP TABLE pi_session_message_submission;
261
+ `);
262
+ }
263
+ if (hasTable(sql, "cf_agents_schedules")) {
264
+ const placeholders = RETIRED_SCHEDULE_CALLBACKS.map(() => "?").join(", ");
265
+ sql.exec(
266
+ `DELETE FROM cf_agents_schedules WHERE callback IN (${placeholders})`,
267
+ ...RETIRED_SCHEDULE_CALLBACKS,
268
+ );
269
+ }
270
+ migrateLegacyAskUserData(sql);
271
+ }
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;
310
+ }
311
+ }
312
+ }
313
+
314
+ function hasToolExecution(value: ToolExecution | undefined): boolean {
315
+ return value?.status === "blocked" ||
316
+ (value?.status === "executed" && value.args !== null &&
317
+ typeof value.args === "object" && !Array.isArray(value.args));
318
+ }
319
+
320
+ /** A terminal Submission cannot resume, so its stale lane pointer is not active work. */
321
+ function clearTerminalOperationPointers(sql: SqlStorage): void {
322
+ if (!hasTable(sql, "pi_submissions")) return;
323
+ const lanes = sql.exec<{ key: string; body: string }>(
324
+ "SELECT key, body FROM pi_values WHERE namespace = ?",
325
+ laneState("").namespace,
326
+ ).toArray();
327
+ for (const lane of lanes) {
328
+ const state = JSON.parse(lane.body) as LaneState;
329
+ if (state.currentOperationId === null) continue;
330
+ const terminal = sql.exec(
331
+ `SELECT 1 FROM pi_submissions
332
+ WHERE submission_id = ?
333
+ AND status IN ('completed', 'failed', 'aborted', 'skipped')
334
+ LIMIT 1`,
335
+ state.currentOperationId,
336
+ ).toArray().length === 1;
337
+ if (!terminal) continue;
338
+ sql.exec(
339
+ `UPDATE pi_values
340
+ SET body = json_set(body, '$.currentOperationId', json('null'))
341
+ WHERE namespace = ? AND key = ?`,
342
+ laneState("").namespace,
343
+ lane.key,
344
+ );
345
+ }
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`);
393
+ }
394
+ const pending = read(pendingEntry(call.resultEntryId));
395
+ if (pending?.type !== "message" || pending.payload.role !== "toolResult" ||
396
+ !hasToolExecution(pending.toolExecution)) {
397
+ reject(`pending ToolResult ${call.resultEntryId} has no reliable execution provenance`);
398
+ }
399
+ }
400
+ }
401
+ }
402
+
403
+ /** Upgrade an already-Harness-backed Session before normal reads begin. */
404
+ export function migrateCurrentPiSession(
405
+ storage: DurableObjectStorage,
406
+ ): boolean {
407
+ return storage.transactionSync(() => {
408
+ const sql = storage.sql;
409
+ if (!hasTable(sql, "pi_storage_state")) return false;
410
+ const version = sql.exec<{ storage_version: number }>(
411
+ "SELECT storage_version FROM pi_storage_state WHERE singleton = 1",
412
+ ).one().storage_version;
413
+ if (version === CURRENT_PI_STORAGE_VERSION) return false;
414
+ if (version !== 1 && version !== 2) {
415
+ throw new Error(`Unsupported Cloudflare Pi storage version: ${version}`);
416
+ }
417
+ clearTerminalOperationPointers(sql);
418
+ assertActiveToolProvenance(sql);
419
+ if (version === 1) removeRetiredRuntimeArtifacts(sql);
420
+ repairDuplicatedUserSidecars(sql);
421
+ sql.exec(
422
+ "UPDATE pi_storage_state SET storage_version = ? WHERE singleton = 1",
423
+ CURRENT_PI_STORAGE_VERSION,
424
+ );
425
+ return true;
426
+ });
427
+ }
428
+
92
429
  function assertSameToolResult(
93
430
  left: ToolResultMessage,
94
431
  right: ToolResultMessage,
@@ -107,6 +444,28 @@ function assertSameToolResult(
107
444
  }
108
445
  }
109
446
 
447
+ function legacyToolResultMessage(input: {
448
+ toolCallId: string;
449
+ toolName: string;
450
+ result: AgentToolResult<unknown>;
451
+ isError: boolean;
452
+ timestamp: number;
453
+ }): ToolResultMessage {
454
+ return {
455
+ role: "toolResult",
456
+ toolCallId: input.toolCallId,
457
+ toolName: input.toolName,
458
+ content: input.result.content ?? [],
459
+ details: input.result.details,
460
+ ...(input.result.usage ? { usage: input.result.usage } : {}),
461
+ ...(input.result.addedToolNames?.length
462
+ ? { addedToolNames: input.result.addedToolNames }
463
+ : {}),
464
+ isError: input.isError,
465
+ timestamp: input.timestamp,
466
+ };
467
+ }
468
+
110
469
  /**
111
470
  * Called synchronously during DO startup, before admission or recovery can execute.
112
471
  * Durable pending work keeps its IDs and state; no old executor runs across this boundary.
@@ -122,6 +481,7 @@ export function migrateLegacyPiSession(
122
481
  try {
123
482
  storage.transactionSync(() => {
124
483
  if (hasTable(sql, "pi_storage_state")) {
484
+ migrateCurrentPiSession(storage);
125
485
  const store = new CloudflareSessionStorage(storage);
126
486
  report = {
127
487
  sourceEntries: 0,
@@ -356,6 +716,20 @@ export function migrateLegacyPiSession(
356
716
  .map((row) => [row.message_id, row.submission_id] as const)
357
717
  : [],
358
718
  );
719
+ const submissionStatuses = new Map(
720
+ hasTable(sql, "pi_submissions")
721
+ ? sql
722
+ .exec<{ submission_id: string; status: string }>(
723
+ "SELECT submission_id, status FROM pi_submissions",
724
+ )
725
+ .toArray()
726
+ .map((row) => [row.submission_id, row.status] as const)
727
+ : [],
728
+ );
729
+ const terminalSubmission = (submissionId: string): boolean =>
730
+ LEGACY_TERMINAL_SUBMISSION_STATUSES.has(
731
+ submissionStatuses.get(submissionId) ?? "",
732
+ );
359
733
  const results = new Map<string, ToolResultMessage>();
360
734
  for (const entry of converted.values()) {
361
735
  if (entry.type !== "message" || entry.message.role !== "toolResult")
@@ -485,8 +859,12 @@ export function migrateLegacyPiSession(
485
859
  if (!knownSubmissions.has(row.submission_id))
486
860
  throw new Error("Dangling recovery submission");
487
861
  const record = decodePiToolRecoveryMilestone(row.body);
488
- if (!record || record.turnId !== row.submission_id)
862
+ if (!record || record.turnId !== row.submission_id) {
863
+ if (LEGACY_TERMINAL_SUBMISSION_STATUSES.has(
864
+ submissionStatuses.get(row.submission_id) ?? "",
865
+ )) continue;
489
866
  throw new Error("Invalid legacy recovery milestone");
867
+ }
490
868
  const bodies = bySubmission.get(row.submission_id) ?? [];
491
869
  bodies.push(row.body);
492
870
  bySubmission.set(row.submission_id, bodies);
@@ -495,8 +873,12 @@ export function migrateLegacyPiSession(
495
873
  .canonicalToolResults) {
496
874
  const key = `${row.submission_id}:${result.toolCallId}`;
497
875
  const previous = results.get(key);
498
- if (previous) assertSameToolResult(previous, result);
499
- results.set(key, result);
876
+ if (previous && !terminalSubmission(row.submission_id)) {
877
+ assertSameToolResult(previous, result);
878
+ }
879
+ if (!previous || !terminalSubmission(row.submission_id)) {
880
+ results.set(key, result);
881
+ }
500
882
  }
501
883
  }
502
884
  }
@@ -508,8 +890,12 @@ export function migrateLegacyPiSession(
508
890
  if (call.result) {
509
891
  const key = `${submissionId}:${call.toolCallId}`;
510
892
  const previous = results.get(key);
511
- if (previous) assertSameToolResult(previous, call.result);
512
- results.set(key, call.result);
893
+ if (previous && !terminalSubmission(submissionId)) {
894
+ assertSameToolResult(previous, call.result);
895
+ }
896
+ if (!previous || !terminalSubmission(submissionId)) {
897
+ results.set(key, call.result);
898
+ }
513
899
  }
514
900
  }
515
901
  }
@@ -567,7 +953,7 @@ export function migrateLegacyPiSession(
567
953
  "SELECT submission_id, tool_call_id, tool_name, result, is_error, created_at FROM pi_tool_settlements",
568
954
  )
569
955
  .toArray()) {
570
- const expected = createToolResultMessage({
956
+ const expected = legacyToolResultMessage({
571
957
  toolCallId: row.tool_call_id,
572
958
  toolName: row.tool_name,
573
959
  result: JSON.parse(row.result) as AgentToolResult<unknown>,
@@ -577,7 +963,9 @@ export function migrateLegacyPiSession(
577
963
  const recorded = results.get(
578
964
  `${row.submission_id}:${row.tool_call_id}`,
579
965
  );
580
- if (recorded) assertSameToolResult(recorded, expected);
966
+ if (recorded && !terminalSubmission(row.submission_id)) {
967
+ assertSameToolResult(recorded, expected);
968
+ }
581
969
  }
582
970
  }
583
971
  // The old table remains an inert backup. Failure rolls back DDL, data and the version together.
@@ -585,6 +973,7 @@ export function migrateLegacyPiSession(
585
973
  "ALTER TABLE pi_session_entries RENAME TO pi_legacy_entries_backup",
586
974
  );
587
975
  const store = new CloudflareSessionStorage(storage);
976
+ removeRetiredRuntimeArtifacts(sql);
588
977
  for (const entry of converted.values()) {
589
978
  const { seq: _seq, timestamp, ...next } = entry;
590
979
  const writes: Write[] = [insertEntry(next)];
@@ -626,6 +1015,7 @@ export function migrateLegacyPiSession(
626
1015
  values.push(setValue(branchTip(`history:${entry.id}`), entry.id));
627
1016
  }
628
1017
  store.commitSync([...values, setValue(branchTip("main"), tip)]);
1018
+ repairDuplicatedUserSidecars(sql);
629
1019
  sql.exec(
630
1020
  "UPDATE pi_storage_state SET session_id = ?, created_at = ? WHERE singleton = 1",
631
1021
  metadata.id,
@@ -8,7 +8,6 @@ import {
8
8
  setValue,
9
9
  insertEntry,
10
10
  insertUsage,
11
- type CompactionEntry,
12
11
  type Storage,
13
12
  type Write,
14
13
  type CommitResult,
@@ -26,9 +25,10 @@ import {
26
25
  type SessionStats,
27
26
  type SessionMetadata,
28
27
  } from "@earendil-works/pi-agent-core/harness/session";
29
- import type { Usage } from "@earendil-works/pi-ai";
30
28
  import type { Context } from "@earendil-works/pi-agent-core/harness/context";
31
29
 
30
+ export const CURRENT_PI_STORAGE_VERSION = 3;
31
+
32
32
  /** Pi's Storage contract, with every physical commit confined to one DO transaction. */
33
33
  export class CloudflareSessionStorage implements Storage {
34
34
  private closed = false;
@@ -60,13 +60,13 @@ export class CloudflareSessionStorage implements Storage {
60
60
  storage_version: number;
61
61
  }>("SELECT storage_version FROM pi_storage_state WHERE singleton = 1")
62
62
  .one().storage_version;
63
- if (version !== 1)
63
+ if (version !== CURRENT_PI_STORAGE_VERSION)
64
64
  throw new Error(
65
65
  `Unsupported Cloudflare Pi storage version: ${version}`,
66
66
  );
67
67
  }
68
68
  this.sql.exec(`
69
- CREATE TABLE IF NOT EXISTS pi_session_message_submission (message_id TEXT PRIMARY KEY, submission_id TEXT NOT NULL);
69
+ CREATE TABLE IF NOT EXISTS pi_session_entry_submission (entry_id TEXT PRIMARY KEY, submission_id TEXT NOT NULL);
70
70
  CREATE TABLE IF NOT EXISTS pi_entries (
71
71
  id TEXT PRIMARY KEY, seq INTEGER NOT NULL UNIQUE, parent_id TEXT,
72
72
  type TEXT NOT NULL, custom_type TEXT, timestamp INTEGER NOT NULL, body TEXT NOT NULL
@@ -83,7 +83,8 @@ export class CloudflareSessionStorage implements Storage {
83
83
  CREATE TABLE IF NOT EXISTS pi_storage_state (singleton INTEGER PRIMARY KEY CHECK(singleton = 1), next_seq INTEGER NOT NULL, storage_version INTEGER NOT NULL, session_id TEXT NOT NULL, created_at INTEGER NOT NULL, stats TEXT NOT NULL);
84
84
  `);
85
85
  this.sql.exec(
86
- "INSERT OR IGNORE INTO pi_storage_state VALUES (1, 1, 1, ?, ?, ?)",
86
+ "INSERT OR IGNORE INTO pi_storage_state VALUES (1, 1, ?, ?, ?, ?)",
87
+ CURRENT_PI_STORAGE_VERSION,
87
88
  crypto.randomUUID(),
88
89
  Date.now(),
89
90
  JSON.stringify({
@@ -495,7 +496,7 @@ export class CloudflareSessionStorage implements Storage {
495
496
  ]);
496
497
  if (input.submissionId)
497
498
  this.sql.exec(
498
- "INSERT INTO pi_session_message_submission VALUES (?, ?)",
499
+ "INSERT INTO pi_session_entry_submission VALUES (?, ?)",
499
500
  input.id,
500
501
  input.submissionId,
501
502
  );
@@ -503,35 +504,6 @@ export class CloudflareSessionStorage implements Storage {
503
504
  });
504
505
  }
505
506
 
506
- moveBranchTip(id: string | null): void {
507
- if (id !== null && !this.entry(id))
508
- throw new Error(`Unknown branch target: ${id}`);
509
- this.commitSync([setValue(branchTip("main"), id)]);
510
- }
511
-
512
- recordCompaction(
513
- entry: Omit<CompactionEntry, "id" | "parentId" | "seq" | "timestamp"> | undefined,
514
- usage: Usage | undefined = entry?.usage,
515
- ): string {
516
- const id = crypto.randomUUID();
517
- const writes: Write[] = [];
518
- if (entry) writes.push(
519
- insertEntry({
520
- ...entry, id,
521
- parentId: this.getValueSync(branchTip("main"))?.value ?? null,
522
- }),
523
- setValue(branchTip("main"), id),
524
- );
525
- if (usage) writes.push(insertUsage({
526
- id: entry ? crypto.randomUUID() : id,
527
- ...(entry ? { entryId: id } : {}),
528
- usage,
529
- adjustment: false,
530
- }));
531
- this.commitSync(writes);
532
- return id;
533
- }
534
-
535
507
  entries(): Entry[] {
536
508
  return this.sql
537
509
  .exec<{ body: string }>("SELECT body FROM pi_entries ORDER BY seq")
@@ -542,7 +514,7 @@ export class CloudflareSessionStorage implements Storage {
542
514
  clear(): void {
543
515
  this.storage.transactionSync(() => {
544
516
  this.sql.exec(
545
- "DELETE FROM pi_session_message_submission; DELETE FROM pi_entries; DELETE FROM pi_values; DELETE FROM pi_lists; DELETE FROM pi_usage;",
517
+ "DELETE FROM pi_session_entry_submission; DELETE FROM pi_entries; DELETE FROM pi_values; DELETE FROM pi_lists; DELETE FROM pi_usage;",
546
518
  );
547
519
  const usage = {
548
520
  input: 0,
@@ -0,0 +1,39 @@
1
+ import { StorageBackedSession } from "@earendil-works/pi-agent-core/harness/session";
2
+ import {
3
+ migrateCurrentPiSession,
4
+ migrateLegacyPiSession,
5
+ } from "./cloudflare/migration";
6
+ import { CloudflareSessionStorage } from "./cloudflare/storage";
7
+
8
+ export { migrateLegacyPiSession } from "./cloudflare/migration";
9
+ export { CloudflareSessionStorage } from "./cloudflare/storage";
10
+
11
+ /** Open only after the startup transaction has installed the current database format. */
12
+ export function openPiSessionStorage(
13
+ storage: DurableObjectStorage,
14
+ ): CloudflareSessionStorage {
15
+ return storage.transactionSync(() => {
16
+ const tables = new Set(
17
+ storage.sql
18
+ .exec<{ name: string }>(
19
+ "SELECT name FROM sqlite_master WHERE type = 'table' AND name IN ('pi_storage_state', 'pi_session_entries')",
20
+ )
21
+ .toArray()
22
+ .map((row) => row.name),
23
+ );
24
+ if (!tables.has("pi_storage_state") && tables.has("pi_session_entries")) {
25
+ migrateLegacyPiSession(storage, { dryRun: false });
26
+ } else if (tables.has("pi_storage_state")) {
27
+ migrateCurrentPiSession(storage);
28
+ }
29
+ return new CloudflareSessionStorage(storage);
30
+ });
31
+ }
32
+
33
+ /** Open the single official Pi Session owned by one Cloudflare Agent facet. */
34
+ export function openCloudflareSession(
35
+ storage: DurableObjectStorage,
36
+ ): StorageBackedSession {
37
+ const store = openPiSessionStorage(storage);
38
+ return new StorageBackedSession(store.metadata, store);
39
+ }
@@ -163,7 +163,7 @@ export const SPRINGBRAND_WORKER_WEBSITE_SKILL = {
163
163
  "5. Call `get_browser_console_logs` for the home page and for every primary internal page. Check navigation responses, missing assets, exceptions, CSP or CORS failures, and client fetch failures.",
164
164
  "6. Open the Website with the available browser evidence tools. For a multi-page Website, follow real links from the home page to every primary internal page and verify navigation back to the home page. Check at least the normal desktop viewport and one narrow viewport; capture and inspect a screenshot when visual appearance matters.",
165
165
  "7. Fix every failure, redeploy, and repeat the same checks. A clean home page does not prove a multi-page Website works.",
166
- "8. After browser verification succeeds, call `save_creation` exactly once with the existing entry HTML Working Copy path. For the recommended layout, that is `/creations/<website-name>/public/index.html`.",
166
+ "8. After browser verification succeeds, call `save_creation` exactly once with a user-visible title and the existing entry HTML Working Copy path. For the recommended layout, that path is `/creations/<website-name>/public/index.html`.",
167
167
  "9. Do not call the Website complete until `save_creation` returns an `artifactId`. Do not save referenced assets as separate Creations.",
168
168
  "",
169
169
  "## Publication contract",