@femtomc/mu-server 26.2.55 → 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.
@@ -0,0 +1,383 @@
1
+ const WEEKDAY_INDEX = {
2
+ Sun: 0,
3
+ Mon: 1,
4
+ Tue: 2,
5
+ Wed: 3,
6
+ Thu: 4,
7
+ Fri: 5,
8
+ Sat: 6,
9
+ };
10
+ const DEFAULT_CRON_SEARCH_LIMIT_MINUTES = 366 * 24 * 60 * 2;
11
+ const formatterCache = new Map();
12
+ function defaultNowMs() {
13
+ return Date.now();
14
+ }
15
+ function isRecord(value) {
16
+ return typeof value === "object" && value != null && !Array.isArray(value);
17
+ }
18
+ function parseInteger(value) {
19
+ if (typeof value === "number" && Number.isFinite(value)) {
20
+ return Math.trunc(value);
21
+ }
22
+ if (typeof value === "string") {
23
+ const trimmed = value.trim();
24
+ if (!trimmed) {
25
+ return null;
26
+ }
27
+ if (!/^-?\d+$/.test(trimmed)) {
28
+ return null;
29
+ }
30
+ const parsed = Number.parseInt(trimmed, 10);
31
+ return Number.isFinite(parsed) ? parsed : null;
32
+ }
33
+ return null;
34
+ }
35
+ function parseAbsoluteTimeMs(value) {
36
+ if (typeof value === "number" && Number.isFinite(value)) {
37
+ return Math.trunc(value);
38
+ }
39
+ if (typeof value !== "string") {
40
+ return null;
41
+ }
42
+ const trimmed = value.trim();
43
+ if (!trimmed) {
44
+ return null;
45
+ }
46
+ if (/^-?\d+$/.test(trimmed)) {
47
+ const parsed = Number.parseInt(trimmed, 10);
48
+ return Number.isFinite(parsed) ? Math.trunc(parsed) : null;
49
+ }
50
+ const explicitTz = /[zZ]$|[+-]\d{2}:?\d{2}$/.test(trimmed);
51
+ const normalized = explicitTz ? trimmed : `${trimmed}Z`;
52
+ const parsed = Date.parse(normalized);
53
+ if (!Number.isFinite(parsed)) {
54
+ return null;
55
+ }
56
+ return Math.trunc(parsed);
57
+ }
58
+ function resolveTimeZone(raw) {
59
+ if (raw == null) {
60
+ return null;
61
+ }
62
+ if (typeof raw !== "string") {
63
+ return null;
64
+ }
65
+ const trimmed = raw.trim();
66
+ if (!trimmed) {
67
+ return null;
68
+ }
69
+ try {
70
+ return new Intl.DateTimeFormat("en-US", { timeZone: trimmed }).resolvedOptions().timeZone;
71
+ }
72
+ catch {
73
+ return null;
74
+ }
75
+ }
76
+ function normalizeCronValue(value, max, wrapSunday) {
77
+ if (wrapSunday && value === max) {
78
+ return 0;
79
+ }
80
+ return value;
81
+ }
82
+ function addRangeValues(set, range, opts) {
83
+ if (range.step <= 0) {
84
+ return false;
85
+ }
86
+ if (range.start < opts.min || range.start > opts.max) {
87
+ return false;
88
+ }
89
+ if (range.end < opts.min || range.end > opts.max) {
90
+ return false;
91
+ }
92
+ if (range.start > range.end) {
93
+ return false;
94
+ }
95
+ for (let value = range.start; value <= range.end; value += range.step) {
96
+ set.add(normalizeCronValue(value, opts.max, opts.wrapSunday));
97
+ }
98
+ return true;
99
+ }
100
+ function parseCronField(rawValue, opts) {
101
+ if (typeof rawValue !== "string") {
102
+ return null;
103
+ }
104
+ const raw = rawValue.trim();
105
+ if (!raw) {
106
+ return null;
107
+ }
108
+ const wrapSunday = opts.wrapSunday === true;
109
+ const values = new Set();
110
+ const segments = raw.split(",");
111
+ for (const segmentRaw of segments) {
112
+ const segment = segmentRaw.trim();
113
+ if (!segment) {
114
+ return null;
115
+ }
116
+ const slashIndex = segment.indexOf("/");
117
+ const [base, stepRaw] = slashIndex >= 0
118
+ ? [segment.slice(0, slashIndex).trim(), segment.slice(slashIndex + 1).trim()]
119
+ : [segment, ""];
120
+ const parsedStep = slashIndex >= 0 ? parseInteger(stepRaw) : 1;
121
+ if (parsedStep == null || parsedStep <= 0) {
122
+ return null;
123
+ }
124
+ if (base === "*" || base.length === 0) {
125
+ if (!addRangeValues(values, { start: opts.min, end: opts.max, step: parsedStep }, { min: opts.min, max: opts.max, wrapSunday })) {
126
+ return null;
127
+ }
128
+ continue;
129
+ }
130
+ const dashIndex = base.indexOf("-");
131
+ if (dashIndex >= 0) {
132
+ const startRaw = base.slice(0, dashIndex).trim();
133
+ const endRaw = base.slice(dashIndex + 1).trim();
134
+ const start = parseInteger(startRaw);
135
+ const end = parseInteger(endRaw);
136
+ if (start == null || end == null) {
137
+ return null;
138
+ }
139
+ if (!addRangeValues(values, { start, end, step: parsedStep }, { min: opts.min, max: opts.max, wrapSunday })) {
140
+ return null;
141
+ }
142
+ continue;
143
+ }
144
+ const value = parseInteger(base);
145
+ if (value == null) {
146
+ return null;
147
+ }
148
+ if (slashIndex >= 0) {
149
+ if (!addRangeValues(values, { start: value, end: opts.max, step: parsedStep }, { min: opts.min, max: opts.max, wrapSunday })) {
150
+ return null;
151
+ }
152
+ continue;
153
+ }
154
+ if (value < opts.min || value > opts.max) {
155
+ return null;
156
+ }
157
+ values.add(normalizeCronValue(value, opts.max, wrapSunday));
158
+ }
159
+ const rangeSize = opts.max - opts.min + 1;
160
+ return {
161
+ any: values.size >= rangeSize,
162
+ values,
163
+ };
164
+ }
165
+ function parseCronExpression(expr) {
166
+ const trimmed = expr.trim();
167
+ if (!trimmed) {
168
+ return null;
169
+ }
170
+ const parts = trimmed.split(/\s+/);
171
+ if (parts.length !== 5) {
172
+ return null;
173
+ }
174
+ const minute = parseCronField(parts[0], { min: 0, max: 59 });
175
+ const hour = parseCronField(parts[1], { min: 0, max: 23 });
176
+ const dayOfMonth = parseCronField(parts[2], { min: 1, max: 31 });
177
+ const month = parseCronField(parts[3], { min: 1, max: 12 });
178
+ const dayOfWeek = parseCronField(parts[4], { min: 0, max: 7, wrapSunday: true });
179
+ if (!minute || !hour || !dayOfMonth || !month || !dayOfWeek) {
180
+ return null;
181
+ }
182
+ return {
183
+ minute,
184
+ hour,
185
+ dayOfMonth,
186
+ month,
187
+ dayOfWeek,
188
+ };
189
+ }
190
+ function getFormatter(timeZone) {
191
+ const cached = formatterCache.get(timeZone);
192
+ if (cached) {
193
+ return cached;
194
+ }
195
+ const formatter = new Intl.DateTimeFormat("en-US", {
196
+ timeZone,
197
+ hour12: false,
198
+ hourCycle: "h23",
199
+ weekday: "short",
200
+ month: "2-digit",
201
+ day: "2-digit",
202
+ hour: "2-digit",
203
+ minute: "2-digit",
204
+ });
205
+ formatterCache.set(timeZone, formatter);
206
+ return formatter;
207
+ }
208
+ function getCronDateParts(timestampMs, timeZone) {
209
+ const formatter = getFormatter(timeZone);
210
+ let minute = -1;
211
+ let hour = -1;
212
+ let dayOfMonth = -1;
213
+ let month = -1;
214
+ let dayOfWeek = -1;
215
+ for (const part of formatter.formatToParts(new Date(timestampMs))) {
216
+ switch (part.type) {
217
+ case "minute":
218
+ minute = Number.parseInt(part.value, 10);
219
+ break;
220
+ case "hour":
221
+ hour = Number.parseInt(part.value, 10);
222
+ if (hour === 24) {
223
+ hour = 0;
224
+ }
225
+ break;
226
+ case "day":
227
+ dayOfMonth = Number.parseInt(part.value, 10);
228
+ break;
229
+ case "month":
230
+ month = Number.parseInt(part.value, 10);
231
+ break;
232
+ case "weekday":
233
+ dayOfWeek = WEEKDAY_INDEX[part.value] ?? -1;
234
+ break;
235
+ default:
236
+ break;
237
+ }
238
+ }
239
+ if (!Number.isInteger(minute) ||
240
+ !Number.isInteger(hour) ||
241
+ !Number.isInteger(dayOfMonth) ||
242
+ !Number.isInteger(month) ||
243
+ !Number.isInteger(dayOfWeek)) {
244
+ return null;
245
+ }
246
+ return {
247
+ minute,
248
+ hour,
249
+ dayOfMonth,
250
+ month,
251
+ dayOfWeek,
252
+ };
253
+ }
254
+ function fieldMatches(field, value) {
255
+ if (field.any) {
256
+ return true;
257
+ }
258
+ return field.values.has(value);
259
+ }
260
+ function dayMatches(parsed, parts) {
261
+ const domMatches = fieldMatches(parsed.dayOfMonth, parts.dayOfMonth);
262
+ const dowMatches = fieldMatches(parsed.dayOfWeek, parts.dayOfWeek);
263
+ if (parsed.dayOfMonth.any && parsed.dayOfWeek.any) {
264
+ return true;
265
+ }
266
+ if (parsed.dayOfMonth.any) {
267
+ return dowMatches;
268
+ }
269
+ if (parsed.dayOfWeek.any) {
270
+ return domMatches;
271
+ }
272
+ return domMatches || dowMatches;
273
+ }
274
+ function resolveCronTimeZone(tz) {
275
+ if (tz) {
276
+ return tz;
277
+ }
278
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
279
+ }
280
+ function computeNextCronRunAtMs(parsed, nowMs, timeZone, searchLimitMinutes = DEFAULT_CRON_SEARCH_LIMIT_MINUTES) {
281
+ const startMinute = Math.floor(nowMs / 60_000) * 60_000 + 60_000;
282
+ for (let offset = 0; offset < searchLimitMinutes; offset += 1) {
283
+ const candidate = startMinute + offset * 60_000;
284
+ const parts = getCronDateParts(candidate, timeZone);
285
+ if (!parts) {
286
+ continue;
287
+ }
288
+ if (!fieldMatches(parsed.minute, parts.minute)) {
289
+ continue;
290
+ }
291
+ if (!fieldMatches(parsed.hour, parts.hour)) {
292
+ continue;
293
+ }
294
+ if (!fieldMatches(parsed.month, parts.month)) {
295
+ continue;
296
+ }
297
+ if (!dayMatches(parsed, parts)) {
298
+ continue;
299
+ }
300
+ return candidate;
301
+ }
302
+ return null;
303
+ }
304
+ export function normalizeCronSchedule(input, opts = {}) {
305
+ if (!isRecord(input)) {
306
+ return null;
307
+ }
308
+ const nowMs = Math.trunc(opts.nowMs ?? defaultNowMs());
309
+ const kindRaw = typeof input.kind === "string" ? input.kind.trim().toLowerCase() : "";
310
+ const inferredKind = kindRaw === "at" || kindRaw === "every" || kindRaw === "cron"
311
+ ? kindRaw
312
+ : input.at_ms != null || input.at != null
313
+ ? "at"
314
+ : input.every_ms != null || input.everyMs != null
315
+ ? "every"
316
+ : input.expr != null
317
+ ? "cron"
318
+ : "";
319
+ if (inferredKind === "at") {
320
+ const atMs = parseAbsoluteTimeMs(input.at_ms ?? input.at);
321
+ if (atMs == null || atMs <= 0) {
322
+ return null;
323
+ }
324
+ return {
325
+ kind: "at",
326
+ at_ms: atMs,
327
+ };
328
+ }
329
+ if (inferredKind === "every") {
330
+ const everyMs = parseInteger(input.every_ms ?? input.everyMs);
331
+ if (everyMs == null || everyMs <= 0) {
332
+ return null;
333
+ }
334
+ const anchorRaw = parseInteger(input.anchor_ms ?? input.anchorMs);
335
+ const fallbackAnchor = Math.trunc(opts.defaultEveryAnchorMs ?? nowMs);
336
+ const anchorMs = anchorRaw != null && anchorRaw >= 0 ? anchorRaw : Math.max(0, fallbackAnchor);
337
+ return {
338
+ kind: "every",
339
+ every_ms: everyMs,
340
+ anchor_ms: anchorMs,
341
+ };
342
+ }
343
+ if (inferredKind === "cron") {
344
+ const expr = typeof input.expr === "string" ? input.expr.trim() : "";
345
+ if (!expr) {
346
+ return null;
347
+ }
348
+ if (!parseCronExpression(expr)) {
349
+ return null;
350
+ }
351
+ const tzRaw = resolveTimeZone(input.tz);
352
+ if (input.tz != null && tzRaw == null) {
353
+ return null;
354
+ }
355
+ return {
356
+ kind: "cron",
357
+ expr,
358
+ tz: tzRaw,
359
+ };
360
+ }
361
+ return null;
362
+ }
363
+ export function computeNextScheduleRunAtMs(schedule, nowMsRaw) {
364
+ const nowMs = Math.trunc(nowMsRaw);
365
+ if (schedule.kind === "at") {
366
+ return schedule.at_ms;
367
+ }
368
+ if (schedule.kind === "every") {
369
+ const everyMs = Math.max(1, Math.trunc(schedule.every_ms));
370
+ const anchorMs = Math.max(0, Math.trunc(schedule.anchor_ms));
371
+ if (nowMs < anchorMs) {
372
+ return anchorMs;
373
+ }
374
+ const elapsed = nowMs - anchorMs;
375
+ const steps = Math.floor(elapsed / everyMs) + 1;
376
+ return anchorMs + steps * everyMs;
377
+ }
378
+ const parsed = parseCronExpression(schedule.expr);
379
+ if (!parsed) {
380
+ return null;
381
+ }
382
+ return computeNextCronRunAtMs(parsed, nowMs, resolveCronTimeZone(schedule.tz));
383
+ }
@@ -0,0 +1,21 @@
1
+ export type CronTimerSnapshot = {
2
+ program_id: string;
3
+ due_at_ms: number;
4
+ };
5
+ export type CronTimerRegistryOpts = {
6
+ nowMs?: () => number;
7
+ maxDelayMs?: number;
8
+ };
9
+ export declare class CronTimerRegistry {
10
+ #private;
11
+ constructor(opts?: CronTimerRegistryOpts);
12
+ arm(opts: {
13
+ programId: string;
14
+ dueAtMs: number;
15
+ onDue: () => void | Promise<void>;
16
+ }): void;
17
+ disarm(programIdRaw: string): boolean;
18
+ dueAt(programIdRaw: string): number | null;
19
+ list(): CronTimerSnapshot[];
20
+ stop(): void;
21
+ }
@@ -0,0 +1,109 @@
1
+ const DEFAULT_MAX_DELAY_MS = 60_000;
2
+ function defaultNowMs() {
3
+ return Date.now();
4
+ }
5
+ export class CronTimerRegistry {
6
+ #entries = new Map();
7
+ #nowMs;
8
+ #maxDelayMs;
9
+ #token = 0;
10
+ constructor(opts = {}) {
11
+ this.#nowMs = opts.nowMs ?? defaultNowMs;
12
+ this.#maxDelayMs = Math.max(1_000, Math.trunc(opts.maxDelayMs ?? DEFAULT_MAX_DELAY_MS));
13
+ }
14
+ #clearTimer(entry) {
15
+ if (entry.handle) {
16
+ clearTimeout(entry.handle);
17
+ }
18
+ entry.handle = null;
19
+ }
20
+ #arm(entry) {
21
+ this.#clearTimer(entry);
22
+ const nowMs = Math.trunc(this.#nowMs());
23
+ const remainingMs = Math.max(0, entry.dueAtMs - nowMs);
24
+ const delayMs = Math.min(this.#maxDelayMs, remainingMs);
25
+ const token = ++this.#token;
26
+ entry.token = token;
27
+ entry.handle = setTimeout(() => {
28
+ void this.#onTimer(entry.programId, token);
29
+ }, delayMs);
30
+ entry.handle.unref?.();
31
+ }
32
+ async #onTimer(programId, token) {
33
+ const entry = this.#entries.get(programId);
34
+ if (!entry || entry.token !== token) {
35
+ return;
36
+ }
37
+ const nowMs = Math.trunc(this.#nowMs());
38
+ if (nowMs < entry.dueAtMs) {
39
+ this.#arm(entry);
40
+ return;
41
+ }
42
+ this.#clearTimer(entry);
43
+ try {
44
+ await entry.onDue();
45
+ }
46
+ catch {
47
+ // Best effort callback execution.
48
+ }
49
+ }
50
+ arm(opts) {
51
+ const programId = opts.programId.trim();
52
+ if (!programId) {
53
+ return;
54
+ }
55
+ const dueAtMs = Math.max(0, Math.trunc(opts.dueAtMs));
56
+ const existing = this.#entries.get(programId);
57
+ const entry = existing ??
58
+ {
59
+ programId,
60
+ dueAtMs,
61
+ handle: null,
62
+ token: 0,
63
+ onDue: opts.onDue,
64
+ };
65
+ entry.dueAtMs = dueAtMs;
66
+ entry.onDue = opts.onDue;
67
+ this.#entries.set(programId, entry);
68
+ this.#arm(entry);
69
+ }
70
+ disarm(programIdRaw) {
71
+ const programId = programIdRaw.trim();
72
+ if (!programId) {
73
+ return false;
74
+ }
75
+ const entry = this.#entries.get(programId);
76
+ if (!entry) {
77
+ return false;
78
+ }
79
+ this.#clearTimer(entry);
80
+ this.#entries.delete(programId);
81
+ return true;
82
+ }
83
+ dueAt(programIdRaw) {
84
+ const programId = programIdRaw.trim();
85
+ if (!programId) {
86
+ return null;
87
+ }
88
+ return this.#entries.get(programId)?.dueAtMs ?? null;
89
+ }
90
+ list() {
91
+ return [...this.#entries.values()]
92
+ .map((entry) => ({
93
+ program_id: entry.programId,
94
+ due_at_ms: entry.dueAtMs,
95
+ }))
96
+ .sort((a, b) => {
97
+ if (a.due_at_ms !== b.due_at_ms) {
98
+ return a.due_at_ms - b.due_at_ms;
99
+ }
100
+ return a.program_id.localeCompare(b.program_id);
101
+ });
102
+ }
103
+ stop() {
104
+ for (const entry of this.#entries.values()) {
105
+ this.#clearTimer(entry);
106
+ }
107
+ this.#entries.clear();
108
+ }
109
+ }
@@ -0,0 +1,21 @@
1
+ import type { GenerationReloadAttempt, GenerationSupervisorSnapshot, ReloadableGenerationIdentity, ReloadLifecycleReason } from "@femtomc/mu-control-plane";
2
+ export type ControlPlaneGenerationSupervisorOpts = {
3
+ supervisorId?: string;
4
+ nowMs?: () => number;
5
+ initialGeneration?: ReloadableGenerationIdentity | null;
6
+ };
7
+ export type BeginGenerationReloadResult = {
8
+ attempt: GenerationReloadAttempt;
9
+ coalesced: boolean;
10
+ };
11
+ export declare class ControlPlaneGenerationSupervisor {
12
+ #private;
13
+ constructor(opts?: ControlPlaneGenerationSupervisorOpts);
14
+ beginReload(reason: ReloadLifecycleReason): BeginGenerationReloadResult;
15
+ markSwapInstalled(attemptId: string): boolean;
16
+ rollbackSwapInstalled(attemptId: string): boolean;
17
+ finishReload(attemptId: string, outcome: "success" | "failure"): boolean;
18
+ activeGeneration(): ReloadableGenerationIdentity | null;
19
+ pendingReload(): GenerationReloadAttempt | null;
20
+ snapshot(): GenerationSupervisorSnapshot;
21
+ }
@@ -0,0 +1,107 @@
1
+ function cloneGeneration(generation) {
2
+ if (!generation) {
3
+ return null;
4
+ }
5
+ return { ...generation };
6
+ }
7
+ function cloneAttempt(attempt) {
8
+ if (!attempt) {
9
+ return null;
10
+ }
11
+ return {
12
+ ...attempt,
13
+ from_generation: cloneGeneration(attempt.from_generation),
14
+ to_generation: { ...attempt.to_generation },
15
+ };
16
+ }
17
+ export class ControlPlaneGenerationSupervisor {
18
+ #supervisorId;
19
+ #nowMs;
20
+ #generationSeq;
21
+ #attemptSeq = 0;
22
+ #activeGeneration;
23
+ #pendingReload = null;
24
+ #lastReload = null;
25
+ constructor(opts = {}) {
26
+ this.#supervisorId = opts.supervisorId?.trim() || "control-plane";
27
+ this.#nowMs = opts.nowMs ?? Date.now;
28
+ this.#activeGeneration = cloneGeneration(opts.initialGeneration ?? null);
29
+ this.#generationSeq = this.#activeGeneration?.generation_seq ?? -1;
30
+ }
31
+ #nextGeneration() {
32
+ const nextSeq = this.#generationSeq + 1;
33
+ return {
34
+ generation_id: `${this.#supervisorId}-gen-${nextSeq}`,
35
+ generation_seq: nextSeq,
36
+ };
37
+ }
38
+ beginReload(reason) {
39
+ if (this.#pendingReload) {
40
+ return {
41
+ attempt: cloneAttempt(this.#pendingReload),
42
+ coalesced: true,
43
+ };
44
+ }
45
+ this.#attemptSeq += 1;
46
+ const nowMs = Math.trunc(this.#nowMs());
47
+ const attempt = {
48
+ attempt_id: `${this.#supervisorId}-reload-${this.#attemptSeq.toString(36)}`,
49
+ reason,
50
+ state: "planned",
51
+ requested_at_ms: nowMs,
52
+ swapped_at_ms: null,
53
+ finished_at_ms: null,
54
+ from_generation: cloneGeneration(this.#activeGeneration),
55
+ to_generation: this.#nextGeneration(),
56
+ };
57
+ this.#pendingReload = attempt;
58
+ return {
59
+ attempt: cloneAttempt(attempt),
60
+ coalesced: false,
61
+ };
62
+ }
63
+ markSwapInstalled(attemptId) {
64
+ if (!this.#pendingReload || this.#pendingReload.attempt_id !== attemptId) {
65
+ return false;
66
+ }
67
+ this.#pendingReload.state = "swapped";
68
+ this.#pendingReload.swapped_at_ms = Math.trunc(this.#nowMs());
69
+ this.#activeGeneration = { ...this.#pendingReload.to_generation };
70
+ this.#generationSeq = this.#pendingReload.to_generation.generation_seq;
71
+ return true;
72
+ }
73
+ rollbackSwapInstalled(attemptId) {
74
+ if (!this.#pendingReload ||
75
+ this.#pendingReload.attempt_id !== attemptId ||
76
+ this.#pendingReload.swapped_at_ms == null) {
77
+ return false;
78
+ }
79
+ this.#activeGeneration = cloneGeneration(this.#pendingReload.from_generation);
80
+ this.#generationSeq = this.#activeGeneration?.generation_seq ?? -1;
81
+ return true;
82
+ }
83
+ finishReload(attemptId, outcome) {
84
+ if (!this.#pendingReload || this.#pendingReload.attempt_id !== attemptId) {
85
+ return false;
86
+ }
87
+ this.#pendingReload.state = outcome === "success" ? "completed" : "failed";
88
+ this.#pendingReload.finished_at_ms = Math.trunc(this.#nowMs());
89
+ this.#lastReload = cloneAttempt(this.#pendingReload);
90
+ this.#pendingReload = null;
91
+ return true;
92
+ }
93
+ activeGeneration() {
94
+ return cloneGeneration(this.#activeGeneration);
95
+ }
96
+ pendingReload() {
97
+ return cloneAttempt(this.#pendingReload);
98
+ }
99
+ snapshot() {
100
+ return {
101
+ supervisor_id: this.#supervisorId,
102
+ active_generation: cloneGeneration(this.#activeGeneration),
103
+ pending_reload: cloneAttempt(this.#pendingReload),
104
+ last_reload: cloneAttempt(this.#lastReload),
105
+ };
106
+ }
107
+ }
@@ -1,5 +1,5 @@
1
1
  import type { JsonlStore } from "@femtomc/mu-core";
2
- import { ActivityHeartbeatScheduler } from "./heartbeat_scheduler.js";
2
+ import type { ActivityHeartbeatScheduler } from "./heartbeat_scheduler.js";
3
3
  export type HeartbeatProgramTarget = {
4
4
  kind: "run";
5
5
  job_id: string | null;
@@ -8,6 +8,7 @@ export type HeartbeatProgramTarget = {
8
8
  kind: "activity";
9
9
  activity_id: string;
10
10
  };
11
+ export type HeartbeatProgramWakeMode = "immediate" | "next_heartbeat";
11
12
  export type HeartbeatProgramSnapshot = {
12
13
  v: 1;
13
14
  program_id: string;
@@ -15,6 +16,7 @@ export type HeartbeatProgramSnapshot = {
15
16
  enabled: boolean;
16
17
  every_ms: number;
17
18
  reason: string;
19
+ wake_mode: HeartbeatProgramWakeMode;
18
20
  target: HeartbeatProgramTarget;
19
21
  metadata: Record<string, unknown>;
20
22
  created_at_ms: number;
@@ -45,6 +47,7 @@ export type HeartbeatProgramRegistryOpts = {
45
47
  jobId?: string | null;
46
48
  rootIssueId?: string | null;
47
49
  reason?: string | null;
50
+ wakeMode?: HeartbeatProgramWakeMode;
48
51
  }) => Promise<{
49
52
  ok: boolean;
50
53
  reason: "not_found" | "not_running" | "missing_target" | null;
@@ -72,6 +75,7 @@ export declare class HeartbeatProgramRegistry {
72
75
  target: HeartbeatProgramTarget;
73
76
  everyMs?: number;
74
77
  reason?: string;
78
+ wakeMode?: HeartbeatProgramWakeMode;
75
79
  enabled?: boolean;
76
80
  metadata?: Record<string, unknown>;
77
81
  }): Promise<HeartbeatProgramSnapshot>;
@@ -80,6 +84,7 @@ export declare class HeartbeatProgramRegistry {
80
84
  title?: string;
81
85
  everyMs?: number;
82
86
  reason?: string;
87
+ wakeMode?: HeartbeatProgramWakeMode;
83
88
  enabled?: boolean;
84
89
  target?: HeartbeatProgramTarget;
85
90
  metadata?: Record<string, unknown>;