@femtomc/mu-server 26.2.56 → 26.2.57

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.
@@ -2,7 +2,7 @@ import { type MessagingOperatorBackend, MessagingOperatorRuntime } from "@femtom
2
2
  import { type Channel, type GenerationTelemetryRecorder, type ReloadableGenerationIdentity } from "@femtomc/mu-control-plane";
3
3
  import { type MuConfig } from "./config.js";
4
4
  import type { ActivityHeartbeatScheduler } from "./heartbeat_scheduler.js";
5
- import { type ControlPlaneRunHeartbeatResult, type ControlPlaneRunInterruptResult, type ControlPlaneRunSnapshot, type ControlPlaneRunTrace } from "./run_supervisor.js";
5
+ import { type ControlPlaneRunHeartbeatResult, type ControlPlaneRunInterruptResult, type ControlPlaneRunSnapshot, type ControlPlaneRunSupervisorOpts, type ControlPlaneRunTrace } from "./run_supervisor.js";
6
6
  export type ActiveAdapter = {
7
7
  name: Channel;
8
8
  route: string;
@@ -70,6 +70,7 @@ export type ControlPlaneHandle = {
70
70
  jobId?: string | null;
71
71
  rootIssueId?: string | null;
72
72
  reason?: string | null;
73
+ wakeMode?: string | null;
73
74
  }): Promise<ControlPlaneRunHeartbeatResult>;
