bunqueue-dashboard 0.0.34 → 0.0.36
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 +20 -13
- package/agent/backup/processExecutor.ts +90 -0
- package/agent/backup/resultValidation.ts +85 -0
- package/agent/backup/routes.ts +160 -0
- package/agent/backup/runner.ts +91 -0
- package/agent/backup/standaloneWorker.ts +23 -0
- package/agent/backup/workerExecutor.ts +40 -0
- package/agent/backup/workerFactory.ts +21 -0
- package/agent/backup/workerProtocol.ts +10 -0
- package/agent/db/core.ts +141 -0
- package/agent/db/csvExport.ts +172 -0
- package/agent/db/exportTimeout.ts +190 -0
- package/agent/db/query.ts +82 -0
- package/agent/db/queryTimeout.ts +76 -0
- package/agent/db/schema.ts +79 -0
- package/agent/db/tables.ts +157 -0
- package/agent/db/types.ts +118 -0
- package/agent/db/workerFactory.ts +25 -0
- package/agent/db.ts +43 -1099
- package/agent/errorMessage.ts +9 -0
- package/agent/flow/routes.ts +250 -0
- package/agent/flow/service.ts +266 -0
- package/agent/flow/types.ts +34 -0
- package/agent/flow/validation.ts +230 -0
- package/agent/index.ts +5 -17
- package/agent/logger.ts +121 -17
- package/agent/managedTarget.ts +43 -0
- package/agent/manager/config.ts +84 -0
- package/agent/manager/logs.ts +83 -0
- package/agent/manager/process.ts +233 -0
- package/agent/manager/storage.ts +28 -0
- package/agent/manager/types.ts +38 -0
- package/agent/manager.ts +10 -490
- package/agent/queue/routes.ts +128 -0
- package/agent/queue/runtime.ts +119 -0
- package/agent/queue/types.ts +34 -0
- package/agent/queue/validation.ts +100 -0
- package/agent/server/controlRoutes.ts +153 -0
- package/agent/server/databaseRoutes.ts +165 -0
- package/agent/server/errors.ts +48 -0
- package/agent/server/handler.ts +146 -0
- package/agent/server/jsonBody.ts +109 -0
- package/agent/server/lifecycle.ts +72 -0
- package/agent/server/managedRuntime.ts +41 -0
- package/agent/server/policy.ts +60 -0
- package/agent/server/router.ts +150 -0
- package/agent/server/types.ts +39 -0
- package/agent/server/workflowReadRoutes.ts +80 -0
- package/agent/server.ts +16 -369
- package/agent/shutdown.ts +156 -0
- package/agent/workflow/codec.ts +78 -0
- package/agent/workflow/detail.ts +63 -0
- package/agent/workflow/list.ts +87 -0
- package/agent/workflow/runtime.ts +274 -0
- package/agent/workflow/runtimeConfig.ts +103 -0
- package/agent/workflow/runtimeRoutes.ts +162 -0
- package/agent/workflow/sqlite.ts +52 -0
- package/agent/workflow/stats.ts +79 -0
- package/agent/workflow/types.ts +81 -0
- package/agent/workflow/validation.ts +114 -0
- package/agent/workflows.ts +16 -0
- package/dist/.vite/manifest.json +619 -246
- package/dist/assets/AddJob-UUa2fSXE.js +3 -0
- package/dist/assets/Alerts-7oGYfQMb.js +1 -0
- package/dist/assets/{AreaChart-REK4ZsTC.js → AreaChart-w0ruozXH.js} +1 -1
- package/dist/assets/Benchmark-C2NFp_kj.js +1 -0
- package/dist/assets/BulkAddJobs-CL4yBae-.js +5 -0
- package/dist/assets/Button-BPqpRZJD.js +1 -0
- package/dist/assets/Card-C7NMhqUt.js +1 -0
- package/dist/assets/{CopilotPanel-CIXpcwkH.js → CopilotPanel-BQ2Cgo9p.js} +3 -3
- package/dist/assets/Cron-DfWBhoBf.js +1 -0
- package/dist/assets/CronManager-DAK7RSOX.js +1 -0
- package/dist/assets/Database-DqL7giEj.js +3 -0
- package/dist/assets/Diagnostics-D98YwN-6.js +1 -0
- package/dist/assets/Dlq-BHmA5PTI.js +1 -0
- package/dist/assets/DlqControl-DihxvP-i.js +1 -0
- package/dist/assets/DlqPro-ncgnDtpE.js +1 -0
- package/dist/assets/Flows-B5N5Cil0.js +2 -0
- package/dist/assets/JobInspector-B40nFiGh.js +2 -0
- package/dist/assets/Jobs-Bla3IEef.js +1 -0
- package/dist/assets/JobsPro-CnczVx7x.js +1 -0
- package/dist/assets/Logs-T91Q3cH0.js +1 -0
- package/dist/assets/LogsPro-CMn6-Wwv.js +1 -0
- package/dist/assets/{McpServer-D5rwBg74.js → McpServer-BQBabwW-.js} +2 -2
- package/dist/assets/Metrics-DxT_RI2B.js +1 -0
- package/dist/assets/MetricsPro-Cjen-nAP.js +1 -0
- package/dist/assets/Overview-XN2ej8ph.js +1 -0
- package/dist/assets/PageHeader-BXlZSBcc.js +1 -0
- package/dist/assets/Pagination-Q-vf4hsX.js +1 -0
- package/dist/assets/QueueActions-C-Aferd6.js +1 -0
- package/dist/assets/QueueControl-B8Q9NX6t.js +1 -0
- package/dist/assets/QueueDetail-BMhG0jUB.js +1 -0
- package/dist/assets/QueueDetailPro-BKTTyLR4.js +1 -0
- package/dist/assets/Queues-COqPMoY9.js +1 -0
- package/dist/assets/QueuesOverview-pWdrPy8Z.js +1 -0
- package/dist/assets/S3Backup-BmsZ8xtq.js +1 -0
- package/dist/assets/S3BackupPro-AtlsPUJL.js +3 -0
- package/dist/assets/ServerControl-D3XwHr72.js +3 -0
- package/dist/assets/Settings-BofKZoHn.js +1 -0
- package/dist/assets/StatusBadge-B0I7hNBX.js +1 -0
- package/dist/assets/Usage-DAR9E22v.js +1 -0
- package/dist/assets/UsagePro-Bc30N2Lo.js +1 -0
- package/dist/assets/Webhooks-Bg2k1PU4.js +1 -0
- package/dist/assets/Workers-BqUZMJVQ.js +1 -0
- package/dist/assets/WorkersPro-C9aSadUh.js +1 -0
- package/dist/assets/Workflows-BEFMfHmo.js +1 -0
- package/dist/assets/api-CQ5Z7xR7.js +1 -0
- package/dist/assets/bq-BeBxa743.js +1 -0
- package/dist/assets/cn-zFEt5SO8.js +1 -0
- package/dist/assets/connectionStore-DbBYqHEY.js +1 -0
- package/dist/assets/controlConnectionEpoch-CFSO6237.js +1 -0
- package/dist/assets/feedback-BnGE2oW2.js +1 -0
- package/dist/assets/{flowMutationSafety-DVD1IMr-.js → flowMutationSafety-CoOp94wS.js} +1 -1
- package/dist/assets/{form-DsUq0w8s.js → form-tC20r4Ma.js} +1 -1
- package/dist/assets/format-Cg7VCIZW.js +1 -0
- package/dist/assets/icons-CIvFfU2b.js +1 -0
- package/dist/assets/index-Cui9IwWy.js +8 -0
- package/dist/assets/index-DOze4TKg.css +1 -0
- package/dist/assets/install-Cd9Is10G.js +3 -0
- package/dist/assets/{react-vendor-Bjui4wS4.js → react-vendor-CyjgmGTq.js} +3 -3
- package/dist/assets/responses-kPO4rSu5.js +1 -0
- package/dist/assets/toastStore-CS_9Xndn.js +1 -0
- package/dist/assets/{upstreamPaths-nG8N1XAz.js → upstreamPaths-D_LPsv7b.js} +1 -1
- package/dist/assets/usePolledData-DzlUpBOO.js +1 -0
- package/dist/assets/{useServerActionGuard-CItj3hvh.js → useServerActionGuard-BlH-Kso1.js} +1 -1
- package/dist/assets/useThroughputSeries-C2pa1Wd_.js +1 -0
- package/dist/index.html +17 -6
- package/package.json +15 -3
- package/scripts/embedded.gen.ts +152 -124
- package/scripts/serve.ts +96 -484
- package/scripts/serveHandler.ts +217 -0
- package/scripts/servePolicy.ts +133 -0
- package/dist/assets/AddJob-Cih9F84t.js +0 -3
- package/dist/assets/Alerts-Dp4Vclez.js +0 -1
- package/dist/assets/Benchmark-X_gdZun5.js +0 -1
- package/dist/assets/BulkAddJobs-CXTpYMZf.js +0 -5
- package/dist/assets/Cron-DUHkse77.js +0 -1
- package/dist/assets/CronManager-BeYrLK2E.js +0 -1
- package/dist/assets/Database-CXPb3XGr.js +0 -3
- package/dist/assets/Diagnostics-DDtoiDYT.js +0 -1
- package/dist/assets/Dlq-CX_uRblW.js +0 -1
- package/dist/assets/DlqControl-CazFmSEd.js +0 -1
- package/dist/assets/DlqPro-HSffdtLJ.js +0 -1
- package/dist/assets/Flows-CGmyghKF.js +0 -1
- package/dist/assets/JobInspector-B9i1HnG5.js +0 -2
- package/dist/assets/Jobs-BZGtMnJI.js +0 -1
- package/dist/assets/JobsPro-tmEN8f-P.js +0 -1
- package/dist/assets/Logs-DxUD9V2A.js +0 -1
- package/dist/assets/LogsPro-BTQhvPKj.js +0 -1
- package/dist/assets/Metrics-CSPpBf1m.js +0 -1
- package/dist/assets/MetricsPro-DSn-3qNT.js +0 -1
- package/dist/assets/Overview-StvHYRZ5.js +0 -1
- package/dist/assets/Pagination-eotgGQf9.js +0 -1
- package/dist/assets/QueueActions-CibRLzrz.js +0 -1
- package/dist/assets/QueueControl-DwYNcMfI.js +0 -1
- package/dist/assets/QueueDetail-NMfKwj6E.js +0 -1
- package/dist/assets/QueueDetailPro-C8SBjX09.js +0 -1
- package/dist/assets/Queues-BeTAi8V8.js +0 -1
- package/dist/assets/QueuesOverview-BfQO8NS4.js +0 -1
- package/dist/assets/S3Backup-BYkAtONl.js +0 -1
- package/dist/assets/S3BackupPro-BP1Ch54R.js +0 -2
- package/dist/assets/ServerControl-C85jYgyN.js +0 -3
- package/dist/assets/Settings-DjP28c_5.js +0 -1
- package/dist/assets/Usage-DECeEsO2.js +0 -1
- package/dist/assets/UsagePro-CD7dgt3d.js +0 -1
- package/dist/assets/Webhooks-Ct-x8DD1.js +0 -1
- package/dist/assets/Workers-lP1BXHFq.js +0 -1
- package/dist/assets/WorkersPro-CAUqy3Cp.js +0 -1
- package/dist/assets/cn-CMFYpumL.js +0 -1
- package/dist/assets/connectionStore-Cd9-3Fw0.js +0 -1
- package/dist/assets/index-BWoUj86M.css +0 -1
- package/dist/assets/index-Dr__4DsT.js +0 -8
- package/dist/assets/install-DPCcfOYo.js +0 -3
- package/dist/assets/useThroughputSeries-Bpyg6Jaq.js +0 -1
package/agent/server.ts
CHANGED
|
@@ -1,372 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Request handling
|
|
3
|
-
* index.ts so it can be unit-tested without binding a port.
|
|
2
|
+
* Request handling and origin/auth policy for the control agent.
|
|
4
3
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* cross-origin request to http://127.0.0.1:6800 (CSRF → RCE). Defenses:
|
|
9
|
-
*
|
|
10
|
-
* 1. CORS is locked to an explicit allowlist (never `*`); the browser is only
|
|
11
|
-
* told a cross-origin response is readable for known dashboard origins.
|
|
12
|
-
* 2. Any request carrying a disallowed `Origin` header is rejected (403)
|
|
13
|
-
* before it can reach the ProcessManager — a cross-origin browser request
|
|
14
|
-
* always sends `Origin`, so a drive-by page cannot start/stop/reconfigure
|
|
15
|
-
* the server. Non-browser callers (curl, same-process) send no Origin and
|
|
16
|
-
* still work for local use.
|
|
17
|
-
* 3. Host-header allowlist (DNS-rebinding defense). The Origin gate does NOT
|
|
18
|
-
* cover a *same-origin* request: a page whose DNS name is rebound to
|
|
19
|
-
* 127.0.0.1 issues same-origin GETs that carry no `Origin` header, so it
|
|
20
|
-
* could otherwise read /control/status, /control/logs and the /db/*
|
|
21
|
-
* inspector (job payloads). When the agent is reachable beyond loopback,
|
|
22
|
-
* every route also requires the configured token; Host validation remains
|
|
23
|
-
* necessary because it rejects the rebound request before any data leaves.
|
|
24
|
-
* When `allowedHosts` is configured, a request whose `Host` header names a
|
|
25
|
-
* hostname outside the allowlist is rejected (403) — the rebinding page's
|
|
26
|
-
* `Host` is the attacker's domain, never a loopback/allowlisted host.
|
|
27
|
-
* 4. Optional bearer token (AGENT_TOKEN): on loopback, when set, changing
|
|
28
|
-
* requests must present it. A network-facing caller must set
|
|
29
|
-
* `requireTokenForAll`, which protects reads and writes alike.
|
|
30
|
-
*/
|
|
31
|
-
import {
|
|
32
|
-
type DbFilter,
|
|
33
|
-
dbCell,
|
|
34
|
-
DbExportBusyError,
|
|
35
|
-
DbExportUnavailableError,
|
|
36
|
-
dbInfo,
|
|
37
|
-
dbRows,
|
|
38
|
-
dbSchema,
|
|
39
|
-
dbTables,
|
|
40
|
-
exportWithTimeout,
|
|
41
|
-
MissingDbError,
|
|
42
|
-
queryWithTimeout,
|
|
43
|
-
} from './db';
|
|
44
|
-
import { type ProcessManager, validateConfigPatch } from './manager';
|
|
45
|
-
|
|
46
|
-
export interface AgentOptions {
|
|
47
|
-
allowedOrigins: string[];
|
|
48
|
-
/**
|
|
49
|
-
* DNS-rebinding defense: when set (non-empty), a request whose `Host` header
|
|
50
|
-
* resolves to a hostname outside this list is rejected. Compared by hostname
|
|
51
|
-
* only (port stripped). Leave undefined only for a loopback-only integration
|
|
52
|
-
* or when a trusted front proxy has already enforced Host. Network-facing
|
|
53
|
-
* listeners must configure this allowlist.
|
|
54
|
-
*/
|
|
55
|
-
allowedHosts?: string[];
|
|
56
|
-
/** On loopback, when set, POST/PUT requests must present this token. */
|
|
57
|
-
token?: string;
|
|
58
|
-
/** Network exposure: require `token` on every non-OPTIONS request, including reads. */
|
|
59
|
-
requireTokenForAll?: boolean;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
const DEFAULT_ORIGINS = ['http://localhost:5273', 'http://127.0.0.1:5273'];
|
|
63
|
-
|
|
64
|
-
/** Hostnames always trusted as loopback for the Host-header allowlist. */
|
|
65
|
-
const LOOPBACK_HOSTS = ['localhost', '127.0.0.1', '::1', '0.0.0.0'];
|
|
66
|
-
|
|
67
|
-
/** Parse AGENT_ALLOWED_ORIGINS (comma-separated) merged with sane dev defaults. */
|
|
68
|
-
export function resolveAllowedOrigins(env = process.env): string[] {
|
|
69
|
-
const extra = (env.AGENT_ALLOWED_ORIGINS ?? '')
|
|
70
|
-
.split(',')
|
|
71
|
-
.map((s) => s.trim().replace(/\/$/, ''))
|
|
72
|
-
.filter(Boolean);
|
|
73
|
-
return Array.from(new Set([...DEFAULT_ORIGINS, ...extra]));
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* Extract the bare hostname from a `Host` header or an origin/URL (strips a
|
|
78
|
-
* leading scheme and any port, unwraps IPv6 brackets, lowercases).
|
|
79
|
-
*/
|
|
80
|
-
export function hostnameOf(host: string): string {
|
|
81
|
-
const s = host.trim().replace(/^[a-z][a-z0-9+.-]*:\/\//i, ''); // drop scheme:// if present
|
|
82
|
-
const v6 = s.match(/^\[([^\]]+)\]/); // [::1]:6800 → ::1
|
|
83
|
-
if (v6) return v6[1].toLowerCase();
|
|
84
|
-
const i = s.indexOf(':');
|
|
85
|
-
if (i === -1) return s.toLowerCase();
|
|
86
|
-
// A bare (unbracketed) IPv6 literal has several colons and no port delimiter:
|
|
87
|
-
// splitting on the first one would both lock the real host out and allowlist a
|
|
88
|
-
// bogus single label ('2001:db8::5' → '2001'). Keep it whole — that also makes
|
|
89
|
-
// hostnameOf idempotent over its own bracket-stripped output.
|
|
90
|
-
if (s.indexOf(':') !== s.lastIndexOf(':')) return s.toLowerCase();
|
|
91
|
-
return s.slice(0, i).toLowerCase();
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* Loopback hostnames plus any from AGENT_ALLOWED_HOSTS (comma-separated) and
|
|
96
|
-
* `extra` (hostnames or full origins of the served/allowlisted origins).
|
|
97
|
-
* Reduced to bare hostnames, deduped.
|
|
98
|
-
*/
|
|
99
|
-
export function resolveAllowedHosts(env = process.env, extra: string[] = []): string[] {
|
|
100
|
-
const fromEnv = (env.AGENT_ALLOWED_HOSTS ?? '')
|
|
101
|
-
.split(',')
|
|
102
|
-
.map((s) => hostnameOf(s.trim()))
|
|
103
|
-
.filter(Boolean);
|
|
104
|
-
const extraHosts = extra.map((s) => hostnameOf(s)).filter(Boolean);
|
|
105
|
-
return Array.from(new Set([...LOOPBACK_HOSTS, ...extraHosts, ...fromEnv]));
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
export function isOriginAllowed(origin: string | null, allowed: string[]): boolean {
|
|
109
|
-
if (!origin) return true; // non-browser caller (curl / same process) — no Origin header
|
|
110
|
-
return allowed.includes(origin.replace(/\/$/, ''));
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* Host-header allowlist. Disabled (always true) when `allowed` is undefined.
|
|
115
|
-
* Once enabled it is fail-closed: a real HTTP request always has Host, and
|
|
116
|
-
* accepting a missing one would create an avoidable bypass for unusual proxy
|
|
117
|
-
* paths. A DNS-rebinding page sends its own domain as Host, so it fails too.
|
|
118
|
-
*/
|
|
119
|
-
export function isHostAllowed(host: string | null, allowed?: string[]): boolean {
|
|
120
|
-
if (!allowed) return true;
|
|
121
|
-
if (!host) return false;
|
|
122
|
-
return allowed.includes(hostnameOf(host));
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/** CORS headers. ACAO is reflected only for an allowed origin (never `*`). */
|
|
126
|
-
export function corsHeaders(origin: string | null, allowed: string[]): Record<string, string> {
|
|
127
|
-
const h: Record<string, string> = {
|
|
128
|
-
'Access-Control-Allow-Methods': 'GET, POST, PUT, OPTIONS',
|
|
129
|
-
'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Agent-Token',
|
|
130
|
-
Vary: 'Origin',
|
|
131
|
-
};
|
|
132
|
-
if (origin && allowed.includes(origin.replace(/\/$/, ''))) {
|
|
133
|
-
h['Access-Control-Allow-Origin'] = origin;
|
|
134
|
-
}
|
|
135
|
-
return h;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
function tokenOk(req: Request, token?: string): boolean {
|
|
139
|
-
if (!token) return false;
|
|
140
|
-
const auth = req.headers.get('authorization') ?? '';
|
|
141
|
-
const bearer = auth.startsWith('Bearer ') ? auth.slice(7) : '';
|
|
142
|
-
return bearer === token || req.headers.get('x-agent-token') === token;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
/**
|
|
146
|
-
* Build the fetch handler for the agent. Pure w.r.t. the network — takes a
|
|
147
|
-
* ProcessManager and policy, returns an async (req) => Response.
|
|
4
|
+
* The agent can spawn configurable commands, so callers must retain both the
|
|
5
|
+
* Origin and Host gates when binding it. Implementations are split under
|
|
6
|
+
* agent/server/; this file preserves the existing public import path.
|
|
148
7
|
*/
|
|
149
|
-
export
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
let version: string | undefined;
|
|
162
|
-
if (snap.status === 'running') {
|
|
163
|
-
const port = snap.runningConfig?.httpPort ?? snap.config.httpPort;
|
|
164
|
-
try {
|
|
165
|
-
const res = await fetch(`http://127.0.0.1:${port}/health`, {
|
|
166
|
-
signal: AbortSignal.timeout(1500),
|
|
167
|
-
});
|
|
168
|
-
if (res.ok) {
|
|
169
|
-
const body: unknown = await res.json();
|
|
170
|
-
if (body !== null && typeof body === 'object' && !Array.isArray(body)) {
|
|
171
|
-
const health = body as Record<string, unknown>;
|
|
172
|
-
// HTTP 2xx alone is not a Bunqueue health verdict: an unrelated
|
|
173
|
-
// process, proxy fallback, or malformed payload can also return it.
|
|
174
|
-
// Fail closed unless the semantic health flag is exactly true.
|
|
175
|
-
healthy = health.ok === true;
|
|
176
|
-
if (typeof health.version === 'string') version = health.version;
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
} catch {
|
|
180
|
-
/* not up yet */
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
const db = await mgr.dbStats().catch(() => null);
|
|
184
|
-
return { ...snap, healthy, version, db };
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
return async function handle(req: Request): Promise<Response> {
|
|
188
|
-
const { pathname } = new URL(req.url);
|
|
189
|
-
const method = req.method;
|
|
190
|
-
const origin = req.headers.get('origin');
|
|
191
|
-
|
|
192
|
-
// DNS-rebinding defense: reject a same-origin request whose Host header is
|
|
193
|
-
// an attacker domain rebound to loopback (no Origin header would be sent,
|
|
194
|
-
// so the Origin gate below can't see it). No-op when allowedHosts is unset.
|
|
195
|
-
if (!isHostAllowed(req.headers.get('host'), allowedHosts)) {
|
|
196
|
-
return json({ ok: false, error: 'Host not allowed' }, 403, origin);
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
// Block any request from a disallowed browser origin before it can act.
|
|
200
|
-
if (!isOriginAllowed(origin, allowedOrigins)) {
|
|
201
|
-
return json({ ok: false, error: 'Origin not allowed' }, 403, origin);
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
// Preflight never needs a bearer token, but Host and Origin are security
|
|
205
|
-
// boundaries in their own right and therefore fail closed before this 204.
|
|
206
|
-
if (method === 'OPTIONS') {
|
|
207
|
-
return new Response(null, { status: 204, headers: corsHeaders(origin, allowedOrigins) });
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
// Preserve zero-config LOOPBACK use: there, reads stay open and a configured
|
|
211
|
-
// token gates mutations. A network-facing listener sets requireTokenForAll,
|
|
212
|
-
// because Origin alone cannot authenticate curl/no-Origin callers and the db
|
|
213
|
-
// inspector contains job payloads. Misconfiguration fails closed: enabling
|
|
214
|
-
// the all-route gate without a token makes every real route answer 401.
|
|
215
|
-
const mutating = method === 'POST' || method === 'PUT';
|
|
216
|
-
const authRequired = requireTokenForAll || (mutating && Boolean(token));
|
|
217
|
-
if (authRequired && !tokenOk(req, token)) {
|
|
218
|
-
return json({ ok: false, error: 'Unauthorized' }, 401, origin);
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
try {
|
|
222
|
-
if (pathname === '/control/status') return json(await statusWithHealth(), 200, origin);
|
|
223
|
-
if (pathname === '/control/logs') return json({ lines: mgr.getLogs() }, 200, origin);
|
|
224
|
-
|
|
225
|
-
if (pathname === '/control/start' && method === 'POST') {
|
|
226
|
-
await mgr.start();
|
|
227
|
-
return json(await statusWithHealth(), 200, origin);
|
|
228
|
-
}
|
|
229
|
-
if (pathname === '/control/stop' && method === 'POST') {
|
|
230
|
-
await mgr.stop();
|
|
231
|
-
return json(await statusWithHealth(), 200, origin);
|
|
232
|
-
}
|
|
233
|
-
if (pathname === '/control/restart' && method === 'POST') {
|
|
234
|
-
await mgr.restart();
|
|
235
|
-
return json(await statusWithHealth(), 200, origin);
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
if (pathname === '/control/config' && method === 'GET') return json(mgr.getConfig(), 200, origin);
|
|
239
|
-
if (pathname === '/control/config' && method === 'PUT') {
|
|
240
|
-
const patch = validateConfigPatch(await req.json());
|
|
241
|
-
return json(mgr.setConfig(patch), 200, origin);
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
// Read-only SQLite inspector (agent/db.ts opens every connection
|
|
245
|
-
// readonly, so none of these can mutate the store). The POST query
|
|
246
|
-
// endpoint rides the same token gate as other mutating methods.
|
|
247
|
-
if (pathname === '/db/info' && method === 'GET') {
|
|
248
|
-
return json({ ok: true, ...dbInfo(mgr.getConfig().dataPath) }, 200, origin);
|
|
249
|
-
}
|
|
250
|
-
if (pathname === '/db/tables' && method === 'GET') {
|
|
251
|
-
return json({ ok: true, tables: dbTables(mgr.getConfig().dataPath) }, 200, origin);
|
|
252
|
-
}
|
|
253
|
-
// Segment-based routing (the client percent-encodes the table name, so it
|
|
254
|
-
// never contains a literal '/'): a single trailing segment is the rows
|
|
255
|
-
// route; `<table>/schema` and `<table>/cell` are sub-resources. This is
|
|
256
|
-
// unambiguous even for a table literally named "schema" or "cell".
|
|
257
|
-
if (pathname.startsWith('/db/tables/') && method === 'GET') {
|
|
258
|
-
const segs = pathname.slice('/db/tables/'.length).split('/');
|
|
259
|
-
const table = decodeURIComponent(segs[0] ?? '');
|
|
260
|
-
const sub = segs[1];
|
|
261
|
-
if (segs.length === 2 && sub === 'export') {
|
|
262
|
-
const sp = new URL(req.url).searchParams;
|
|
263
|
-
const allowedParams = new Set(['orderBy', 'dir', 'fcol', 'fop', 'fval']);
|
|
264
|
-
for (const key of sp.keys()) {
|
|
265
|
-
if (!allowedParams.has(key)) throw new Error(`Unknown database export option: ${key}`);
|
|
266
|
-
if (sp.getAll(key).length !== 1) {
|
|
267
|
-
throw new Error(`Duplicate database export option: ${key}`);
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
const orderByParam = sp.get('orderBy');
|
|
272
|
-
if (orderByParam === '') throw new Error('Database export orderBy must not be empty');
|
|
273
|
-
const dirParam = sp.get('dir');
|
|
274
|
-
if (dirParam !== null && dirParam !== 'asc' && dirParam !== 'desc') {
|
|
275
|
-
throw new Error('Database export dir must be "asc" or "desc"');
|
|
276
|
-
}
|
|
277
|
-
const fCol = sp.get('fcol');
|
|
278
|
-
const fOp = sp.get('fop');
|
|
279
|
-
const fVal = sp.get('fval');
|
|
280
|
-
const hasAnyFilterPart = fCol !== null || fOp !== null || fVal !== null;
|
|
281
|
-
let filter: DbFilter | undefined;
|
|
282
|
-
if (hasAnyFilterPart) {
|
|
283
|
-
if (!fCol || !fVal || (fOp !== 'contains' && fOp !== 'eq' && fOp !== 'ne')) {
|
|
284
|
-
throw new Error('Database export filter requires valid fcol, fop and fval values');
|
|
285
|
-
}
|
|
286
|
-
filter = { column: fCol, op: fOp, value: fVal };
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
const exported = await exportWithTimeout(
|
|
290
|
-
mgr.getConfig().dataPath,
|
|
291
|
-
table,
|
|
292
|
-
orderByParam ?? undefined,
|
|
293
|
-
dirParam === 'desc' ? 'desc' : 'asc',
|
|
294
|
-
filter,
|
|
295
|
-
req.signal
|
|
296
|
-
);
|
|
297
|
-
return new Response(exported.content, {
|
|
298
|
-
status: 200,
|
|
299
|
-
headers: {
|
|
300
|
-
'Content-Type': 'text/csv; charset=utf-8',
|
|
301
|
-
'Content-Length': String(exported.bytes),
|
|
302
|
-
// Keep Content-Length equal to the authoritative export-byte contract;
|
|
303
|
-
// intermediaries must not transparently recompress this body.
|
|
304
|
-
'Cache-Control': 'no-store, no-transform',
|
|
305
|
-
'X-Content-Type-Options': 'nosniff',
|
|
306
|
-
'X-Bunqueue-Db-Export-Version': '1',
|
|
307
|
-
'X-Bunqueue-Db-Export-Table': encodeURIComponent(exported.table),
|
|
308
|
-
'X-Bunqueue-Db-Export-Rows': String(exported.rowCount),
|
|
309
|
-
'X-Bunqueue-Db-Export-Bytes': String(exported.bytes),
|
|
310
|
-
'X-Bunqueue-Db-Export-Cap': exported.cap ?? 'none',
|
|
311
|
-
'Access-Control-Expose-Headers':
|
|
312
|
-
'Content-Length, X-Bunqueue-Db-Export-Version, X-Bunqueue-Db-Export-Table, X-Bunqueue-Db-Export-Rows, X-Bunqueue-Db-Export-Bytes, X-Bunqueue-Db-Export-Cap',
|
|
313
|
-
...corsHeaders(origin, allowedOrigins),
|
|
314
|
-
},
|
|
315
|
-
});
|
|
316
|
-
}
|
|
317
|
-
if (segs.length === 2 && sub === 'schema') {
|
|
318
|
-
return json({ ok: true, ...dbSchema(mgr.getConfig().dataPath, table) }, 200, origin);
|
|
319
|
-
}
|
|
320
|
-
if (segs.length === 2 && sub === 'cell') {
|
|
321
|
-
const sp = new URL(req.url).searchParams;
|
|
322
|
-
// Keep rowid textual through the HTTP boundary. Number() rounded
|
|
323
|
-
// values above 2^53 before SQLite ever saw the bound parameter.
|
|
324
|
-
const rowid = sp.get('rowid');
|
|
325
|
-
if (rowid === null) throw new Error('rowid is required');
|
|
326
|
-
const column = sp.get('column') ?? '';
|
|
327
|
-
return json(
|
|
328
|
-
{ ok: true, ...dbCell(mgr.getConfig().dataPath, table, rowid, column) },
|
|
329
|
-
200,
|
|
330
|
-
origin
|
|
331
|
-
);
|
|
332
|
-
}
|
|
333
|
-
if (segs.length !== 1) return json({ ok: false, error: 'Not found' }, 404, origin);
|
|
334
|
-
const sp = new URL(req.url).searchParams;
|
|
335
|
-
const limit = Number(sp.get('limit')) || 50;
|
|
336
|
-
const offset = Number(sp.get('offset')) || 0;
|
|
337
|
-
const orderBy = sp.get('orderBy') || undefined;
|
|
338
|
-
const dir = sp.get('dir') === 'desc' ? 'desc' : 'asc';
|
|
339
|
-
const fCol = sp.get('fcol');
|
|
340
|
-
const fOp = sp.get('fop');
|
|
341
|
-
const fVal = sp.get('fval');
|
|
342
|
-
const filter: DbFilter | undefined =
|
|
343
|
-
fCol && fVal
|
|
344
|
-
? { column: fCol, op: fOp === 'eq' ? 'eq' : fOp === 'ne' ? 'ne' : 'contains', value: fVal }
|
|
345
|
-
: undefined;
|
|
346
|
-
return json(
|
|
347
|
-
{ ok: true, ...dbRows(mgr.getConfig().dataPath, table, limit, offset, orderBy, dir, filter) },
|
|
348
|
-
200,
|
|
349
|
-
origin
|
|
350
|
-
);
|
|
351
|
-
}
|
|
352
|
-
if (pathname === '/db/query' && method === 'POST') {
|
|
353
|
-
const { sql } = (await req.json()) as { sql?: string };
|
|
354
|
-
return json({ ok: true, ...(await queryWithTimeout(mgr.getConfig().dataPath, sql ?? '')) }, 200, origin);
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
return json({ ok: false, error: 'Not found' }, 404, origin);
|
|
358
|
-
} catch (e) {
|
|
359
|
-
// A missing database file is an expected pre-first-start condition — 404
|
|
360
|
-
// so the UI can show "no database yet" distinctly from a real read error.
|
|
361
|
-
const status =
|
|
362
|
-
e instanceof MissingDbError
|
|
363
|
-
? 404
|
|
364
|
-
: e instanceof DbExportBusyError
|
|
365
|
-
? 429
|
|
366
|
-
: e instanceof DbExportUnavailableError
|
|
367
|
-
? 503
|
|
368
|
-
: 400;
|
|
369
|
-
return json({ ok: false, error: (e as Error).message ?? String(e) }, status, origin);
|
|
370
|
-
}
|
|
371
|
-
};
|
|
372
|
-
}
|
|
8
|
+
export { createFetchHandler } from './server/handler';
|
|
9
|
+
export { AgentLifecycleGate } from './server/lifecycle';
|
|
10
|
+
export type { AgentLifecyclePort } from './server/lifecycle';
|
|
11
|
+
export {
|
|
12
|
+
corsHeaders,
|
|
13
|
+
hostnameOf,
|
|
14
|
+
isHostAllowed,
|
|
15
|
+
isOriginAllowed,
|
|
16
|
+
resolveAllowedHosts,
|
|
17
|
+
resolveAllowedOrigins,
|
|
18
|
+
} from './server/policy';
|
|
19
|
+
export type { AgentFetchHandler, AgentOptions } from './server/types';
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { logger } from './logger';
|
|
2
|
+
import { safeErrorMessage } from './errorMessage';
|
|
3
|
+
import type { AgentFetchHandler } from './server';
|
|
4
|
+
|
|
5
|
+
const DEFAULT_GRACE_MS = 30_000;
|
|
6
|
+
|
|
7
|
+
export interface AgentShutdownOptions {
|
|
8
|
+
/** Stop listeners synchronously; returned promises drain in-flight HTTP requests. */
|
|
9
|
+
stopAccepting?: Array<() => unknown | Promise<unknown>>;
|
|
10
|
+
graceMs?: number;
|
|
11
|
+
/** Test seam; production uses process.exit. */
|
|
12
|
+
exit?: (code: number) => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Install one idempotent terminal shutdown path for agent entrypoints. */
|
|
16
|
+
export function installAgentShutdown(
|
|
17
|
+
handle: AgentFetchHandler,
|
|
18
|
+
options: AgentShutdownOptions = {}
|
|
19
|
+
): void {
|
|
20
|
+
const shutdown = createAgentShutdown(handle, options);
|
|
21
|
+
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
22
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Build the signal callback separately so ordering and exit codes are testable. */
|
|
26
|
+
export function createAgentShutdown(
|
|
27
|
+
handle: AgentFetchHandler,
|
|
28
|
+
options: AgentShutdownOptions = {}
|
|
29
|
+
): (signal: string) => void {
|
|
30
|
+
const exit = options.exit ?? ((code: number) => process.exit(code));
|
|
31
|
+
const graceMs = options.graceMs ?? DEFAULT_GRACE_MS;
|
|
32
|
+
let shuttingDown = false;
|
|
33
|
+
let finished = false;
|
|
34
|
+
let graceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
35
|
+
|
|
36
|
+
return (signal) => {
|
|
37
|
+
if (shuttingDown) {
|
|
38
|
+
logger.error({ signal }, 'second signal received, forcing agent shutdown');
|
|
39
|
+
force();
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
shuttingDown = true;
|
|
43
|
+
logger.info({ signal }, 'signal received, draining agent and managed server');
|
|
44
|
+
|
|
45
|
+
const drains = (options.stopAccepting ?? []).map(invoke);
|
|
46
|
+
const beginning = invoke(() => handle.beginShutdown());
|
|
47
|
+
void beginning.catch(() => undefined);
|
|
48
|
+
graceTimer = setTimeout(() => {
|
|
49
|
+
logger.error({ graceMs }, 'agent shutdown grace period expired');
|
|
50
|
+
force();
|
|
51
|
+
}, graceMs);
|
|
52
|
+
|
|
53
|
+
void finishShutdown(handle, beginning, drains).then(
|
|
54
|
+
() => finish(0),
|
|
55
|
+
(error) => {
|
|
56
|
+
logger.error({ err: errorDetails(error) }, 'agent shutdown completed with errors');
|
|
57
|
+
finish(1);
|
|
58
|
+
}
|
|
59
|
+
);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
function finish(code: number): void {
|
|
63
|
+
if (finished) return;
|
|
64
|
+
finished = true;
|
|
65
|
+
if (graceTimer) clearTimeout(graceTimer);
|
|
66
|
+
graceTimer = null;
|
|
67
|
+
exit(code);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function force(): void {
|
|
71
|
+
if (finished) return;
|
|
72
|
+
try {
|
|
73
|
+
handle.forceShutdown();
|
|
74
|
+
} catch (error) {
|
|
75
|
+
logger.error({ err: errorDetails(error) }, 'failed to force-stop managed server');
|
|
76
|
+
}
|
|
77
|
+
finish(1);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function finishShutdown(
|
|
82
|
+
handle: AgentFetchHandler,
|
|
83
|
+
beginning: Promise<unknown>,
|
|
84
|
+
drains: Promise<unknown>[]
|
|
85
|
+
): Promise<void> {
|
|
86
|
+
// Start managed shutdown immediately: long-lived /api event streams only
|
|
87
|
+
// finish after their upstream Bunqueue child stops, so waiting for HTTP
|
|
88
|
+
// drain first would deadlock terminal shutdown.
|
|
89
|
+
const shutdown = settled(() => handle.shutdown());
|
|
90
|
+
const drained = await Promise.allSettled([beginning, ...drains]);
|
|
91
|
+
const shutdownResult = await shutdown;
|
|
92
|
+
const failures: unknown[] = [];
|
|
93
|
+
for (const result of drained) {
|
|
94
|
+
if (result.status === 'rejected') addFailure(failures, result.reason);
|
|
95
|
+
}
|
|
96
|
+
if (!shutdownResult.ok) addFailure(failures, shutdownResult.error);
|
|
97
|
+
if (failures.length === 1) throw failures[0];
|
|
98
|
+
if (failures.length > 1) {
|
|
99
|
+
throw new AggregateError(failures, 'HTTP drain and agent shutdown failed');
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function addFailure(failures: unknown[], failure: unknown): void {
|
|
104
|
+
if (!failures.some((current) => Object.is(current, failure))) failures.push(failure);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function invoke(operation: () => unknown | Promise<unknown>): Promise<unknown> {
|
|
108
|
+
try {
|
|
109
|
+
return Promise.resolve(operation());
|
|
110
|
+
} catch (error) {
|
|
111
|
+
return Promise.reject(error);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
type SettledOperation = { ok: true } | { ok: false; error: unknown };
|
|
116
|
+
|
|
117
|
+
async function settled(operation: () => Promise<unknown>): Promise<SettledOperation> {
|
|
118
|
+
try {
|
|
119
|
+
await operation();
|
|
120
|
+
return { ok: true };
|
|
121
|
+
} catch (error) {
|
|
122
|
+
return { ok: false, error };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function errorDetails(error: unknown, seen = new Set<object>()): unknown {
|
|
127
|
+
try {
|
|
128
|
+
if (typeof error === 'object' && error !== null) {
|
|
129
|
+
if (seen.has(error)) return { type: 'circular', value: '[Circular rejection]' };
|
|
130
|
+
seen.add(error);
|
|
131
|
+
}
|
|
132
|
+
if (error instanceof AggregateError) {
|
|
133
|
+
return {
|
|
134
|
+
name: safeField(error.name),
|
|
135
|
+
message: safeErrorMessage(error),
|
|
136
|
+
stack: safeField(error.stack),
|
|
137
|
+
errors: error.errors.map((cause) => errorDetails(cause, seen)),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
if (error instanceof Error) {
|
|
141
|
+
return {
|
|
142
|
+
name: safeField(error.name),
|
|
143
|
+
message: safeErrorMessage(error),
|
|
144
|
+
stack: safeField(error.stack),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
return { type: error === null ? 'null' : typeof error, value: safeErrorMessage(error) };
|
|
148
|
+
} catch {
|
|
149
|
+
return { type: 'uninspectable', value: safeErrorMessage(error) };
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function safeField(value: unknown): string | undefined {
|
|
154
|
+
if (value === undefined) return undefined;
|
|
155
|
+
return safeErrorMessage(value);
|
|
156
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { Unpackr } from 'msgpackr';
|
|
2
|
+
import { WORKFLOW_LIMITS } from './types';
|
|
3
|
+
|
|
4
|
+
const unpackr = new Unpackr({ structuredClone: true });
|
|
5
|
+
|
|
6
|
+
export function decodeWorkflowValue(
|
|
7
|
+
blob: Uint8Array | null | undefined,
|
|
8
|
+
cap: number,
|
|
9
|
+
label: string
|
|
10
|
+
): unknown {
|
|
11
|
+
if (!blob) return null;
|
|
12
|
+
if (!(blob instanceof Uint8Array)) throw new Error(`Workflow ${label} is not a BLOB`);
|
|
13
|
+
if (blob.byteLength > cap) {
|
|
14
|
+
throw new Error(`Workflow ${label} exceeds the ${cap}-byte inspection limit`);
|
|
15
|
+
}
|
|
16
|
+
try {
|
|
17
|
+
return unpackr.unpack(blob);
|
|
18
|
+
} catch (error) {
|
|
19
|
+
throw new Error(`Could not decode workflow ${label}: ${(error as Error).message}`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function decodeWorkflowObject(
|
|
24
|
+
blob: Uint8Array | null | undefined,
|
|
25
|
+
cap: number,
|
|
26
|
+
label: string
|
|
27
|
+
): Record<string, unknown> {
|
|
28
|
+
const value = decodeWorkflowValue(blob, cap, label);
|
|
29
|
+
if (value == null) return {};
|
|
30
|
+
if (typeof value !== 'object' || Array.isArray(value)) {
|
|
31
|
+
throw new Error(`Workflow ${label} must decode to an object`);
|
|
32
|
+
}
|
|
33
|
+
return value as Record<string, unknown>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Convert decoded MessagePack extensions to a bounded, JSON-safe value. */
|
|
37
|
+
export function jsonSafeWorkflowValue(value: unknown): unknown {
|
|
38
|
+
let nodes = 0;
|
|
39
|
+
const seen = new WeakSet<object>();
|
|
40
|
+
const visit = (entry: unknown, depth: number): unknown => {
|
|
41
|
+
nodes++;
|
|
42
|
+
if (nodes > WORKFLOW_LIMITS.jsonNodes) throw new Error('Workflow detail exceeds the inspection node limit');
|
|
43
|
+
if (depth > WORKFLOW_LIMITS.jsonDepth) throw new Error('Workflow detail exceeds the inspection depth limit');
|
|
44
|
+
if (entry == null || typeof entry === 'boolean' || typeof entry === 'number') return entry;
|
|
45
|
+
if (typeof entry === 'string') {
|
|
46
|
+
if (entry.length > WORKFLOW_LIMITS.string) throw new Error('Workflow detail contains an oversized string');
|
|
47
|
+
return entry;
|
|
48
|
+
}
|
|
49
|
+
if (typeof entry === 'bigint') return entry.toString();
|
|
50
|
+
if (typeof entry === 'undefined') return null;
|
|
51
|
+
if (typeof entry !== 'object') return String(entry);
|
|
52
|
+
if (seen.has(entry)) throw new Error('Workflow detail contains a cyclic value');
|
|
53
|
+
seen.add(entry);
|
|
54
|
+
try {
|
|
55
|
+
if (entry instanceof Date) return entry.toISOString();
|
|
56
|
+
if (entry instanceof Uint8Array) return `<binary ${entry.byteLength} B>`;
|
|
57
|
+
if (entry instanceof Map) return mapValue(entry, visit, depth);
|
|
58
|
+
if (entry instanceof Set) return [...entry].map((item) => visit(item, depth + 1));
|
|
59
|
+
if (Array.isArray(entry)) return entry.map((item) => visit(item, depth + 1));
|
|
60
|
+
const out = Object.create(null) as Record<string, unknown>;
|
|
61
|
+
for (const [key, item] of Object.entries(entry)) out[key] = visit(item, depth + 1);
|
|
62
|
+
return out;
|
|
63
|
+
} finally {
|
|
64
|
+
seen.delete(entry);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
return visit(value, 0);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function mapValue(
|
|
71
|
+
map: Map<unknown, unknown>,
|
|
72
|
+
visit: (entry: unknown, depth: number) => unknown,
|
|
73
|
+
depth: number
|
|
74
|
+
): Record<string, unknown> {
|
|
75
|
+
const out = Object.create(null) as Record<string, unknown>;
|
|
76
|
+
for (const [key, item] of map) out[String(key)] = visit(item, depth + 1);
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { decodeWorkflowObject, decodeWorkflowValue, jsonSafeWorkflowValue } from './codec';
|
|
2
|
+
import { openWorkflowDatabase, workflowSchema } from './sqlite';
|
|
3
|
+
import {
|
|
4
|
+
WORKFLOW_LIMITS,
|
|
5
|
+
type WorkflowExecutionDetail,
|
|
6
|
+
type WorkflowRow,
|
|
7
|
+
type WorkflowStoreKind,
|
|
8
|
+
} from './types';
|
|
9
|
+
import { assertWorkflowRow, assertWorkflowSteps } from './validation';
|
|
10
|
+
|
|
11
|
+
interface WorkflowDetailRow extends WorkflowRow {
|
|
12
|
+
input: Uint8Array | null;
|
|
13
|
+
steps: Uint8Array | null;
|
|
14
|
+
resolved_steps: Uint8Array | null;
|
|
15
|
+
signals: Uint8Array | null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function workflowExecution(
|
|
19
|
+
path: string,
|
|
20
|
+
id: string,
|
|
21
|
+
kind: WorkflowStoreKind = 'active'
|
|
22
|
+
): WorkflowExecutionDetail | null {
|
|
23
|
+
if (!id || id.length > 1024) throw new Error('Workflow execution id must contain 1–1024 characters');
|
|
24
|
+
const db = openWorkflowDatabase(path);
|
|
25
|
+
try {
|
|
26
|
+
const info = workflowSchema(db, kind);
|
|
27
|
+
if (!info) return null;
|
|
28
|
+
const row = db.query(`SELECT * FROM ${info.table} WHERE id = ?`).get(id) as WorkflowDetailRow | null;
|
|
29
|
+
if (!row) return null;
|
|
30
|
+
const summary = assertWorkflowRow(row);
|
|
31
|
+
const input = safeDecode(row.input, 'input');
|
|
32
|
+
const steps = assertWorkflowSteps(safeObject(row.steps, 'steps'));
|
|
33
|
+
const resolved = safeDecode(row.resolved_steps, 'resolved steps');
|
|
34
|
+
const signals = safeObject(row.signals, 'signals');
|
|
35
|
+
const meta = decodeWorkflowObject(row.meta, WORKFLOW_LIMITS.metaBlob, 'meta');
|
|
36
|
+
if (resolved != null && (!Array.isArray(resolved) || resolved.some((item) => typeof item !== 'string'))) {
|
|
37
|
+
throw new Error('Workflow resolved steps must decode to a string array');
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
...summary,
|
|
41
|
+
input,
|
|
42
|
+
steps,
|
|
43
|
+
...(resolved ? { resolvedSteps: resolved as string[] } : {}),
|
|
44
|
+
signals,
|
|
45
|
+
...(meta.decisions && typeof meta.decisions === 'object'
|
|
46
|
+
? { decisions: jsonSafeWorkflowValue(meta.decisions) as Record<string, unknown> }
|
|
47
|
+
: {}),
|
|
48
|
+
...(typeof meta.committedAt === 'number' ? { committedAt: meta.committedAt } : {}),
|
|
49
|
+
};
|
|
50
|
+
} finally {
|
|
51
|
+
db.close();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function safeDecode(blob: Uint8Array | null, label: string): unknown {
|
|
56
|
+
return jsonSafeWorkflowValue(decodeWorkflowValue(blob, WORKFLOW_LIMITS.detailBlob, label));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function safeObject(blob: Uint8Array | null, label: string): Record<string, unknown> {
|
|
60
|
+
return jsonSafeWorkflowValue(
|
|
61
|
+
decodeWorkflowObject(blob, WORKFLOW_LIMITS.detailBlob, label)
|
|
62
|
+
) as Record<string, unknown>;
|
|
63
|
+
}
|