@johpaz/hive-sdk 0.1.4 → 0.1.6

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 (278) hide show
  1. package/CHANGELOG.md +129 -0
  2. package/README.md +78 -23
  3. package/bun.lock +55 -29
  4. package/bunfig.toml +4 -2
  5. package/docs/API-AGENTS.md +78 -27
  6. package/docs/API-CONTEXT-COMPILER.md +31 -34
  7. package/docs/API-TOOLS-SKILLS-CHANNELS.md +58 -22
  8. package/docs/HIVE-HARNESS.md +1 -1
  9. package/docs/INDEX.md +4 -4
  10. package/docs/TEMPLATE-HIVE-APP.md +10 -10
  11. package/package.json +17 -12
  12. package/packages/cli/package.json +2 -2
  13. package/packages/cli/src/commands/create-app.test.ts +36 -7
  14. package/packages/cli/src/commands/init.ts +3 -3
  15. package/packages/cli/src/commands/run.ts +1 -1
  16. package/packages/cli/src/commands/test.ts +37 -25
  17. package/packages/cli/src/commands/trace.ts +30 -28
  18. package/packages/cli/templates/hive-app/.env.example +10 -2
  19. package/packages/cli/templates/hive-app/README.md +103 -0
  20. package/packages/cli/templates/hive-app/hive.config.ts +9 -3
  21. package/packages/cli/templates/hive-app/src/agents/coordinator.ts +8 -1
  22. package/packages/cli/templates/hive-app/src/main.ts +12 -19
  23. package/packages/core/package.json +13 -12
  24. package/packages/core/src/agent/acceptance-checks.ts +172 -0
  25. package/packages/core/src/agent/agent-catalog.ts +348 -0
  26. package/packages/core/src/agent/agent-loop.ts +1373 -0
  27. package/packages/core/src/agent/capability-search.ts +186 -0
  28. package/packages/core/src/agent/catalog-selector.ts +103 -0
  29. package/packages/core/src/agent/{Compaction.ts → compaction.ts} +86 -63
  30. package/packages/core/src/agent/context-compiler.ts +689 -0
  31. package/packages/core/src/agent/conversation-store.ts +381 -0
  32. package/packages/core/src/agent/curator.ts +276 -0
  33. package/packages/core/src/agent/delegation-runtime.ts +241 -0
  34. package/packages/core/src/agent/goal-runner.ts +323 -0
  35. package/packages/core/src/agent/index.ts +17 -12
  36. package/packages/core/src/agent/llm-client.ts +266 -0
  37. package/packages/core/src/agent/llm-providers/anthropic.ts +264 -0
  38. package/packages/core/src/agent/llm-providers/deepseek.ts +8 -0
  39. package/packages/core/src/agent/{providers → llm-providers}/gemini.ts +98 -60
  40. package/packages/core/src/agent/llm-providers/groq.ts +5 -0
  41. package/packages/core/src/agent/llm-providers/hiveagents.ts +253 -0
  42. package/packages/core/src/agent/{providers → llm-providers}/interface.ts +73 -13
  43. package/packages/core/src/agent/llm-providers/kimi.ts +8 -0
  44. package/packages/core/src/agent/llm-providers/minimax.ts +13 -0
  45. package/packages/core/src/agent/llm-providers/mistral.ts +5 -0
  46. package/packages/core/src/agent/llm-providers/modelscope.ts +5 -0
  47. package/packages/core/src/agent/llm-providers/nvidia.ts +5 -0
  48. package/packages/core/src/agent/{providers → llm-providers}/ollama.ts +31 -5
  49. package/packages/core/src/agent/llm-providers/openai-compat-base.ts +418 -0
  50. package/packages/core/src/agent/llm-providers/openai.ts +5 -0
  51. package/packages/core/src/agent/llm-providers/opencode-go.ts +9 -0
  52. package/packages/core/src/agent/llm-providers/openrouter.ts +5 -0
  53. package/packages/core/src/agent/llm-providers/qwen.ts +5 -0
  54. package/packages/core/src/agent/llm-providers/z-ai.ts +5 -0
  55. package/packages/core/src/agent/minimal-loadout.ts +47 -0
  56. package/packages/core/src/agent/playbook-selector.ts +119 -0
  57. package/packages/core/src/agent/{PromptBuilder.ts → prompt-builder.ts} +21 -22
  58. package/packages/core/src/{harness → agent}/proof-packet.ts +16 -21
  59. package/packages/core/src/agent/providers/index.ts +35 -16
  60. package/packages/core/src/agent/reflector.ts +320 -0
  61. package/packages/core/src/agent/routing-intent.ts +22 -0
  62. package/packages/core/src/{harness → agent}/run-epoch.ts +4 -3
  63. package/packages/core/src/{harness → agent}/run-store.ts +142 -81
  64. package/packages/core/src/agent/{Service.ts → service.ts} +37 -26
  65. package/packages/core/src/agent/skill-selector.ts +374 -0
  66. package/packages/core/src/agent/stuck-loop.ts +209 -0
  67. package/packages/core/src/agent/{selectors/ToolSelector.ts → tool-selector.ts} +188 -178
  68. package/packages/core/src/{ace/Tracer.ts → agent/tracer.ts} +37 -27
  69. package/packages/core/src/api/createAgent.test.ts +139 -27
  70. package/packages/core/src/api/createAgent.ts +232 -44
  71. package/packages/core/src/artifacts/store.ts +162 -0
  72. package/packages/core/src/canvas/canvas-manager.ts +161 -0
  73. package/packages/core/src/canvas/canvas.test.ts +8 -4
  74. package/packages/core/src/canvas/emitter.ts +131 -80
  75. package/packages/core/src/canvas/index.ts +1 -3
  76. package/packages/core/src/channels/base.ts +9 -1
  77. package/packages/core/src/channels/discord.ts +5 -4
  78. package/packages/core/src/channels/manager.ts +122 -30
  79. package/packages/core/src/channels/slack.ts +5 -4
  80. package/packages/core/src/channels/telegram.ts +36 -6
  81. package/packages/core/src/channels/webchat.ts +11 -10
  82. package/packages/core/src/channels/whatsapp.ts +23 -7
  83. package/packages/core/src/config/index.ts +13 -2
  84. package/packages/core/src/config/loader.ts +76 -29
  85. package/packages/core/src/ethics/EthicsGuard.test.ts +90 -36
  86. package/packages/core/src/ethics/EthicsGuard.ts +51 -47
  87. package/packages/core/src/events/agent-bus.ts +44 -68
  88. package/packages/core/src/events/channel-narration.ts +150 -0
  89. package/packages/core/src/events/narration.ts +82 -0
  90. package/packages/core/src/events/tool-narration.ts +62 -0
  91. package/packages/core/src/gateway/delegation-groups.ts +258 -0
  92. package/packages/core/src/{harness → gateway}/durable-queue.ts +102 -42
  93. package/packages/core/src/{harness → gateway}/job-store.ts +85 -48
  94. package/packages/core/src/gateway/lane-queue.ts +173 -0
  95. package/packages/core/src/gateway/notification-inbox.ts +57 -0
  96. package/packages/core/src/gateway/server.ts +1 -1
  97. package/packages/core/src/harness/index.ts +46 -27
  98. package/packages/core/src/index.ts +33 -27
  99. package/packages/core/src/mcp/hot-reload.ts +32 -23
  100. package/packages/core/src/mcp/index.ts +6 -3
  101. package/packages/core/src/mcp/singleton.ts +1 -4
  102. package/packages/core/src/mcp/tool-sync.ts +138 -0
  103. package/packages/core/src/memory/Scratchpad.test.ts +39 -20
  104. package/packages/core/src/memory/Scratchpad.ts +27 -34
  105. package/packages/core/src/multimodal/vision-service.ts +44 -38
  106. package/packages/core/src/resilience/retry.ts +95 -0
  107. package/packages/core/src/scheduler/CronScheduler.ts +334 -287
  108. package/packages/core/src/scheduler/index.ts +9 -7
  109. package/packages/core/src/scheduler/integration.ts +46 -26
  110. package/packages/core/src/scheduler/scheduler.test.ts +9 -13
  111. package/packages/core/src/scheduler/types.ts +7 -2
  112. package/packages/core/src/security/Pairing.ts +1 -1
  113. package/packages/core/src/skills/bundled/a2ui/a2ui_dashboard/SKILL.md +176 -0
  114. package/packages/core/src/skills/bundled/a2ui/a2ui_form/SKILL.md +202 -0
  115. package/packages/core/src/skills/bundled/a2ui/a2ui_interactive/SKILL.md +206 -0
  116. package/packages/core/src/skills/bundled/agents/agent_spawner/SKILL.md +173 -0
  117. package/packages/core/src/skills/bundled/agents/memory_manager/SKILL.md +143 -0
  118. package/packages/core/src/skills/bundled/agents/research_and_remember/SKILL.md +139 -0
  119. package/packages/core/src/skills/bundled/agents/task_orchestrator/SKILL.md +98 -0
  120. package/packages/core/src/skills/bundled/api/api_client/SKILL.md +132 -0
  121. package/packages/core/src/skills/bundled/cli/cli_pipeline/SKILL.md +135 -0
  122. package/packages/core/src/skills/bundled/cli/cli_safe_exec/SKILL.md +125 -0
  123. package/packages/core/src/skills/bundled/cli/software_engineering/SKILL.md +23 -0
  124. package/packages/core/src/skills/bundled/cron_manager/SKILL.md +188 -0
  125. package/packages/core/src/skills/bundled/cron_reminder/SKILL.md +112 -0
  126. package/packages/core/src/skills/bundled/filesystem/file_manager/SKILL.md +118 -0
  127. package/packages/core/src/skills/bundled/filesystem/file_read_and_summarize/SKILL.md +109 -0
  128. package/packages/core/src/skills/bundled/filesystem/file_writer/SKILL.md +129 -0
  129. package/packages/core/src/skills/bundled/filesystem/workspace_file_operator/SKILL.md +22 -0
  130. package/packages/core/src/skills/bundled/office/office_document_manager/SKILL.md +262 -0
  131. package/packages/core/src/skills/bundled/search_knowledge/capability_discovery/SKILL.md +75 -0
  132. package/packages/core/src/skills/bundled/web/browser_automate/SKILL.md +120 -0
  133. package/packages/core/src/skills/bundled/web/browser_scrape/SKILL.md +109 -0
  134. package/packages/core/src/skills/bundled/web/web_monitor/SKILL.md +127 -0
  135. package/packages/core/src/skills/bundled/web/web_research/SKILL.md +119 -0
  136. package/packages/core/src/skills/bundled-data.generated.ts +731 -2678
  137. package/packages/core/src/skills/skills.test.ts +52 -11
  138. package/packages/core/src/{harness → storage}/boot-id.ts +5 -2
  139. package/packages/core/src/storage/bootstrap.ts +151 -0
  140. package/packages/core/src/storage/causal-events.ts +84 -0
  141. package/packages/core/src/storage/collections.ts +680 -0
  142. package/packages/core/src/storage/crypto.ts +205 -74
  143. package/packages/core/src/{harness/db-helpers.ts → storage/hive.ts} +63 -7
  144. package/packages/core/src/storage/hivedb.ts +61 -0
  145. package/packages/core/src/storage/index.ts +111 -18
  146. package/packages/core/src/storage/model-id.ts +53 -0
  147. package/packages/core/src/storage/onboarding.ts +540 -972
  148. package/packages/core/src/storage/reconcile.ts +238 -0
  149. package/packages/core/src/storage/seed.ts +572 -406
  150. package/packages/core/src/storage/usage.ts +285 -225
  151. package/packages/core/src/storage/user-email.ts +11 -0
  152. package/packages/core/src/swarm/AgentExecutor.ts +1 -1
  153. package/packages/core/src/swarm/EventBridge.ts +1 -1
  154. package/packages/core/src/swarm/index.ts +12 -9
  155. package/packages/core/src/tool-runtime/index.ts +146 -23
  156. package/packages/core/src/tool-runtime/tool-worker.ts +2 -2
  157. package/packages/core/src/tool-runtime/worker-tools.ts +27 -0
  158. package/packages/core/src/{canvas/a2ui-tools.ts → tools/a2ui/index.ts} +17 -8
  159. package/packages/core/src/tools/agents/get-available-models.ts +36 -54
  160. package/packages/core/src/tools/agents/index.ts +784 -292
  161. package/packages/core/src/tools/api/api-request.test.ts +164 -0
  162. package/packages/core/src/tools/api/api-request.ts +174 -0
  163. package/packages/core/src/tools/api/index.ts +16 -0
  164. package/packages/core/src/tools/cli/index.ts +4 -0
  165. package/packages/core/src/tools/core/index.ts +281 -112
  166. package/packages/core/src/tools/cron/index.ts +121 -124
  167. package/packages/core/src/tools/index.ts +63 -78
  168. package/packages/core/src/tools/office/office-escribir-xlsx.ts +3 -1
  169. package/packages/core/src/tools/types.ts +3 -1
  170. package/packages/core/src/tools/web/artifact-inspect.ts +23 -0
  171. package/packages/core/src/tools/web/browser-backend.ts +129 -0
  172. package/packages/core/src/tools/web/browser-screenshot.ts +26 -5
  173. package/packages/core/src/tools/web/browser-service.ts +80 -35
  174. package/packages/core/src/tools/web/browser-type.ts +3 -8
  175. package/packages/core/src/tools/web/index.ts +4 -4
  176. package/packages/core/src/tools/web/webview-backend.ts +412 -0
  177. package/packages/core/src/voice/index.ts +89 -63
  178. package/packages/core/src/workers/agent.worker.ts +2 -2
  179. package/packages/core/src/workers/workers.test.ts +3 -10
  180. package/scripts/bump-version.ts +248 -0
  181. package/scripts/generate-skill-bundle.ts +108 -0
  182. package/test/acceptance-checks.test.ts +403 -0
  183. package/test/agent-loop-terminal-synthesis.test.ts +32 -0
  184. package/test/browser-backend.test.ts +308 -0
  185. package/test/catalog-agents-stay-enabled.test.ts +117 -0
  186. package/test/causal-events.test.ts +117 -0
  187. package/test/compaction.test.ts +105 -0
  188. package/test/context-compiler.test.ts +269 -0
  189. package/test/curator.test.ts +130 -0
  190. package/test/durable-queue.test.ts +114 -0
  191. package/test/harness-barrel.test.ts +64 -0
  192. package/test/hive-helpers.test.ts +130 -0
  193. package/test/hivedb-search.test.ts +189 -0
  194. package/test/internal-turns.test.ts +166 -0
  195. package/test/job-idempotency.test.ts +68 -0
  196. package/test/job-retry-backoff.test.ts +184 -0
  197. package/test/job-store.test.ts +381 -0
  198. package/test/llm-retry.test.ts +97 -0
  199. package/test/memory-perf.test.ts +774 -0
  200. package/test/minimal-loadout.test.ts +78 -0
  201. package/test/model-catalog.test.ts +105 -0
  202. package/test/preload.ts +12 -0
  203. package/test/reflector.test.ts +320 -0
  204. package/test/retention-cap.test.ts +91 -0
  205. package/test/retired-capabilities-pruned.test.ts +192 -0
  206. package/test/run-store.test.ts +355 -0
  207. package/test/scratchpad.test.ts +74 -0
  208. package/test/secrets-durability.test.ts +119 -0
  209. package/test/seed-model-reseed.test.ts +155 -0
  210. package/test/setup-agent-seed.test.ts +264 -0
  211. package/test/tool-inventory.test.ts +65 -0
  212. package/test/tool-runtime.test.ts +258 -0
  213. package/test/tool-selector-runtime-tools.test.ts +117 -0
  214. package/test/toon.test.ts +429 -0
  215. package/tsconfig.json +2 -0
  216. package/packages/core/src/ace/Curator.ts +0 -158
  217. package/packages/core/src/ace/Reflector.ts +0 -200
  218. package/packages/core/src/ace/index.ts +0 -4
  219. package/packages/core/src/agent/AgentRunner.ts +0 -711
  220. package/packages/core/src/agent/ContextCompiler.ts +0 -567
  221. package/packages/core/src/agent/ContextGuard.ts +0 -91
  222. package/packages/core/src/agent/ConversationStore.ts +0 -254
  223. package/packages/core/src/agent/Hooks.ts +0 -166
  224. package/packages/core/src/agent/StuckLoop.ts +0 -133
  225. package/packages/core/src/agent/providers/LLMClient.ts +0 -149
  226. package/packages/core/src/agent/providers/anthropic.ts +0 -212
  227. package/packages/core/src/agent/providers/openai-compat.ts +0 -231
  228. package/packages/core/src/agent/selectors/PlaybookSelector.ts +0 -121
  229. package/packages/core/src/agent/selectors/SkillSelector.ts +0 -322
  230. package/packages/core/src/agent/selectors/index.ts +0 -6
  231. package/packages/core/src/auth/auth.ts +0 -121
  232. package/packages/core/src/auth/index.ts +0 -1
  233. package/packages/core/src/canvas/CanvasManager.ts +0 -390
  234. package/packages/core/src/canvas/canvas-tools.ts +0 -448
  235. package/packages/core/src/harness/collections.ts +0 -98
  236. package/packages/core/src/harness/goal-verifier.ts +0 -141
  237. package/packages/core/src/harness/harness.test.ts +0 -236
  238. package/packages/core/src/harness/reconcile.ts +0 -149
  239. package/packages/core/src/mcp/MCPToolAdapter.ts +0 -176
  240. package/packages/core/src/multimodal/VisionService.ts +0 -293
  241. package/packages/core/src/scheduler/dag/AgentExecutor.ts +0 -53
  242. package/packages/core/src/scheduler/dag/DAGScheduler.ts +0 -250
  243. package/packages/core/src/scheduler/dag/EventBridge.ts +0 -122
  244. package/packages/core/src/scheduler/dag/TaskGraph.ts +0 -192
  245. package/packages/core/src/scheduler/dag/TaskNode.ts +0 -97
  246. package/packages/core/src/scheduler/dag/TaskResult.ts +0 -22
  247. package/packages/core/src/scheduler/dag/errors.ts +0 -37
  248. package/packages/core/src/scheduler/dag/index.ts +0 -26
  249. package/packages/core/src/scheduler/dag/presets/ResearchPreset.ts +0 -97
  250. package/packages/core/src/scheduler/dag/strategies/ParallelStrategy.ts +0 -21
  251. package/packages/core/src/scheduler/dag/strategies/PriorityStrategy.ts +0 -46
  252. package/packages/core/src/storage/HiveDBStorage.ts +0 -64
  253. package/packages/core/src/storage/SQLiteStorage.ts +0 -414
  254. package/packages/core/src/storage/hiveSeed.ts +0 -308
  255. package/packages/core/src/storage/hiveStorage.test.ts +0 -38
  256. package/packages/core/src/storage/schema.ts +0 -689
  257. package/packages/core/src/storage/storage.test.ts +0 -37
  258. package/packages/core/src/swarm/AgentBus.ts +0 -460
  259. package/packages/core/src/swarm/EventBus.ts +0 -169
  260. package/packages/core/src/swarm/WorkerPool.ts +0 -236
  261. package/packages/core/src/tools/bridge-events.ts +0 -26
  262. package/packages/core/src/tools/canvas/index.ts +0 -375
  263. package/packages/core/src/tools/codebridge/index.ts +0 -342
  264. package/packages/core/src/tools/meeting/index.ts +0 -353
  265. package/packages/core/src/tools/projects/index.ts +0 -37
  266. package/packages/core/src/tools/projects/project-create.ts +0 -94
  267. package/packages/core/src/tools/projects/project-done.ts +0 -66
  268. package/packages/core/src/tools/projects/project-fail.ts +0 -66
  269. package/packages/core/src/tools/projects/project-list.ts +0 -96
  270. package/packages/core/src/tools/projects/project-update.ts +0 -72
  271. package/packages/core/src/tools/projects/task-create.ts +0 -68
  272. package/packages/core/src/tools/projects/task-evaluate.ts +0 -93
  273. package/packages/core/src/tools/projects/task-update.ts +0 -93
  274. package/packages/core/src/tools/voice/index.ts +0 -104
  275. package/packages/core/src/tools/web/api-request.test.ts +0 -170
  276. package/packages/core/src/tools/web/api-request.ts +0 -239
  277. package/test/setup-db.ts +0 -216
  278. /package/packages/core/src/agent/{NativeTools.ts → native-tools.ts} +0 -0
