@ixo/oracle-runtime-workers 0.12.0 → 0.15.0

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 (205) hide show
  1. package/README.md +32 -10
  2. package/package.json +14 -9
  3. package/src/artifacts/config.ts +158 -0
  4. package/src/artifacts/crypto.ts +94 -0
  5. package/src/artifacts/routes.ts +73 -0
  6. package/src/artifacts/store.ts +238 -0
  7. package/src/artifacts/sweep.ts +200 -0
  8. package/src/artifacts/tool.ts +103 -0
  9. package/src/artifacts/viewer-page.ts +614 -0
  10. package/src/attachments/download.ts +168 -45
  11. package/src/attachments/index.ts +9 -0
  12. package/src/attachments/magic.ts +33 -0
  13. package/src/attachments/pipeline.ts +105 -49
  14. package/src/attachments/sandbox-archive.ts +9 -8
  15. package/src/attachments/view-cache.ts +142 -0
  16. package/src/attachments/view.ts +53 -12
  17. package/src/channels/auth.ts +267 -0
  18. package/src/channels/contract.ts +137 -0
  19. package/src/channels/turns.ts +270 -0
  20. package/src/core/budgeted-llm.ts +223 -0
  21. package/src/core/capability-router.ts +273 -0
  22. package/src/core/context-budget.ts +97 -10
  23. package/src/core/env.ts +111 -0
  24. package/src/core/execution-profile.ts +17 -0
  25. package/src/core/index.ts +267 -9
  26. package/src/core/llm.ts +30 -9
  27. package/src/core/main-agent-types.ts +41 -1
  28. package/src/core/main-agent.ts +367 -65
  29. package/src/core/manifest.ts +34 -0
  30. package/src/core/meta-tools.ts +128 -18
  31. package/src/core/middlewares/capability-gate.ts +130 -12
  32. package/src/core/middlewares/context-guard.ts +55 -24
  33. package/src/core/middlewares/index.ts +20 -2
  34. package/src/core/middlewares/page-context.ts +46 -2
  35. package/src/core/middlewares/return-direct-first.ts +47 -0
  36. package/src/core/middlewares/summarization.ts +152 -10
  37. package/src/core/middlewares/tool-execution.ts +323 -0
  38. package/src/core/middlewares/tool-repetition-guard.ts +202 -35
  39. package/src/core/middlewares/turn-boundary.ts +162 -0
  40. package/src/core/openrouter-pricing.ts +155 -22
  41. package/src/core/prompt-composer.ts +112 -7
  42. package/src/core/registries.ts +253 -48
  43. package/src/core/request-admission.ts +159 -0
  44. package/src/core/runtime-context.ts +107 -8
  45. package/src/core/state.ts +1 -1
  46. package/src/core/sub-agent-fallback.ts +21 -2
  47. package/src/core/subagent-as-tool.ts +78 -43
  48. package/src/core/tool-access.ts +189 -0
  49. package/src/core/tool-scheduler.ts +125 -0
  50. package/src/core/turn-budget.ts +205 -0
  51. package/src/core/turn-time-note.ts +43 -0
  52. package/src/core/user-kv.ts +446 -0
  53. package/src/core/wrap-plugin-tool.ts +20 -0
  54. package/src/delivery/plan.ts +278 -0
  55. package/src/delivery/profile.ts +161 -0
  56. package/src/delivery/prompt.ts +27 -0
  57. package/src/delivery/schema.ts +61 -0
  58. package/src/delivery/shaper.ts +389 -0
  59. package/src/delivery/types.ts +84 -0
  60. package/src/do/ambient.ts +51 -15
  61. package/src/do/contracts.ts +198 -6
  62. package/src/do/idle-eviction.ts +38 -0
  63. package/src/do/matrix-turn-ledger.ts +24 -5
  64. package/src/do/result-store-test-do.ts +137 -3
  65. package/src/do/result-store.ts +239 -58
  66. package/src/do/room-mirror.ts +29 -0
  67. package/src/do/run-buffer.ts +64 -9
  68. package/src/do/run-coordinator.ts +451 -76
  69. package/src/do/run-request.ts +2 -0
  70. package/src/do/run-store-test-do.ts +301 -1
  71. package/src/do/run-store.ts +385 -43
  72. package/src/do/secrets-adapter.ts +22 -3
  73. package/src/do/sse-stream.ts +60 -9
  74. package/src/do/transcript.ts +48 -5
  75. package/src/do/turn-body.ts +105 -5
  76. package/src/do/ucan-service.ts +204 -34
  77. package/src/do/user-oracle-do.ts +1883 -264
  78. package/src/feedback/config.ts +127 -0
  79. package/src/feedback/contract.ts +148 -0
  80. package/src/feedback/linear-sink.ts +285 -0
  81. package/src/feedback/privacy.ts +150 -0
  82. package/src/feedback/reservation.ts +298 -0
  83. package/src/feedback/submit.ts +247 -0
  84. package/src/index.ts +138 -6
  85. package/src/llm/byo-adapter.ts +27 -6
  86. package/src/llm/byo-catalog.ts +13 -0
  87. package/src/llm/byo-model-fallback.ts +248 -0
  88. package/src/llm/byo-service.ts +45 -8
  89. package/src/llm/provider-error.ts +139 -2
  90. package/src/matrix/gateway-do.ts +523 -111
  91. package/src/matrix/group-chat.ts +66 -23
  92. package/src/matrix/inbox-store.ts +16 -1
  93. package/src/matrix/ingest.ts +79 -14
  94. package/src/matrix/media-deadline.ts +130 -0
  95. package/src/matrix/replay-format.ts +5 -3
  96. package/src/matrix/reply-parts.ts +92 -0
  97. package/src/matrix/room-alias.ts +96 -0
  98. package/src/matrix/user-homeserver.ts +166 -7
  99. package/src/owner-store/ixo-vfs-store.ts +170 -43
  100. package/src/owner-store/migrating-store.ts +31 -15
  101. package/src/owner-store/types.ts +18 -3
  102. package/src/plugin-api/index.ts +43 -2
  103. package/src/plugin-api/oracle-plugin.ts +35 -0
  104. package/src/plugin-api/request-admission.ts +41 -0
  105. package/src/plugin-api/tool-helper.ts +25 -1
  106. package/src/plugin-api/tool-plane.ts +92 -0
  107. package/src/plugin-api/types.ts +194 -6
  108. package/src/plugins/agui/agui.plugin.ts +31 -16
  109. package/src/plugins/composio/composio-tools.ts +57 -6
  110. package/src/plugins/composio/composio-ucan.ts +3 -2
  111. package/src/plugins/composio/composio.plugin.ts +2 -0
  112. package/src/plugins/delegated-capabilities.ts +25 -0
  113. package/src/plugins/editor/content-session.ts +173 -83
  114. package/src/plugins/editor/content-tools.ts +76 -17
  115. package/src/plugins/editor/editor-mx.ts +28 -9
  116. package/src/plugins/editor/editor.plugin.ts +3 -1
  117. package/src/plugins/editor/failures.ts +28 -7
  118. package/src/plugins/editor/prompts.ts +2 -1
  119. package/src/plugins/editor/provider.ts +553 -40
  120. package/src/plugins/editor/standalone-editor-tool.ts +61 -18
  121. package/src/plugins/firecrawl/firecrawl-tools.ts +13 -5
  122. package/src/plugins/flows/action-metadata.ts +111 -11
  123. package/src/plugins/flows/actions.ts +18 -0
  124. package/src/plugins/flows/decision-context.ts +31 -0
  125. package/src/plugins/flows/edit.ts +223 -52
  126. package/src/plugins/flows/errors.ts +22 -1
  127. package/src/plugins/flows/flow-doc.ts +92 -9
  128. package/src/plugins/flows/flows.plugin.ts +11 -5
  129. package/src/plugins/flows/forms.ts +34 -0
  130. package/src/plugins/flows/index.ts +2 -0
  131. package/src/plugins/flows/input-policy.ts +140 -0
  132. package/src/plugins/flows/prompts.ts +14 -11
  133. package/src/plugins/flows/read.ts +49 -9
  134. package/src/plugins/flows/ref-syntax.ts +129 -0
  135. package/src/plugins/flows/test-homeserver.ts +245 -0
  136. package/src/plugins/flows/tools/authoring.ts +216 -112
  137. package/src/plugins/flows/tools/discovery.ts +4 -1
  138. package/src/plugins/flows/tools/forms.ts +21 -2
  139. package/src/plugins/flows/tools/settings.ts +34 -13
  140. package/src/plugins/flows/translator.ts +13 -10
  141. package/src/plugins/flows/types.ts +167 -42
  142. package/src/plugins/index.ts +11 -1
  143. package/src/plugins/ixo-transaction/chain-ids.ts +43 -0
  144. package/src/plugins/ixo-transaction/index.ts +2 -0
  145. package/src/plugins/ixo-transaction/ixo-transaction.plugin.ts +119 -0
  146. package/src/plugins/ixo-transaction/receipts.ts +99 -0
  147. package/src/plugins/ixo-transaction/tools.ts +329 -0
  148. package/src/plugins/ixo-transaction/wallet-signing.ts +202 -0
  149. package/src/plugins/mcp-tool-adapter.ts +23 -2
  150. package/src/plugins/memory/memory-tools.ts +7 -2
  151. package/src/plugins/memory/memory-ucan.ts +3 -2
  152. package/src/plugins/memory/memory.plugin.ts +2 -0
  153. package/src/plugins/pod-creator/blueprint-store.ts +163 -0
  154. package/src/plugins/pod-creator/blueprint-types.ts +61 -0
  155. package/src/plugins/pod-creator/bounded-map.ts +87 -0
  156. package/src/plugins/pod-creator/capsule-content-client.ts +285 -0
  157. package/src/plugins/pod-creator/chain-gateway.ts +110 -0
  158. package/src/plugins/pod-creator/config.ts +85 -0
  159. package/src/plugins/pod-creator/create-session-store.ts +180 -0
  160. package/src/plugins/pod-creator/create-tools.ts +538 -0
  161. package/src/plugins/pod-creator/design-pod-roles.ts +111 -0
  162. package/src/plugins/pod-creator/index.ts +65 -0
  163. package/src/plugins/pod-creator/orchestration-tools.ts +172 -0
  164. package/src/plugins/pod-creator/pod-creator.plugin.ts +190 -0
  165. package/src/plugins/pod-creator/stage.ts +201 -0
  166. package/src/plugins/pod-creator/sub-agents.ts +189 -0
  167. package/src/plugins/portal/action-log.ts +21 -1
  168. package/src/plugins/portal/declared-tools.ts +90 -0
  169. package/src/plugins/portal/portal.plugin.ts +42 -16
  170. package/src/plugins/sandbox/sandbox-bridge.ts +79 -10
  171. package/src/plugins/sandbox/sandbox-mcp.ts +3 -2
  172. package/src/plugins/sandbox/sandbox-write-blob.ts +11 -1
  173. package/src/plugins/sandbox/sandbox.plugin.ts +8 -16
  174. package/src/plugins/tasks/manifest.ts +1 -1
  175. package/src/plugins/tasks/middleware.ts +20 -7
  176. package/src/plugins/tasks/tasks-tools.ts +30 -3
  177. package/src/plugins/tasks/tasks.plugin.ts +64 -11
  178. package/src/plugins/user-preferences/schema.ts +12 -1
  179. package/src/plugins/user-preferences/user-preferences-store.ts +38 -13
  180. package/src/plugins/user-preferences/user-preferences-tool.ts +3 -2
  181. package/src/plugins/vfs/vfs-client.ts +250 -71
  182. package/src/plugins/vfs/vfs-content.ts +30 -11
  183. package/src/plugins/vfs/vfs-errors.ts +22 -0
  184. package/src/plugins/vfs/vfs-sandbox-tools.ts +12 -5
  185. package/src/plugins/vfs/vfs-tools.ts +60 -20
  186. package/src/realtime/frontend-call-registry.ts +228 -81
  187. package/src/realtime/realtime-endpoint.ts +215 -44
  188. package/src/realtime/session-socket-hub.ts +47 -2
  189. package/src/secrets/secrets-service.ts +9 -3
  190. package/src/shell/app.ts +529 -82
  191. package/src/shell/auth.ts +131 -37
  192. package/src/shell/turn-body-cap.ts +11 -13
  193. package/src/sqlite/blob-compactor.ts +61 -28
  194. package/src/sqlite/database.ts +156 -90
  195. package/src/sqlite/do-vfs.ts +416 -129
  196. package/src/sqlite/page-tier.ts +133 -40
  197. package/src/sqlite/serialization.ts +40 -0
  198. package/src/sqlite/sessions-store.ts +10 -2
  199. package/src/sqlite/sqlite-saver.ts +139 -19
  200. package/src/sqlite/user-kv-store.ts +317 -0
  201. package/src/tasks/schedule.ts +201 -12
  202. package/src/tasks/scheduler.ts +586 -98
  203. package/src/tasks/spec.ts +67 -3
  204. package/src/tasks/store.ts +292 -27
  205. package/src/tasks/topic-deliverables.ts +70 -0
