@femtomc/mu-server 26.2.40 → 26.2.42

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,415 @@
1
+ import { join } from "node:path";
2
+ import { FsJsonlStore } from "@femtomc/mu-core/node";
3
+ const HEARTBEAT_PROGRAMS_FILENAME = "heartbeats.jsonl";
4
+ function defaultNowMs() {
5
+ return Date.now();
6
+ }
7
+ function normalizeTarget(input) {
8
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
9
+ return null;
10
+ }
11
+ const record = input;
12
+ const kind = typeof record.kind === "string" ? record.kind.trim().toLowerCase() : "";
13
+ if (kind === "run") {
14
+ const jobId = typeof record.job_id === "string" ? record.job_id.trim() : "";
15
+ const rootIssueId = typeof record.root_issue_id === "string" ? record.root_issue_id.trim() : "";
16
+ if (!jobId && !rootIssueId) {
17
+ return null;
18
+ }
19
+ return {
20
+ kind: "run",
21
+ job_id: jobId || null,
22
+ root_issue_id: rootIssueId || null,
23
+ };
24
+ }
25
+ if (kind === "activity") {
26
+ const activityId = typeof record.activity_id === "string" ? record.activity_id.trim() : "";
27
+ if (!activityId) {
28
+ return null;
29
+ }
30
+ return {
31
+ kind: "activity",
32
+ activity_id: activityId,
33
+ };
34
+ }
35
+ return null;
36
+ }
37
+ function sanitizeMetadata(value) {
38
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
39
+ return {};
40
+ }
41
+ return { ...value };
42
+ }
43
+ function normalizeProgram(row) {
44
+ if (!row || typeof row !== "object" || Array.isArray(row)) {
45
+ return null;
46
+ }
47
+ const record = row;
48
+ const programId = typeof record.program_id === "string" ? record.program_id.trim() : "";
49
+ const title = typeof record.title === "string" ? record.title.trim() : "";
50
+ const target = normalizeTarget(record.target);
51
+ if (!programId || !title || !target) {
52
+ return null;
53
+ }
54
+ const everyMsRaw = record.every_ms;
55
+ const everyMs = typeof everyMsRaw === "number" && Number.isFinite(everyMsRaw)
56
+ ? Math.max(0, Math.trunc(everyMsRaw))
57
+ : 0;
58
+ const createdAt = typeof record.created_at_ms === "number" && Number.isFinite(record.created_at_ms)
59
+ ? Math.trunc(record.created_at_ms)
60
+ : defaultNowMs();
61
+ const updatedAt = typeof record.updated_at_ms === "number" && Number.isFinite(record.updated_at_ms)
62
+ ? Math.trunc(record.updated_at_ms)
63
+ : createdAt;
64
+ const lastTriggeredAt = typeof record.last_triggered_at_ms === "number" && Number.isFinite(record.last_triggered_at_ms)
65
+ ? Math.trunc(record.last_triggered_at_ms)
66
+ : null;
67
+ const lastResultRaw = typeof record.last_result === "string" ? record.last_result.trim().toLowerCase() : null;
68
+ const lastResult = lastResultRaw === "ok" ||
69
+ lastResultRaw === "not_found" ||
70
+ lastResultRaw === "not_running" ||
71
+ lastResultRaw === "failed"
72
+ ? lastResultRaw
73
+ : null;
74
+ const reason = typeof record.reason === "string" && record.reason.trim().length > 0 ? record.reason.trim() : "scheduled";
75
+ return {
76
+ v: 1,
77
+ program_id: programId,
78
+ title,
79
+ enabled: record.enabled !== false,
80
+ every_ms: everyMs,
81
+ reason,
82
+ target,
83
+ metadata: sanitizeMetadata(record.metadata),
84
+ created_at_ms: createdAt,
85
+ updated_at_ms: updatedAt,
86
+ last_triggered_at_ms: lastTriggeredAt,
87
+ last_result: lastResult,
88
+ last_error: typeof record.last_error === "string" ? record.last_error : null,
89
+ };
90
+ }
91
+ function sortPrograms(programs) {
92
+ return [...programs].sort((a, b) => {
93
+ if (a.created_at_ms !== b.created_at_ms) {
94
+ return a.created_at_ms - b.created_at_ms;
95
+ }
96
+ return a.program_id.localeCompare(b.program_id);
97
+ });
98
+ }
99
+ export class HeartbeatProgramRegistry {
100
+ #store;
101
+ #heartbeatScheduler;
102
+ #runHeartbeat;
103
+ #activityHeartbeat;
104
+ #onTickEvent;
105
+ #nowMs;
106
+ #programs = new Map();
107
+ #loaded = null;
108
+ constructor(opts) {
109
+ this.#heartbeatScheduler = opts.heartbeatScheduler;
110
+ this.#runHeartbeat = opts.runHeartbeat;
111
+ this.#activityHeartbeat = opts.activityHeartbeat;
112
+ this.#onTickEvent = opts.onTickEvent;
113
+ this.#nowMs = opts.nowMs ?? defaultNowMs;
114
+ this.#store =
115
+ opts.store ??
116
+ new FsJsonlStore(join(opts.repoRoot, ".mu", HEARTBEAT_PROGRAMS_FILENAME));
117
+ }
118
+ #scheduleId(programId) {
119
+ return `heartbeat-program:${programId}`;
120
+ }
121
+ #snapshot(program) {
122
+ return {
123
+ ...program,
124
+ target: program.target.kind === "run" ? { ...program.target } : { ...program.target },
125
+ metadata: { ...program.metadata },
126
+ };
127
+ }
128
+ async #ensureLoaded() {
129
+ if (!this.#loaded) {
130
+ this.#loaded = this.#load();
131
+ }
132
+ await this.#loaded;
133
+ }
134
+ async #load() {
135
+ const rows = await this.#store.read();
136
+ for (const row of rows) {
137
+ const normalized = normalizeProgram(row);
138
+ if (!normalized) {
139
+ continue;
140
+ }
141
+ this.#programs.set(normalized.program_id, normalized);
142
+ }
143
+ for (const program of this.#programs.values()) {
144
+ this.#applySchedule(program);
145
+ }
146
+ }
147
+ async #persist() {
148
+ const rows = sortPrograms([...this.#programs.values()]);
149
+ await this.#store.write(rows);
150
+ }
151
+ #applySchedule(program) {
152
+ const scheduleId = this.#scheduleId(program.program_id);
153
+ if (!program.enabled || program.every_ms <= 0) {
154
+ this.#heartbeatScheduler.unregister(scheduleId);
155
+ return;
156
+ }
157
+ this.#heartbeatScheduler.register({
158
+ activityId: scheduleId,
159
+ everyMs: program.every_ms,
160
+ handler: async ({ reason }) => {
161
+ return await this.#tickProgram(program.program_id, reason ?? undefined);
162
+ },
163
+ });
164
+ }
165
+ async #emitTickEvent(event) {
166
+ if (!this.#onTickEvent) {
167
+ return;
168
+ }
169
+ await this.#onTickEvent(event);
170
+ }
171
+ async #tickProgram(programId, reason) {
172
+ const program = this.#programs.get(programId);
173
+ if (!program) {
174
+ return { status: "skipped", reason: "not_found" };
175
+ }
176
+ if (!program.enabled) {
177
+ return { status: "skipped", reason: "disabled" };
178
+ }
179
+ const heartbeatReason = reason?.trim() || program.reason || "scheduled";
180
+ const nowMs = Math.trunc(this.#nowMs());
181
+ program.last_triggered_at_ms = nowMs;
182
+ program.updated_at_ms = nowMs;
183
+ try {
184
+ const result = program.target.kind === "run"
185
+ ? await this.#runHeartbeat({
186
+ jobId: program.target.job_id,
187
+ rootIssueId: program.target.root_issue_id,
188
+ reason: heartbeatReason,
189
+ })
190
+ : await this.#activityHeartbeat({
191
+ activityId: program.target.activity_id,
192
+ reason: heartbeatReason,
193
+ });
194
+ if (result.ok) {
195
+ program.last_result = "ok";
196
+ program.last_error = null;
197
+ void this.#persist().catch(() => {
198
+ // Best effort persistence on background ticks.
199
+ });
200
+ void this.#emitTickEvent({
201
+ ts_ms: nowMs,
202
+ program_id: program.program_id,
203
+ message: `heartbeat program tick: ${program.title}`,
204
+ status: "ok",
205
+ reason: heartbeatReason,
206
+ program: this.#snapshot(program),
207
+ }).catch(() => {
208
+ // best effort only
209
+ });
210
+ return { status: "ran" };
211
+ }
212
+ if (result.reason === "not_running") {
213
+ program.last_result = "not_running";
214
+ program.last_error = null;
215
+ void this.#persist().catch(() => {
216
+ // Best effort persistence on background ticks.
217
+ });
218
+ void this.#emitTickEvent({
219
+ ts_ms: nowMs,
220
+ program_id: program.program_id,
221
+ message: `heartbeat program skipped (not running): ${program.title}`,
222
+ status: "not_running",
223
+ reason: result.reason,
224
+ program: this.#snapshot(program),
225
+ }).catch(() => {
226
+ // best effort only
227
+ });
228
+ return { status: "skipped", reason: "not_running" };
229
+ }
230
+ if (result.reason === "not_found") {
231
+ program.last_result = "not_found";
232
+ program.last_error = null;
233
+ void this.#persist().catch(() => {
234
+ // Best effort persistence on background ticks.
235
+ });
236
+ void this.#emitTickEvent({
237
+ ts_ms: nowMs,
238
+ program_id: program.program_id,
239
+ message: `heartbeat program skipped (not found): ${program.title}`,
240
+ status: "not_found",
241
+ reason: result.reason,
242
+ program: this.#snapshot(program),
243
+ }).catch(() => {
244
+ // best effort only
245
+ });
246
+ return { status: "skipped", reason: "not_found" };
247
+ }
248
+ program.last_result = "failed";
249
+ program.last_error = result.reason ?? "heartbeat_program_tick_failed";
250
+ void this.#persist().catch(() => {
251
+ // Best effort persistence on background ticks.
252
+ });
253
+ void this.#emitTickEvent({
254
+ ts_ms: nowMs,
255
+ program_id: program.program_id,
256
+ message: `heartbeat program failed: ${program.title}`,
257
+ status: "failed",
258
+ reason: program.last_error,
259
+ program: this.#snapshot(program),
260
+ }).catch(() => {
261
+ // best effort only
262
+ });
263
+ return { status: "failed", reason: program.last_error };
264
+ }
265
+ catch (err) {
266
+ program.last_result = "failed";
267
+ program.last_error = err instanceof Error ? err.message : String(err);
268
+ void this.#persist().catch(() => {
269
+ // Best effort persistence on background ticks.
270
+ });
271
+ void this.#emitTickEvent({
272
+ ts_ms: nowMs,
273
+ program_id: program.program_id,
274
+ message: `heartbeat program failed: ${program.title}`,
275
+ status: "failed",
276
+ reason: program.last_error,
277
+ program: this.#snapshot(program),
278
+ }).catch(() => {
279
+ // best effort only
280
+ });
281
+ return { status: "failed", reason: program.last_error };
282
+ }
283
+ }
284
+ async list(opts = {}) {
285
+ await this.#ensureLoaded();
286
+ const limit = Math.max(1, Math.min(500, Math.trunc(opts.limit ?? 100)));
287
+ return sortPrograms([...this.#programs.values()])
288
+ .filter((program) => {
289
+ if (typeof opts.enabled === "boolean" && program.enabled !== opts.enabled) {
290
+ return false;
291
+ }
292
+ if (opts.targetKind && program.target.kind !== opts.targetKind) {
293
+ return false;
294
+ }
295
+ return true;
296
+ })
297
+ .slice(0, limit)
298
+ .map((program) => this.#snapshot(program));
299
+ }
300
+ async get(programId) {
301
+ await this.#ensureLoaded();
302
+ const program = this.#programs.get(programId.trim());
303
+ return program ? this.#snapshot(program) : null;
304
+ }
305
+ async create(opts) {
306
+ await this.#ensureLoaded();
307
+ const title = opts.title.trim();
308
+ if (!title) {
309
+ throw new Error("heartbeat_program_title_required");
310
+ }
311
+ const target = normalizeTarget(opts.target);
312
+ if (!target) {
313
+ throw new Error("heartbeat_program_invalid_target");
314
+ }
315
+ const nowMs = Math.trunc(this.#nowMs());
316
+ const program = {
317
+ v: 1,
318
+ program_id: `hb-${crypto.randomUUID().slice(0, 12)}`,
319
+ title,
320
+ enabled: opts.enabled !== false,
321
+ every_ms: typeof opts.everyMs === "number" && Number.isFinite(opts.everyMs)
322
+ ? Math.max(0, Math.trunc(opts.everyMs))
323
+ : 15_000,
324
+ reason: opts.reason?.trim() || "scheduled",
325
+ target,
326
+ metadata: sanitizeMetadata(opts.metadata),
327
+ created_at_ms: nowMs,
328
+ updated_at_ms: nowMs,
329
+ last_triggered_at_ms: null,
330
+ last_result: null,
331
+ last_error: null,
332
+ };
333
+ this.#programs.set(program.program_id, program);
334
+ this.#applySchedule(program);
335
+ await this.#persist();
336
+ return this.#snapshot(program);
337
+ }
338
+ async update(opts) {
339
+ await this.#ensureLoaded();
340
+ const program = this.#programs.get(opts.programId.trim());
341
+ if (!program) {
342
+ return { ok: false, reason: "not_found", program: null };
343
+ }
344
+ if (typeof opts.title === "string") {
345
+ const title = opts.title.trim();
346
+ if (!title) {
347
+ throw new Error("heartbeat_program_title_required");
348
+ }
349
+ program.title = title;
350
+ }
351
+ if (typeof opts.everyMs === "number" && Number.isFinite(opts.everyMs)) {
352
+ program.every_ms = Math.max(0, Math.trunc(opts.everyMs));
353
+ }
354
+ if (typeof opts.reason === "string") {
355
+ program.reason = opts.reason.trim() || "scheduled";
356
+ }
357
+ if (typeof opts.enabled === "boolean") {
358
+ program.enabled = opts.enabled;
359
+ }
360
+ if (opts.target) {
361
+ const target = normalizeTarget(opts.target);
362
+ if (!target) {
363
+ return { ok: false, reason: "invalid_target", program: this.#snapshot(program) };
364
+ }
365
+ program.target = target;
366
+ }
367
+ if (opts.metadata) {
368
+ program.metadata = sanitizeMetadata(opts.metadata);
369
+ }
370
+ program.updated_at_ms = Math.trunc(this.#nowMs());
371
+ this.#applySchedule(program);
372
+ await this.#persist();
373
+ return { ok: true, reason: null, program: this.#snapshot(program) };
374
+ }
375
+ async remove(programId) {
376
+ await this.#ensureLoaded();
377
+ const normalizedId = programId.trim();
378
+ if (!normalizedId) {
379
+ return { ok: false, reason: "missing_target", program: null };
380
+ }
381
+ const program = this.#programs.get(normalizedId);
382
+ if (!program) {
383
+ return { ok: false, reason: "not_found", program: null };
384
+ }
385
+ this.#heartbeatScheduler.unregister(this.#scheduleId(program.program_id));
386
+ this.#programs.delete(normalizedId);
387
+ await this.#persist();
388
+ return { ok: true, reason: null, program: this.#snapshot(program) };
389
+ }
390
+ async trigger(opts) {
391
+ await this.#ensureLoaded();
392
+ const programId = opts.programId?.trim() || "";
393
+ if (!programId) {
394
+ return { ok: false, reason: "missing_target", program: null };
395
+ }
396
+ const program = this.#programs.get(programId);
397
+ if (!program) {
398
+ return { ok: false, reason: "not_found", program: null };
399
+ }
400
+ if (!program.enabled) {
401
+ return { ok: false, reason: "not_running", program: this.#snapshot(program) };
402
+ }
403
+ const tick = await this.#tickProgram(program.program_id, opts.reason?.trim() || "manual");
404
+ if (tick.status === "failed") {
405
+ return { ok: false, reason: "failed", program: this.#snapshot(program) };
406
+ }
407
+ return { ok: true, reason: null, program: this.#snapshot(program) };
408
+ }
409
+ stop() {
410
+ for (const program of this.#programs.values()) {
411
+ this.#heartbeatScheduler.unregister(this.#scheduleId(program.program_id));
412
+ }
413
+ this.#programs.clear();
414
+ }
415
+ }
@@ -0,0 +1,38 @@
1
+ export type HeartbeatRunResult = {
2
+ status: "ran";
3
+ durationMs?: number;
4
+ } | {
5
+ status: "skipped";
6
+ reason: string;
7
+ } | {
8
+ status: "failed";
9
+ reason: string;
10
+ };
11
+ export type HeartbeatTickHandler = (opts: {
12
+ activityId: string;
13
+ reason?: string;
14
+ }) => Promise<HeartbeatRunResult> | HeartbeatRunResult;
15
+ export type ActivityHeartbeatSchedulerOpts = {
16
+ nowMs?: () => number;
17
+ defaultCoalesceMs?: number;
18
+ retryMs?: number;
19
+ minIntervalMs?: number;
20
+ };
21
+ export declare class ActivityHeartbeatScheduler {
22
+ #private;
23
+ constructor(opts?: ActivityHeartbeatSchedulerOpts);
24
+ register(opts: {
25
+ activityId: string;
26
+ everyMs: number;
27
+ handler: HeartbeatTickHandler;
28
+ coalesceMs?: number;
29
+ }): void;
30
+ requestNow(activityIdRaw: string, opts?: {
31
+ reason?: string;
32
+ coalesceMs?: number;
33
+ }): boolean;
34
+ unregister(activityIdRaw: string): boolean;
35
+ has(activityIdRaw: string): boolean;
36
+ listActivityIds(): string[];
37
+ stop(): void;
38
+ }
@@ -0,0 +1,238 @@
1
+ const DEFAULT_COALESCE_MS = 250;
2
+ const DEFAULT_RETRY_MS = 1_000;
3
+ const DEFAULT_MIN_INTERVAL_MS = 2_000;
4
+ const HOOK_REASON_PREFIX = "hook:";
5
+ const REASON_PRIORITY = {
6
+ RETRY: 0,
7
+ INTERVAL: 1,
8
+ DEFAULT: 2,
9
+ ACTION: 3,
10
+ };
11
+ function defaultNowMs() {
12
+ return Date.now();
13
+ }
14
+ function normalizeActivityId(value) {
15
+ const trimmed = value.trim();
16
+ if (trimmed.length === 0) {
17
+ throw new Error("activity_id_required");
18
+ }
19
+ return trimmed;
20
+ }
21
+ function normalizeReason(reason) {
22
+ if (typeof reason !== "string") {
23
+ return "requested";
24
+ }
25
+ const trimmed = reason.trim();
26
+ return trimmed.length > 0 ? trimmed : "requested";
27
+ }
28
+ function isActionReason(reason) {
29
+ if (reason === "manual" || reason === "exec-event") {
30
+ return true;
31
+ }
32
+ return reason.startsWith(HOOK_REASON_PREFIX);
33
+ }
34
+ function resolveReasonPriority(reason) {
35
+ if (reason === "retry") {
36
+ return REASON_PRIORITY.RETRY;
37
+ }
38
+ if (reason === "interval") {
39
+ return REASON_PRIORITY.INTERVAL;
40
+ }
41
+ if (isActionReason(reason)) {
42
+ return REASON_PRIORITY.ACTION;
43
+ }
44
+ return REASON_PRIORITY.DEFAULT;
45
+ }
46
+ function toMs(value, fallback, min) {
47
+ if (typeof value !== "number" || !Number.isFinite(value)) {
48
+ return fallback;
49
+ }
50
+ return Math.max(min, Math.trunc(value));
51
+ }
52
+ export class ActivityHeartbeatScheduler {
53
+ #states = new Map();
54
+ #nowMs;
55
+ #defaultCoalesceMs;
56
+ #retryMs;
57
+ #minIntervalMs;
58
+ constructor(opts = {}) {
59
+ this.#nowMs = opts.nowMs ?? defaultNowMs;
60
+ this.#defaultCoalesceMs = Math.max(0, Math.trunc(opts.defaultCoalesceMs ?? DEFAULT_COALESCE_MS));
61
+ this.#retryMs = Math.max(100, Math.trunc(opts.retryMs ?? DEFAULT_RETRY_MS));
62
+ this.#minIntervalMs = Math.max(100, Math.trunc(opts.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS));
63
+ }
64
+ #clearWakeTimer(state) {
65
+ if (state.wakeTimer) {
66
+ clearTimeout(state.wakeTimer);
67
+ state.wakeTimer = null;
68
+ }
69
+ state.wakeDueAt = null;
70
+ state.wakeKind = null;
71
+ }
72
+ #disposeState(state) {
73
+ if (state.intervalTimer) {
74
+ clearInterval(state.intervalTimer);
75
+ state.intervalTimer = null;
76
+ }
77
+ this.#clearWakeTimer(state);
78
+ state.pendingWake = null;
79
+ state.running = false;
80
+ state.scheduled = false;
81
+ }
82
+ #queuePendingWakeReason(state, reason) {
83
+ const normalized = normalizeReason(reason);
84
+ const next = {
85
+ reason: normalized,
86
+ priority: resolveReasonPriority(normalized),
87
+ requestedAt: this.#nowMs(),
88
+ };
89
+ if (!state.pendingWake) {
90
+ state.pendingWake = next;
91
+ return;
92
+ }
93
+ if (next.priority > state.pendingWake.priority) {
94
+ state.pendingWake = next;
95
+ return;
96
+ }
97
+ if (next.priority === state.pendingWake.priority && next.requestedAt >= state.pendingWake.requestedAt) {
98
+ state.pendingWake = next;
99
+ }
100
+ }
101
+ #schedule(state, coalesceMs, kind = "normal") {
102
+ const delay = Math.max(0, Math.trunc(Number.isFinite(coalesceMs) ? coalesceMs : this.#defaultCoalesceMs));
103
+ const dueAt = this.#nowMs() + delay;
104
+ if (state.wakeTimer) {
105
+ // Retry cooldown should remain in force.
106
+ if (state.wakeKind === "retry") {
107
+ return;
108
+ }
109
+ if (typeof state.wakeDueAt === "number" && state.wakeDueAt <= dueAt) {
110
+ return;
111
+ }
112
+ this.#clearWakeTimer(state);
113
+ }
114
+ state.wakeDueAt = dueAt;
115
+ state.wakeKind = kind;
116
+ state.wakeTimer = setTimeout(() => {
117
+ void this.#flush(state.activityId, delay, kind);
118
+ }, delay);
119
+ state.wakeTimer.unref?.();
120
+ }
121
+ async #flush(activityId, delay, kind) {
122
+ const state = this.#states.get(activityId);
123
+ if (!state) {
124
+ return;
125
+ }
126
+ this.#clearWakeTimer(state);
127
+ state.scheduled = false;
128
+ if (state.running) {
129
+ state.scheduled = true;
130
+ this.#schedule(state, delay, kind);
131
+ return;
132
+ }
133
+ const reason = state.pendingWake?.reason;
134
+ state.pendingWake = null;
135
+ state.running = true;
136
+ let result;
137
+ const startedAt = this.#nowMs();
138
+ try {
139
+ result = await state.handler({ activityId, reason: reason ?? undefined });
140
+ if (result.status === "ran" && result.durationMs == null) {
141
+ result = {
142
+ status: "ran",
143
+ durationMs: Math.max(0, Math.trunc(this.#nowMs() - startedAt)),
144
+ };
145
+ }
146
+ }
147
+ catch (err) {
148
+ result = {
149
+ status: "failed",
150
+ reason: err instanceof Error ? err.message : String(err),
151
+ };
152
+ }
153
+ state.running = false;
154
+ // If the activity was removed while the handler was running, bail out quietly.
155
+ if (this.#states.get(activityId) !== state) {
156
+ return;
157
+ }
158
+ if (result.status === "failed") {
159
+ this.#queuePendingWakeReason(state, reason ?? "retry");
160
+ this.#schedule(state, this.#retryMs, "retry");
161
+ }
162
+ else if (result.status === "skipped" && result.reason === "requests-in-flight") {
163
+ this.#queuePendingWakeReason(state, reason ?? "retry");
164
+ this.#schedule(state, this.#retryMs, "retry");
165
+ }
166
+ if (state.pendingWake || state.scheduled) {
167
+ this.#schedule(state, state.coalesceMs, "normal");
168
+ }
169
+ }
170
+ register(opts) {
171
+ const activityId = normalizeActivityId(opts.activityId);
172
+ const existing = this.#states.get(activityId);
173
+ if (existing) {
174
+ this.#disposeState(existing);
175
+ this.#states.delete(activityId);
176
+ }
177
+ const state = {
178
+ activityId,
179
+ everyMs: toMs(opts.everyMs, this.#minIntervalMs, this.#minIntervalMs),
180
+ coalesceMs: Math.max(0, Math.trunc(opts.coalesceMs ?? this.#defaultCoalesceMs)),
181
+ handler: opts.handler,
182
+ pendingWake: null,
183
+ scheduled: false,
184
+ running: false,
185
+ intervalTimer: null,
186
+ wakeTimer: null,
187
+ wakeDueAt: null,
188
+ wakeKind: null,
189
+ };
190
+ state.intervalTimer = setInterval(() => {
191
+ this.requestNow(activityId, { reason: "interval", coalesceMs: 0 });
192
+ }, state.everyMs);
193
+ state.intervalTimer.unref?.();
194
+ this.#states.set(activityId, state);
195
+ }
196
+ requestNow(activityIdRaw, opts) {
197
+ const activityId = activityIdRaw.trim();
198
+ if (activityId.length === 0) {
199
+ return false;
200
+ }
201
+ const state = this.#states.get(activityId);
202
+ if (!state) {
203
+ return false;
204
+ }
205
+ this.#queuePendingWakeReason(state, opts?.reason);
206
+ this.#schedule(state, opts?.coalesceMs ?? state.coalesceMs, "normal");
207
+ return true;
208
+ }
209
+ unregister(activityIdRaw) {
210
+ const activityId = activityIdRaw.trim();
211
+ if (activityId.length === 0) {
212
+ return false;
213
+ }
214
+ const state = this.#states.get(activityId);
215
+ if (!state) {
216
+ return false;
217
+ }
218
+ this.#disposeState(state);
219
+ this.#states.delete(activityId);
220
+ return true;
221
+ }
222
+ has(activityIdRaw) {
223
+ const activityId = activityIdRaw.trim();
224
+ if (activityId.length === 0) {
225
+ return false;
226
+ }
227
+ return this.#states.has(activityId);
228
+ }
229
+ listActivityIds() {
230
+ return [...this.#states.keys()];
231
+ }
232
+ stop() {
233
+ for (const state of this.#states.values()) {
234
+ this.#disposeState(state);
235
+ }
236
+ this.#states.clear();
237
+ }
238
+ }
package/dist/index.d.ts CHANGED
@@ -1,6 +1,12 @@
1
1
  export type { MuConfig, MuConfigPatch, MuConfigPresence } from "./config.js";
2
2
  export { applyMuConfigPatch, DEFAULT_MU_CONFIG, getMuConfigPath, muConfigPresence, normalizeMuConfig, readMuConfigFile, redactMuConfigSecrets, writeMuConfigFile, } from "./config.js";
3
+ export type { ControlPlaneActivityEvent, ControlPlaneActivityEventKind, ControlPlaneActivityMutationResult, ControlPlaneActivitySnapshot, ControlPlaneActivityStatus, ControlPlaneActivitySupervisorOpts, } from "./activity_supervisor.js";
4
+ export { ControlPlaneActivitySupervisor } from "./activity_supervisor.js";
5
+ export type { HeartbeatProgramOperationResult, HeartbeatProgramRegistryOpts, HeartbeatProgramSnapshot, HeartbeatProgramTarget, HeartbeatProgramTickEvent, } from "./heartbeat_programs.js";
6
+ export { HeartbeatProgramRegistry } from "./heartbeat_programs.js";
3
7
  export type { ActiveAdapter, ControlPlaneConfig, ControlPlaneHandle } from "./control_plane.js";
4
8
  export { bootstrapControlPlane, detectAdapters } from "./control_plane.js";
9
+ export type { HeartbeatRunResult, HeartbeatTickHandler, ActivityHeartbeatSchedulerOpts } from "./heartbeat_scheduler.js";
10
+ export { ActivityHeartbeatScheduler } from "./heartbeat_scheduler.js";
5
11
  export type { ServerContext, ServerOptions, ServerWithControlPlane } from "./server.js";
6
12
  export { createContext, createServer, createServerAsync } from "./server.js";