@monotykamary/localterm-server 2.80.2 → 2.81.0
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/agent-runner.d.ts +1 -0
- package/dist/agent-runner.d.ts.map +1 -1
- package/dist/agent-runner.js +203 -151
- package/dist/agent-runner.js.map +1 -1
- package/dist/automation-dispatcher.d.ts +27 -0
- package/dist/automation-dispatcher.d.ts.map +1 -0
- package/dist/automation-dispatcher.js +195 -0
- package/dist/automation-dispatcher.js.map +1 -0
- package/dist/automation-run-tracker.d.ts +1 -1
- package/dist/automation-run-tracker.d.ts.map +1 -1
- package/dist/automation-run-tracker.js +2 -2
- package/dist/automation-run-tracker.js.map +1 -1
- package/dist/automation-scheduler.d.ts +7 -4
- package/dist/automation-scheduler.d.ts.map +1 -1
- package/dist/automation-scheduler.js +59 -39
- package/dist/automation-scheduler.js.map +1 -1
- package/dist/automation-store.d.ts +1 -0
- package/dist/automation-store.d.ts.map +1 -1
- package/dist/automation-store.js +59 -23
- package/dist/automation-store.js.map +1 -1
- package/dist/constants.d.ts +12 -1
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +13 -1
- package/dist/constants.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +195 -213
- package/dist/index.js.map +1 -1
- package/dist/pi-rpc-client.d.ts +9 -3
- package/dist/pi-rpc-client.d.ts.map +1 -1
- package/dist/pi-rpc-client.js +133 -32
- package/dist/pi-rpc-client.js.map +1 -1
- package/dist/protocol.d.ts +2 -0
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +2 -0
- package/dist/protocol.js.map +1 -1
- package/dist/schemas.d.ts +882 -1
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +57 -8
- package/dist/schemas.js.map +1 -1
- package/dist/utils/compile-schedule.d.ts +1 -1
- package/dist/utils/compile-schedule.d.ts.map +1 -1
- package/dist/utils/compile-schedule.js +7 -6
- package/dist/utils/compile-schedule.js.map +1 -1
- package/dist/utils/compute-next-automation-run-at.d.ts.map +1 -1
- package/dist/utils/compute-next-automation-run-at.js +3 -18
- package/dist/utils/compute-next-automation-run-at.js.map +1 -1
- package/dist/utils/is-active-automation-run.d.ts +3 -0
- package/dist/utils/is-active-automation-run.d.ts.map +1 -0
- package/dist/utils/is-active-automation-run.js +2 -0
- package/dist/utils/is-active-automation-run.js.map +1 -0
- package/dist/utils/is-valid-time-zone.d.ts +2 -0
- package/dist/utils/is-valid-time-zone.d.ts.map +1 -0
- package/dist/utils/is-valid-time-zone.js +14 -0
- package/dist/utils/is-valid-time-zone.js.map +1 -0
- package/dist/utils/next-schedule-occurrence.d.ts +4 -0
- package/dist/utils/next-schedule-occurrence.d.ts.map +1 -0
- package/dist/utils/next-schedule-occurrence.js +201 -0
- package/dist/utils/next-schedule-occurrence.js.map +1 -0
- package/dist/utils/reconcile-downtime.d.ts.map +1 -1
- package/dist/utils/reconcile-downtime.js +4 -24
- package/dist/utils/reconcile-downtime.js.map +1 -1
- package/dist/utils/trim-automation-runs.d.ts +3 -0
- package/dist/utils/trim-automation-runs.d.ts.map +1 -0
- package/dist/utils/trim-automation-runs.js +7 -0
- package/dist/utils/trim-automation-runs.js.map +1 -0
- package/package.json +1 -1
- package/vendor/node-pty/prebuilds/darwin-arm64/pty.node +0 -0
- package/vendor/node-pty/prebuilds/darwin-arm64/spawn-helper +0 -0
- package/vendor/node-pty/prebuilds/darwin-x64/pty.node +0 -0
- package/vendor/node-pty/prebuilds/darwin-x64/spawn-helper +0 -0
package/dist/index.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
2
1
|
import { spawn } from "node:child_process";
|
|
3
2
|
import fs from "node:fs";
|
|
4
3
|
import os from "node:os";
|
|
@@ -9,6 +8,9 @@ import { bodyLimit } from "hono/body-limit";
|
|
|
9
8
|
import open from "open";
|
|
10
9
|
import { WebSocketServer } from "ws";
|
|
11
10
|
import { AutomationRunTracker } from "./automation-run-tracker.js";
|
|
11
|
+
import { AutomationDispatcher } from "./automation-dispatcher.js";
|
|
12
|
+
import { isActiveAutomationRun } from "./utils/is-active-automation-run.js";
|
|
13
|
+
import { isValidTimeZone } from "./utils/is-valid-time-zone.js";
|
|
12
14
|
import { AutomationScheduler } from "./automation-scheduler.js";
|
|
13
15
|
import { AutomationStore } from "./automation-store.js";
|
|
14
16
|
import { runAgent, compactAgent, listAgentModels, readAgentSession } from "./agent-runner.js";
|
|
@@ -25,7 +27,7 @@ import { detectWithExplicitPort } from "./cdp/discover-explicit-endpoint.js";
|
|
|
25
27
|
import { DaemonConfigStore } from "./daemon-config-store.js";
|
|
26
28
|
import { formatCandidates, resolveCandidates, resolveCompletionContext, } from "./completion/index.js";
|
|
27
29
|
import { z } from "zod";
|
|
28
|
-
import { ACTIVITY_DIRNAME, ACTIVITY_REFRESH_DEBOUNCE_MS, ACTIVITY_WATCHED_PROGRAMS, AUTOMATION_EVENT_DEBOUNCE_MS,
|
|
30
|
+
import { ACTIVITY_DIRNAME, ACTIVITY_REFRESH_DEBOUNCE_MS, ACTIVITY_WATCHED_PROGRAMS, AUTOMATION_EVENT_DEBOUNCE_MS, AUTOMATION_RUN_QUERY_PARAM, AUTOMATION_WATCH_DEBOUNCE_MS, AUTOMATION_WATCH_POST_RUN_GRACE_MS, AUTOMATION_WEBHOOK_DEBOUNCE_MS, CDP_EXTENSION_BROWSER_NAME, CDP_EXTENSION_PATH, DEFAULT_HOST, DEFAULT_PORT, FRIENDLY_HOSTNAME, GIT_DIRTY_THROTTLE_MS, GIT_MAX_REF_LENGTH, HERDR_THEME_SYNC_DEBOUNCE_MS, HIBERNATE_FILENAME, HTTP_STATUS_ACCEPTED, HTTP_STATUS_BAD_REQUEST, HTTP_STATUS_CONFLICT, HTTP_STATUS_SERVICE_UNAVAILABLE, HTTP_STATUS_CREATED, HTTP_STATUS_NOT_FOUND, HTTP_STATUS_PAYLOAD_TOO_LARGE, HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE, MAX_AUTOMATIONS, MAX_IMAGE_UPLOAD_BYTES, MAX_IMAGE_UPLOAD_REQUEST_BYTES, MAX_PROCESSES, MAX_SECRETS, PROCESSES_FILENAME, SECRETS_FILENAME, THEMES_FILENAME, FONTS_FILENAME, SECRETS_SHIMS_DIRNAME, SECRET_EXPORT_SCRYPT_WORK_FACTOR, AUTOMATION_AGENT_SESSIONS_DIRNAME, SERVER_STOP_GRACE_MS, SESSION_ID_QUERY_PARAM, SESSION_ACTIVITY_WINDOW_MS, WINDOW_ID_QUERY_PARAM, WAIT_DEFAULT_TIMEOUT_MS, WS_BACKPRESSURE_THRESHOLD_BYTES, WS_CLOSE_BACKPRESSURE, WS_CLOSE_CAPACITY_REACHED, WS_CLOSE_SPAWN_FAILED, WS_CLOSE_POLICY_VIOLATION, WS_HEARTBEAT_GRACE_MS, WS_HEARTBEAT_INTERVAL_MS, WS_HEARTBEAT_TIMEOUT_MS, WS_MAX_INBOUND_PAYLOAD_BYTES, WS_READY_STATE_OPEN, AUTH_SECRET_FILENAME, AUTH_COOKIE_NAME, UPDATE_CHECK_FILENAME, } from "./constants.js";
|
|
29
31
|
import { getDefaultShell, listKnownShells, resolveShellOverride } from "./default-shell.js";
|
|
30
32
|
import { resolveWindowId } from "./utils/resolve-window-id.js";
|
|
31
33
|
import { resolveTermBackspaceSequence } from "./utils/resolve-term-backspace-sequence.js";
|
|
@@ -82,7 +84,6 @@ import { isLocaltermTabUrl } from "./utils/is-localterm-tab-url.js";
|
|
|
82
84
|
import { normalizeTriggerInput } from "./utils/normalize-trigger.js";
|
|
83
85
|
import { buildAutomationSecretEnv } from "./utils/build-automation-secret-env.js";
|
|
84
86
|
import { migrateSecretsToProcesses } from "./utils/migrate-secrets-to-processes.js";
|
|
85
|
-
import { enumerateMissedOccurrences } from "./utils/reconcile-downtime.js";
|
|
86
87
|
const callRawMethod = (raw, method) => {
|
|
87
88
|
if (!raw || typeof raw !== "object")
|
|
88
89
|
return false;
|
|
@@ -197,7 +198,7 @@ const normalizeMouseAction = (input) => {
|
|
|
197
198
|
};
|
|
198
199
|
const buildApiRoutes = (ctx) => {
|
|
199
200
|
const api = new Hono();
|
|
200
|
-
const { registry, ownerFor, cdpClient, secretBackend, secretExportScryptWorkFactor, secretStore, shimsDir, processStore, themeStore, fontStore, syncSecretShims, automationStore, broadcastAutomations, syncFolderWatchers, syncSessionEventListeners, webhookTriggerManager, worktreeConfigStore, portsSnapshotProcesses, portsSnapshotListeners, toAutomationWithNextRun, listAutomationsWithNextRun, tryLaunch, getCdpPort, applyCdpPort, getGraceSeconds, applyGraceSeconds, connectCdpNow, buildTabUrl, mintViewerCookie, stateDirectory, broadcastThemes, broadcastFonts, updateCheckStore, } = ctx;
|
|
201
|
+
const { registry, ownerFor, cdpClient, secretBackend, secretExportScryptWorkFactor, secretStore, shimsDir, processStore, themeStore, fontStore, syncSecretShims, automationStore, broadcastAutomations, syncFolderWatchers, syncSessionEventListeners, webhookTriggerManager, worktreeConfigStore, portsSnapshotProcesses, portsSnapshotListeners, toAutomationWithNextRun, listAutomationsWithNextRun, tryLaunch, automationDispatcher, refreshAutomationSchedule, getCdpPort, applyCdpPort, getGraceSeconds, applyGraceSeconds, connectCdpNow, buildTabUrl, mintViewerCookie, stateDirectory, broadcastThemes, broadcastFonts, updateCheckStore, } = ctx;
|
|
201
202
|
// Headless SGR-1006 fallback for `mouse` when no CDP tab is reachable:
|
|
202
203
|
// encode the gesture as SGR bytes and write them straight to the PTY. Closes
|
|
203
204
|
// over `registry` so the automation layer stays CDP-agnostic. Coords arrive
|
|
@@ -1353,6 +1354,8 @@ const buildApiRoutes = (ctx) => {
|
|
|
1353
1354
|
const normalized = normalizeTriggerInput(trigger);
|
|
1354
1355
|
if (normalized.kind === "watch" || normalized.kind === "event" || normalized.kind === "webhook")
|
|
1355
1356
|
return true;
|
|
1357
|
+
if (normalized.schedule.kind === "interval")
|
|
1358
|
+
return true;
|
|
1356
1359
|
const crons = compileScheduleAll(normalized.schedule);
|
|
1357
1360
|
return crons.length > 0 && crons.every((cron) => parseCronExpression(cron) !== null);
|
|
1358
1361
|
};
|
|
@@ -1375,7 +1378,16 @@ const buildApiRoutes = (ctx) => {
|
|
|
1375
1378
|
if (unknown.length > 0)
|
|
1376
1379
|
return context.json({ error: "invalid_secret" }, HTTP_STATUS_BAD_REQUEST);
|
|
1377
1380
|
}
|
|
1381
|
+
if (parsed.data.timezone !== undefined && !isValidTimeZone(parsed.data.timezone)) {
|
|
1382
|
+
return context.json({ error: "invalid_timezone" }, HTTP_STATUS_BAD_REQUEST);
|
|
1383
|
+
}
|
|
1384
|
+
if (parsed.data.runner.kind === "agent" &&
|
|
1385
|
+
parsed.data.runner.sessionMode === "thread" &&
|
|
1386
|
+
parsed.data.concurrencyPolicy === "allow") {
|
|
1387
|
+
return context.json({ error: "thread_requires_serial_execution" }, HTTP_STATUS_BAD_REQUEST);
|
|
1388
|
+
}
|
|
1378
1389
|
const automation = automationStore.create(parsed.data);
|
|
1390
|
+
refreshAutomationSchedule();
|
|
1379
1391
|
broadcastAutomations();
|
|
1380
1392
|
syncFolderWatchers();
|
|
1381
1393
|
syncSessionEventListeners();
|
|
@@ -1399,6 +1411,16 @@ const buildApiRoutes = (ctx) => {
|
|
|
1399
1411
|
const existing = automationStore.get(context.req.param("id"));
|
|
1400
1412
|
if (!existing)
|
|
1401
1413
|
return context.json({ error: "not_found" }, HTTP_STATUS_NOT_FOUND);
|
|
1414
|
+
if (parsed.data.timezone !== undefined && !isValidTimeZone(parsed.data.timezone)) {
|
|
1415
|
+
return context.json({ error: "invalid_timezone" }, HTTP_STATUS_BAD_REQUEST);
|
|
1416
|
+
}
|
|
1417
|
+
const runner = parsed.data.runner ?? existing.runner;
|
|
1418
|
+
if ((parsed.data.runner !== undefined || parsed.data.concurrencyPolicy !== undefined) &&
|
|
1419
|
+
runner.kind === "agent" &&
|
|
1420
|
+
runner.sessionMode === "thread" &&
|
|
1421
|
+
(parsed.data.concurrencyPolicy ?? existing.concurrencyPolicy) === "allow") {
|
|
1422
|
+
return context.json({ error: "thread_requires_serial_execution" }, HTTP_STATUS_BAD_REQUEST);
|
|
1423
|
+
}
|
|
1402
1424
|
// A PATCH never un-finishes — re-enabling a finished automation must go
|
|
1403
1425
|
// through reset so it can't accidentally fire past its limit.
|
|
1404
1426
|
if (existing.lifecycle === "finished" && parsed.data.enabled === true) {
|
|
@@ -1407,14 +1429,24 @@ const buildApiRoutes = (ctx) => {
|
|
|
1407
1429
|
const automation = automationStore.update(context.req.param("id"), parsed.data);
|
|
1408
1430
|
if (!automation)
|
|
1409
1431
|
return context.json({ error: "not_found" }, HTTP_STATUS_NOT_FOUND);
|
|
1432
|
+
automationDispatcher.drain(automation.id);
|
|
1433
|
+
refreshAutomationSchedule();
|
|
1410
1434
|
broadcastAutomations();
|
|
1411
1435
|
syncFolderWatchers();
|
|
1412
1436
|
syncSessionEventListeners();
|
|
1413
|
-
return context.json({
|
|
1437
|
+
return context.json({
|
|
1438
|
+
automation: toAutomationWithNextRun(automationStore.get(automation.id) ?? automation, new Date()),
|
|
1439
|
+
});
|
|
1414
1440
|
});
|
|
1415
1441
|
api.delete("/automations/:id", (context) => {
|
|
1416
1442
|
const id = context.req.param("id");
|
|
1417
1443
|
const automation = automationStore.get(id);
|
|
1444
|
+
if (automation && automationDispatcher.isBusy(automation.id)) {
|
|
1445
|
+
return context.json({
|
|
1446
|
+
error: "automation_busy",
|
|
1447
|
+
message: "Wait for active runs or cancel queued work before deleting.",
|
|
1448
|
+
}, HTTP_STATUS_CONFLICT);
|
|
1449
|
+
}
|
|
1418
1450
|
if (!automation || !automationStore.remove(id)) {
|
|
1419
1451
|
return context.json({ error: "not_found" }, HTTP_STATUS_NOT_FOUND);
|
|
1420
1452
|
}
|
|
@@ -1442,7 +1474,17 @@ const buildApiRoutes = (ctx) => {
|
|
|
1442
1474
|
const runId = tryLaunch(automation, "manual");
|
|
1443
1475
|
if (!runId)
|
|
1444
1476
|
return context.json({ error: "launch_failed" }, HTTP_STATUS_BAD_REQUEST);
|
|
1445
|
-
|
|
1477
|
+
const run = automationStore.get(automation.id)?.runs.find((entry) => entry.runId === runId);
|
|
1478
|
+
return context.json({ runId, status: run?.status });
|
|
1479
|
+
});
|
|
1480
|
+
api.post("/automations/:id/runs/:runId/cancel", (context) => {
|
|
1481
|
+
const id = context.req.param("id");
|
|
1482
|
+
if (!automationStore.get(id))
|
|
1483
|
+
return context.json({ error: "not_found" }, HTTP_STATUS_NOT_FOUND);
|
|
1484
|
+
if (!automationDispatcher.cancel(id, context.req.param("runId"))) {
|
|
1485
|
+
return context.json({ error: "run_not_queued" }, HTTP_STATUS_CONFLICT);
|
|
1486
|
+
}
|
|
1487
|
+
return context.json({ ok: true });
|
|
1446
1488
|
});
|
|
1447
1489
|
api.post("/automations/:id/reset", async (context) => {
|
|
1448
1490
|
const parsed = resetAutomationInputSchema.safeParse((await readJsonBody(context)) ?? {});
|
|
@@ -1451,6 +1493,7 @@ const buildApiRoutes = (ctx) => {
|
|
|
1451
1493
|
const automation = automationStore.reset(context.req.param("id"), parsed.data.clearHistory);
|
|
1452
1494
|
if (!automation)
|
|
1453
1495
|
return context.json({ error: "not_found" }, HTTP_STATUS_NOT_FOUND);
|
|
1496
|
+
refreshAutomationSchedule();
|
|
1454
1497
|
broadcastAutomations();
|
|
1455
1498
|
// Reset re-enables + reactivates; a watch automation resumes watching.
|
|
1456
1499
|
syncFolderWatchers();
|
|
@@ -1569,26 +1612,34 @@ const buildApiRoutes = (ctx) => {
|
|
|
1569
1612
|
if (automation.runner.kind !== "agent" || automation.runner.sessionMode !== "thread") {
|
|
1570
1613
|
return context.json({ error: "not_compactable" }, HTTP_STATUS_CONFLICT);
|
|
1571
1614
|
}
|
|
1572
|
-
|
|
1573
|
-
|
|
1615
|
+
if (!automationDispatcher.acquireExclusive(automation.id)) {
|
|
1616
|
+
return context.json({ error: "automation_busy" }, HTTP_STATUS_CONFLICT);
|
|
1617
|
+
}
|
|
1574
1618
|
try {
|
|
1575
|
-
|
|
1619
|
+
const sessionFile = path.join(stateDirectory, AUTOMATION_AGENT_SESSIONS_DIRNAME, `${automation.id}.jsonl`);
|
|
1620
|
+
let secretEnv = {};
|
|
1621
|
+
try {
|
|
1622
|
+
secretEnv = await buildAutomationSecretEnv(automation.requestedSecrets, secretStore, secretBackend);
|
|
1623
|
+
}
|
|
1624
|
+
catch (error) {
|
|
1625
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1626
|
+
console.warn(`failed to resolve secrets for compaction of "${automation.name}": ${message}`);
|
|
1627
|
+
}
|
|
1628
|
+
const result = await compactAgent({
|
|
1629
|
+
harness: automation.runner.harness,
|
|
1630
|
+
cwd: automation.cwd,
|
|
1631
|
+
env: secretEnv,
|
|
1632
|
+
sessionFile,
|
|
1633
|
+
shimsDir,
|
|
1634
|
+
});
|
|
1635
|
+
if (!result.ok) {
|
|
1636
|
+
return context.json({ error: "compact_failed", message: result.message }, HTTP_STATUS_BAD_REQUEST);
|
|
1637
|
+
}
|
|
1638
|
+
return context.json({ ok: true, message: result.message });
|
|
1576
1639
|
}
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
console.warn(`failed to resolve secrets for compaction of "${automation.name}": ${message}`);
|
|
1580
|
-
}
|
|
1581
|
-
const result = await compactAgent({
|
|
1582
|
-
harness: automation.runner.harness,
|
|
1583
|
-
cwd: automation.cwd,
|
|
1584
|
-
env: secretEnv,
|
|
1585
|
-
sessionFile,
|
|
1586
|
-
shimsDir,
|
|
1587
|
-
});
|
|
1588
|
-
if (!result.ok) {
|
|
1589
|
-
return context.json({ error: "compact_failed", message: result.message }, HTTP_STATUS_BAD_REQUEST);
|
|
1640
|
+
finally {
|
|
1641
|
+
automationDispatcher.releaseExclusive(automation.id);
|
|
1590
1642
|
}
|
|
1591
|
-
return context.json({ ok: true, message: result.message });
|
|
1592
1643
|
});
|
|
1593
1644
|
// Restart a thread-mode agent automation from a fresh session: delete the
|
|
1594
1645
|
// persisted session file so the next fire starts a blank branch instead of
|
|
@@ -1601,15 +1652,23 @@ const buildApiRoutes = (ctx) => {
|
|
|
1601
1652
|
if (automation.runner.kind !== "agent" || automation.runner.sessionMode !== "thread") {
|
|
1602
1653
|
return context.json({ error: "not_thread" }, HTTP_STATUS_CONFLICT);
|
|
1603
1654
|
}
|
|
1604
|
-
|
|
1655
|
+
if (!automationDispatcher.acquireExclusive(automation.id)) {
|
|
1656
|
+
return context.json({ error: "automation_busy" }, HTTP_STATUS_CONFLICT);
|
|
1657
|
+
}
|
|
1605
1658
|
try {
|
|
1606
|
-
|
|
1659
|
+
const sessionFile = path.join(stateDirectory, AUTOMATION_AGENT_SESSIONS_DIRNAME, `${automation.id}.jsonl`);
|
|
1660
|
+
try {
|
|
1661
|
+
await fs.promises.rm(sessionFile, { force: true });
|
|
1662
|
+
}
|
|
1663
|
+
catch (error) {
|
|
1664
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1665
|
+
return context.json({ error: "clear_thread_failed", message }, HTTP_STATUS_BAD_REQUEST);
|
|
1666
|
+
}
|
|
1667
|
+
return context.json({ ok: true });
|
|
1607
1668
|
}
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
return context.json({ error: "clear_thread_failed", message }, HTTP_STATUS_BAD_REQUEST);
|
|
1669
|
+
finally {
|
|
1670
|
+
automationDispatcher.releaseExclusive(automation.id);
|
|
1611
1671
|
}
|
|
1612
|
-
return context.json({ ok: true });
|
|
1613
1672
|
});
|
|
1614
1673
|
// Daemon config (the editable CDP port). GET is a cheap read of the live
|
|
1615
1674
|
// value; PUT persists it, drops the persistent CDP socket so the next
|
|
@@ -1714,6 +1773,8 @@ export const createServer = async (options = {}) => {
|
|
|
1714
1773
|
sessionEventManager.onSessionEvent(event, cwd);
|
|
1715
1774
|
},
|
|
1716
1775
|
onAutomationExit: (automationId, runId, exitCode, log) => {
|
|
1776
|
+
if (automationExecutionStopped)
|
|
1777
|
+
return;
|
|
1717
1778
|
automationStore.updateRun(automationId, runId, {
|
|
1718
1779
|
status: exitCode === 0 ? "completed" : "failed",
|
|
1719
1780
|
exitCode,
|
|
@@ -1724,6 +1785,7 @@ export const createServer = async (options = {}) => {
|
|
|
1724
1785
|
closeRunTabIfRequested(automationId, runId);
|
|
1725
1786
|
folderWatchManager.notifyRunFinished(automationId);
|
|
1726
1787
|
sessionEventManager.notifyRunFinished(automationId);
|
|
1788
|
+
automationDispatcher.drain(automationId);
|
|
1727
1789
|
},
|
|
1728
1790
|
onClientExit: (ws, exitCode) => {
|
|
1729
1791
|
const targetId = wsToTargetId.get(ws);
|
|
@@ -1744,6 +1806,7 @@ export const createServer = async (options = {}) => {
|
|
|
1744
1806
|
// server-side offer goes unanswered and the connection stays uncompressed
|
|
1745
1807
|
// (verified: a no-extension-header client negotiates ""). See
|
|
1746
1808
|
// session-manager.ts broadcastBytes and the client's DecompressionStream path.
|
|
1809
|
+
let automationExecutionStopped = false;
|
|
1747
1810
|
const automationStore = new AutomationStore(path.join(stateDirectory, "automations.json"));
|
|
1748
1811
|
const automationRunTracker = new AutomationRunTracker();
|
|
1749
1812
|
const automationScheduler = new AutomationScheduler(automationStore);
|
|
@@ -1753,20 +1816,14 @@ export const createServer = async (options = {}) => {
|
|
|
1753
1816
|
const folderWatchManager = new FolderWatchManager({
|
|
1754
1817
|
debounceMs: AUTOMATION_WATCH_DEBOUNCE_MS,
|
|
1755
1818
|
postRunGraceMs: AUTOMATION_WATCH_POST_RUN_GRACE_MS,
|
|
1756
|
-
isRunInFlight: (automationId) =>
|
|
1757
|
-
const status = automationStore.get(automationId)?.runs[0]?.status;
|
|
1758
|
-
return status === "launched" || status === "running";
|
|
1759
|
-
},
|
|
1819
|
+
isRunInFlight: (automationId) => automationStore.get(automationId)?.runs.some(isActiveAutomationRun) ?? false,
|
|
1760
1820
|
getAutomation: (automationId) => automationStore.get(automationId),
|
|
1761
1821
|
});
|
|
1762
1822
|
const syncFolderWatchers = () => folderWatchManager.sync(automationStore.list());
|
|
1763
1823
|
const sessionEventManager = new SessionEventManager({
|
|
1764
1824
|
debounceMs: AUTOMATION_EVENT_DEBOUNCE_MS,
|
|
1765
1825
|
postRunGraceMs: AUTOMATION_WATCH_POST_RUN_GRACE_MS,
|
|
1766
|
-
isRunInFlight: (automationId) =>
|
|
1767
|
-
const status = automationStore.get(automationId)?.runs[0]?.status;
|
|
1768
|
-
return status === "launched" || status === "running";
|
|
1769
|
-
},
|
|
1826
|
+
isRunInFlight: (automationId) => automationStore.get(automationId)?.runs.some(isActiveAutomationRun) ?? false,
|
|
1770
1827
|
getAutomation: (automationId) => automationStore.get(automationId),
|
|
1771
1828
|
});
|
|
1772
1829
|
// Daemon-global git detection for event automations: arms a recursive
|
|
@@ -1786,10 +1843,7 @@ export const createServer = async (options = {}) => {
|
|
|
1786
1843
|
// Stateless vs the watch/event managers — nothing to arm, so no sync().
|
|
1787
1844
|
const webhookTriggerManager = new WebhookTriggerManager({
|
|
1788
1845
|
debounceMs: AUTOMATION_WEBHOOK_DEBOUNCE_MS,
|
|
1789
|
-
isRunInFlight: (
|
|
1790
|
-
const status = automationStore.get(automationId)?.runs[0]?.status;
|
|
1791
|
-
return status === "launched" || status === "running";
|
|
1792
|
-
},
|
|
1846
|
+
isRunInFlight: () => false,
|
|
1793
1847
|
getAutomation: (automationId) => automationStore.get(automationId),
|
|
1794
1848
|
});
|
|
1795
1849
|
const heartbeatStore = new HeartbeatStore(path.join(stateDirectory, "daemon-heartbeat.json"));
|
|
@@ -2014,9 +2068,12 @@ export const createServer = async (options = {}) => {
|
|
|
2014
2068
|
// log-stripped records (see automationRunWireSchema) so list/broadcast
|
|
2015
2069
|
// payloads stay small.
|
|
2016
2070
|
runs: automation.runs.map((run) => {
|
|
2017
|
-
const { log, ...wire } = run;
|
|
2071
|
+
const { log, execution: _execution, ...wire } = run;
|
|
2018
2072
|
return { ...wire, hasLog: log !== null || wire.findings !== null };
|
|
2019
2073
|
}),
|
|
2074
|
+
timezone: automation.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
2075
|
+
concurrencyPolicy: automation.concurrencyPolicy ?? "skip",
|
|
2076
|
+
missedRunPolicy: automation.missedRunPolicy ?? "skip",
|
|
2020
2077
|
nextRunAt: computeNextAutomationRunAt(automation, from),
|
|
2021
2078
|
cron: automation.trigger.kind === "schedule" ? compileSchedule(automation.trigger.schedule) : null,
|
|
2022
2079
|
lastRun: deriveLastRun(automation),
|
|
@@ -2109,61 +2166,29 @@ export const createServer = async (options = {}) => {
|
|
|
2109
2166
|
}
|
|
2110
2167
|
};
|
|
2111
2168
|
caffeinateManager.on("change", broadcastCaffeinate);
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
// run's findings, diffs git status for changedFiles, and lands a completed/
|
|
2115
|
-
// failed run with an unread Triage flag (when there are findings). The run
|
|
2116
|
-
// goes straight to "running" (there is no "launched -> tab claim" step), so
|
|
2117
|
-
// reconcileOnStartup's running->missed sweep covers a daemon restart mid-run.
|
|
2118
|
-
const launchAgentRun = (automation, trigger) => {
|
|
2119
|
-
const now = Date.now();
|
|
2120
|
-
const runId = randomUUID();
|
|
2121
|
-
const counts = trigger !== "manual";
|
|
2122
|
-
automationStore.appendRun(automation.id, {
|
|
2123
|
-
runId,
|
|
2124
|
-
scheduledFor: trigger === "schedule" ? Math.floor(now / MS_PER_MINUTE) * MS_PER_MINUTE : now,
|
|
2125
|
-
startedAt: now,
|
|
2126
|
-
finishedAt: null,
|
|
2127
|
-
status: "running",
|
|
2128
|
-
exitCode: null,
|
|
2129
|
-
trigger,
|
|
2130
|
-
countsTowardLimit: counts,
|
|
2131
|
-
findings: null,
|
|
2132
|
-
changedFiles: [],
|
|
2133
|
-
unread: false,
|
|
2134
|
-
log: null,
|
|
2135
|
-
});
|
|
2136
|
-
if (counts)
|
|
2137
|
-
automationStore.incrementRunCount(automation.id);
|
|
2138
|
-
broadcastAutomations();
|
|
2139
|
-
syncFolderWatchers();
|
|
2140
|
-
syncSessionEventListeners();
|
|
2169
|
+
const agentRuns = new Map();
|
|
2170
|
+
const launchAgentRun = (automation, run) => {
|
|
2141
2171
|
if (automation.runner.kind !== "agent")
|
|
2142
|
-
return
|
|
2172
|
+
return Promise.resolve();
|
|
2143
2173
|
const runner = automation.runner;
|
|
2144
|
-
const
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
try {
|
|
2150
|
-
secretEnv = await buildAutomationSecretEnv(automation.requestedSecrets, secretStore, secretBackend);
|
|
2151
|
-
}
|
|
2152
|
-
catch (error) {
|
|
2153
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
2154
|
-
console.warn(`failed to resolve secrets for automation "${automation.name}": ${message}`);
|
|
2155
|
-
}
|
|
2174
|
+
const controller = new AbortController();
|
|
2175
|
+
const promise = (async () => {
|
|
2176
|
+
const secretEnv = await buildAutomationSecretEnv(automation.requestedSecrets, secretStore, secretBackend);
|
|
2177
|
+
if (automationExecutionStopped)
|
|
2178
|
+
return;
|
|
2156
2179
|
const result = await runAgent({
|
|
2157
2180
|
runner,
|
|
2158
2181
|
cwd: automation.cwd,
|
|
2159
2182
|
env: secretEnv,
|
|
2160
|
-
sessionFile,
|
|
2161
2183
|
shimsDir,
|
|
2184
|
+
signal: controller.signal,
|
|
2185
|
+
sessionFile: runner.sessionMode === "thread"
|
|
2186
|
+
? path.join(agentSessionsDir, `${automation.id}.jsonl`)
|
|
2187
|
+
: null,
|
|
2162
2188
|
});
|
|
2163
|
-
|
|
2164
|
-
if (!automationStore.get(automation.id))
|
|
2189
|
+
if (automationExecutionStopped || !automationStore.get(automation.id))
|
|
2165
2190
|
return;
|
|
2166
|
-
automationStore.updateRun(automation.id, runId, {
|
|
2191
|
+
automationStore.updateRun(automation.id, run.runId, {
|
|
2167
2192
|
status: result.exitCode === 0 ? "completed" : "failed",
|
|
2168
2193
|
exitCode: result.exitCode,
|
|
2169
2194
|
finishedAt: Date.now(),
|
|
@@ -2175,91 +2200,49 @@ export const createServer = async (options = {}) => {
|
|
|
2175
2200
|
broadcastAutomations();
|
|
2176
2201
|
folderWatchManager.notifyRunFinished(automation.id);
|
|
2177
2202
|
sessionEventManager.notifyRunFinished(automation.id);
|
|
2178
|
-
|
|
2179
|
-
|
|
2203
|
+
automationDispatcher.drain(automation.id);
|
|
2204
|
+
})().finally(() => agentRuns.delete(run.runId));
|
|
2205
|
+
agentRuns.set(run.runId, { controller, promise });
|
|
2206
|
+
return promise;
|
|
2180
2207
|
};
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
// Agent runs bypass the tab/PTY path entirely (headless). Returns the runId
|
|
2185
|
-
// (null only when a non-manual trigger hits the finished/disabled guard).
|
|
2186
|
-
const tryLaunch = (automation, trigger) => {
|
|
2187
|
-
if (trigger !== "manual") {
|
|
2188
|
-
const current = automationStore.get(automation.id);
|
|
2189
|
-
if (!current || !current.enabled || current.lifecycle === "finished")
|
|
2190
|
-
return null;
|
|
2191
|
-
}
|
|
2192
|
-
if (automation.runner.kind === "agent")
|
|
2193
|
-
return launchAgentRun(automation, trigger);
|
|
2194
|
-
const run = automationRunTracker.create(automation);
|
|
2195
|
-
const counts = trigger !== "manual";
|
|
2196
|
-
automationStore.appendRun(automation.id, {
|
|
2197
|
-
runId: run.runId,
|
|
2198
|
-
scheduledFor: trigger === "schedule"
|
|
2199
|
-
? Math.floor(run.createdAt / MS_PER_MINUTE) * MS_PER_MINUTE
|
|
2200
|
-
: run.createdAt,
|
|
2201
|
-
startedAt: run.createdAt,
|
|
2202
|
-
finishedAt: null,
|
|
2203
|
-
status: "launched",
|
|
2204
|
-
exitCode: null,
|
|
2205
|
-
trigger,
|
|
2206
|
-
countsTowardLimit: counts,
|
|
2207
|
-
findings: null,
|
|
2208
|
-
changedFiles: [],
|
|
2209
|
-
unread: false,
|
|
2210
|
-
log: null,
|
|
2211
|
-
});
|
|
2212
|
-
if (counts)
|
|
2213
|
-
automationStore.incrementRunCount(automation.id);
|
|
2214
|
-
broadcastAutomations();
|
|
2215
|
-
// A watch automation that just reached its limit is now "finished"; stop
|
|
2216
|
-
// watching its folder promptly instead of waiting for the next mutation.
|
|
2217
|
-
syncFolderWatchers();
|
|
2218
|
-
syncSessionEventListeners();
|
|
2219
|
-
// Open the run tab at the announced LOCAL surface origin when the CLI
|
|
2220
|
-
// resolved one (portless / loopback) — run tabs open in the daemon's own
|
|
2221
|
-
// debugged browser, where a flapping `tailscale serve` (laptop wake, DERP
|
|
2222
|
-
// relay, cert renewal) would fail the tab load and the automation, so they
|
|
2223
|
-
// never ride the tailnet even when `publicOrigin` is the tailnet URL. Fall
|
|
2224
|
-
// back to `publicOrigin` (then the loopback form) so a caller that only set
|
|
2225
|
-
// `publicUrl` keeps the prior single-surface behavior. A bare origin (no
|
|
2226
|
-
// path) is the contract, so the `new URL` base rewrites any stray path and
|
|
2227
|
-
// searchParams encodes the id.
|
|
2208
|
+
const launchShellRun = async (automation, record) => {
|
|
2209
|
+
const run = automationRunTracker.create(automation, record.startedAt ?? Date.now(), record.runId);
|
|
2210
|
+
// Use the local surface: a waking laptop's tailnet may not be available yet.
|
|
2228
2211
|
const runUrl = new URL(localOrigin ?? publicOrigin ?? `http://${FRIENDLY_HOSTNAME}:${actualPort}`);
|
|
2229
2212
|
runUrl.searchParams.set(AUTOMATION_RUN_QUERY_PARAM, run.runId);
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
if (automation.redactOutput) {
|
|
2243
|
-
automationRunTracker.setRedactionValues(run.runId, Object.values(secretEnv));
|
|
2244
|
-
}
|
|
2245
|
-
}
|
|
2246
|
-
catch (error) {
|
|
2247
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
2248
|
-
console.warn(`failed to resolve secrets for automation "${automation.name}": ${message}`);
|
|
2249
|
-
}
|
|
2250
|
-
try {
|
|
2251
|
-
const handle = await tabController.open(runUrl.href);
|
|
2252
|
-
// Remember the tab so `automation-exit` can close it if closeOnFinish.
|
|
2253
|
-
if (handle)
|
|
2213
|
+
try {
|
|
2214
|
+
const secretEnv = await buildAutomationSecretEnv(automation.requestedSecrets, secretStore, secretBackend);
|
|
2215
|
+
automationRunTracker.setEnv(run.runId, secretEnv);
|
|
2216
|
+
if (automation.redactOutput)
|
|
2217
|
+
automationRunTracker.setRedactionValues(run.runId, Object.values(secretEnv));
|
|
2218
|
+
if (automationExecutionStopped)
|
|
2219
|
+
return;
|
|
2220
|
+
const handle = await tabController.open(runUrl.href);
|
|
2221
|
+
if (handle) {
|
|
2222
|
+
if (automationExecutionStopped)
|
|
2223
|
+
await tabController.close(handle);
|
|
2224
|
+
else
|
|
2254
2225
|
runTabHandles.set(run.runId, handle);
|
|
2255
2226
|
}
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
}
|
|
2261
|
-
return run.runId;
|
|
2227
|
+
}
|
|
2228
|
+
catch (error) {
|
|
2229
|
+
automationRunTracker.claim(run.runId);
|
|
2230
|
+
throw error;
|
|
2231
|
+
}
|
|
2262
2232
|
};
|
|
2233
|
+
const automationDispatcher = new AutomationDispatcher({
|
|
2234
|
+
store: automationStore,
|
|
2235
|
+
launch: (automation, run) => automation.runner.kind === "agent"
|
|
2236
|
+
? launchAgentRun(automation, run)
|
|
2237
|
+
: launchShellRun(automation, run),
|
|
2238
|
+
changed: () => {
|
|
2239
|
+
broadcastAutomations();
|
|
2240
|
+
syncFolderWatchers();
|
|
2241
|
+
syncSessionEventListeners();
|
|
2242
|
+
},
|
|
2243
|
+
});
|
|
2244
|
+
const refreshAutomationSchedule = () => automationScheduler.refresh();
|
|
2245
|
+
const tryLaunch = (automation, trigger) => automationDispatcher.request(automation.id, trigger);
|
|
2263
2246
|
// Close a finished run's tab when the automation opted into closeOnFinish.
|
|
2264
2247
|
const closeRunTabIfRequested = (automationId, runId) => {
|
|
2265
2248
|
const handle = runTabHandles.get(runId);
|
|
@@ -2267,51 +2250,14 @@ export const createServer = async (options = {}) => {
|
|
|
2267
2250
|
if (!handle)
|
|
2268
2251
|
return;
|
|
2269
2252
|
const automation = automationStore.get(automationId);
|
|
2270
|
-
|
|
2253
|
+
const run = automation?.runs.find((entry) => entry.runId === runId);
|
|
2254
|
+
if (!(run?.execution?.closeOnFinish ?? automation?.closeOnFinish))
|
|
2271
2255
|
return;
|
|
2272
2256
|
void tabController.close(handle).catch((error) => {
|
|
2273
2257
|
const message = error instanceof Error ? error.message : String(error);
|
|
2274
2258
|
console.warn(`failed to close automation tab (run ${runId}): ${message}`);
|
|
2275
2259
|
});
|
|
2276
2260
|
};
|
|
2277
|
-
// On boot, settle the state the dead process left behind: any run still
|
|
2278
|
-
// "launched"/"running" can never resume (the run tracker is in-memory), so it
|
|
2279
|
-
// becomes "missed"; and if the daemon was down across scheduled times, record
|
|
2280
|
-
// those as "skipped" so the user can see what didn't run while the machine was
|
|
2281
|
-
// off. Skipped runs never launch and never count toward a limit. No clients
|
|
2282
|
-
// exist yet, so nothing is broadcast.
|
|
2283
|
-
const reconcileOnStartup = (now) => {
|
|
2284
|
-
const lastAliveAt = heartbeatStore.read();
|
|
2285
|
-
const hadOutage = lastAliveAt !== null && now - lastAliveAt >= AUTOMATION_RECONCILE_MIN_DOWNTIME_MS;
|
|
2286
|
-
for (const automation of automationStore.list()) {
|
|
2287
|
-
for (const run of automation.runs) {
|
|
2288
|
-
if (run.status === "launched" || run.status === "running") {
|
|
2289
|
-
automationStore.updateRun(automation.id, run.runId, {
|
|
2290
|
-
status: "missed",
|
|
2291
|
-
finishedAt: now,
|
|
2292
|
-
});
|
|
2293
|
-
}
|
|
2294
|
-
}
|
|
2295
|
-
if (!hadOutage || !automation.enabled || automation.lifecycle === "finished")
|
|
2296
|
-
continue;
|
|
2297
|
-
for (const occurrence of enumerateMissedOccurrences(automation, lastAliveAt, now)) {
|
|
2298
|
-
automationStore.appendRun(automation.id, {
|
|
2299
|
-
runId: randomUUID(),
|
|
2300
|
-
scheduledFor: occurrence,
|
|
2301
|
-
startedAt: null,
|
|
2302
|
-
finishedAt: occurrence,
|
|
2303
|
-
status: "skipped",
|
|
2304
|
-
exitCode: null,
|
|
2305
|
-
trigger: "schedule",
|
|
2306
|
-
countsTowardLimit: false,
|
|
2307
|
-
findings: null,
|
|
2308
|
-
changedFiles: [],
|
|
2309
|
-
unread: false,
|
|
2310
|
-
log: null,
|
|
2311
|
-
});
|
|
2312
|
-
}
|
|
2313
|
-
}
|
|
2314
|
-
};
|
|
2315
2261
|
const getCdpPort = () => cdpPort;
|
|
2316
2262
|
// Live read of the persisted no-clients grace window (seconds; `null` = never
|
|
2317
2263
|
// reap). The SessionManager's getGraceMs closure converts to ms at arm time.
|
|
@@ -2401,6 +2347,8 @@ export const createServer = async (options = {}) => {
|
|
|
2401
2347
|
toAutomationWithNextRun,
|
|
2402
2348
|
listAutomationsWithNextRun,
|
|
2403
2349
|
tryLaunch,
|
|
2350
|
+
automationDispatcher,
|
|
2351
|
+
refreshAutomationSchedule,
|
|
2404
2352
|
getCdpPort,
|
|
2405
2353
|
applyCdpPort,
|
|
2406
2354
|
getGraceSeconds,
|
|
@@ -2615,11 +2563,21 @@ export const createServer = async (options = {}) => {
|
|
|
2615
2563
|
});
|
|
2616
2564
|
runTabHandles.delete(claimedRun.runId);
|
|
2617
2565
|
broadcastAutomations();
|
|
2566
|
+
automationDispatcher.drain(claimedRun.automationId);
|
|
2618
2567
|
}
|
|
2619
2568
|
ws.close(WS_CLOSE_SPAWN_FAILED, error.message);
|
|
2620
2569
|
return;
|
|
2621
2570
|
}
|
|
2622
2571
|
if (!spawned) {
|
|
2572
|
+
if (claimedRun) {
|
|
2573
|
+
automationStore.updateRun(claimedRun.automationId, claimedRun.runId, {
|
|
2574
|
+
status: "failed",
|
|
2575
|
+
reason: "capacity",
|
|
2576
|
+
finishedAt: Date.now(),
|
|
2577
|
+
});
|
|
2578
|
+
broadcastAutomations();
|
|
2579
|
+
automationDispatcher.drain(claimedRun.automationId);
|
|
2580
|
+
}
|
|
2623
2581
|
ws.close(WS_CLOSE_CAPACITY_REACHED, "session capacity reached");
|
|
2624
2582
|
return;
|
|
2625
2583
|
}
|
|
@@ -2846,9 +2804,13 @@ export const createServer = async (options = {}) => {
|
|
|
2846
2804
|
if (!httpServer) {
|
|
2847
2805
|
throw new ServerErrorException(serverError.listenFailed(host, port, new Error("hono serve() resolved without binding an http server")));
|
|
2848
2806
|
}
|
|
2849
|
-
automationScheduler.on("due", (automation) => {
|
|
2850
|
-
|
|
2807
|
+
automationScheduler.on("due", (automation, scheduledFor) => {
|
|
2808
|
+
automationDispatcher.request(automation.id, "schedule", scheduledFor);
|
|
2851
2809
|
});
|
|
2810
|
+
automationScheduler.on("skipped", (automation, scheduledFor) => {
|
|
2811
|
+
automationDispatcher.request(automation.id, "schedule", scheduledFor, "downtime");
|
|
2812
|
+
});
|
|
2813
|
+
automationScheduler.on("fault", (error) => console.error("automation scheduling failed", error));
|
|
2852
2814
|
folderWatchManager.on("due", (automation) => {
|
|
2853
2815
|
tryLaunch(automation, "watch");
|
|
2854
2816
|
});
|
|
@@ -2858,9 +2820,21 @@ export const createServer = async (options = {}) => {
|
|
|
2858
2820
|
webhookTriggerManager.on("due", (automation) => {
|
|
2859
2821
|
tryLaunch(automation, "webhook");
|
|
2860
2822
|
});
|
|
2861
|
-
automationScheduler.on("tick", (now) => {
|
|
2862
|
-
|
|
2863
|
-
|
|
2823
|
+
automationScheduler.on("tick", (now, healthy) => {
|
|
2824
|
+
let dispatchHealthy = healthy;
|
|
2825
|
+
// A queue commit can succeed while the subsequent launch commit fails.
|
|
2826
|
+
// Retry accepted work even when its occurrence watermark already advanced.
|
|
2827
|
+
for (const automation of automationStore.list()) {
|
|
2828
|
+
try {
|
|
2829
|
+
automationDispatcher.drain(automation.id);
|
|
2830
|
+
}
|
|
2831
|
+
catch (error) {
|
|
2832
|
+
dispatchHealthy = false;
|
|
2833
|
+
console.error("automation queue dispatch failed", error);
|
|
2834
|
+
}
|
|
2835
|
+
}
|
|
2836
|
+
if (dispatchHealthy)
|
|
2837
|
+
heartbeatStore.write(now.getTime());
|
|
2864
2838
|
let didExpireAny = false;
|
|
2865
2839
|
for (const expiredRun of automationRunTracker.sweepExpired(now.getTime())) {
|
|
2866
2840
|
const automation = automationStore.get(expiredRun.automationId);
|
|
@@ -2871,13 +2845,14 @@ export const createServer = async (options = {}) => {
|
|
|
2871
2845
|
status: "missed",
|
|
2872
2846
|
finishedAt: now.getTime(),
|
|
2873
2847
|
});
|
|
2848
|
+
automationDispatcher.drain(automation.id);
|
|
2874
2849
|
didExpireAny = true;
|
|
2875
2850
|
}
|
|
2876
2851
|
if (didExpireAny)
|
|
2877
2852
|
broadcastAutomations();
|
|
2878
2853
|
});
|
|
2879
|
-
|
|
2880
|
-
automationScheduler.start();
|
|
2854
|
+
automationDispatcher.recover();
|
|
2855
|
+
automationScheduler.start(heartbeatStore.read());
|
|
2881
2856
|
// Arm folder-watch triggers for the automations loaded at boot.
|
|
2882
2857
|
syncFolderWatchers();
|
|
2883
2858
|
syncSessionEventListeners();
|
|
@@ -2909,10 +2884,17 @@ export const createServer = async (options = {}) => {
|
|
|
2909
2884
|
const stop = () => {
|
|
2910
2885
|
if (stopPromise)
|
|
2911
2886
|
return stopPromise;
|
|
2887
|
+
automationExecutionStopped = true;
|
|
2888
|
+
automationDispatcher.dispose();
|
|
2889
|
+
automationScheduler.dispose();
|
|
2890
|
+
for (const run of agentRuns.values())
|
|
2891
|
+
run.controller.abort();
|
|
2912
2892
|
stopPromise = (async () => {
|
|
2893
|
+
await Promise.allSettled([...agentRuns.values()].map((run) => run.promise));
|
|
2913
2894
|
hibernateStore.write(await registry.hibernateEntries());
|
|
2914
2895
|
automationScheduler.dispose();
|
|
2915
2896
|
folderWatchManager.dispose();
|
|
2897
|
+
sessionEventManager.dispose();
|
|
2916
2898
|
automationGitWatcher.dispose();
|
|
2917
2899
|
updateCheckStore.dispose();
|
|
2918
2900
|
webhookTriggerManager.dispose();
|