bunqueue-dashboard 0.0.35 → 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.
Files changed (173) hide show
  1. package/README.md +18 -11
  2. package/agent/backup/processExecutor.ts +90 -0
  3. package/agent/backup/resultValidation.ts +85 -0
  4. package/agent/backup/routes.ts +160 -0
  5. package/agent/backup/runner.ts +91 -0
  6. package/agent/backup/standaloneWorker.ts +23 -0
  7. package/agent/backup/workerExecutor.ts +40 -0
  8. package/agent/backup/workerFactory.ts +21 -0
  9. package/agent/backup/workerProtocol.ts +10 -0
  10. package/agent/db/core.ts +141 -0
  11. package/agent/db/csvExport.ts +172 -0
  12. package/agent/db/exportTimeout.ts +190 -0
  13. package/agent/db/query.ts +82 -0
  14. package/agent/db/queryTimeout.ts +76 -0
  15. package/agent/db/schema.ts +79 -0
  16. package/agent/db/tables.ts +157 -0
  17. package/agent/db/types.ts +118 -0
  18. package/agent/db/workerFactory.ts +25 -0
  19. package/agent/db.ts +43 -1099
  20. package/agent/errorMessage.ts +9 -0
  21. package/agent/flow/routes.ts +250 -0
  22. package/agent/flow/service.ts +266 -0
  23. package/agent/flow/types.ts +34 -0
  24. package/agent/flow/validation.ts +230 -0
  25. package/agent/index.ts +5 -17
  26. package/agent/logger.ts +121 -17
  27. package/agent/managedTarget.ts +43 -0
  28. package/agent/manager/config.ts +84 -0
  29. package/agent/manager/logs.ts +83 -0
  30. package/agent/manager/process.ts +233 -0
  31. package/agent/manager/storage.ts +28 -0
  32. package/agent/manager/types.ts +38 -0
  33. package/agent/manager.ts +10 -490
  34. package/agent/queue/routes.ts +128 -0
  35. package/agent/queue/runtime.ts +119 -0
  36. package/agent/queue/types.ts +34 -0
  37. package/agent/queue/validation.ts +100 -0
  38. package/agent/server/controlRoutes.ts +153 -0
  39. package/agent/server/databaseRoutes.ts +165 -0
  40. package/agent/server/errors.ts +48 -0
  41. package/agent/server/handler.ts +146 -0
  42. package/agent/server/jsonBody.ts +109 -0
  43. package/agent/server/lifecycle.ts +72 -0
  44. package/agent/server/managedRuntime.ts +41 -0
  45. package/agent/server/policy.ts +60 -0
  46. package/agent/server/router.ts +150 -0
  47. package/agent/server/types.ts +39 -0
  48. package/agent/server/workflowReadRoutes.ts +80 -0
  49. package/agent/server.ts +16 -441
  50. package/agent/shutdown.ts +156 -0
  51. package/agent/workflow/codec.ts +78 -0
  52. package/agent/workflow/detail.ts +63 -0
  53. package/agent/workflow/list.ts +87 -0
  54. package/agent/workflow/runtime.ts +274 -0
  55. package/agent/workflow/runtimeConfig.ts +103 -0
  56. package/agent/workflow/runtimeRoutes.ts +162 -0
  57. package/agent/workflow/sqlite.ts +52 -0
  58. package/agent/workflow/stats.ts +79 -0
  59. package/agent/workflow/types.ts +81 -0
  60. package/agent/workflow/validation.ts +114 -0
  61. package/agent/workflows.ts +14 -489
  62. package/dist/.vite/manifest.json +595 -234
  63. package/dist/assets/AddJob-UUa2fSXE.js +3 -0
  64. package/dist/assets/Alerts-7oGYfQMb.js +1 -0
  65. package/dist/assets/{AreaChart-REK4ZsTC.js → AreaChart-w0ruozXH.js} +1 -1
  66. package/dist/assets/Benchmark-C2NFp_kj.js +1 -0
  67. package/dist/assets/BulkAddJobs-CL4yBae-.js +5 -0
  68. package/dist/assets/Button-BPqpRZJD.js +1 -0
  69. package/dist/assets/Card-C7NMhqUt.js +1 -0
  70. package/dist/assets/{CopilotPanel-COLw-atS.js → CopilotPanel-BQ2Cgo9p.js} +3 -3
  71. package/dist/assets/Cron-DfWBhoBf.js +1 -0
  72. package/dist/assets/CronManager-DAK7RSOX.js +1 -0
  73. package/dist/assets/Database-DqL7giEj.js +3 -0
  74. package/dist/assets/Diagnostics-D98YwN-6.js +1 -0
  75. package/dist/assets/Dlq-BHmA5PTI.js +1 -0
  76. package/dist/assets/DlqControl-DihxvP-i.js +1 -0
  77. package/dist/assets/DlqPro-ncgnDtpE.js +1 -0
  78. package/dist/assets/Flows-B5N5Cil0.js +2 -0
  79. package/dist/assets/JobInspector-B40nFiGh.js +2 -0
  80. package/dist/assets/Jobs-Bla3IEef.js +1 -0
  81. package/dist/assets/JobsPro-CnczVx7x.js +1 -0
  82. package/dist/assets/Logs-T91Q3cH0.js +1 -0
  83. package/dist/assets/LogsPro-CMn6-Wwv.js +1 -0
  84. package/dist/assets/{McpServer-CbaU52l2.js → McpServer-BQBabwW-.js} +2 -2
  85. package/dist/assets/Metrics-DxT_RI2B.js +1 -0
  86. package/dist/assets/MetricsPro-Cjen-nAP.js +1 -0
  87. package/dist/assets/Overview-XN2ej8ph.js +1 -0
  88. package/dist/assets/PageHeader-BXlZSBcc.js +1 -0
  89. package/dist/assets/Pagination-Q-vf4hsX.js +1 -0
  90. package/dist/assets/QueueActions-C-Aferd6.js +1 -0
  91. package/dist/assets/QueueControl-B8Q9NX6t.js +1 -0
  92. package/dist/assets/QueueDetail-BMhG0jUB.js +1 -0
  93. package/dist/assets/QueueDetailPro-BKTTyLR4.js +1 -0
  94. package/dist/assets/Queues-COqPMoY9.js +1 -0
  95. package/dist/assets/QueuesOverview-pWdrPy8Z.js +1 -0
  96. package/dist/assets/S3Backup-BmsZ8xtq.js +1 -0
  97. package/dist/assets/S3BackupPro-AtlsPUJL.js +3 -0
  98. package/dist/assets/ServerControl-D3XwHr72.js +3 -0
  99. package/dist/assets/Settings-BofKZoHn.js +1 -0
  100. package/dist/assets/StatusBadge-B0I7hNBX.js +1 -0
  101. package/dist/assets/Usage-DAR9E22v.js +1 -0
  102. package/dist/assets/UsagePro-Bc30N2Lo.js +1 -0
  103. package/dist/assets/Webhooks-Bg2k1PU4.js +1 -0
  104. package/dist/assets/Workers-BqUZMJVQ.js +1 -0
  105. package/dist/assets/WorkersPro-C9aSadUh.js +1 -0
  106. package/dist/assets/Workflows-BEFMfHmo.js +1 -0
  107. package/dist/assets/api-CQ5Z7xR7.js +1 -0
  108. package/dist/assets/bq-BeBxa743.js +1 -0
  109. package/dist/assets/cn-zFEt5SO8.js +1 -0
  110. package/dist/assets/connectionStore-DbBYqHEY.js +1 -0
  111. package/dist/assets/controlConnectionEpoch-CFSO6237.js +1 -0
  112. package/dist/assets/feedback-BnGE2oW2.js +1 -0
  113. package/dist/assets/{form-DsUq0w8s.js → form-tC20r4Ma.js} +1 -1
  114. package/dist/assets/format-Cg7VCIZW.js +1 -0
  115. package/dist/assets/icons-CIvFfU2b.js +1 -0
  116. package/dist/assets/index-Cui9IwWy.js +8 -0
  117. package/dist/assets/index-DOze4TKg.css +1 -0
  118. package/dist/assets/install-Cd9Is10G.js +3 -0
  119. package/dist/assets/{react-vendor-Bjui4wS4.js → react-vendor-CyjgmGTq.js} +3 -3
  120. package/dist/assets/responses-kPO4rSu5.js +1 -0
  121. package/dist/assets/toastStore-CS_9Xndn.js +1 -0
  122. package/dist/assets/usePolledData-DzlUpBOO.js +1 -0
  123. package/dist/assets/{useServerActionGuard-CItj3hvh.js → useServerActionGuard-BlH-Kso1.js} +1 -1
  124. package/dist/assets/useThroughputSeries-C2pa1Wd_.js +1 -0
  125. package/dist/index.html +16 -5
  126. package/package.json +12 -3
  127. package/scripts/embedded.gen.ts +152 -126
  128. package/scripts/serve.ts +96 -484
  129. package/scripts/serveHandler.ts +217 -0
  130. package/scripts/servePolicy.ts +133 -0
  131. package/dist/assets/AddJob-CG28MSxU.js +0 -3
  132. package/dist/assets/Alerts-D9JojJrj.js +0 -1
  133. package/dist/assets/Benchmark-BVjUH9dG.js +0 -1
  134. package/dist/assets/BulkAddJobs-BHzUCMj_.js +0 -5
  135. package/dist/assets/Cron-DoEHDHwy.js +0 -1
  136. package/dist/assets/CronManager-vulcesqJ.js +0 -1
  137. package/dist/assets/Database-DlB_t04Z.js +0 -3
  138. package/dist/assets/Diagnostics-DddCUgeR.js +0 -1
  139. package/dist/assets/Dlq-DMKrsSMP.js +0 -1
  140. package/dist/assets/DlqControl-9m0Re_TH.js +0 -1
  141. package/dist/assets/DlqPro-Dmw-0wta.js +0 -1
  142. package/dist/assets/Flows-C3-9nXW4.js +0 -1
  143. package/dist/assets/JobInspector-DwIfABmI.js +0 -2
  144. package/dist/assets/Jobs-j8tTS3NC.js +0 -1
  145. package/dist/assets/JobsPro-CS__CJJl.js +0 -1
  146. package/dist/assets/Logs-BB-VDPA1.js +0 -1
  147. package/dist/assets/LogsPro-RNtSQZRT.js +0 -1
  148. package/dist/assets/Metrics-ivcddPwT.js +0 -1
  149. package/dist/assets/MetricsPro-xjZno4vg.js +0 -1
  150. package/dist/assets/Overview-D84aD2ph.js +0 -1
  151. package/dist/assets/Pagination-Bfi4vkpp.js +0 -1
  152. package/dist/assets/QueueActions-DTbD6uek.js +0 -1
  153. package/dist/assets/QueueControl-B5xK_SQ-.js +0 -1
  154. package/dist/assets/QueueDetail-BH59kwm9.js +0 -1
  155. package/dist/assets/QueueDetailPro-DoN25ndy.js +0 -1
  156. package/dist/assets/Queues-CKMJqFHX.js +0 -1
  157. package/dist/assets/QueuesOverview-BX1lzojl.js +0 -1
  158. package/dist/assets/S3Backup-Bz3GfoZ9.js +0 -1
  159. package/dist/assets/S3BackupPro-BC_6-uX-.js +0 -2
  160. package/dist/assets/ServerControl-CSSrN8G5.js +0 -3
  161. package/dist/assets/Settings-DkqmZUIS.js +0 -1
  162. package/dist/assets/Usage-Ba20p0xd.js +0 -1
  163. package/dist/assets/UsagePro-C2pQwjN9.js +0 -1
  164. package/dist/assets/Webhooks-CVuuvb0L.js +0 -1
  165. package/dist/assets/Workers-C1wPU6CS.js +0 -1
  166. package/dist/assets/WorkersPro-B0vEkC0T.js +0 -1
  167. package/dist/assets/Workflows-C8Qmkflf.js +0 -1
  168. package/dist/assets/cn-CMFYpumL.js +0 -1
  169. package/dist/assets/connectionStore-Cd9-3Fw0.js +0 -1
  170. package/dist/assets/index-BCNzbhhD.css +0 -1
  171. package/dist/assets/index-CAtsVmyh.js +0 -8
  172. package/dist/assets/install-D0eRLf9F.js +0 -3
  173. package/dist/assets/useThroughputSeries-DjxGN8ob.js +0 -1
