@ixo/oracle-runtime-workers 0.2.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 (215) hide show
  1. package/README.md +110 -0
  2. package/package.json +77 -0
  3. package/src/attachments/classify.ts +114 -0
  4. package/src/attachments/content-blocks.ts +51 -0
  5. package/src/attachments/download.ts +235 -0
  6. package/src/attachments/extract.ts +241 -0
  7. package/src/attachments/index.ts +84 -0
  8. package/src/attachments/magic.ts +133 -0
  9. package/src/attachments/pipeline.ts +409 -0
  10. package/src/attachments/retention.ts +205 -0
  11. package/src/attachments/route.ts +41 -0
  12. package/src/attachments/sandbox-archive.ts +90 -0
  13. package/src/attachments/sanitize.ts +35 -0
  14. package/src/attachments/types.ts +86 -0
  15. package/src/attachments/view.ts +107 -0
  16. package/src/core/env.ts +200 -0
  17. package/src/core/index.ts +513 -0
  18. package/src/core/llm.ts +842 -0
  19. package/src/core/main-agent-types.ts +123 -0
  20. package/src/core/main-agent.ts +399 -0
  21. package/src/core/manifest.ts +365 -0
  22. package/src/core/meta-tools.ts +232 -0
  23. package/src/core/middlewares/byo-history-sanitizer.ts +165 -0
  24. package/src/core/middlewares/capability-gate.ts +76 -0
  25. package/src/core/middlewares/index.ts +18 -0
  26. package/src/core/middlewares/page-context.ts +98 -0
  27. package/src/core/middlewares/safety-guardrail.ts +117 -0
  28. package/src/core/middlewares/summarization.ts +99 -0
  29. package/src/core/middlewares/tool-repetition-guard.ts +125 -0
  30. package/src/core/middlewares/tool-validation.ts +83 -0
  31. package/src/core/middlewares/work-status.ts +57 -0
  32. package/src/core/openrouter-pricing.ts +124 -0
  33. package/src/core/plugin-loader.ts +192 -0
  34. package/src/core/plugins/index.ts +6 -0
  35. package/src/core/plugins/skills/index.ts +8 -0
  36. package/src/core/plugins/skills/skills-tools.ts +356 -0
  37. package/src/core/plugins/skills/skills.plugin.ts +113 -0
  38. package/src/core/plugins/weather/index.ts +8 -0
  39. package/src/core/plugins/weather/weather-client.ts +153 -0
  40. package/src/core/plugins/weather/weather-middleware.ts +27 -0
  41. package/src/core/plugins/weather/weather-sub-agent.ts +65 -0
  42. package/src/core/plugins/weather/weather-tools.ts +103 -0
  43. package/src/core/plugins/weather/weather.plugin.ts +227 -0
  44. package/src/core/prompt-composer.ts +469 -0
  45. package/src/core/registries.ts +642 -0
  46. package/src/core/runtime-context.ts +619 -0
  47. package/src/core/state.ts +108 -0
  48. package/src/core/sub-agent-fallback.ts +187 -0
  49. package/src/core/subagent-as-tool.ts +322 -0
  50. package/src/core/template.ts +245 -0
  51. package/src/core/utils.ts +246 -0
  52. package/src/core/wrap-plugin-tool.ts +100 -0
  53. package/src/do/ambient.ts +266 -0
  54. package/src/do/contracts.ts +562 -0
  55. package/src/do/debug-timers.ts +120 -0
  56. package/src/do/fresh-stub.ts +32 -0
  57. package/src/do/gateway-retry.ts +73 -0
  58. package/src/do/secrets-adapter.ts +32 -0
  59. package/src/do/size-alerts.ts +76 -0
  60. package/src/do/sse-stream.ts +472 -0
  61. package/src/do/transcript.ts +192 -0
  62. package/src/do/turn-body.ts +84 -0
  63. package/src/do/turn-metadata.ts +146 -0
  64. package/src/do/ucan-service.ts +339 -0
  65. package/src/do/user-oracle-do.ts +3035 -0
  66. package/src/gateway-worker.ts +82 -0
  67. package/src/index.ts +237 -0
  68. package/src/llm/byo-adapter.ts +59 -0
  69. package/src/llm/byo-catalog.ts +520 -0
  70. package/src/llm/byo-client.ts +280 -0
  71. package/src/llm/byo-routes.ts +255 -0
  72. package/src/llm/byo-service.ts +836 -0
  73. package/src/llm/chatgpt-oauth.ts +430 -0
  74. package/src/llm/provider-error.ts +283 -0
  75. package/src/matrix/gateway-do.ts +918 -0
  76. package/src/matrix/ingest.ts +209 -0
  77. package/src/matrix/replay-format.ts +34 -0
  78. package/src/matrix/reply-chain.ts +109 -0
  79. package/src/matrix/room-state-codec.ts +115 -0
  80. package/src/matrix/user-homeserver.ts +106 -0
  81. package/src/matrix/work-status.ts +294 -0
  82. package/src/memory/session-history-indexer.ts +328 -0
  83. package/src/owner-store/ixo-vfs-store.ts +570 -0
  84. package/src/owner-store/matrix-media-store.ts +136 -0
  85. package/src/owner-store/migrating-store.ts +185 -0
  86. package/src/owner-store/owner-copy-errors.ts +175 -0
  87. package/src/owner-store/retry.ts +45 -0
  88. package/src/owner-store/tus-upload.ts +333 -0
  89. package/src/owner-store/types.ts +0 -0
  90. package/src/plugin-api/define-plugin.ts +45 -0
  91. package/src/plugin-api/index.ts +56 -0
  92. package/src/plugin-api/oracle-plugin.ts +145 -0
  93. package/src/plugin-api/tool-helper.ts +82 -0
  94. package/src/plugin-api/types.ts +816 -0
  95. package/src/plugin-api/ucan-errors.ts +16 -0
  96. package/src/plugins/agui/agui-agent.ts +115 -0
  97. package/src/plugins/agui/agui.plugin.ts +148 -0
  98. package/src/plugins/agui/index.ts +2 -0
  99. package/src/plugins/attachments/attachments.plugin.ts +54 -0
  100. package/src/plugins/attachments/index.ts +6 -0
  101. package/src/plugins/attachments/view-attachment-tool.ts +91 -0
  102. package/src/plugins/base64.ts +26 -0
  103. package/src/plugins/composio/composio-tools.ts +365 -0
  104. package/src/plugins/composio/composio-ucan.ts +43 -0
  105. package/src/plugins/composio/composio.plugin.ts +214 -0
  106. package/src/plugins/composio/index.ts +15 -0
  107. package/src/plugins/domain-indexer/domain-indexer-agent.ts +57 -0
  108. package/src/plugins/domain-indexer/domain-indexer-tools.ts +253 -0
  109. package/src/plugins/domain-indexer/domain-indexer.plugin.ts +100 -0
  110. package/src/plugins/domain-indexer/index.ts +3 -0
  111. package/src/plugins/editor/blocknote-bridge.ts +120 -0
  112. package/src/plugins/editor/content-session.ts +331 -0
  113. package/src/plugins/editor/content-tools.ts +686 -0
  114. package/src/plugins/editor/document-model.ts +0 -0
  115. package/src/plugins/editor/editor-access-denied-tool.ts +58 -0
  116. package/src/plugins/editor/editor-agent.ts +34 -0
  117. package/src/plugins/editor/editor-config.ts +64 -0
  118. package/src/plugins/editor/editor-mx.ts +128 -0
  119. package/src/plugins/editor/editor.plugin.ts +143 -0
  120. package/src/plugins/editor/failures.ts +154 -0
  121. package/src/plugins/editor/index.ts +31 -0
  122. package/src/plugins/editor/jsdom-shim.ts +55 -0
  123. package/src/plugins/editor/prompts.ts +59 -0
  124. package/src/plugins/editor/prop-policy.ts +147 -0
  125. package/src/plugins/editor/provider.ts +386 -0
  126. package/src/plugins/editor/room-membership.ts +95 -0
  127. package/src/plugins/editor/standalone-editor-tool.ts +200 -0
  128. package/src/plugins/firecrawl/firecrawl-agent.ts +93 -0
  129. package/src/plugins/firecrawl/firecrawl-tools.ts +220 -0
  130. package/src/plugins/firecrawl/firecrawl.plugin.ts +105 -0
  131. package/src/plugins/firecrawl/index.ts +15 -0
  132. package/src/plugins/flows/action-metadata.ts +3505 -0
  133. package/src/plugins/flows/actions.ts +190 -0
  134. package/src/plugins/flows/edit.ts +314 -0
  135. package/src/plugins/flows/errors.ts +38 -0
  136. package/src/plugins/flows/explain.ts +43 -0
  137. package/src/plugins/flows/flow-doc.ts +111 -0
  138. package/src/plugins/flows/flows.plugin.ts +129 -0
  139. package/src/plugins/flows/forms.ts +330 -0
  140. package/src/plugins/flows/index.ts +2 -0
  141. package/src/plugins/flows/linkage.ts +147 -0
  142. package/src/plugins/flows/port-types.ts +53 -0
  143. package/src/plugins/flows/prompts.ts +65 -0
  144. package/src/plugins/flows/read.ts +392 -0
  145. package/src/plugins/flows/references.ts +66 -0
  146. package/src/plugins/flows/test-support.ts +165 -0
  147. package/src/plugins/flows/tools/authoring.ts +516 -0
  148. package/src/plugins/flows/tools/discovery.ts +119 -0
  149. package/src/plugins/flows/tools/forms.ts +142 -0
  150. package/src/plugins/flows/tools/inspect.ts +165 -0
  151. package/src/plugins/flows/tools/linkage.ts +117 -0
  152. package/src/plugins/flows/tools/settings.ts +184 -0
  153. package/src/plugins/flows/translator.ts +328 -0
  154. package/src/plugins/flows/types.ts +210 -0
  155. package/src/plugins/flows/ydoc-helpers.ts +543 -0
  156. package/src/plugins/index.ts +70 -0
  157. package/src/plugins/mcp-call-timeout.ts +38 -0
  158. package/src/plugins/mcp-tool-adapter.ts +87 -0
  159. package/src/plugins/memory/index.ts +17 -0
  160. package/src/plugins/memory/memory-tools.ts +463 -0
  161. package/src/plugins/memory/memory-ucan.ts +51 -0
  162. package/src/plugins/memory/memory.plugin.ts +122 -0
  163. package/src/plugins/memory/types.ts +147 -0
  164. package/src/plugins/portal/action-log.ts +34 -0
  165. package/src/plugins/portal/index.ts +2 -0
  166. package/src/plugins/portal/portal.plugin.ts +203 -0
  167. package/src/plugins/sandbox/index.ts +34 -0
  168. package/src/plugins/sandbox/sandbox-bridge.ts +295 -0
  169. package/src/plugins/sandbox/sandbox-mcp.ts +127 -0
  170. package/src/plugins/sandbox/sandbox-write-blob.ts +103 -0
  171. package/src/plugins/sandbox/sandbox.plugin.ts +599 -0
  172. package/src/plugins/tasks/index.ts +17 -0
  173. package/src/plugins/tasks/manifest.ts +81 -0
  174. package/src/plugins/tasks/middleware.ts +219 -0
  175. package/src/plugins/tasks/tasks-tools.ts +414 -0
  176. package/src/plugins/tasks/tasks.plugin.ts +74 -0
  177. package/src/plugins/ucan-failure.ts +133 -0
  178. package/src/plugins/user-preferences/index.ts +22 -0
  179. package/src/plugins/user-preferences/schema.ts +31 -0
  180. package/src/plugins/user-preferences/user-preferences-store.ts +172 -0
  181. package/src/plugins/user-preferences/user-preferences-tool.ts +131 -0
  182. package/src/plugins/user-preferences/user-preferences.plugin.ts +137 -0
  183. package/src/plugins/vfs/index.ts +40 -0
  184. package/src/plugins/vfs/vfs-auth.ts +56 -0
  185. package/src/plugins/vfs/vfs-client.ts +655 -0
  186. package/src/plugins/vfs/vfs-content.ts +181 -0
  187. package/src/plugins/vfs/vfs-errors.ts +163 -0
  188. package/src/plugins/vfs/vfs-sandbox-tools.ts +272 -0
  189. package/src/plugins/vfs/vfs-tools.ts +635 -0
  190. package/src/plugins/vfs/vfs.plugin.ts +201 -0
  191. package/src/realtime/frontend-call-registry.ts +163 -0
  192. package/src/realtime/realtime-endpoint.ts +619 -0
  193. package/src/realtime/session-socket-hub.ts +249 -0
  194. package/src/realtime/socket-io-codec.ts +187 -0
  195. package/src/secrets/jwe.ts +74 -0
  196. package/src/secrets/pin-cipher.ts +43 -0
  197. package/src/secrets/secrets-service.ts +319 -0
  198. package/src/shell/app.ts +564 -0
  199. package/src/shell/auth.ts +290 -0
  200. package/src/sqlite/blob-codec.ts +100 -0
  201. package/src/sqlite/blob-compactor.ts +146 -0
  202. package/src/sqlite/cache-config.ts +45 -0
  203. package/src/sqlite/database.ts +643 -0
  204. package/src/sqlite/do-vfs.ts +1485 -0
  205. package/src/sqlite/index.ts +61 -0
  206. package/src/sqlite/serialization.ts +370 -0
  207. package/src/sqlite/sessions-store.ts +311 -0
  208. package/src/sqlite/sqlite-saver.ts +919 -0
  209. package/src/sqlite/vacuum-policy.ts +77 -0
  210. package/src/sqlite/wa-sqlite-loader.ts +123 -0
  211. package/src/sqlite/wasm.d.ts +10 -0
  212. package/src/tasks/schedule.ts +174 -0
  213. package/src/tasks/scheduler.ts +753 -0
  214. package/src/tasks/spec.ts +125 -0
  215. package/src/tasks/store.ts +351 -0
