@mono-agent/agent-app 0.19.1 → 0.20.1
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/README.md +307 -32
- package/dist/acp-bridge.d.ts.map +1 -1
- package/dist/acp-bridge.js +113 -54
- package/dist/acp-bridge.js.map +1 -1
- package/dist/acp-session-store.d.ts +12 -0
- package/dist/acp-session-store.d.ts.map +1 -0
- package/dist/acp-session-store.js +74 -0
- package/dist/acp-session-store.js.map +1 -0
- package/dist/agent-artifact-paths.d.ts +8 -0
- package/dist/agent-artifact-paths.d.ts.map +1 -0
- package/dist/agent-artifact-paths.js +11 -0
- package/dist/agent-artifact-paths.js.map +1 -0
- package/dist/agent-root-coordinator.d.ts +101 -0
- package/dist/agent-root-coordinator.d.ts.map +1 -0
- package/dist/agent-root-coordinator.js +470 -0
- package/dist/agent-root-coordinator.js.map +1 -0
- package/dist/app-controller-channels.d.ts +13 -1
- package/dist/app-controller-channels.d.ts.map +1 -1
- package/dist/app-controller-channels.js +152 -20
- package/dist/app-controller-channels.js.map +1 -1
- package/dist/app-controller-continuation.d.ts +2 -0
- package/dist/app-controller-continuation.d.ts.map +1 -1
- package/dist/app-controller-continuation.js.map +1 -1
- package/dist/app-controller-lifecycle.d.ts +5 -0
- package/dist/app-controller-lifecycle.d.ts.map +1 -1
- package/dist/app-controller-lifecycle.js +24 -5
- package/dist/app-controller-lifecycle.js.map +1 -1
- package/dist/app-controller-maintenance.d.ts +4 -0
- package/dist/app-controller-maintenance.d.ts.map +1 -1
- package/dist/app-controller-maintenance.js +34 -2
- package/dist/app-controller-maintenance.js.map +1 -1
- package/dist/app-controller-memory.d.ts +2 -0
- package/dist/app-controller-memory.d.ts.map +1 -1
- package/dist/app-controller-memory.js +6 -3
- package/dist/app-controller-memory.js.map +1 -1
- package/dist/app-controller-process-jobs.d.ts +49 -0
- package/dist/app-controller-process-jobs.d.ts.map +1 -0
- package/dist/app-controller-process-jobs.js +294 -0
- package/dist/app-controller-process-jobs.js.map +1 -0
- package/dist/app-controller-responder.d.ts +17 -0
- package/dist/app-controller-responder.d.ts.map +1 -1
- package/dist/app-controller-responder.js +164 -13
- package/dist/app-controller-responder.js.map +1 -1
- package/dist/app-controller-traceability.d.ts +8 -0
- package/dist/app-controller-traceability.d.ts.map +1 -1
- package/dist/app-controller-traceability.js +22 -1
- package/dist/app-controller-traceability.js.map +1 -1
- package/dist/app-controller-types.d.ts +1 -1
- package/dist/app-controller-types.d.ts.map +1 -1
- package/dist/app-controller-utils.d.ts +8 -0
- package/dist/app-controller-utils.d.ts.map +1 -1
- package/dist/app-controller-utils.js +24 -1
- package/dist/app-controller-utils.js.map +1 -1
- package/dist/app-controller.d.ts +45 -1
- package/dist/app-controller.d.ts.map +1 -1
- package/dist/app-controller.js +135 -39
- package/dist/app-controller.js.map +1 -1
- package/dist/background-log-maintenance.d.ts +85 -2
- package/dist/background-log-maintenance.d.ts.map +1 -1
- package/dist/background-log-maintenance.js +407 -136
- package/dist/background-log-maintenance.js.map +1 -1
- package/dist/background-runtime.d.ts.map +1 -1
- package/dist/background-runtime.js +22 -41
- package/dist/background-runtime.js.map +1 -1
- package/dist/background.d.ts +18 -26
- package/dist/background.d.ts.map +1 -1
- package/dist/background.js +105 -138
- package/dist/background.js.map +1 -1
- package/dist/channel-drivers/cron.d.ts +12 -1
- package/dist/channel-drivers/cron.d.ts.map +1 -1
- package/dist/channel-drivers/cron.js +121 -18
- package/dist/channel-drivers/cron.js.map +1 -1
- package/dist/channel-drivers/native-notify.d.ts +3 -0
- package/dist/channel-drivers/native-notify.d.ts.map +1 -1
- package/dist/channel-drivers/native-notify.js +2 -1
- package/dist/channel-drivers/native-notify.js.map +1 -1
- package/dist/channel-drivers/slack.d.ts.map +1 -1
- package/dist/channel-drivers/slack.js +41 -4
- package/dist/channel-drivers/slack.js.map +1 -1
- package/dist/channel-drivers/telegram.d.ts.map +1 -1
- package/dist/channel-drivers/telegram.js +46 -3
- package/dist/channel-drivers/telegram.js.map +1 -1
- package/dist/channel-drivers/tui.d.ts +13 -2
- package/dist/channel-drivers/tui.d.ts.map +1 -1
- package/dist/channel-drivers/tui.js +112 -3
- package/dist/channel-drivers/tui.js.map +1 -1
- package/dist/channels.d.ts +1 -1
- package/dist/channels.d.ts.map +1 -1
- package/dist/channels.js +4 -2
- package/dist/channels.js.map +1 -1
- package/dist/cli-args.d.ts +15 -1
- package/dist/cli-args.d.ts.map +1 -1
- package/dist/cli-args.js +82 -12
- package/dist/cli-args.js.map +1 -1
- package/dist/cli-background-command.d.ts +9 -2
- package/dist/cli-background-command.d.ts.map +1 -1
- package/dist/cli-background-command.js +179 -66
- package/dist/cli-background-command.js.map +1 -1
- package/dist/cli-commands.d.ts.map +1 -1
- package/dist/cli-commands.js +50 -12
- package/dist/cli-commands.js.map +1 -1
- package/dist/cli-help.d.ts.map +1 -1
- package/dist/cli-help.js +21 -1
- package/dist/cli-help.js.map +1 -1
- package/dist/config-reference.d.ts.map +1 -1
- package/dist/config-reference.js +105 -2
- package/dist/config-reference.js.map +1 -1
- package/dist/configured-agent.d.ts +64 -21
- package/dist/configured-agent.d.ts.map +1 -1
- package/dist/configured-agent.js +840 -156
- package/dist/configured-agent.js.map +1 -1
- package/dist/conversation-state-roots.d.ts +59 -0
- package/dist/conversation-state-roots.d.ts.map +1 -0
- package/dist/conversation-state-roots.js +248 -0
- package/dist/conversation-state-roots.js.map +1 -0
- package/dist/cron-control-store.d.ts +91 -0
- package/dist/cron-control-store.d.ts.map +1 -0
- package/dist/cron-control-store.js +1251 -0
- package/dist/cron-control-store.js.map +1 -0
- package/dist/cron-operator-service.d.ts +54 -0
- package/dist/cron-operator-service.d.ts.map +1 -0
- package/dist/cron-operator-service.js +352 -0
- package/dist/cron-operator-service.js.map +1 -0
- package/dist/doctor-session-history.d.ts +9 -0
- package/dist/doctor-session-history.d.ts.map +1 -0
- package/dist/doctor-session-history.js +313 -0
- package/dist/doctor-session-history.js.map +1 -0
- package/dist/doctor.d.ts +2 -1
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +356 -9
- package/dist/doctor.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/interaction-bridge.d.ts +1 -0
- package/dist/interaction-bridge.d.ts.map +1 -1
- package/dist/interaction-bridge.js +54 -6
- package/dist/interaction-bridge.js.map +1 -1
- package/dist/jobs-command.d.ts +17 -0
- package/dist/jobs-command.d.ts.map +1 -0
- package/dist/jobs-command.js +207 -0
- package/dist/jobs-command.js.map +1 -0
- package/dist/launchd-lifecycle-lock.d.ts +36 -0
- package/dist/launchd-lifecycle-lock.d.ts.map +1 -0
- package/dist/launchd-lifecycle-lock.js +124 -0
- package/dist/launchd-lifecycle-lock.js.map +1 -0
- package/dist/launchd-log-monitor-status.d.ts +7 -0
- package/dist/launchd-log-monitor-status.d.ts.map +1 -0
- package/dist/launchd-log-monitor-status.js +224 -0
- package/dist/launchd-log-monitor-status.js.map +1 -0
- package/dist/launchd-logs.d.ts +9 -1
- package/dist/launchd-logs.d.ts.map +1 -1
- package/dist/launchd-logs.js +69 -16
- package/dist/launchd-logs.js.map +1 -1
- package/dist/launchd-maintenance-command.d.ts +10 -0
- package/dist/launchd-maintenance-command.d.ts.map +1 -0
- package/dist/launchd-maintenance-command.js +2 -0
- package/dist/launchd-maintenance-command.js.map +1 -0
- package/dist/launchd-maintenance-entry.d.ts +30 -0
- package/dist/launchd-maintenance-entry.d.ts.map +1 -0
- package/dist/launchd-maintenance-entry.js +206 -0
- package/dist/launchd-maintenance-entry.js.map +1 -0
- package/dist/launchd-maintenance-gate.d.ts +28 -0
- package/dist/launchd-maintenance-gate.d.ts.map +1 -0
- package/dist/launchd-maintenance-gate.js +80 -0
- package/dist/launchd-maintenance-gate.js.map +1 -0
- package/dist/launchd-private-files.d.ts +3 -0
- package/dist/launchd-private-files.d.ts.map +1 -0
- package/dist/launchd-private-files.js +58 -0
- package/dist/launchd-private-files.js.map +1 -0
- package/dist/launchd.d.ts +56 -2
- package/dist/launchd.d.ts.map +1 -1
- package/dist/launchd.js +210 -5
- package/dist/launchd.js.map +1 -1
- package/dist/local-configuration.d.ts.map +1 -1
- package/dist/local-configuration.js +34 -7
- package/dist/local-configuration.js.map +1 -1
- package/dist/managed-launchd-maintenance-environment.d.ts +3 -0
- package/dist/managed-launchd-maintenance-environment.d.ts.map +1 -0
- package/dist/managed-launchd-maintenance-environment.js +12 -0
- package/dist/managed-launchd-maintenance-environment.js.map +1 -0
- package/dist/managed-runtime-launch-proof.d.ts +11 -0
- package/dist/managed-runtime-launch-proof.d.ts.map +1 -0
- package/dist/managed-runtime-launch-proof.js +59 -0
- package/dist/managed-runtime-launch-proof.js.map +1 -0
- package/dist/managed-runtime-maintenance-entry.d.ts +10 -0
- package/dist/managed-runtime-maintenance-entry.d.ts.map +1 -0
- package/dist/managed-runtime-maintenance-entry.js +244 -0
- package/dist/managed-runtime-maintenance-entry.js.map +1 -0
- package/dist/managed-web-logs.d.ts +29 -11
- package/dist/managed-web-logs.d.ts.map +1 -1
- package/dist/managed-web-logs.js +244 -121
- package/dist/managed-web-logs.js.map +1 -1
- package/dist/managed-web-maintenance-environment.d.ts +6 -0
- package/dist/managed-web-maintenance-environment.d.ts.map +1 -0
- package/dist/managed-web-maintenance-environment.js +19 -0
- package/dist/managed-web-maintenance-environment.js.map +1 -0
- package/dist/mcp-apps.d.ts +37 -0
- package/dist/mcp-apps.d.ts.map +1 -0
- package/dist/mcp-apps.js +1753 -0
- package/dist/mcp-apps.js.map +1 -0
- package/dist/memory-command.d.ts +12 -0
- package/dist/memory-command.d.ts.map +1 -1
- package/dist/memory-command.js +510 -1
- package/dist/memory-command.js.map +1 -1
- package/dist/modules/known-tools.d.ts +1 -1
- package/dist/modules/known-tools.d.ts.map +1 -1
- package/dist/modules/known-tools.js +2 -0
- package/dist/modules/known-tools.js.map +1 -1
- package/dist/posted-reply-history.d.ts.map +1 -1
- package/dist/posted-reply-history.js +12 -0
- package/dist/posted-reply-history.js.map +1 -1
- package/dist/proactive-notify.d.ts +4 -1
- package/dist/proactive-notify.d.ts.map +1 -1
- package/dist/proactive-notify.js +43 -5
- package/dist/proactive-notify.js.map +1 -1
- package/dist/process-jobs-config.d.ts +84 -0
- package/dist/process-jobs-config.d.ts.map +1 -0
- package/dist/process-jobs-config.js +427 -0
- package/dist/process-jobs-config.js.map +1 -0
- package/dist/process-jobs-context.d.ts +24 -0
- package/dist/process-jobs-context.d.ts.map +1 -0
- package/dist/process-jobs-context.js +135 -0
- package/dist/process-jobs-context.js.map +1 -0
- package/dist/process-jobs-protection.d.ts +33 -0
- package/dist/process-jobs-protection.d.ts.map +1 -0
- package/dist/process-jobs-protection.js +74 -0
- package/dist/process-jobs-protection.js.map +1 -0
- package/dist/process-jobs-root-registry.d.ts +115 -0
- package/dist/process-jobs-root-registry.d.ts.map +1 -0
- package/dist/process-jobs-root-registry.js +978 -0
- package/dist/process-jobs-root-registry.js.map +1 -0
- package/dist/process-jobs-runtime.d.ts +38 -0
- package/dist/process-jobs-runtime.d.ts.map +1 -0
- package/dist/process-jobs-runtime.js +178 -0
- package/dist/process-jobs-runtime.js.map +1 -0
- package/dist/process-jobs-service.d.ts +70 -0
- package/dist/process-jobs-service.d.ts.map +1 -0
- package/dist/process-jobs-service.js +1989 -0
- package/dist/process-jobs-service.js.map +1 -0
- package/dist/process-jobs-store.d.ts +151 -0
- package/dist/process-jobs-store.d.ts.map +1 -0
- package/dist/process-jobs-store.js +1290 -0
- package/dist/process-jobs-store.js.map +1 -0
- package/dist/reply-artifacts.d.ts +65 -0
- package/dist/reply-artifacts.d.ts.map +1 -0
- package/dist/reply-artifacts.js +1147 -0
- package/dist/reply-artifacts.js.map +1 -0
- package/dist/reply-part-budget.d.ts +16 -0
- package/dist/reply-part-budget.d.ts.map +1 -0
- package/dist/reply-part-budget.js +57 -0
- package/dist/reply-part-budget.js.map +1 -0
- package/dist/request-model-override.d.ts +19 -0
- package/dist/request-model-override.d.ts.map +1 -1
- package/dist/request-model-override.js +54 -1
- package/dist/request-model-override.js.map +1 -1
- package/dist/request-scoped-mcp.d.ts +31 -0
- package/dist/request-scoped-mcp.d.ts.map +1 -0
- package/dist/request-scoped-mcp.js +197 -0
- package/dist/request-scoped-mcp.js.map +1 -0
- package/dist/run-history.d.ts.map +1 -1
- package/dist/run-history.js +50 -254
- package/dist/run-history.js.map +1 -1
- package/dist/runtime-option-extensions.d.ts +23 -0
- package/dist/runtime-option-extensions.d.ts.map +1 -1
- package/dist/runtime-option-extensions.js +55 -0
- package/dist/runtime-option-extensions.js.map +1 -1
- package/dist/runtime-routes.d.ts +7 -1
- package/dist/runtime-routes.d.ts.map +1 -1
- package/dist/runtime-routes.js +25 -0
- package/dist/runtime-routes.js.map +1 -1
- package/dist/secure-file-replace.d.ts +3 -0
- package/dist/secure-file-replace.d.ts.map +1 -1
- package/dist/secure-file-replace.js +31 -7
- package/dist/secure-file-replace.js.map +1 -1
- package/dist/session-history.d.ts +146 -0
- package/dist/session-history.d.ts.map +1 -0
- package/dist/session-history.js +347 -0
- package/dist/session-history.js.map +1 -0
- package/dist/sessions.d.ts +45 -2
- package/dist/sessions.d.ts.map +1 -1
- package/dist/sessions.js +849 -30
- package/dist/sessions.js.map +1 -1
- package/dist/web-command.d.ts +12 -11
- package/dist/web-command.d.ts.map +1 -1
- package/dist/web-command.js +554 -80
- package/dist/web-command.js.map +1 -1
- package/dist/web-log-maintenance-command.d.ts +7 -0
- package/dist/web-log-maintenance-command.d.ts.map +1 -0
- package/dist/web-log-maintenance-command.js +21 -0
- package/dist/web-log-maintenance-command.js.map +1 -0
- package/dist/web-log-maintenance-status.d.ts +26 -0
- package/dist/web-log-maintenance-status.d.ts.map +1 -0
- package/dist/web-log-maintenance-status.js +171 -0
- package/dist/web-log-maintenance-status.js.map +1 -0
- package/dist/web-log-maintenance.d.ts +32 -0
- package/dist/web-log-maintenance.d.ts.map +1 -0
- package/dist/web-log-maintenance.js +163 -0
- package/dist/web-log-maintenance.js.map +1 -0
- package/dist/web-maintenance-paths.d.ts +12 -0
- package/dist/web-maintenance-paths.d.ts.map +1 -0
- package/dist/web-maintenance-paths.js +24 -0
- package/dist/web-maintenance-paths.js.map +1 -0
- package/dist/wizard/prompts.d.ts.map +1 -1
- package/dist/wizard/prompts.js +1 -0
- package/dist/wizard/prompts.js.map +1 -1
- package/dist/wizard/run.js +1 -1
- package/dist/wizard/run.js.map +1 -1
- package/package.json +15 -15
- package/schema/mono-agent.config.schema.json +106 -1
- package/skills/mono-agent-composer/references/config-blueprint.md +27 -2
- package/skills/mono-agent-composer/references/discovery-questions.md +2 -2
- package/skills/mono-agent-composer/references/feature-coverage.md +6 -4
- package/skills/mono-agent-composer/references/package-map.md +19 -3
- package/skills/mono-agent-composer/references/playbooks.md +3 -3
- package/skills/mono-agent-composer/references/validation.md +4 -2
|
@@ -0,0 +1,1251 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { chmod, lstat, mkdir, open, readFile, readdir, realpath, rename } from "node:fs/promises";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { DatabaseSync } from "node:sqlite";
|
|
5
|
+
import { MAX_AGENT_REPLY_PARTS, MAX_CRON_OPERATOR_DETAIL_ARTIFACT_ID_BYTES, MAX_CRON_OPERATOR_DETAIL_ERROR_BYTES, MAX_CRON_OPERATOR_DETAIL_EVENT_BYTES, MAX_CRON_OPERATOR_DETAIL_EVENTS, MAX_CRON_OPERATOR_DETAIL_FAILURE_KIND_BYTES, MAX_CRON_OPERATOR_DETAIL_TEXT_BYTES, MAX_CRON_OPERATOR_SUMMARY_ARTIFACT_ID_BYTES, MAX_CRON_OPERATOR_SUMMARY_ERROR_BYTES, MAX_CRON_OPERATOR_SUMMARY_FAILURE_KIND_BYTES, MAX_CRON_OPERATOR_SUMMARY_REPLY_PART_OUTCOMES, MAX_CRON_OPERATOR_SUMMARY_TEXT_BYTES, MAX_CRON_OPERATOR_RESPONSE_BYTES, isAgentReplyPartDeliveryOutcomes, sanitizeReplyPartDeliveryOutcomes, } from "@mono-agent/agent-contracts";
|
|
6
|
+
const CONTROL_SCHEMA = 1;
|
|
7
|
+
const CONTROL_MARKER = ".mono-agent-cron-control";
|
|
8
|
+
const CONTROL_DATABASE = "state.sqlite";
|
|
9
|
+
const CONTROL_LEASE = "lease.sqlite";
|
|
10
|
+
const CONTROL_INITIALIZING_SUFFIX = ".initializing";
|
|
11
|
+
const MARKER_CONTENT = `${JSON.stringify({ kind: "mono-agent-cron-control", schema: CONTROL_SCHEMA })}\n`;
|
|
12
|
+
const INITIALIZING_MARKER_CONTENT = `${JSON.stringify({
|
|
13
|
+
kind: "mono-agent-cron-control",
|
|
14
|
+
schema: CONTROL_SCHEMA,
|
|
15
|
+
state: "initializing",
|
|
16
|
+
})}\n`;
|
|
17
|
+
const MAX_RUNS_PER_JOB = 500;
|
|
18
|
+
const RUN_RETENTION_MS = 90 * 24 * 60 * 60 * 1_000;
|
|
19
|
+
const IDEMPOTENCY_RETENTION_MS = 7 * 24 * 60 * 60 * 1_000;
|
|
20
|
+
const AUDIT_RETENTION_MS = 180 * 24 * 60 * 60 * 1_000;
|
|
21
|
+
const MAX_IDEMPOTENCY_RECORDS = 2_048;
|
|
22
|
+
const MAX_AUDIT_RECORDS = 5_000;
|
|
23
|
+
const MAX_EVENTS_PER_RUN = MAX_CRON_OPERATOR_DETAIL_EVENTS;
|
|
24
|
+
const MAX_EVENT_BYTES = 1024 * 1024;
|
|
25
|
+
const MAX_EVENT_BYTES_PER_RUN = 4 * 1024 * 1024;
|
|
26
|
+
const MAX_DETAIL_EVENT_BYTES = MAX_CRON_OPERATOR_DETAIL_EVENT_BYTES;
|
|
27
|
+
const MAX_SUMMARY_TEXT_BYTES = MAX_CRON_OPERATOR_SUMMARY_TEXT_BYTES;
|
|
28
|
+
const MAX_SUMMARY_ERROR_BYTES = MAX_CRON_OPERATOR_SUMMARY_ERROR_BYTES;
|
|
29
|
+
const MAX_DETAIL_TEXT_BYTES = MAX_CRON_OPERATOR_DETAIL_TEXT_BYTES;
|
|
30
|
+
const MAX_DETAIL_ERROR_BYTES = MAX_CRON_OPERATOR_DETAIL_ERROR_BYTES;
|
|
31
|
+
const MAX_SUMMARY_ARTIFACT_ID_BYTES = MAX_CRON_OPERATOR_SUMMARY_ARTIFACT_ID_BYTES;
|
|
32
|
+
const MAX_DETAIL_ARTIFACT_ID_BYTES = MAX_CRON_OPERATOR_DETAIL_ARTIFACT_ID_BYTES;
|
|
33
|
+
const MAX_SUMMARY_FAILURE_KIND_BYTES = MAX_CRON_OPERATOR_SUMMARY_FAILURE_KIND_BYTES;
|
|
34
|
+
const MAX_DETAIL_FAILURE_KIND_BYTES = MAX_CRON_OPERATOR_DETAIL_FAILURE_KIND_BYTES;
|
|
35
|
+
const REPLY_PART_OUTCOMES_STORAGE_SCHEMA = 1;
|
|
36
|
+
const MAX_REPLY_PART_OUTCOMES_STORAGE_BYTES = 8 * 1024;
|
|
37
|
+
const MUTABLE_RUN_STATUSES = new Set([
|
|
38
|
+
"admitted",
|
|
39
|
+
"running",
|
|
40
|
+
"queued",
|
|
41
|
+
]);
|
|
42
|
+
// Inspection runs synchronously during config load. A bounded wait absorbs a
|
|
43
|
+
// brief DELETE-journal writer handoff without letting sustained contention
|
|
44
|
+
// stall reload indefinitely or be mistaken for a permanently corrupt store.
|
|
45
|
+
const INSPECTION_BUSY_TIMEOUT_MS = 500;
|
|
46
|
+
export class CronControlStoreError extends Error {
|
|
47
|
+
kind;
|
|
48
|
+
constructor(kind, message, options) {
|
|
49
|
+
super(message, options);
|
|
50
|
+
this.name = "CronControlStoreError";
|
|
51
|
+
this.kind = kind;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
export function resolveCronControlPaths(cwd) {
|
|
55
|
+
const root = resolve(cwd, ".mono-agent", "cron-control-v1");
|
|
56
|
+
return {
|
|
57
|
+
root,
|
|
58
|
+
marker: join(root, CONTROL_MARKER),
|
|
59
|
+
database: join(root, CONTROL_DATABASE),
|
|
60
|
+
lease: join(root, CONTROL_LEASE),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
export async function inspectCronControlStore(cwd) {
|
|
64
|
+
try {
|
|
65
|
+
const paths = await resolveCanonicalCronControlPaths(cwd);
|
|
66
|
+
const initializingPaths = initializationPaths(paths);
|
|
67
|
+
const root = await lstatIfPresent(paths.root);
|
|
68
|
+
if (root === undefined) {
|
|
69
|
+
const initializingRoot = await lstatIfPresent(initializingPaths.root);
|
|
70
|
+
if (initializingRoot === undefined)
|
|
71
|
+
return { status: "absent" };
|
|
72
|
+
await assertRecoverableInitialization(initializingPaths, initializingRoot);
|
|
73
|
+
return { status: "initializing" };
|
|
74
|
+
}
|
|
75
|
+
if (await lstatIfPresent(initializingPaths.root) !== undefined) {
|
|
76
|
+
throw new CronControlStoreError("corrupt", `Cron control state has an ambiguous completed and initializing root: ${paths.root}`);
|
|
77
|
+
}
|
|
78
|
+
return { status: "ready", overrides: await inspectReadyControlStore(paths, root) };
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
82
|
+
return { status: "degraded", reason };
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
export async function openCronControlStore(cwd, options = {}) {
|
|
86
|
+
// The caller-selected agent cwd is the trust boundary. Canonicalize that
|
|
87
|
+
// boundary once so platform aliases above it (notably macOS /var ->
|
|
88
|
+
// /private/var) do not look like attacker-controlled links in our state
|
|
89
|
+
// subtree. The strict realpath checks below still reject links introduced in
|
|
90
|
+
// .mono-agent/cron-control-v1 itself.
|
|
91
|
+
const canonicalCwd = await realpath(resolve(cwd));
|
|
92
|
+
const paths = resolveCronControlPaths(canonicalCwd);
|
|
93
|
+
const initial = await inspectCronControlStore(canonicalCwd);
|
|
94
|
+
if (initial.status === "absent" || initial.status === "initializing") {
|
|
95
|
+
await initializeControlStore(paths, options.onInitializationCheckpoint);
|
|
96
|
+
}
|
|
97
|
+
else if (initial.status === "degraded") {
|
|
98
|
+
throw new CronControlStoreError("corrupt", initial.reason);
|
|
99
|
+
}
|
|
100
|
+
// Re-run the bounded structural/path inspection immediately before opening
|
|
101
|
+
// the lease. This retains the managed-subtree swap checks without repeating
|
|
102
|
+
// the whole-database quick_check.
|
|
103
|
+
const verified = await inspectCronControlStore(canonicalCwd);
|
|
104
|
+
if (verified.status !== "ready") {
|
|
105
|
+
throw new CronControlStoreError("corrupt", verified.status === "degraded" ? verified.reason : "Cron control state disappeared during startup.");
|
|
106
|
+
}
|
|
107
|
+
const lease = new DatabaseSync(paths.lease, { timeout: 0 });
|
|
108
|
+
try {
|
|
109
|
+
lease.exec("PRAGMA locking_mode=EXCLUSIVE; BEGIN EXCLUSIVE;");
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
lease.close();
|
|
113
|
+
throw new CronControlStoreError("lease_conflict", `Cron control state is already owned by another live process: ${paths.root}`, { cause: error });
|
|
114
|
+
}
|
|
115
|
+
const releaseLease = () => {
|
|
116
|
+
try {
|
|
117
|
+
if (lease.isTransaction)
|
|
118
|
+
lease.exec("ROLLBACK");
|
|
119
|
+
}
|
|
120
|
+
finally {
|
|
121
|
+
lease.close();
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
let database;
|
|
125
|
+
try {
|
|
126
|
+
database = new DatabaseSync(paths.database, { timeout: 5_000 });
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
releaseLease();
|
|
130
|
+
throw error;
|
|
131
|
+
}
|
|
132
|
+
const now = options.now ?? (() => new Date());
|
|
133
|
+
try {
|
|
134
|
+
database.exec("PRAGMA foreign_keys=ON; PRAGMA journal_mode=DELETE;");
|
|
135
|
+
// This is intentionally the only O(database) health check in the normal
|
|
136
|
+
// inspect -> open lifecycle. It runs after exclusive ownership is proven
|
|
137
|
+
// and before interrupted-run reconciliation or any scheduler admission.
|
|
138
|
+
assertHealthyDatabase(database);
|
|
139
|
+
ensureControlSchemaColumns(database);
|
|
140
|
+
reconcileInterruptedRuns(database, now().toISOString());
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
database.close();
|
|
144
|
+
releaseLease();
|
|
145
|
+
throw error;
|
|
146
|
+
}
|
|
147
|
+
let closed = false;
|
|
148
|
+
const requireOpen = () => {
|
|
149
|
+
if (closed)
|
|
150
|
+
throw new CronControlStoreError("corrupt", "Cron control store is closed.");
|
|
151
|
+
};
|
|
152
|
+
const withTransaction = (operation) => {
|
|
153
|
+
requireOpen();
|
|
154
|
+
database.exec("BEGIN IMMEDIATE");
|
|
155
|
+
try {
|
|
156
|
+
const value = operation();
|
|
157
|
+
database.exec("COMMIT");
|
|
158
|
+
return value;
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
if (database.isTransaction)
|
|
162
|
+
database.exec("ROLLBACK");
|
|
163
|
+
throw error;
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
const allocate = (input) => {
|
|
167
|
+
const previous = database.prepare("SELECT last_sequence FROM job_sequences WHERE job_id = ?")
|
|
168
|
+
.get(input.jobId);
|
|
169
|
+
const sequence = (previous?.last_sequence ?? 0) + 1;
|
|
170
|
+
database.prepare(`
|
|
171
|
+
INSERT INTO job_sequences (job_id, last_sequence) VALUES (?, ?)
|
|
172
|
+
ON CONFLICT(job_id) DO UPDATE SET last_sequence = excluded.last_sequence
|
|
173
|
+
`).run(input.jobId, sequence);
|
|
174
|
+
const runId = input.trigger === "manual"
|
|
175
|
+
? `cron:${encodeURIComponent(input.jobId)}:${input.observedAt}:m${String(sequence)}`
|
|
176
|
+
: `cron:${encodeURIComponent(input.jobId)}:${input.scheduledAt}`;
|
|
177
|
+
database.prepare(`
|
|
178
|
+
INSERT INTO cron_runs (
|
|
179
|
+
run_id, job_id, scheduled_at, ordered_at, sequence, trigger, status, created_at
|
|
180
|
+
) VALUES (?, ?, ?, ?, ?, ?, 'admitted', ?)
|
|
181
|
+
`).run(runId, input.jobId, input.scheduledAt, input.observedAt, sequence, input.trigger, input.observedAt);
|
|
182
|
+
return {
|
|
183
|
+
runId,
|
|
184
|
+
jobId: input.jobId,
|
|
185
|
+
scheduledAt: input.scheduledAt,
|
|
186
|
+
orderedAt: input.observedAt,
|
|
187
|
+
sequence,
|
|
188
|
+
trigger: input.trigger,
|
|
189
|
+
};
|
|
190
|
+
};
|
|
191
|
+
return {
|
|
192
|
+
paths,
|
|
193
|
+
overrides() {
|
|
194
|
+
requireOpen();
|
|
195
|
+
return new Map(database.prepare("SELECT job_id, runtime_enabled FROM job_controls WHERE runtime_enabled IS NOT NULL ORDER BY job_id").all().map((row) => [row.job_id, row.runtime_enabled === 1]));
|
|
196
|
+
},
|
|
197
|
+
syncConfiguredJobs(jobIds) {
|
|
198
|
+
withTransaction(() => {
|
|
199
|
+
const timestamp = now().toISOString();
|
|
200
|
+
const statement = database.prepare(`
|
|
201
|
+
INSERT INTO cron_jobs (job_id, first_seen_at, last_seen_at) VALUES (?, ?, ?)
|
|
202
|
+
ON CONFLICT(job_id) DO UPDATE SET last_seen_at = excluded.last_seen_at
|
|
203
|
+
`);
|
|
204
|
+
for (const jobId of jobIds)
|
|
205
|
+
statement.run(jobId, timestamp, timestamp);
|
|
206
|
+
});
|
|
207
|
+
},
|
|
208
|
+
knownJobIds() {
|
|
209
|
+
requireOpen();
|
|
210
|
+
return database.prepare("SELECT job_id FROM cron_jobs ORDER BY job_id").all()
|
|
211
|
+
.map((row) => row.job_id);
|
|
212
|
+
},
|
|
213
|
+
allocateFiring(input) {
|
|
214
|
+
return withTransaction(() => allocate(input));
|
|
215
|
+
},
|
|
216
|
+
replayRunNowAction(input) {
|
|
217
|
+
requireOpen();
|
|
218
|
+
const replay = idempotencyRecord(database, input.idempotencyKey, "run_now", input.jobId, input.requestHash);
|
|
219
|
+
if (replay === undefined)
|
|
220
|
+
return undefined;
|
|
221
|
+
const stored = parseRunNowReceipt(replay);
|
|
222
|
+
const runId = typeof stored === "string" ? stored : stored.runId;
|
|
223
|
+
const record = runRow(database, runId);
|
|
224
|
+
if (record !== undefined)
|
|
225
|
+
return operatorRunSummary(record);
|
|
226
|
+
if (typeof stored !== "string")
|
|
227
|
+
return stored;
|
|
228
|
+
throw new CronControlStoreError("replay_expired", `Cron action replay expired after run retention: ${runId}`);
|
|
229
|
+
},
|
|
230
|
+
runNowAction(input) {
|
|
231
|
+
return withTransaction(() => {
|
|
232
|
+
const replay = idempotencyRecord(database, input.idempotencyKey, "run_now", input.jobId, input.requestHash);
|
|
233
|
+
if (replay !== undefined) {
|
|
234
|
+
const stored = parseRunNowReceipt(replay);
|
|
235
|
+
const runId = typeof stored === "string" ? stored : stored.runId;
|
|
236
|
+
const record = runRow(database, runId);
|
|
237
|
+
if (record !== undefined) {
|
|
238
|
+
return { firing: firingFromRow(record), run: operatorRunSummary(record), replayed: true };
|
|
239
|
+
}
|
|
240
|
+
if (typeof stored !== "string") {
|
|
241
|
+
return { firing: firingFromOperatorRun(stored), run: stored, replayed: true };
|
|
242
|
+
}
|
|
243
|
+
throw new CronControlStoreError("replay_expired", `Cron action replay expired after run retention: ${runId}`);
|
|
244
|
+
}
|
|
245
|
+
const firing = allocate({
|
|
246
|
+
jobId: input.jobId,
|
|
247
|
+
scheduledAt: input.observedAt,
|
|
248
|
+
observedAt: input.observedAt,
|
|
249
|
+
trigger: "manual",
|
|
250
|
+
});
|
|
251
|
+
const allocated = runRow(database, firing.runId);
|
|
252
|
+
if (allocated === undefined) {
|
|
253
|
+
throw new CronControlStoreError("corrupt", "Allocated cron run is missing.");
|
|
254
|
+
}
|
|
255
|
+
const run = operatorRunSummary(allocated);
|
|
256
|
+
insertIdempotency(database, {
|
|
257
|
+
key: input.idempotencyKey,
|
|
258
|
+
action: "run_now",
|
|
259
|
+
jobId: input.jobId,
|
|
260
|
+
requestHash: input.requestHash,
|
|
261
|
+
response: JSON.stringify({ run }),
|
|
262
|
+
targetRunId: firing.runId,
|
|
263
|
+
createdAt: input.observedAt,
|
|
264
|
+
});
|
|
265
|
+
insertAudit(database, {
|
|
266
|
+
action: "run_now",
|
|
267
|
+
jobId: input.jobId,
|
|
268
|
+
outcome: "accepted",
|
|
269
|
+
idempotencyKey: input.idempotencyKey,
|
|
270
|
+
detail: { runId: firing.runId },
|
|
271
|
+
createdAt: input.observedAt,
|
|
272
|
+
});
|
|
273
|
+
applyRetention(database, now());
|
|
274
|
+
return { firing, run, replayed: false };
|
|
275
|
+
});
|
|
276
|
+
},
|
|
277
|
+
replayEnabledAction(input) {
|
|
278
|
+
requireOpen();
|
|
279
|
+
const replay = idempotencyRecord(database, input.idempotencyKey, "set_enabled", input.jobId, input.requestHash);
|
|
280
|
+
if (replay === undefined)
|
|
281
|
+
return undefined;
|
|
282
|
+
const parsed = JSON.parse(replay);
|
|
283
|
+
if (typeof parsed.enabled !== "boolean") {
|
|
284
|
+
throw new CronControlStoreError("corrupt", "Stored cron enable idempotency result is invalid.");
|
|
285
|
+
}
|
|
286
|
+
return parsed.enabled;
|
|
287
|
+
},
|
|
288
|
+
setEnabledAction(input) {
|
|
289
|
+
return withTransaction(() => {
|
|
290
|
+
const replay = idempotencyRecord(database, input.idempotencyKey, "set_enabled", input.jobId, input.requestHash);
|
|
291
|
+
if (replay !== undefined) {
|
|
292
|
+
const parsed = JSON.parse(replay);
|
|
293
|
+
if (typeof parsed.enabled !== "boolean") {
|
|
294
|
+
throw new CronControlStoreError("corrupt", "Stored cron enable idempotency result is invalid.");
|
|
295
|
+
}
|
|
296
|
+
return { enabled: parsed.enabled, replayed: true };
|
|
297
|
+
}
|
|
298
|
+
const timestamp = now().toISOString();
|
|
299
|
+
database.prepare(`
|
|
300
|
+
INSERT INTO job_controls (job_id, runtime_enabled, updated_at) VALUES (?, ?, ?)
|
|
301
|
+
ON CONFLICT(job_id) DO UPDATE SET runtime_enabled = excluded.runtime_enabled, updated_at = excluded.updated_at
|
|
302
|
+
`).run(input.jobId, input.enabled ? 1 : 0, timestamp);
|
|
303
|
+
insertIdempotency(database, {
|
|
304
|
+
key: input.idempotencyKey,
|
|
305
|
+
action: "set_enabled",
|
|
306
|
+
jobId: input.jobId,
|
|
307
|
+
requestHash: input.requestHash,
|
|
308
|
+
response: JSON.stringify({ enabled: input.enabled }),
|
|
309
|
+
createdAt: timestamp,
|
|
310
|
+
});
|
|
311
|
+
insertAudit(database, {
|
|
312
|
+
action: input.enabled ? "enable" : "disable",
|
|
313
|
+
jobId: input.jobId,
|
|
314
|
+
outcome: "completed",
|
|
315
|
+
idempotencyKey: input.idempotencyKey,
|
|
316
|
+
detail: { effectiveEnabled: input.enabled },
|
|
317
|
+
createdAt: timestamp,
|
|
318
|
+
});
|
|
319
|
+
applyRetention(database, now());
|
|
320
|
+
return { enabled: input.enabled, replayed: false };
|
|
321
|
+
});
|
|
322
|
+
},
|
|
323
|
+
markStarted(firing, startedAt) {
|
|
324
|
+
requireOpen();
|
|
325
|
+
database.prepare(`
|
|
326
|
+
UPDATE cron_runs SET status = 'running', started_at = COALESCE(started_at, ?)
|
|
327
|
+
WHERE run_id = ? AND status IN ('admitted', 'queued')
|
|
328
|
+
`).run(startedAt, firing.runId);
|
|
329
|
+
},
|
|
330
|
+
appendEvent(firing, event) {
|
|
331
|
+
withTransaction(() => {
|
|
332
|
+
const row = database.prepare(`
|
|
333
|
+
SELECT status, event_count, event_bytes FROM cron_runs WHERE run_id = ?
|
|
334
|
+
`).get(firing.runId);
|
|
335
|
+
if (row === undefined)
|
|
336
|
+
throw new CronControlStoreError("corrupt", `Unknown cron run: ${firing.runId}`);
|
|
337
|
+
// A watchdog/cancellation result owns the first terminal projection.
|
|
338
|
+
// Ignore any later stream activity without even serializing it, so a
|
|
339
|
+
// responder that settles late cannot mutate or leak into that record.
|
|
340
|
+
if (!MUTABLE_RUN_STATUSES.has(row.status))
|
|
341
|
+
return;
|
|
342
|
+
const encoded = JSON.stringify(event);
|
|
343
|
+
const bytes = Buffer.byteLength(encoded, "utf8");
|
|
344
|
+
if (bytes > MAX_EVENT_BYTES
|
|
345
|
+
|| row.event_count >= MAX_EVENTS_PER_RUN
|
|
346
|
+
|| row.event_bytes + bytes > MAX_EVENT_BYTES_PER_RUN) {
|
|
347
|
+
database.prepare("UPDATE cron_runs SET events_truncated = 1 WHERE run_id = ?").run(firing.runId);
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
database.prepare(`
|
|
351
|
+
INSERT INTO cron_run_events (run_id, position, event_json, bytes) VALUES (?, ?, ?, ?)
|
|
352
|
+
`).run(firing.runId, row.event_count + 1, encoded, bytes);
|
|
353
|
+
database.prepare(`
|
|
354
|
+
UPDATE cron_runs SET event_count = event_count + 1, event_bytes = event_bytes + ? WHERE run_id = ?
|
|
355
|
+
`).run(bytes, firing.runId);
|
|
356
|
+
});
|
|
357
|
+
},
|
|
358
|
+
recordResult(result) {
|
|
359
|
+
withTransaction(() => {
|
|
360
|
+
const fields = resultFields(result);
|
|
361
|
+
const changed = database.prepare(`
|
|
362
|
+
UPDATE cron_runs SET
|
|
363
|
+
status = ?, started_at = COALESCE(started_at, ?), completed_at = ?, artifact_run_id = ?,
|
|
364
|
+
text = ?, error = ?, failure_kind = ?, reply_part_outcomes_json = ?,
|
|
365
|
+
blocked_by_run_id = ?, blocked_by_trigger = ?, queue_depth = ?
|
|
366
|
+
WHERE run_id = ?
|
|
367
|
+
AND status IN ('admitted', 'running', 'queued')
|
|
368
|
+
`).run(fields.status, fields.startedAt ?? null, fields.completedAt ?? null, fields.artifactRunId ?? null, fields.text ?? null, fields.error ?? null, fields.failureKind ?? null, fields.replyPartOutcomesJson, fields.blockedByRunId ?? null, fields.blockedByTrigger ?? null, fields.queueDepth ?? null, result.cronRunId);
|
|
369
|
+
if (changed.changes !== 1) {
|
|
370
|
+
const existing = runRow(database, result.cronRunId);
|
|
371
|
+
if (existing === undefined) {
|
|
372
|
+
throw new CronControlStoreError("corrupt", `Unknown cron run result: ${result.cronRunId}`);
|
|
373
|
+
}
|
|
374
|
+
// The first terminal callback is authoritative. A responder, timer,
|
|
375
|
+
// or host callback that settles later cannot overwrite its state,
|
|
376
|
+
// text, or sanitized rich-part outcomes.
|
|
377
|
+
if (!MUTABLE_RUN_STATUSES.has(existing.status))
|
|
378
|
+
return;
|
|
379
|
+
throw new CronControlStoreError("corrupt", `Cron run result could not be recorded: ${result.cronRunId}`);
|
|
380
|
+
}
|
|
381
|
+
const completed = runRow(database, result.cronRunId);
|
|
382
|
+
if (completed === undefined) {
|
|
383
|
+
throw new CronControlStoreError("corrupt", `Updated cron run is missing: ${result.cronRunId}`);
|
|
384
|
+
}
|
|
385
|
+
updateRunNowReceipt(database, completed);
|
|
386
|
+
if (fields.completedAt !== undefined)
|
|
387
|
+
applyRetention(database, now());
|
|
388
|
+
});
|
|
389
|
+
},
|
|
390
|
+
getRun(runId) {
|
|
391
|
+
requireOpen();
|
|
392
|
+
const row = runRow(database, runId);
|
|
393
|
+
return row === undefined ? undefined : operatorRunDetail(database, row);
|
|
394
|
+
},
|
|
395
|
+
getRunSummary(runId) {
|
|
396
|
+
requireOpen();
|
|
397
|
+
const row = runRow(database, runId);
|
|
398
|
+
return row === undefined ? undefined : operatorRunSummary(row);
|
|
399
|
+
},
|
|
400
|
+
lastRun(jobId) {
|
|
401
|
+
requireOpen();
|
|
402
|
+
const row = database.prepare(`${RUN_SELECT} WHERE job_id = ? ORDER BY ordered_at DESC, sequence DESC, run_id DESC LIMIT 1`)
|
|
403
|
+
.get(jobId);
|
|
404
|
+
return row === undefined ? undefined : operatorRunSummary(row);
|
|
405
|
+
},
|
|
406
|
+
runs(jobId, limit, before) {
|
|
407
|
+
requireOpen();
|
|
408
|
+
const cursor = before === undefined ? undefined : decodeCursor(before);
|
|
409
|
+
const rows = (cursor === undefined
|
|
410
|
+
? database.prepare(`${RUN_SELECT} WHERE job_id = ? ORDER BY ordered_at DESC, sequence DESC, run_id DESC LIMIT ?`)
|
|
411
|
+
.all(jobId, limit + 1)
|
|
412
|
+
: database.prepare(`${RUN_SELECT} WHERE job_id = ? AND (
|
|
413
|
+
ordered_at < ? OR (ordered_at = ? AND sequence < ?)
|
|
414
|
+
OR (ordered_at = ? AND sequence = ? AND run_id < ?)
|
|
415
|
+
) ORDER BY ordered_at DESC, sequence DESC, run_id DESC LIMIT ?`)
|
|
416
|
+
.all(jobId, cursor.orderedAt, cursor.orderedAt, cursor.sequence, cursor.orderedAt, cursor.sequence, cursor.runId, limit + 1));
|
|
417
|
+
return operatorRunPage(rows, limit);
|
|
418
|
+
},
|
|
419
|
+
audit(input) {
|
|
420
|
+
withTransaction(() => {
|
|
421
|
+
insertAudit(database, { ...input, createdAt: now().toISOString() });
|
|
422
|
+
applyRetention(database, now());
|
|
423
|
+
});
|
|
424
|
+
},
|
|
425
|
+
async close() {
|
|
426
|
+
if (closed)
|
|
427
|
+
return;
|
|
428
|
+
closed = true;
|
|
429
|
+
try {
|
|
430
|
+
database.close();
|
|
431
|
+
}
|
|
432
|
+
finally {
|
|
433
|
+
releaseLease();
|
|
434
|
+
}
|
|
435
|
+
},
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
export function cronActionRequestHash(value) {
|
|
439
|
+
return createHash("sha256").update(JSON.stringify(value)).digest("base64url");
|
|
440
|
+
}
|
|
441
|
+
async function resolveCanonicalCronControlPaths(cwd) {
|
|
442
|
+
return resolveCronControlPaths(await realpath(resolve(cwd)));
|
|
443
|
+
}
|
|
444
|
+
function initializationPaths(paths) {
|
|
445
|
+
const root = `${paths.root}${CONTROL_INITIALIZING_SUFFIX}`;
|
|
446
|
+
return {
|
|
447
|
+
root,
|
|
448
|
+
marker: join(root, CONTROL_MARKER),
|
|
449
|
+
database: join(root, CONTROL_DATABASE),
|
|
450
|
+
lease: join(root, CONTROL_LEASE),
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
async function lstatIfPresent(path) {
|
|
454
|
+
return await lstat(path).catch((error) => missing(error) ? undefined : Promise.reject(error));
|
|
455
|
+
}
|
|
456
|
+
async function inspectReadyControlStore(paths, root) {
|
|
457
|
+
assertOwnedDirectory(root, paths.root);
|
|
458
|
+
if (await realpath(paths.root) !== paths.root) {
|
|
459
|
+
throw new CronControlStoreError("insecure", `Cron control state contains a symbolic-link hop: ${paths.root}`);
|
|
460
|
+
}
|
|
461
|
+
await assertOwnedFile(paths.marker, "Cron control marker");
|
|
462
|
+
if (await readFile(paths.marker, "utf8") !== MARKER_CONTENT) {
|
|
463
|
+
throw new CronControlStoreError("corrupt", `Cron control marker is invalid: ${paths.marker}`);
|
|
464
|
+
}
|
|
465
|
+
await assertOwnedFile(paths.database, "Cron control database");
|
|
466
|
+
await assertOwnedFile(paths.lease, "Cron control lease database");
|
|
467
|
+
const database = new DatabaseSync(paths.database, {
|
|
468
|
+
readOnly: true,
|
|
469
|
+
timeout: INSPECTION_BUSY_TIMEOUT_MS,
|
|
470
|
+
});
|
|
471
|
+
try {
|
|
472
|
+
// Config inspection reads only the bounded state needed to decide which
|
|
473
|
+
// jobs may arm. The lease-owning open below is the one lifecycle boundary
|
|
474
|
+
// that performs the whole-database quick_check before scheduler startup.
|
|
475
|
+
assertSupportedDatabaseSchema(database);
|
|
476
|
+
const overrides = new Map();
|
|
477
|
+
for (const row of database.prepare("SELECT job_id, runtime_enabled FROM job_controls WHERE runtime_enabled IS NOT NULL ORDER BY job_id").all()) {
|
|
478
|
+
overrides.set(row.job_id, row.runtime_enabled === 1);
|
|
479
|
+
}
|
|
480
|
+
return overrides;
|
|
481
|
+
}
|
|
482
|
+
finally {
|
|
483
|
+
database.close();
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
async function assertRecoverableInitialization(paths, root) {
|
|
487
|
+
assertOwnedDirectory(root, paths.root);
|
|
488
|
+
if (await realpath(paths.root) !== paths.root) {
|
|
489
|
+
throw new CronControlStoreError("insecure", `Cron control initialization contains a symbolic-link hop: ${paths.root}`);
|
|
490
|
+
}
|
|
491
|
+
const allowedEntries = new Set([
|
|
492
|
+
CONTROL_MARKER,
|
|
493
|
+
CONTROL_DATABASE,
|
|
494
|
+
CONTROL_LEASE,
|
|
495
|
+
`${CONTROL_DATABASE}-journal`,
|
|
496
|
+
`${CONTROL_DATABASE}-shm`,
|
|
497
|
+
`${CONTROL_DATABASE}-wal`,
|
|
498
|
+
`${CONTROL_LEASE}-journal`,
|
|
499
|
+
`${CONTROL_LEASE}-shm`,
|
|
500
|
+
`${CONTROL_LEASE}-wal`,
|
|
501
|
+
]);
|
|
502
|
+
const entries = await readdir(paths.root);
|
|
503
|
+
for (const entry of entries) {
|
|
504
|
+
if (!allowedEntries.has(entry)) {
|
|
505
|
+
throw new CronControlStoreError("corrupt", `Cron control initialization contains an unexpected entry: ${join(paths.root, entry)}`);
|
|
506
|
+
}
|
|
507
|
+
await assertOwnedFile(join(paths.root, entry), "Cron control initialization entry");
|
|
508
|
+
}
|
|
509
|
+
if (entries.includes(CONTROL_MARKER)) {
|
|
510
|
+
const marker = await readFile(paths.marker, "utf8");
|
|
511
|
+
const recoverableMarker = marker.length === 0
|
|
512
|
+
|| INITIALIZING_MARKER_CONTENT.startsWith(marker)
|
|
513
|
+
|| MARKER_CONTENT.startsWith(marker);
|
|
514
|
+
if (!recoverableMarker) {
|
|
515
|
+
throw new CronControlStoreError("corrupt", `Cron control initialization marker is invalid: ${paths.marker}`);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
async function initializeControlStore(paths, onCheckpoint) {
|
|
520
|
+
const checkpoint = async (value) => {
|
|
521
|
+
await onCheckpoint?.(value);
|
|
522
|
+
};
|
|
523
|
+
const parent = dirname(paths.root);
|
|
524
|
+
await mkdir(parent, { recursive: true, mode: 0o700 });
|
|
525
|
+
const parentInfo = await lstat(parent);
|
|
526
|
+
assertOwnedDirectory(parentInfo, parent);
|
|
527
|
+
const canonicalParent = await realpath(parent);
|
|
528
|
+
if (canonicalParent !== parent) {
|
|
529
|
+
throw new CronControlStoreError("insecure", `Cron control parent contains a symbolic-link hop: ${parent}`);
|
|
530
|
+
}
|
|
531
|
+
await checkpoint("parent_ready");
|
|
532
|
+
if (await lstatIfPresent(paths.root) !== undefined) {
|
|
533
|
+
throw new CronControlStoreError("corrupt", `Cron control state appeared during initialization: ${paths.root}`);
|
|
534
|
+
}
|
|
535
|
+
const initializing = initializationPaths(paths);
|
|
536
|
+
let initializingRoot = await lstatIfPresent(initializing.root);
|
|
537
|
+
if (initializingRoot === undefined) {
|
|
538
|
+
await mkdir(initializing.root, { mode: 0o700 });
|
|
539
|
+
initializingRoot = await lstat(initializing.root);
|
|
540
|
+
await syncDirectory(parent);
|
|
541
|
+
}
|
|
542
|
+
await assertRecoverableInitialization(initializing, initializingRoot);
|
|
543
|
+
await checkpoint("initializing_root_ready");
|
|
544
|
+
await writeInitializationMarker(initializing.marker, initializing.root, checkpoint);
|
|
545
|
+
await syncDirectory(initializing.root);
|
|
546
|
+
await checkpoint("initializing_marker_ready");
|
|
547
|
+
await prepareInitializationControlDatabase(initializing.database, initializing.root, checkpoint);
|
|
548
|
+
await prepareInitializationLeaseDatabase(initializing.lease, initializing.root, checkpoint);
|
|
549
|
+
if (process.platform !== "win32") {
|
|
550
|
+
await Promise.all([
|
|
551
|
+
chmod(initializing.root, 0o700),
|
|
552
|
+
chmod(initializing.marker, 0o600),
|
|
553
|
+
chmod(initializing.database, 0o600),
|
|
554
|
+
chmod(initializing.lease, 0o600),
|
|
555
|
+
]);
|
|
556
|
+
}
|
|
557
|
+
await checkpoint("permissions_ready");
|
|
558
|
+
await writeExactOwnerFile(initializing.marker, MARKER_CONTENT);
|
|
559
|
+
await checkpoint("ready_marker_ready");
|
|
560
|
+
await inspectReadyControlStore(initializing, await lstat(initializing.root));
|
|
561
|
+
assertInitializationLeaseSchema(initializing.lease);
|
|
562
|
+
if (await lstatIfPresent(paths.root) !== undefined) {
|
|
563
|
+
throw new CronControlStoreError("corrupt", `Cron control state appeared during initialization: ${paths.root}`);
|
|
564
|
+
}
|
|
565
|
+
await rename(initializing.root, paths.root);
|
|
566
|
+
await syncDirectory(parent);
|
|
567
|
+
await checkpoint("published");
|
|
568
|
+
}
|
|
569
|
+
async function createOwnerFile(path) {
|
|
570
|
+
if (await lstatIfPresent(path) !== undefined)
|
|
571
|
+
return false;
|
|
572
|
+
const file = await open(path, "wx", 0o600);
|
|
573
|
+
try {
|
|
574
|
+
await file.sync();
|
|
575
|
+
}
|
|
576
|
+
finally {
|
|
577
|
+
await file.close();
|
|
578
|
+
}
|
|
579
|
+
return true;
|
|
580
|
+
}
|
|
581
|
+
async function writeInitializationMarker(path, root, checkpoint) {
|
|
582
|
+
await createOwnerFile(path);
|
|
583
|
+
await assertOwnedFile(path, "Cron control initialization marker");
|
|
584
|
+
await syncDirectory(root);
|
|
585
|
+
await checkpoint("initializing_marker_file_ready");
|
|
586
|
+
await writeExactOwnerFile(path, INITIALIZING_MARKER_CONTENT);
|
|
587
|
+
}
|
|
588
|
+
async function writeExactOwnerFile(path, content) {
|
|
589
|
+
await assertOwnedFile(path, "Cron control initialization marker");
|
|
590
|
+
const file = await open(path, "r+");
|
|
591
|
+
try {
|
|
592
|
+
await file.truncate(0);
|
|
593
|
+
await file.writeFile(content, "utf8");
|
|
594
|
+
await file.sync();
|
|
595
|
+
}
|
|
596
|
+
finally {
|
|
597
|
+
await file.close();
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
async function prepareInitializationControlDatabase(path, root, checkpoint) {
|
|
601
|
+
await createOwnerFile(path);
|
|
602
|
+
await assertOwnedFile(path, "Cron control initialization database");
|
|
603
|
+
await syncDirectory(root);
|
|
604
|
+
await checkpoint("database_file_ready");
|
|
605
|
+
const database = new DatabaseSync(path, { timeout: 5_000 });
|
|
606
|
+
try {
|
|
607
|
+
const version = database.prepare("PRAGMA user_version").get();
|
|
608
|
+
const tables = userTableNames(database);
|
|
609
|
+
if (version?.user_version === 0 && tables.length === 0)
|
|
610
|
+
createSchema(database);
|
|
611
|
+
else
|
|
612
|
+
assertInitializationControlSchema(database);
|
|
613
|
+
}
|
|
614
|
+
finally {
|
|
615
|
+
database.close();
|
|
616
|
+
}
|
|
617
|
+
await syncFile(path);
|
|
618
|
+
await checkpoint("database_schema_ready");
|
|
619
|
+
}
|
|
620
|
+
async function prepareInitializationLeaseDatabase(path, root, checkpoint) {
|
|
621
|
+
await createOwnerFile(path);
|
|
622
|
+
await assertOwnedFile(path, "Cron control initialization lease database");
|
|
623
|
+
await syncDirectory(root);
|
|
624
|
+
await checkpoint("lease_file_ready");
|
|
625
|
+
const lease = new DatabaseSync(path, { timeout: 5_000 });
|
|
626
|
+
try {
|
|
627
|
+
const tables = userTableNames(lease);
|
|
628
|
+
if (tables.length === 0) {
|
|
629
|
+
lease.exec("PRAGMA journal_mode=DELETE; CREATE TABLE lease_guard (id INTEGER PRIMARY KEY CHECK (id = 1));");
|
|
630
|
+
}
|
|
631
|
+
else {
|
|
632
|
+
assertInitializationLeaseDatabase(lease);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
finally {
|
|
636
|
+
lease.close();
|
|
637
|
+
}
|
|
638
|
+
await syncFile(path);
|
|
639
|
+
await checkpoint("lease_schema_ready");
|
|
640
|
+
}
|
|
641
|
+
function userTableNames(database) {
|
|
642
|
+
return database.prepare(`
|
|
643
|
+
SELECT name FROM sqlite_schema
|
|
644
|
+
WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name
|
|
645
|
+
`).all().map((row) => row.name);
|
|
646
|
+
}
|
|
647
|
+
function assertInitializationControlSchema(database) {
|
|
648
|
+
assertHealthyDatabase(database);
|
|
649
|
+
const expected = [
|
|
650
|
+
"action_idempotency",
|
|
651
|
+
"cron_audit",
|
|
652
|
+
"cron_jobs",
|
|
653
|
+
"cron_run_events",
|
|
654
|
+
"cron_runs",
|
|
655
|
+
"job_controls",
|
|
656
|
+
"job_sequences",
|
|
657
|
+
];
|
|
658
|
+
if (JSON.stringify(userTableNames(database)) !== JSON.stringify(expected)) {
|
|
659
|
+
throw new CronControlStoreError("corrupt", "Cron control initialization database has an ambiguous schema.");
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
function assertInitializationLeaseDatabase(database) {
|
|
663
|
+
const check = database.prepare("PRAGMA quick_check(1)").get();
|
|
664
|
+
if (check === undefined || Object.values(check)[0] !== "ok"
|
|
665
|
+
|| JSON.stringify(userTableNames(database)) !== JSON.stringify(["lease_guard"])) {
|
|
666
|
+
throw new CronControlStoreError("corrupt", "Cron control initialization lease database is corrupt.");
|
|
667
|
+
}
|
|
668
|
+
database.prepare("SELECT id FROM lease_guard LIMIT 1").get();
|
|
669
|
+
}
|
|
670
|
+
function assertInitializationLeaseSchema(path) {
|
|
671
|
+
const database = new DatabaseSync(path, { readOnly: true, timeout: INSPECTION_BUSY_TIMEOUT_MS });
|
|
672
|
+
try {
|
|
673
|
+
assertInitializationLeaseDatabase(database);
|
|
674
|
+
}
|
|
675
|
+
finally {
|
|
676
|
+
database.close();
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
async function syncFile(path) {
|
|
680
|
+
const file = await open(path, "r");
|
|
681
|
+
try {
|
|
682
|
+
await file.sync();
|
|
683
|
+
}
|
|
684
|
+
finally {
|
|
685
|
+
await file.close();
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
async function syncDirectory(path) {
|
|
689
|
+
if (process.platform === "win32")
|
|
690
|
+
return;
|
|
691
|
+
const directory = await open(path, "r");
|
|
692
|
+
try {
|
|
693
|
+
await directory.sync();
|
|
694
|
+
}
|
|
695
|
+
finally {
|
|
696
|
+
await directory.close();
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
function createSchema(database) {
|
|
700
|
+
database.exec(`
|
|
701
|
+
PRAGMA journal_mode=DELETE;
|
|
702
|
+
PRAGMA foreign_keys=ON;
|
|
703
|
+
BEGIN IMMEDIATE;
|
|
704
|
+
CREATE TABLE cron_jobs (
|
|
705
|
+
job_id TEXT PRIMARY KEY,
|
|
706
|
+
first_seen_at TEXT NOT NULL,
|
|
707
|
+
last_seen_at TEXT NOT NULL
|
|
708
|
+
);
|
|
709
|
+
CREATE TABLE job_controls (
|
|
710
|
+
job_id TEXT PRIMARY KEY,
|
|
711
|
+
runtime_enabled INTEGER CHECK (runtime_enabled IN (0, 1)),
|
|
712
|
+
updated_at TEXT NOT NULL
|
|
713
|
+
);
|
|
714
|
+
CREATE TABLE job_sequences (
|
|
715
|
+
job_id TEXT PRIMARY KEY,
|
|
716
|
+
last_sequence INTEGER NOT NULL CHECK (last_sequence > 0)
|
|
717
|
+
);
|
|
718
|
+
CREATE TABLE cron_runs (
|
|
719
|
+
run_id TEXT PRIMARY KEY,
|
|
720
|
+
job_id TEXT NOT NULL,
|
|
721
|
+
scheduled_at TEXT NOT NULL,
|
|
722
|
+
ordered_at TEXT NOT NULL,
|
|
723
|
+
sequence INTEGER NOT NULL CHECK (sequence > 0),
|
|
724
|
+
trigger TEXT NOT NULL CHECK (trigger IN ('scheduled', 'manual')),
|
|
725
|
+
status TEXT NOT NULL,
|
|
726
|
+
started_at TEXT,
|
|
727
|
+
completed_at TEXT,
|
|
728
|
+
artifact_run_id TEXT,
|
|
729
|
+
text TEXT,
|
|
730
|
+
error TEXT,
|
|
731
|
+
failure_kind TEXT,
|
|
732
|
+
reply_part_outcomes_json TEXT,
|
|
733
|
+
blocked_by_run_id TEXT,
|
|
734
|
+
blocked_by_trigger TEXT,
|
|
735
|
+
queue_depth INTEGER,
|
|
736
|
+
event_count INTEGER NOT NULL DEFAULT 0,
|
|
737
|
+
event_bytes INTEGER NOT NULL DEFAULT 0,
|
|
738
|
+
events_truncated INTEGER NOT NULL DEFAULT 0 CHECK (events_truncated IN (0, 1)),
|
|
739
|
+
created_at TEXT NOT NULL,
|
|
740
|
+
UNIQUE (job_id, sequence)
|
|
741
|
+
);
|
|
742
|
+
CREATE INDEX cron_runs_job_order ON cron_runs(job_id, ordered_at DESC, sequence DESC, run_id DESC);
|
|
743
|
+
CREATE TABLE cron_run_events (
|
|
744
|
+
run_id TEXT NOT NULL REFERENCES cron_runs(run_id) ON DELETE CASCADE,
|
|
745
|
+
position INTEGER NOT NULL,
|
|
746
|
+
event_json TEXT NOT NULL,
|
|
747
|
+
bytes INTEGER NOT NULL,
|
|
748
|
+
PRIMARY KEY (run_id, position)
|
|
749
|
+
);
|
|
750
|
+
CREATE TABLE action_idempotency (
|
|
751
|
+
idempotency_key TEXT PRIMARY KEY,
|
|
752
|
+
action TEXT NOT NULL,
|
|
753
|
+
job_id TEXT NOT NULL,
|
|
754
|
+
request_hash TEXT NOT NULL,
|
|
755
|
+
response_json TEXT NOT NULL,
|
|
756
|
+
target_run_id TEXT,
|
|
757
|
+
created_at TEXT NOT NULL
|
|
758
|
+
);
|
|
759
|
+
CREATE TABLE cron_audit (
|
|
760
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
761
|
+
created_at TEXT NOT NULL,
|
|
762
|
+
action TEXT NOT NULL,
|
|
763
|
+
job_id TEXT,
|
|
764
|
+
outcome TEXT NOT NULL,
|
|
765
|
+
idempotency_key TEXT,
|
|
766
|
+
detail_json TEXT
|
|
767
|
+
);
|
|
768
|
+
PRAGMA user_version=${String(CONTROL_SCHEMA)};
|
|
769
|
+
COMMIT;
|
|
770
|
+
`);
|
|
771
|
+
}
|
|
772
|
+
function assertSupportedDatabaseSchema(database) {
|
|
773
|
+
const version = database.prepare("PRAGMA user_version").get();
|
|
774
|
+
if (version?.user_version !== CONTROL_SCHEMA) {
|
|
775
|
+
throw new CronControlStoreError("corrupt", "Cron control database is corrupt or uses an unsupported schema.");
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
function assertHealthyDatabase(database) {
|
|
779
|
+
assertSupportedDatabaseSchema(database);
|
|
780
|
+
const check = database.prepare("PRAGMA quick_check(1)").get();
|
|
781
|
+
const result = check === undefined ? undefined : Object.values(check)[0];
|
|
782
|
+
if (result !== "ok") {
|
|
783
|
+
throw new CronControlStoreError("corrupt", "Cron control database is corrupt or uses an unsupported schema.");
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
function ensureControlSchemaColumns(database) {
|
|
787
|
+
const idempotencyColumns = database.prepare("PRAGMA table_info(action_idempotency)").all();
|
|
788
|
+
if (!idempotencyColumns.some((column) => column.name === "target_run_id")) {
|
|
789
|
+
// This feature's first schema revision existed in review worktrees before
|
|
790
|
+
// run receipts needed a direct retention link. The additive repair keeps
|
|
791
|
+
// those private stores restartable without weakening schema validation.
|
|
792
|
+
database.exec("ALTER TABLE action_idempotency ADD COLUMN target_run_id TEXT");
|
|
793
|
+
}
|
|
794
|
+
const runColumns = database.prepare("PRAGMA table_info(cron_runs)").all();
|
|
795
|
+
if (!runColumns.some((column) => column.name === "reply_part_outcomes_json")) {
|
|
796
|
+
// Schema-1 stores created before rich-part outcome persistence remain valid;
|
|
797
|
+
// the absent column is upgraded under the exclusive store lease and every
|
|
798
|
+
// legacy row receives NULL (the backwards-compatible no-outcomes state).
|
|
799
|
+
database.exec("ALTER TABLE cron_runs ADD COLUMN reply_part_outcomes_json TEXT");
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
function reconcileInterruptedRuns(database, completedAt) {
|
|
803
|
+
database.exec("BEGIN IMMEDIATE");
|
|
804
|
+
try {
|
|
805
|
+
const interrupted = database.prepare(`
|
|
806
|
+
SELECT run_id FROM cron_runs WHERE status IN ('admitted', 'running', 'queued')
|
|
807
|
+
`).all();
|
|
808
|
+
database.prepare(`
|
|
809
|
+
UPDATE cron_runs SET status = 'cancelled', completed_at = ?,
|
|
810
|
+
error = COALESCE(error, 'Agent restarted before this cron firing completed.')
|
|
811
|
+
WHERE status IN ('admitted', 'running', 'queued')
|
|
812
|
+
`).run(completedAt);
|
|
813
|
+
for (const { run_id: runId } of interrupted) {
|
|
814
|
+
const row = runRow(database, runId);
|
|
815
|
+
if (row !== undefined)
|
|
816
|
+
updateRunNowReceipt(database, row);
|
|
817
|
+
}
|
|
818
|
+
database.exec("COMMIT");
|
|
819
|
+
}
|
|
820
|
+
catch (error) {
|
|
821
|
+
if (database.isTransaction)
|
|
822
|
+
database.exec("ROLLBACK");
|
|
823
|
+
throw error;
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
const RUN_SELECT = `SELECT run_id, job_id, scheduled_at, ordered_at, sequence, trigger, status,
|
|
827
|
+
started_at, completed_at, artifact_run_id, text, error, failure_kind,
|
|
828
|
+
reply_part_outcomes_json, blocked_by_run_id, blocked_by_trigger, queue_depth,
|
|
829
|
+
event_count, events_truncated FROM cron_runs`;
|
|
830
|
+
function runRow(database, runId) {
|
|
831
|
+
return database.prepare(`${RUN_SELECT} WHERE run_id = ?`).get(runId);
|
|
832
|
+
}
|
|
833
|
+
function firingFromRow(row) {
|
|
834
|
+
return {
|
|
835
|
+
runId: row.run_id,
|
|
836
|
+
jobId: row.job_id,
|
|
837
|
+
scheduledAt: row.scheduled_at,
|
|
838
|
+
orderedAt: row.ordered_at,
|
|
839
|
+
sequence: row.sequence,
|
|
840
|
+
trigger: row.trigger,
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
function firingFromOperatorRun(run) {
|
|
844
|
+
return {
|
|
845
|
+
runId: run.runId,
|
|
846
|
+
jobId: run.jobId,
|
|
847
|
+
scheduledAt: run.scheduledAt,
|
|
848
|
+
orderedAt: run.orderedAt,
|
|
849
|
+
sequence: run.sequence,
|
|
850
|
+
trigger: run.trigger,
|
|
851
|
+
};
|
|
852
|
+
}
|
|
853
|
+
function operatorRunSummary(row, includeReplyPartOutcomes = true) {
|
|
854
|
+
return operatorRunBase(row, {
|
|
855
|
+
projection: "summary",
|
|
856
|
+
textBytes: MAX_SUMMARY_TEXT_BYTES,
|
|
857
|
+
errorBytes: MAX_SUMMARY_ERROR_BYTES,
|
|
858
|
+
artifactRunIdBytes: MAX_SUMMARY_ARTIFACT_ID_BYTES,
|
|
859
|
+
failureKindBytes: MAX_SUMMARY_FAILURE_KIND_BYTES,
|
|
860
|
+
replyPartOutcomeLimit: includeReplyPartOutcomes ? MAX_CRON_OPERATOR_SUMMARY_REPLY_PART_OUTCOMES : 0,
|
|
861
|
+
});
|
|
862
|
+
}
|
|
863
|
+
function operatorRunPage(rows, limit) {
|
|
864
|
+
const candidates = rows.slice(0, limit);
|
|
865
|
+
if (candidates.length === 0)
|
|
866
|
+
return { runs: [] };
|
|
867
|
+
const runs = [];
|
|
868
|
+
for (let index = 0; index < candidates.length; index += 1) {
|
|
869
|
+
const row = candidates[index];
|
|
870
|
+
runs.push(operatorRunSummary(row));
|
|
871
|
+
const more = index + 1 < rows.length;
|
|
872
|
+
const candidatePage = {
|
|
873
|
+
runs,
|
|
874
|
+
...(more ? { nextCursor: encodeRunCursor(row) } : {}),
|
|
875
|
+
};
|
|
876
|
+
if (Buffer.byteLength(JSON.stringify(candidatePage), "utf8") > MAX_CRON_OPERATOR_RESPONSE_BYTES) {
|
|
877
|
+
runs.pop();
|
|
878
|
+
if (runs.length === 0) {
|
|
879
|
+
throw new CronControlStoreError("corrupt", "A single cron run summary exceeded the operator response boundary.");
|
|
880
|
+
}
|
|
881
|
+
break;
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
const tail = candidates[runs.length - 1];
|
|
885
|
+
return {
|
|
886
|
+
runs,
|
|
887
|
+
...(runs.length < rows.length ? { nextCursor: encodeRunCursor(tail) } : {}),
|
|
888
|
+
};
|
|
889
|
+
}
|
|
890
|
+
function encodeRunCursor(row) {
|
|
891
|
+
return encodeCursor({ orderedAt: row.ordered_at, sequence: row.sequence, runId: row.run_id });
|
|
892
|
+
}
|
|
893
|
+
function operatorRunDetail(database, row) {
|
|
894
|
+
const eventRows = database.prepare("SELECT event_json, bytes FROM cron_run_events WHERE run_id = ? ORDER BY position").all(row.run_id);
|
|
895
|
+
const events = [];
|
|
896
|
+
let eventBytes = 2;
|
|
897
|
+
let wireTruncated = false;
|
|
898
|
+
for (const event of eventRows) {
|
|
899
|
+
const nextBytes = eventBytes + event.bytes + (events.length === 0 ? 0 : 1);
|
|
900
|
+
if (nextBytes > MAX_DETAIL_EVENT_BYTES) {
|
|
901
|
+
wireTruncated = true;
|
|
902
|
+
break;
|
|
903
|
+
}
|
|
904
|
+
try {
|
|
905
|
+
events.push(JSON.parse(event.event_json));
|
|
906
|
+
eventBytes = nextBytes;
|
|
907
|
+
}
|
|
908
|
+
catch {
|
|
909
|
+
wireTruncated = true;
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
const base = operatorRunBase(row, {
|
|
913
|
+
projection: "detail",
|
|
914
|
+
textBytes: MAX_DETAIL_TEXT_BYTES,
|
|
915
|
+
errorBytes: MAX_DETAIL_ERROR_BYTES,
|
|
916
|
+
artifactRunIdBytes: MAX_DETAIL_ARTIFACT_ID_BYTES,
|
|
917
|
+
failureKindBytes: MAX_DETAIL_FAILURE_KIND_BYTES,
|
|
918
|
+
replyPartOutcomeLimit: MAX_AGENT_REPLY_PARTS,
|
|
919
|
+
});
|
|
920
|
+
return {
|
|
921
|
+
...base,
|
|
922
|
+
projection: "detail",
|
|
923
|
+
events,
|
|
924
|
+
eventsIncluded: events.length,
|
|
925
|
+
...(base.eventsTruncated === true || wireTruncated ? { eventsTruncated: true } : {}),
|
|
926
|
+
};
|
|
927
|
+
}
|
|
928
|
+
function operatorRunBase(row, limits) {
|
|
929
|
+
const truncated = [];
|
|
930
|
+
const text = boundedRunField(row.text, limits.textBytes, "text", truncated);
|
|
931
|
+
const error = boundedRunField(row.error, limits.errorBytes, "error", truncated);
|
|
932
|
+
const artifactRunId = boundedRunField(row.artifact_run_id, limits.artifactRunIdBytes, "artifactRunId", truncated);
|
|
933
|
+
const failureKind = boundedRunField(row.failure_kind, limits.failureKindBytes, "failureKind", truncated);
|
|
934
|
+
const storedReplyPartOutcomes = parseStoredReplyPartOutcomes(row.reply_part_outcomes_json);
|
|
935
|
+
const replyPartOutcomes = storedReplyPartOutcomes === undefined || limits.replyPartOutcomeLimit === 0
|
|
936
|
+
? undefined
|
|
937
|
+
: storedReplyPartOutcomes.slice(0, limits.replyPartOutcomeLimit);
|
|
938
|
+
return {
|
|
939
|
+
projection: limits.projection,
|
|
940
|
+
runId: row.run_id,
|
|
941
|
+
jobId: row.job_id,
|
|
942
|
+
scheduledAt: row.scheduled_at,
|
|
943
|
+
orderedAt: row.ordered_at,
|
|
944
|
+
sequence: row.sequence,
|
|
945
|
+
trigger: row.trigger,
|
|
946
|
+
status: row.status,
|
|
947
|
+
...(row.started_at === null ? {} : { startedAt: row.started_at }),
|
|
948
|
+
...(row.completed_at === null ? {} : { completedAt: row.completed_at }),
|
|
949
|
+
...(artifactRunId === undefined ? {} : { artifactRunId }),
|
|
950
|
+
...(text === undefined ? {} : { text }),
|
|
951
|
+
...(error === undefined ? {} : { error }),
|
|
952
|
+
...(failureKind === undefined ? {} : { failureKind }),
|
|
953
|
+
...(replyPartOutcomes === undefined ? {} : { replyPartOutcomes }),
|
|
954
|
+
...(row.blocked_by_run_id === null ? {} : { blockedByRunId: row.blocked_by_run_id }),
|
|
955
|
+
...(row.blocked_by_trigger === null ? {} : { blockedByTrigger: row.blocked_by_trigger }),
|
|
956
|
+
...(row.queue_depth === null ? {} : { queueDepth: row.queue_depth }),
|
|
957
|
+
eventCount: row.event_count,
|
|
958
|
+
...(truncated.length === 0 ? {} : { fieldsTruncated: truncated }),
|
|
959
|
+
...(row.events_truncated === 1 ? { eventsTruncated: true } : {}),
|
|
960
|
+
};
|
|
961
|
+
}
|
|
962
|
+
function boundedRunField(value, maxBytes, field, truncated) {
|
|
963
|
+
if (value === null)
|
|
964
|
+
return undefined;
|
|
965
|
+
if (Buffer.byteLength(value, "utf8") <= maxBytes)
|
|
966
|
+
return value;
|
|
967
|
+
truncated.push(field);
|
|
968
|
+
return truncateUtf8(value, maxBytes);
|
|
969
|
+
}
|
|
970
|
+
function truncateUtf8(value, maxBytes) {
|
|
971
|
+
let low = 0;
|
|
972
|
+
let high = value.length;
|
|
973
|
+
while (low < high) {
|
|
974
|
+
const middle = Math.ceil((low + high) / 2);
|
|
975
|
+
if (Buffer.byteLength(value.slice(0, middle), "utf8") <= maxBytes)
|
|
976
|
+
low = middle;
|
|
977
|
+
else
|
|
978
|
+
high = middle - 1;
|
|
979
|
+
}
|
|
980
|
+
let end = low;
|
|
981
|
+
if (end > 0 && /[\uD800-\uDBFF]/u.test(value[end - 1]))
|
|
982
|
+
end -= 1;
|
|
983
|
+
return value.slice(0, end);
|
|
984
|
+
}
|
|
985
|
+
function resultFields(result) {
|
|
986
|
+
if (result.kind === "succeeded") {
|
|
987
|
+
const runId = typeof result.metadata?.runId === "string" ? result.metadata.runId : undefined;
|
|
988
|
+
return {
|
|
989
|
+
status: "succeeded",
|
|
990
|
+
startedAt: result.startedAt,
|
|
991
|
+
completedAt: result.completedAt,
|
|
992
|
+
...(runId === undefined ? {} : { artifactRunId: runId }),
|
|
993
|
+
...(result.text === undefined ? {} : { text: result.text }),
|
|
994
|
+
replyPartOutcomesJson: serializeStoredReplyPartOutcomes(result.replyPartOutcomes),
|
|
995
|
+
};
|
|
996
|
+
}
|
|
997
|
+
if (result.kind === "failed" || result.kind === "cancelled") {
|
|
998
|
+
return {
|
|
999
|
+
status: result.kind,
|
|
1000
|
+
startedAt: result.startedAt,
|
|
1001
|
+
completedAt: result.completedAt,
|
|
1002
|
+
...(result.runId === undefined ? {} : { artifactRunId: result.runId }),
|
|
1003
|
+
error: result.error,
|
|
1004
|
+
...(result.failureKind === undefined ? {} : { failureKind: result.failureKind }),
|
|
1005
|
+
replyPartOutcomesJson: serializeStoredReplyPartOutcomes(result.replyPartOutcomes),
|
|
1006
|
+
};
|
|
1007
|
+
}
|
|
1008
|
+
if (result.kind === "skipped") {
|
|
1009
|
+
return {
|
|
1010
|
+
status: "skipped_overlap",
|
|
1011
|
+
completedAt: result.orderedAt,
|
|
1012
|
+
blockedByRunId: result.blockedByRunId,
|
|
1013
|
+
blockedByTrigger: result.blockedByTrigger,
|
|
1014
|
+
replyPartOutcomesJson: null,
|
|
1015
|
+
};
|
|
1016
|
+
}
|
|
1017
|
+
if (result.kind === "queued")
|
|
1018
|
+
return { status: "queued", queueDepth: result.queueDepth, replyPartOutcomesJson: null };
|
|
1019
|
+
return { status: "dropped", completedAt: result.orderedAt, replyPartOutcomesJson: null };
|
|
1020
|
+
}
|
|
1021
|
+
function serializeStoredReplyPartOutcomes(value) {
|
|
1022
|
+
const replyPartOutcomes = sanitizeReplyPartDeliveryOutcomes(value);
|
|
1023
|
+
if (replyPartOutcomes === undefined)
|
|
1024
|
+
return null;
|
|
1025
|
+
const serialized = JSON.stringify({
|
|
1026
|
+
schemaVersion: REPLY_PART_OUTCOMES_STORAGE_SCHEMA,
|
|
1027
|
+
replyPartOutcomes,
|
|
1028
|
+
});
|
|
1029
|
+
if (Buffer.byteLength(serialized, "utf8") > MAX_REPLY_PART_OUTCOMES_STORAGE_BYTES) {
|
|
1030
|
+
throw new CronControlStoreError("corrupt", "Cron reply-part outcomes exceeded the storage boundary.");
|
|
1031
|
+
}
|
|
1032
|
+
return serialized;
|
|
1033
|
+
}
|
|
1034
|
+
function parseStoredReplyPartOutcomes(serialized) {
|
|
1035
|
+
if (serialized === null)
|
|
1036
|
+
return undefined;
|
|
1037
|
+
if (typeof serialized !== "string"
|
|
1038
|
+
|| Buffer.byteLength(serialized, "utf8") > MAX_REPLY_PART_OUTCOMES_STORAGE_BYTES) {
|
|
1039
|
+
throw new CronControlStoreError("corrupt", "Stored cron reply-part outcomes are invalid.");
|
|
1040
|
+
}
|
|
1041
|
+
let value;
|
|
1042
|
+
try {
|
|
1043
|
+
value = JSON.parse(serialized);
|
|
1044
|
+
}
|
|
1045
|
+
catch (error) {
|
|
1046
|
+
throw new CronControlStoreError("corrupt", "Stored cron reply-part outcomes are invalid.", { cause: error });
|
|
1047
|
+
}
|
|
1048
|
+
const envelope = record(value);
|
|
1049
|
+
const schemaVersion = envelope?.schemaVersion;
|
|
1050
|
+
if (Number.isSafeInteger(schemaVersion)
|
|
1051
|
+
&& Number(schemaVersion) > REPLY_PART_OUTCOMES_STORAGE_SCHEMA) {
|
|
1052
|
+
return undefined;
|
|
1053
|
+
}
|
|
1054
|
+
if (envelope === undefined
|
|
1055
|
+
|| Object.keys(envelope).some((key) => key !== "schemaVersion" && key !== "replyPartOutcomes")
|
|
1056
|
+
|| schemaVersion !== REPLY_PART_OUTCOMES_STORAGE_SCHEMA
|
|
1057
|
+
|| !isAgentReplyPartDeliveryOutcomes(envelope.replyPartOutcomes)) {
|
|
1058
|
+
throw new CronControlStoreError("corrupt", "Stored cron reply-part outcomes are invalid.");
|
|
1059
|
+
}
|
|
1060
|
+
const replyPartOutcomes = sanitizeReplyPartDeliveryOutcomes(envelope.replyPartOutcomes);
|
|
1061
|
+
if (replyPartOutcomes === undefined) {
|
|
1062
|
+
throw new CronControlStoreError("corrupt", "Stored cron reply-part outcomes are invalid.");
|
|
1063
|
+
}
|
|
1064
|
+
return replyPartOutcomes;
|
|
1065
|
+
}
|
|
1066
|
+
function idempotencyRecord(database, key, action, jobId, requestHash) {
|
|
1067
|
+
const row = database.prepare(`
|
|
1068
|
+
SELECT action, job_id, request_hash, response_json FROM action_idempotency WHERE idempotency_key = ?
|
|
1069
|
+
`).get(key);
|
|
1070
|
+
if (row === undefined)
|
|
1071
|
+
return undefined;
|
|
1072
|
+
if (row.action !== action || row.job_id !== jobId || row.request_hash !== requestHash) {
|
|
1073
|
+
throw new CronControlStoreError("idempotency_conflict", "The cron idempotency key was already used for a different action.");
|
|
1074
|
+
}
|
|
1075
|
+
return row.response_json;
|
|
1076
|
+
}
|
|
1077
|
+
function parseRunNowReceipt(serialized) {
|
|
1078
|
+
let value;
|
|
1079
|
+
try {
|
|
1080
|
+
value = JSON.parse(serialized);
|
|
1081
|
+
}
|
|
1082
|
+
catch (error) {
|
|
1083
|
+
throw new CronControlStoreError("corrupt", "Stored cron run-now idempotency result is invalid.", { cause: error });
|
|
1084
|
+
}
|
|
1085
|
+
const receipt = record(value);
|
|
1086
|
+
const run = record(receipt?.run);
|
|
1087
|
+
if (run === undefined) {
|
|
1088
|
+
// Pre-detail development receipts did not retain a terminal summary. A
|
|
1089
|
+
// missing target is an expired replay, not database corruption, and must
|
|
1090
|
+
// never be admitted as a second job execution.
|
|
1091
|
+
const legacyRunId = typeof receipt?.runId === "string" ? receipt.runId : undefined;
|
|
1092
|
+
if (legacyRunId !== undefined)
|
|
1093
|
+
return legacyRunId;
|
|
1094
|
+
throw new CronControlStoreError("corrupt", "Stored cron run-now idempotency result is invalid.");
|
|
1095
|
+
}
|
|
1096
|
+
const sourceReplyPartOutcomes = run.replyPartOutcomes;
|
|
1097
|
+
const allowed = new Set([
|
|
1098
|
+
"projection", "runId", "jobId", "scheduledAt", "orderedAt", "sequence", "trigger", "status",
|
|
1099
|
+
"startedAt", "completedAt", "artifactRunId", "text", "error", "failureKind", "blockedByRunId",
|
|
1100
|
+
"blockedByTrigger", "queueDepth", "replyPartOutcomes", "eventCount", "fieldsTruncated", "eventsTruncated",
|
|
1101
|
+
]);
|
|
1102
|
+
if (Object.keys(run).some((key) => !allowed.has(key))
|
|
1103
|
+
|| run.projection !== "summary"
|
|
1104
|
+
|| typeof run.runId !== "string"
|
|
1105
|
+
|| typeof run.jobId !== "string"
|
|
1106
|
+
|| typeof run.scheduledAt !== "string"
|
|
1107
|
+
|| typeof run.orderedAt !== "string"
|
|
1108
|
+
|| !Number.isSafeInteger(run.sequence)
|
|
1109
|
+
|| (run.trigger !== "scheduled" && run.trigger !== "manual")
|
|
1110
|
+
|| !["admitted", "running", "queued", "succeeded", "failed", "cancelled", "skipped_overlap", "dropped"]
|
|
1111
|
+
.includes(String(run.status))
|
|
1112
|
+
|| !Number.isSafeInteger(run.eventCount)
|
|
1113
|
+
|| Number(run.eventCount) < 0
|
|
1114
|
+
|| (sourceReplyPartOutcomes !== undefined && !isAgentReplyPartDeliveryOutcomes(sourceReplyPartOutcomes))
|
|
1115
|
+
|| (run.eventsTruncated !== undefined && run.eventsTruncated !== true)) {
|
|
1116
|
+
throw new CronControlStoreError("corrupt", "Stored cron run-now idempotency result is invalid.");
|
|
1117
|
+
}
|
|
1118
|
+
for (const field of [
|
|
1119
|
+
"startedAt", "completedAt", "artifactRunId", "text", "error", "failureKind", "blockedByRunId",
|
|
1120
|
+
]) {
|
|
1121
|
+
if (run[field] !== undefined && typeof run[field] !== "string") {
|
|
1122
|
+
throw new CronControlStoreError("corrupt", "Stored cron run-now idempotency result is invalid.");
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
if (run.blockedByTrigger !== undefined
|
|
1126
|
+
&& run.blockedByTrigger !== "scheduled"
|
|
1127
|
+
&& run.blockedByTrigger !== "manual") {
|
|
1128
|
+
throw new CronControlStoreError("corrupt", "Stored cron run-now idempotency result is invalid.");
|
|
1129
|
+
}
|
|
1130
|
+
if (run.queueDepth !== undefined && !Number.isSafeInteger(run.queueDepth)) {
|
|
1131
|
+
throw new CronControlStoreError("corrupt", "Stored cron run-now idempotency result is invalid.");
|
|
1132
|
+
}
|
|
1133
|
+
const truncatedFields = ["artifactRunId", "error", "failureKind", "text"];
|
|
1134
|
+
if (run.fieldsTruncated !== undefined
|
|
1135
|
+
&& (!Array.isArray(run.fieldsTruncated)
|
|
1136
|
+
|| run.fieldsTruncated.some((field) => !truncatedFields.includes(String(field))))) {
|
|
1137
|
+
throw new CronControlStoreError("corrupt", "Stored cron run-now idempotency result is invalid.");
|
|
1138
|
+
}
|
|
1139
|
+
const replyPartOutcomes = sourceReplyPartOutcomes === undefined
|
|
1140
|
+
? undefined
|
|
1141
|
+
: sourceReplyPartOutcomes
|
|
1142
|
+
.slice(0, MAX_CRON_OPERATOR_SUMMARY_REPLY_PART_OUTCOMES)
|
|
1143
|
+
.map((outcome) => ({ ...outcome }));
|
|
1144
|
+
return {
|
|
1145
|
+
...run,
|
|
1146
|
+
...(replyPartOutcomes === undefined ? {} : { replyPartOutcomes }),
|
|
1147
|
+
};
|
|
1148
|
+
}
|
|
1149
|
+
function updateRunNowReceipt(database, row) {
|
|
1150
|
+
database.prepare(`
|
|
1151
|
+
UPDATE action_idempotency SET response_json = ?
|
|
1152
|
+
WHERE action = 'run_now' AND target_run_id = ?
|
|
1153
|
+
`).run(JSON.stringify({ run: operatorRunSummary(row, false) }), row.run_id);
|
|
1154
|
+
}
|
|
1155
|
+
function insertIdempotency(database, input) {
|
|
1156
|
+
database.prepare(`
|
|
1157
|
+
INSERT INTO action_idempotency (
|
|
1158
|
+
idempotency_key, action, job_id, request_hash, response_json, target_run_id, created_at
|
|
1159
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
1160
|
+
`).run(input.key, input.action, input.jobId, input.requestHash, input.response, input.targetRunId ?? null, input.createdAt);
|
|
1161
|
+
}
|
|
1162
|
+
function insertAudit(database, input) {
|
|
1163
|
+
database.prepare(`
|
|
1164
|
+
INSERT INTO cron_audit (created_at, action, job_id, outcome, idempotency_key, detail_json)
|
|
1165
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
1166
|
+
`).run(input.createdAt, input.action, input.jobId ?? null, input.outcome, input.idempotencyKey ?? null, input.detail === undefined ? null : JSON.stringify(input.detail));
|
|
1167
|
+
}
|
|
1168
|
+
function applyRetention(database, date) {
|
|
1169
|
+
const runCutoff = new Date(date.getTime() - RUN_RETENTION_MS).toISOString();
|
|
1170
|
+
database.prepare(`
|
|
1171
|
+
DELETE FROM cron_runs WHERE run_id IN (
|
|
1172
|
+
SELECT run_id FROM (
|
|
1173
|
+
SELECT run_id, ordered_at,
|
|
1174
|
+
ROW_NUMBER() OVER (PARTITION BY job_id ORDER BY ordered_at DESC, sequence DESC, run_id DESC) AS rank
|
|
1175
|
+
FROM cron_runs WHERE status NOT IN ('admitted', 'running', 'queued')
|
|
1176
|
+
) WHERE rank > ? OR ordered_at < ?
|
|
1177
|
+
)
|
|
1178
|
+
`).run(MAX_RUNS_PER_JOB, runCutoff);
|
|
1179
|
+
const idempotencyCutoff = new Date(date.getTime() - IDEMPOTENCY_RETENTION_MS).toISOString();
|
|
1180
|
+
database.prepare(`
|
|
1181
|
+
DELETE FROM action_idempotency WHERE created_at < ? OR idempotency_key IN (
|
|
1182
|
+
SELECT idempotency_key FROM action_idempotency
|
|
1183
|
+
ORDER BY created_at DESC, idempotency_key DESC LIMIT -1 OFFSET ?
|
|
1184
|
+
)
|
|
1185
|
+
`).run(idempotencyCutoff, MAX_IDEMPOTENCY_RECORDS);
|
|
1186
|
+
const auditCutoff = new Date(date.getTime() - AUDIT_RETENTION_MS).toISOString();
|
|
1187
|
+
database.prepare(`
|
|
1188
|
+
DELETE FROM cron_audit WHERE created_at < ? OR id IN (
|
|
1189
|
+
SELECT id FROM cron_audit ORDER BY created_at DESC, id DESC LIMIT -1 OFFSET ?
|
|
1190
|
+
)
|
|
1191
|
+
`).run(auditCutoff, MAX_AUDIT_RECORDS);
|
|
1192
|
+
}
|
|
1193
|
+
function encodeCursor(value) {
|
|
1194
|
+
return Buffer.from(JSON.stringify([value.orderedAt, value.sequence, value.runId]), "utf8").toString("base64url");
|
|
1195
|
+
}
|
|
1196
|
+
function decodeCursor(value) {
|
|
1197
|
+
try {
|
|
1198
|
+
const parsed = JSON.parse(Buffer.from(value, "base64url").toString("utf8"));
|
|
1199
|
+
if (!Array.isArray(parsed)
|
|
1200
|
+
|| parsed.length !== 3
|
|
1201
|
+
|| typeof parsed[0] !== "string"
|
|
1202
|
+
|| Number.isNaN(Date.parse(parsed[0]))
|
|
1203
|
+
|| !Number.isSafeInteger(parsed[1])
|
|
1204
|
+
|| Number(parsed[1]) <= 0
|
|
1205
|
+
|| typeof parsed[2] !== "string"
|
|
1206
|
+
|| parsed[2].length === 0)
|
|
1207
|
+
throw new Error("invalid");
|
|
1208
|
+
return { orderedAt: parsed[0], sequence: Number(parsed[1]), runId: parsed[2] };
|
|
1209
|
+
}
|
|
1210
|
+
catch (error) {
|
|
1211
|
+
throw new CronControlStoreError("corrupt", "Cron run cursor is invalid.", { cause: error });
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
function record(value) {
|
|
1215
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
1216
|
+
? value
|
|
1217
|
+
: undefined;
|
|
1218
|
+
}
|
|
1219
|
+
async function assertOwnedFile(path, label) {
|
|
1220
|
+
const info = await lstat(path).catch((error) => {
|
|
1221
|
+
if (missing(error)) {
|
|
1222
|
+
throw new CronControlStoreError("corrupt", `${label} is missing: ${path}`, { cause: error });
|
|
1223
|
+
}
|
|
1224
|
+
throw error;
|
|
1225
|
+
});
|
|
1226
|
+
if (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1) {
|
|
1227
|
+
throw new CronControlStoreError("insecure", `${label} is not a single-link regular file: ${path}`);
|
|
1228
|
+
}
|
|
1229
|
+
assertOwner(info, path);
|
|
1230
|
+
if (process.platform !== "win32" && (info.mode & 0o077) !== 0) {
|
|
1231
|
+
throw new CronControlStoreError("insecure", `${label} permissions are not owner-only: ${path}`);
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
function assertOwnedDirectory(info, path) {
|
|
1235
|
+
if (!info.isDirectory() || info.isSymbolicLink()) {
|
|
1236
|
+
throw new CronControlStoreError("insecure", `Cron control state is not a real directory: ${path}`);
|
|
1237
|
+
}
|
|
1238
|
+
assertOwner(info, path);
|
|
1239
|
+
if (process.platform !== "win32" && (info.mode & 0o077) !== 0) {
|
|
1240
|
+
throw new CronControlStoreError("insecure", `Cron control state permissions are not owner-only: ${path}`);
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
function assertOwner(info, path) {
|
|
1244
|
+
if (typeof process.getuid === "function" && info.uid !== process.getuid()) {
|
|
1245
|
+
throw new CronControlStoreError("insecure", `Cron control state is not owned by the current user: ${path}`);
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
function missing(error) {
|
|
1249
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
|
|
1250
|
+
}
|
|
1251
|
+
//# sourceMappingURL=cron-control-store.js.map
|