@@ -14,10 +14,15 @@
14
14
  * - `turn_tool_marks`: one row per tool call, written BEFORE the tool runs
15
15
  * (`started`) and updated when it returns (`done`). This is what makes a
16
16
  * resume safe: a started-but-unfinished write call is never re-executed.
17
+ * - `turn_write_claims`: one row per write whose outcome is not known yet
18
+ * (tool-execution.ts), keyed by the fingerprint of the tool name and
19
+ * arguments. Released by a returned outcome; kept by an abort, a deadline
20
+ * or a dropped connection, so the same write is not repeated blindly by
21
+ * a later call — across sessions of the user, until the run retention.
17
22
  *
18
23
  * Cost shape (SQLite-backed DO storage bills rows written or deleted): a run
19
24
  * costs 1 insert + ~2 updates, a segment 1 insert + 1 delete, a tool call
20
- * 1 insert + 1 update.
25
+ * 1 insert + 1 update, a write 1 insert + 1 delete more.
21
26
  */
22
27
  import type { DoSqliteDatabase } from '../sqlite/database';
23
28
  import type { PackedSegment } from './run-buffer';
@@ -39,11 +44,64 @@ export const ACTIVE_RUN_STATUSES: ReadonlySet<RunStatus> = new Set([
39
44
 
40
45
  export type ToolEffect = 'read' | 'write';
41
46
 
47
+ /** The columns a run's row is updated with. */
48
+ export type RunPatch = Partial<
49
+ Pick<
50
+ RunRecord,
51
+ | 'status'
52
+ | 'attempts'
53
+ | 'generation'
54
+ | 'nextAttemptAt'
55
+ | 'checkpointId'
56
+ | 'startCheckpointId'
57
+ | 'lastSeq'
58
+ | 'partialText'
59
+ | 'messageId'
60
+ | 'error'
61
+ | 'taskRunId'
62
+ | 'instanceId'
63
+ | 'usage'
64
+ | 'request'
65
+ >
66
+ >;
67
+
68
+ /**
69
+ * `record` as its row reads after `update(patch)` stamped `updatedAt`, so a
70
+ * writer keeps its copy current without reading the row back. Like
71
+ * `update`, a field whose value is `undefined` is left as it was.
72
+ */
73
+ export function patchRunRecord(
74
+ record: RunRecord,
75
+ patch: RunPatch,
76
+ updatedAt: string,
77
+ ): RunRecord {
78
+ const next: RunRecord = { ...record, updatedAt };
79
+ if (patch.status !== undefined) next.status = patch.status;
80
+ if (patch.attempts !== undefined) next.attempts = patch.attempts;
81
+ if (patch.generation !== undefined) next.generation = patch.generation;
82
+ if (patch.nextAttemptAt !== undefined)
83
+ next.nextAttemptAt = patch.nextAttemptAt;
84
+ if (patch.checkpointId !== undefined) next.checkpointId = patch.checkpointId;
85
+ if (patch.startCheckpointId !== undefined) {
86
+ next.startCheckpointId = patch.startCheckpointId;
87
+ next.startRecorded = true;
88
+ }
89
+ if (patch.lastSeq !== undefined) next.lastSeq = patch.lastSeq;
90
+ if (patch.partialText !== undefined) next.partialText = patch.partialText;
91
+ if (patch.messageId !== undefined) next.messageId = patch.messageId;
92
+ if (patch.error !== undefined) next.error = patch.error;
93
+ if (patch.taskRunId !== undefined) next.taskRunId = patch.taskRunId;
94
+ if (patch.instanceId !== undefined) next.instanceId = patch.instanceId;
95
+ if (patch.usage !== undefined) next.usage = patch.usage;
96
+ if (patch.request !== undefined) next.request = patch.request;
97
+ return next;
98
+ }
99
+
42
100
  export interface RunRecord {
43
101
  runId: string;
44
102
  sessionId: string;
45
103
  requestId: string;
46
- client: 'portal' | 'matrix';
104
+ client: 'portal' | 'matrix' | 'channel';
47
105
  status: RunStatus;
48
106
  startedAt: string;
49
107
  updatedAt: string;
@@ -62,6 +120,15 @@ export interface RunRecord {
62
120
  nextAttemptAt: number | null;
63
121
  /** Checkpoint id observed when the run (or its last attempt) started. */
64
122
  checkpointId: string | null;
123
+ /**
124
+ * Checkpoint id of the session when the run began executing (at begin, or
125
+ * when it was dequeued) — before the graph wrote the run's input. Unlike
126
+ * `checkpointId` it is never moved by a recovery, so it answers whether
127
+ * the graph ever persisted anything for this run (`runHasCheckpointed`).
128
+ */
129
+ startCheckpointId: string | null;
130
+ /** `startCheckpointId` was recorded (rows written before it existed: false). */
131
+ startRecorded: boolean;
65
132
  /** Highest frame sequence packed into a segment. */
66
133
  lastSeq: number;
67
134
  /** The reply so far, kept when the run ends without a committed reply. */
@@ -73,6 +140,22 @@ export interface RunRecord {
73
140
  taskRunId: string | null;
74
141
  /** Instance that owns the run — a different instance at boot means an orphan. */
75
142
  instanceId: string;
143
+ /** JSON: the turn's budget usage (`TurnUsage`), written once when the run ends. */
144
+ usage: string | null;
145
+ }
146
+
147
+ /**
148
+ * A write whose outcome the ledger still has to account for (tool-execution.ts).
149
+ * `pending`: started, no known outcome yet. `warned`: an identical write was
150
+ * refused since and the model was told; a later turn may run it again.
151
+ */
152
+ export interface WriteClaimRecord {
153
+ fingerprint: string;
154
+ toolName: string;
155
+ runId: string;
156
+ sessionId: string;
157
+ startedAt: string;
158
+ state: 'pending' | 'warned';
76
159
  }
77
160
 
78
161
  export interface ToolMark {
@@ -91,6 +174,9 @@ export interface ToolMark {
91
174
  /** Rows of ended runs are kept a week (a re-join that late gets 404). */
92
175
  export const RUN_RETENTION_MS = 7 * 24 * 3600 * 1000;
93
176
 
177
+ /** An ended run last touched before the cutoff (the one bound parameter). */
178
+ export const STALE_RUN_FILTER = `status IN ('finished','aborted','interrupted','failed') AND updated_at < ?`;
179
+
94
180
  /**
95
181
  * Sequence-number space of one attempt. A cursor is `after=<seq>`; the
96
182
  * next attempt starts numbering at the next multiple of this, above any
@@ -152,6 +238,32 @@ export function runDurabilityConfig(
152
238
  };
153
239
  }
154
240
 
241
+ /** The session's checkpoint moved on from `since` (an unreadable checkpoint, `null`, never counts). */
242
+ export function checkpointAdvanced(
243
+ since: string | null,
244
+ currentCheckpointId: string | null,
245
+ ): boolean {
246
+ return currentCheckpointId !== null && currentCheckpointId !== since;
247
+ }
248
+
249
+ /**
250
+ * Whether the graph persisted anything for this run, judged by the same
251
+ * comparison as `decideRecovery`'s progress, against the checkpoint the run
252
+ * started from. `true` → the next attempt resumes from the checkpoint;
253
+ * `false` → the graph never saw the input, so the attempt runs fresh with it.
254
+ * A row without a recorded start (written before it was stored) resumes, as
255
+ * every recovery did then.
256
+ */
257
+ export function runHasCheckpointed(
258
+ run: Pick<RunRecord, 'startCheckpointId' | 'startRecorded'>,
259
+ currentCheckpointId: string | null,
260
+ ): boolean {
261
+ return (
262
+ !run.startRecorded ||
263
+ checkpointAdvanced(run.startCheckpointId, currentCheckpointId)
264
+ );
265
+ }
266
+
155
267
  export type RecoveryDecision =
156
268
  | { action: 'schedule'; at: number; attempts: number }
157
269
  | { action: 'interrupt'; attempts: number };
@@ -168,8 +280,7 @@ export function decideRecovery(
168
280
  now: number,
169
281
  config: Pick<RunDurabilityConfig, 'recoveryAttempts' | 'recoveryDelaysMs'>,
170
282
  ): RecoveryDecision {
171
- const progressed =
172
- currentCheckpointId !== null && currentCheckpointId !== run.checkpointId;
283
+ const progressed = checkpointAdvanced(run.checkpointId, currentCheckpointId);
173
284
  const spent = progressed ? 0 : run.attempts;
174
285
  if (spent >= config.recoveryAttempts)
175
286
  return { action: 'interrupt', attempts: spent };
@@ -191,12 +302,24 @@ type RunRow = {
191
302
  generation: number;
192
303
  next_attempt_at: number | null;
193
304
  checkpoint_id: string | null;
305
+ start_checkpoint_id: string | null;
306
+ start_recorded: number;
194
307
  last_seq: number;
195
308
  partial_text: string | null;
196
309
  message_id: string | null;
197
310
  error: string | null;
198
311
  task_run_id: string | null;
199
312
  instance_id: string;
313
+ usage: string | null;
314
+ } & Record<string, string | number | null>;
315
+
316
+ type ClaimRow = {
317
+ fingerprint: string;
318
+ tool_name: string;
319
+ run_id: string;
320
+ session_id: string;
321
+ started_at: string;
322
+ state: string;
200
323
  } & Record<string, string | number | null>;
201
324
 
202
325
  type MarkRow = {
@@ -217,14 +340,17 @@ type SegmentRow = {
217
340
  } & Record<string, string | number | null>;
218
341
 
219
342
  const RUN_COLUMNS =
220
- 'run_id, session_id, request_id, client, status, started_at, updated_at, request, attempts, generation, next_attempt_at, checkpoint_id, last_seq, partial_text, message_id, error, task_run_id, instance_id';
343
+ 'run_id, session_id, request_id, client, status, started_at, updated_at, request, attempts, generation, next_attempt_at, checkpoint_id, start_checkpoint_id, start_recorded, last_seq, partial_text, message_id, error, task_run_id, instance_id, usage';
221
344
 
222
345
  function toRecord(row: RunRow): RunRecord {
223
346
  return {
224
347
  runId: row.run_id,
225
348
  sessionId: row.session_id,
226
349
  requestId: row.request_id,
227
- client: row.client === 'matrix' ? 'matrix' : 'portal',
350
+ client:
351
+ row.client === 'matrix' || row.client === 'channel'
352
+ ? row.client
353
+ : 'portal',
228
354
  status: row.status as RunStatus,
229
355
  startedAt: row.started_at,
230
356
  updatedAt: row.updated_at,
@@ -234,12 +360,26 @@ function toRecord(row: RunRow): RunRecord {
234
360
  nextAttemptAt:
235
361
  row.next_attempt_at === null ? null : Number(row.next_attempt_at),
236
362
  checkpointId: row.checkpoint_id,
363
+ startCheckpointId: row.start_checkpoint_id,
364
+ startRecorded: Number(row.start_recorded) === 1,
237
365
  lastSeq: Number(row.last_seq),
238
366
  partialText: row.partial_text,
239
367
  messageId: row.message_id,
240
368
  error: row.error,
241
369
  taskRunId: row.task_run_id,
242
370
  instanceId: row.instance_id,
371
+ usage: row.usage,
372
+ };
373
+ }
374
+
375
+ function toClaim(row: ClaimRow): WriteClaimRecord {
376
+ return {
377
+ fingerprint: row.fingerprint,
378
+ toolName: row.tool_name,
379
+ runId: row.run_id,
380
+ sessionId: row.session_id,
381
+ startedAt: row.started_at,
382
+ state: row.state === 'warned' ? 'warned' : 'pending',
243
383
  };
244
384
  }
245
385
 
@@ -303,6 +443,16 @@ export class RunStore {
303
443
  await this.db.run(
304
444
  `ALTER TABLE turn_runs ADD COLUMN generation INTEGER NOT NULL DEFAULT 0`,
305
445
  );
446
+ if (!columns.some((c) => c.name === 'usage'))
447
+ await this.db.run(`ALTER TABLE turn_runs ADD COLUMN usage TEXT`);
448
+ if (!columns.some((c) => c.name === 'start_checkpoint_id'))
449
+ await this.db.run(
450
+ `ALTER TABLE turn_runs ADD COLUMN start_checkpoint_id TEXT`,
451
+ );
452
+ if (!columns.some((c) => c.name === 'start_recorded'))
453
+ await this.db.run(
454
+ `ALTER TABLE turn_runs ADD COLUMN start_recorded INTEGER NOT NULL DEFAULT 0`,
455
+ );
306
456
  await this.db.run(
307
457
  `CREATE INDEX IF NOT EXISTS idx_turn_runs_session ON turn_runs(session_id, started_at)`,
308
458
  );
@@ -326,22 +476,105 @@ export class RunStore {
326
476
  attempts INTEGER NOT NULL DEFAULT 1,
327
477
  PRIMARY KEY (run_id, tool_call_id)
328
478
  ) WITHOUT ROWID`);
329
- // Ended runs older than the retention window: their segments are gone
330
- // already (cutover); drop the rows and marks in one pass per boot.
479
+ await this.db.run(`
480
+ CREATE TABLE IF NOT EXISTS turn_write_claims (
481
+ fingerprint TEXT PRIMARY KEY,
482
+ tool_name TEXT NOT NULL,
483
+ run_id TEXT NOT NULL,
484
+ session_id TEXT NOT NULL,
485
+ started_at TEXT NOT NULL,
486
+ state TEXT NOT NULL DEFAULT 'pending'
487
+ ) WITHOUT ROWID`);
488
+ await this.db.run(`CREATE TABLE IF NOT EXISTS channel_run_tombstones (
489
+ run_id TEXT PRIMARY KEY, status TEXT NOT NULL
490
+ ) WITHOUT ROWID`);
491
+ // The Reply Plan a finished chat-surface run delivers (chat delivery).
492
+ await this.db.run(`CREATE TABLE IF NOT EXISTS turn_run_plans (
493
+ run_id TEXT PRIMARY KEY, plan TEXT NOT NULL
494
+ ) WITHOUT ROWID`);
495
+ // Boot pruning looks rows up by age: these keep it a range seek, not a
496
+ // scan of every run and claim, on every boot.
497
+ await this.db.run(
498
+ `CREATE INDEX IF NOT EXISTS idx_turn_runs_updated ON turn_runs(updated_at)`,
499
+ );
500
+ await this.db.run(
501
+ `CREATE INDEX IF NOT EXISTS idx_turn_write_claims_started ON turn_write_claims(started_at)`,
502
+ );
503
+ await this.pruneEnded();
504
+ }
505
+
506
+ /**
507
+ * Ended runs older than the retention window: their segments are gone
508
+ * already (cutover); drop the rows, marks, plans and leftover segments in
509
+ * one transaction per boot, set-based. A channel run leaves a tombstone
510
+ * (its id and final status) and loses its receipt (channels/turns.ts):
511
+ * from then on the tombstone alone refuses a replay (410).
512
+ */
513
+ private async pruneEnded(): Promise<void> {
331
514
  const cutoff = new Date(this.now() - RUN_RETENTION_MS).toISOString();
332
- const stale = await this.db.exec<{ run_id: string }>(
333
- `SELECT run_id FROM turn_runs WHERE status IN ('finished','aborted','interrupted','failed') AND updated_at < ?`,
334
- [cutoff],
515
+ await this.db.run(`DELETE FROM turn_write_claims WHERE started_at < ?`, [
516
+ cutoff,
517
+ ]);
518
+ const stale = `SELECT run_id FROM turn_runs WHERE ${STALE_RUN_FILTER}`;
519
+ if (await this.db.get(`${stale} LIMIT 1`, [cutoff]))
520
+ await this.db.transaction(async () => {
521
+ await this.db.run(
522
+ `INSERT OR IGNORE INTO channel_run_tombstones (run_id, status)
523
+ SELECT run_id, status FROM turn_runs WHERE client = 'channel' AND ${STALE_RUN_FILTER}`,
524
+ [cutoff],
525
+ );
526
+ for (const table of [
527
+ 'turn_tool_marks',
528
+ 'turn_run_plans',
529
+ 'turn_run_segments',
530
+ ])
531
+ await this.db.run(`DELETE FROM ${table} WHERE run_id IN (${stale})`, [
532
+ cutoff,
533
+ ]);
534
+ await this.db.run(`DELETE FROM turn_runs WHERE ${STALE_RUN_FILTER}`, [
535
+ cutoff,
536
+ ]);
537
+ });
538
+ // Every receipt whose run is tombstoned, this boot or an earlier one
539
+ // that stopped before getting here. Driven by the receipts (at most the
540
+ // retention window of them), one key lookup each into the tombstones,
541
+ // which are never pruned and never scanned. The table exists once the
542
+ // object has served a channel turn.
543
+ const receipts = await this.db.get<{ name: string }>(
544
+ `SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'channel_requests'`,
335
545
  );
336
- for (const row of stale) {
337
- await this.db.run(`DELETE FROM turn_tool_marks WHERE run_id = ?`, [
338
- row.run_id,
339
- ]);
340
- await this.db.run(`DELETE FROM turn_run_segments WHERE run_id = ?`, [
341
- row.run_id,
342
- ]);
343
- await this.db.run(`DELETE FROM turn_runs WHERE run_id = ?`, [row.run_id]);
344
- }
546
+ if (receipts)
547
+ await this.db.run(
548
+ `DELETE FROM channel_requests WHERE EXISTS (
549
+ SELECT 1 FROM channel_run_tombstones t WHERE t.run_id = channel_requests.run_id
550
+ )`,
551
+ );
552
+ }
553
+
554
+ /** Record the Reply Plan (JSON) a chat-surface run finished with. */
555
+ async setPlan(runId: string, plan: string): Promise<void> {
556
+ await this.setup();
557
+ await this.db.run(
558
+ 'INSERT OR REPLACE INTO turn_run_plans (run_id, plan) VALUES (?, ?)',
559
+ [runId, plan],
560
+ );
561
+ }
562
+
563
+ async getPlan(runId: string): Promise<string | null> {
564
+ await this.setup();
565
+ const row = await this.db.get<{ plan: string }>(
566
+ 'SELECT plan FROM turn_run_plans WHERE run_id = ?',
567
+ [runId],
568
+ );
569
+ return row?.plan ?? null;
570
+ }
571
+
572
+ async wasChannelRunPruned(runId: string): Promise<boolean> {
573
+ await this.setup();
574
+ return !!(await this.db.get<{ run_id: string }>(
575
+ 'SELECT run_id FROM channel_run_tombstones WHERE run_id = ?',
576
+ [runId],
577
+ ));
345
578
  }
346
579
 
347
580
  private iso(): string {
@@ -354,18 +587,18 @@ export class RunStore {
354
587
  runId: string;
355
588
  sessionId: string;
356
589
  requestId: string;
357
- client: 'portal' | 'matrix';
590
+ client: 'portal' | 'matrix' | 'channel';
358
591
  status: 'queued' | 'running';
359
592
  request: string;
360
593
  checkpointId: string | null;
361
594
  taskRunId?: string | null;
362
595
  instanceId: string;
363
- }): Promise<void> {
596
+ }): Promise<RunRecord> {
364
597
  await this.setup();
365
598
  const at = this.iso();
366
599
  await this.db.run(
367
600
  `INSERT INTO turn_runs (${RUN_COLUMNS})
368
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, 0, NULL, ?, 0, NULL, NULL, NULL, ?, ?)`,
601
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, 0, NULL, ?, ?, 1, 0, NULL, NULL, NULL, ?, ?, NULL)`,
369
602
  [
370
603
  input.runId,
371
604
  input.sessionId,
@@ -376,10 +609,34 @@ export class RunStore {
376
609
  at,
377
610
  input.request,
378
611
  input.checkpointId,
612
+ input.checkpointId,
379
613
  input.taskRunId ?? null,
380
614
  input.instanceId,
381
615
  ],
382
616
  );
617
+ return {
618
+ runId: input.runId,
619
+ sessionId: input.sessionId,
620
+ requestId: input.requestId,
621
+ client: input.client,
622
+ status: input.status,
623
+ startedAt: at,
624
+ updatedAt: at,
625
+ request: input.request,
626
+ attempts: 0,
627
+ generation: 0,
628
+ nextAttemptAt: null,
629
+ checkpointId: input.checkpointId,
630
+ startCheckpointId: input.checkpointId,
631
+ startRecorded: true,
632
+ lastSeq: 0,
633
+ partialText: null,
634
+ messageId: null,
635
+ error: null,
636
+ taskRunId: input.taskRunId ?? null,
637
+ instanceId: input.instanceId,
638
+ usage: null,
639
+ };
383
640
  }
384
641
 
385
642
  async get(runId: string): Promise<RunRecord | undefined> {
@@ -444,51 +701,55 @@ export class RunStore {
444
701
  return rows.map(toRecord);
445
702
  }
446
703
 
447
- async update(
448
- runId: string,
449
- patch: Partial<
450
- Pick<
451
- RunRecord,
452
- | 'status'
453
- | 'attempts'
454
- | 'generation'
455
- | 'nextAttemptAt'
456
- | 'checkpointId'
457
- | 'lastSeq'
458
- | 'partialText'
459
- | 'messageId'
460
- | 'error'
461
- | 'taskRunId'
462
- | 'instanceId'
463
- >
464
- >,
465
- ): Promise<void> {
704
+ /** Write `patch` to the run's row; returns the `updatedAt` it stamped. */
705
+ async update(runId: string, patch: RunPatch): Promise<string> {
466
706
  await this.setup();
707
+ const updatedAt = this.iso();
467
708
  const sets: string[] = ['updated_at = ?'];
468
- const params: Array<string | number | null> = [this.iso()];
709
+ const params: Array<string | number | null> = [updatedAt];
469
710
  const column: Record<string, string> = {
711
+ request: 'request',
470
712
  status: 'status',
471
713
  attempts: 'attempts',
472
714
  generation: 'generation',
473
715
  nextAttemptAt: 'next_attempt_at',
474
716
  checkpointId: 'checkpoint_id',
717
+ startCheckpointId: 'start_checkpoint_id',
475
718
  lastSeq: 'last_seq',
476
719
  partialText: 'partial_text',
477
720
  messageId: 'message_id',
478
721
  error: 'error',
479
722
  taskRunId: 'task_run_id',
480
723
  instanceId: 'instance_id',
724
+ usage: 'usage',
481
725
  };
482
726
  for (const [key, value] of Object.entries(patch)) {
483
727
  if (value === undefined) continue;
484
728
  sets.push(`${column[key]} = ?`);
485
729
  params.push(value);
486
730
  }
731
+ // Writing the start checkpoint records it (a row from before the column).
732
+ if (patch.startCheckpointId !== undefined) sets.push('start_recorded = 1');
487
733
  params.push(runId);
488
734
  await this.db.run(
489
735
  `UPDATE turn_runs SET ${sets.join(', ')} WHERE run_id = ?`,
490
736
  params,
491
737
  );
738
+ return updatedAt;
739
+ }
740
+
741
+ /**
742
+ * A run's end in one transaction: its row takes the terminal `patch` and
743
+ * its segments are dropped (the reply, or the partial text, is in the row
744
+ * and the transcript now). Returns the `updatedAt` it stamped.
745
+ */
746
+ async close(runId: string, patch: RunPatch): Promise<string> {
747
+ await this.setup();
748
+ return this.db.transaction(async () => {
749
+ const updatedAt = await this.update(runId, patch);
750
+ await this.deleteSegments(runId);
751
+ return updatedAt;
752
+ });
492
753
  }
493
754
 
494
755
  // ── segments ────────────────────────────────────────────────────────────
@@ -601,4 +862,85 @@ export class RunStore {
601
862
  );
602
863
  return rows.map(toMark);
603
864
  }
865
+
866
+ // ── write claims ────────────────────────────────────────────────────────
867
+
868
+ /**
869
+ * Claim a write before it runs. `claimed` when no identical write is
870
+ * outstanding. `blocked` when one is: a `pending` claim becomes `warned`
871
+ * (owned by this run, so the same turn stays blocked); a claim already
872
+ * `warned` by an earlier run is released to this run — the user was told
873
+ * and asked again.
874
+ */
875
+ async claimWrite(input: {
876
+ fingerprint: string;
877
+ toolName: string;
878
+ runId: string;
879
+ sessionId: string;
880
+ }): Promise<
881
+ | { status: 'claimed' }
882
+ | { status: 'blocked'; toolName: string; since: string }
883
+ > {
884
+ await this.setup();
885
+ const inserted = await this.db.run(
886
+ `INSERT OR IGNORE INTO turn_write_claims (fingerprint, tool_name, run_id, session_id, started_at, state)
887
+ VALUES (?, ?, ?, ?, ?, 'pending')`,
888
+ [
889
+ input.fingerprint,
890
+ input.toolName,
891
+ input.runId,
892
+ input.sessionId,
893
+ this.iso(),
894
+ ],
895
+ );
896
+ if (inserted.changes === 1) return { status: 'claimed' };
897
+ const existing = await this.getClaim(input.fingerprint);
898
+ if (!existing) return { status: 'claimed' };
899
+ if (existing.state === 'warned' && existing.runId !== input.runId) {
900
+ await this.db.run(
901
+ `UPDATE turn_write_claims SET run_id = ?, session_id = ?, started_at = ?, state = 'pending' WHERE fingerprint = ?`,
902
+ [input.runId, input.sessionId, this.iso(), input.fingerprint],
903
+ );
904
+ return { status: 'claimed' };
905
+ }
906
+ if (existing.state === 'pending')
907
+ await this.db.run(
908
+ `UPDATE turn_write_claims SET state = 'warned', run_id = ? WHERE fingerprint = ? AND state = 'pending'`,
909
+ [input.runId, input.fingerprint],
910
+ );
911
+ return {
912
+ status: 'blocked',
913
+ toolName: existing.toolName,
914
+ since: existing.startedAt,
915
+ };
916
+ }
917
+
918
+ /** The write returned an outcome (or a known failure): nothing to account for. */
919
+ async releaseWrite(fingerprint: string, runId: string): Promise<void> {
920
+ await this.setup();
921
+ await this.db.run(
922
+ `DELETE FROM turn_write_claims WHERE fingerprint = ? AND run_id = ?`,
923
+ [fingerprint, runId],
924
+ );
925
+ }
926
+
927
+ async getClaim(fingerprint: string): Promise<WriteClaimRecord | undefined> {
928
+ await this.setup();
929
+ const row = await this.db.get<ClaimRow>(
930
+ `SELECT fingerprint, tool_name, run_id, session_id, started_at, state
931
+ FROM turn_write_claims WHERE fingerprint = ?`,
932
+ [fingerprint],
933
+ );
934
+ return row ? toClaim(row) : undefined;
935
+ }
936
+
937
+ /** Outstanding claims, oldest first (operator inspection). */
938
+ async listClaims(): Promise<WriteClaimRecord[]> {
939
+ await this.setup();
940
+ const rows = await this.db.exec<ClaimRow>(
941
+ `SELECT fingerprint, tool_name, run_id, session_id, started_at, state
942
+ FROM turn_write_claims ORDER BY started_at ASC`,
943
+ );
944
+ return rows.map(toClaim);
945
+ }
604
946
  }
@@ -3,21 +3,33 @@
3
3
  * gateway) to the core's `SecretsAdapter` shape — the same mapping the Node
4
4
  * runtime's `ambient-factory` performs over its `SecretsService`:
5
5
  * `getIndex` lists names (plugins never see event ids), `getValues` decrypts
6
- * only the requested names. Degrades exactly like Node when the oracle has no
6
+ * only the requested names, and `getAll` decrypts every listed name from one
7
+ * read of the room state. Degrades exactly like Node when the oracle has no
7
8
  * P-256 key seated: the index still lists, values come back empty.
9
+ *
10
+ * The runtime's own LLM credentials (BYO API keys, the ChatGPT OAuth tokens
11
+ * with their refresh token) share the room but are not the user's tool
12
+ * secrets: plugins neither list nor read them. The sandbox forwards every
13
+ * secret it sees to code the model writes, where a prompt injection could
14
+ * send it anywhere. The BYO service reads them from the service directly.
8
15
  */
9
16
  import type { SecretsAdapter } from '../core/runtime-context';
17
+ import { isRuntimeOnlySecret } from '../llm/byo-catalog';
10
18
  import type { SecretIndex } from '../plugin-api/types';
11
19
  import type { WorkersSecretsService } from '../secrets/secrets-service';
12
20
 
13
21
  export function createSecretsAdapter(
14
- service: WorkersSecretsService,
22
+ service: Pick<
23
+ WorkersSecretsService,
24
+ 'getIndex' | 'getValues' | 'getValuesFor'
25
+ >,
15
26
  ): SecretsAdapter {
16
27
  return {
17
28
  async getIndex(roomId: string): Promise<SecretIndex> {
18
29
  const entries = await service.getIndex(roomId);
19
30
  const index: Record<string, { key: string }> = {};
20
31
  for (const entry of entries) {
32
+ if (isRuntimeOnlySecret(entry.name)) continue;
21
33
  index[entry.name] = { key: entry.name };
22
34
  }
23
35
  return index satisfies SecretIndex;
@@ -26,7 +38,14 @@ export function createSecretsAdapter(
26
38
  roomId: string,
27
39
  keys: string[],
28
40
  ): Promise<Record<string, string>> {
29
- return service.getValues(roomId, keys);
41
+ const allowed = keys.filter((key) => !isRuntimeOnlySecret(key));
42
+ return allowed.length > 0 ? service.getValues(roomId, allowed) : {};
43
+ },
44
+ async getAll(roomId: string): Promise<Record<string, string>> {
45
+ const entries = (await service.getIndex(roomId)).filter(
46
+ (entry) => !isRuntimeOnlySecret(entry.name),
47
+ );
48
+ return entries.length > 0 ? service.getValuesFor(roomId, entries) : {};
30
49
  },
31
50
  };
32
51
  }