@femtomc/mu-server 26.2.56 → 26.2.58
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.
- package/dist/control_plane.d.ts +4 -1
- package/dist/control_plane.js +2 -0
- package/dist/cron_programs.d.ts +122 -0
- package/dist/cron_programs.js +536 -0
- package/dist/cron_schedule.d.ts +19 -0
- package/dist/cron_schedule.js +383 -0
- package/dist/cron_timer.d.ts +21 -0
- package/dist/cron_timer.js +109 -0
- package/dist/heartbeat_programs.d.ts +6 -1
- package/dist/heartbeat_programs.js +70 -77
- package/dist/heartbeat_scheduler.js +94 -51
- package/dist/index.d.ts +11 -5
- package/dist/index.js +5 -2
- package/dist/run_supervisor.d.ts +2 -0
- package/dist/run_supervisor.js +28 -3
- package/dist/server.d.ts +4 -0
- package/dist/server.js +553 -1
- package/package.json +6 -6
package/dist/control_plane.d.ts
CHANGED
|
@@ -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;
|
package/dist/control_plane.js
CHANGED
|
@@ -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
|
+
}
|