@@ -0,0 +1,119 @@
1
+ import { Queue, type QueueMetricType, type QueueMetrics } from 'bunqueue/client';
2
+ import { managedAuthToken } from '../managedTarget';
3
+ import type { ServerConfig } from '../manager';
4
+ import type { QueueLimitSnapshot, QueueOperationsPort } from './types';
5
+ import { QueueOperationsUnavailableError } from './types';
6
+
7
+ export type QueueOperationsClient = Pick<
8
+ Queue,
9
+ | 'waitUntilReady'
10
+ | 'getGlobalRateLimit'
11
+ | 'getGlobalConcurrency'
12
+ | 'getRateLimitTtl'
13
+ | 'isMaxed'
14
+ | 'getDeduplicationJobId'
15
+ | 'removeDeduplicationKey'
16
+ | 'getMetrics'
17
+ | 'trimEvents'
18
+ | 'close'
19
+ >;
20
+ export type QueueOperationsClientFactory = (
21
+ config: ServerConfig,
22
+ queue: string
23
+ ) => QueueOperationsClient;
24
+
25
+ /** Serializes operator reads and mutations and owns each dedicated TCP session. */
26
+ export class QueueOperationsRuntime implements QueueOperationsPort {
27
+ private gate: Promise<void> = Promise.resolve();
28
+ private closed = false;
29
+
30
+ constructor(private readonly createClient: QueueOperationsClientFactory = defaultClient) {}
31
+
32
+ limits(config: ServerConfig, queue: string, maxJobs?: number): Promise<QueueLimitSnapshot> {
33
+ return this.withQueue(config, queue, async (client) => {
34
+ const [rateLimit, concurrency, rateLimitTtl, maxed] = await Promise.all([
35
+ client.getGlobalRateLimit(),
36
+ client.getGlobalConcurrency(),
37
+ client.getRateLimitTtl(maxJobs),
38
+ client.isMaxed(),
39
+ ]);
40
+ return { rateLimit, concurrency, rateLimitTtl, maxed };
41
+ });
42
+ }
43
+
44
+ deduplicationJobId(
45
+ config: ServerConfig,
46
+ queue: string,
47
+ deduplicationId: string
48
+ ): Promise<string | null> {
49
+ return this.withQueue(config, queue, (client) =>
50
+ client.getDeduplicationJobId(deduplicationId)
51
+ );
52
+ }
53
+
54
+ removeDeduplicationKey(
55
+ config: ServerConfig,
56
+ queue: string,
57
+ deduplicationId: string
58
+ ): Promise<number> {
59
+ return this.withQueue(config, queue, (client) => client.removeDeduplicationKey(deduplicationId));
60
+ }
61
+
62
+ metrics(
63
+ config: ServerConfig,
64
+ queue: string,
65
+ type: QueueMetricType,
66
+ start: number,
67
+ end: number
68
+ ): Promise<QueueMetrics> {
69
+ return this.withQueue(config, queue, (client) => client.getMetrics(type, start, end));
70
+ }
71
+
72
+ trimEvents(config: ServerConfig, queue: string, maxLength: number): Promise<number> {
73
+ return this.withQueue(config, queue, (client) => client.trimEvents(maxLength));
74
+ }
75
+
76
+ async close(): Promise<void> {
77
+ this.closed = true;
78
+ await this.gate;
79
+ }
80
+
81
+ private withQueue<T>(
82
+ config: ServerConfig,
83
+ queue: string,
84
+ operation: (client: QueueOperationsClient) => Promise<T>
85
+ ): Promise<T> {
86
+ return this.serial(async () => {
87
+ const client = this.createClient(config, queue);
88
+ try {
89
+ await client.waitUntilReady();
90
+ return await operation(client);
91
+ } finally {
92
+ client.close();
93
+ }
94
+ });
95
+ }
96
+
97
+ private serial<T>(operation: () => Promise<T>): Promise<T> {
98
+ if (this.closed) {
99
+ return Promise.reject(new QueueOperationsUnavailableError('Queue operations runtime is closed'));
100
+ }
101
+ const result = this.gate.then(operation, operation);
102
+ this.gate = result.then(
103
+ () => undefined,
104
+ () => undefined
105
+ );
106
+ return result;
107
+ }
108
+ }
109
+
110
+ function connectionFor(config: ServerConfig) {
111
+ return { host: '127.0.0.1', port: config.tcpPort, token: managedAuthToken(config) };
112
+ }
113
+
114
+ function defaultClient(config: ServerConfig, queue: string): QueueOperationsClient {
115
+ return new Queue(queue, {
116
+ autoBatch: { enabled: false },
117
+ connection: { ...connectionFor(config), poolSize: 1 },
118
+ });
119
+ }
@@ -0,0 +1,34 @@
1
+ import type { QueueMetricType, QueueMetrics } from 'bunqueue/client';
2
+ import type { ServerConfig } from '../manager';
3
+
4
+ export interface QueueLimitSnapshot {
5
+ rateLimit: { max: number; duration: number } | null;
6
+ concurrency: number | null;
7
+ rateLimitTtl: number;
8
+ maxed: boolean;
9
+ }
10
+
11
+ export interface QueueOperationsPort {
12
+ limits(config: ServerConfig, queue: string, maxJobs?: number): Promise<QueueLimitSnapshot>;
13
+ deduplicationJobId(
14
+ config: ServerConfig,
15
+ queue: string,
16
+ deduplicationId: string
17
+ ): Promise<string | null>;
18
+ removeDeduplicationKey(
19
+ config: ServerConfig,
20
+ queue: string,
21
+ deduplicationId: string
22
+ ): Promise<number>;
23
+ metrics(
24
+ config: ServerConfig,
25
+ queue: string,
26
+ type: QueueMetricType,
27
+ start: number,
28
+ end: number
29
+ ): Promise<QueueMetrics>;
30
+ trimEvents(config: ServerConfig, queue: string, maxLength: number): Promise<number>;
31
+ close(): Promise<void>;
32
+ }
33
+
34
+ export class QueueOperationsUnavailableError extends Error {}
@@ -0,0 +1,100 @@
1
+ import { readLimitedJsonBody } from '../server/jsonBody';
2
+
3
+ const MAX_BODY_BYTES = 8 * 1024;
4
+ const MAX_PAGE_INDEX = 10_000;
5
+ const MAX_EVENT_RETENTION = 1_000_000;
6
+
7
+ export function exactQuery(url: string, allowed: readonly string[]): URLSearchParams {
8
+ const query = new URL(url).searchParams;
9
+ for (const key of query.keys()) {
10
+ if (!allowed.includes(key)) throw new Error(`Unknown Queue operation option: ${key}`);
11
+ if (query.getAll(key).length !== 1) {
12
+ throw new Error(`Duplicate Queue operation option: ${key}`);
13
+ }
14
+ }
15
+ return query;
16
+ }
17
+
18
+ export function validateQueueName(queue: string): string {
19
+ if (
20
+ !queue ||
21
+ queue === '.' ||
22
+ queue === '..' ||
23
+ queue.length > 256 ||
24
+ !/^[a-zA-Z0-9_\-.:]+$/.test(queue)
25
+ ) {
26
+ throw new Error('Queue name must contain 1-256 letters, numbers, underscores, dashes, dots or colons');
27
+ }
28
+ return queue;
29
+ }
30
+
31
+ export function requiredDeduplicationId(value: string | null): string {
32
+ if (!value || value.length > 1024) {
33
+ throw new Error('Deduplication ID must contain 1-1024 characters');
34
+ }
35
+ return value;
36
+ }
37
+
38
+ export function optionalMaxJobs(query: URLSearchParams): number | undefined {
39
+ const raw = query.get('maxJobs');
40
+ return raw === null ? undefined : boundedInteger(raw, 'maxJobs', 0, 1_000_000);
41
+ }
42
+
43
+ export function metricsRange(query: URLSearchParams): { start: number; end: number } {
44
+ const start = optionalInteger(query.get('start'), 'start', 0, MAX_PAGE_INDEX, 0);
45
+ const rawEnd = query.get('end');
46
+ const end = rawEnd === null ? -1 : boundedInteger(rawEnd, 'end', -1, MAX_PAGE_INDEX);
47
+ if (end !== -1 && end < start) throw new Error('Metrics end must be -1 or at least start');
48
+ return { start, end };
49
+ }
50
+
51
+ export function eventRetention(value: unknown): number {
52
+ return bodyInteger(value, 'maxLength', 0, MAX_EVENT_RETENTION);
53
+ }
54
+
55
+ export async function exactJsonBody(
56
+ request: Request,
57
+ allowed: readonly string[]
58
+ ): Promise<Record<string, unknown>> {
59
+ const value = await readLimitedJsonBody(request, {
60
+ scope: 'Queue operation',
61
+ maxBytes: MAX_BODY_BYTES,
62
+ limitLabel: '8 KiB',
63
+ missingMessage: 'Queue operation request must be valid JSON',
64
+ invalidJsonMessage: 'Queue operation request must be valid JSON',
65
+ });
66
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
67
+ throw new Error('Queue operation request must be an object');
68
+ }
69
+ const body = value as Record<string, unknown>;
70
+ const unknown = Object.keys(body).find((key) => !allowed.includes(key));
71
+ if (unknown) throw new Error(`Unknown Queue operation body field: ${unknown}`);
72
+ return body;
73
+ }
74
+
75
+ function optionalInteger(
76
+ value: string | null,
77
+ label: string,
78
+ minimum: number,
79
+ maximum: number,
80
+ fallback: number
81
+ ): number {
82
+ return value === null ? fallback : boundedInteger(value, label, minimum, maximum);
83
+ }
84
+
85
+ function boundedInteger(
86
+ raw: string,
87
+ label: string,
88
+ minimum: number,
89
+ maximum: number
90
+ ): number {
91
+ if (!/^-?\d+$/.test(raw)) throw new Error(`${label} must be an integer`);
92
+ return bodyInteger(Number(raw), label, minimum, maximum);
93
+ }
94
+
95
+ function bodyInteger(value: unknown, label: string, minimum: number, maximum: number): number {
96
+ if (!Number.isSafeInteger(value) || (value as number) < minimum || (value as number) > maximum) {
97
+ throw new Error(`${label} must be an integer from ${minimum} to ${maximum}`);
98
+ }
99
+ return value as number;
100
+ }
@@ -0,0 +1,153 @@
1
+ import { type ProcessManager, validateConfigPatch } from '../manager';
2
+ import { safeErrorMessage } from '../errorMessage';
3
+ import {
4
+ WorkflowRuntimeUnavailableError,
5
+ type WorkflowRuntimePort,
6
+ } from '../workflow/runtime';
7
+ import { readLimitedJsonBody } from './jsonBody';
8
+ import type { AgentLifecyclePort } from './lifecycle';
9
+ import type { RouteResponse } from './types';
10
+
11
+ const MAX_CONFIG_BODY_BYTES = 64 * 1024;
12
+
13
+ async function statusWithHealth(manager: ProcessManager) {
14
+ const snapshot = manager.getStatus();
15
+ let healthy = false;
16
+ let version: string | undefined;
17
+ if (snapshot.status === 'running') {
18
+ const port = snapshot.runningConfig?.httpPort ?? snapshot.config.httpPort;
19
+ try {
20
+ const response = await fetch(`http://127.0.0.1:${port}/health`, {
21
+ signal: AbortSignal.timeout(1500),
22
+ });
23
+ if (response.ok) {
24
+ const body: unknown = await response.json();
25
+ if (body !== null && typeof body === 'object' && !Array.isArray(body)) {
26
+ const health = body as Record<string, unknown>;
27
+ healthy = health.ok === true;
28
+ if (typeof health.version === 'string') version = health.version;
29
+ }
30
+ }
31
+ } catch {
32
+ // The managed process is not healthy yet.
33
+ }
34
+ }
35
+ const database = await manager.dbStats().catch(() => null);
36
+ return { ...snapshot, healthy, version, db: database };
37
+ }
38
+
39
+ export async function routeControlRequest(
40
+ request: Request,
41
+ pathname: string,
42
+ method: string,
43
+ manager: ProcessManager,
44
+ runtime: WorkflowRuntimePort,
45
+ lifecycle?: AgentLifecyclePort
46
+ ): Promise<RouteResponse | null> {
47
+ if (pathname === '/control/status') {
48
+ return { status: 200, body: await statusWithHealth(manager) };
49
+ }
50
+ if (pathname === '/control/logs') {
51
+ return { status: 200, body: { lines: manager.getLogs() } };
52
+ }
53
+ if (pathname === '/control/start' && method === 'POST') {
54
+ return coordinated(lifecycle, async () => {
55
+ await manager.start();
56
+ return { status: 200, body: await statusWithHealth(manager) };
57
+ });
58
+ }
59
+ if (pathname === '/control/stop' && method === 'POST') {
60
+ return coordinated(lifecycle, async () => {
61
+ await stopWithRuntimeCleanup(manager, runtime);
62
+ return { status: 200, body: await statusWithHealth(manager) };
63
+ });
64
+ }
65
+ if (pathname === '/control/restart' && method === 'POST') {
66
+ return coordinated(lifecycle, async () => {
67
+ const closeResult = await settle(() => runtime.close());
68
+ if (!closeResult.ok) {
69
+ await stopAfterCloseFailure(manager, workflowCloseError(closeResult.error));
70
+ }
71
+ await manager.restart();
72
+ return { status: 200, body: await statusWithHealth(manager) };
73
+ });
74
+ }
75
+ if (pathname === '/control/config' && method === 'GET') {
76
+ return { status: 200, body: manager.getConfig() };
77
+ }
78
+ if (pathname === '/control/config' && method === 'PUT') {
79
+ const patch = validateConfigPatch(
80
+ await readLimitedJsonBody(request, {
81
+ scope: 'Agent configuration',
82
+ maxBytes: MAX_CONFIG_BODY_BYTES,
83
+ limitLabel: '64 KiB',
84
+ })
85
+ );
86
+ return { status: 200, body: manager.setConfig(patch) };
87
+ }
88
+ return null;
89
+ }
90
+
91
+ async function stopWithRuntimeCleanup(
92
+ manager: ProcessManager,
93
+ runtime: WorkflowRuntimePort
94
+ ): Promise<void> {
95
+ const closeResult = await settle(() => runtime.close());
96
+ try {
97
+ await manager.stop();
98
+ } catch (stopFailure) {
99
+ if (!closeResult.ok) throw combinedShutdownError(closeResult.error, stopFailure);
100
+ throw stopFailure;
101
+ }
102
+ if (!closeResult.ok) throw workflowCloseError(closeResult.error);
103
+ }
104
+
105
+ async function stopAfterCloseFailure(
106
+ manager: ProcessManager,
107
+ closeFailure: unknown
108
+ ): Promise<never> {
109
+ try {
110
+ await manager.stop();
111
+ } catch (stopFailure) {
112
+ throw combinedShutdownError(closeFailure, stopFailure);
113
+ }
114
+ throw closeFailure;
115
+ }
116
+
117
+ function combinedShutdownError(closeFailure: unknown, stopFailure: unknown): Error {
118
+ return new WorkflowRuntimeUnavailableError(
119
+ `Workflow Engine shutdown failed (${message(closeFailure)}); ` +
120
+ `managed server stop also failed: ${message(stopFailure)}`
121
+ );
122
+ }
123
+
124
+ function workflowCloseError(error: unknown): WorkflowRuntimeUnavailableError {
125
+ try {
126
+ if (error instanceof WorkflowRuntimeUnavailableError) return error;
127
+ } catch {
128
+ // A hostile thrown Proxy must still become an unavailable-runtime error.
129
+ }
130
+ return new WorkflowRuntimeUnavailableError(`Workflow Engine shutdown failed: ${message(error)}`);
131
+ }
132
+
133
+ function message(error: unknown): string {
134
+ return safeErrorMessage(error);
135
+ }
136
+
137
+ function coordinated<T>(
138
+ lifecycle: AgentLifecyclePort | undefined,
139
+ operation: () => Promise<T>
140
+ ): Promise<T> {
141
+ return lifecycle ? lifecycle.run(operation) : operation();
142
+ }
143
+
144
+ type SettledOperation = { ok: true } | { ok: false; error: unknown };
145
+
146
+ async function settle(operation: () => Promise<unknown>): Promise<SettledOperation> {
147
+ try {
148
+ await operation();
149
+ return { ok: true };
150
+ } catch (error) {
151
+ return { ok: false, error };
152
+ }
153
+ }
@@ -0,0 +1,165 @@
1
+ import {
2
+ type DbFilter,
3
+ dbCell,
4
+ dbInfo,
5
+ dbRows,
6
+ dbSchema,
7
+ dbTables,
8
+ exportWithTimeout,
9
+ queryWithTimeout,
10
+ } from '../db';
11
+ import { corsHeaders } from './policy';
12
+ import { readLimitedJsonBody } from './jsonBody';
13
+ import type { RouteResponse } from './types';
14
+
15
+ const MAX_QUERY_BODY_BYTES = 64 * 1024;
16
+
17
+ function exportFilter(query: URLSearchParams): DbFilter | undefined {
18
+ const column = query.get('fcol');
19
+ const operation = query.get('fop');
20
+ const value = query.get('fval');
21
+ const hasPart = column !== null || operation !== null || value !== null;
22
+ if (!hasPart) return undefined;
23
+ if (
24
+ !column ||
25
+ !value ||
26
+ (operation !== 'contains' && operation !== 'eq' && operation !== 'ne')
27
+ ) {
28
+ throw new Error('Database export filter requires valid fcol, fop and fval values');
29
+ }
30
+ return { column, op: operation, value };
31
+ }
32
+
33
+ function validateExportQuery(query: URLSearchParams): void {
34
+ const allowed = new Set(['orderBy', 'dir', 'fcol', 'fop', 'fval']);
35
+ for (const key of query.keys()) {
36
+ if (!allowed.has(key)) throw new Error(`Unknown database export option: ${key}`);
37
+ if (query.getAll(key).length !== 1) {
38
+ throw new Error(`Duplicate database export option: ${key}`);
39
+ }
40
+ }
41
+ }
42
+
43
+ async function exportTable(
44
+ request: Request,
45
+ table: string,
46
+ dataPath: string,
47
+ origin: string | null,
48
+ allowedOrigins: string[]
49
+ ): Promise<Response> {
50
+ const query = new URL(request.url).searchParams;
51
+ validateExportQuery(query);
52
+ const orderBy = query.get('orderBy');
53
+ if (orderBy === '') throw new Error('Database export orderBy must not be empty');
54
+ const direction = query.get('dir');
55
+ if (direction !== null && direction !== 'asc' && direction !== 'desc') {
56
+ throw new Error('Database export dir must be "asc" or "desc"');
57
+ }
58
+ const exported = await exportWithTimeout(
59
+ dataPath,
60
+ table,
61
+ orderBy ?? undefined,
62
+ direction === 'desc' ? 'desc' : 'asc',
63
+ exportFilter(query),
64
+ request.signal
65
+ );
66
+ return new Response(exported.content, {
67
+ status: 200,
68
+ headers: {
69
+ 'Content-Type': 'text/csv; charset=utf-8',
70
+ 'Content-Length': String(exported.bytes),
71
+ 'Cache-Control': 'no-store, no-transform',
72
+ 'X-Content-Type-Options': 'nosniff',
73
+ 'X-Bunqueue-Db-Export-Version': '1',
74
+ 'X-Bunqueue-Db-Export-Table': encodeURIComponent(exported.table),
75
+ 'X-Bunqueue-Db-Export-Rows': String(exported.rowCount),
76
+ 'X-Bunqueue-Db-Export-Bytes': String(exported.bytes),
77
+ 'X-Bunqueue-Db-Export-Cap': exported.cap ?? 'none',
78
+ 'Access-Control-Expose-Headers':
79
+ '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',
80
+ ...corsHeaders(origin, allowedOrigins),
81
+ },
82
+ });
83
+ }
84
+
85
+ function browseFilter(query: URLSearchParams): DbFilter | undefined {
86
+ const column = query.get('fcol');
87
+ const operation = query.get('fop');
88
+ const value = query.get('fval');
89
+ if (!column || !value) return undefined;
90
+ return {
91
+ column,
92
+ op: operation === 'eq' ? 'eq' : operation === 'ne' ? 'ne' : 'contains',
93
+ value,
94
+ };
95
+ }
96
+
97
+ export async function routeDatabaseRequest(
98
+ request: Request,
99
+ pathname: string,
100
+ method: string,
101
+ dataPath: string,
102
+ origin: string | null,
103
+ allowedOrigins: string[]
104
+ ): Promise<RouteResponse | Response | null> {
105
+ if (pathname === '/db/info' && method === 'GET') {
106
+ return { status: 200, body: { ok: true, ...dbInfo(dataPath) } };
107
+ }
108
+ if (pathname === '/db/tables' && method === 'GET') {
109
+ return { status: 200, body: { ok: true, tables: dbTables(dataPath) } };
110
+ }
111
+ if (pathname.startsWith('/db/tables/') && method === 'GET') {
112
+ const segments = pathname.slice('/db/tables/'.length).split('/');
113
+ const table = decodeURIComponent(segments[0] ?? '');
114
+ const subresource = segments[1];
115
+ if (segments.length === 2 && subresource === 'export') {
116
+ return exportTable(request, table, dataPath, origin, allowedOrigins);
117
+ }
118
+ if (segments.length === 2 && subresource === 'schema') {
119
+ return { status: 200, body: { ok: true, ...dbSchema(dataPath, table) } };
120
+ }
121
+ if (segments.length === 2 && subresource === 'cell') {
122
+ const query = new URL(request.url).searchParams;
123
+ const rowid = query.get('rowid');
124
+ if (rowid === null) throw new Error('rowid is required');
125
+ return {
126
+ status: 200,
127
+ body: {
128
+ ok: true,
129
+ ...dbCell(dataPath, table, rowid, query.get('column') ?? ''),
130
+ },
131
+ };
132
+ }
133
+ if (segments.length !== 1) {
134
+ return { status: 404, body: { ok: false, error: 'Not found' } };
135
+ }
136
+ const query = new URL(request.url).searchParams;
137
+ return {
138
+ status: 200,
139
+ body: {
140
+ ok: true,
141
+ ...dbRows(
142
+ dataPath,
143
+ table,
144
+ Number(query.get('limit')) || 50,
145
+ Number(query.get('offset')) || 0,
146
+ query.get('orderBy') || undefined,
147
+ query.get('dir') === 'desc' ? 'desc' : 'asc',
148
+ browseFilter(query)
149
+ ),
150
+ },
151
+ };
152
+ }
153
+ if (pathname === '/db/query' && method === 'POST') {
154
+ const { sql } = (await readLimitedJsonBody(request, {
155
+ scope: 'Database query',
156
+ maxBytes: MAX_QUERY_BODY_BYTES,
157
+ limitLabel: '64 KiB',
158
+ })) as { sql?: string };
159
+ return {
160
+ status: 200,
161
+ body: { ok: true, ...(await queryWithTimeout(dataPath, sql ?? '')) },
162
+ };
163
+ }
164
+ return null;
165
+ }
@@ -0,0 +1,48 @@
1
+ import {
2
+ DbExportBusyError,
3
+ DbExportUnavailableError,
4
+ MissingDbError,
5
+ } from '../db';
6
+ import { safeErrorMessage } from '../errorMessage';
7
+ import { QueueOperationsUnavailableError } from '../queue/types';
8
+ import { WorkflowRuntimeUnavailableError } from '../workflow/runtime';
9
+ import { AgentLifecycleClosedError } from './lifecycle';
10
+
11
+ const CONFLICT_MESSAGES = [
12
+ 'Start the managed Bunqueue server',
13
+ 'Stop the managed Bunqueue server',
14
+ 'Another backup operation is already running',
15
+ 'Another maintenance operation is already running',
16
+ 'Cannot start the managed Bunqueue server',
17
+ 'Cannot restart the managed Bunqueue server',
18
+ 'BUNQUEUE_WORKFLOW_QUEUE_NAME must',
19
+ 'BUNQUEUE_WORKFLOW_CONCURRENCY must',
20
+ 'Managed Bunqueue server restarted',
21
+ ];
22
+
23
+ export function errorStatus(error: unknown): number {
24
+ try {
25
+ if (error instanceof AgentLifecycleClosedError) return 503;
26
+ if (error instanceof MissingDbError) return 404;
27
+ if (error instanceof DbExportBusyError) return 429;
28
+ if (error instanceof DbExportUnavailableError) return 503;
29
+ if (error instanceof QueueOperationsUnavailableError) {
30
+ return error.message.includes('runtime is closed') ? 503 : 409;
31
+ }
32
+ if (error instanceof WorkflowRuntimeUnavailableError) {
33
+ // Configuration shape is actionable by the caller; a missing/unreadable
34
+ // configured module is an unavailable server-side dependency.
35
+ return /^(Set BUNQUEUE_WORKFLOW_MODULE|BUNQUEUE_WORKFLOW_MODULE must|BUNQUEUE_WORKFLOW_QUEUE_NAME must|BUNQUEUE_WORKFLOW_CONCURRENCY must|Workflow module must|workflowNames must)/.test(error.message)
36
+ ? 409
37
+ : 503;
38
+ }
39
+ } catch {
40
+ return 500;
41
+ }
42
+ const message = safeErrorMessage(error);
43
+ return CONFLICT_MESSAGES.some((prefix) => message.startsWith(prefix)) ? 409 : 400;
44
+ }
45
+
46
+ export function errorMessage(error: unknown): string {
47
+ return safeErrorMessage(error);
48
+ }