@@ -0,0 +1,753 @@
1
+ /**
2
+ * Per-user task scheduler running on the user object's Durable Object alarm —
3
+ * the Workers replacement for the Node runtime's BullMQ+Redis tasks stack.
4
+ *
5
+ * Contract with `UserOracleDO`:
6
+ * - `createTaskScheduler(host)` is called once per object boot, AFTER the
7
+ * user's database is open.
8
+ * - The returned scheduler is registered as an alarm client: the object
9
+ * calls `nextWakeAt()` when re-arming its alarm and `onAlarm(now)` on
10
+ * every alarm tick (the object multiplexes flush/idle/task deadlines onto
11
+ * the single DO alarm).
12
+ * - `surface` is exposed to plugins as `ctx.tasks` (see
13
+ * `OracleTasksSurface` in the plugin API).
14
+ *
15
+ * Task records live in the USER'S OWN SQLite database (`host.db`) so they are
16
+ * part of the owner file the user controls — pausing, exporting or deleting
17
+ * the file carries the tasks with it. A Durable Object is single-threaded, so
18
+ * there are no run locks: `onAlarm` executes due runs serially.
19
+ *
20
+ * A run re-enters the SAME agent through `host.runTurn` — the exact entry the
21
+ * HTTP shell and Matrix gateway use — on a synthetic per-task session
22
+ * (`task:<taskId>`, `client: 'matrix'` so the session row is auto-created),
23
+ * and the result is delivered to the user's oracle room via the Matrix
24
+ * gateway. `before-action` tasks do not execute on fire: the scheduler posts
25
+ * an approval request to the room and waits; `surface.resolveApproval`
26
+ * (driven by the `resolve_task_approval` tool) triggers or drops the actual
27
+ * run.
28
+ */
29
+ import type {
30
+ Logger,
31
+ OracleTaskInput,
32
+ OracleTaskRecord,
33
+ OracleTaskSchedule,
34
+ OracleTasksSurface,
35
+ } from '../plugin-api/types';
36
+ import type { DoSqliteDatabase } from '../sqlite/database';
37
+ import type { TurnRequest, TurnResult } from '../do/contracts';
38
+ import {
39
+ backoffDelayMs,
40
+ computeNextRunAtMs,
41
+ DEFAULT_MAX_TASKS_PER_USER,
42
+ DEFAULT_MIN_CRON_INTERVAL_SEC,
43
+ MAX_CONSECUTIVE_FAILURES,
44
+ cronIntervalMs,
45
+ previewRuns,
46
+ summarizeSchedule,
47
+ validateSchedule,
48
+ } from './schedule';
49
+ import { newTaskId } from './spec';
50
+ import { TasksStore, type TaskRecord } from './store';
51
+
52
+ /** Session-id prefix for the synthetic sessions task runs execute on. */
53
+ export const TASK_SESSION_PREFIX = 'task:';
54
+
55
+ /**
56
+ * The slice of the Matrix gateway the scheduler needs. Structural, so the
57
+ * host passes its `DurableObjectStub<MatrixGatewayObject>` unchanged while
58
+ * tests inject a recording fake.
59
+ */
60
+ export interface TaskGateway {
61
+ /** Create a private bot-owned room and invite the user (dedicated task rooms). */
62
+ createDedicatedRoom(opts: {
63
+ name: string;
64
+ topic?: string;
65
+ invite: string[];
66
+ userDid: string;
67
+ }): Promise<{ roomId: string }>;
68
+ /** Send a text message to a room; resolves to the new event id. */
69
+ sendText(
70
+ roomId: string,
71
+ body: string,
72
+ opts?: { threadId?: string; formattedBody?: string },
73
+ ): Promise<string>;
74
+ /** Resolve the canonical user↔oracle room for a user DID. */
75
+ resolveUserRoom(
76
+ userDid: string,
77
+ ): Promise<{ roomId: string; alias: string } | null>;
78
+ }
79
+
80
+ export interface TaskSchedulerHost {
81
+ db: DoSqliteDatabase;
82
+ userDid: string;
83
+ oracleDid: string;
84
+ oracleName: string;
85
+ /** The user's Matrix id when known (threaded into run identities). */
86
+ matrixUserId?: string;
87
+ gateway: TaskGateway;
88
+ /** Run one agent turn in this user's object (same entry the HTTP shell uses). */
89
+ runTurn: (req: TurnRequest) => Promise<TurnResult>;
90
+ /** Ask the object to re-arm its alarm no later than `at` (ms epoch). */
91
+ requestAlarm: (at: number) => void;
92
+ log: Logger;
93
+ /** Live-task cap (`TASKS_MAX_PER_USER`). Default 50. */
94
+ maxTasksPerUser?: number;
95
+ /** Minimum seconds between recurring runs (`TASKS_MIN_CRON_INTERVAL_SEC`). Default 300. */
96
+ minCronIntervalSec?: number;
97
+ }
98
+
99
+ export interface TaskScheduler {
100
+ /** Plugin-facing surface, exposed as `ctx.tasks`. */
101
+ surface: OracleTasksSurface;
102
+ /** Earliest pending deadline (ms epoch), or null when nothing is scheduled. */
103
+ nextWakeAt(): Promise<number | null>;
104
+ /** Run everything that is due. Must be safe to call spuriously. */
105
+ onAlarm(now: number): Promise<void>;
106
+ }
107
+
108
+ /** How much of a run's output is kept as `lastResult.summary`. */
109
+ const RESULT_SUMMARY_MAX = 500;
110
+ /** How much of an error message is kept in bookkeeping / notices. */
111
+ const ERROR_MAX = 1024;
112
+
113
+ /** The instruction message a scheduled run enters the agent with. */
114
+ function buildRunMessage(task: TaskRecord, approvalNote?: string): string {
115
+ const lines = [
116
+ `[Scheduled task run — "${task.title}" (${task.id})]`,
117
+ 'You are executing a scheduled background task for the user. No user is present in this turn: do the work now and reply with the final result only — your reply is delivered to their chat room as the task result.',
118
+ ];
119
+ if (approvalNote?.trim()) {
120
+ lines.push(
121
+ '',
122
+ `The user approved this run with a note: ${approvalNote.trim()}`,
123
+ );
124
+ }
125
+ lines.push('', 'Task instructions:', task.intent);
126
+ return lines.join('\n');
127
+ }
128
+
129
+ /** Posted to the room when a `before-action` task fires. */
130
+ const DAY_MS = 24 * 3600 * 1000;
131
+
132
+ /**
133
+ * The Node runtime's heuristic for giving a task its own room: an explicit
134
+ * choice wins; otherwise a sub-daily cadence, a long intent, or an ongoing
135
+ * monitor/watch/track intent gets one.
136
+ */
137
+ export function shouldCreateDedicatedRoom(args: {
138
+ schedule: OracleTaskSchedule;
139
+ intent: string;
140
+ explicit: 'auto' | 'yes' | 'no';
141
+ }): boolean {
142
+ if (args.explicit !== 'auto') return args.explicit === 'yes';
143
+ const { schedule } = args;
144
+ if (schedule.kind === 'cron') {
145
+ const interval = cronIntervalMs(schedule.cron, schedule.timezone);
146
+ if (interval !== null && interval < DAY_MS) return true;
147
+ } else if (schedule.kind === 'interval') {
148
+ if (schedule.everySeconds * 1000 < DAY_MS) return true;
149
+ }
150
+ if (args.intent.length > 800) return true;
151
+ return /\b(monitor|ongoing|watch|track)\b/i.test(args.intent);
152
+ }
153
+
154
+ /** Posted into a freshly created `[Task]` room so the user knows what lives there. */
155
+ function taskSummaryMessage(task: TaskRecord): string {
156
+ return [
157
+ `🗓️ **${task.title}** (task \`${task.id}\`)`,
158
+ '',
159
+ `Schedule: ${summarizeSchedule(task.schedule)}`,
160
+ task.approval === 'before-action'
161
+ ? 'Each run asks for your approval here before it acts.'
162
+ : 'Each run posts its result here.',
163
+ '',
164
+ 'What it does:',
165
+ task.intent,
166
+ ].join('\n');
167
+ }
168
+
169
+ function approvalRequestMessage(task: TaskRecord): string {
170
+ return [
171
+ `⏳ **${task.title}** is ready to run and needs your approval before it acts (task \`${task.id}\`).`,
172
+ '',
173
+ 'What it will do:',
174
+ task.intent,
175
+ '',
176
+ "Reply here to approve or decline — I'll run it only once you approve.",
177
+ ].join('\n');
178
+ }
179
+
180
+ function errorMessage(err: unknown): string {
181
+ return (err instanceof Error ? err.message : String(err)).slice(0, ERROR_MAX);
182
+ }
183
+
184
+ class AlarmTaskScheduler implements TaskScheduler {
185
+ readonly surface: OracleTasksSurface;
186
+ private readonly maxTasksPerUser: number;
187
+ private readonly minCronIntervalSec: number;
188
+
189
+ constructor(
190
+ private readonly host: TaskSchedulerHost,
191
+ private readonly store: TasksStore,
192
+ ) {
193
+ this.maxTasksPerUser = host.maxTasksPerUser ?? DEFAULT_MAX_TASKS_PER_USER;
194
+ this.minCronIntervalSec =
195
+ host.minCronIntervalSec ?? DEFAULT_MIN_CRON_INTERVAL_SEC;
196
+ this.surface = {
197
+ preview: (input) => this.preview(input),
198
+ create: (input) => this.create(input),
199
+ list: () => this.store.list(),
200
+ get: (id) => this.store.get(id),
201
+ update: (id, patch) => this.update(id, patch),
202
+ pause: (id) => this.pause(id),
203
+ resume: (id) => this.resume(id),
204
+ cancel: (id) => this.cancel(id),
205
+ resolveApproval: (taskId, decision, note) =>
206
+ this.resolveApproval(taskId, decision, note),
207
+ };
208
+ }
209
+
210
+ // ── alarm client ─────────────────────────────────────────────────────────
211
+
212
+ async nextWakeAt(): Promise<number | null> {
213
+ return this.store.minNextRunAt();
214
+ }
215
+
216
+ async onAlarm(now: number): Promise<void> {
217
+ const due = await this.store.due(now);
218
+ for (const stale of due) {
219
+ // Re-load: a tool call awaited between iterations may have paused,
220
+ // cancelled or rescheduled the task since the due scan.
221
+ const task = await this.store.get(stale.id);
222
+ if (!task || task.status !== 'active' || task.nextRunAt === undefined) {
223
+ continue;
224
+ }
225
+ if (Date.parse(task.nextRunAt) > now) continue;
226
+ try {
227
+ if (task.approval === 'before-action') {
228
+ await this.requestApproval(task, now);
229
+ } else {
230
+ await this.executeRun(task, now);
231
+ }
232
+ } catch (err) {
233
+ // `executeRun`/`requestApproval` do their own failure bookkeeping —
234
+ // reaching here means the BOOKKEEPING failed (storage trouble). Log
235
+ // and move on; the 1s re-arm floor below prevents a hot loop.
236
+ this.host.log.error(
237
+ `[tasks] run bookkeeping crashed for ${task.id}: ${errorMessage(err)}`,
238
+ );
239
+ }
240
+ }
241
+ const next = await this.store.minNextRunAt();
242
+ if (next !== null) this.host.requestAlarm(Math.max(next, now + 1000));
243
+ }
244
+
245
+ // ── surface: preview / create ────────────────────────────────────────────
246
+
247
+ private async problemsFor(input: OracleTaskInput): Promise<string[]> {
248
+ const problems: string[] = [];
249
+ if (input.title.trim().length === 0)
250
+ problems.push('Title must not be empty.');
251
+ if (input.title.trim().length > 120) {
252
+ problems.push('Title must be at most 120 characters.');
253
+ }
254
+ if (input.intent.trim().length === 0) {
255
+ problems.push('Intent must not be empty.');
256
+ }
257
+ problems.push(...validateSchedule(input.schedule, this.minCronIntervalSec));
258
+ const live = await this.store.countLive();
259
+ if (live >= this.maxTasksPerUser) {
260
+ problems.push(
261
+ `Task limit reached (${this.maxTasksPerUser}). Cancel an existing task first.`,
262
+ );
263
+ }
264
+ return problems;
265
+ }
266
+
267
+ private async preview(
268
+ input: OracleTaskInput,
269
+ ): Promise<{ ok: boolean; nextRuns: string[]; problems: string[] }> {
270
+ const problems = await this.problemsFor(input);
271
+ const nextRuns = previewRuns(input.schedule, 3, Date.now());
272
+ if (problems.length === 0 && nextRuns.length === 0) {
273
+ problems.push('Schedule has no future run time.');
274
+ }
275
+ return { ok: problems.length === 0, nextRuns, problems };
276
+ }
277
+
278
+ private async create(input: OracleTaskInput): Promise<OracleTaskRecord> {
279
+ const problems = await this.problemsFor(input);
280
+ if (problems.length > 0) throw new Error(problems.join(' '));
281
+ const nowMs = Date.now();
282
+ const nextMs = computeNextRunAtMs(input.schedule, nowMs);
283
+ if (nextMs === null) throw new Error('Schedule has no future run time.');
284
+ const title = input.title.trim();
285
+ const nowIso = new Date(nowMs).toISOString();
286
+ const record: TaskRecord = {
287
+ id: newTaskId(title),
288
+ title,
289
+ intent: input.intent.trim(),
290
+ schedule: input.schedule,
291
+ status: 'active',
292
+ approval: input.approval ?? 'never',
293
+ createdAt: nowIso,
294
+ updatedAt: nowIso,
295
+ nextRunAt: new Date(nextMs).toISOString(),
296
+ consecutiveFailures: 0,
297
+ };
298
+ // A `before-action` task ALWAYS needs its own room: that is where each
299
+ // run posts its approval request and the user answers. Without one the
300
+ // approval conversation has nowhere to happen, so a failure is fatal.
301
+ if (record.approval === 'before-action') {
302
+ const roomId = await this.createDedicatedRoom(record);
303
+ if (!roomId) {
304
+ throw new Error(
305
+ 'Could not create the task room needed for approval — try again.',
306
+ );
307
+ }
308
+ record.deliveryRoomId = roomId;
309
+ } else if (
310
+ shouldCreateDedicatedRoom({
311
+ schedule: record.schedule,
312
+ intent: record.intent,
313
+ explicit: input.dedicatedRoom ?? 'auto',
314
+ })
315
+ ) {
316
+ const roomId = await this.createDedicatedRoom(record);
317
+ if (roomId) record.deliveryRoomId = roomId;
318
+ }
319
+ await this.store.insert(record);
320
+ this.host.requestAlarm(nextMs);
321
+ this.host.log.log(
322
+ `[tasks] created ${record.id} — ${summarizeSchedule(record.schedule)}; next run ${record.nextRunAt}`,
323
+ );
324
+ return record;
325
+ }
326
+
327
+ // ── surface: update / lifecycle ──────────────────────────────────────────
328
+
329
+ private async load(id: string): Promise<TaskRecord> {
330
+ const task = await this.store.get(id);
331
+ if (!task) throw new Error('Task not found.');
332
+ return task;
333
+ }
334
+
335
+ private async update(
336
+ id: string,
337
+ patch: Partial<OracleTaskInput>,
338
+ ): Promise<OracleTaskRecord> {
339
+ const task = await this.load(id);
340
+ if (task.status === 'completed' || task.status === 'cancelled') {
341
+ throw new Error(
342
+ `A ${task.status} task cannot be updated — create a new one.`,
343
+ );
344
+ }
345
+ if (patch.title !== undefined) {
346
+ const title = patch.title.trim();
347
+ if (title.length === 0 || title.length > 120) {
348
+ throw new Error('Title must be 1–120 characters.');
349
+ }
350
+ task.title = title;
351
+ }
352
+ if (patch.intent !== undefined) {
353
+ const intent = patch.intent.trim();
354
+ if (intent.length === 0) throw new Error('Intent must not be empty.');
355
+ task.intent = intent;
356
+ }
357
+ if (patch.approval !== undefined) {
358
+ task.approval = patch.approval;
359
+ // A task no longer gated on approval cannot keep a pending request.
360
+ if (patch.approval === 'never') delete task.pendingApprovalAt;
361
+ }
362
+ let nextMs: number | null = null;
363
+ if (patch.schedule !== undefined) {
364
+ const problems = validateSchedule(
365
+ patch.schedule,
366
+ this.minCronIntervalSec,
367
+ );
368
+ if (problems.length > 0) throw new Error(problems.join(' '));
369
+ task.schedule = patch.schedule;
370
+ if (task.status === 'active') {
371
+ nextMs = computeNextRunAtMs(patch.schedule, Date.now());
372
+ if (nextMs === null) {
373
+ throw new Error(
374
+ 'Schedule has no future run time — adjust it and try again.',
375
+ );
376
+ }
377
+ task.nextRunAt = new Date(nextMs).toISOString();
378
+ } else {
379
+ // Paused/failed: resume recomputes the next run from the new schedule.
380
+ delete task.nextRunAt;
381
+ }
382
+ }
383
+ task.updatedAt = new Date().toISOString();
384
+ await this.store.save(task);
385
+ if (nextMs !== null) this.host.requestAlarm(nextMs);
386
+ return task;
387
+ }
388
+
389
+ private async pause(id: string): Promise<OracleTaskRecord> {
390
+ const task = await this.load(id);
391
+ if (task.status === 'paused') return task;
392
+ if (task.status !== 'active') {
393
+ throw new Error(`A ${task.status} task cannot be paused.`);
394
+ }
395
+ task.status = 'paused';
396
+ delete task.nextRunAt;
397
+ // Pausing drops an unanswered approval request with it.
398
+ delete task.pendingApprovalAt;
399
+ task.updatedAt = new Date().toISOString();
400
+ await this.store.save(task);
401
+ return task;
402
+ }
403
+
404
+ private async resume(id: string): Promise<OracleTaskRecord> {
405
+ const task = await this.load(id);
406
+ if (task.status === 'active') return task;
407
+ if (task.status !== 'paused' && task.status !== 'failed') {
408
+ throw new Error(
409
+ `A ${task.status} task cannot be resumed — create a new one.`,
410
+ );
411
+ }
412
+ const nextMs = computeNextRunAtMs(task.schedule, Date.now());
413
+ if (nextMs === null) {
414
+ throw new Error(
415
+ 'Schedule has no future run time — update the schedule first.',
416
+ );
417
+ }
418
+ task.status = 'active';
419
+ task.nextRunAt = new Date(nextMs).toISOString();
420
+ task.consecutiveFailures = 0;
421
+ // Resuming skips any stale unanswered approval request.
422
+ delete task.pendingApprovalAt;
423
+ task.updatedAt = new Date().toISOString();
424
+ await this.store.save(task);
425
+ this.host.requestAlarm(nextMs);
426
+ return task;
427
+ }
428
+
429
+ private async cancel(id: string): Promise<OracleTaskRecord> {
430
+ const task = await this.load(id);
431
+ if (task.status === 'cancelled') return task;
432
+ if (task.status === 'completed') {
433
+ throw new Error('A completed task cannot be cancelled.');
434
+ }
435
+ task.status = 'cancelled';
436
+ delete task.nextRunAt;
437
+ delete task.pendingApprovalAt;
438
+ task.updatedAt = new Date().toISOString();
439
+ await this.store.save(task);
440
+ this.host.log.log(`[tasks] cancelled ${task.id}`);
441
+ return task;
442
+ }
443
+
444
+ // ── approval flow ────────────────────────────────────────────────────────
445
+
446
+ /**
447
+ * A `before-action` fire does NOT execute: post the approval request and
448
+ * wait. The cadence continues regardless — a later fire of a recurring task
449
+ * supersedes an unanswered request (the marker is simply overwritten). A
450
+ * one-shot clears its `nextRunAt` and sits waiting on the decision.
451
+ */
452
+ private async requestApproval(
453
+ task: TaskRecord,
454
+ nowMs: number,
455
+ ): Promise<void> {
456
+ const startedAt = new Date(nowMs).toISOString();
457
+ try {
458
+ const roomId = await this.resolveDeliveryRoom(task);
459
+ if (!roomId) {
460
+ throw new Error(
461
+ 'Could not resolve a delivery room for the approval request',
462
+ );
463
+ }
464
+ await this.host.gateway.sendText(roomId, approvalRequestMessage(task));
465
+ task.pendingApprovalAt = startedAt;
466
+ if (task.schedule.kind === 'once') {
467
+ delete task.nextRunAt;
468
+ } else {
469
+ const nextMs = computeNextRunAtMs(
470
+ task.schedule,
471
+ Math.max(nowMs, Date.now()),
472
+ );
473
+ if (nextMs !== null) task.nextRunAt = new Date(nextMs).toISOString();
474
+ else delete task.nextRunAt;
475
+ }
476
+ task.updatedAt = new Date().toISOString();
477
+ await this.host.db.transaction(async () => {
478
+ await this.store.save(task);
479
+ await this.store.recordRun({
480
+ runId: crypto.randomUUID(),
481
+ taskId: task.id,
482
+ startedAt,
483
+ finishedAt: new Date().toISOString(),
484
+ detail: 'approval requested',
485
+ });
486
+ });
487
+ this.host.log.log(`[tasks] approval requested for ${task.id}`);
488
+ } catch (err) {
489
+ await this.recordFailure(task, nowMs, startedAt, err);
490
+ }
491
+ }
492
+
493
+ private async resolveApproval(
494
+ taskId: string,
495
+ decision: 'approve' | 'reject',
496
+ note?: string,
497
+ ): Promise<{ resolved: boolean }> {
498
+ const task = await this.store.get(taskId);
499
+ if (
500
+ !task ||
501
+ task.status !== 'active' ||
502
+ task.pendingApprovalAt === undefined
503
+ ) {
504
+ return { resolved: false };
505
+ }
506
+ // Clear the marker FIRST (and persist) so a concurrent resolve is a
507
+ // no-op instead of a double execution.
508
+ delete task.pendingApprovalAt;
509
+ task.updatedAt = new Date().toISOString();
510
+
511
+ if (decision === 'reject') {
512
+ if (task.schedule.kind === 'once') {
513
+ // A declined one-shot has nothing left to run.
514
+ task.status = 'cancelled';
515
+ delete task.nextRunAt;
516
+ }
517
+ await this.host.db.transaction(async () => {
518
+ await this.store.save(task);
519
+ await this.store.recordRun({
520
+ runId: crypto.randomUUID(),
521
+ taskId: task.id,
522
+ startedAt: task.updatedAt,
523
+ finishedAt: task.updatedAt,
524
+ detail: note?.trim() ? `declined: ${note.trim()}` : 'declined',
525
+ });
526
+ });
527
+ this.host.log.log(`[tasks] approval declined for ${task.id}`);
528
+ return { resolved: true };
529
+ }
530
+
531
+ await this.store.save(task);
532
+ this.host.log.log(`[tasks] approval granted for ${task.id} — running now`);
533
+ await this.executeRun(task, Date.now(), { approvalNote: note });
534
+ return { resolved: true };
535
+ }
536
+
537
+ // ── run execution ────────────────────────────────────────────────────────
538
+
539
+ /** The task's dedicated room when it has one, else the user's main room. */
540
+ private async resolveDeliveryRoom(task: TaskRecord): Promise<string | null> {
541
+ if (task.deliveryRoomId) return task.deliveryRoomId;
542
+ const room = await this.host.gateway
543
+ .resolveUserRoom(this.host.userDid)
544
+ .catch(() => null);
545
+ return room?.roomId ?? null;
546
+ }
547
+
548
+ /**
549
+ * Create the `[Task] <title>` room and post the task summary into it —
550
+ * the Node runtime's `DeliveryService.createDedicatedRoom`. Returns null
551
+ * (never throws) when the user has no known Matrix id or the gateway
552
+ * fails; the caller decides whether that is fatal.
553
+ */
554
+ private async createDedicatedRoom(task: TaskRecord): Promise<string | null> {
555
+ const userMatrixId = this.host.matrixUserId;
556
+ if (!userMatrixId) {
557
+ this.host.log.warn(
558
+ `[tasks] no Matrix id known for ${this.host.userDid}; ${task.id} delivers to the main room`,
559
+ );
560
+ return null;
561
+ }
562
+ try {
563
+ const { roomId } = await this.host.gateway.createDedicatedRoom({
564
+ name: `[Task] ${task.title}`,
565
+ topic: `Scheduled task: ${task.title}`,
566
+ invite: [userMatrixId],
567
+ userDid: this.host.userDid,
568
+ });
569
+ await this.host.gateway
570
+ .sendText(roomId, taskSummaryMessage(task))
571
+ .catch((err: unknown) => {
572
+ this.host.log.warn(
573
+ `[tasks] summary for ${task.id} not posted to ${roomId}: ${errorMessage(err)}`,
574
+ );
575
+ });
576
+ return roomId;
577
+ } catch (err) {
578
+ this.host.log.warn(
579
+ `[tasks] dedicated room for ${task.id} could not be created: ${errorMessage(err)}`,
580
+ );
581
+ return null;
582
+ }
583
+ }
584
+
585
+ /**
586
+ * The hot path: run the agent turn on the task's synthetic session, deliver
587
+ * the output to the user's room, then advance the schedule. Any throw —
588
+ * turn, empty output, delivery — lands in `recordFailure`.
589
+ *
590
+ * `opts.approvalNote` marks an approval-triggered run: the schedule was
591
+ * already advanced when the request was posted, so only a one-shot's
592
+ * completion is recorded here.
593
+ */
594
+ private async executeRun(
595
+ task: TaskRecord,
596
+ nowMs: number,
597
+ opts: { approvalNote?: string } = {},
598
+ ): Promise<void> {
599
+ const approvalRun = task.approval === 'before-action';
600
+ const startedAt = new Date(nowMs).toISOString();
601
+ try {
602
+ const roomId = await this.resolveDeliveryRoom(task);
603
+ if (!roomId) throw new Error('Could not resolve a delivery room');
604
+ const result = await this.host.runTurn({
605
+ identity: {
606
+ userDid: this.host.userDid,
607
+ ...(this.host.matrixUserId
608
+ ? { matrixUserId: this.host.matrixUserId }
609
+ : {}),
610
+ },
611
+ sessionId: `${TASK_SESSION_PREFIX}${task.id}`,
612
+ message: buildRunMessage(task, opts.approvalNote),
613
+ client: 'matrix',
614
+ roomId,
615
+ requestId: crypto.randomUUID(),
616
+ });
617
+ const text = result.text.trim();
618
+ if (text.length === 0) throw new Error('Agent returned no output');
619
+ // A delivery failure IS a run failure — a silent log-and-continue would
620
+ // mean "task succeeded" while the user never saw the result.
621
+ await this.host.gateway.sendText(
622
+ roomId,
623
+ `🕒 **${task.title}**\n\n${text}`,
624
+ );
625
+
626
+ const finishedAt = new Date().toISOString();
627
+ task.lastRunAt = startedAt;
628
+ task.lastResult = {
629
+ ok: true,
630
+ summary: text.slice(0, RESULT_SUMMARY_MAX),
631
+ at: finishedAt,
632
+ };
633
+ task.consecutiveFailures = 0;
634
+ if (task.schedule.kind === 'once') {
635
+ task.status = 'completed';
636
+ delete task.nextRunAt;
637
+ } else if (!approvalRun) {
638
+ // Advance from real time (a long run must not produce a next fire in
639
+ // the past). Approval runs already advanced when the request posted.
640
+ const nextMs = computeNextRunAtMs(
641
+ task.schedule,
642
+ Math.max(nowMs, Date.now()),
643
+ );
644
+ if (nextMs !== null) task.nextRunAt = new Date(nextMs).toISOString();
645
+ else delete task.nextRunAt;
646
+ }
647
+ task.updatedAt = finishedAt;
648
+ await this.host.db.transaction(async () => {
649
+ await this.store.save(task);
650
+ await this.store.recordRun({
651
+ runId: crypto.randomUUID(),
652
+ taskId: task.id,
653
+ startedAt,
654
+ finishedAt,
655
+ ok: true,
656
+ detail: task.lastResult?.summary,
657
+ });
658
+ });
659
+ this.host.log.log(
660
+ `[tasks] run delivered for ${task.id} (${text.length} chars)`,
661
+ );
662
+ } catch (err) {
663
+ await this.recordFailure(task, nowMs, startedAt, err);
664
+ }
665
+ }
666
+
667
+ /**
668
+ * Failure bookkeeping. A one-shot has no next occurrence to retry into, so
669
+ * any failure stops it as `failed` — loudly. A recurring task retries at
670
+ * the LATER of its own next fire and the exponential backoff, and stops as
671
+ * `failed` at the consecutive-failure threshold.
672
+ */
673
+ private async recordFailure(
674
+ task: TaskRecord,
675
+ nowMs: number,
676
+ startedAt: string,
677
+ err: unknown,
678
+ ): Promise<void> {
679
+ const message = errorMessage(err);
680
+ const finishedAt = new Date().toISOString();
681
+ task.consecutiveFailures += 1;
682
+ task.lastRunAt = startedAt;
683
+ task.lastResult = { ok: false, summary: message, at: finishedAt };
684
+ task.updatedAt = finishedAt;
685
+
686
+ const oneShot = task.schedule.kind === 'once';
687
+ const exhausted =
688
+ oneShot || task.consecutiveFailures >= MAX_CONSECUTIVE_FAILURES;
689
+ let stopped = false;
690
+ if (exhausted) {
691
+ task.status = 'failed';
692
+ delete task.nextRunAt;
693
+ delete task.pendingApprovalAt;
694
+ stopped = true;
695
+ } else {
696
+ const scheduleNext = computeNextRunAtMs(
697
+ task.schedule,
698
+ Math.max(nowMs, Date.now()),
699
+ );
700
+ if (scheduleNext === null) {
701
+ task.status = 'failed';
702
+ delete task.nextRunAt;
703
+ delete task.pendingApprovalAt;
704
+ stopped = true;
705
+ } else {
706
+ const retryAt = Math.max(
707
+ scheduleNext,
708
+ Math.max(nowMs, Date.now()) +
709
+ backoffDelayMs(task.consecutiveFailures),
710
+ );
711
+ task.nextRunAt = new Date(retryAt).toISOString();
712
+ }
713
+ }
714
+ await this.host.db.transaction(async () => {
715
+ await this.store.save(task);
716
+ await this.store.recordRun({
717
+ runId: crypto.randomUUID(),
718
+ taskId: task.id,
719
+ startedAt,
720
+ finishedAt,
721
+ ok: false,
722
+ detail: message,
723
+ });
724
+ });
725
+ this.host.log.warn(
726
+ `[tasks] run failed for ${task.id} (${task.consecutiveFailures} consecutive${stopped ? ', stopped' : ''}): ${message}`,
727
+ );
728
+ if (stopped) {
729
+ // Best-effort notice — the failure bookkeeping above is already saved.
730
+ const roomId = await this.resolveDeliveryRoom(task);
731
+ if (roomId) {
732
+ const text = oneShot
733
+ ? `🛑 Your scheduled task \`${task.id}\` failed and is stopped: ${message.slice(0, 200)}\n\nAsk me to **suggest a fix** when you're ready.`
734
+ : `🛑 Task \`${task.id}\` failed ${task.consecutiveFailures} times in a row and is stopped. Ask me to **suggest a fix** when you're ready.`;
735
+ await this.host.gateway
736
+ .sendText(roomId, text)
737
+ .catch((postErr: unknown) => {
738
+ this.host.log.warn(
739
+ `[tasks] failure notice for ${task.id} could not be posted: ${errorMessage(postErr)}`,
740
+ );
741
+ });
742
+ }
743
+ }
744
+ }
745
+ }
746
+
747
+ export async function createTaskScheduler(
748
+ host: TaskSchedulerHost,
749
+ ): Promise<TaskScheduler> {
750
+ const store = new TasksStore(host.db);
751
+ await store.setup();
752
+ return new AlarmTaskScheduler(host, store);
753
+ }