@clinebot/core 0.0.36 → 0.0.37

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 (228) hide show
  1. package/dist/ClineCore.d.ts +312 -3
  2. package/dist/ClineCore.d.ts.map +1 -1
  3. package/dist/account/cline-account-service.d.ts.map +1 -1
  4. package/dist/cron/cron-event-ingress.d.ts +38 -0
  5. package/dist/cron/cron-event-ingress.d.ts.map +1 -0
  6. package/dist/cron/cron-materializer.d.ts +36 -0
  7. package/dist/cron/cron-materializer.d.ts.map +1 -0
  8. package/dist/cron/cron-reconciler.d.ts +62 -0
  9. package/dist/cron/cron-reconciler.d.ts.map +1 -0
  10. package/dist/cron/cron-report-writer.d.ts +41 -0
  11. package/dist/cron/cron-report-writer.d.ts.map +1 -0
  12. package/dist/cron/cron-runner.d.ts +43 -0
  13. package/dist/cron/cron-runner.d.ts.map +1 -0
  14. package/dist/cron/cron-schema.d.ts +3 -0
  15. package/dist/cron/cron-schema.d.ts.map +1 -0
  16. package/dist/cron/cron-service.d.ts +57 -0
  17. package/dist/cron/cron-service.d.ts.map +1 -0
  18. package/dist/cron/cron-spec-parser.d.ts +27 -0
  19. package/dist/cron/cron-spec-parser.d.ts.map +1 -0
  20. package/dist/cron/cron-watcher.d.ts +23 -0
  21. package/dist/cron/cron-watcher.d.ts.map +1 -0
  22. package/dist/cron/scheduler.d.ts +3 -1
  23. package/dist/cron/scheduler.d.ts.map +1 -1
  24. package/dist/cron/sqlite-cron-store.d.ts +230 -0
  25. package/dist/cron/sqlite-cron-store.d.ts.map +1 -0
  26. package/dist/extensions/plugin/plugin-config-loader.d.ts +7 -1
  27. package/dist/extensions/plugin/plugin-config-loader.d.ts.map +1 -1
  28. package/dist/extensions/plugin/plugin-loader.d.ts +10 -6
  29. package/dist/extensions/plugin/plugin-loader.d.ts.map +1 -1
  30. package/dist/extensions/plugin/plugin-sandbox.d.ts +7 -1
  31. package/dist/extensions/plugin/plugin-sandbox.d.ts.map +1 -1
  32. package/dist/extensions/plugin-sandbox-bootstrap.js +236 -275
  33. package/dist/extensions/tools/constants.d.ts +1 -0
  34. package/dist/extensions/tools/constants.d.ts.map +1 -1
  35. package/dist/extensions/tools/definitions.d.ts +2 -3
  36. package/dist/extensions/tools/definitions.d.ts.map +1 -1
  37. package/dist/extensions/tools/executors/editor.d.ts.map +1 -1
  38. package/dist/extensions/tools/helpers.d.ts +1 -0
  39. package/dist/extensions/tools/helpers.d.ts.map +1 -1
  40. package/dist/extensions/tools/index.d.ts +1 -2
  41. package/dist/extensions/tools/index.d.ts.map +1 -1
  42. package/dist/extensions/tools/presets.d.ts +1 -1
  43. package/dist/extensions/tools/schemas.d.ts +25 -3
  44. package/dist/extensions/tools/schemas.d.ts.map +1 -1
  45. package/dist/extensions/tools/team/delegated-agent.d.ts +2 -2
  46. package/dist/extensions/tools/team/delegated-agent.d.ts.map +1 -1
  47. package/dist/extensions/tools/team/multi-agent.d.ts +7 -3
  48. package/dist/extensions/tools/team/multi-agent.d.ts.map +1 -1
  49. package/dist/extensions/tools/team/team-tools.d.ts.map +1 -1
  50. package/dist/extensions/tools/types.d.ts +0 -5
  51. package/dist/extensions/tools/types.d.ts.map +1 -1
  52. package/dist/hooks/hook-bridge.d.ts +118 -0
  53. package/dist/hooks/hook-bridge.d.ts.map +1 -0
  54. package/dist/hooks/hook-file-hooks.d.ts +2 -1
  55. package/dist/hooks/hook-file-hooks.d.ts.map +1 -1
  56. package/dist/hooks/hook-registry.d.ts +16 -0
  57. package/dist/hooks/hook-registry.d.ts.map +1 -0
  58. package/dist/hub/browser-websocket.d.ts.map +1 -1
  59. package/dist/hub/client.d.ts +7 -1
  60. package/dist/hub/client.d.ts.map +1 -1
  61. package/dist/hub/daemon-entry.js +721 -461
  62. package/dist/hub/daemon.d.ts.map +1 -1
  63. package/dist/hub/defaults.d.ts +8 -4
  64. package/dist/hub/defaults.d.ts.map +1 -1
  65. package/dist/hub/index.js +665 -415
  66. package/dist/hub/runtime-handlers.d.ts.map +1 -1
  67. package/dist/hub/server.d.ts +18 -0
  68. package/dist/hub/server.d.ts.map +1 -1
  69. package/dist/hub/session-client.d.ts +3 -0
  70. package/dist/hub/session-client.d.ts.map +1 -1
  71. package/dist/hub/start-shared-server.d.ts.map +1 -1
  72. package/dist/hub/ui-client.d.ts +1 -0
  73. package/dist/hub/ui-client.d.ts.map +1 -1
  74. package/dist/index.d.ts +9 -7
  75. package/dist/index.d.ts.map +1 -1
  76. package/dist/index.js +756 -467
  77. package/dist/llms/cline-recommended-models.d.ts +20 -0
  78. package/dist/llms/cline-recommended-models.d.ts.map +1 -0
  79. package/dist/llms/handler-factory.d.ts +16 -0
  80. package/dist/llms/handler-factory.d.ts.map +1 -0
  81. package/dist/llms/provider-defaults.d.ts.map +1 -1
  82. package/dist/llms/provider-settings.d.ts +45 -2
  83. package/dist/llms/provider-settings.d.ts.map +1 -1
  84. package/dist/llms/runtime-registry.d.ts.map +1 -1
  85. package/dist/runtime/agent-config-adapter.d.ts +148 -0
  86. package/dist/runtime/agent-config-adapter.d.ts.map +1 -0
  87. package/dist/runtime/agent-runtime-config-builder.d.ts +96 -0
  88. package/dist/runtime/agent-runtime-config-builder.d.ts.map +1 -0
  89. package/dist/runtime/history.d.ts +6 -0
  90. package/dist/runtime/history.d.ts.map +1 -1
  91. package/dist/runtime/host.d.ts.map +1 -1
  92. package/dist/runtime/loop-detection.d.ts +59 -0
  93. package/dist/runtime/loop-detection.d.ts.map +1 -0
  94. package/dist/runtime/mistake-tracker.d.ts +69 -0
  95. package/dist/runtime/mistake-tracker.d.ts.map +1 -0
  96. package/dist/runtime/runtime-builder.d.ts.map +1 -1
  97. package/dist/runtime/runtime-event-adapter.d.ts +102 -0
  98. package/dist/runtime/runtime-event-adapter.d.ts.map +1 -0
  99. package/dist/runtime/runtime-host.d.ts +28 -3
  100. package/dist/runtime/runtime-host.d.ts.map +1 -1
  101. package/dist/runtime/session-runtime-orchestrator.d.ts +261 -0
  102. package/dist/runtime/session-runtime-orchestrator.d.ts.map +1 -0
  103. package/dist/runtime/session-runtime.d.ts +16 -3
  104. package/dist/runtime/session-runtime.d.ts.map +1 -1
  105. package/dist/runtime/user-input-builder.d.ts +24 -0
  106. package/dist/runtime/user-input-builder.d.ts.map +1 -0
  107. package/dist/services/index.js +28 -0
  108. package/dist/services/local-runtime-bootstrap.d.ts.map +1 -1
  109. package/dist/services/plugin-tools.d.ts.map +1 -1
  110. package/dist/services/providers/local-provider-registry.d.ts +197 -21
  111. package/dist/services/providers/local-provider-registry.d.ts.map +1 -1
  112. package/dist/services/providers/local-provider-service.d.ts +3 -1
  113. package/dist/services/providers/local-provider-service.d.ts.map +1 -1
  114. package/dist/services/session-data.d.ts.map +1 -1
  115. package/dist/services/session-telemetry.d.ts +7 -2
  116. package/dist/services/session-telemetry.d.ts.map +1 -1
  117. package/dist/services/storage/file-team-store.d.ts.map +1 -1
  118. package/dist/services/storage/provider-settings-legacy-migration.d.ts.map +1 -1
  119. package/dist/services/storage/provider-settings-manager.d.ts +1 -0
  120. package/dist/services/storage/provider-settings-manager.d.ts.map +1 -1
  121. package/dist/services/storage/sqlite-team-store.d.ts.map +1 -1
  122. package/dist/session/conversation-store.d.ts +30 -0
  123. package/dist/session/conversation-store.d.ts.map +1 -0
  124. package/dist/session/message-builder.d.ts +65 -0
  125. package/dist/session/message-builder.d.ts.map +1 -0
  126. package/dist/session/session-manifest.d.ts +1 -1
  127. package/dist/transports/hub.d.ts +14 -3
  128. package/dist/transports/hub.d.ts.map +1 -1
  129. package/dist/transports/local.d.ts +14 -4
  130. package/dist/transports/local.d.ts.map +1 -1
  131. package/dist/transports/remote.d.ts.map +1 -1
  132. package/dist/types/chat-schema.d.ts +5 -5
  133. package/dist/types/config.d.ts +9 -0
  134. package/dist/types/config.d.ts.map +1 -1
  135. package/dist/types/events.d.ts +7 -6
  136. package/dist/types/events.d.ts.map +1 -1
  137. package/dist/types/provider-settings.d.ts +2 -2
  138. package/dist/types/provider-settings.d.ts.map +1 -1
  139. package/dist/types/session.d.ts +5 -2
  140. package/dist/types/session.d.ts.map +1 -1
  141. package/dist/types.d.ts +4 -4
  142. package/dist/types.d.ts.map +1 -1
  143. package/package.json +4 -4
  144. package/src/ClineCore.ts +691 -6
  145. package/src/account/cline-account-service.ts +44 -6
  146. package/src/cron/cron-event-ingress.ts +357 -0
  147. package/src/cron/cron-materializer.ts +97 -0
  148. package/src/cron/cron-reconciler.ts +241 -0
  149. package/src/cron/cron-report-writer.ts +153 -0
  150. package/src/cron/cron-runner.ts +495 -0
  151. package/src/cron/cron-schema.ts +127 -0
  152. package/src/cron/cron-service.ts +163 -0
  153. package/src/cron/cron-spec-parser.ts +489 -0
  154. package/src/cron/cron-watcher.ts +102 -0
  155. package/src/cron/index.ts +10 -0
  156. package/src/cron/scheduler.ts +141 -6
  157. package/src/cron/sqlite-cron-store.ts +1286 -0
  158. package/src/extensions/plugin/plugin-config-loader.ts +21 -1
  159. package/src/extensions/plugin/plugin-loader.ts +25 -9
  160. package/src/extensions/plugin/plugin-sandbox-bootstrap.ts +151 -1
  161. package/src/extensions/plugin/plugin-sandbox.ts +131 -7
  162. package/src/extensions/tools/constants.ts +2 -0
  163. package/src/extensions/tools/definitions.ts +31 -22
  164. package/src/extensions/tools/executors/editor.ts +4 -3
  165. package/src/extensions/tools/helpers.ts +24 -0
  166. package/src/extensions/tools/index.ts +1 -2
  167. package/src/extensions/tools/presets.ts +1 -1
  168. package/src/extensions/tools/schemas.ts +13 -18
  169. package/src/extensions/tools/team/delegated-agent.ts +8 -3
  170. package/src/extensions/tools/team/multi-agent.ts +135 -19
  171. package/src/extensions/tools/team/team-tools.ts +151 -91
  172. package/src/extensions/tools/types.ts +0 -6
  173. package/src/hooks/hook-bridge.ts +489 -0
  174. package/src/hooks/hook-file-hooks.ts +58 -3
  175. package/src/hooks/hook-registry.ts +257 -0
  176. package/src/hub/browser-websocket.ts +26 -4
  177. package/src/hub/client.ts +72 -13
  178. package/src/hub/daemon-entry.ts +35 -0
  179. package/src/hub/daemon.ts +117 -14
  180. package/src/hub/defaults.ts +39 -12
  181. package/src/hub/runtime-handlers.ts +4 -3
  182. package/src/hub/server.ts +506 -77
  183. package/src/hub/session-client.ts +43 -1
  184. package/src/hub/start-shared-server.ts +3 -0
  185. package/src/hub/ui-client.ts +4 -0
  186. package/src/index.ts +46 -1
  187. package/src/llms/cline-recommended-models.ts +167 -0
  188. package/src/llms/handler-factory.ts +56 -0
  189. package/src/llms/provider-defaults.ts +17 -1
  190. package/src/llms/provider-settings.ts +48 -1
  191. package/src/llms/runtime-registry.ts +1 -0
  192. package/src/runtime/agent-config-adapter.ts +636 -0
  193. package/src/runtime/agent-runtime-config-builder.ts +205 -0
  194. package/src/runtime/error-feedback.ts +142 -0
  195. package/src/runtime/history.ts +137 -0
  196. package/src/runtime/host.ts +22 -0
  197. package/src/runtime/loop-detection.ts +162 -0
  198. package/src/runtime/mistake-tracker.ts +221 -0
  199. package/src/runtime/runtime-builder.ts +61 -5
  200. package/src/runtime/runtime-event-adapter.ts +412 -0
  201. package/src/runtime/runtime-host.ts +45 -1
  202. package/src/runtime/session-runtime-orchestrator.ts +1253 -0
  203. package/src/runtime/session-runtime.ts +16 -2
  204. package/src/runtime/user-input-builder.ts +167 -0
  205. package/src/services/local-runtime-bootstrap.ts +128 -22
  206. package/src/services/plugin-tools.ts +1 -0
  207. package/src/services/providers/local-provider-registry.ts +273 -57
  208. package/src/services/providers/local-provider-service.ts +67 -7
  209. package/src/services/session-data.ts +16 -14
  210. package/src/services/session-telemetry.ts +6 -15
  211. package/src/services/storage/file-team-store.ts +1 -5
  212. package/src/services/storage/provider-settings-legacy-migration.ts +8 -47
  213. package/src/services/storage/provider-settings-manager.ts +16 -1
  214. package/src/services/storage/sqlite-team-store.ts +1 -5
  215. package/src/session/conversation-store.ts +77 -0
  216. package/src/session/message-builder.ts +941 -0
  217. package/src/transports/hub.ts +458 -33
  218. package/src/transports/local.ts +296 -65
  219. package/src/transports/remote.ts +1 -0
  220. package/src/types/config.ts +9 -0
  221. package/src/types/events.ts +8 -6
  222. package/src/types/index.ts +3 -0
  223. package/src/types/provider-settings.ts +8 -1
  224. package/src/types/session.ts +5 -2
  225. package/src/types.ts +15 -1
  226. package/dist/cron/index.d.ts +0 -6
  227. package/dist/cron/index.d.ts.map +0 -1
  228. package/dist/services/telemetry/index.js +0 -28
