@sleep2agi/commhub-server 0.9.0-preview.13 → 0.9.0-preview.130
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 +130 -10
- package/bin/commhub.ts +99 -11
- package/package.json +7 -7
- package/src/ack-create-request-transport.test.ts +182 -0
- package/src/ack-create-request.test.ts +50 -7
- package/src/admin-delete-network-http.test.ts +228 -0
- package/src/admin-networks-http.test.ts +88 -0
- package/src/adoption-candidates-http.test.ts +137 -0
- package/src/adoption-candidates.ts +158 -0
- package/src/agent-access-batch.test.ts +210 -0
- package/src/agent-access.ts +487 -0
- package/src/agent-acl-groups-http.test.ts +247 -0
- package/src/agent-acl-http.test.ts +659 -0
- package/src/agent-acl-slice3-http.test.ts +267 -0
- package/src/agent-groups-perf.test.ts +223 -0
- package/src/agent-notice.ts +48 -0
- package/src/agent-team-whoami-http.test.ts +98 -0
- package/src/agent-teams-http.test.ts +323 -0
- package/src/agent-teams.ts +244 -0
- package/src/alias-filter.test.ts +72 -0
- package/src/alias-filter.ts +42 -0
- package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
- package/src/api-host-supervisors-fallback.test.ts +271 -0
- package/src/api-host-supervisors-runtime-readiness.test.ts +195 -0
- package/src/api-nodes-shape.test.ts +26 -10
- package/src/auth-password-change-cli-tokens-http.test.ts +294 -0
- package/src/auth-sessions-http.test.ts +250 -0
- package/src/auth-validate.test.ts +5 -1
- package/src/auth-weak-password-login.test.ts +176 -0
- package/src/auth.ts +598 -49
- package/src/avatar-validate.test.ts +163 -0
- package/src/avatar-validate.ts +103 -0
- package/src/binding-generation-http.test.ts +121 -0
- package/src/blocked-exit-on-reply.test.ts +122 -0
- package/src/boot-side-effect.test.ts +124 -0
- package/src/clone-node.test.ts +248 -0
- package/src/clone-node.ts +289 -0
- package/src/codex-config-flags.ts +77 -0
- package/src/codex-fork-contract.ts +23 -0
- package/src/codex-fork-migration.test.ts +38 -0
- package/src/commhub-bin-args.test.ts +85 -0
- package/src/config-apply-sec1.test.ts +107 -0
- package/src/config-apply-validate.test.ts +134 -1
- package/src/config-apply-validate.ts +111 -5
- package/src/create-node-blocked-gate.test.ts +356 -0
- package/src/create-node-network-id.test.ts +79 -0
- package/src/create-node-tool-schema.test.ts +48 -0
- package/src/create-node-unicode-name.test.ts +120 -0
- package/src/create-node-validate.test.ts +193 -7
- package/src/create-node-validate.ts +227 -15
- package/src/create-node-workdir.test.ts +289 -0
- package/src/create-node.ts +77 -23
- package/src/daemon-provider-snapshots.test.ts +421 -0
- package/src/daemon-provider-snapshots.ts +327 -0
- package/src/daemon-token-owner-http.test.ts +236 -0
- package/src/dashboard-slash-routing-http.test.ts +107 -0
- package/src/db-adapter-guard.test.ts +148 -0
- package/src/db-adapter-translate.test.ts +128 -0
- package/src/db-adapter.ts +323 -87
- package/src/db-timestamp.test.ts +38 -0
- package/src/db-timestamp.ts +15 -0
- package/src/db.ts +930 -35
- package/src/delivered-without-push.test.ts +140 -0
- package/src/department-groups-http.test.ts +209 -0
- package/src/department-groups-sync-http.test.ts +328 -0
- package/src/department-groups.ts +434 -0
- package/src/department-heads-http.test.ts +330 -0
- package/src/department-heads.ts +226 -0
- package/src/departments-http.test.ts +185 -0
- package/src/departments.ts +260 -0
- package/src/desktop-message-seam.test.ts +150 -0
- package/src/desktop-message.test.ts +368 -0
- package/src/dm-file-scope-http.test.ts +178 -0
- package/src/ext-token-shared.test.ts +60 -0
- package/src/ext-validation-regression.test.ts +131 -0
- package/src/external-schedule-edits-http.test.ts +321 -0
- package/src/external-schedule-edits-timestamps.test.ts +64 -0
- package/src/external-schedule-edits.ts +327 -0
- package/src/file-download-authz-dev-open.test.ts +135 -0
- package/src/file-download-authz.test.ts +404 -0
- package/src/file-download-date-bucket.test.ts +436 -0
- package/src/file-network-scope-dev-open.test.ts +97 -0
- package/src/file-network-scope.test.ts +732 -0
- package/src/group-membership-events-http.test.ts +264 -0
- package/src/group-messages-http.test.ts +482 -0
- package/src/group-messages.ts +297 -0
- package/src/health-redaction.test.ts +642 -0
- package/src/http-gzip.test.ts +94 -0
- package/src/http-gzip.ts +112 -0
- package/src/hub-provider-config.test.ts +139 -0
- package/src/hub-provider-config.ts +105 -0
- package/src/hub-timestamp.test.ts +38 -0
- package/src/hub-timestamp.ts +27 -0
- package/src/human-dm.ts +215 -0
- package/src/inbox-caller.ts +49 -0
- package/src/inbox-count-sse.test.ts +310 -0
- package/src/inbox-count.ts +29 -0
- package/src/index.ts +15 -2362
- package/src/lifecycle-guard.ts +1 -1
- package/src/mcp-context-cost-http.test.ts +123 -0
- package/src/mcp-projects-events-http.test.ts +162 -0
- package/src/mcp-route-label-http.test.ts +120 -0
- package/src/mcp-write-network-resolution.test.ts +432 -0
- package/src/member-audit-network-id-http.test.ts +93 -0
- package/src/member-presence-http.test.ts +210 -0
- package/src/messages-ack-by-agent-http.test.ts +142 -0
- package/src/migrate-sqlite-to-pg.test.ts +47 -0
- package/src/migrate-sqlite-to-pg.ts +263 -0
- package/src/model-auth-notify-http.test.ts +181 -0
- package/src/model-auth-notify.ts +116 -0
- package/src/network-name-http.test.ts +75 -0
- package/src/network-scope.ts +276 -0
- package/src/network-token-membership-http.test.ts +139 -0
- package/src/node-alias-control.ts +12 -0
- package/src/node-attrs-validate.ts +88 -0
- package/src/node-attrs.test.ts +217 -0
- package/src/node-caller-identity-http.test.ts +781 -0
- package/src/node-create-request-status-http.test.ts +105 -0
- package/src/node-daemon-bindings.test.ts +132 -0
- package/src/node-daemon-bindings.ts +130 -0
- package/src/node-files-transport.test.ts +344 -0
- package/src/node-health-codex-login.test.ts +120 -0
- package/src/node-health-dispatch-http.test.ts +279 -0
- package/src/node-health-guard.ts +105 -0
- package/src/node-health-passthrough.test.ts +115 -0
- package/src/node-health-store.ts +101 -0
- package/src/node-id-alias.test.ts +190 -0
- package/src/node-identity-conflict-http.test.ts +269 -0
- package/src/node-identity-conflict.ts +142 -0
- package/src/node-identity-mismatch.test.ts +74 -0
- package/src/node-lifecycle-controllable.test.ts +64 -0
- package/src/node-lifecycle-controllable.ts +53 -0
- package/src/node-lifecycle-read-http.test.ts +245 -0
- package/src/node-lifecycle-read.ts +121 -0
- package/src/node-logs-transport.test.ts +263 -0
- package/src/node-permissions-http.test.ts +276 -0
- package/src/node-permissions.ts +285 -0
- package/src/node-request-retention.test.ts +281 -0
- package/src/node-request-retention.ts +120 -0
- package/src/node-skills-transport.test.ts +209 -0
- package/src/node-token-ownership.ts +51 -0
- package/src/node-token-privilege-http.test.ts +158 -0
- package/src/observer-avatar-http.test.ts +431 -0
- package/src/observer-push.test.ts +252 -0
- package/src/opencode-v2-catalog.test.ts +268 -0
- package/src/opencode-v2-catalog.ts +352 -0
- package/src/package-version-consistency.test.ts +25 -0
- package/src/peer-reply-atomic.test.ts +455 -0
- package/src/peer-reply-capability.test.ts +70 -0
- package/src/pg-worker.ts +87 -0
- package/src/project-dir-warn-clear-http.test.ts +132 -0
- package/src/project-viewer-can-http.test.ts +114 -0
- package/src/push.ts +429 -10
- package/src/redact-tokens.test.ts +65 -0
- package/src/redact-tokens.ts +40 -0
- package/src/rename.ts +22 -2
- package/src/reply-target-derivation.test.ts +96 -0
- package/src/report-status-handover-capabilities-http.test.ts +124 -0
- package/src/report-status-host-schema.test.ts +235 -0
- package/src/report-status-resume-id-handover.test.ts +150 -0
- package/src/require-explicit-test-db.ts +9 -0
- package/src/requirement-due-reminders-http.test.ts +620 -0
- package/src/requirement-due-reminders.ts +416 -0
- package/src/requirement-events-http.test.ts +268 -0
- package/src/requirement-events.test.ts +45 -0
- package/src/requirement-events.ts +144 -0
- package/src/requirement-notify.ts +165 -0
- package/src/requirement-participant-notify-http.test.ts +309 -0
- package/src/requirement-tag-ops-http.test.ts +266 -0
- package/src/requirement-tags.ts +89 -0
- package/src/requirements-abandoned-http.test.ts +236 -0
- package/src/requirements-comment-http.test.ts +167 -0
- package/src/requirements-completed-at-migration.test.ts +70 -0
- package/src/requirements-errors-mcp-http.test.ts +164 -0
- package/src/requirements-errors.ts +143 -0
- package/src/requirements-etag-http.test.ts +91 -0
- package/src/requirements-http.test.ts +709 -0
- package/src/requirements-last-event-http.test.ts +349 -0
- package/src/requirements-list-mcp-slim-http.test.ts +155 -0
- package/src/requirements-list-slim-http.test.ts +319 -0
- package/src/requirements-mcp.test.ts +163 -0
- package/src/requirements-migrate.ts +259 -0
- package/src/requirements-owner-strict-mcp-http.test.ts +145 -0
- package/src/requirements-people-http.test.ts +114 -0
- package/src/requirements-people-mcp-http.test.ts +225 -0
- package/src/requirements-people.ts +127 -0
- package/src/requirements-priority-lowest.test.ts +114 -0
- package/src/requirements-search-http.test.ts +182 -0
- package/src/requirements-search.test.ts +32 -0
- package/src/requirements-search.ts +145 -0
- package/src/requirements-seq-http.test.ts +193 -0
- package/src/requirements-seq-migration.test.ts +63 -0
- package/src/requirements-start-date.test.ts +125 -0
- package/src/requirements-stats-http.test.ts +288 -0
- package/src/requirements-stats.ts +167 -0
- package/src/requirements-status-participants-http.test.ts +222 -0
- package/src/requirements-upgrade-e2e.test.ts +126 -0
- package/src/requirements-upgrade-migration.test.ts +98 -0
- package/src/requirements.ts +1806 -0
- package/src/resolve-port.test.ts +57 -0
- package/src/resolve-port.ts +44 -0
- package/src/response-charset.test.ts +130 -0
- package/src/rest-explicit-columns-http.test.ts +222 -0
- package/src/rest-identity.test.ts +129 -0
- package/src/rest-identity.ts +125 -0
- package/src/rest-projections.ts +68 -0
- package/src/rest-write-network-resolution.test.ts +133 -0
- package/src/restricted-files.ts +76 -0
- package/src/retention.ts +15 -3
- package/src/route-callers-http.test.ts +120 -0
- package/src/route-timing.test.ts +211 -0
- package/src/route-timing.ts +258 -0
- package/src/rules-file-duplicate-alias.test.ts +234 -0
- package/src/rules-file-session-target.test.ts +207 -0
- package/src/rules-file-transport.test.ts +237 -0
- package/src/runtime-label.test.ts +22 -0
- package/src/runtime-label.ts +21 -0
- package/src/runtime-readiness.ts +90 -0
- package/src/schedule-agent-mcp-http.test.ts +381 -0
- package/src/schedule-agent.ts +192 -0
- package/src/scheduled-doorbell-nodeless-session.test.ts +134 -0
- package/src/scheduled-failures-http.test.ts +233 -0
- package/src/scheduled-failures.ts +179 -0
- package/src/scheduled-offline.test.ts +131 -0
- package/src/scheduled-offline.ts +66 -0
- package/src/scheduled-reply-unread-http.test.ts +212 -0
- package/src/scheduled-run-terminal.test.ts +268 -0
- package/src/scheduled-skip-blocking-task.test.ts +120 -0
- package/src/scheduled-stuck-http.test.ts +238 -0
- package/src/scheduled-stuck.ts +153 -0
- package/src/scheduled-tasks-http.test.ts +638 -0
- package/src/scheduled-tasks.ts +766 -0
- package/src/scoped-secrets.ts +124 -0
- package/src/send-dedup-request-id.test.ts +229 -0
- package/src/send-reply-agent-warning.test.ts +202 -0
- package/src/send-reply-alias-optional.test.ts +131 -0
- package/src/send-reply-attachments.test.ts +458 -0
- package/src/send_dedup.test.ts +54 -0
- package/src/send_dedup.ts +52 -6
- package/src/serve-error.test.ts +38 -0
- package/src/serve-error.ts +27 -0
- package/src/server.ts +5257 -0
- package/src/session-task-on-dispatch.ts +14 -0
- package/src/session-token-ownership-http.test.ts +122 -0
- package/src/shared/codex-inventory.ts +41 -0
- package/src/shared/daemon-provider-login.ts +31 -0
- package/src/shared/daemon-provider-snapshot.ts +52 -0
- package/src/shared/external-schedule-contract.ts +60 -0
- package/src/shared/node-name.test.ts +62 -0
- package/src/shared/node-name.ts +161 -0
- package/src/shared/provider-application.ts +29 -0
- package/src/shared/provider-collection.ts +14 -0
- package/src/shared/provider-initialization.ts +8 -0
- package/src/shared/reserved-env-drift.test.ts +10 -0
- package/src/shared/scoped-secret-contract.ts +5 -0
- package/src/shared/task-content-limit-drift.test.ts +28 -0
- package/src/shared/task-content-limit.ts +25 -0
- package/src/side-thread-command-transport.test.ts +312 -0
- package/src/side-thread-command-transport.ts +318 -0
- package/src/side-thread-http-integration.test.ts +233 -0
- package/src/side-thread-http.ts +504 -0
- package/src/side-thread-production.test.ts +72 -0
- package/src/side-thread-production.ts +118 -0
- package/src/side-thread.test.ts +1067 -0
- package/src/side-thread.ts +2499 -0
- package/src/skillhub-http.test.ts +108 -0
- package/src/sqlite-close-checkpoint.test.ts +78 -0
- package/src/start-node.test.ts +264 -0
- package/src/status-alias-filter-http.test.ts +106 -0
- package/src/status-alias-resolver-http.test.ts +163 -0
- package/src/status-alias-resolver.ts +19 -0
- package/src/status-node-id-filter-http.test.ts +161 -0
- package/src/status-read-cache-http.test.ts +229 -0
- package/src/status-read-cache.ts +122 -0
- package/src/stop-delete-node.test.ts +509 -1
- package/src/task-access-http.test.ts +402 -0
- package/src/task-access.ts +420 -0
- package/src/task-auth-origin-http.test.ts +91 -0
- package/src/task-auth-origin.test.ts +24 -0
- package/src/task-auth-origin.ts +7 -0
- package/src/task-consumption.test.ts +482 -0
- package/src/task-diagnostic-http.test.ts +149 -0
- package/src/task-diagnostic.test.ts +115 -0
- package/src/task-diagnostic.ts +118 -0
- package/src/task-expiry-notice-http.test.ts +287 -0
- package/src/task-expiry-notice.ts +315 -0
- package/src/task-idempotency-mcp.test.ts +142 -0
- package/src/task-idempotency.test.ts +60 -0
- package/src/task-idempotency.ts +75 -0
- package/src/task-late-replies.ts +54 -0
- package/src/task-late-reply-http.test.ts +152 -0
- package/src/task-lifecycle-watcher.test.ts +204 -0
- package/src/task-lifecycle-watcher.ts +49 -0
- package/src/task-network-resolution-http.test.ts +141 -0
- package/src/task-orphan-http.test.ts +287 -0
- package/src/task-orphan.ts +119 -0
- package/src/task-queue-ahead-http.test.ts +309 -0
- package/src/task-queue-ahead-scale.test.ts +236 -0
- package/src/task-queue-ahead.ts +186 -0
- package/src/task-rejection-log-http.test.ts +160 -0
- package/src/task-rejection-log.ts +49 -0
- package/src/task-stale-open-http.test.ts +290 -0
- package/src/task-stale-open.ts +103 -0
- package/src/tasks-pagination-http.test.ts +141 -0
- package/src/tool-audience-http.test.ts +220 -0
- package/src/tool-audience.ts +118 -0
- package/src/tools-list-json-schema.test.ts +67 -0
- package/src/tools.ts +3927 -323
- package/src/upload-early-reject-guard.test.ts +155 -0
- package/src/uploads-http.test.ts +97 -15
- package/src/uploads.ts +103 -6
- package/src/user-avatar-http.test.ts +74 -0
- package/src/user-avatar-migration.test.ts +25 -0
- package/src/user-inbox-by-agent-http.test.ts +161 -0
- package/src/user-inbox-netid-notnull.test.ts +122 -0
- package/src/user-inbox-read-http.test.ts +223 -0
- package/src/user-inbox-schema.test.ts +54 -0
|
@@ -0,0 +1,766 @@
|
|
|
1
|
+
import { db, logTaskEvent } from "./db.js";
|
|
2
|
+
import { sessionTaskOnDispatch } from "./session-task-on-dispatch.js";
|
|
3
|
+
import { TASK_CONTENT_MAX, sessionTaskPreview } from "./shared/task-content-limit.js";
|
|
4
|
+
import { assertNodeActive } from "./lifecycle-guard.js";
|
|
5
|
+
import { assertNodeHealthy } from "./node-health-guard.js";
|
|
6
|
+
import { addNetworkScope, canRestWriteNetwork, singleNetworkId, type RestNetworkScope } from "./network-scope.js";
|
|
7
|
+
import { canMessageAgent, type AgentRef } from "./agent-access.js";
|
|
8
|
+
import { dispatchVerdict } from "./node-permissions.js";
|
|
9
|
+
import { hasSubscribers, pushEvent, pushNetworkObserverEvent } from "./push.js";
|
|
10
|
+
import { SCHEDULED_TASK_STORAGE_SELECT } from "./rest-projections.js";
|
|
11
|
+
import { parseDbTimestampMs } from "./db-timestamp.js";
|
|
12
|
+
import { blockerTimedOut, expireStuckBlocker, sendStuckNotice, skipsBlockedBy, stuckNoticeSkips, stuckTimeoutMs, type StuckNotice } from "./scheduled-stuck.js";
|
|
13
|
+
import { evaluateScheduleFailures, failureSummary } from "./scheduled-failures.js";
|
|
14
|
+
import { scheduledTargetOffline, sendOfflineNotice, type OfflineNotice } from "./scheduled-offline.js";
|
|
15
|
+
|
|
16
|
+
export type ScheduleSpec =
|
|
17
|
+
| { type: "once"; run_at: string }
|
|
18
|
+
| { type: "interval"; every_seconds: number }
|
|
19
|
+
| { type: "daily"; time: string }
|
|
20
|
+
| { type: "weekly"; time: string; weekdays: number[] };
|
|
21
|
+
|
|
22
|
+
export type MisfirePolicy = "catch_up_once" | "skip";
|
|
23
|
+
|
|
24
|
+
export type ScheduledRow = {
|
|
25
|
+
schedule_id: string;
|
|
26
|
+
network_id: string;
|
|
27
|
+
created_by: string | null;
|
|
28
|
+
created_by_node_id: string | null;
|
|
29
|
+
name: string;
|
|
30
|
+
target_node_id: string;
|
|
31
|
+
target_alias: string;
|
|
32
|
+
task_content: string;
|
|
33
|
+
priority: "high" | "normal" | "low";
|
|
34
|
+
schedule_type: ScheduleSpec["type"];
|
|
35
|
+
schedule_json: string;
|
|
36
|
+
timezone: string;
|
|
37
|
+
overlap_policy: "skip" | "allow";
|
|
38
|
+
misfire_policy: MisfirePolicy;
|
|
39
|
+
status: "active" | "paused" | "completed" | "cancelled";
|
|
40
|
+
next_run_at: string | null;
|
|
41
|
+
last_run_at: string | null;
|
|
42
|
+
revision: number;
|
|
43
|
+
created_at: string;
|
|
44
|
+
updated_at: string;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
type RequestAuth = {
|
|
48
|
+
userId: string;
|
|
49
|
+
networkId: string | null;
|
|
50
|
+
username: string;
|
|
51
|
+
} | null;
|
|
52
|
+
|
|
53
|
+
export type ScheduledRequestContext = {
|
|
54
|
+
req: Request;
|
|
55
|
+
url: URL;
|
|
56
|
+
auth: RequestAuth;
|
|
57
|
+
isAdmin: boolean;
|
|
58
|
+
isNodeToken: boolean;
|
|
59
|
+
scope: RestNetworkScope;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const PRIORITIES = new Set(["high", "normal", "low"]);
|
|
63
|
+
const TIME_RE = /^(?:[01]\d|2[0-3]):[0-5]\d$/;
|
|
64
|
+
const OPEN_TASK_STATUSES = ["created", "delivered", "acked", "running"];
|
|
65
|
+
const MISFIRE_POLICIES = new Set<MisfirePolicy>(["catch_up_once", "skip"]);
|
|
66
|
+
export const MISFIRE_GRACE_MS = 60_000;
|
|
67
|
+
|
|
68
|
+
export function assertScheduledTaskBackendSupported(): void {
|
|
69
|
+
const refusal = db.transactionalFeaturesRefusal;
|
|
70
|
+
if (refusal !== null) {
|
|
71
|
+
throw new Error(`scheduled_tasks_require_transactional_sqlite_backend${refusal ? `: ${refusal}` : ""}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function jsonError(error: string, status: number, extra: Record<string, unknown> = {}): Response {
|
|
76
|
+
return Response.json({ ok: false, error, ...extra }, { status });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function iso(date: Date): string {
|
|
80
|
+
return date.toISOString();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function validTimezone(timezone: string): boolean {
|
|
84
|
+
try {
|
|
85
|
+
new Intl.DateTimeFormat("en-US", { timeZone: timezone }).format(new Date());
|
|
86
|
+
return true;
|
|
87
|
+
} catch {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function parseMisfirePolicy(raw: unknown, fallback: MisfirePolicy = "catch_up_once"): MisfirePolicy {
|
|
93
|
+
if (raw === undefined) return fallback;
|
|
94
|
+
if (typeof raw !== "string" || !MISFIRE_POLICIES.has(raw as MisfirePolicy)) throw new Error("invalid_misfire_policy");
|
|
95
|
+
return raw as MisfirePolicy;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function zonedParts(date: Date, timezone: string): { date: string; time: string; weekday: number } {
|
|
99
|
+
const parts = new Intl.DateTimeFormat("en-US", {
|
|
100
|
+
timeZone: timezone,
|
|
101
|
+
year: "numeric",
|
|
102
|
+
month: "2-digit",
|
|
103
|
+
day: "2-digit",
|
|
104
|
+
hourCycle: "h23",
|
|
105
|
+
hour: "2-digit",
|
|
106
|
+
minute: "2-digit",
|
|
107
|
+
weekday: "short",
|
|
108
|
+
}).formatToParts(date);
|
|
109
|
+
const value = (type: Intl.DateTimeFormatPartTypes) => parts.find((p) => p.type === type)?.value || "";
|
|
110
|
+
const weekday = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].indexOf(value("weekday"));
|
|
111
|
+
return { date: `${value("year")}-${value("month")}-${value("day")}`, time: `${value("hour")}:${value("minute")}`, weekday };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Parse and validate the public schedule shape. No cron expressions: the
|
|
115
|
+
* supported forms are intentionally portable between Dashboard and mobile. */
|
|
116
|
+
export function parseScheduleSpec(raw: unknown, timezoneRaw: unknown): { spec: ScheduleSpec; timezone: string } {
|
|
117
|
+
if (!raw || typeof raw !== "object") throw new Error("schedule_required");
|
|
118
|
+
const obj = raw as Record<string, unknown>;
|
|
119
|
+
const type = obj.type;
|
|
120
|
+
const timezone = typeof timezoneRaw === "string" && timezoneRaw.trim() ? timezoneRaw.trim() : "UTC";
|
|
121
|
+
if (!validTimezone(timezone)) throw new Error("invalid_timezone");
|
|
122
|
+
|
|
123
|
+
if (type === "once") {
|
|
124
|
+
if (typeof obj.run_at !== "string") throw new Error("run_at_required");
|
|
125
|
+
const when = new Date(obj.run_at);
|
|
126
|
+
if (!Number.isFinite(when.getTime())) throw new Error("invalid_run_at");
|
|
127
|
+
return { spec: { type, run_at: iso(when) }, timezone };
|
|
128
|
+
}
|
|
129
|
+
if (type === "interval") {
|
|
130
|
+
const every = Number(obj.every_seconds);
|
|
131
|
+
if (!Number.isSafeInteger(every) || every < 60 || every > 365 * 86400) throw new Error("invalid_interval");
|
|
132
|
+
return { spec: { type, every_seconds: every }, timezone };
|
|
133
|
+
}
|
|
134
|
+
if (type === "daily") {
|
|
135
|
+
if (typeof obj.time !== "string" || !TIME_RE.test(obj.time)) throw new Error("invalid_time");
|
|
136
|
+
return { spec: { type, time: obj.time }, timezone };
|
|
137
|
+
}
|
|
138
|
+
if (type === "weekly") {
|
|
139
|
+
if (typeof obj.time !== "string" || !TIME_RE.test(obj.time)) throw new Error("invalid_time");
|
|
140
|
+
if (!Array.isArray(obj.weekdays) || obj.weekdays.length === 0) throw new Error("weekdays_required");
|
|
141
|
+
const weekdays = [...new Set(obj.weekdays.map(Number))].sort();
|
|
142
|
+
if (weekdays.some((d) => !Number.isSafeInteger(d) || d < 0 || d > 6)) throw new Error("invalid_weekdays");
|
|
143
|
+
return { spec: { type, time: obj.time, weekdays }, timezone };
|
|
144
|
+
}
|
|
145
|
+
throw new Error("invalid_schedule_type");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Return the first occurrence strictly after `after`. Daily/weekly scans UTC
|
|
149
|
+
* minutes and compares IANA-zone wall time, so DST gaps/folds follow the
|
|
150
|
+
* platform timezone database instead of a hand-written offset guess. */
|
|
151
|
+
export function nextOccurrence(spec: ScheduleSpec, timezone: string, after: Date): Date | null {
|
|
152
|
+
if (spec.type === "once") {
|
|
153
|
+
const when = new Date(spec.run_at);
|
|
154
|
+
return when.getTime() > after.getTime() ? when : null;
|
|
155
|
+
}
|
|
156
|
+
if (spec.type === "interval") return new Date(after.getTime() + spec.every_seconds * 1000);
|
|
157
|
+
|
|
158
|
+
// A wall-clock time can occur twice when DST falls back. If today's local
|
|
159
|
+
// occurrence already happened at or before `after`, skip every duplicate
|
|
160
|
+
// carrying that same local date. Otherwise a "daily 01:30" schedule runs
|
|
161
|
+
// twice on the fall-back day.
|
|
162
|
+
const afterLocal = zonedParts(after, timezone);
|
|
163
|
+
let alreadyOccurredDate: string | null = null;
|
|
164
|
+
const backward = new Date(Math.floor(after.getTime() / 60000) * 60000);
|
|
165
|
+
for (let i = 0; i <= 26 * 60; i++, backward.setTime(backward.getTime() - 60000)) {
|
|
166
|
+
const local = zonedParts(backward, timezone);
|
|
167
|
+
if (local.date === afterLocal.date && local.time === spec.time && (spec.type === "daily" || spec.weekdays.includes(local.weekday))) {
|
|
168
|
+
alreadyOccurredDate = local.date;
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const cursor = new Date(Math.floor(after.getTime() / 60000) * 60000 + 60000);
|
|
174
|
+
const max = 8 * 24 * 60;
|
|
175
|
+
for (let i = 0; i < max; i++, cursor.setTime(cursor.getTime() + 60000)) {
|
|
176
|
+
const local = zonedParts(cursor, timezone);
|
|
177
|
+
if (local.time !== spec.time) continue;
|
|
178
|
+
if (local.date === alreadyOccurredDate) continue;
|
|
179
|
+
if (spec.type === "daily" || spec.weekdays.includes(local.weekday)) return new Date(cursor);
|
|
180
|
+
}
|
|
181
|
+
throw new Error("next_occurrence_unresolvable");
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** REST shape (pinned by rest-explicit-columns-http.test.ts): creator ids stay server-side. */
|
|
185
|
+
function decodeRow(row: ScheduledRow): Record<string, unknown> {
|
|
186
|
+
let schedule: unknown = null;
|
|
187
|
+
try { schedule = JSON.parse(row.schedule_json); } catch {}
|
|
188
|
+
const { created_by: _createdBy, created_by_node_id: _createdByNode, schedule_json: _scheduleJson, ...publicRow } = row;
|
|
189
|
+
return { ...publicRow, schedule };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** MCP (agent) shape: REST shape + which node created it (null = a person / old row). Never the user id. */
|
|
193
|
+
export function decodeRowForAgent(row: ScheduledRow): Record<string, unknown> {
|
|
194
|
+
return { ...decodeRow(row), created_by_node_id: row.created_by_node_id ?? null };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function getScheduleRow(scheduleId: string): ScheduledRow | null {
|
|
198
|
+
return db.get<ScheduledRow>(`SELECT ${SCHEDULED_TASK_STORAGE_SELECT} FROM scheduled_tasks WHERE schedule_id = ?1`, scheduleId);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export class ScheduleInputError extends Error {
|
|
202
|
+
constructor(public code: string, public status: number, public extra: Record<string, unknown> = {}) { super(code); }
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export type ValidatedScheduleCreate = {
|
|
206
|
+
name: string; content: string; priority: string; misfirePolicy: MisfirePolicy;
|
|
207
|
+
target: { node_id: string; alias: string }; spec: ScheduleSpec; timezone: string; next: Date;
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
/** Shared by REST POST and MCP schedule_create. Throws Error(code) like before. */
|
|
211
|
+
export function validateScheduleCreate(networkId: string, body: Record<string, unknown>): ValidatedScheduleCreate {
|
|
212
|
+
const name = typeof body.name === "string" ? body.name.trim() : "";
|
|
213
|
+
const content = typeof body.task === "string" ? body.task.trim() : "";
|
|
214
|
+
if (!name || name.length > 120) throw new Error("invalid_name");
|
|
215
|
+
if (!content || content.length > TASK_CONTENT_MAX) throw new Error("invalid_task");
|
|
216
|
+
const priority = typeof body.priority === "string" ? body.priority : "normal";
|
|
217
|
+
if (!PRIORITIES.has(priority)) throw new Error("invalid_priority");
|
|
218
|
+
const misfirePolicy = parseMisfirePolicy(body.misfire_policy);
|
|
219
|
+
const target = validateTarget(networkId, body.target_node_id);
|
|
220
|
+
const { spec, timezone } = parseScheduleSpec(body.schedule, body.timezone);
|
|
221
|
+
const next = nextOccurrence(spec, timezone, new Date());
|
|
222
|
+
if (!next) throw new Error("schedule_has_no_future_occurrence");
|
|
223
|
+
return { name, content, priority, misfirePolicy, target, spec, timezone, next };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function insertSchedule(networkId: string, createdBy: string | null, createdByNodeId: string | null, v: ValidatedScheduleCreate): ScheduledRow {
|
|
227
|
+
const scheduleId = `sched_${crypto.randomUUID()}`;
|
|
228
|
+
db.run(
|
|
229
|
+
`INSERT INTO scheduled_tasks
|
|
230
|
+
(schedule_id, network_id, created_by, created_by_node_id, name, target_node_id, target_alias, task_content, priority, schedule_type, schedule_json, timezone, overlap_policy, misfire_policy, next_run_at)
|
|
231
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, 'skip', ?13, ?14)`,
|
|
232
|
+
[scheduleId, networkId, createdBy, createdByNodeId, v.name, v.target.node_id, v.target.alias, v.content, v.priority, v.spec.type, JSON.stringify(v.spec), v.timezone, v.misfirePolicy, iso(v.next)],
|
|
233
|
+
);
|
|
234
|
+
return getScheduleRow(scheduleId)!;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Idempotent cancel (shared by REST DELETE / POST cancel and MCP schedule_cancel). */
|
|
238
|
+
export function cancelSchedule(row: ScheduledRow): void {
|
|
239
|
+
if (row.status === "cancelled") return;
|
|
240
|
+
db.run("UPDATE scheduled_tasks SET status = 'cancelled', next_run_at = NULL, revision = revision + 1, updated_at = datetime('now') WHERE schedule_id = ?1", [row.schedule_id]);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function scheduleRuns(row: ScheduledRow, limit: number): Record<string, unknown> {
|
|
244
|
+
const runs = db.all(
|
|
245
|
+
// #523 — a task that ended `failed` leaves run.error_message empty; its reason is the task's reply text.
|
|
246
|
+
`SELECT r.run_id, r.schedule_id, r.scheduled_for, r.task_id, r.status, r.error_code,
|
|
247
|
+
CASE WHEN r.error_message IS NULL AND r.status = 'failed' THEN SUBSTR(t.result, 1, 500) ELSE r.error_message END AS error_message,
|
|
248
|
+
r.blocked_by_task_id, r.blocked_by_state, r.created_at, r.completed_at
|
|
249
|
+
FROM scheduled_task_runs r
|
|
250
|
+
LEFT JOIN tasks t ON t.task_id = r.task_id AND t.network_id = r.network_id
|
|
251
|
+
WHERE r.schedule_id = ?1 AND r.network_id = ?2 ORDER BY r.created_at DESC, r.scheduled_for DESC LIMIT ?3`,
|
|
252
|
+
row.schedule_id, row.network_id, limit,
|
|
253
|
+
);
|
|
254
|
+
return { runs, ...failureSummary(row.schedule_id) };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Shared PATCH semantics (REST PATCH and MCP schedule_update). Throws ScheduleInputError. */
|
|
258
|
+
export function patchSchedule(row: ScheduledRow, body: Record<string, unknown>): ScheduledRow {
|
|
259
|
+
// Cancellation/completion are terminal states. Editing must never become
|
|
260
|
+
// an implicit resurrection path (including by supplying status=active).
|
|
261
|
+
if (row.status === "cancelled") throw new ScheduleInputError("schedule_cancelled", 409);
|
|
262
|
+
if (row.status === "completed") throw new ScheduleInputError("schedule_completed", 409);
|
|
263
|
+
if (!Number.isSafeInteger(body.revision) || Number(body.revision) !== row.revision) throw new ScheduleInputError("revision_conflict", 409, { current_revision: row.revision });
|
|
264
|
+
try {
|
|
265
|
+
const name = body.name === undefined ? row.name : String(body.name).trim();
|
|
266
|
+
const content = body.task === undefined ? row.task_content : String(body.task).trim();
|
|
267
|
+
const priority = body.priority === undefined ? row.priority : String(body.priority);
|
|
268
|
+
if (!name || name.length > 120) throw new Error("invalid_name");
|
|
269
|
+
if (!content || content.length > TASK_CONTENT_MAX) throw new Error("invalid_task");
|
|
270
|
+
if (!PRIORITIES.has(priority)) throw new Error("invalid_priority");
|
|
271
|
+
const target = body.target_node_id === undefined ? { node_id: row.target_node_id, alias: row.target_alias } : validateTarget(row.network_id, body.target_node_id);
|
|
272
|
+
const parsed = body.schedule === undefined
|
|
273
|
+
? { spec: JSON.parse(row.schedule_json) as ScheduleSpec, timezone: body.timezone === undefined ? row.timezone : String(body.timezone) }
|
|
274
|
+
: parseScheduleSpec(body.schedule, body.timezone === undefined ? row.timezone : body.timezone);
|
|
275
|
+
if (!validTimezone(parsed.timezone)) throw new Error("invalid_timezone");
|
|
276
|
+
const requestedStatus = body.status === undefined ? row.status : String(body.status);
|
|
277
|
+
if (!new Set(["active", "paused"]).has(requestedStatus)) throw new Error("invalid_status");
|
|
278
|
+
const misfirePolicy = parseMisfirePolicy(body.misfire_policy, row.misfire_policy);
|
|
279
|
+
// Editing descriptive fields must not silently reset the schedule's
|
|
280
|
+
// cadence. Recompute only when the scheduling inputs change, when a
|
|
281
|
+
// paused schedule resumes, or when repairing an impossible active row
|
|
282
|
+
// with no next occurrence. The recompute uses the same DST-safe helper
|
|
283
|
+
// as creation and dispatch advancement.
|
|
284
|
+
const scheduleJson = JSON.stringify(parsed.spec);
|
|
285
|
+
const schedulingChanged = scheduleJson !== row.schedule_json || parsed.timezone !== row.timezone;
|
|
286
|
+
const resumed = row.status !== "active" && requestedStatus === "active";
|
|
287
|
+
const next = requestedStatus !== "active"
|
|
288
|
+
? null
|
|
289
|
+
: schedulingChanged || resumed || !row.next_run_at
|
|
290
|
+
? nextOccurrence(parsed.spec, parsed.timezone, new Date())
|
|
291
|
+
: new Date(row.next_run_at);
|
|
292
|
+
if (requestedStatus === "active" && !next) throw new Error("schedule_has_no_future_occurrence");
|
|
293
|
+
// Runs no longer bump revision, so the scheduler may have advanced
|
|
294
|
+
// next_run_at after `row` was read. When cadence is preserved, keep the
|
|
295
|
+
// stored value instead of writing back the already-fired occurrence.
|
|
296
|
+
const keepStoredNext = requestedStatus === "active" && !schedulingChanged && !resumed && !!row.next_run_at ? 1 : 0;
|
|
297
|
+
const updated = db.run(
|
|
298
|
+
`UPDATE scheduled_tasks SET name = ?1, target_node_id = ?2, target_alias = ?3, task_content = ?4,
|
|
299
|
+
priority = ?5, schedule_type = ?6, schedule_json = ?7, timezone = ?8, status = ?9,
|
|
300
|
+
next_run_at = CASE WHEN ?14 = 1 AND next_run_at IS NOT NULL THEN next_run_at ELSE ?10 END,
|
|
301
|
+
misfire_policy = ?11, revision = revision + 1, updated_at = datetime('now') WHERE schedule_id = ?12 AND revision = ?13`,
|
|
302
|
+
[name, target.node_id, target.alias, content, priority, parsed.spec.type, scheduleJson, parsed.timezone, requestedStatus, next ? iso(next) : null, misfirePolicy, row.schedule_id, row.revision, keepStoredNext],
|
|
303
|
+
);
|
|
304
|
+
if (updated.changes !== 1) throw new ScheduleInputError("revision_conflict", 409);
|
|
305
|
+
return getScheduleRow(row.schedule_id)!;
|
|
306
|
+
} catch (e: any) {
|
|
307
|
+
if (e instanceof ScheduleInputError) throw e;
|
|
308
|
+
const code = String(e?.message || "invalid_schedule");
|
|
309
|
+
throw new ScheduleInputError(code, code === "target_node_not_found" ? 404 : 400);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function scopedSchedule(scheduleId: string, scope: RestNetworkScope): ScheduledRow | null {
|
|
314
|
+
const params: unknown[] = [scheduleId];
|
|
315
|
+
let sql = `SELECT ${SCHEDULED_TASK_STORAGE_SELECT} FROM scheduled_tasks WHERE schedule_id = ?1`;
|
|
316
|
+
sql = addNetworkScope(sql, params, scope);
|
|
317
|
+
return db.get<ScheduledRow>(sql, ...params);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function resolveWriteNetwork(body: Record<string, unknown>, ctx: ScheduledRequestContext): string | null {
|
|
321
|
+
if (ctx.auth?.networkId) return ctx.auth.networkId;
|
|
322
|
+
if (typeof body.network_id === "string" && body.network_id) return body.network_id;
|
|
323
|
+
return singleNetworkId(ctx.scope);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function validateTarget(networkId: string, nodeId: unknown): { node_id: string; alias: string } {
|
|
327
|
+
if (typeof nodeId !== "string" || nodeId.length < 2 || nodeId.length > 200) throw new Error("invalid_target_node_id");
|
|
328
|
+
const node = db.get<{ node_id: string; alias: string | null }>(
|
|
329
|
+
"SELECT node_id, alias FROM nodes WHERE node_id = ?1 AND network_id = ?2",
|
|
330
|
+
nodeId, networkId,
|
|
331
|
+
);
|
|
332
|
+
if (!node?.alias) throw new Error("target_node_not_found");
|
|
333
|
+
return { node_id: node.node_id, alias: node.alias };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function stuckNoticeFor(row: ScheduledRow, reason: StuckNotice["reason"], blocker: { task_id: string; created_at: string }, skips: number, timeoutMs: number): StuckNotice {
|
|
337
|
+
return {
|
|
338
|
+
reason, scheduleId: row.schedule_id, scheduleName: row.name, networkId: row.network_id, createdBy: row.created_by,
|
|
339
|
+
alias: row.target_alias, timezone: row.timezone, taskId: blocker.task_id, taskCreatedAt: blocker.created_at, skips, timeoutMs,
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** The line an Agent-created schedule's task always starts with (board #733). */
|
|
344
|
+
export function agentSchedulePrefix(alias: string | null, nodeId: string): string {
|
|
345
|
+
return `[scheduled by agent ${alias ?? "?"} (${nodeId})]\n`;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function agentCreatorDenial(row: ScheduledRow, target: AgentRef): { code: string; message: string } | null {
|
|
349
|
+
const creator = db.get<{ alias: string | null; owner_user_id: string | null; permission_mode: string | null }>(
|
|
350
|
+
"SELECT alias, owner_user_id, permission_mode FROM nodes WHERE node_id = ?1 AND network_id = ?2", row.created_by_node_id, row.network_id);
|
|
351
|
+
if (!creator) return { code: "creator_node_gone", message: "The node that created this schedule no longer exists in this network" };
|
|
352
|
+
const mode = creator.permission_mode === "readonly" || creator.permission_mode === "restricted" ? creator.permission_mode : "normal";
|
|
353
|
+
if (mode === "readonly") return { code: "creator_node_readonly", message: "The node that created this schedule is read-only" };
|
|
354
|
+
const verdict = dispatchVerdict({
|
|
355
|
+
networkId: row.network_id, nodeId: row.created_by_node_id, logKey: row.created_by_node_id!,
|
|
356
|
+
aliases: creator.alias ? [creator.alias] : [], ownerUserId: creator.owner_user_id, mode,
|
|
357
|
+
}, target);
|
|
358
|
+
if (verdict) return { code: "creator_access_revoked", message: `The node that created this schedule may not send_task to this agent now (${verdict.reason})` };
|
|
359
|
+
return null;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
type DispatchEvent = { alias: string; networkId: string; taskId: string; priority: string; state: "delivered" | "queued" };
|
|
363
|
+
|
|
364
|
+
export function dispatchScheduledOccurrence(row: ScheduledRow, scheduledFor: string, advanceSchedule: boolean, advanceAfter = new Date()): { runId: string; taskId?: string; status: string; event?: DispatchEvent } {
|
|
365
|
+
const runId = `srun_${crypto.randomUUID()}`;
|
|
366
|
+
let event: DispatchEvent | undefined;
|
|
367
|
+
let finalStatus = "failed";
|
|
368
|
+
let createdTaskId: string | undefined;
|
|
369
|
+
const notices: StuckNotice[] = [];
|
|
370
|
+
let offlineNotice: OfflineNotice | null = null;
|
|
371
|
+
const timedOut: Array<{ taskId: string; fromStatus: string }> = [];
|
|
372
|
+
|
|
373
|
+
db.transaction(() => {
|
|
374
|
+
// UNIQUE(schedule_id, scheduled_for) is the cross-process claim. If two
|
|
375
|
+
// Hub processes race, one INSERT wins and the other transaction aborts.
|
|
376
|
+
db.run(
|
|
377
|
+
`INSERT INTO scheduled_task_runs (run_id, schedule_id, network_id, scheduled_for, status)
|
|
378
|
+
VALUES (?1, ?2, ?3, ?4, 'claiming')`,
|
|
379
|
+
[runId, row.schedule_id, row.network_id, scheduledFor],
|
|
380
|
+
);
|
|
381
|
+
|
|
382
|
+
// Board #757 — do not create a task that will merely expire while its
|
|
383
|
+
// exact target session is offline. This branch deliberately precedes the
|
|
384
|
+
// overlap check: target_offline is the actionable reason for this round.
|
|
385
|
+
const targetNode = db.get<{ node_id: string; alias: string | null }>(
|
|
386
|
+
"SELECT node_id, alias FROM nodes WHERE node_id = ?1 AND network_id = ?2",
|
|
387
|
+
row.target_node_id, row.network_id,
|
|
388
|
+
);
|
|
389
|
+
if (targetNode?.alias && scheduledTargetOffline(targetNode.node_id, targetNode.alias, row.network_id, advanceAfter.getTime())) {
|
|
390
|
+
finalStatus = "skipped";
|
|
391
|
+
db.run(
|
|
392
|
+
`UPDATE scheduled_task_runs SET status = 'skipped', error_code = 'target_offline',
|
|
393
|
+
error_message = 'The target node is offline; no task was created', completed_at = datetime('now') WHERE run_id = ?1`,
|
|
394
|
+
[runId],
|
|
395
|
+
);
|
|
396
|
+
const claimed = db.run(
|
|
397
|
+
"UPDATE scheduled_tasks SET offline_alert_at = ?1 WHERE schedule_id = ?2 AND offline_alert_at IS NULL",
|
|
398
|
+
[advanceAfter.toISOString(), row.schedule_id],
|
|
399
|
+
).changes === 1;
|
|
400
|
+
if (claimed) offlineNotice = {
|
|
401
|
+
scheduleId: row.schedule_id, scheduleName: row.name, networkId: row.network_id,
|
|
402
|
+
createdBy: row.created_by, createdByNodeId: row.created_by_node_id,
|
|
403
|
+
targetAlias: targetNode.alias,
|
|
404
|
+
};
|
|
405
|
+
if (advanceSchedule) advance({ ...row, target_alias: targetNode.alias }, scheduledFor, advanceAfter);
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
// Seeing the target online rearms exactly one warning for a future offline episode.
|
|
409
|
+
if (targetNode?.alias) db.run("UPDATE scheduled_tasks SET offline_alert_at = NULL WHERE schedule_id = ?1 AND offline_alert_at IS NOT NULL", [row.schedule_id]);
|
|
410
|
+
|
|
411
|
+
if (row.overlap_policy === "skip") {
|
|
412
|
+
const placeholders = OPEN_TASK_STATUSES.map((_, i) => `?${i + 2}`).join(", ");
|
|
413
|
+
// received:节点那边有没有把这条任务取走过(inbox 行被 ack,或任务已进 acked/running)。
|
|
414
|
+
// 跳过记录带上它,客户端才分得清「节点还没收到」和「节点在处理」—— 前者是投递故障,
|
|
415
|
+
// 后者才是真的上一次还没做完。
|
|
416
|
+
const findOpen = () => db.get<{ task_id: string; received: number; created_at: string; status: string }>(
|
|
417
|
+
`SELECT r.task_id, t.created_at, t.status,
|
|
418
|
+
CASE WHEN t.status IN ('acked', 'running')
|
|
419
|
+
OR EXISTS (SELECT 1 FROM inbox i WHERE (i.task_id = r.task_id OR i.id = r.task_id) AND i.acked = 1)
|
|
420
|
+
THEN 1 ELSE 0 END AS received
|
|
421
|
+
FROM scheduled_task_runs r
|
|
422
|
+
JOIN tasks t ON t.task_id = r.task_id
|
|
423
|
+
WHERE r.schedule_id = ?1 AND t.status IN (${placeholders})
|
|
424
|
+
ORDER BY r.created_at DESC LIMIT 1`,
|
|
425
|
+
row.schedule_id, ...OPEN_TASK_STATUSES,
|
|
426
|
+
);
|
|
427
|
+
let open = findOpen();
|
|
428
|
+
// #464 — a blocker open past the stuck timeout is ended as `expired` (the existing terminal state) and
|
|
429
|
+
// this occurrence dispatches normally. Bounded: each pass ends one exact open task or stops.
|
|
430
|
+
for (let guard = 0; open && guard < 20 && blockerTimedOut(open, row.schedule_json, advanceAfter); guard++) {
|
|
431
|
+
const limitMs = stuckTimeoutMs(row.schedule_json);
|
|
432
|
+
const ageMs = advanceAfter.getTime() - parseDbTimestampMs(open.created_at);
|
|
433
|
+
if (!expireStuckBlocker(open.task_id, row.network_id, ageMs, limitMs)) break;
|
|
434
|
+
timedOut.push({ taskId: open.task_id, fromStatus: open.status });
|
|
435
|
+
// Each stuck episode gets exactly one notice: if the stuck notice already went out (N skips), stay quiet.
|
|
436
|
+
if (skipsBlockedBy(row.schedule_id, open.task_id) < stuckNoticeSkips()) {
|
|
437
|
+
notices.push(stuckNoticeFor(row, "timed_out", open, skipsBlockedBy(row.schedule_id, open.task_id), limitMs));
|
|
438
|
+
}
|
|
439
|
+
open = findOpen();
|
|
440
|
+
}
|
|
441
|
+
if (open) {
|
|
442
|
+
finalStatus = "skipped";
|
|
443
|
+
const blockedState = open.received ? "in_progress" : "not_received";
|
|
444
|
+
db.run(
|
|
445
|
+
`UPDATE scheduled_task_runs SET status = 'skipped', error_code = 'previous_run_active', error_message = ?2,
|
|
446
|
+
blocked_by_task_id = ?3, blocked_by_state = ?4, completed_at = datetime('now') WHERE run_id = ?1`,
|
|
447
|
+
[
|
|
448
|
+
runId,
|
|
449
|
+
blockedState === "not_received"
|
|
450
|
+
? `previous run task ${open.task_id} has not been received by the node yet`
|
|
451
|
+
: `previous run task ${open.task_id} is still in progress on the node`,
|
|
452
|
+
open.task_id,
|
|
453
|
+
blockedState,
|
|
454
|
+
],
|
|
455
|
+
);
|
|
456
|
+
// #464 — the Nth consecutive skip behind the same task tells the schedule's creator, once.
|
|
457
|
+
const skips = skipsBlockedBy(row.schedule_id, open.task_id);
|
|
458
|
+
if (skips === stuckNoticeSkips()) notices.push(stuckNoticeFor(row, "stuck", open, skips, stuckTimeoutMs(row.schedule_json)));
|
|
459
|
+
if (advanceSchedule) advance(row, scheduledFor, advanceAfter);
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const node = targetNode;
|
|
465
|
+
if (!node?.alias) {
|
|
466
|
+
finalStatus = "failed";
|
|
467
|
+
db.run(
|
|
468
|
+
"UPDATE scheduled_task_runs SET status = 'failed', error_code = 'target_node_not_found', error_message = 'The bound node no longer exists in this network', completed_at = datetime('now') WHERE run_id = ?1",
|
|
469
|
+
[runId],
|
|
470
|
+
);
|
|
471
|
+
if (advanceSchedule) advance(row, scheduledFor, advanceAfter);
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
// 多用户 Agent 权限:排程是「建它的人」的委托。建的人后来被设成受限成员、而且没被授权
|
|
475
|
+
// 给目标 Agent 发任务时,不再代他派发(否则受限前建的排程会永远绕过授权)。
|
|
476
|
+
// Board #733 — an Agent-created schedule is that node's standing send_task: every run re-evaluates the
|
|
477
|
+
// creating node as it is NOW (exists, not read-only, current owner) with send_task's own dispatchVerdict.
|
|
478
|
+
const creatorDenial = row.created_by_node_id
|
|
479
|
+
? agentCreatorDenial(row, { alias: node.alias, nodeId: node.node_id })
|
|
480
|
+
: row.created_by && !canMessageAgent(row.created_by, row.network_id, { alias: node.alias, nodeId: node.node_id })
|
|
481
|
+
? { code: "creator_access_revoked", message: "The schedule creator is no longer allowed to message this agent" }
|
|
482
|
+
: null;
|
|
483
|
+
if (creatorDenial) {
|
|
484
|
+
finalStatus = "failed";
|
|
485
|
+
db.run(
|
|
486
|
+
"UPDATE scheduled_task_runs SET status = 'failed', error_code = ?1, error_message = ?2, completed_at = datetime('now') WHERE run_id = ?3",
|
|
487
|
+
[creatorDenial.code, creatorDenial.message, runId],
|
|
488
|
+
);
|
|
489
|
+
if (advanceSchedule) advance(row, scheduledFor, advanceAfter);
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
const creatorNode = row.created_by_node_id
|
|
493
|
+
? db.get<{ alias: string | null }>("SELECT alias FROM nodes WHERE node_id = ?1 AND network_id = ?2", row.created_by_node_id, row.network_id)
|
|
494
|
+
: null;
|
|
495
|
+
const lifecycle = assertNodeActive(node.alias, row.network_id);
|
|
496
|
+
if (!lifecycle.ok) {
|
|
497
|
+
finalStatus = "failed";
|
|
498
|
+
db.run(
|
|
499
|
+
"UPDATE scheduled_task_runs SET status = 'failed', error_code = 'target_not_active', error_message = ?1, completed_at = datetime('now') WHERE run_id = ?2",
|
|
500
|
+
[String((lifecycle as any).error || "target_not_active").slice(0, 500), runId],
|
|
501
|
+
);
|
|
502
|
+
if (advanceSchedule) advance(row, scheduledFor, advanceAfter);
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// #460 — the target reports a degraded layer (App Server down / TUI gone / login revoked): the run fails
|
|
507
|
+
// with that reason instead of queueing a task nobody will execute. Unknown / stale health runs as before.
|
|
508
|
+
const health = assertNodeHealthy(node.alias, row.network_id, { forceAllowed: false });
|
|
509
|
+
if (!health.ok) {
|
|
510
|
+
finalStatus = "failed";
|
|
511
|
+
db.run(
|
|
512
|
+
"UPDATE scheduled_task_runs SET status = 'failed', error_code = 'node_degraded', error_message = ?1, completed_at = datetime('now') WHERE run_id = ?2",
|
|
513
|
+
[health.message.slice(0, 500), runId],
|
|
514
|
+
);
|
|
515
|
+
if (advanceSchedule) advance(row, scheduledFor, advanceAfter);
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const taskId = crypto.randomUUID();
|
|
520
|
+
// claude-code 裸节点的 node-server 上报 report_status 不带 node_id,它的 sessions 行
|
|
521
|
+
// node_id 永远是 NULL —— 只按 node_id 找会话,这类节点每一次都判成 queued、门铃不响,
|
|
522
|
+
// 任务在 inbox 里等到别的消息顺带捎进去(生产上 20–36 分钟)。按 node_id 找不到时退回
|
|
523
|
+
// 同网同别名、node_id 为空的会话;SSE 订阅者注册表是「有没有人在听」的实测,也算数。
|
|
524
|
+
const session = db.get<{ status: string | null }>(
|
|
525
|
+
"SELECT status FROM sessions WHERE node_id = ?1 AND network_id = ?2 ORDER BY updated_at DESC LIMIT 1",
|
|
526
|
+
row.target_node_id, row.network_id,
|
|
527
|
+
) ?? db.get<{ status: string | null }>(
|
|
528
|
+
"SELECT status FROM sessions WHERE alias = ?1 AND network_id = ?2 AND node_id IS NULL ORDER BY updated_at DESC LIMIT 1",
|
|
529
|
+
node.alias, row.network_id,
|
|
530
|
+
);
|
|
531
|
+
const deliveryState: "delivered" | "queued" =
|
|
532
|
+
(session && session.status !== "offline") || hasSubscribers(node.alias, row.network_id) ? "delivered" : "queued";
|
|
533
|
+
// Board #733 — provenance is server-set: an Agent-created schedule names its node in meta and in a
|
|
534
|
+
// prefix the receiving agent always sees first, so it can never pass as a person's schedule.
|
|
535
|
+
const metaJson = JSON.stringify({
|
|
536
|
+
scheduled_task_id: row.schedule_id,
|
|
537
|
+
scheduled_run_id: runId,
|
|
538
|
+
scheduled_for: scheduledFor,
|
|
539
|
+
auth_origin: "hub_scheduler",
|
|
540
|
+
...(row.created_by_node_id ? { scheduled_by_node_id: row.created_by_node_id, scheduled_by_alias: creatorNode?.alias ?? null } : {}),
|
|
541
|
+
});
|
|
542
|
+
const taskContent = row.created_by_node_id
|
|
543
|
+
? `${agentSchedulePrefix(creatorNode?.alias ?? null, row.created_by_node_id)}${row.task_content}`
|
|
544
|
+
: row.task_content;
|
|
545
|
+
db.run(
|
|
546
|
+
`INSERT INTO inbox (id, task_id, session_name, node_id, type, priority, content, from_session, requires_response, network_id, meta_json)
|
|
547
|
+
VALUES (?1, ?1, ?2, ?3, 'task', ?4, ?5, 'scheduler', 'reply', ?6, ?7)`,
|
|
548
|
+
[taskId, node.alias, node.node_id, row.priority, taskContent, row.network_id, metaJson],
|
|
549
|
+
);
|
|
550
|
+
db.run(
|
|
551
|
+
`INSERT INTO tasks (task_id, from_name, to_node_id, to_name, priority, status, content, requires_response, created_at, delivered_at, expires_at, network_id, meta_json)
|
|
552
|
+
VALUES (?1, 'scheduler', ?2, ?3, ?4, 'delivered', ?5, 'reply', datetime('now'), datetime('now'), datetime('now', '+86400 seconds'), ?6, ?7)`,
|
|
553
|
+
[taskId, node.node_id, node.alias, row.priority, taskContent, row.network_id, metaJson],
|
|
554
|
+
);
|
|
555
|
+
// Dispatch is not completion. Keep the run open until the exact bound
|
|
556
|
+
// task reaches replied/failed/cancelled/expired; db.ts owns that mirror.
|
|
557
|
+
db.run("UPDATE scheduled_task_runs SET task_id = ?1, status = ?2, completed_at = NULL WHERE run_id = ?3", [taskId, deliveryState, runId]);
|
|
558
|
+
db.run(`UPDATE sessions SET task = ${sessionTaskOnDispatch("?1")}, updated_at = datetime('now') WHERE node_id = ?2 AND network_id = ?3`, [sessionTaskPreview(taskContent), node.node_id, row.network_id]);
|
|
559
|
+
db.run("UPDATE scheduled_tasks SET target_alias = ?1, last_run_at = ?2, updated_at = datetime('now') WHERE schedule_id = ?3", [node.alias, scheduledFor, row.schedule_id]);
|
|
560
|
+
if (advanceSchedule) advance({ ...row, target_alias: node.alias }, scheduledFor, advanceAfter);
|
|
561
|
+
createdTaskId = taskId;
|
|
562
|
+
finalStatus = deliveryState;
|
|
563
|
+
event = { alias: node.alias, networkId: row.network_id, taskId, priority: row.priority, state: deliveryState };
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
// Everything below is a post-commit doorbell. A logging/SSE failure must
|
|
567
|
+
// never turn a durably-created task into an API-level failure: callers may
|
|
568
|
+
// retry an apparent failure and create a second manual occurrence.
|
|
569
|
+
try {
|
|
570
|
+
for (const t of timedOut) logTaskEvent(t.taskId, t.fromStatus, "expired", "hub-scheduler", `stuck timeout; schedule=${row.schedule_id} run=${runId}`);
|
|
571
|
+
for (const n of notices) sendStuckNotice(n);
|
|
572
|
+
if (offlineNotice) sendOfflineNotice(offlineNotice);
|
|
573
|
+
// #523 — a run that failed at dispatch counts toward the consecutive-failure alert (task failures arrive via db.ts).
|
|
574
|
+
if (finalStatus === "failed") evaluateScheduleFailures(row.schedule_id, advanceAfter);
|
|
575
|
+
if (createdTaskId) logTaskEvent(createdTaskId, null, "delivered", "hub-scheduler", `schedule=${row.schedule_id} run=${runId}`);
|
|
576
|
+
if (event) {
|
|
577
|
+
const pending = db.get<{ cnt: number }>("SELECT COUNT(*) AS cnt FROM inbox WHERE session_name = ?1 AND network_id = ?2 AND acked = 0", event.alias, event.networkId);
|
|
578
|
+
if (event.state === "delivered") pushEvent(event.alias, { type: "new_task", inbox_count: pending?.cnt ?? 1, priority: event.priority, from: "scheduler" }, event.networkId);
|
|
579
|
+
pushNetworkObserverEvent(event.networkId, { type: "new_task", task_id: event.taskId, from: "scheduler", to: event.alias, status: event.state, priority: event.priority });
|
|
580
|
+
}
|
|
581
|
+
} catch (e: any) {
|
|
582
|
+
console.error(`[scheduled-tasks] post-commit notification failed schedule=${row.schedule_id} run=${runId}: ${e?.message || e}`);
|
|
583
|
+
}
|
|
584
|
+
return { runId, taskId: createdTaskId, status: finalStatus, event };
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// `revision` is the optimistic-concurrency token for *user edits*. Run
|
|
588
|
+
// bookkeeping (next_run_at/last_run_at) must not bump it: a 2-minute schedule
|
|
589
|
+
// would otherwise turn every edit that spans a run into a 409 and the client
|
|
590
|
+
// would drop the user's draft. Terminal completion still bumps, because it is
|
|
591
|
+
// a status change the user sees and it is what stops an in-flight PATCH (whose
|
|
592
|
+
// UPDATE is guarded by `AND revision = ?`) from reviving a finished one-shot.
|
|
593
|
+
function advance(row: ScheduledRow, scheduledFor: string, after = new Date(), recordLastRun = true): void {
|
|
594
|
+
const spec = JSON.parse(row.schedule_json) as ScheduleSpec;
|
|
595
|
+
const next = nextOccurrence(spec, row.timezone, after);
|
|
596
|
+
const lastRunAt = recordLastRun ? scheduledFor : row.last_run_at;
|
|
597
|
+
if (spec.type === "once" || !next) {
|
|
598
|
+
db.run(
|
|
599
|
+
"UPDATE scheduled_tasks SET status = 'completed', next_run_at = NULL, last_run_at = ?1, revision = revision + 1, updated_at = datetime('now') WHERE schedule_id = ?2",
|
|
600
|
+
[lastRunAt, row.schedule_id],
|
|
601
|
+
);
|
|
602
|
+
} else {
|
|
603
|
+
db.run(
|
|
604
|
+
"UPDATE scheduled_tasks SET next_run_at = ?1, last_run_at = ?2, updated_at = datetime('now') WHERE schedule_id = ?3",
|
|
605
|
+
[iso(next), lastRunAt, row.schedule_id],
|
|
606
|
+
);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function isMissedOccurrence(scheduledFor: string, now: Date): boolean {
|
|
611
|
+
const scheduledAt = new Date(scheduledFor).getTime();
|
|
612
|
+
return Number.isFinite(scheduledAt) && now.getTime() - scheduledAt > MISFIRE_GRACE_MS;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function skipScheduledMisfire(row: ScheduledRow, scheduledFor: string, now: Date): void {
|
|
616
|
+
const runId = `srun_${crypto.randomUUID()}`;
|
|
617
|
+
db.transaction(() => {
|
|
618
|
+
db.run(
|
|
619
|
+
`INSERT INTO scheduled_task_runs
|
|
620
|
+
(run_id, schedule_id, network_id, scheduled_for, status, error_code, error_message, completed_at)
|
|
621
|
+
VALUES (?1, ?2, ?3, ?4, 'skipped', 'misfire_skipped', 'The occurrence was overdue and the schedule is configured to skip missed runs', datetime('now'))`,
|
|
622
|
+
[runId, row.schedule_id, row.network_id, scheduledFor],
|
|
623
|
+
);
|
|
624
|
+
// A skipped occurrence is auditable but is not an execution: preserve
|
|
625
|
+
// last_run_at while moving the schedule directly to its next future slot.
|
|
626
|
+
advance(row, scheduledFor, now, false);
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
export function runDueScheduledTasks(now = new Date(), limit = 100): { processed: number; failed: number } {
|
|
631
|
+
const due = db.all<ScheduledRow>(
|
|
632
|
+
`SELECT ${SCHEDULED_TASK_STORAGE_SELECT} FROM scheduled_tasks WHERE status = 'active' AND next_run_at IS NOT NULL AND next_run_at <= ?1 ORDER BY next_run_at LIMIT ?2`,
|
|
633
|
+
iso(now), limit,
|
|
634
|
+
);
|
|
635
|
+
let processed = 0;
|
|
636
|
+
let failed = 0;
|
|
637
|
+
for (const row of due) {
|
|
638
|
+
try {
|
|
639
|
+
// Re-read inside the claim path so a pause/cancel immediately before the
|
|
640
|
+
// tick wins. The unique occurrence key handles a second scheduler.
|
|
641
|
+
const current = db.get<ScheduledRow>(`SELECT ${SCHEDULED_TASK_STORAGE_SELECT} FROM scheduled_tasks WHERE schedule_id = ?1 AND status = 'active' AND next_run_at = ?2`, row.schedule_id, row.next_run_at);
|
|
642
|
+
if (!current?.next_run_at) continue;
|
|
643
|
+
if (current.misfire_policy === "skip" && isMissedOccurrence(current.next_run_at, now)) {
|
|
644
|
+
skipScheduledMisfire(current, current.next_run_at, now);
|
|
645
|
+
} else {
|
|
646
|
+
// catch_up_once is also the compatibility default for pre-migration
|
|
647
|
+
// rows. Advancing from `now` collapses any number of missed slots into
|
|
648
|
+
// one task instead of replaying an outage as a dispatch storm.
|
|
649
|
+
dispatchScheduledOccurrence(current, current.next_run_at, true, now);
|
|
650
|
+
}
|
|
651
|
+
processed++;
|
|
652
|
+
} catch (e: any) {
|
|
653
|
+
if (/UNIQUE|duplicate key/i.test(String(e?.message))) continue;
|
|
654
|
+
failed++;
|
|
655
|
+
console.error(`[scheduled-tasks] occurrence failed schedule=${row.schedule_id}: ${e?.message || e}`);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
return { processed, failed };
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
export function startScheduledTaskScheduler(intervalMs?: number): ReturnType<typeof setInterval> {
|
|
662
|
+
const configured = Number(process.env.COMMHUB_SCHEDULER_TICK_MS);
|
|
663
|
+
const ms = intervalMs && intervalMs > 0 ? intervalMs : configured > 0 ? configured : 10_000;
|
|
664
|
+
// First tick is immediate so a restarted Hub converges without waiting one
|
|
665
|
+
// full cadence. Recurrences advance from "now", intentionally collapsing a
|
|
666
|
+
// long outage into one catch-up occurrence instead of a dispatch storm.
|
|
667
|
+
runDueScheduledTasks();
|
|
668
|
+
return setInterval(() => runDueScheduledTasks(), ms);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
async function bodyObject(req: Request): Promise<Record<string, unknown>> {
|
|
672
|
+
try {
|
|
673
|
+
const value = await req.json();
|
|
674
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error();
|
|
675
|
+
return value as Record<string, unknown>;
|
|
676
|
+
} catch {
|
|
677
|
+
throw new Error("invalid_json");
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function writeAllowed(ctx: ScheduledRequestContext, networkId: string | null): boolean {
|
|
682
|
+
return !ctx.isNodeToken && canRestWriteNetwork(ctx.auth, networkId, ctx.isAdmin);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
export async function handleScheduledTaskRequest(ctx: ScheduledRequestContext): Promise<Response | null> {
|
|
686
|
+
const { req, url } = ctx;
|
|
687
|
+
if (url.pathname !== "/api/scheduled-tasks" && !url.pathname.startsWith("/api/scheduled-tasks/")) return null;
|
|
688
|
+
if (ctx.isNodeToken) return jsonError("user_token_required", 403);
|
|
689
|
+
|
|
690
|
+
if (url.pathname === "/api/scheduled-tasks" && req.method === "GET") {
|
|
691
|
+
const params: unknown[] = [];
|
|
692
|
+
let sql = `SELECT ${SCHEDULED_TASK_STORAGE_SELECT} FROM scheduled_tasks WHERE 1=1`;
|
|
693
|
+
sql = addNetworkScope(sql, params, ctx.scope);
|
|
694
|
+
const status = url.searchParams.get("status");
|
|
695
|
+
if (status) { sql += ` AND status = ?${params.length + 1}`; params.push(status); }
|
|
696
|
+
sql += " ORDER BY updated_at DESC LIMIT 500";
|
|
697
|
+
return Response.json({ ok: true, schedules: db.all<ScheduledRow>(sql, ...params).map(decodeRow) });
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
if (url.pathname === "/api/scheduled-tasks" && req.method === "POST") {
|
|
701
|
+
let body: Record<string, unknown>;
|
|
702
|
+
try { body = await bodyObject(req); } catch { return jsonError("invalid_json", 400); }
|
|
703
|
+
const networkId = resolveWriteNetwork(body, ctx);
|
|
704
|
+
if (!networkId) return jsonError("network_id_required", 400);
|
|
705
|
+
if (!writeAllowed(ctx, networkId)) return jsonError("permission_denied", 403);
|
|
706
|
+
try {
|
|
707
|
+
const created = insertSchedule(networkId, ctx.auth?.userId ?? null, null, validateScheduleCreate(networkId, body));
|
|
708
|
+
return Response.json({ ok: true, schedule: decodeRow(created) }, { status: 201 });
|
|
709
|
+
} catch (e: any) {
|
|
710
|
+
const code = String(e?.message || "invalid_schedule");
|
|
711
|
+
const status = code === "target_node_not_found" ? 404 : 400;
|
|
712
|
+
return jsonError(code, status);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
const match = url.pathname.match(/^\/api\/scheduled-tasks\/([^/]+)(?:\/(runs|run-now|cancel))?$/);
|
|
717
|
+
if (!match) return jsonError("not_found", 404);
|
|
718
|
+
const scheduleId = decodeURIComponent(match[1]);
|
|
719
|
+
const sub = match[2] || null;
|
|
720
|
+
const row = scopedSchedule(scheduleId, ctx.scope);
|
|
721
|
+
if (!row) return jsonError("schedule_not_found", 404);
|
|
722
|
+
|
|
723
|
+
if (sub === "runs" && req.method === "GET") {
|
|
724
|
+
const limit = Math.max(1, Math.min(200, Number(url.searchParams.get("limit")) || 50));
|
|
725
|
+
return Response.json({ ok: true, ...scheduleRuns(row, limit) });
|
|
726
|
+
}
|
|
727
|
+
if (!sub && req.method === "GET") return Response.json({ ok: true, schedule: decodeRow(row) });
|
|
728
|
+
if (!writeAllowed(ctx, row.network_id)) return jsonError("permission_denied", 403);
|
|
729
|
+
|
|
730
|
+
if (sub === "run-now" && req.method === "POST") {
|
|
731
|
+
if (row.status === "cancelled") return jsonError("schedule_cancelled", 409);
|
|
732
|
+
try {
|
|
733
|
+
const result = dispatchScheduledOccurrence(row, iso(new Date()), false);
|
|
734
|
+
return Response.json({ ok: true, ...result }, { status: result.status === "failed" ? 409 : 202 });
|
|
735
|
+
} catch (e: any) {
|
|
736
|
+
return jsonError("dispatch_failed", 409, { message: String(e?.message || e).slice(0, 500) });
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// Cancel — accept two spellings for the same operation:
|
|
741
|
+
// DELETE /api/scheduled-tasks/:id (original)
|
|
742
|
+
// POST /api/scheduled-tasks/:id/cancel (added because some reverse
|
|
743
|
+
// proxies swallow DELETE and
|
|
744
|
+
// return 405 HTML; POST is
|
|
745
|
+
// universally allowed)
|
|
746
|
+
// Idempotent: re-cancelling an already-cancelled row is a 200 no-op, not a
|
|
747
|
+
// 409, because a client that just saw the row (still on screen as
|
|
748
|
+
// "cancelled" between polls) should get the same outcome whether it's the
|
|
749
|
+
// first click or a retry.
|
|
750
|
+
if ((!sub && req.method === "DELETE") || (sub === "cancel" && req.method === "POST")) {
|
|
751
|
+
cancelSchedule(row);
|
|
752
|
+
return Response.json({ ok: true, status: "cancelled" });
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
if (!sub && req.method === "PATCH") {
|
|
756
|
+
let body: Record<string, unknown>;
|
|
757
|
+
try { body = await bodyObject(req); } catch { return jsonError("invalid_json", 400); }
|
|
758
|
+
try {
|
|
759
|
+
return Response.json({ ok: true, schedule: decodeRow(patchSchedule(row, body)) });
|
|
760
|
+
} catch (e: any) {
|
|
761
|
+
if (e instanceof ScheduleInputError) return jsonError(e.code, e.status, e.extra);
|
|
762
|
+
throw e;
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
return jsonError("method_not_allowed", 405);
|
|
766
|
+
}
|