@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.
- 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/server.js
CHANGED
|
@@ -9,8 +9,9 @@ import { forumRoutes } from "./api/forum.js";
|
|
|
9
9
|
import { issueRoutes } from "./api/issues.js";
|
|
10
10
|
import { applyMuConfigPatch, DEFAULT_MU_CONFIG, getMuConfigPath, muConfigPresence, readMuConfigFile, redactMuConfigSecrets, writeMuConfigFile, } from "./config.js";
|
|
11
11
|
import { bootstrapControlPlane, } from "./control_plane.js";
|
|
12
|
+
import { CronProgramRegistry } from "./cron_programs.js";
|
|
12
13
|
import { ControlPlaneGenerationSupervisor } from "./generation_supervisor.js";
|
|
13
|
-
import { HeartbeatProgramRegistry } from "./heartbeat_programs.js";
|
|
14
|
+
import { HeartbeatProgramRegistry, } from "./heartbeat_programs.js";
|
|
14
15
|
import { ActivityHeartbeatScheduler } from "./heartbeat_scheduler.js";
|
|
15
16
|
const MIME_TYPES = {
|
|
16
17
|
".html": "text/html; charset=utf-8",
|
|
@@ -26,6 +27,25 @@ const MIME_TYPES = {
|
|
|
26
27
|
};
|
|
27
28
|
// Resolve public/ dir relative to this file (works in npm global installs)
|
|
28
29
|
const PUBLIC_DIR = join(new URL(".", import.meta.url).pathname, "..", "public");
|
|
30
|
+
const DEFAULT_OPERATOR_WAKE_COALESCE_MS = 2_000;
|
|
31
|
+
const DEFAULT_AUTO_RUN_HEARTBEAT_EVERY_MS = 15_000;
|
|
32
|
+
const AUTO_RUN_HEARTBEAT_REASON = "auto-run-heartbeat";
|
|
33
|
+
function normalizeWakeMode(value) {
|
|
34
|
+
if (typeof value !== "string") {
|
|
35
|
+
return "immediate";
|
|
36
|
+
}
|
|
37
|
+
const normalized = value.trim().toLowerCase().replaceAll("-", "_");
|
|
38
|
+
return normalized === "next_heartbeat" ? "next_heartbeat" : "immediate";
|
|
39
|
+
}
|
|
40
|
+
function toNonNegativeInt(value, fallback) {
|
|
41
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
42
|
+
return Math.max(0, Math.trunc(value));
|
|
43
|
+
}
|
|
44
|
+
if (typeof value === "string" && /^\d+$/.test(value.trim())) {
|
|
45
|
+
return Math.max(0, Number.parseInt(value, 10));
|
|
46
|
+
}
|
|
47
|
+
return Math.max(0, Math.trunc(fallback));
|
|
48
|
+
}
|
|
29
49
|
function describeError(err) {
|
|
30
50
|
if (err instanceof Error)
|
|
31
51
|
return err.message;
|
|
@@ -41,6 +61,71 @@ function summarizeControlPlane(handle) {
|
|
|
41
61
|
routes: handle.activeAdapters.map((adapter) => ({ name: adapter.name, route: adapter.route })),
|
|
42
62
|
};
|
|
43
63
|
}
|
|
64
|
+
function parseCronTarget(body) {
|
|
65
|
+
const targetKind = typeof body.target_kind === "string" ? body.target_kind.trim().toLowerCase() : "";
|
|
66
|
+
if (targetKind === "run") {
|
|
67
|
+
const jobId = typeof body.run_job_id === "string" ? body.run_job_id.trim() : "";
|
|
68
|
+
const rootIssueId = typeof body.run_root_issue_id === "string" ? body.run_root_issue_id.trim() : "";
|
|
69
|
+
if (!jobId && !rootIssueId) {
|
|
70
|
+
return {
|
|
71
|
+
target: null,
|
|
72
|
+
error: "run target requires run_job_id or run_root_issue_id",
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
target: {
|
|
77
|
+
kind: "run",
|
|
78
|
+
job_id: jobId || null,
|
|
79
|
+
root_issue_id: rootIssueId || null,
|
|
80
|
+
},
|
|
81
|
+
error: null,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
if (targetKind === "activity") {
|
|
85
|
+
const activityId = typeof body.activity_id === "string" ? body.activity_id.trim() : "";
|
|
86
|
+
if (!activityId) {
|
|
87
|
+
return {
|
|
88
|
+
target: null,
|
|
89
|
+
error: "activity target requires activity_id",
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
target: {
|
|
94
|
+
kind: "activity",
|
|
95
|
+
activity_id: activityId,
|
|
96
|
+
},
|
|
97
|
+
error: null,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
target: null,
|
|
102
|
+
error: "target_kind must be run or activity",
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
function hasCronScheduleInput(body) {
|
|
106
|
+
return (body.schedule != null ||
|
|
107
|
+
body.schedule_kind != null ||
|
|
108
|
+
body.at_ms != null ||
|
|
109
|
+
body.at != null ||
|
|
110
|
+
body.every_ms != null ||
|
|
111
|
+
body.anchor_ms != null ||
|
|
112
|
+
body.expr != null ||
|
|
113
|
+
body.tz != null);
|
|
114
|
+
}
|
|
115
|
+
function cronScheduleInputFromBody(body) {
|
|
116
|
+
if (body.schedule && typeof body.schedule === "object" && !Array.isArray(body.schedule)) {
|
|
117
|
+
return { ...body.schedule };
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
kind: typeof body.schedule_kind === "string" ? body.schedule_kind : undefined,
|
|
121
|
+
at_ms: body.at_ms,
|
|
122
|
+
at: body.at,
|
|
123
|
+
every_ms: body.every_ms,
|
|
124
|
+
anchor_ms: body.anchor_ms,
|
|
125
|
+
expr: body.expr,
|
|
126
|
+
tz: body.tz,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
44
129
|
export function createContext(repoRoot) {
|
|
45
130
|
const paths = getStorePaths(repoRoot);
|
|
46
131
|
const eventsStore = new FsJsonlStore(paths.eventsPath);
|
|
@@ -76,6 +161,33 @@ export function createServer(options = {}) {
|
|
|
76
161
|
});
|
|
77
162
|
},
|
|
78
163
|
});
|
|
164
|
+
const operatorWakeCoalesceMs = toNonNegativeInt(options.operatorWakeCoalesceMs, DEFAULT_OPERATOR_WAKE_COALESCE_MS);
|
|
165
|
+
const autoRunHeartbeatEveryMs = Math.max(1_000, toNonNegativeInt(options.autoRunHeartbeatEveryMs, DEFAULT_AUTO_RUN_HEARTBEAT_EVERY_MS));
|
|
166
|
+
const operatorWakeLastByKey = new Map();
|
|
167
|
+
const autoRunHeartbeatProgramByJobId = new Map();
|
|
168
|
+
const emitOperatorWake = async (opts) => {
|
|
169
|
+
const dedupeKey = opts.dedupeKey.trim();
|
|
170
|
+
if (!dedupeKey) {
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
const nowMs = Date.now();
|
|
174
|
+
const coalesceMs = Math.max(0, Math.trunc(opts.coalesceMs ?? operatorWakeCoalesceMs));
|
|
175
|
+
const previous = operatorWakeLastByKey.get(dedupeKey);
|
|
176
|
+
if (typeof previous === "number" && nowMs - previous < coalesceMs) {
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
operatorWakeLastByKey.set(dedupeKey, nowMs);
|
|
180
|
+
await context.eventLog.emit("operator.wake", {
|
|
181
|
+
source: "mu-server.operator-wake",
|
|
182
|
+
payload: {
|
|
183
|
+
message: opts.message,
|
|
184
|
+
dedupe_key: dedupeKey,
|
|
185
|
+
coalesce_ms: coalesceMs,
|
|
186
|
+
...opts.payload,
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
return true;
|
|
190
|
+
};
|
|
79
191
|
let controlPlaneCurrent = options.controlPlane ?? null;
|
|
80
192
|
let reloadInFlight = null;
|
|
81
193
|
const generationTelemetry = options.generationTelemetry ?? new GenerationTelemetryRecorder();
|
|
@@ -174,6 +286,7 @@ export function createServer(options = {}) {
|
|
|
174
286
|
jobId: opts.jobId ?? null,
|
|
175
287
|
rootIssueId: opts.rootIssueId ?? null,
|
|
176
288
|
reason: opts.reason ?? null,
|
|
289
|
+
wakeMode: opts.wakeMode,
|
|
177
290
|
});
|
|
178
291
|
return result ?? { ok: false, reason: "not_found" };
|
|
179
292
|
},
|
|
@@ -194,8 +307,217 @@ export function createServer(options = {}) {
|
|
|
194
307
|
program: event.program,
|
|
195
308
|
},
|
|
196
309
|
});
|
|
310
|
+
await emitOperatorWake({
|
|
311
|
+
dedupeKey: `heartbeat-program:${event.program_id}`,
|
|
312
|
+
message: event.message,
|
|
313
|
+
payload: {
|
|
314
|
+
wake_source: "heartbeat_program",
|
|
315
|
+
program_id: event.program_id,
|
|
316
|
+
status: event.status,
|
|
317
|
+
reason: event.reason,
|
|
318
|
+
wake_mode: event.program.wake_mode,
|
|
319
|
+
target_kind: event.program.target.kind,
|
|
320
|
+
target: event.program.target.kind === "run"
|
|
321
|
+
? {
|
|
322
|
+
job_id: event.program.target.job_id,
|
|
323
|
+
root_issue_id: event.program.target.root_issue_id,
|
|
324
|
+
}
|
|
325
|
+
: { activity_id: event.program.target.activity_id },
|
|
326
|
+
},
|
|
327
|
+
});
|
|
197
328
|
},
|
|
198
329
|
});
|
|
330
|
+
const cronPrograms = new CronProgramRegistry({
|
|
331
|
+
repoRoot,
|
|
332
|
+
heartbeatScheduler,
|
|
333
|
+
runHeartbeat: async (opts) => {
|
|
334
|
+
const result = await controlPlaneProxy.heartbeatRun?.({
|
|
335
|
+
jobId: opts.jobId ?? null,
|
|
336
|
+
rootIssueId: opts.rootIssueId ?? null,
|
|
337
|
+
reason: opts.reason ?? null,
|
|
338
|
+
wakeMode: opts.wakeMode,
|
|
339
|
+
});
|
|
340
|
+
return result ?? { ok: false, reason: "not_found" };
|
|
341
|
+
},
|
|
342
|
+
activityHeartbeat: async (opts) => {
|
|
343
|
+
return activitySupervisor.heartbeat({
|
|
344
|
+
activityId: opts.activityId ?? null,
|
|
345
|
+
reason: opts.reason ?? null,
|
|
346
|
+
});
|
|
347
|
+
},
|
|
348
|
+
onLifecycleEvent: async (event) => {
|
|
349
|
+
await context.eventLog.emit("cron_program.lifecycle", {
|
|
350
|
+
source: "mu-server.cron-programs",
|
|
351
|
+
payload: {
|
|
352
|
+
action: event.action,
|
|
353
|
+
program_id: event.program_id,
|
|
354
|
+
message: event.message,
|
|
355
|
+
program: event.program,
|
|
356
|
+
},
|
|
357
|
+
});
|
|
358
|
+
},
|
|
359
|
+
onTickEvent: async (event) => {
|
|
360
|
+
await context.eventLog.emit("cron_program.tick", {
|
|
361
|
+
source: "mu-server.cron-programs",
|
|
362
|
+
payload: {
|
|
363
|
+
program_id: event.program_id,
|
|
364
|
+
status: event.status,
|
|
365
|
+
reason: event.reason,
|
|
366
|
+
message: event.message,
|
|
367
|
+
program: event.program,
|
|
368
|
+
},
|
|
369
|
+
});
|
|
370
|
+
await emitOperatorWake({
|
|
371
|
+
dedupeKey: `cron-program:${event.program_id}`,
|
|
372
|
+
message: event.message,
|
|
373
|
+
payload: {
|
|
374
|
+
wake_source: "cron_program",
|
|
375
|
+
program_id: event.program_id,
|
|
376
|
+
status: event.status,
|
|
377
|
+
reason: event.reason,
|
|
378
|
+
wake_mode: event.program.wake_mode,
|
|
379
|
+
target_kind: event.program.target.kind,
|
|
380
|
+
target: event.program.target.kind === "run"
|
|
381
|
+
? {
|
|
382
|
+
job_id: event.program.target.job_id,
|
|
383
|
+
root_issue_id: event.program.target.root_issue_id,
|
|
384
|
+
}
|
|
385
|
+
: { activity_id: event.program.target.activity_id },
|
|
386
|
+
},
|
|
387
|
+
});
|
|
388
|
+
},
|
|
389
|
+
});
|
|
390
|
+
const findAutoRunHeartbeatProgram = async (jobId) => {
|
|
391
|
+
const normalizedJobId = jobId.trim();
|
|
392
|
+
if (!normalizedJobId) {
|
|
393
|
+
return null;
|
|
394
|
+
}
|
|
395
|
+
const knownProgramId = autoRunHeartbeatProgramByJobId.get(normalizedJobId);
|
|
396
|
+
if (knownProgramId) {
|
|
397
|
+
const knownProgram = await heartbeatPrograms.get(knownProgramId);
|
|
398
|
+
if (knownProgram) {
|
|
399
|
+
return knownProgram;
|
|
400
|
+
}
|
|
401
|
+
autoRunHeartbeatProgramByJobId.delete(normalizedJobId);
|
|
402
|
+
}
|
|
403
|
+
const programs = await heartbeatPrograms.list({ targetKind: "run", limit: 500 });
|
|
404
|
+
for (const program of programs) {
|
|
405
|
+
if (program.metadata.auto_run_job_id !== normalizedJobId) {
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
autoRunHeartbeatProgramByJobId.set(normalizedJobId, program.program_id);
|
|
409
|
+
return program;
|
|
410
|
+
}
|
|
411
|
+
return null;
|
|
412
|
+
};
|
|
413
|
+
const registerAutoRunHeartbeatProgram = async (run) => {
|
|
414
|
+
if (run.source === "command") {
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
const jobId = run.job_id.trim();
|
|
418
|
+
if (!jobId || run.status !== "running") {
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
const rootIssueId = typeof run.root_issue_id === "string" ? run.root_issue_id.trim() : "";
|
|
422
|
+
const metadata = {
|
|
423
|
+
auto_run_heartbeat: true,
|
|
424
|
+
auto_run_job_id: jobId,
|
|
425
|
+
auto_run_root_issue_id: rootIssueId || null,
|
|
426
|
+
auto_disable_on_terminal: true,
|
|
427
|
+
run_mode: run.mode,
|
|
428
|
+
run_source: run.source,
|
|
429
|
+
};
|
|
430
|
+
const existing = await findAutoRunHeartbeatProgram(jobId);
|
|
431
|
+
if (existing) {
|
|
432
|
+
const result = await heartbeatPrograms.update({
|
|
433
|
+
programId: existing.program_id,
|
|
434
|
+
title: `Run heartbeat: ${rootIssueId || jobId}`,
|
|
435
|
+
target: {
|
|
436
|
+
kind: "run",
|
|
437
|
+
job_id: jobId,
|
|
438
|
+
root_issue_id: rootIssueId || null,
|
|
439
|
+
},
|
|
440
|
+
enabled: true,
|
|
441
|
+
everyMs: autoRunHeartbeatEveryMs,
|
|
442
|
+
reason: AUTO_RUN_HEARTBEAT_REASON,
|
|
443
|
+
wakeMode: "next_heartbeat",
|
|
444
|
+
metadata,
|
|
445
|
+
});
|
|
446
|
+
if (result.ok && result.program) {
|
|
447
|
+
autoRunHeartbeatProgramByJobId.set(jobId, result.program.program_id);
|
|
448
|
+
await context.eventLog.emit("run.auto_heartbeat.lifecycle", {
|
|
449
|
+
source: "mu-server.runs",
|
|
450
|
+
payload: {
|
|
451
|
+
action: "updated",
|
|
452
|
+
run_job_id: jobId,
|
|
453
|
+
run_root_issue_id: rootIssueId || null,
|
|
454
|
+
program_id: result.program.program_id,
|
|
455
|
+
program: result.program,
|
|
456
|
+
},
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
const created = await heartbeatPrograms.create({
|
|
462
|
+
title: `Run heartbeat: ${rootIssueId || jobId}`,
|
|
463
|
+
target: {
|
|
464
|
+
kind: "run",
|
|
465
|
+
job_id: jobId,
|
|
466
|
+
root_issue_id: rootIssueId || null,
|
|
467
|
+
},
|
|
468
|
+
everyMs: autoRunHeartbeatEveryMs,
|
|
469
|
+
reason: AUTO_RUN_HEARTBEAT_REASON,
|
|
470
|
+
wakeMode: "next_heartbeat",
|
|
471
|
+
metadata,
|
|
472
|
+
enabled: true,
|
|
473
|
+
});
|
|
474
|
+
autoRunHeartbeatProgramByJobId.set(jobId, created.program_id);
|
|
475
|
+
await context.eventLog.emit("run.auto_heartbeat.lifecycle", {
|
|
476
|
+
source: "mu-server.runs",
|
|
477
|
+
payload: {
|
|
478
|
+
action: "registered",
|
|
479
|
+
run_job_id: jobId,
|
|
480
|
+
run_root_issue_id: rootIssueId || null,
|
|
481
|
+
program_id: created.program_id,
|
|
482
|
+
program: created,
|
|
483
|
+
},
|
|
484
|
+
});
|
|
485
|
+
};
|
|
486
|
+
const disableAutoRunHeartbeatProgram = async (opts) => {
|
|
487
|
+
const program = await findAutoRunHeartbeatProgram(opts.jobId);
|
|
488
|
+
if (!program) {
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
const metadata = {
|
|
492
|
+
...program.metadata,
|
|
493
|
+
auto_disabled_from_status: opts.status,
|
|
494
|
+
auto_disabled_reason: opts.reason,
|
|
495
|
+
auto_disabled_at_ms: Date.now(),
|
|
496
|
+
};
|
|
497
|
+
const result = await heartbeatPrograms.update({
|
|
498
|
+
programId: program.program_id,
|
|
499
|
+
enabled: false,
|
|
500
|
+
everyMs: 0,
|
|
501
|
+
reason: AUTO_RUN_HEARTBEAT_REASON,
|
|
502
|
+
wakeMode: program.wake_mode,
|
|
503
|
+
metadata,
|
|
504
|
+
});
|
|
505
|
+
autoRunHeartbeatProgramByJobId.delete(opts.jobId.trim());
|
|
506
|
+
if (!result.ok || !result.program) {
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
await context.eventLog.emit("run.auto_heartbeat.lifecycle", {
|
|
510
|
+
source: "mu-server.runs",
|
|
511
|
+
payload: {
|
|
512
|
+
action: "disabled",
|
|
513
|
+
run_job_id: opts.jobId,
|
|
514
|
+
status: opts.status,
|
|
515
|
+
reason: opts.reason,
|
|
516
|
+
program_id: result.program.program_id,
|
|
517
|
+
program: result.program,
|
|
518
|
+
},
|
|
519
|
+
});
|
|
520
|
+
};
|
|
199
521
|
const loadConfigFromDisk = async () => {
|
|
200
522
|
try {
|
|
201
523
|
return await readConfig(context.repoRoot);
|
|
@@ -813,6 +1135,16 @@ export function createServer(options = {}) {
|
|
|
813
1135
|
if (!run) {
|
|
814
1136
|
return Response.json({ error: "run supervisor unavailable" }, { status: 503, headers });
|
|
815
1137
|
}
|
|
1138
|
+
await registerAutoRunHeartbeatProgram(run).catch(async (error) => {
|
|
1139
|
+
await context.eventLog.emit("run.auto_heartbeat.lifecycle", {
|
|
1140
|
+
source: "mu-server.runs",
|
|
1141
|
+
payload: {
|
|
1142
|
+
action: "register_failed",
|
|
1143
|
+
run_job_id: run.job_id,
|
|
1144
|
+
error: describeError(error),
|
|
1145
|
+
},
|
|
1146
|
+
});
|
|
1147
|
+
});
|
|
816
1148
|
return Response.json({ ok: true, run }, { status: 201, headers });
|
|
817
1149
|
}
|
|
818
1150
|
catch (err) {
|
|
@@ -842,6 +1174,16 @@ export function createServer(options = {}) {
|
|
|
842
1174
|
if (!run) {
|
|
843
1175
|
return Response.json({ error: "run supervisor unavailable" }, { status: 503, headers });
|
|
844
1176
|
}
|
|
1177
|
+
await registerAutoRunHeartbeatProgram(run).catch(async (error) => {
|
|
1178
|
+
await context.eventLog.emit("run.auto_heartbeat.lifecycle", {
|
|
1179
|
+
source: "mu-server.runs",
|
|
1180
|
+
payload: {
|
|
1181
|
+
action: "register_failed",
|
|
1182
|
+
run_job_id: run.job_id,
|
|
1183
|
+
error: describeError(error),
|
|
1184
|
+
},
|
|
1185
|
+
});
|
|
1186
|
+
});
|
|
845
1187
|
return Response.json({ ok: true, run }, { status: 201, headers });
|
|
846
1188
|
}
|
|
847
1189
|
catch (err) {
|
|
@@ -868,6 +1210,15 @@ export function createServer(options = {}) {
|
|
|
868
1210
|
if (!result) {
|
|
869
1211
|
return Response.json({ error: "run supervisor unavailable" }, { status: 503, headers });
|
|
870
1212
|
}
|
|
1213
|
+
if (!result.ok && result.reason === "not_running" && result.run) {
|
|
1214
|
+
await disableAutoRunHeartbeatProgram({
|
|
1215
|
+
jobId: result.run.job_id,
|
|
1216
|
+
status: result.run.status,
|
|
1217
|
+
reason: "interrupt_not_running",
|
|
1218
|
+
}).catch(() => {
|
|
1219
|
+
// best effort cleanup only
|
|
1220
|
+
});
|
|
1221
|
+
}
|
|
871
1222
|
return Response.json(result, { status: result.ok ? 200 : 404, headers });
|
|
872
1223
|
}
|
|
873
1224
|
if (path === "/api/runs/heartbeat") {
|
|
@@ -884,14 +1235,25 @@ export function createServer(options = {}) {
|
|
|
884
1235
|
const rootIssueId = typeof body.root_issue_id === "string" ? body.root_issue_id.trim() : null;
|
|
885
1236
|
const jobId = typeof body.job_id === "string" ? body.job_id.trim() : null;
|
|
886
1237
|
const reason = typeof body.reason === "string" ? body.reason.trim() : null;
|
|
1238
|
+
const wakeMode = normalizeWakeMode(body.wake_mode);
|
|
887
1239
|
const result = await controlPlaneProxy.heartbeatRun?.({
|
|
888
1240
|
rootIssueId,
|
|
889
1241
|
jobId,
|
|
890
1242
|
reason,
|
|
1243
|
+
wakeMode,
|
|
891
1244
|
});
|
|
892
1245
|
if (!result) {
|
|
893
1246
|
return Response.json({ error: "run supervisor unavailable" }, { status: 503, headers });
|
|
894
1247
|
}
|
|
1248
|
+
if (!result.ok && result.reason === "not_running" && result.run) {
|
|
1249
|
+
await disableAutoRunHeartbeatProgram({
|
|
1250
|
+
jobId: result.run.job_id,
|
|
1251
|
+
status: result.run.status,
|
|
1252
|
+
reason: "run_not_running",
|
|
1253
|
+
}).catch(() => {
|
|
1254
|
+
// best effort cleanup only
|
|
1255
|
+
});
|
|
1256
|
+
}
|
|
895
1257
|
if (result.ok) {
|
|
896
1258
|
return Response.json(result, { status: 200, headers });
|
|
897
1259
|
}
|
|
@@ -931,8 +1293,193 @@ export function createServer(options = {}) {
|
|
|
931
1293
|
if (!run) {
|
|
932
1294
|
return Response.json({ error: "run not found" }, { status: 404, headers });
|
|
933
1295
|
}
|
|
1296
|
+
if (run.status !== "running") {
|
|
1297
|
+
await disableAutoRunHeartbeatProgram({
|
|
1298
|
+
jobId: run.job_id,
|
|
1299
|
+
status: run.status,
|
|
1300
|
+
reason: "run_terminal_snapshot",
|
|
1301
|
+
}).catch(() => {
|
|
1302
|
+
// best effort cleanup only
|
|
1303
|
+
});
|
|
1304
|
+
}
|
|
934
1305
|
return Response.json(run, { headers });
|
|
935
1306
|
}
|
|
1307
|
+
if (path === "/api/cron/status") {
|
|
1308
|
+
if (request.method !== "GET") {
|
|
1309
|
+
return Response.json({ error: "Method Not Allowed" }, { status: 405, headers });
|
|
1310
|
+
}
|
|
1311
|
+
const status = await cronPrograms.status();
|
|
1312
|
+
return Response.json(status, { headers });
|
|
1313
|
+
}
|
|
1314
|
+
if (path === "/api/cron") {
|
|
1315
|
+
if (request.method !== "GET") {
|
|
1316
|
+
return Response.json({ error: "Method Not Allowed" }, { status: 405, headers });
|
|
1317
|
+
}
|
|
1318
|
+
const enabledRaw = url.searchParams.get("enabled")?.trim().toLowerCase();
|
|
1319
|
+
const enabled = enabledRaw === "true" ? true : enabledRaw === "false" ? false : undefined;
|
|
1320
|
+
const targetKindRaw = url.searchParams.get("target_kind")?.trim().toLowerCase();
|
|
1321
|
+
const targetKind = targetKindRaw === "run" || targetKindRaw === "activity" ? targetKindRaw : undefined;
|
|
1322
|
+
const scheduleKindRaw = url.searchParams.get("schedule_kind")?.trim().toLowerCase();
|
|
1323
|
+
const scheduleKind = scheduleKindRaw === "at" || scheduleKindRaw === "every" || scheduleKindRaw === "cron"
|
|
1324
|
+
? scheduleKindRaw
|
|
1325
|
+
: undefined;
|
|
1326
|
+
const limitRaw = url.searchParams.get("limit");
|
|
1327
|
+
const limit = limitRaw && /^\d+$/.test(limitRaw) ? Math.max(1, Math.min(500, Number.parseInt(limitRaw, 10))) : undefined;
|
|
1328
|
+
const programs = await cronPrograms.list({ enabled, targetKind, scheduleKind, limit });
|
|
1329
|
+
return Response.json({ count: programs.length, programs }, { headers });
|
|
1330
|
+
}
|
|
1331
|
+
if (path === "/api/cron/create") {
|
|
1332
|
+
if (request.method !== "POST") {
|
|
1333
|
+
return Response.json({ error: "Method Not Allowed" }, { status: 405, headers });
|
|
1334
|
+
}
|
|
1335
|
+
let body;
|
|
1336
|
+
try {
|
|
1337
|
+
body = (await request.json());
|
|
1338
|
+
}
|
|
1339
|
+
catch {
|
|
1340
|
+
return Response.json({ error: "invalid json body" }, { status: 400, headers });
|
|
1341
|
+
}
|
|
1342
|
+
const title = typeof body.title === "string" ? body.title.trim() : "";
|
|
1343
|
+
if (!title) {
|
|
1344
|
+
return Response.json({ error: "title is required" }, { status: 400, headers });
|
|
1345
|
+
}
|
|
1346
|
+
const parsedTarget = parseCronTarget(body);
|
|
1347
|
+
if (!parsedTarget.target) {
|
|
1348
|
+
return Response.json({ error: parsedTarget.error ?? "invalid target" }, { status: 400, headers });
|
|
1349
|
+
}
|
|
1350
|
+
if (!hasCronScheduleInput(body)) {
|
|
1351
|
+
return Response.json({ error: "schedule is required" }, { status: 400, headers });
|
|
1352
|
+
}
|
|
1353
|
+
const schedule = cronScheduleInputFromBody(body);
|
|
1354
|
+
const reason = typeof body.reason === "string" ? body.reason.trim() : undefined;
|
|
1355
|
+
const wakeMode = normalizeWakeMode(body.wake_mode);
|
|
1356
|
+
const enabled = typeof body.enabled === "boolean" ? body.enabled : undefined;
|
|
1357
|
+
try {
|
|
1358
|
+
const program = await cronPrograms.create({
|
|
1359
|
+
title,
|
|
1360
|
+
target: parsedTarget.target,
|
|
1361
|
+
schedule,
|
|
1362
|
+
reason,
|
|
1363
|
+
wakeMode,
|
|
1364
|
+
enabled,
|
|
1365
|
+
metadata: body.metadata && typeof body.metadata === "object" && !Array.isArray(body.metadata)
|
|
1366
|
+
? body.metadata
|
|
1367
|
+
: undefined,
|
|
1368
|
+
});
|
|
1369
|
+
return Response.json({ ok: true, program }, { status: 201, headers });
|
|
1370
|
+
}
|
|
1371
|
+
catch (err) {
|
|
1372
|
+
return Response.json({ error: describeError(err) }, { status: 400, headers });
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
if (path === "/api/cron/update") {
|
|
1376
|
+
if (request.method !== "POST") {
|
|
1377
|
+
return Response.json({ error: "Method Not Allowed" }, { status: 405, headers });
|
|
1378
|
+
}
|
|
1379
|
+
let body;
|
|
1380
|
+
try {
|
|
1381
|
+
body = (await request.json());
|
|
1382
|
+
}
|
|
1383
|
+
catch {
|
|
1384
|
+
return Response.json({ error: "invalid json body" }, { status: 400, headers });
|
|
1385
|
+
}
|
|
1386
|
+
const programId = typeof body.program_id === "string" ? body.program_id.trim() : "";
|
|
1387
|
+
if (!programId) {
|
|
1388
|
+
return Response.json({ error: "program_id is required" }, { status: 400, headers });
|
|
1389
|
+
}
|
|
1390
|
+
let target;
|
|
1391
|
+
if (typeof body.target_kind === "string") {
|
|
1392
|
+
const parsedTarget = parseCronTarget(body);
|
|
1393
|
+
if (!parsedTarget.target) {
|
|
1394
|
+
return Response.json({ error: parsedTarget.error ?? "invalid target" }, { status: 400, headers });
|
|
1395
|
+
}
|
|
1396
|
+
target = parsedTarget.target;
|
|
1397
|
+
}
|
|
1398
|
+
const schedule = hasCronScheduleInput(body) ? cronScheduleInputFromBody(body) : undefined;
|
|
1399
|
+
const wakeMode = Object.hasOwn(body, "wake_mode") ? normalizeWakeMode(body.wake_mode) : undefined;
|
|
1400
|
+
try {
|
|
1401
|
+
const result = await cronPrograms.update({
|
|
1402
|
+
programId,
|
|
1403
|
+
title: typeof body.title === "string" ? body.title : undefined,
|
|
1404
|
+
reason: typeof body.reason === "string" ? body.reason : undefined,
|
|
1405
|
+
wakeMode,
|
|
1406
|
+
enabled: typeof body.enabled === "boolean" ? body.enabled : undefined,
|
|
1407
|
+
target,
|
|
1408
|
+
schedule,
|
|
1409
|
+
metadata: body.metadata && typeof body.metadata === "object" && !Array.isArray(body.metadata)
|
|
1410
|
+
? body.metadata
|
|
1411
|
+
: undefined,
|
|
1412
|
+
});
|
|
1413
|
+
if (result.ok) {
|
|
1414
|
+
return Response.json(result, { headers });
|
|
1415
|
+
}
|
|
1416
|
+
if (result.reason === "not_found") {
|
|
1417
|
+
return Response.json(result, { status: 404, headers });
|
|
1418
|
+
}
|
|
1419
|
+
return Response.json(result, { status: 400, headers });
|
|
1420
|
+
}
|
|
1421
|
+
catch (err) {
|
|
1422
|
+
return Response.json({ error: describeError(err) }, { status: 400, headers });
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
if (path === "/api/cron/delete") {
|
|
1426
|
+
if (request.method !== "POST") {
|
|
1427
|
+
return Response.json({ error: "Method Not Allowed" }, { status: 405, headers });
|
|
1428
|
+
}
|
|
1429
|
+
let body;
|
|
1430
|
+
try {
|
|
1431
|
+
body = (await request.json());
|
|
1432
|
+
}
|
|
1433
|
+
catch {
|
|
1434
|
+
return Response.json({ error: "invalid json body" }, { status: 400, headers });
|
|
1435
|
+
}
|
|
1436
|
+
const programId = typeof body.program_id === "string" ? body.program_id.trim() : "";
|
|
1437
|
+
if (!programId) {
|
|
1438
|
+
return Response.json({ error: "program_id is required" }, { status: 400, headers });
|
|
1439
|
+
}
|
|
1440
|
+
const result = await cronPrograms.remove(programId);
|
|
1441
|
+
return Response.json(result, { status: result.ok ? 200 : result.reason === "not_found" ? 404 : 400, headers });
|
|
1442
|
+
}
|
|
1443
|
+
if (path === "/api/cron/trigger") {
|
|
1444
|
+
if (request.method !== "POST") {
|
|
1445
|
+
return Response.json({ error: "Method Not Allowed" }, { status: 405, headers });
|
|
1446
|
+
}
|
|
1447
|
+
let body;
|
|
1448
|
+
try {
|
|
1449
|
+
body = (await request.json());
|
|
1450
|
+
}
|
|
1451
|
+
catch {
|
|
1452
|
+
return Response.json({ error: "invalid json body" }, { status: 400, headers });
|
|
1453
|
+
}
|
|
1454
|
+
const result = await cronPrograms.trigger({
|
|
1455
|
+
programId: typeof body.program_id === "string" ? body.program_id : null,
|
|
1456
|
+
reason: typeof body.reason === "string" ? body.reason : null,
|
|
1457
|
+
});
|
|
1458
|
+
if (result.ok) {
|
|
1459
|
+
return Response.json(result, { headers });
|
|
1460
|
+
}
|
|
1461
|
+
if (result.reason === "missing_target") {
|
|
1462
|
+
return Response.json(result, { status: 400, headers });
|
|
1463
|
+
}
|
|
1464
|
+
if (result.reason === "not_found") {
|
|
1465
|
+
return Response.json(result, { status: 404, headers });
|
|
1466
|
+
}
|
|
1467
|
+
return Response.json(result, { status: 409, headers });
|
|
1468
|
+
}
|
|
1469
|
+
if (path.startsWith("/api/cron/")) {
|
|
1470
|
+
if (request.method !== "GET") {
|
|
1471
|
+
return Response.json({ error: "Method Not Allowed" }, { status: 405, headers });
|
|
1472
|
+
}
|
|
1473
|
+
const id = decodeURIComponent(path.slice("/api/cron/".length)).trim();
|
|
1474
|
+
if (!id) {
|
|
1475
|
+
return Response.json({ error: "missing program id" }, { status: 400, headers });
|
|
1476
|
+
}
|
|
1477
|
+
const program = await cronPrograms.get(id);
|
|
1478
|
+
if (!program) {
|
|
1479
|
+
return Response.json({ error: "program not found" }, { status: 404, headers });
|
|
1480
|
+
}
|
|
1481
|
+
return Response.json(program, { headers });
|
|
1482
|
+
}
|
|
936
1483
|
if (path === "/api/heartbeats") {
|
|
937
1484
|
if (request.method !== "GET") {
|
|
938
1485
|
return Response.json({ error: "Method Not Allowed" }, { status: 405, headers });
|
|
@@ -992,6 +1539,7 @@ export function createServer(options = {}) {
|
|
|
992
1539
|
? Math.max(0, Math.trunc(body.every_ms))
|
|
993
1540
|
: undefined;
|
|
994
1541
|
const reason = typeof body.reason === "string" ? body.reason.trim() : undefined;
|
|
1542
|
+
const wakeMode = normalizeWakeMode(body.wake_mode);
|
|
995
1543
|
const enabled = typeof body.enabled === "boolean" ? body.enabled : undefined;
|
|
996
1544
|
try {
|
|
997
1545
|
const program = await heartbeatPrograms.create({
|
|
@@ -999,6 +1547,7 @@ export function createServer(options = {}) {
|
|
|
999
1547
|
target,
|
|
1000
1548
|
everyMs,
|
|
1001
1549
|
reason,
|
|
1550
|
+
wakeMode,
|
|
1002
1551
|
enabled,
|
|
1003
1552
|
metadata: body.metadata && typeof body.metadata === "object" && !Array.isArray(body.metadata)
|
|
1004
1553
|
? body.metadata
|
|
@@ -1054,6 +1603,7 @@ export function createServer(options = {}) {
|
|
|
1054
1603
|
return Response.json({ error: "target_kind must be run or activity" }, { status: 400, headers });
|
|
1055
1604
|
}
|
|
1056
1605
|
}
|
|
1606
|
+
const wakeMode = Object.hasOwn(body, "wake_mode") ? normalizeWakeMode(body.wake_mode) : undefined;
|
|
1057
1607
|
try {
|
|
1058
1608
|
const result = await heartbeatPrograms.update({
|
|
1059
1609
|
programId,
|
|
@@ -1063,6 +1613,7 @@ export function createServer(options = {}) {
|
|
|
1063
1613
|
? Math.max(0, Math.trunc(body.every_ms))
|
|
1064
1614
|
: undefined,
|
|
1065
1615
|
reason: typeof body.reason === "string" ? body.reason : undefined,
|
|
1616
|
+
wakeMode,
|
|
1066
1617
|
enabled: typeof body.enabled === "boolean" ? body.enabled : undefined,
|
|
1067
1618
|
metadata: body.metadata && typeof body.metadata === "object" && !Array.isArray(body.metadata)
|
|
1068
1619
|
? body.metadata
|
|
@@ -1447,6 +1998,7 @@ export function createServer(options = {}) {
|
|
|
1447
1998
|
controlPlane: controlPlaneProxy,
|
|
1448
1999
|
activitySupervisor,
|
|
1449
2000
|
heartbeatPrograms,
|
|
2001
|
+
cronPrograms,
|
|
1450
2002
|
};
|
|
1451
2003
|
return server;
|
|
1452
2004
|
}
|