@@ -0,0 +1,1286 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import type {
3
+ AutomationEventEnvelope,
4
+ CronSpec,
5
+ CronSpecExtensionKind,
6
+ CronTriggerKind,
7
+ } from "@clinebot/shared";
8
+ import {
9
+ asOptionalString,
10
+ asString,
11
+ loadSqliteDb,
12
+ nowIso,
13
+ type SqliteDb,
14
+ } from "@clinebot/shared/db";
15
+ import { resolveCronDbPath } from "@clinebot/shared/storage";
16
+ import { ensureCronSchema } from "./cron-schema";
17
+ import { getNextCronTime } from "./scheduler";
18
+
19
+ /**
20
+ * Generalized cron/automation store backed by `cron.db`. Sessions stay in
21
+ * their own database (see @clinebot/shared `ensureSessionSchema`). cron_runs
22
+ * here absorb one-off, recurring, and event-driven work under one queue.
23
+ */
24
+
25
+ export type CronRunStatus =
26
+ | "queued"
27
+ | "running"
28
+ | "done"
29
+ | "failed"
30
+ | "cancelled";
31
+
32
+ export type CronRunTriggerKind =
33
+ | "one_off"
34
+ | "schedule"
35
+ | "event"
36
+ | "manual"
37
+ | "retry";
38
+
39
+ export type CronParseStatus = "valid" | "invalid";
40
+
41
+ export type CronEventProcessingStatus =
42
+ | "received"
43
+ | "unmatched"
44
+ | "queued"
45
+ | "suppressed"
46
+ | "failed";
47
+
48
+ export interface CronSpecRecord {
49
+ specId: string;
50
+ externalId: string;
51
+ sourcePath: string;
52
+ triggerKind: CronTriggerKind;
53
+ sourceMtimeMs?: number;
54
+ sourceHash?: string;
55
+ parseStatus: CronParseStatus;
56
+ parseError?: string;
57
+ enabled: boolean;
58
+ removed: boolean;
59
+ title: string;
60
+ prompt?: string;
61
+ workspaceRoot?: string;
62
+ scheduleExpr?: string;
63
+ timezone?: string;
64
+ eventType?: string;
65
+ filters?: Record<string, unknown>;
66
+ debounceSeconds?: number;
67
+ dedupeWindowSeconds?: number;
68
+ cooldownSeconds?: number;
69
+ mode?: string;
70
+ systemPrompt?: string;
71
+ providerId?: string;
72
+ modelId?: string;
73
+ maxIterations?: number;
74
+ timeoutSeconds?: number;
75
+ maxParallel?: number;
76
+ tools?: string[];
77
+ notesDirectory?: string;
78
+ extensions?: CronSpecExtensionKind[];
79
+ source?: string;
80
+ tags?: string[];
81
+ metadata?: Record<string, unknown>;
82
+ revision: number;
83
+ lastMaterializedRunId?: string;
84
+ lastRunAt?: string;
85
+ nextRunAt?: string;
86
+ createdAt: string;
87
+ updatedAt: string;
88
+ }
89
+
90
+ export interface CronRunRecord {
91
+ runId: string;
92
+ specId: string;
93
+ specRevision: number;
94
+ triggerKind: CronRunTriggerKind;
95
+ status: CronRunStatus;
96
+ claimToken?: string;
97
+ claimStartedAt?: string;
98
+ claimUntilAt?: string;
99
+ scheduledFor?: string;
100
+ triggerEventId?: string;
101
+ startedAt?: string;
102
+ completedAt?: string;
103
+ sessionId?: string;
104
+ reportPath?: string;
105
+ error?: string;
106
+ attemptCount: number;
107
+ createdAt: string;
108
+ updatedAt: string;
109
+ }
110
+
111
+ export interface CronEventLogRecord {
112
+ eventId: string;
113
+ eventType: string;
114
+ source: string;
115
+ subject?: string;
116
+ occurredAt: string;
117
+ receivedAt: string;
118
+ workspaceRoot?: string;
119
+ dedupeKey?: string;
120
+ payload?: Record<string, unknown>;
121
+ attributes?: Record<string, unknown>;
122
+ processingStatus: CronEventProcessingStatus;
123
+ matchedSpecCount: number;
124
+ queuedRunCount: number;
125
+ suppressedCount: number;
126
+ error?: string;
127
+ createdAt: string;
128
+ updatedAt: string;
129
+ }
130
+
131
+ export interface UpsertSpecInput {
132
+ externalId: string;
133
+ sourcePath: string;
134
+ triggerKind: CronTriggerKind;
135
+ sourceMtimeMs?: number;
136
+ sourceHash: string;
137
+ parseStatus: CronParseStatus;
138
+ parseError?: string;
139
+ spec?: CronSpec;
140
+ }
141
+
142
+ export interface UpsertSpecResult {
143
+ record: CronSpecRecord;
144
+ created: boolean;
145
+ revisionChanged: boolean;
146
+ }
147
+
148
+ export interface SqliteCronStoreOptions {
149
+ dbPath?: string;
150
+ }
151
+
152
+ function parseJsonRecord(
153
+ value: string | undefined,
154
+ ): Record<string, unknown> | undefined {
155
+ if (!value) return undefined;
156
+ try {
157
+ const parsed = JSON.parse(value) as unknown;
158
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
159
+ return parsed as Record<string, unknown>;
160
+ }
161
+ } catch {
162
+ // ignore
163
+ }
164
+ return undefined;
165
+ }
166
+
167
+ function parseJsonArray(
168
+ value: string | undefined,
169
+ options: { preserveEmpty?: boolean } = {},
170
+ ): string[] | undefined {
171
+ if (!value) return undefined;
172
+ try {
173
+ const parsed = JSON.parse(value) as unknown;
174
+ if (!Array.isArray(parsed)) return undefined;
175
+ const tags = parsed
176
+ .map((item) => (typeof item === "string" ? item.trim() : ""))
177
+ .filter((item) => item.length > 0);
178
+ if (options.preserveEmpty) {
179
+ return tags;
180
+ }
181
+ return tags.length > 0 ? tags : undefined;
182
+ } catch {
183
+ return undefined;
184
+ }
185
+ }
186
+
187
+ function toInt(value: unknown): number | undefined {
188
+ if (typeof value === "number" && Number.isFinite(value)) return value;
189
+ if (typeof value === "bigint") return Number(value);
190
+ return undefined;
191
+ }
192
+
193
+ function specToRecord(row: Record<string, unknown>): CronSpecRecord {
194
+ return {
195
+ specId: asString(row.spec_id),
196
+ externalId: asString(row.external_id),
197
+ sourcePath: asString(row.source_path),
198
+ triggerKind: asString(row.trigger_kind) as CronTriggerKind,
199
+ sourceMtimeMs: toInt(row.source_mtime_ms),
200
+ sourceHash: asOptionalString(row.source_hash),
201
+ parseStatus: asString(row.parse_status) === "invalid" ? "invalid" : "valid",
202
+ parseError: asOptionalString(row.parse_error),
203
+ enabled: Number(row.enabled ?? 0) === 1,
204
+ removed: Number(row.removed ?? 0) === 1,
205
+ title: asString(row.title),
206
+ prompt: asOptionalString(row.prompt),
207
+ workspaceRoot: asOptionalString(row.workspace_root),
208
+ scheduleExpr: asOptionalString(row.schedule_expr),
209
+ timezone: asOptionalString(row.timezone),
210
+ eventType: asOptionalString(row.event_type),
211
+ filters: parseJsonRecord(asOptionalString(row.filters_json)),
212
+ debounceSeconds: toInt(row.debounce_seconds),
213
+ dedupeWindowSeconds: toInt(row.dedupe_window_seconds),
214
+ cooldownSeconds: toInt(row.cooldown_seconds),
215
+ mode: asOptionalString(row.mode),
216
+ systemPrompt: asOptionalString(row.system_prompt),
217
+ providerId: asOptionalString(row.provider_id),
218
+ modelId: asOptionalString(row.model_id),
219
+ maxIterations: toInt(row.max_iterations),
220
+ timeoutSeconds: toInt(row.timeout_seconds),
221
+ maxParallel: toInt(row.max_parallel),
222
+ tools: parseJsonArray(asOptionalString(row.tools_json), {
223
+ preserveEmpty: true,
224
+ }),
225
+ notesDirectory: asOptionalString(row.notes_directory),
226
+ extensions: parseJsonArray(asOptionalString(row.extensions_json), {
227
+ preserveEmpty: true,
228
+ }) as CronSpecExtensionKind[] | undefined,
229
+ source: asOptionalString(row.source),
230
+ tags: parseJsonArray(asOptionalString(row.tags_json)),
231
+ metadata: parseJsonRecord(asOptionalString(row.metadata_json)),
232
+ revision: Number(row.revision ?? 1),
233
+ lastMaterializedRunId: asOptionalString(row.last_materialized_run_id),
234
+ lastRunAt: asOptionalString(row.last_run_at),
235
+ nextRunAt: asOptionalString(row.next_run_at),
236
+ createdAt: asString(row.created_at),
237
+ updatedAt: asString(row.updated_at),
238
+ };
239
+ }
240
+
241
+ function runToRecord(row: Record<string, unknown>): CronRunRecord {
242
+ return {
243
+ runId: asString(row.run_id),
244
+ specId: asString(row.spec_id),
245
+ specRevision: Number(row.spec_revision ?? 1),
246
+ triggerKind: asString(row.trigger_kind) as CronRunTriggerKind,
247
+ status: asString(row.status) as CronRunStatus,
248
+ claimToken: asOptionalString(row.claim_token),
249
+ claimStartedAt: asOptionalString(row.claim_started_at),
250
+ claimUntilAt: asOptionalString(row.claim_until_at),
251
+ scheduledFor: asOptionalString(row.scheduled_for),
252
+ triggerEventId: asOptionalString(row.trigger_event_id),
253
+ startedAt: asOptionalString(row.started_at),
254
+ completedAt: asOptionalString(row.completed_at),
255
+ sessionId: asOptionalString(row.session_id),
256
+ reportPath: asOptionalString(row.report_path),
257
+ error: asOptionalString(row.error),
258
+ attemptCount: Number(row.attempt_count ?? 0),
259
+ createdAt: asString(row.created_at),
260
+ updatedAt: asString(row.updated_at),
261
+ };
262
+ }
263
+
264
+ function eventLogToRecord(row: Record<string, unknown>): CronEventLogRecord {
265
+ return {
266
+ eventId: asString(row.event_id),
267
+ eventType: asString(row.event_type),
268
+ source: asString(row.source),
269
+ subject: asOptionalString(row.subject),
270
+ occurredAt: asString(row.occurred_at),
271
+ receivedAt: asString(row.received_at),
272
+ workspaceRoot: asOptionalString(row.workspace_root),
273
+ dedupeKey: asOptionalString(row.dedupe_key),
274
+ payload: parseJsonRecord(asOptionalString(row.payload_json)),
275
+ attributes: parseJsonRecord(asOptionalString(row.attributes_json)),
276
+ processingStatus: asString(
277
+ row.processing_status,
278
+ ) as CronEventProcessingStatus,
279
+ matchedSpecCount: Number(row.matched_spec_count ?? 0),
280
+ queuedRunCount: Number(row.queued_run_count ?? 0),
281
+ suppressedCount: Number(row.suppressed_count ?? 0),
282
+ error: asOptionalString(row.error),
283
+ createdAt: asString(row.created_at),
284
+ updatedAt: asString(row.updated_at),
285
+ };
286
+ }
287
+
288
+ function jsonOrNull(value: Record<string, unknown> | undefined): string | null {
289
+ return value ? JSON.stringify(value) : null;
290
+ }
291
+
292
+ const MEANINGFUL_FIELD_KEYS = [
293
+ "prompt",
294
+ "workspaceRoot",
295
+ "mode",
296
+ "systemPrompt",
297
+ "providerId",
298
+ "modelId",
299
+ "maxIterations",
300
+ "timeoutSeconds",
301
+ "maxParallel",
302
+ "tools",
303
+ "notesDirectory",
304
+ "extensions",
305
+ "source",
306
+ "scheduleExpr",
307
+ "timezone",
308
+ "eventType",
309
+ "filters",
310
+ "debounceSeconds",
311
+ "dedupeWindowSeconds",
312
+ "cooldownSeconds",
313
+ ] as const;
314
+
315
+ function normalizeForCompare(value: unknown): unknown {
316
+ if (value === undefined) return null;
317
+ if (value && typeof value === "object") {
318
+ return JSON.stringify(value);
319
+ }
320
+ return value;
321
+ }
322
+
323
+ function hasMeaningfulChange(
324
+ prev: CronSpecRecord,
325
+ nextValues: Record<string, unknown>,
326
+ prevEnabled: boolean,
327
+ nextEnabled: boolean,
328
+ ): boolean {
329
+ for (const key of MEANINGFUL_FIELD_KEYS) {
330
+ const prevVal = (prev as unknown as Record<string, unknown>)[key];
331
+ const nextVal = nextValues[key];
332
+ if (normalizeForCompare(prevVal) !== normalizeForCompare(nextVal)) {
333
+ return true;
334
+ }
335
+ }
336
+ if (prevEnabled === false && nextEnabled === true) return true;
337
+ return false;
338
+ }
339
+
340
+ function filenameStemFromPath(sourcePath: string): string {
341
+ const base = sourcePath.split("/").pop() ?? sourcePath;
342
+ return base
343
+ .replace(/\.event\.md$/, "")
344
+ .replace(/\.cron\.md$/, "")
345
+ .replace(/\.md$/, "");
346
+ }
347
+
348
+ export interface ListSpecsOptions {
349
+ triggerKind?: CronTriggerKind;
350
+ enabled?: boolean;
351
+ parseStatus?: CronParseStatus;
352
+ includeRemoved?: boolean;
353
+ limit?: number;
354
+ }
355
+
356
+ export interface ListRunsOptions {
357
+ specId?: string;
358
+ status?: CronRunStatus | CronRunStatus[];
359
+ limit?: number;
360
+ }
361
+
362
+ export interface ClaimRunOptions {
363
+ nowIso: string;
364
+ leaseMs: number;
365
+ limit?: number;
366
+ }
367
+
368
+ export interface ClaimedCronRun {
369
+ run: CronRunRecord;
370
+ claimToken: string;
371
+ claimUntilAt: string;
372
+ }
373
+
374
+ interface ClaimBoundUpdate {
375
+ runId: string;
376
+ claimToken: string;
377
+ }
378
+
379
+ export interface MaterializeScheduleRunResult {
380
+ queued: boolean;
381
+ run?: CronRunRecord;
382
+ nextRunAt?: string;
383
+ }
384
+
385
+ export interface EnqueueRunInput {
386
+ specId: string;
387
+ specRevision: number;
388
+ triggerKind: CronRunTriggerKind;
389
+ scheduledFor?: string;
390
+ triggerEventId?: string;
391
+ }
392
+
393
+ export interface InsertEventLogResult {
394
+ record: CronEventLogRecord;
395
+ created: boolean;
396
+ }
397
+
398
+ export interface ListEventLogsOptions {
399
+ eventType?: string;
400
+ source?: string;
401
+ processingStatus?: CronEventProcessingStatus;
402
+ limit?: number;
403
+ }
404
+
405
+ export class SqliteCronStore {
406
+ private readonly db: SqliteDb;
407
+
408
+ constructor(options: SqliteCronStoreOptions = {}) {
409
+ const path = options.dbPath ?? resolveCronDbPath();
410
+ this.db = loadSqliteDb(path);
411
+ ensureCronSchema(this.db);
412
+ }
413
+
414
+ public close(): void {
415
+ this.db.close?.();
416
+ }
417
+
418
+ public getSpecBySourcePath(sourcePath: string): CronSpecRecord | undefined {
419
+ const row = this.db
420
+ .prepare("SELECT * FROM cron_specs WHERE source_path = ?")
421
+ .get(sourcePath);
422
+ return row ? specToRecord(row) : undefined;
423
+ }
424
+
425
+ public getSpec(specId: string): CronSpecRecord | undefined {
426
+ const row = this.db
427
+ .prepare("SELECT * FROM cron_specs WHERE spec_id = ?")
428
+ .get(specId);
429
+ return row ? specToRecord(row) : undefined;
430
+ }
431
+
432
+ public getSpecByExternalId(externalId: string): CronSpecRecord | undefined {
433
+ const row = this.db
434
+ .prepare(
435
+ "SELECT * FROM cron_specs WHERE external_id = ? ORDER BY created_at ASC LIMIT 1",
436
+ )
437
+ .get(externalId);
438
+ return row ? specToRecord(row) : undefined;
439
+ }
440
+
441
+ public listSpecs(options: ListSpecsOptions = {}): CronSpecRecord[] {
442
+ const where: string[] = [];
443
+ const params: unknown[] = [];
444
+ if (options.triggerKind) {
445
+ where.push("trigger_kind = ?");
446
+ params.push(options.triggerKind);
447
+ }
448
+ if (typeof options.enabled === "boolean") {
449
+ where.push("enabled = ?");
450
+ params.push(options.enabled ? 1 : 0);
451
+ }
452
+ if (options.parseStatus) {
453
+ where.push("parse_status = ?");
454
+ params.push(options.parseStatus);
455
+ }
456
+ if (!options.includeRemoved) {
457
+ where.push("removed = 0");
458
+ }
459
+ const whereClause = where.length > 0 ? `WHERE ${where.join(" AND ")}` : "";
460
+ const limit = Math.max(1, Math.floor(options.limit ?? 500));
461
+ const rows = this.db
462
+ .prepare(
463
+ `SELECT * FROM cron_specs ${whereClause} ORDER BY created_at DESC LIMIT ?`,
464
+ )
465
+ .all(...params, limit);
466
+ return rows.map((row) => specToRecord(row));
467
+ }
468
+
469
+ public listEventSpecsForType(eventType: string): CronSpecRecord[] {
470
+ const rows = this.db
471
+ .prepare(
472
+ `SELECT * FROM cron_specs
473
+ WHERE trigger_kind = 'event'
474
+ AND event_type = ?
475
+ AND enabled = 1
476
+ AND removed = 0
477
+ AND parse_status = 'valid'
478
+ ORDER BY created_at ASC`,
479
+ )
480
+ .all(eventType);
481
+ return rows.map((row) => specToRecord(row));
482
+ }
483
+
484
+ public upsertSpec(input: UpsertSpecInput): UpsertSpecResult {
485
+ const now = nowIso();
486
+ const existing = this.getSpecBySourcePath(input.sourcePath);
487
+
488
+ const spec = input.spec;
489
+ const nextValues: Record<string, unknown> = {
490
+ title:
491
+ spec?.title ??
492
+ existing?.title ??
493
+ filenameStemFromPath(input.sourcePath),
494
+ prompt: spec?.prompt,
495
+ workspaceRoot: spec?.workspaceRoot,
496
+ scheduleExpr:
497
+ spec?.triggerKind === "schedule" ? spec.schedule : undefined,
498
+ timezone: spec?.triggerKind === "schedule" ? spec.timezone : undefined,
499
+ eventType: spec?.triggerKind === "event" ? spec.event : undefined,
500
+ filters: spec?.triggerKind === "event" ? spec.filters : undefined,
501
+ debounceSeconds:
502
+ spec?.triggerKind === "event" ? spec.debounceSeconds : undefined,
503
+ dedupeWindowSeconds:
504
+ spec?.triggerKind === "event" ? spec.dedupeWindowSeconds : undefined,
505
+ cooldownSeconds:
506
+ spec?.triggerKind === "event" ? spec.cooldownSeconds : undefined,
507
+ mode: spec?.mode,
508
+ systemPrompt: spec?.systemPrompt,
509
+ providerId: spec?.modelSelection?.providerId,
510
+ modelId: spec?.modelSelection?.modelId,
511
+ maxIterations: spec?.maxIterations,
512
+ timeoutSeconds: spec?.timeoutSeconds,
513
+ maxParallel: spec?.triggerKind === "event" ? spec.maxParallel : undefined,
514
+ tools: spec?.tools,
515
+ notesDirectory: spec?.notesDirectory,
516
+ extensions: spec?.extensions,
517
+ source: spec?.source,
518
+ };
519
+
520
+ const enabled = input.parseStatus === "valid" && (spec?.enabled ?? true);
521
+
522
+ if (!existing) {
523
+ const specId = `cspec_${randomUUID()}`;
524
+ this.insertSpecRow(specId, input, nextValues, enabled, now);
525
+ const record = this.getSpec(specId);
526
+ if (!record) throw new Error("failed to insert cron_spec row");
527
+ return { record, created: true, revisionChanged: true };
528
+ }
529
+
530
+ const hashChanged = existing.sourceHash !== input.sourceHash;
531
+ const revisionChanged =
532
+ hashChanged &&
533
+ hasMeaningfulChange(existing, nextValues, existing.enabled, enabled);
534
+ const revision = revisionChanged
535
+ ? existing.revision + 1
536
+ : existing.revision;
537
+ this.updateSpecRow(
538
+ existing.specId,
539
+ input,
540
+ nextValues,
541
+ enabled,
542
+ revision,
543
+ now,
544
+ );
545
+ const record = this.getSpec(existing.specId);
546
+ if (!record) throw new Error("failed to reload cron_spec after update");
547
+ return { record, created: false, revisionChanged };
548
+ }
549
+
550
+ private insertSpecRow(
551
+ specId: string,
552
+ input: UpsertSpecInput,
553
+ v: Record<string, unknown>,
554
+ enabled: boolean,
555
+ now: string,
556
+ ): void {
557
+ const spec = input.spec;
558
+ this.db
559
+ .prepare(
560
+ `INSERT INTO cron_specs (
561
+ spec_id, external_id, source_path, trigger_kind,
562
+ source_mtime_ms, source_hash, parse_status, parse_error,
563
+ enabled, removed, title, prompt, workspace_root,
564
+ schedule_expr, timezone, event_type, filters_json,
565
+ debounce_seconds, dedupe_window_seconds, cooldown_seconds,
566
+ mode, system_prompt, provider_id, model_id,
567
+ max_iterations, timeout_seconds, max_parallel,
568
+ tools_json, notes_directory, extensions_json, source,
569
+ tags_json, metadata_json, revision,
570
+ created_at, updated_at
571
+ ) VALUES (${Array.from({ length: 36 }, () => "?").join(",")})`,
572
+ )
573
+ .run(
574
+ specId,
575
+ input.externalId,
576
+ input.sourcePath,
577
+ input.triggerKind,
578
+ input.sourceMtimeMs ?? null,
579
+ input.sourceHash,
580
+ input.parseStatus,
581
+ input.parseError ?? null,
582
+ enabled ? 1 : 0,
583
+ 0,
584
+ (v.title as string) ?? "",
585
+ (v.prompt as string | undefined) ?? null,
586
+ (v.workspaceRoot as string | undefined) ?? null,
587
+ (v.scheduleExpr as string | undefined) ?? null,
588
+ (v.timezone as string | undefined) ?? null,
589
+ (v.eventType as string | undefined) ?? null,
590
+ v.filters ? JSON.stringify(v.filters) : null,
591
+ (v.debounceSeconds as number | undefined) ?? null,
592
+ (v.dedupeWindowSeconds as number | undefined) ?? null,
593
+ (v.cooldownSeconds as number | undefined) ?? null,
594
+ (v.mode as string | undefined) ?? null,
595
+ (v.systemPrompt as string | undefined) ?? null,
596
+ (v.providerId as string | undefined) ?? null,
597
+ (v.modelId as string | undefined) ?? null,
598
+ (v.maxIterations as number | undefined) ?? null,
599
+ (v.timeoutSeconds as number | undefined) ?? null,
600
+ (v.maxParallel as number | undefined) ?? null,
601
+ v.tools ? JSON.stringify(v.tools) : null,
602
+ (v.notesDirectory as string | undefined) ?? null,
603
+ v.extensions ? JSON.stringify(v.extensions) : null,
604
+ (v.source as string | undefined) ?? null,
605
+ spec?.tags ? JSON.stringify(spec.tags) : null,
606
+ spec?.metadata ? JSON.stringify(spec.metadata) : null,
607
+ 1,
608
+ now,
609
+ now,
610
+ );
611
+ }
612
+
613
+ private updateSpecRow(
614
+ specId: string,
615
+ input: UpsertSpecInput,
616
+ v: Record<string, unknown>,
617
+ enabled: boolean,
618
+ revision: number,
619
+ now: string,
620
+ ): void {
621
+ const spec = input.spec;
622
+ this.db
623
+ .prepare(
624
+ `UPDATE cron_specs SET
625
+ external_id = ?, trigger_kind = ?,
626
+ source_mtime_ms = ?, source_hash = ?, parse_status = ?, parse_error = ?,
627
+ enabled = ?, removed = 0, title = ?, prompt = ?,
628
+ workspace_root = ?, schedule_expr = ?, timezone = ?,
629
+ event_type = ?, filters_json = ?,
630
+ debounce_seconds = ?, dedupe_window_seconds = ?, cooldown_seconds = ?,
631
+ mode = ?, system_prompt = ?, provider_id = ?, model_id = ?,
632
+ max_iterations = ?, timeout_seconds = ?, max_parallel = ?,
633
+ tools_json = ?, notes_directory = ?, extensions_json = ?, source = ?,
634
+ tags_json = ?, metadata_json = ?,
635
+ revision = ?, updated_at = ?
636
+ WHERE spec_id = ?`,
637
+ )
638
+ .run(
639
+ input.externalId,
640
+ input.triggerKind,
641
+ input.sourceMtimeMs ?? null,
642
+ input.sourceHash,
643
+ input.parseStatus,
644
+ input.parseError ?? null,
645
+ enabled ? 1 : 0,
646
+ (v.title as string) ?? "",
647
+ (v.prompt as string | undefined) ?? null,
648
+ (v.workspaceRoot as string | undefined) ?? null,
649
+ (v.scheduleExpr as string | undefined) ?? null,
650
+ (v.timezone as string | undefined) ?? null,
651
+ (v.eventType as string | undefined) ?? null,
652
+ v.filters ? JSON.stringify(v.filters) : null,
653
+ (v.debounceSeconds as number | undefined) ?? null,
654
+ (v.dedupeWindowSeconds as number | undefined) ?? null,
655
+ (v.cooldownSeconds as number | undefined) ?? null,
656
+ (v.mode as string | undefined) ?? null,
657
+ (v.systemPrompt as string | undefined) ?? null,
658
+ (v.providerId as string | undefined) ?? null,
659
+ (v.modelId as string | undefined) ?? null,
660
+ (v.maxIterations as number | undefined) ?? null,
661
+ (v.timeoutSeconds as number | undefined) ?? null,
662
+ (v.maxParallel as number | undefined) ?? null,
663
+ v.tools ? JSON.stringify(v.tools) : null,
664
+ (v.notesDirectory as string | undefined) ?? null,
665
+ v.extensions ? JSON.stringify(v.extensions) : null,
666
+ (v.source as string | undefined) ?? null,
667
+ spec?.tags ? JSON.stringify(spec.tags) : null,
668
+ spec?.metadata ? JSON.stringify(spec.metadata) : null,
669
+ revision,
670
+ now,
671
+ specId,
672
+ );
673
+ }
674
+
675
+ public markSpecRemoved(specId: string): void {
676
+ this.db
677
+ .prepare(
678
+ `UPDATE cron_specs SET removed = 1, enabled = 0, updated_at = ? WHERE spec_id = ?`,
679
+ )
680
+ .run(nowIso(), specId);
681
+ }
682
+
683
+ public updateSpecNextRunAt(
684
+ specId: string,
685
+ nextRunAt: string | undefined,
686
+ ): void {
687
+ this.db
688
+ .prepare(
689
+ `UPDATE cron_specs SET next_run_at = ?, updated_at = ? WHERE spec_id = ?`,
690
+ )
691
+ .run(nextRunAt ?? null, nowIso(), specId);
692
+ }
693
+
694
+ public updateSpecLastRunAt(specId: string, lastRunAt: string): void {
695
+ this.db
696
+ .prepare(
697
+ `UPDATE cron_specs SET last_run_at = ?, updated_at = ? WHERE spec_id = ?`,
698
+ )
699
+ .run(lastRunAt, nowIso(), specId);
700
+ }
701
+
702
+ public updateLastMaterializedRunId(specId: string, runId: string): void {
703
+ this.db
704
+ .prepare(
705
+ `UPDATE cron_specs SET last_materialized_run_id = ?, updated_at = ? WHERE spec_id = ?`,
706
+ )
707
+ .run(runId, nowIso(), specId);
708
+ }
709
+
710
+ public materializeDueScheduleRun(options: {
711
+ specId: string;
712
+ nowMs: number;
713
+ }): MaterializeScheduleRunResult {
714
+ const nowMs = options.nowMs;
715
+ const now = new Date(nowMs).toISOString();
716
+ this.db.exec("BEGIN IMMEDIATE;");
717
+ try {
718
+ const row = this.db
719
+ .prepare("SELECT * FROM cron_specs WHERE spec_id = ?")
720
+ .get(options.specId);
721
+ if (!row) {
722
+ this.db.exec("COMMIT;");
723
+ return { queued: false };
724
+ }
725
+ const spec = specToRecord(row);
726
+ if (
727
+ spec.triggerKind !== "schedule" ||
728
+ !spec.enabled ||
729
+ spec.removed ||
730
+ spec.parseStatus !== "valid" ||
731
+ !spec.scheduleExpr
732
+ ) {
733
+ this.db.exec("COMMIT;");
734
+ return { queued: false };
735
+ }
736
+
737
+ const dueAt = spec.nextRunAt;
738
+ if (!dueAt) {
739
+ const initializedNext = new Date(
740
+ getNextCronTime(spec.scheduleExpr, nowMs, spec.timezone),
741
+ ).toISOString();
742
+ this.db
743
+ .prepare(
744
+ `UPDATE cron_specs SET next_run_at = ?, updated_at = ? WHERE spec_id = ?`,
745
+ )
746
+ .run(initializedNext, now, spec.specId);
747
+ this.db.exec("COMMIT;");
748
+ return { queued: false, nextRunAt: initializedNext };
749
+ }
750
+
751
+ if (new Date(dueAt).getTime() > nowMs) {
752
+ this.db.exec("COMMIT;");
753
+ return { queued: false, nextRunAt: dueAt };
754
+ }
755
+
756
+ const runId = `crun_${randomUUID()}`;
757
+ let nextRunAt: string | undefined;
758
+ try {
759
+ nextRunAt = new Date(
760
+ getNextCronTime(spec.scheduleExpr, nowMs, spec.timezone),
761
+ ).toISOString();
762
+ } catch {
763
+ nextRunAt = undefined;
764
+ }
765
+
766
+ this.db
767
+ .prepare(
768
+ `INSERT INTO cron_runs (
769
+ run_id, spec_id, spec_revision, trigger_kind, status,
770
+ scheduled_for, trigger_event_id, attempt_count,
771
+ created_at, updated_at
772
+ ) VALUES (?,?,?,?,?, ?,?,?, ?,?)`,
773
+ )
774
+ .run(
775
+ runId,
776
+ spec.specId,
777
+ spec.revision,
778
+ "schedule",
779
+ "queued",
780
+ dueAt,
781
+ null,
782
+ 0,
783
+ now,
784
+ now,
785
+ );
786
+ this.db
787
+ .prepare(
788
+ `UPDATE cron_specs SET
789
+ last_materialized_run_id = ?,
790
+ last_run_at = ?,
791
+ next_run_at = ?,
792
+ updated_at = ?
793
+ WHERE spec_id = ?`,
794
+ )
795
+ .run(runId, now, nextRunAt ?? null, now, spec.specId);
796
+ this.db.exec("COMMIT;");
797
+ return {
798
+ queued: true,
799
+ run: this.getRun(runId),
800
+ nextRunAt,
801
+ };
802
+ } catch (err) {
803
+ this.db.exec("ROLLBACK;");
804
+ throw err;
805
+ }
806
+ }
807
+
808
+ public getRun(runId: string): CronRunRecord | undefined {
809
+ const row = this.db
810
+ .prepare("SELECT * FROM cron_runs WHERE run_id = ?")
811
+ .get(runId);
812
+ return row ? runToRecord(row) : undefined;
813
+ }
814
+
815
+ public insertEventLog(
816
+ event: AutomationEventEnvelope,
817
+ options: { receivedAtIso?: string } = {},
818
+ ): InsertEventLogResult {
819
+ const now = nowIso();
820
+ const receivedAt = options.receivedAtIso ?? now;
821
+ const eventId = event.eventId.trim();
822
+ if (!eventId) {
823
+ throw new Error("automation event requires eventId");
824
+ }
825
+ const eventType = event.eventType.trim();
826
+ if (!eventType) {
827
+ throw new Error("automation event requires eventType");
828
+ }
829
+ const source = event.source.trim();
830
+ if (!source) {
831
+ throw new Error("automation event requires source");
832
+ }
833
+ const occurredAt = event.occurredAt.trim() || receivedAt;
834
+ const changes =
835
+ this.db
836
+ .prepare(
837
+ `INSERT OR IGNORE INTO cron_event_log (
838
+ event_id, event_type, source, subject,
839
+ occurred_at, received_at, workspace_root, dedupe_key,
840
+ payload_json, attributes_json, processing_status,
841
+ matched_spec_count, queued_run_count, suppressed_count,
842
+ error, created_at, updated_at
843
+ ) VALUES (?,?,?,?, ?,?,?,?, ?,?,?, ?,?,?, ?,?,?)`,
844
+ )
845
+ .run(
846
+ eventId,
847
+ eventType,
848
+ source,
849
+ event.subject?.trim() || null,
850
+ occurredAt,
851
+ receivedAt,
852
+ event.workspaceRoot?.trim() || null,
853
+ event.dedupeKey?.trim() || null,
854
+ jsonOrNull(event.payload),
855
+ jsonOrNull(event.attributes),
856
+ "received",
857
+ 0,
858
+ 0,
859
+ 0,
860
+ null,
861
+ now,
862
+ now,
863
+ ).changes ?? 0;
864
+ const record = this.getEventLog(eventId);
865
+ if (!record) throw new Error("failed to insert cron_event_log row");
866
+ return { record, created: changes === 1 };
867
+ }
868
+
869
+ public getEventLog(eventId: string): CronEventLogRecord | undefined {
870
+ const row = this.db
871
+ .prepare("SELECT * FROM cron_event_log WHERE event_id = ?")
872
+ .get(eventId);
873
+ return row ? eventLogToRecord(row) : undefined;
874
+ }
875
+
876
+ public listEventLogs(
877
+ options: ListEventLogsOptions = {},
878
+ ): CronEventLogRecord[] {
879
+ const where: string[] = [];
880
+ const params: unknown[] = [];
881
+ if (options.eventType) {
882
+ where.push("event_type = ?");
883
+ params.push(options.eventType);
884
+ }
885
+ if (options.source) {
886
+ where.push("source = ?");
887
+ params.push(options.source);
888
+ }
889
+ if (options.processingStatus) {
890
+ where.push("processing_status = ?");
891
+ params.push(options.processingStatus);
892
+ }
893
+ const whereClause = where.length > 0 ? `WHERE ${where.join(" AND ")}` : "";
894
+ const limit = Math.max(1, Math.floor(options.limit ?? 200));
895
+ const rows = this.db
896
+ .prepare(
897
+ `SELECT * FROM cron_event_log ${whereClause}
898
+ ORDER BY received_at DESC, created_at DESC
899
+ LIMIT ?`,
900
+ )
901
+ .all(...params, limit);
902
+ return rows.map((row) => eventLogToRecord(row));
903
+ }
904
+
905
+ public updateEventLogProcessing(
906
+ eventId: string,
907
+ update: {
908
+ status: CronEventProcessingStatus;
909
+ matchedSpecCount?: number;
910
+ queuedRunCount?: number;
911
+ suppressedCount?: number;
912
+ error?: string;
913
+ },
914
+ ): boolean {
915
+ const changes =
916
+ this.db
917
+ .prepare(
918
+ `UPDATE cron_event_log SET
919
+ processing_status = ?,
920
+ matched_spec_count = COALESCE(?, matched_spec_count),
921
+ queued_run_count = COALESCE(?, queued_run_count),
922
+ suppressed_count = COALESCE(?, suppressed_count),
923
+ error = ?,
924
+ updated_at = ?
925
+ WHERE event_id = ?`,
926
+ )
927
+ .run(
928
+ update.status,
929
+ update.matchedSpecCount ?? null,
930
+ update.queuedRunCount ?? null,
931
+ update.suppressedCount ?? null,
932
+ update.error ?? null,
933
+ nowIso(),
934
+ eventId,
935
+ ).changes ?? 0;
936
+ return changes === 1;
937
+ }
938
+
939
+ public listRuns(options: ListRunsOptions = {}): CronRunRecord[] {
940
+ const where: string[] = [];
941
+ const params: unknown[] = [];
942
+ if (options.specId) {
943
+ where.push("spec_id = ?");
944
+ params.push(options.specId);
945
+ }
946
+ if (options.status) {
947
+ const statuses = Array.isArray(options.status)
948
+ ? options.status
949
+ : [options.status];
950
+ if (statuses.length > 0) {
951
+ const placeholders = statuses.map(() => "?").join(",");
952
+ where.push(`status IN (${placeholders})`);
953
+ for (const s of statuses) params.push(s);
954
+ }
955
+ }
956
+ const whereClause = where.length > 0 ? `WHERE ${where.join(" AND ")}` : "";
957
+ const limit = Math.max(1, Math.floor(options.limit ?? 200));
958
+ const rows = this.db
959
+ .prepare(
960
+ `SELECT * FROM cron_runs ${whereClause} ORDER BY created_at DESC LIMIT ?`,
961
+ )
962
+ .all(...params, limit);
963
+ return rows.map((row) => runToRecord(row));
964
+ }
965
+
966
+ public hasRecentEventRunForDedupe(options: {
967
+ specId: string;
968
+ dedupeKey: string;
969
+ sinceIso: string;
970
+ }): boolean {
971
+ const row = this.db
972
+ .prepare(
973
+ `SELECT r.run_id FROM cron_runs r
974
+ INNER JOIN cron_event_log e ON e.event_id = r.trigger_event_id
975
+ WHERE r.spec_id = ?
976
+ AND r.trigger_kind = 'event'
977
+ AND e.dedupe_key = ?
978
+ AND e.received_at >= ?
979
+ LIMIT 1`,
980
+ )
981
+ .get(options.specId, options.dedupeKey, options.sinceIso);
982
+ return !!row;
983
+ }
984
+
985
+ public hasRecentEventRunForSpec(options: {
986
+ specId: string;
987
+ sinceIso: string;
988
+ }): boolean {
989
+ const row = this.db
990
+ .prepare(
991
+ `SELECT r.run_id FROM cron_runs r
992
+ INNER JOIN cron_event_log e ON e.event_id = r.trigger_event_id
993
+ WHERE r.spec_id = ?
994
+ AND r.trigger_kind = 'event'
995
+ AND e.received_at >= ?
996
+ LIMIT 1`,
997
+ )
998
+ .get(options.specId, options.sinceIso);
999
+ return !!row;
1000
+ }
1001
+
1002
+ public findQueuedEventRunForDedupe(options: {
1003
+ specId: string;
1004
+ dedupeKey: string;
1005
+ }): CronRunRecord | undefined {
1006
+ const row = this.db
1007
+ .prepare(
1008
+ `SELECT r.* FROM cron_runs r
1009
+ INNER JOIN cron_event_log e ON e.event_id = r.trigger_event_id
1010
+ WHERE r.spec_id = ?
1011
+ AND r.trigger_kind = 'event'
1012
+ AND r.status = 'queued'
1013
+ AND e.dedupe_key = ?
1014
+ ORDER BY COALESCE(r.scheduled_for, r.created_at) DESC
1015
+ LIMIT 1`,
1016
+ )
1017
+ .get(options.specId, options.dedupeKey);
1018
+ return row ? runToRecord(row) : undefined;
1019
+ }
1020
+
1021
+ public updateQueuedEventRunForDebounce(options: {
1022
+ runId: string;
1023
+ triggerEventId: string;
1024
+ scheduledFor: string;
1025
+ }): CronRunRecord | undefined {
1026
+ const updatedAt = nowIso();
1027
+ const changes =
1028
+ this.db
1029
+ .prepare(
1030
+ `UPDATE cron_runs SET
1031
+ trigger_event_id = ?,
1032
+ scheduled_for = ?,
1033
+ updated_at = ?
1034
+ WHERE run_id = ?
1035
+ AND trigger_kind = 'event'
1036
+ AND status = 'queued'`,
1037
+ )
1038
+ .run(
1039
+ options.triggerEventId,
1040
+ options.scheduledFor,
1041
+ updatedAt,
1042
+ options.runId,
1043
+ ).changes ?? 0;
1044
+ if (changes !== 1) return undefined;
1045
+ return this.getRun(options.runId);
1046
+ }
1047
+
1048
+ public hasOneOffRunForRevision(specId: string, revision: number): boolean {
1049
+ const row = this.db
1050
+ .prepare(
1051
+ `SELECT run_id FROM cron_runs
1052
+ WHERE spec_id = ? AND spec_revision = ?
1053
+ AND trigger_kind = 'one_off'
1054
+ LIMIT 1`,
1055
+ )
1056
+ .get(specId, revision);
1057
+ return !!row;
1058
+ }
1059
+
1060
+ public enqueueRun(input: EnqueueRunInput): CronRunRecord {
1061
+ const runId = `crun_${randomUUID()}`;
1062
+ const now = nowIso();
1063
+ this.db
1064
+ .prepare(
1065
+ `INSERT INTO cron_runs (
1066
+ run_id, spec_id, spec_revision, trigger_kind, status,
1067
+ scheduled_for, trigger_event_id, attempt_count,
1068
+ created_at, updated_at
1069
+ ) VALUES (?,?,?,?,?, ?,?,?, ?,?)`,
1070
+ )
1071
+ .run(
1072
+ runId,
1073
+ input.specId,
1074
+ input.specRevision,
1075
+ input.triggerKind,
1076
+ "queued",
1077
+ input.scheduledFor ?? null,
1078
+ input.triggerEventId ?? null,
1079
+ 0,
1080
+ now,
1081
+ now,
1082
+ );
1083
+ this.updateLastMaterializedRunId(input.specId, runId);
1084
+ const run = this.getRun(runId);
1085
+ if (!run) throw new Error("failed to insert cron_run row");
1086
+ return run;
1087
+ }
1088
+
1089
+ public cancelQueuedRunsForSpec(specId: string): number {
1090
+ const changes =
1091
+ this.db
1092
+ .prepare(
1093
+ `UPDATE cron_runs SET status = 'cancelled', updated_at = ?
1094
+ WHERE spec_id = ? AND status = 'queued'`,
1095
+ )
1096
+ .run(nowIso(), specId).changes ?? 0;
1097
+ return changes;
1098
+ }
1099
+
1100
+ public claimDueRuns(options: ClaimRunOptions): ClaimedCronRun[] {
1101
+ const referenceIso = options.nowIso;
1102
+ const boundedLease = Math.max(1_000, Math.floor(options.leaseMs));
1103
+ const leaseUntilIso = new Date(
1104
+ new Date(referenceIso).getTime() + boundedLease,
1105
+ ).toISOString();
1106
+ const limit = Math.max(1, Math.floor(options.limit ?? 25));
1107
+ const claimed: ClaimedCronRun[] = [];
1108
+ this.db.exec("BEGIN IMMEDIATE;");
1109
+ try {
1110
+ const rows = this.db
1111
+ .prepare(
1112
+ `SELECT * FROM cron_runs
1113
+ WHERE (
1114
+ status = 'queued'
1115
+ OR (
1116
+ status = 'running'
1117
+ AND claim_until_at IS NOT NULL
1118
+ AND claim_until_at <= ?
1119
+ AND completed_at IS NULL
1120
+ )
1121
+ )
1122
+ AND (scheduled_for IS NULL OR scheduled_for <= ?)
1123
+ ORDER BY COALESCE(scheduled_for, created_at) ASC
1124
+ LIMIT ?`,
1125
+ )
1126
+ .all(referenceIso, referenceIso, limit);
1127
+ for (const row of rows) {
1128
+ const runId = asString(row.run_id);
1129
+ if (!runId) continue;
1130
+ const claimToken = `cclaim_${randomUUID()}`;
1131
+ const changes =
1132
+ this.db
1133
+ .prepare(
1134
+ `UPDATE cron_runs SET
1135
+ status = 'running',
1136
+ claim_token = ?,
1137
+ claim_started_at = ?,
1138
+ claim_until_at = ?,
1139
+ started_at = ?,
1140
+ completed_at = NULL,
1141
+ session_id = NULL,
1142
+ report_path = NULL,
1143
+ error = NULL,
1144
+ attempt_count = attempt_count + 1,
1145
+ updated_at = ?
1146
+ WHERE run_id = ?
1147
+ AND (
1148
+ status = 'queued'
1149
+ OR (
1150
+ status = 'running'
1151
+ AND claim_until_at IS NOT NULL
1152
+ AND claim_until_at <= ?
1153
+ AND completed_at IS NULL
1154
+ )
1155
+ )`,
1156
+ )
1157
+ .run(
1158
+ claimToken,
1159
+ referenceIso,
1160
+ leaseUntilIso,
1161
+ referenceIso,
1162
+ referenceIso,
1163
+ runId,
1164
+ referenceIso,
1165
+ ).changes ?? 0;
1166
+ if (changes !== 1) continue;
1167
+ const run = this.getRun(runId);
1168
+ if (!run) continue;
1169
+ claimed.push({ run, claimToken, claimUntilAt: leaseUntilIso });
1170
+ }
1171
+ this.db.exec("COMMIT;");
1172
+ } catch (err) {
1173
+ this.db.exec("ROLLBACK;");
1174
+ throw err;
1175
+ }
1176
+ return claimed;
1177
+ }
1178
+
1179
+ public renewClaim(
1180
+ runId: string,
1181
+ claimToken: string,
1182
+ leaseUntilAt: string,
1183
+ ): boolean {
1184
+ const changes =
1185
+ this.db
1186
+ .prepare(
1187
+ `UPDATE cron_runs SET claim_until_at = ?, updated_at = ?
1188
+ WHERE run_id = ? AND claim_token = ?`,
1189
+ )
1190
+ .run(leaseUntilAt, nowIso(), runId, claimToken).changes ?? 0;
1191
+ return changes === 1;
1192
+ }
1193
+
1194
+ public completeRun(
1195
+ runId: string,
1196
+ update: {
1197
+ status: "done" | "failed" | "cancelled";
1198
+ sessionId?: string;
1199
+ reportPath?: string;
1200
+ error?: string;
1201
+ completedAtIso?: string;
1202
+ claimToken?: string;
1203
+ },
1204
+ ): boolean {
1205
+ const completedAt = update.completedAtIso ?? nowIso();
1206
+ const whereClause = update.claimToken
1207
+ ? "WHERE run_id = ? AND claim_token = ?"
1208
+ : "WHERE run_id = ?";
1209
+ const changes =
1210
+ this.db
1211
+ .prepare(
1212
+ `UPDATE cron_runs SET
1213
+ status = ?,
1214
+ session_id = COALESCE(?, session_id),
1215
+ report_path = COALESCE(?, report_path),
1216
+ error = ?,
1217
+ completed_at = ?,
1218
+ claim_started_at = NULL,
1219
+ claim_token = NULL,
1220
+ claim_until_at = NULL,
1221
+ updated_at = ?
1222
+ ${whereClause}`,
1223
+ )
1224
+ .run(
1225
+ update.status,
1226
+ update.sessionId ?? null,
1227
+ update.reportPath ?? null,
1228
+ update.error ?? null,
1229
+ completedAt,
1230
+ completedAt,
1231
+ runId,
1232
+ ...(update.claimToken ? [update.claimToken] : []),
1233
+ ).changes ?? 0;
1234
+ return changes > 0;
1235
+ }
1236
+
1237
+ public requeueRun(
1238
+ update: ClaimBoundUpdate & {
1239
+ error?: string;
1240
+ scheduledFor?: string;
1241
+ },
1242
+ ): boolean {
1243
+ const updatedAt = nowIso();
1244
+ const changes =
1245
+ this.db
1246
+ .prepare(
1247
+ `UPDATE cron_runs SET
1248
+ status = 'queued',
1249
+ claim_started_at = NULL,
1250
+ claim_token = NULL,
1251
+ claim_until_at = NULL,
1252
+ started_at = NULL,
1253
+ completed_at = NULL,
1254
+ session_id = NULL,
1255
+ report_path = NULL,
1256
+ error = ?,
1257
+ scheduled_for = COALESCE(?, scheduled_for),
1258
+ updated_at = ?
1259
+ WHERE run_id = ? AND claim_token = ?`,
1260
+ )
1261
+ .run(
1262
+ update.error ?? null,
1263
+ update.scheduledFor ?? null,
1264
+ updatedAt,
1265
+ update.runId,
1266
+ update.claimToken,
1267
+ ).changes ?? 0;
1268
+ return changes > 0;
1269
+ }
1270
+
1271
+ public attachSessionIdToRun(runId: string, sessionId: string): void {
1272
+ this.db
1273
+ .prepare(
1274
+ `UPDATE cron_runs SET session_id = ?, updated_at = ? WHERE run_id = ?`,
1275
+ )
1276
+ .run(sessionId, nowIso(), runId);
1277
+ }
1278
+
1279
+ public attachReportPathToRun(runId: string, reportPath: string): void {
1280
+ this.db
1281
+ .prepare(
1282
+ `UPDATE cron_runs SET report_path = ?, updated_at = ? WHERE run_id = ?`,
1283
+ )
1284
+ .run(reportPath, nowIso(), runId);
1285
+ }
1286
+ }