@@ -1,14 +1,16 @@
1
1
  /**
2
2
  * Hive CronScheduler
3
- *
4
- * Croner-based scheduler for Hive with SQLite persistence.
3
+ *
4
+ * Croner-based scheduler for Hive with HiveDB persistence.
5
5
  * Manages recurring and one-shot cron jobs that execute through the agent pipeline.
6
6
  */
7
7
 
8
8
  import { Cron } from "croner";
9
- import type { Database } from "bun:sqlite";
10
9
  import { logger } from "../utils/logger";
11
10
  import { notifyTaskCompletion } from "./integration";
11
+ import { col, toIndexable, fromIndexable } from "../storage/hive";
12
+ import type { CronJobDoc, TaskRunDoc } from "../storage/collections";
13
+ import { expireArtifacts } from "../artifacts/store";
12
14
  import type {
13
15
  CronJob,
14
16
  TaskRun,
@@ -20,38 +22,107 @@ import type {
20
22
 
21
23
  const log = logger.child("CronScheduler");
22
24
 
25
+ function fromDoc(doc: CronJobDoc): CronJob {
26
+ return { ...doc, agent_id: fromIndexable(doc.agent_id) };
27
+ }
28
+
29
+ function toDoc(job: CronJob): CronJobDoc {
30
+ return { ...job, agent_id: toIndexable(job.agent_id) };
31
+ }
32
+
23
33
  export class CronScheduler {
24
34
  private jobs: Map<string, Cron> = new Map();
25
- private db: Database;
26
35
  private handler: CronJobExecutionHandler;
27
36
  private cleanupTaskId: string | null = null;
28
37
 
29
- constructor(db: Database, handler: CronJobExecutionHandler) {
30
- this.db = db;
38
+ constructor(handler: CronJobExecutionHandler) {
31
39
  this.handler = handler;
32
40
  }
33
41
 
34
42
  /**
35
- * Boot the scheduler - load all active jobs from DB and activate them
43
+ * Boot the scheduler - load all active jobs from DB and activate them.
44
+ * Also detects misfires (next_run_at/fire_at in the past) and handles
45
+ * them according to misfire_policy.
36
46
  */
37
- boot(): void {
38
- const tasks = this.db.query(`
39
- SELECT * FROM cron_jobs WHERE status = 'active'
40
- `).all() as CronJob[];
47
+ async boot(): Promise<void> {
48
+ const cronJobsCol = await col<CronJobDoc>("cronJobs");
49
+ const tasks = (await cronJobsCol.findBy("status", "active")).map(e => fromDoc(e.doc));
50
+
51
+ const now = new Date();
52
+ let misfireCount = 0;
41
53
 
42
54
  for (const task of tasks) {
43
- this.activate(task);
55
+ // ── Misfire detection ──────────────────────────────────────────────
56
+ const misfirePolicy = task.misfire_policy ?? "skip";
57
+ const graceMin = task.misfire_grace_min ?? 60;
58
+ const graceCutoff = new Date(now.getTime() - graceMin * 60 * 1000);
59
+
60
+ let misfireTime: Date | null = null;
61
+ if (task.task_type === "recurring" && task.next_run_at) {
62
+ const nextRun = new Date(task.next_run_at);
63
+ if (nextRun < now) {
64
+ misfireTime = nextRun;
65
+ }
66
+ } else if (task.task_type === "one_shot" && task.fire_at) {
67
+ const fireAt = new Date(task.fire_at);
68
+ if (fireAt < now) {
69
+ misfireTime = fireAt;
70
+ }
71
+ }
72
+
73
+ if (misfireTime) {
74
+ misfireCount++;
75
+ const withinGrace = misfireTime >= graceCutoff;
76
+
77
+ if (misfirePolicy === "fire_once" && withinGrace) {
78
+ log.info(`[boot:misfire] Job "${task.name}" (${task.id}) misfired at ${misfireTime.toISOString()} — executing now (fire_once, within grace)`);
79
+ // Activate the job first (so the Croner handle exists for future runs)
80
+ await this.activate(task);
81
+ // Then execute it immediately
82
+ this.execute(task.id).catch((err) => {
83
+ log.error(`[boot:misfire] Catch-up execution failed for "${task.name}": ${(err as Error).message}`);
84
+ });
85
+ } else if (task.task_type === "one_shot") {
86
+ // A missed one-shot that won't be caught up can never fire again
87
+ // (its fire_at is in the past); activating it would leave a zombie
88
+ // "active" job forever.
89
+ log.warn(`[boot:misfire] One-shot "${task.name}" (${task.id}) missed at ${misfireTime.toISOString()} (policy=${misfirePolicy}${misfirePolicy === "fire_once" ? ", outside grace" : ""}) → failed`);
90
+ await this.updateJob(task.id, {
91
+ status: "failed",
92
+ last_error: `Missed while down (fire_at=${misfireTime.toISOString()}, policy=${misfirePolicy})`,
93
+ updated_at: now.toISOString(),
94
+ });
95
+ } else if (misfirePolicy === "fire_once" && !withinGrace) {
96
+ log.warn(`[boot:misfire] Job "${task.name}" (${task.id}) misfired at ${misfireTime.toISOString()} — outside grace window (${graceMin}min), skipping catch-up`);
97
+ await this.updateJob(task.id, {
98
+ last_error: `Missed run at ${misfireTime.toISOString()} (outside grace)`,
99
+ updated_at: now.toISOString(),
100
+ });
101
+ await this.activate(task);
102
+ } else {
103
+ // skip policy — recurring re-schedules its next occurrence via Croner
104
+ log.info(`[boot:misfire] Job "${task.name}" (${task.id}) misfired at ${misfireTime.toISOString()} — skipping (misfire_policy=skip)`);
105
+ await this.updateJob(task.id, {
106
+ last_error: `Missed run at ${misfireTime.toISOString()} (policy: skip)`,
107
+ updated_at: now.toISOString(),
108
+ });
109
+ await this.activate(task);
110
+ }
111
+ } else {
112
+ // No misfire — activate normally
113
+ await this.activate(task);
114
+ }
44
115
  }
45
116
 
46
- log.info(`[boot] Loaded ${tasks.length} active job(s)`);
117
+ log.info(`[boot] Loaded ${tasks.length} active job(s)${misfireCount > 0 ? `, ${misfireCount} misfire(s) detected` : ""}`);
47
118
 
48
- this.ensureCleanupTask();
119
+ await this.ensureCleanupTask();
49
120
  }
50
121
 
51
122
  /**
52
123
  * Activate a cron job - create or recreate its Croner instance
53
124
  */
54
- activate(task: CronJob): void {
125
+ async activate(task: CronJob): Promise<void> {
55
126
  const existingJob = this.jobs.get(task.id);
56
127
  if (existingJob) {
57
128
  existingJob.stop();
@@ -66,10 +137,13 @@ export class CronScheduler {
66
137
 
67
138
  // Fix 2A: auto-pause jobs that exceeded the error threshold
68
139
  const MAX_ERRORS = 5;
140
+ const cronJobsCol = await col<CronJobDoc>("cronJobs");
69
141
  if (task.error_count >= MAX_ERRORS) {
70
- this.db.query(
71
- "UPDATE cron_jobs SET status = 'paused', last_error = ?, updated_at = ? WHERE id = ?"
72
- ).run(`Auto-paused after ${MAX_ERRORS} consecutive errors`, new Date().toISOString(), task.id);
142
+ await this.updateJob(task.id, {
143
+ status: "paused",
144
+ last_error: `Auto-paused after ${MAX_ERRORS} consecutive errors`,
145
+ updated_at: new Date().toISOString(),
146
+ });
73
147
  log.warn(`[activate] Job "${task.name}" (${task.id}) auto-paused (error_count=${task.error_count})`);
74
148
  return;
75
149
  }
@@ -134,9 +208,7 @@ export class CronScheduler {
134
208
  if (nextRun) {
135
209
  const nextRunIso = nextRun.toISOString();
136
210
  // Fix 4: also update updated_at when writing next_run_at
137
- this.db.query(
138
- "UPDATE cron_jobs SET next_run_at = ?, updated_at = ? WHERE id = ?"
139
- ).run(nextRunIso, new Date().toISOString(), task.id);
211
+ await this.updateJob(task.id, { next_run_at: nextRunIso, updated_at: new Date().toISOString() });
140
212
  log.info(`[activate] Job "${task.name}" (${task.id}) scheduled - next: ${nextRunIso}`);
141
213
  } else {
142
214
  log.warn(`[activate] Job "${task.name}" (${task.id}) has no next run date`);
@@ -146,16 +218,35 @@ export class CronScheduler {
146
218
  }
147
219
  }
148
220
 
221
+ /** Read-modify-write helper for cron_jobs partial updates, retrying on OCC conflict. */
222
+ private async updateJob(taskId: string, patch: Partial<CronJobDoc>): Promise<CronJobDoc | null> {
223
+ const cronJobsCol = await col<CronJobDoc>("cronJobs");
224
+ for (let attempt = 0; attempt < 5; attempt++) {
225
+ const existing = await cronJobsCol.get(taskId);
226
+ if (!existing) return null;
227
+ const merged = { ...existing.doc, ...patch };
228
+ try {
229
+ await cronJobsCol.put(taskId, merged, { expectedVersion: existing.version });
230
+ return merged;
231
+ } catch {
232
+ // Version conflict — retry with a fresh read.
233
+ }
234
+ }
235
+ throw new Error(`CronScheduler.updateJob: too much contention on cronJobs/${taskId}`);
236
+ }
237
+
149
238
  /**
150
239
  * Execute a cron job - run it through the agent pipeline
151
240
  */
152
241
  private async execute(taskId: string): Promise<void> {
153
242
  // Fix 1: read fresh task data from DB to avoid stale closure snapshots
154
- const task = this.db.query("SELECT * FROM cron_jobs WHERE id = ?").get(taskId) as CronJob | null;
155
- if (!task) {
243
+ const cronJobsCol = await col<CronJobDoc>("cronJobs");
244
+ const taskEntry = await cronJobsCol.get(taskId);
245
+ if (!taskEntry) {
156
246
  log.warn(`[execute] Job "${taskId}" not found in DB — skipping`);
157
247
  return;
158
248
  }
249
+ const task = fromDoc(taskEntry.doc);
159
250
 
160
251
  const runId = crypto.randomUUID().replace(/-/g, "").slice(0, 16);
161
252
  const startedAt = new Date().toISOString();
@@ -163,11 +254,19 @@ export class CronScheduler {
163
254
 
164
255
  log.info(`[execute] Starting job "${task.name}" (${task.id}) run #${runId}`);
165
256
 
257
+ const taskRunsCol = await col<TaskRunDoc>("taskRuns");
166
258
  try {
167
- this.db.query(`
168
- INSERT INTO task_runs (id, task_id, status, started_at, payload_snapshot)
169
- VALUES (?, ?, 'running', ?, ?)
170
- `).run(runId, task.id, startedAt, task.payload);
259
+ await taskRunsCol.put(runId, {
260
+ id: runId,
261
+ task_id: task.id,
262
+ status: "running",
263
+ started_at: startedAt,
264
+ finished_at: null,
265
+ duration_ms: null,
266
+ error_message: null,
267
+ payload_snapshot: task.payload,
268
+ agent_response: null,
269
+ }, { expectedVersion: 0 });
171
270
  } catch (err) {
172
271
  log.error(`[execute] Failed to create task_run record: ${(err as Error).message}`);
173
272
  }
@@ -178,36 +277,31 @@ export class CronScheduler {
178
277
  const finishedAt = new Date().toISOString();
179
278
 
180
279
  if (result.success) {
181
- this.db.query(`
182
- UPDATE task_runs
183
- SET status = 'success', finished_at = ?, duration_ms = ?, agent_response = ?
184
- WHERE id = ?
185
- `).run(finishedAt, Math.round(duration), result.response?.slice(0, 1000) || null, runId);
186
-
187
- this.db.query(`
188
- UPDATE cron_jobs
189
- SET run_count = run_count + 1, last_run_at = ?, last_error = NULL
190
- WHERE id = ?
191
- `).run(finishedAt, task.id);
280
+ await this.updateTaskRun(runId, {
281
+ status: "success",
282
+ finished_at: finishedAt,
283
+ duration_ms: Math.round(duration),
284
+ agent_response: result.response?.slice(0, 1000) || null,
285
+ });
286
+
287
+ const refreshed = await this.updateJob(task.id, {
288
+ run_count: task.run_count + 1,
289
+ last_run_at: finishedAt,
290
+ last_error: null,
291
+ });
192
292
 
193
293
  const job = this.jobs.get(task.id);
194
294
  if (job) {
195
295
  const nextRun = job.nextRun();
196
296
  if (nextRun) {
197
- this.db.query(
198
- "UPDATE cron_jobs SET next_run_at = ? WHERE id = ?"
199
- ).run(nextRun.toISOString(), task.id);
297
+ await this.updateJob(task.id, { next_run_at: nextRun.toISOString() });
200
298
  }
201
299
  }
202
300
 
203
301
  await notifyTaskCompletion(task.id, task.name, true, result.response);
204
302
 
205
303
  if (task.task_type === "one_shot") {
206
- this.db.query(`
207
- UPDATE cron_jobs
208
- SET status = 'completed', completed_at = ?
209
- WHERE id = ?
210
- `).run(finishedAt, task.id);
304
+ await this.updateJob(task.id, { status: "completed", completed_at: finishedAt });
211
305
  this.deactivate(task.id);
212
306
  log.info(`[execute] One-shot job "${task.name}" (${task.id}) completed`);
213
307
  } else {
@@ -221,30 +315,24 @@ export class CronScheduler {
221
315
  const finishedAt = new Date().toISOString();
222
316
  const errorMessage = (err as Error).message;
223
317
 
224
- this.db.query(`
225
- UPDATE task_runs
226
- SET status = 'failed', finished_at = ?, duration_ms = ?, error_message = ?
227
- WHERE id = ?
228
- `).run(finishedAt, Math.round(duration), errorMessage, runId);
318
+ await this.updateTaskRun(runId, {
319
+ status: "failed",
320
+ finished_at: finishedAt,
321
+ duration_ms: Math.round(duration),
322
+ error_message: errorMessage,
323
+ });
229
324
 
230
- this.db.query(`
231
- UPDATE cron_jobs
232
- SET error_count = error_count + 1, last_error = ?
233
- WHERE id = ?
234
- `).run(errorMessage, task.id);
325
+ const updated = await this.updateJob(task.id, {
326
+ error_count: task.error_count + 1,
327
+ last_error: errorMessage,
328
+ });
235
329
 
236
330
  log.error(`[execute] Job "${task.name}" (${task.id}) failed: ${errorMessage}`);
237
331
 
238
332
  // Fix 2B: auto-pause if error threshold reached
239
333
  const MAX_ERRORS = 5;
240
- const updated = this.db.query(
241
- "SELECT error_count FROM cron_jobs WHERE id = ?"
242
- ).get(task.id) as { error_count: number } | null;
243
-
244
334
  if (updated && updated.error_count >= MAX_ERRORS) {
245
- this.db.query(
246
- "UPDATE cron_jobs SET status = 'paused', updated_at = ? WHERE id = ?"
247
- ).run(new Date().toISOString(), task.id);
335
+ await this.updateJob(task.id, { status: "paused", updated_at: new Date().toISOString() });
248
336
  this.deactivate(task.id);
249
337
  log.warn(`[execute] Job "${task.name}" (${task.id}) auto-paused after ${MAX_ERRORS} errors`);
250
338
  }
@@ -253,6 +341,22 @@ export class CronScheduler {
253
341
  }
254
342
  }
255
343
 
344
+ /** Read-modify-write helper for task_runs partial updates, retrying on OCC conflict. */
345
+ private async updateTaskRun(runId: string, patch: Partial<TaskRunDoc>): Promise<void> {
346
+ const taskRunsCol = await col<TaskRunDoc>("taskRuns");
347
+ for (let attempt = 0; attempt < 5; attempt++) {
348
+ const existing = await taskRunsCol.get(runId);
349
+ if (!existing) return;
350
+ try {
351
+ await taskRunsCol.put(runId, { ...existing.doc, ...patch }, { expectedVersion: existing.version });
352
+ return;
353
+ } catch {
354
+ // Version conflict — retry with a fresh read.
355
+ }
356
+ }
357
+ log.warn(`[updateTaskRun] Too much contention on taskRuns/${runId}`);
358
+ }
359
+
256
360
  /**
257
361
  * Handle errors from Croner
258
362
  */
@@ -260,38 +364,45 @@ export class CronScheduler {
260
364
  log.error(`[error] Job "${task.name}" (${task.id}) error: ${error.message}`);
261
365
 
262
366
  // Fix 3: record Croner-level errors in task_runs for full history
263
- const runId = crypto.randomUUID().replace(/-/g, "").slice(0, 16);
264
- const now = new Date().toISOString();
265
- try {
266
- this.db.query(`
267
- INSERT INTO task_runs (id, task_id, status, started_at, finished_at, duration_ms, error_message)
268
- VALUES (?, ?, 'failed', ?, ?, 0, ?)
269
- `).run(runId, task.id, now, now, error.message);
270
- } catch (e) {
271
- log.warn(`[handleError] Failed to insert task_run: ${(e as Error).message}`);
272
- }
367
+ Promise.resolve().then(async () => {
368
+ const runId = crypto.randomUUID().replace(/-/g, "").slice(0, 16);
369
+ const now = new Date().toISOString();
370
+ try {
371
+ const taskRunsCol = await col<TaskRunDoc>("taskRuns");
372
+ await taskRunsCol.put(runId, {
373
+ id: runId,
374
+ task_id: task.id,
375
+ status: "failed",
376
+ started_at: now,
377
+ finished_at: now,
378
+ duration_ms: 0,
379
+ error_message: error.message,
380
+ payload_snapshot: null,
381
+ agent_response: null,
382
+ }, { expectedVersion: 0 });
383
+ } catch (e) {
384
+ log.warn(`[handleError] Failed to insert task_run: ${(e as Error).message}`);
385
+ }
273
386
 
274
- this.db.query(`
275
- UPDATE cron_jobs
276
- SET error_count = error_count + 1, last_error = ?
277
- WHERE id = ?
278
- `).run(error.message, task.id);
387
+ await this.updateJob(task.id, {
388
+ error_count: task.error_count + 1,
389
+ last_error: error.message,
390
+ });
391
+ });
279
392
  }
280
393
 
281
394
  /**
282
395
  * Pause a cron job
283
396
  */
284
- pause(taskId: string): boolean {
397
+ async pause(taskId: string): Promise<boolean> {
285
398
  const job = this.jobs.get(taskId);
286
399
  if (job) {
287
400
  job.pause();
288
401
  }
289
402
 
290
- const result = this.db.query(
291
- "UPDATE cron_jobs SET status = 'paused' WHERE id = ?"
292
- ).run(taskId);
403
+ const updated = await this.updateJob(taskId, { status: "paused" });
293
404
 
294
- if (result.changes > 0) {
405
+ if (updated) {
295
406
  log.info(`[pause] Job "${taskId}" paused`);
296
407
  return true;
297
408
  }
@@ -303,21 +414,18 @@ export class CronScheduler {
303
414
  /**
304
415
  * Resume a paused cron job
305
416
  */
306
- resume(taskId: string): boolean {
307
- const task = this.db.query(
308
- "SELECT * FROM cron_jobs WHERE id = ?"
309
- ).get(taskId) as CronJob | undefined;
417
+ async resume(taskId: string): Promise<boolean> {
418
+ const cronJobsCol = await col<CronJobDoc>("cronJobs");
419
+ const taskEntry = await cronJobsCol.get(taskId);
310
420
 
311
- if (!task) {
421
+ if (!taskEntry) {
312
422
  log.warn(`[resume] Job "${taskId}" not found`);
313
423
  return false;
314
424
  }
315
425
 
316
- this.db.query(
317
- "UPDATE cron_jobs SET status = 'active' WHERE id = ?"
318
- ).run(taskId);
426
+ const updated = await this.updateJob(taskId, { status: "active" });
319
427
 
320
- this.activate(task);
428
+ await this.activate(fromDoc(updated!));
321
429
  log.info(`[resume] Job "${taskId}" resumed`);
322
430
  return true;
323
431
  }
@@ -337,26 +445,25 @@ export class CronScheduler {
337
445
  /**
338
446
  * Delete a cron job - deactivate and remove from DB
339
447
  */
340
- delete(taskId: string): boolean {
448
+ async delete(taskId: string): Promise<boolean> {
341
449
  this.deactivate(taskId);
342
450
 
343
- const result = this.db.query(
344
- "DELETE FROM cron_jobs WHERE id = ?"
345
- ).run(taskId);
346
-
347
- if (result.changes > 0) {
348
- log.info(`[delete] Job "${taskId}" deleted`);
349
- return true;
451
+ const cronJobsCol = await col<CronJobDoc>("cronJobs");
452
+ const existing = await cronJobsCol.get(taskId);
453
+ if (!existing) {
454
+ log.warn(`[delete] Job "${taskId}" not found`);
455
+ return false;
350
456
  }
351
457
 
352
- log.warn(`[delete] Job "${taskId}" not found`);
353
- return false;
458
+ await cronJobsCol.delete(taskId);
459
+ log.info(`[delete] Job "${taskId}" deleted`);
460
+ return true;
354
461
  }
355
462
 
356
463
  /**
357
464
  * Create a new cron job
358
465
  */
359
- create(input: CreateCronJobInput): { id: string; nextRun?: string } {
466
+ async create(input: CreateCronJobInput): Promise<{ id: string; nextRun?: string }> {
360
467
  const id = crypto.randomUUID().replace(/-/g, "").slice(0, 16);
361
468
  const now = new Date().toISOString();
362
469
 
@@ -394,40 +501,41 @@ export class CronScheduler {
394
501
  throw new Error("Invalid payload JSON");
395
502
  }
396
503
 
397
- this.db.query(`
398
- INSERT INTO cron_jobs (
399
- id, name, task, task_type, cron_expression, fire_at, timezone,
400
- start_at, stop_at, dom_and_dow,
401
- max_runs, protect, interval_sec, agent_id, channel, payload, tool_name,
402
- status, created_at, updated_at
403
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?)
404
- `).run(
504
+ const doc: CronJobDoc = {
405
505
  id,
406
- input.name,
407
- input.task,
408
- input.task_type,
409
- input.cron_expression || null,
410
- input.fire_at || null,
411
- input.timezone,
412
- input.start_at || null,
413
- input.stop_at || null,
414
- input.dom_and_dow ? 1 : 0,
415
- input.max_runs || null,
416
- input.protect !== false ? 1 : 0,
417
- input.interval_sec || null,
418
- input.agent_id || null,
419
- input.channel || "system",
420
- payloadJson,
421
- input.tool_name || null,
422
- now,
423
- now
424
- );
425
-
426
- const task = this.db.query(
427
- "SELECT * FROM cron_jobs WHERE id = ?"
428
- ).get(id) as CronJob;
429
-
430
- this.activate(task);
506
+ name: input.name,
507
+ task: input.task,
508
+ task_type: input.task_type,
509
+ cron_expression: input.cron_expression || null,
510
+ fire_at: input.fire_at || null,
511
+ timezone: input.timezone,
512
+ start_at: input.start_at || null,
513
+ stop_at: input.stop_at || null,
514
+ dom_and_dow: input.dom_and_dow ? 1 : 0,
515
+ max_runs: input.max_runs || null,
516
+ protect: input.protect !== false ? 1 : 0,
517
+ interval_sec: input.interval_sec || null,
518
+ agent_id: toIndexable(input.agent_id || null),
519
+ channel: input.channel || "system",
520
+ payload: payloadJson,
521
+ tool_name: input.tool_name || null,
522
+ status: "active",
523
+ run_count: 0,
524
+ error_count: 0,
525
+ last_error: null,
526
+ misfire_policy: input.misfire_policy ?? "skip",
527
+ misfire_grace_min: input.misfire_grace_min ?? 60,
528
+ created_at: now,
529
+ updated_at: now,
530
+ last_run_at: null,
531
+ next_run_at: null,
532
+ completed_at: null,
533
+ };
534
+
535
+ const cronJobsCol = await col<CronJobDoc>("cronJobs");
536
+ await cronJobsCol.put(id, doc, { expectedVersion: 0 });
537
+
538
+ await this.activate(fromDoc(doc));
431
539
 
432
540
  const job = this.jobs.get(id);
433
541
  const nextRun = job?.nextRun()?.toISOString();
@@ -440,100 +548,42 @@ export class CronScheduler {
440
548
  /**
441
549
  * Update an existing cron job
442
550
  */
443
- update(taskId: string, changes: UpdateCronJobInput): boolean {
444
- const task = this.db.query(
445
- "SELECT * FROM cron_jobs WHERE id = ?"
446
- ).get(taskId) as CronJob | undefined;
551
+ async update(taskId: string, changes: UpdateCronJobInput): Promise<boolean> {
552
+ const cronJobsCol = await col<CronJobDoc>("cronJobs");
553
+ const taskEntry = await cronJobsCol.get(taskId);
447
554
 
448
- if (!task) {
555
+ if (!taskEntry) {
449
556
  log.warn(`[update] Job "${taskId}" not found`);
450
557
  return false;
451
558
  }
452
559
 
453
- const fields: string[] = [];
454
- const values: any[] = [];
455
-
456
- if (changes.name !== undefined) {
457
- fields.push("name = ?");
458
- values.push(changes.name);
459
- }
460
- if (changes.task !== undefined) {
461
- fields.push("task = ?");
462
- values.push(changes.task);
463
- }
464
- if (changes.task_type !== undefined) {
465
- fields.push("task_type = ?");
466
- values.push(changes.task_type);
467
- }
468
- if (changes.cron_expression !== undefined) {
469
- fields.push("cron_expression = ?");
470
- values.push(changes.cron_expression);
471
- }
472
- if (changes.fire_at !== undefined) {
473
- fields.push("fire_at = ?");
474
- values.push(changes.fire_at);
475
- }
476
- if (changes.timezone !== undefined) {
477
- fields.push("timezone = ?");
478
- values.push(changes.timezone);
479
- }
480
- if (changes.start_at !== undefined) {
481
- fields.push("start_at = ?");
482
- values.push(changes.start_at);
483
- }
484
- if (changes.stop_at !== undefined) {
485
- fields.push("stop_at = ?");
486
- values.push(changes.stop_at);
487
- }
488
- if (changes.dom_and_dow !== undefined) {
489
- fields.push("dom_and_dow = ?");
490
- values.push(changes.dom_and_dow ? 1 : 0);
491
- }
492
- if (changes.agent_id !== undefined) {
493
- fields.push("agent_id = ?");
494
- values.push(changes.agent_id);
495
- }
496
- if (changes.channel !== undefined) {
497
- fields.push("channel = ?");
498
- values.push(changes.channel);
499
- }
500
- if (changes.payload !== undefined) {
501
- fields.push("payload = ?");
502
- values.push(JSON.stringify(changes.payload));
503
- }
504
- if (changes.tool_name !== undefined) {
505
- fields.push("tool_name = ?");
506
- values.push(changes.tool_name);
507
- }
508
- if (changes.max_runs !== undefined) {
509
- fields.push("max_runs = ?");
510
- values.push(changes.max_runs);
511
- }
512
- if (changes.protect !== undefined) {
513
- fields.push("protect = ?");
514
- values.push(changes.protect ? 1 : 0);
515
- }
516
- if (changes.interval_sec !== undefined) {
517
- fields.push("interval_sec = ?");
518
- values.push(changes.interval_sec);
519
- }
520
- if (changes.status !== undefined) {
521
- fields.push("status = ?");
522
- values.push(changes.status);
523
- }
524
-
525
- if (fields.length === 0) {
560
+ const patch: Partial<CronJobDoc> = {};
561
+
562
+ if (changes.name !== undefined) patch.name = changes.name;
563
+ if (changes.task !== undefined) patch.task = changes.task;
564
+ if (changes.task_type !== undefined) patch.task_type = changes.task_type;
565
+ if (changes.cron_expression !== undefined) patch.cron_expression = changes.cron_expression;
566
+ if (changes.fire_at !== undefined) patch.fire_at = changes.fire_at;
567
+ if (changes.timezone !== undefined) patch.timezone = changes.timezone;
568
+ if (changes.start_at !== undefined) patch.start_at = changes.start_at;
569
+ if (changes.stop_at !== undefined) patch.stop_at = changes.stop_at;
570
+ if (changes.dom_and_dow !== undefined) patch.dom_and_dow = changes.dom_and_dow ? 1 : 0;
571
+ if (changes.agent_id !== undefined) patch.agent_id = toIndexable(changes.agent_id);
572
+ if (changes.channel !== undefined) patch.channel = changes.channel;
573
+ if (changes.payload !== undefined) patch.payload = JSON.stringify(changes.payload);
574
+ if (changes.tool_name !== undefined) patch.tool_name = changes.tool_name;
575
+ if (changes.max_runs !== undefined) patch.max_runs = changes.max_runs;
576
+ if (changes.protect !== undefined) patch.protect = changes.protect ? 1 : 0;
577
+ if (changes.interval_sec !== undefined) patch.interval_sec = changes.interval_sec;
578
+ if (changes.status !== undefined) patch.status = changes.status;
579
+
580
+ if (Object.keys(patch).length === 0) {
526
581
  return true;
527
582
  }
528
583
 
529
- values.push(taskId);
530
- this.db.query(`UPDATE cron_jobs SET ${fields.join(", ")} WHERE id = ?`).run(...values);
531
-
532
- const updatedTask = this.db.query(
533
- "SELECT * FROM cron_jobs WHERE id = ?"
534
- ).get(taskId) as CronJob;
584
+ const updatedDoc = await this.updateJob(taskId, patch);
535
585
 
536
- this.activate(updatedTask);
586
+ await this.activate(fromDoc(updatedDoc!));
537
587
 
538
588
  log.info(`[update] Job "${taskId}" updated`);
539
589
  return true;
@@ -542,10 +592,9 @@ export class CronScheduler {
542
592
  /**
543
593
  * Get status of all cron jobs
544
594
  */
545
- getStatus(): CronJobStatus[] {
546
- const tasks = this.db.query(
547
- "SELECT id, name, status FROM cron_jobs ORDER BY id"
548
- ).all() as Array<{ id: string; name: string; status: string }>;
595
+ async getStatus(): Promise<CronJobStatus[]> {
596
+ const cronJobsCol = await col<CronJobDoc>("cronJobs");
597
+ const tasks = (await cronJobsCol.scan({})).map(e => e.doc).sort((a, b) => a.id.localeCompare(b.id));
549
598
 
550
599
  return tasks.map((task) => {
551
600
  const job = this.jobs.get(task.id);
@@ -563,15 +612,6 @@ export class CronScheduler {
563
612
  * Manually trigger a cron job execution
564
613
  */
565
614
  trigger(taskId: string): boolean {
566
- const task = this.db.query(
567
- "SELECT * FROM cron_jobs WHERE id = ?"
568
- ).get(taskId) as CronJob | undefined;
569
-
570
- if (!task) {
571
- log.warn(`[trigger] Job "${taskId}" not found`);
572
- return false;
573
- }
574
-
575
615
  const job = this.jobs.get(taskId);
576
616
  if (!job) {
577
617
  log.warn(`[trigger] Job "${taskId}" has no active job`);
@@ -597,10 +637,9 @@ export class CronScheduler {
597
637
  /**
598
638
  * Ensure the cleanup job exists
599
639
  */
600
- private ensureCleanupTask(): void {
601
- const existing = this.db.query(
602
- "SELECT id FROM cron_jobs WHERE name = '_hive_cleanup_runs'"
603
- ).get() as { id: string } | undefined;
640
+ private async ensureCleanupTask(): Promise<void> {
641
+ const cronJobsCol = await col<CronJobDoc>("cronJobs");
642
+ const existing = (await cronJobsCol.scan({})).find(e => e.doc.name === "_hive_cleanup_runs");
604
643
 
605
644
  if (existing) {
606
645
  this.cleanupTaskId = existing.id;
@@ -609,7 +648,7 @@ export class CronScheduler {
609
648
  }
610
649
 
611
650
  try {
612
- const result = this.create({
651
+ const result = await this.create({
613
652
  name: "_hive_cleanup_runs",
614
653
  task: "Automatic cleanup of old task_runs and completed one_shot jobs",
615
654
  task_type: "recurring",
@@ -629,71 +668,79 @@ export class CronScheduler {
629
668
  /**
630
669
  * Run cleanup - called by the internal cleanup job
631
670
  */
632
- runCleanup(): void {
671
+ async runCleanup(): Promise<void> {
633
672
  const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
634
- this.db.query(`
635
- DELETE FROM task_runs
636
- WHERE status IN ('success', 'failed') AND started_at < ?
637
- `).run(thirtyDaysAgo);
638
-
639
673
  const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
640
- this.db.query(`
641
- UPDATE cron_jobs
642
- SET status = 'cancelled'
643
- WHERE task_type = 'one_shot' AND status = 'completed' AND completed_at < ?
644
- `).run(sevenDaysAgo);
645
-
646
- const tasks = this.db.query(`
647
- SELECT DISTINCT task_id FROM task_runs
648
- `).all() as { task_id: string }[];
649
-
650
- for (const { task_id } of tasks) {
651
- this.db.query(`
652
- DELETE FROM task_runs
653
- WHERE task_id = ? AND id NOT IN (
654
- SELECT id FROM task_runs
655
- WHERE task_id = ?
656
- ORDER BY started_at DESC
657
- LIMIT 1000
658
- )
659
- `).run(task_id, task_id);
674
+
675
+ const taskRunsCol = await col<TaskRunDoc>("taskRuns");
676
+ const allRuns = await taskRunsCol.scan({});
677
+
678
+ const oldRuns = allRuns.filter(e =>
679
+ (e.doc.status === "success" || e.doc.status === "failed") && e.doc.started_at < thirtyDaysAgo
680
+ );
681
+ for (const run of oldRuns) {
682
+ await taskRunsCol.delete(run.id);
660
683
  }
661
684
 
662
- log.info("[runCleanup] Cleanup completed");
685
+ const cronJobsCol = await col<CronJobDoc>("cronJobs");
686
+ const allJobs = await cronJobsCol.scan({});
687
+ const staleOneShots = allJobs.filter(e =>
688
+ e.doc.task_type === "one_shot" && e.doc.status === "completed" &&
689
+ e.doc.completed_at !== null && e.doc.completed_at < sevenDaysAgo
690
+ );
691
+ for (const job of staleOneShots) {
692
+ await this.updateJob(job.id, { status: "cancelled" });
693
+ }
694
+
695
+ // Keep only the most recent 1000 runs per task
696
+ const remainingRuns = allRuns.filter(r => !oldRuns.includes(r));
697
+ const runsByTask = new Map<string, typeof remainingRuns>();
698
+ for (const run of remainingRuns) {
699
+ const list = runsByTask.get(run.doc.task_id) ?? [];
700
+ list.push(run);
701
+ runsByTask.set(run.doc.task_id, list);
702
+ }
703
+ for (const [, runs] of runsByTask) {
704
+ if (runs.length <= 1000) continue;
705
+ const toDelete = runs.sort((a, b) => b.doc.started_at.localeCompare(a.doc.started_at)).slice(1000);
706
+ for (const run of toDelete) {
707
+ await taskRunsCol.delete(run.id);
708
+ }
709
+ }
710
+
711
+ const artifacts = await expireArtifacts();
712
+ log.info(`[runCleanup] Cleanup completed (expired artifacts=${artifacts.expired})`);
663
713
  }
664
714
 
665
715
  /**
666
716
  * Get task run history
667
717
  */
668
- getHistory(taskId: string, limit = 50): TaskRun[] {
669
- return this.db.query(`
670
- SELECT * FROM task_runs
671
- WHERE task_id = ?
672
- ORDER BY started_at DESC
673
- LIMIT ?
674
- `).all(taskId, limit) as TaskRun[];
718
+ async getHistory(taskId: string, limit = 50): Promise<TaskRun[]> {
719
+ const taskRunsCol = await col<TaskRunDoc>("taskRuns");
720
+ const runs = (await taskRunsCol.scan({})).map(e => e.doc).filter(r => r.task_id === taskId);
721
+ runs.sort((a, b) => b.started_at.localeCompare(a.started_at));
722
+ return runs.slice(0, limit);
675
723
  }
676
724
 
677
725
  /**
678
726
  * Get a single cron job by ID
679
727
  */
680
- getTask(taskId: string): CronJob | null {
681
- return this.db.query(
682
- "SELECT * FROM cron_jobs WHERE id = ?"
683
- ).get(taskId) as CronJob | null;
728
+ async getTask(taskId: string): Promise<CronJob | null> {
729
+ const cronJobsCol = await col<CronJobDoc>("cronJobs");
730
+ const entry = await cronJobsCol.get(taskId);
731
+ return entry ? fromDoc(entry.doc) : null;
684
732
  }
685
733
 
686
734
  /**
687
735
  * List all cron jobs
688
736
  */
689
- listTasks(status?: string): CronJob[] {
690
- if (status) {
691
- return this.db.query(
692
- "SELECT * FROM cron_jobs WHERE status = ? ORDER BY next_run_at"
693
- ).all(status) as CronJob[];
694
- }
695
- return this.db.query(
696
- "SELECT * FROM cron_jobs ORDER BY next_run_at"
697
- ).all() as CronJob[];
737
+ async listTasks(status?: string): Promise<CronJob[]> {
738
+ const cronJobsCol = await col<CronJobDoc>("cronJobs");
739
+ const entries = status
740
+ ? await cronJobsCol.findBy("status", status)
741
+ : await cronJobsCol.scan({});
742
+ const tasks = entries.map(e => fromDoc(e.doc));
743
+ tasks.sort((a, b) => (a.next_run_at ?? "").localeCompare(b.next_run_at ?? ""));
744
+ return tasks;
698
745
  }
699
- }
746
+ }