74
75
  traceRun?(opts: {
75
76
  idOrRoot: string;
@@ -132,6 +133,8 @@ export type BootstrapControlPlaneOpts = {
132
133
  operatorRuntime?: MessagingOperatorRuntime | null;
133
134
  operatorBackend?: MessagingOperatorBackend;
134
135
  heartbeatScheduler?: ActivityHeartbeatScheduler;
136
+ runSupervisorSpawnProcess?: ControlPlaneRunSupervisorOpts["spawnProcess"];
137
+ runSupervisorHeartbeatIntervalMs?: number;
135
138
  generation?: ControlPlaneGenerationContext;
136
139
  telemetry?: GenerationTelemetryRecorder | null;
137
140
  telegramGenerationHooks?: TelegramGenerationSwapHooks;
@@ -728,6 +728,8 @@ export async function bootstrapControlPlane(opts) {
728
728
  runSupervisor = new ControlPlaneRunSupervisor({
729
729
  repoRoot: opts.repoRoot,
730
730
  heartbeatScheduler: opts.heartbeatScheduler,
731
+ heartbeatIntervalMs: opts.runSupervisorHeartbeatIntervalMs,
732
+ spawnProcess: opts.runSupervisorSpawnProcess,
731
733
  onEvent: async (event) => {
732
734
  const outboxRecord = await enqueueRunEventOutbox({
733
735
  outbox,
@@ -0,0 +1,122 @@
1
+ import type { JsonlStore } from "@femtomc/mu-core";
2
+ import { type CronProgramSchedule } from "./cron_schedule.js";
3
+ import { CronTimerRegistry } from "./cron_timer.js";
4
+ import type { ActivityHeartbeatScheduler } from "./heartbeat_scheduler.js";
5
+ export type CronProgramTarget = {
6
+ kind: "run";
7
+ job_id: string | null;
8
+ root_issue_id: string | null;
9
+ } | {
10
+ kind: "activity";
11
+ activity_id: string;
12
+ };
13
+ export type CronProgramWakeMode = "immediate" | "next_heartbeat";
14
+ export type CronProgramSnapshot = {
15
+ v: 1;
16
+ program_id: string;
17
+ title: string;
18
+ enabled: boolean;
19
+ schedule: CronProgramSchedule;
20
+ reason: string;
21
+ wake_mode: CronProgramWakeMode;
22
+ target: CronProgramTarget;
23
+ metadata: Record<string, unknown>;
24
+ created_at_ms: number;
25
+ updated_at_ms: number;
26
+ next_run_at_ms: number | null;
27
+ last_triggered_at_ms: number | null;
28
+ last_result: "ok" | "not_found" | "not_running" | "failed" | null;
29
+ last_error: string | null;
30
+ };
31
+ export type CronProgramLifecycleAction = "created" | "updated" | "deleted" | "scheduled" | "disabled" | "oneshot_completed";
32
+ export type CronProgramLifecycleEvent = {
33
+ ts_ms: number;
34
+ action: CronProgramLifecycleAction;
35
+ program_id: string;
36
+ message: string;
37
+ program: CronProgramSnapshot | null;
38
+ };
39
+ export type CronProgramTickEvent = {
40
+ ts_ms: number;
41
+ program_id: string;
42
+ message: string;
43
+ status: "ok" | "not_found" | "not_running" | "failed";
44
+ reason: string | null;
45
+ program: CronProgramSnapshot;
46
+ };
47
+ export type CronProgramOperationResult = {
48
+ ok: boolean;
49
+ reason: "not_found" | "missing_target" | "invalid_target" | "invalid_schedule" | "not_running" | "failed" | null;
50
+ program: CronProgramSnapshot | null;
51
+ };
52
+ export type CronProgramStatusSnapshot = {
53
+ count: number;
54
+ enabled_count: number;
55
+ armed_count: number;
56
+ armed: Array<{
57
+ program_id: string;
58
+ due_at_ms: number;
59
+ }>;
60
+ };
61
+ export type CronProgramRegistryOpts = {
62
+ repoRoot: string;
63
+ heartbeatScheduler: ActivityHeartbeatScheduler;
64
+ nowMs?: () => number;
65
+ timer?: CronTimerRegistry;
66
+ store?: JsonlStore<CronProgramSnapshot>;
67
+ runHeartbeat: (opts: {
68
+ jobId?: string | null;
69
+ rootIssueId?: string | null;
70
+ reason?: string | null;
71
+ wakeMode?: CronProgramWakeMode;
72
+ }) => Promise<{
73
+ ok: boolean;
74
+ reason: "not_found" | "not_running" | "missing_target" | null;
75
+ }>;
76
+ activityHeartbeat: (opts: {
77
+ activityId?: string | null;
78
+ reason?: string | null;
79
+ }) => Promise<{
80
+ ok: boolean;
81
+ reason: "not_found" | "not_running" | "missing_target" | null;
82
+ }>;
83
+ onTickEvent?: (event: CronProgramTickEvent) => void | Promise<void>;
84
+ onLifecycleEvent?: (event: CronProgramLifecycleEvent) => void | Promise<void>;
85
+ };
86
+ export declare class CronProgramRegistry {
87
+ #private;
88
+ constructor(opts: CronProgramRegistryOpts);
89
+ list(opts?: {
90
+ enabled?: boolean;
91
+ targetKind?: "run" | "activity";
92
+ scheduleKind?: "at" | "every" | "cron";
93
+ limit?: number;
94
+ }): Promise<CronProgramSnapshot[]>;
95
+ status(): Promise<CronProgramStatusSnapshot>;
96
+ get(programId: string): Promise<CronProgramSnapshot | null>;
97
+ create(opts: {
98
+ title: string;
99
+ target: CronProgramTarget;
100
+ schedule: unknown;
101
+ reason?: string;
102
+ wakeMode?: CronProgramWakeMode;
103
+ enabled?: boolean;
104
+ metadata?: Record<string, unknown>;
105
+ }): Promise<CronProgramSnapshot>;
106
+ update(opts: {
107
+ programId: string;
108
+ title?: string;
109
+ reason?: string;
110
+ wakeMode?: CronProgramWakeMode;
111
+ enabled?: boolean;
112
+ target?: CronProgramTarget;
113
+ schedule?: unknown;
114
+ metadata?: Record<string, unknown>;
115
+ }): Promise<CronProgramOperationResult>;
116
+ remove(programId: string): Promise<CronProgramOperationResult>;
117
+ trigger(opts: {
118
+ programId?: string | null;
119
+ reason?: string | null;
120
+ }): Promise<CronProgramOperationResult>;
121
+ stop(): void;
122
+ }
@@ -0,0 +1,536 @@
1
+ import { join } from "node:path";
2
+ import { FsJsonlStore } from "@femtomc/mu-core/node";
3
+ import { computeNextScheduleRunAtMs, normalizeCronSchedule } from "./cron_schedule.js";
4
+ import { CronTimerRegistry } from "./cron_timer.js";
5
+ const CRON_PROGRAMS_FILENAME = "cron.jsonl";
6
+ function defaultNowMs() {
7
+ return Date.now();
8
+ }
9
+ function normalizeTarget(input) {
10
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
11
+ return null;
12
+ }
13
+ const record = input;
14
+ const kind = typeof record.kind === "string" ? record.kind.trim().toLowerCase() : "";
15
+ if (kind === "run") {
16
+ const jobId = typeof record.job_id === "string" ? record.job_id.trim() : "";
17
+ const rootIssueId = typeof record.root_issue_id === "string" ? record.root_issue_id.trim() : "";
18
+ if (!jobId && !rootIssueId) {
19
+ return null;
20
+ }
21
+ return {
22
+ kind: "run",
23
+ job_id: jobId || null,
24
+ root_issue_id: rootIssueId || null,
25
+ };
26
+ }
27
+ if (kind === "activity") {
28
+ const activityId = typeof record.activity_id === "string" ? record.activity_id.trim() : "";
29
+ if (!activityId) {
30
+ return null;
31
+ }
32
+ return {
33
+ kind: "activity",
34
+ activity_id: activityId,
35
+ };
36
+ }
37
+ return null;
38
+ }
39
+ function normalizeWakeMode(value) {
40
+ if (typeof value !== "string") {
41
+ return "immediate";
42
+ }
43
+ const normalized = value.trim().toLowerCase().replaceAll("-", "_");
44
+ return normalized === "next_heartbeat" ? "next_heartbeat" : "immediate";
45
+ }
46
+ function sanitizeMetadata(value) {
47
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
48
+ return {};
49
+ }
50
+ return { ...value };
51
+ }
52
+ function normalizeProgram(row) {
53
+ if (!row || typeof row !== "object" || Array.isArray(row)) {
54
+ return null;
55
+ }
56
+ const record = row;
57
+ const programId = typeof record.program_id === "string" ? record.program_id.trim() : "";
58
+ const title = typeof record.title === "string" ? record.title.trim() : "";
59
+ const target = normalizeTarget(record.target);
60
+ const createdAt = typeof record.created_at_ms === "number" && Number.isFinite(record.created_at_ms)
61
+ ? Math.trunc(record.created_at_ms)
62
+ : defaultNowMs();
63
+ const schedule = normalizeCronSchedule(record.schedule, {
64
+ nowMs: createdAt,
65
+ defaultEveryAnchorMs: createdAt,
66
+ });
67
+ if (!programId || !title || !target || !schedule) {
68
+ return null;
69
+ }
70
+ const updatedAt = typeof record.updated_at_ms === "number" && Number.isFinite(record.updated_at_ms)
71
+ ? Math.trunc(record.updated_at_ms)
72
+ : createdAt;
73
+ const nextRunAt = typeof record.next_run_at_ms === "number" && Number.isFinite(record.next_run_at_ms)
74
+ ? Math.trunc(record.next_run_at_ms)
75
+ : null;
76
+ const lastTriggeredAt = typeof record.last_triggered_at_ms === "number" && Number.isFinite(record.last_triggered_at_ms)
77
+ ? Math.trunc(record.last_triggered_at_ms)
78
+ : null;
79
+ const lastResultRaw = typeof record.last_result === "string" ? record.last_result.trim().toLowerCase() : null;
80
+ const lastResult = lastResultRaw === "ok" ||
81
+ lastResultRaw === "not_found" ||
82
+ lastResultRaw === "not_running" ||
83
+ lastResultRaw === "failed"
84
+ ? lastResultRaw
85
+ : null;
86
+ const reason = typeof record.reason === "string" && record.reason.trim().length > 0 ? record.reason.trim() : "scheduled";
87
+ const wakeMode = normalizeWakeMode(record.wake_mode);
88
+ return {
89
+ v: 1,
90
+ program_id: programId,
91
+ title,
92
+ enabled: record.enabled !== false,
93
+ schedule,
94
+ reason,
95
+ wake_mode: wakeMode,
96
+ target,
97
+ metadata: sanitizeMetadata(record.metadata),
98
+ created_at_ms: createdAt,
99
+ updated_at_ms: updatedAt,
100
+ next_run_at_ms: nextRunAt,
101
+ last_triggered_at_ms: lastTriggeredAt,
102
+ last_result: lastResult,
103
+ last_error: typeof record.last_error === "string" ? record.last_error : null,
104
+ };
105
+ }
106
+ function sortPrograms(programs) {
107
+ return [...programs].sort((a, b) => {
108
+ if (a.created_at_ms !== b.created_at_ms) {
109
+ return a.created_at_ms - b.created_at_ms;
110
+ }
111
+ return a.program_id.localeCompare(b.program_id);
112
+ });
113
+ }
114
+ function shouldRetry(result) {
115
+ if (result.status === "failed") {
116
+ return true;
117
+ }
118
+ return result.status === "skipped" && result.reason === "requests-in-flight";
119
+ }
120
+ export class CronProgramRegistry {
121
+ #store;
122
+ #heartbeatScheduler;
123
+ #timer;
124
+ #runHeartbeat;
125
+ #activityHeartbeat;
126
+ #onTickEvent;
127
+ #onLifecycleEvent;
128
+ #nowMs;
129
+ #programs = new Map();
130
+ #loaded = null;
131
+ constructor(opts) {
132
+ this.#heartbeatScheduler = opts.heartbeatScheduler;
133
+ this.#runHeartbeat = opts.runHeartbeat;
134
+ this.#activityHeartbeat = opts.activityHeartbeat;
135
+ this.#onTickEvent = opts.onTickEvent;
136
+ this.#onLifecycleEvent = opts.onLifecycleEvent;
137
+ this.#nowMs = opts.nowMs ?? defaultNowMs;
138
+ this.#timer = opts.timer ?? new CronTimerRegistry({ nowMs: this.#nowMs });
139
+ this.#store =
140
+ opts.store ?? new FsJsonlStore(join(opts.repoRoot, ".mu", CRON_PROGRAMS_FILENAME));
141
+ void this.#ensureLoaded().catch(() => {
142
+ // Best effort eager load for startup re-arming.
143
+ });
144
+ }
145
+ #scheduleId(programId) {
146
+ return `cron-program:${programId}`;
147
+ }
148
+ #snapshot(program) {
149
+ return {
150
+ ...program,
151
+ schedule: { ...program.schedule },
152
+ target: program.target.kind === "run" ? { ...program.target } : { ...program.target },
153
+ metadata: { ...program.metadata },
154
+ };
155
+ }
156
+ async #emitLifecycleEvent(event) {
157
+ if (!this.#onLifecycleEvent) {
158
+ return;
159
+ }
160
+ await this.#onLifecycleEvent(event);
161
+ }
162
+ async #emitTickEvent(event) {
163
+ if (!this.#onTickEvent) {
164
+ return;
165
+ }
166
+ await this.#onTickEvent(event);
167
+ }
168
+ async #ensureLoaded() {
169
+ if (!this.#loaded) {
170
+ this.#loaded = this.#load();
171
+ }
172
+ await this.#loaded;
173
+ }
174
+ async #load() {
175
+ const rows = await this.#store.read();
176
+ for (const row of rows) {
177
+ const normalized = normalizeProgram(row);
178
+ if (!normalized) {
179
+ continue;
180
+ }
181
+ this.#programs.set(normalized.program_id, normalized);
182
+ }
183
+ let dirty = false;
184
+ for (const program of this.#programs.values()) {
185
+ dirty = this.#applySchedule(program) || dirty;
186
+ }
187
+ if (dirty) {
188
+ await this.#persist();
189
+ }
190
+ }
191
+ async #persist() {
192
+ const rows = sortPrograms([...this.#programs.values()]);
193
+ await this.#store.write(rows);
194
+ }
195
+ #armTimer(program) {
196
+ this.#timer.disarm(program.program_id);
197
+ const nowMs = Math.trunc(this.#nowMs());
198
+ const nextRunAt = computeNextScheduleRunAtMs(program.schedule, nowMs);
199
+ const normalizedNextRun = typeof nextRunAt === "number" && Number.isFinite(nextRunAt) ? Math.trunc(nextRunAt) : null;
200
+ const changed = program.next_run_at_ms !== normalizedNextRun;
201
+ program.next_run_at_ms = normalizedNextRun;
202
+ if (normalizedNextRun == null) {
203
+ return changed;
204
+ }
205
+ this.#timer.arm({
206
+ programId: program.program_id,
207
+ dueAtMs: normalizedNextRun,
208
+ onDue: async () => {
209
+ const current = this.#programs.get(program.program_id);
210
+ if (!current || !current.enabled) {
211
+ return;
212
+ }
213
+ this.#heartbeatScheduler.requestNow(this.#scheduleId(program.program_id), {
214
+ reason: `cron:${program.program_id}`,
215
+ coalesceMs: 0,
216
+ });
217
+ },
218
+ });
219
+ return changed;
220
+ }
221
+ #applySchedule(program) {
222
+ const scheduleId = this.#scheduleId(program.program_id);
223
+ this.#timer.disarm(program.program_id);
224
+ this.#heartbeatScheduler.unregister(scheduleId);
225
+ if (!program.enabled) {
226
+ const changed = program.next_run_at_ms !== null;
227
+ program.next_run_at_ms = null;
228
+ return changed;
229
+ }
230
+ this.#heartbeatScheduler.register({
231
+ activityId: scheduleId,
232
+ everyMs: 0,
233
+ handler: async ({ reason }) => {
234
+ return await this.#tickProgram(program.program_id, {
235
+ reason: reason ?? undefined,
236
+ advanceSchedule: true,
237
+ });
238
+ },
239
+ });
240
+ return this.#armTimer(program);
241
+ }
242
+ async #tickProgram(programId, opts) {
243
+ const program = this.#programs.get(programId);
244
+ if (!program) {
245
+ return { status: "skipped", reason: "not_found" };
246
+ }
247
+ if (!program.enabled) {
248
+ return { status: "skipped", reason: "disabled" };
249
+ }
250
+ const triggerReason = opts.reason?.trim() || program.reason || "scheduled";
251
+ const nowMs = Math.trunc(this.#nowMs());
252
+ program.last_triggered_at_ms = nowMs;
253
+ program.updated_at_ms = nowMs;
254
+ let heartbeatResult;
255
+ let eventStatus = "ok";
256
+ let eventReason = triggerReason;
257
+ let eventMessage = `cron program tick: ${program.title}`;
258
+ try {
259
+ const executionResult = program.target.kind === "run"
260
+ ? await this.#runHeartbeat({
261
+ jobId: program.target.job_id,
262
+ rootIssueId: program.target.root_issue_id,
263
+ reason: triggerReason,
264
+ wakeMode: program.wake_mode,
265
+ })
266
+ : await this.#activityHeartbeat({
267
+ activityId: program.target.activity_id,
268
+ reason: triggerReason,
269
+ });
270
+ if (executionResult.ok) {
271
+ program.last_result = "ok";
272
+ program.last_error = null;
273
+ heartbeatResult = { status: "ran" };
274
+ }
275
+ else if (executionResult.reason === "not_running") {
276
+ program.last_result = "not_running";
277
+ program.last_error = null;
278
+ eventStatus = "not_running";
279
+ eventReason = executionResult.reason;
280
+ eventMessage = `cron program skipped (not running): ${program.title}`;
281
+ heartbeatResult = { status: "skipped", reason: "not_running" };
282
+ }
283
+ else if (executionResult.reason === "not_found") {
284
+ program.last_result = "not_found";
285
+ program.last_error = null;
286
+ eventStatus = "not_found";
287
+ eventReason = executionResult.reason;
288
+ eventMessage = `cron program skipped (not found): ${program.title}`;
289
+ heartbeatResult = { status: "skipped", reason: "not_found" };
290
+ }
291
+ else {
292
+ program.last_result = "failed";
293
+ program.last_error = executionResult.reason ?? "cron_program_tick_failed";
294
+ eventStatus = "failed";
295
+ eventReason = program.last_error;
296
+ eventMessage = `cron program failed: ${program.title}`;
297
+ heartbeatResult = { status: "failed", reason: program.last_error };
298
+ }
299
+ }
300
+ catch (err) {
301
+ program.last_result = "failed";
302
+ program.last_error = err instanceof Error ? err.message : String(err);
303
+ eventStatus = "failed";
304
+ eventReason = program.last_error;
305
+ eventMessage = `cron program failed: ${program.title}`;
306
+ heartbeatResult = { status: "failed", reason: program.last_error };
307
+ }
308
+ if (opts.advanceSchedule && !shouldRetry(heartbeatResult)) {
309
+ if (program.schedule.kind === "at") {
310
+ program.enabled = false;
311
+ program.next_run_at_ms = null;
312
+ this.#timer.disarm(program.program_id);
313
+ this.#heartbeatScheduler.unregister(this.#scheduleId(program.program_id));
314
+ void this.#emitLifecycleEvent({
315
+ ts_ms: Math.trunc(this.#nowMs()),
316
+ action: "oneshot_completed",
317
+ program_id: program.program_id,
318
+ message: `cron one-shot completed: ${program.title}`,
319
+ program: this.#snapshot(program),
320
+ }).catch(() => {
321
+ // best effort only
322
+ });
323
+ }
324
+ else {
325
+ this.#armTimer(program);
326
+ }
327
+ }
328
+ await this.#persist();
329
+ await this.#emitTickEvent({
330
+ ts_ms: nowMs,
331
+ program_id: program.program_id,
332
+ message: eventMessage,
333
+ status: eventStatus,
334
+ reason: eventReason,
335
+ program: this.#snapshot(program),
336
+ }).catch(() => {
337
+ // best effort only
338
+ });
339
+ return heartbeatResult;
340
+ }
341
+ async list(opts = {}) {
342
+ await this.#ensureLoaded();
343
+ const limit = Math.max(1, Math.min(500, Math.trunc(opts.limit ?? 100)));
344
+ return sortPrograms([...this.#programs.values()])
345
+ .filter((program) => {
346
+ if (typeof opts.enabled === "boolean" && program.enabled !== opts.enabled) {
347
+ return false;
348
+ }
349
+ if (opts.targetKind && program.target.kind !== opts.targetKind) {
350
+ return false;
351
+ }
352
+ if (opts.scheduleKind && program.schedule.kind !== opts.scheduleKind) {
353
+ return false;
354
+ }
355
+ return true;
356
+ })
357
+ .slice(0, limit)
358
+ .map((program) => this.#snapshot(program));
359
+ }
360
+ async status() {
361
+ await this.#ensureLoaded();
362
+ const armed = this.#timer.list();
363
+ const programs = [...this.#programs.values()];
364
+ return {
365
+ count: programs.length,
366
+ enabled_count: programs.filter((program) => program.enabled).length,
367
+ armed_count: armed.length,
368
+ armed,
369
+ };
370
+ }
371
+ async get(programId) {
372
+ await this.#ensureLoaded();
373
+ const program = this.#programs.get(programId.trim());
374
+ return program ? this.#snapshot(program) : null;
375
+ }
376
+ async create(opts) {
377
+ await this.#ensureLoaded();
378
+ const title = opts.title.trim();
379
+ if (!title) {
380
+ throw new Error("cron_program_title_required");
381
+ }
382
+ const target = normalizeTarget(opts.target);
383
+ if (!target) {
384
+ throw new Error("cron_program_invalid_target");
385
+ }
386
+ const nowMs = Math.trunc(this.#nowMs());
387
+ const schedule = normalizeCronSchedule(opts.schedule, {
388
+ nowMs,
389
+ defaultEveryAnchorMs: nowMs,
390
+ });
391
+ if (!schedule) {
392
+ throw new Error("cron_program_invalid_schedule");
393
+ }
394
+ const program = {
395
+ v: 1,
396
+ program_id: `cron-${crypto.randomUUID().slice(0, 12)}`,
397
+ title,
398
+ enabled: opts.enabled !== false,
399
+ schedule,
400
+ reason: opts.reason?.trim() || "scheduled",
401
+ wake_mode: normalizeWakeMode(opts.wakeMode),
402
+ target,
403
+ metadata: sanitizeMetadata(opts.metadata),
404
+ created_at_ms: nowMs,
405
+ updated_at_ms: nowMs,
406
+ next_run_at_ms: null,
407
+ last_triggered_at_ms: null,
408
+ last_result: null,
409
+ last_error: null,
410
+ };
411
+ this.#programs.set(program.program_id, program);
412
+ this.#applySchedule(program);
413
+ await this.#persist();
414
+ void this.#emitLifecycleEvent({
415
+ ts_ms: nowMs,
416
+ action: "created",
417
+ program_id: program.program_id,
418
+ message: `cron program created: ${program.title}`,
419
+ program: this.#snapshot(program),
420
+ }).catch(() => {
421
+ // best effort only
422
+ });
423
+ return this.#snapshot(program);
424
+ }
425
+ async update(opts) {
426
+ await this.#ensureLoaded();
427
+ const program = this.#programs.get(opts.programId.trim());
428
+ if (!program) {
429
+ return { ok: false, reason: "not_found", program: null };
430
+ }
431
+ if (typeof opts.title === "string") {
432
+ const title = opts.title.trim();
433
+ if (!title) {
434
+ throw new Error("cron_program_title_required");
435
+ }
436
+ program.title = title;
437
+ }
438
+ if (typeof opts.reason === "string") {
439
+ program.reason = opts.reason.trim() || "scheduled";
440
+ }
441
+ if (typeof opts.wakeMode === "string") {
442
+ program.wake_mode = normalizeWakeMode(opts.wakeMode);
443
+ }
444
+ if (typeof opts.enabled === "boolean") {
445
+ program.enabled = opts.enabled;
446
+ }
447
+ if (opts.target) {
448
+ const target = normalizeTarget(opts.target);
449
+ if (!target) {
450
+ return { ok: false, reason: "invalid_target", program: this.#snapshot(program) };
451
+ }
452
+ program.target = target;
453
+ }
454
+ if (opts.schedule) {
455
+ const normalizedSchedule = normalizeCronSchedule(opts.schedule, {
456
+ nowMs: Math.trunc(this.#nowMs()),
457
+ defaultEveryAnchorMs: program.schedule.kind === "every" ? program.schedule.anchor_ms : Math.trunc(this.#nowMs()),
458
+ });
459
+ if (!normalizedSchedule) {
460
+ return { ok: false, reason: "invalid_schedule", program: this.#snapshot(program) };
461
+ }
462
+ program.schedule = normalizedSchedule;
463
+ }
464
+ if (opts.metadata) {
465
+ program.metadata = sanitizeMetadata(opts.metadata);
466
+ }
467
+ program.updated_at_ms = Math.trunc(this.#nowMs());
468
+ this.#applySchedule(program);
469
+ await this.#persist();
470
+ void this.#emitLifecycleEvent({
471
+ ts_ms: Math.trunc(this.#nowMs()),
472
+ action: "updated",
473
+ program_id: program.program_id,
474
+ message: `cron program updated: ${program.title}`,
475
+ program: this.#snapshot(program),
476
+ }).catch(() => {
477
+ // best effort only
478
+ });
479
+ return { ok: true, reason: null, program: this.#snapshot(program) };
480
+ }
481
+ async remove(programId) {
482
+ await this.#ensureLoaded();
483
+ const normalizedId = programId.trim();
484
+ if (!normalizedId) {
485
+ return { ok: false, reason: "missing_target", program: null };
486
+ }
487
+ const program = this.#programs.get(normalizedId);
488
+ if (!program) {
489
+ return { ok: false, reason: "not_found", program: null };
490
+ }
491
+ this.#timer.disarm(program.program_id);
492
+ this.#heartbeatScheduler.unregister(this.#scheduleId(program.program_id));
493
+ this.#programs.delete(normalizedId);
494
+ await this.#persist();
495
+ void this.#emitLifecycleEvent({
496
+ ts_ms: Math.trunc(this.#nowMs()),
497
+ action: "deleted",
498
+ program_id: program.program_id,
499
+ message: `cron program deleted: ${program.title}`,
500
+ program: this.#snapshot(program),
501
+ }).catch(() => {
502
+ // best effort only
503
+ });
504
+ return { ok: true, reason: null, program: this.#snapshot(program) };
505
+ }
506
+ async trigger(opts) {
507
+ await this.#ensureLoaded();
508
+ const programId = opts.programId?.trim() || "";
509
+ if (!programId) {
510
+ return { ok: false, reason: "missing_target", program: null };
511
+ }
512
+ const program = this.#programs.get(programId);
513
+ if (!program) {
514
+ return { ok: false, reason: "not_found", program: null };
515
+ }
516
+ if (!program.enabled) {
517
+ return { ok: false, reason: "not_running", program: this.#snapshot(program) };
518
+ }
519
+ const tick = await this.#tickProgram(program.program_id, {
520
+ reason: opts.reason?.trim() || "manual",
521
+ advanceSchedule: false,
522
+ });
523
+ if (tick.status === "failed") {
524
+ return { ok: false, reason: "failed", program: this.#snapshot(program) };
525
+ }
526
+ return { ok: true, reason: null, program: this.#snapshot(program) };
527
+ }
528
+ stop() {
529
+ for (const program of this.#programs.values()) {
530
+ this.#timer.disarm(program.program_id);
531
+ this.#heartbeatScheduler.unregister(this.#scheduleId(program.program_id));
532
+ }
533
+ this.#timer.stop();
534
+ this.#programs.clear();
535
+ }
536
+ }