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,146 @@
1
+ import { BunqueueBackupRunner, type BackupRunnerPort } from '../backup/runner';
2
+ import type { ProcessManager } from '../manager';
3
+ import { QueueOperationsRuntime } from '../queue/runtime';
4
+ import type { QueueOperationsPort } from '../queue/types';
5
+ import { WorkflowRuntime, type WorkflowRuntimePort } from '../workflow/runtime';
6
+ import { errorMessage, errorStatus } from './errors';
7
+ import { AgentLifecycleGate, type AgentLifecyclePort } from './lifecycle';
8
+ import { corsHeaders, isHostAllowed, isOriginAllowed, tokenOk } from './policy';
9
+ import { routeAgentRequest } from './router';
10
+ import type { AgentFetchHandler, AgentOptions, RouteResponse } from './types';
11
+
12
+ function responseOf(
13
+ result: RouteResponse | Response | null,
14
+ origin: string | null,
15
+ allowedOrigins: string[]
16
+ ): Response {
17
+ if (result instanceof Response) return result;
18
+ const routed = result ?? { status: 404, body: { ok: false, error: 'Not found' } };
19
+ return new Response(JSON.stringify(routed.body), {
20
+ status: routed.status,
21
+ headers: {
22
+ 'Content-Type': 'application/json',
23
+ ...corsHeaders(origin, allowedOrigins),
24
+ },
25
+ });
26
+ }
27
+
28
+ /** Build a network-independent request handler with persistent SDK resources. */
29
+ export function createFetchHandler(
30
+ manager: ProcessManager,
31
+ options: AgentOptions,
32
+ runtime: WorkflowRuntimePort = new WorkflowRuntime(),
33
+ backupRunner: BackupRunnerPort = new BunqueueBackupRunner(),
34
+ queueRuntime: QueueOperationsPort = new QueueOperationsRuntime(),
35
+ lifecycle: AgentLifecyclePort = new AgentLifecycleGate()
36
+ ): AgentFetchHandler {
37
+ const {
38
+ allowedOrigins,
39
+ allowedHosts,
40
+ token,
41
+ requireTokenForAll = false,
42
+ } = options;
43
+ const json = (body: unknown, status: number, origin: string | null) =>
44
+ responseOf({ body, status }, origin, allowedOrigins);
45
+
46
+ const handle = async (request: Request): Promise<Response> => {
47
+ const { pathname } = new URL(request.url);
48
+ const method = request.method;
49
+ const origin = request.headers.get('origin');
50
+ if (!isHostAllowed(request.headers.get('host'), allowedHosts)) {
51
+ return json({ ok: false, error: 'Host not allowed' }, 403, origin);
52
+ }
53
+ if (!isOriginAllowed(origin, allowedOrigins)) {
54
+ return json({ ok: false, error: 'Origin not allowed' }, 403, origin);
55
+ }
56
+ if (method === 'OPTIONS') {
57
+ return new Response(null, { status: 204, headers: corsHeaders(origin, allowedOrigins) });
58
+ }
59
+ const mutating = method === 'POST' || method === 'PUT';
60
+ const authRequired = requireTokenForAll || (mutating && Boolean(token));
61
+ if (authRequired && !tokenOk(request, token)) {
62
+ return json({ ok: false, error: 'Unauthorized' }, 401, origin);
63
+ }
64
+
65
+ try {
66
+ lifecycle.assertOpen();
67
+ const result = await routeAgentRequest(
68
+ request,
69
+ pathname,
70
+ method,
71
+ manager,
72
+ runtime,
73
+ backupRunner,
74
+ queueRuntime,
75
+ lifecycle,
76
+ origin,
77
+ allowedOrigins
78
+ );
79
+ return responseOf(result, origin, allowedOrigins);
80
+ } catch (error) {
81
+ return json({ ok: false, error: errorMessage(error) }, errorStatus(error), origin);
82
+ }
83
+ };
84
+ let closing: Promise<void> | null = null;
85
+ const close = () => {
86
+ closing ??= lifecycle.close(() =>
87
+ settleResourceClosures([
88
+ () => runtime.close(),
89
+ () => backupRunner.close(),
90
+ () => queueRuntime.close(),
91
+ ])
92
+ );
93
+ return closing;
94
+ };
95
+ const beginShutdown = () => {
96
+ manager.beginShutdown();
97
+ return close();
98
+ };
99
+ let shuttingDown: Promise<void> | null = null;
100
+ const shutdown = () => {
101
+ shuttingDown ??= closeThenShutdown(beginShutdown, manager);
102
+ return shuttingDown;
103
+ };
104
+ const forceShutdown = () => manager.forceShutdown();
105
+ return Object.assign(handle, { close, beginShutdown, shutdown, forceShutdown });
106
+ }
107
+
108
+ async function closeThenShutdown(
109
+ beginShutdown: () => Promise<void>,
110
+ manager: ProcessManager
111
+ ): Promise<void> {
112
+ const closeResult = await settle(beginShutdown);
113
+ const shutdownResult = await settle(() => manager.shutdown());
114
+ if (!closeResult.ok && !shutdownResult.ok) {
115
+ throw new AggregateError(
116
+ [closeResult.error, shutdownResult.error],
117
+ 'Agent resources and managed server both failed to shut down'
118
+ );
119
+ }
120
+ if (!closeResult.ok) throw closeResult.error;
121
+ if (!shutdownResult.ok) throw shutdownResult.error;
122
+ }
123
+
124
+ async function settleResourceClosures(closures: Array<() => Promise<void>>): Promise<void> {
125
+ const settled = await Promise.allSettled(
126
+ closures.map((closeResource) => Promise.resolve().then(closeResource))
127
+ );
128
+ const failures = settled.flatMap((result) =>
129
+ result.status === 'rejected' ? [result.reason] : []
130
+ );
131
+ if (failures.length === 1) throw failures[0];
132
+ if (failures.length > 1) {
133
+ throw new AggregateError(failures, 'Multiple agent resources failed to close');
134
+ }
135
+ }
136
+
137
+ type SettledOperation = { ok: true } | { ok: false; error: unknown };
138
+
139
+ async function settle(operation: () => Promise<unknown>): Promise<SettledOperation> {
140
+ try {
141
+ await operation();
142
+ return { ok: true };
143
+ } catch (error) {
144
+ return { ok: false, error };
145
+ }
146
+ }
@@ -0,0 +1,109 @@
1
+ export interface LimitedJsonBodyOptions {
2
+ scope: string;
3
+ maxBytes: number;
4
+ limitLabel: string;
5
+ invalidContentLengthMessage?: string;
6
+ exceedsMessage?: string;
7
+ missingMessage?: string;
8
+ invalidUtf8Message?: string;
9
+ invalidJsonMessage?: string;
10
+ }
11
+
12
+ /** Read and parse a JSON body without ever buffering more than maxBytes. */
13
+ export async function readLimitedJsonBody(
14
+ request: Request,
15
+ options: LimitedJsonBodyOptions
16
+ ): Promise<unknown> {
17
+ validateContentLength(request.headers.get('content-length'), options);
18
+ if (!request.body) throw new Error(message(options, 'missing'));
19
+
20
+ const reader = request.body.getReader();
21
+ const decoder = new TextDecoder('utf-8', { fatal: true });
22
+ const textChunks: string[] = [];
23
+ let totalBytes = 0;
24
+ try {
25
+ for (;;) {
26
+ const next = await reader.read();
27
+ if (next.done) break;
28
+ totalBytes += next.value.byteLength;
29
+ if (totalBytes > options.maxBytes) {
30
+ throw new Error(message(options, 'exceeds'));
31
+ }
32
+ textChunks.push(decode(decoder, next.value, true, options));
33
+ }
34
+ textChunks.push(decode(decoder, undefined, false, options));
35
+ } catch (error) {
36
+ await cancelQuietly(reader);
37
+ throw error;
38
+ } finally {
39
+ reader.releaseLock();
40
+ }
41
+
42
+ if (totalBytes === 0) throw new Error(message(options, 'missing'));
43
+ try {
44
+ return JSON.parse(textChunks.join(''));
45
+ } catch {
46
+ throw new Error(message(options, 'invalidJson'));
47
+ }
48
+ }
49
+
50
+ function validateContentLength(
51
+ declared: string | null,
52
+ options: LimitedJsonBodyOptions
53
+ ): void {
54
+ if (declared === null) return;
55
+ if (!/^\d+$/.test(declared)) {
56
+ throw new Error(message(options, 'invalidContentLength'));
57
+ }
58
+ const normalized = declared.replace(/^0+/, '') || '0';
59
+ const maximum = String(options.maxBytes);
60
+ if (
61
+ normalized.length > maximum.length ||
62
+ (normalized.length === maximum.length && normalized > maximum)
63
+ ) {
64
+ throw new Error(message(options, 'exceeds'));
65
+ }
66
+ }
67
+
68
+ function decode(
69
+ decoder: TextDecoder,
70
+ value: Uint8Array | undefined,
71
+ stream: boolean,
72
+ options: LimitedJsonBodyOptions
73
+ ): string {
74
+ try {
75
+ return decoder.decode(value, { stream });
76
+ } catch {
77
+ throw new Error(message(options, 'invalidUtf8'));
78
+ }
79
+ }
80
+
81
+ async function cancelQuietly(reader: { cancel(reason?: unknown): Promise<void> }): Promise<void> {
82
+ await reader.cancel().catch(() => undefined);
83
+ }
84
+
85
+ type MessageKind =
86
+ | 'invalidContentLength'
87
+ | 'exceeds'
88
+ | 'missing'
89
+ | 'invalidUtf8'
90
+ | 'invalidJson';
91
+
92
+ function message(options: LimitedJsonBodyOptions, kind: MessageKind): string {
93
+ if (kind === 'invalidContentLength') {
94
+ return (
95
+ options.invalidContentLengthMessage ??
96
+ `${options.scope} Content-Length must be a non-negative integer`
97
+ );
98
+ }
99
+ if (kind === 'exceeds') {
100
+ return options.exceedsMessage ?? `${options.scope} request exceeds ${options.limitLabel}`;
101
+ }
102
+ if (kind === 'missing') {
103
+ return options.missingMessage ?? `${options.scope} request body is required`;
104
+ }
105
+ if (kind === 'invalidUtf8') {
106
+ return options.invalidUtf8Message ?? `${options.scope} request body must use valid UTF-8`;
107
+ }
108
+ return options.invalidJsonMessage ?? `${options.scope} request body must contain valid JSON`;
109
+ }
@@ -0,0 +1,72 @@
1
+ /** Coordinates exclusive process transitions with concurrent SDK leases. */
2
+ export interface AgentLifecyclePort {
3
+ assertOpen(): void;
4
+ lease<T>(operation: () => Promise<T>): Promise<T>;
5
+ run<T>(operation: () => Promise<T>): Promise<T>;
6
+ close(operation: () => Promise<void>): Promise<void>;
7
+ }
8
+
9
+ export class AgentLifecycleClosedError extends Error {
10
+ constructor() {
11
+ super('The Bunqueue control agent is closing');
12
+ this.name = 'AgentLifecycleClosedError';
13
+ }
14
+ }
15
+
16
+ export class AgentLifecycleGate implements AgentLifecyclePort {
17
+ private barrier: Promise<void> = Promise.resolve();
18
+ private leases = new Set<Promise<void>>();
19
+ private closed = false;
20
+ private closing: Promise<void> | null = null;
21
+
22
+ assertOpen(): void {
23
+ if (this.closed) throw new AgentLifecycleClosedError();
24
+ }
25
+
26
+ lease<T>(operation: () => Promise<T>): Promise<T> {
27
+ try {
28
+ this.assertOpen();
29
+ } catch (error) {
30
+ return Promise.reject(error);
31
+ }
32
+ const result = this.barrier.then(operation);
33
+ const batch = this.leases;
34
+ const completion = result.then(
35
+ () => undefined,
36
+ () => undefined
37
+ );
38
+ batch.add(completion);
39
+ void completion.then(() => batch.delete(completion));
40
+ return result;
41
+ }
42
+
43
+ run<T>(operation: () => Promise<T>): Promise<T> {
44
+ try {
45
+ this.assertOpen();
46
+ } catch (error) {
47
+ return Promise.reject(error);
48
+ }
49
+ return this.enqueueTransition(operation);
50
+ }
51
+
52
+ close(operation: () => Promise<void>): Promise<void> {
53
+ if (this.closing) return this.closing;
54
+ this.closed = true;
55
+ this.closing = this.enqueueTransition(operation);
56
+ return this.closing;
57
+ }
58
+
59
+ private enqueueTransition<T>(operation: () => Promise<T>): Promise<T> {
60
+ const precedingBarrier = this.barrier;
61
+ const precedingLeases = this.leases;
62
+ this.leases = new Set();
63
+ const result = precedingBarrier
64
+ .then(() => Promise.all(precedingLeases))
65
+ .then(operation);
66
+ this.barrier = result.then(
67
+ () => undefined,
68
+ () => undefined
69
+ );
70
+ return result;
71
+ }
72
+ }
@@ -0,0 +1,41 @@
1
+ import type { ProcessManager, ServerConfig, StatusSnapshot } from '../manager';
2
+ import type { AgentLifecyclePort } from './lifecycle';
3
+
4
+ export interface ManagedRuntimeSnapshot {
5
+ config: ServerConfig;
6
+ running: boolean;
7
+ }
8
+
9
+ export type ManagedRuntimeAdmission = <T>(
10
+ operation: (snapshot: ManagedRuntimeSnapshot) => Promise<T>
11
+ ) => Promise<T>;
12
+
13
+ /**
14
+ * Admit SDK work against one managed-process generation. The operation and its
15
+ * final state/config check share the same lifecycle section as start/stop.
16
+ */
17
+ export function managedRuntimeAdmission(
18
+ manager: ProcessManager,
19
+ lifecycle: AgentLifecyclePort,
20
+ initial: StatusSnapshot
21
+ ): ManagedRuntimeAdmission {
22
+ return (operation) =>
23
+ lifecycle.lease(async () => {
24
+ const current = manager.getStatus();
25
+ if (generationChanged(initial, current)) {
26
+ throw new Error('Managed Bunqueue server restarted while the request was prepared');
27
+ }
28
+ return operation({
29
+ config: current.runningConfig ?? current.config,
30
+ running: current.status === 'running',
31
+ });
32
+ });
33
+ }
34
+
35
+ function generationChanged(initial: StatusSnapshot, current: StatusSnapshot): boolean {
36
+ return (
37
+ initial.status === 'running' &&
38
+ current.status === 'running' &&
39
+ initial.generation !== current.generation
40
+ );
41
+ }
@@ -0,0 +1,60 @@
1
+ const DEFAULT_ORIGINS = ['http://localhost:5273', 'http://127.0.0.1:5273'];
2
+ const LOOPBACK_HOSTS = ['localhost', '127.0.0.1', '::1', '0.0.0.0'];
3
+
4
+ export function resolveAllowedOrigins(env = process.env): string[] {
5
+ const extra = (env.AGENT_ALLOWED_ORIGINS ?? '')
6
+ .split(',')
7
+ .map((origin) => origin.trim().replace(/\/$/, ''))
8
+ .filter(Boolean);
9
+ return Array.from(new Set([...DEFAULT_ORIGINS, ...extra]));
10
+ }
11
+
12
+ /** Extract a lowercase bare hostname from a Host header or origin. */
13
+ export function hostnameOf(host: string): string {
14
+ const stripped = host.trim().replace(/^[a-z][a-z0-9+.-]*:\/\//i, '');
15
+ const ipv6 = stripped.match(/^\[([^\]]+)\]/);
16
+ if (ipv6) return ipv6[1].toLowerCase();
17
+ const firstColon = stripped.indexOf(':');
18
+ if (firstColon === -1) return stripped.toLowerCase();
19
+ if (firstColon !== stripped.lastIndexOf(':')) return stripped.toLowerCase();
20
+ return stripped.slice(0, firstColon).toLowerCase();
21
+ }
22
+
23
+ export function resolveAllowedHosts(env = process.env, extra: string[] = []): string[] {
24
+ const fromEnvironment = (env.AGENT_ALLOWED_HOSTS ?? '')
25
+ .split(',')
26
+ .map((host) => hostnameOf(host.trim()))
27
+ .filter(Boolean);
28
+ const extraHosts = extra.map(hostnameOf).filter(Boolean);
29
+ return Array.from(new Set([...LOOPBACK_HOSTS, ...extraHosts, ...fromEnvironment]));
30
+ }
31
+
32
+ export function isOriginAllowed(origin: string | null, allowed: string[]): boolean {
33
+ if (!origin) return true;
34
+ return allowed.includes(origin.replace(/\/$/, ''));
35
+ }
36
+
37
+ export function isHostAllowed(host: string | null, allowed?: string[]): boolean {
38
+ if (!allowed) return true;
39
+ if (!host) return false;
40
+ return allowed.includes(hostnameOf(host));
41
+ }
42
+
43
+ export function corsHeaders(origin: string | null, allowed: string[]): Record<string, string> {
44
+ const headers: Record<string, string> = {
45
+ 'Access-Control-Allow-Methods': 'GET, POST, PUT, OPTIONS',
46
+ 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Agent-Token',
47
+ Vary: 'Origin',
48
+ };
49
+ if (origin && allowed.includes(origin.replace(/\/$/, ''))) {
50
+ headers['Access-Control-Allow-Origin'] = origin;
51
+ }
52
+ return headers;
53
+ }
54
+
55
+ export function tokenOk(request: Request, token?: string): boolean {
56
+ if (!token) return false;
57
+ const authorization = request.headers.get('authorization') ?? '';
58
+ const bearer = authorization.startsWith('Bearer ') ? authorization.slice(7) : '';
59
+ return bearer === token || request.headers.get('x-agent-token') === token;
60
+ }
@@ -0,0 +1,150 @@
1
+ import { routeBackupRequest } from '../backup/routes';
2
+ import type { BackupRunnerPort } from '../backup/runner';
3
+ import { routeFlowRequest } from '../flow/routes';
4
+ import type { ProcessManager } from '../manager';
5
+ import { routeQueueOperationsRequest } from '../queue/routes';
6
+ import type { QueueOperationsPort } from '../queue/types';
7
+ import type { WorkflowRuntimePort } from '../workflow/runtime';
8
+ import { routeWorkflowRuntimeRequest } from '../workflow/runtimeRoutes';
9
+ import { routeControlRequest } from './controlRoutes';
10
+ import { routeDatabaseRequest } from './databaseRoutes';
11
+ import type { AgentLifecyclePort } from './lifecycle';
12
+ import { managedRuntimeAdmission } from './managedRuntime';
13
+ import type { RouteResponse } from './types';
14
+ import { routeWorkflowReadRequest } from './workflowReadRoutes';
15
+
16
+ export async function routeAgentRequest(
17
+ request: Request,
18
+ pathname: string,
19
+ method: string,
20
+ manager: ProcessManager,
21
+ runtime: WorkflowRuntimePort,
22
+ backupRunner: BackupRunnerPort,
23
+ queueRuntime: QueueOperationsPort,
24
+ lifecycle: AgentLifecyclePort,
25
+ origin: string | null,
26
+ allowedOrigins: string[]
27
+ ): Promise<RouteResponse | Response | null> {
28
+ if (pathname.startsWith('/control/')) {
29
+ const control = await routeControlRequest(
30
+ request,
31
+ pathname,
32
+ method,
33
+ manager,
34
+ runtime,
35
+ lifecycle
36
+ );
37
+ if (control) return control;
38
+ }
39
+
40
+ const snapshot = manager.getStatus();
41
+ const managedConfig = snapshot.runningConfig ?? snapshot.config;
42
+ const admission = managedRuntimeAdmission(manager, lifecycle, snapshot);
43
+ if (pathname.startsWith('/flows/')) {
44
+ const flow = await routeFlowRequest(
45
+ request,
46
+ pathname,
47
+ method,
48
+ managedConfig,
49
+ snapshot.status === 'running',
50
+ { admission }
51
+ );
52
+ if (flow) return flow;
53
+ }
54
+
55
+ // Runtime control must precede the generic /workflows/:executionId reader,
56
+ // otherwise GET /workflows/runtime is interpreted as an execution lookup.
57
+ if (isWorkflowRuntimeRoute(pathname, method)) {
58
+ const workflowRuntime = await routeWorkflowRuntimeRequest(
59
+ request,
60
+ pathname,
61
+ method,
62
+ managedConfig,
63
+ runtime,
64
+ snapshot.status === 'running',
65
+ admission
66
+ );
67
+ if (workflowRuntime) return workflowRuntime;
68
+ }
69
+
70
+ const desiredConfig = manager.getConfig();
71
+ const workflowRead = routeWorkflowReadRequest(
72
+ request,
73
+ pathname,
74
+ method,
75
+ managedConfig.dataPath
76
+ );
77
+ if (workflowRead) return workflowRead;
78
+
79
+ if (pathname.startsWith('/queue-operations/')) {
80
+ const queueOperation = await routeQueueOperationsRequest(
81
+ request,
82
+ pathname,
83
+ method,
84
+ managedConfig,
85
+ queueRuntime,
86
+ snapshot.status === 'running',
87
+ admission
88
+ );
89
+ if (queueOperation) return queueOperation;
90
+ }
91
+
92
+ if (pathname.startsWith('/backup/')) {
93
+ if (pathname === '/backup/restore' && method === 'POST') {
94
+ return manager.withStoppedMaintenance('restoring a backup', async () => {
95
+ const stoppedSnapshot = manager.getStatus();
96
+ const restoreDesired = manager.getConfig();
97
+ const restoreConfig = {
98
+ ...(stoppedSnapshot.runningConfig ?? stoppedSnapshot.config),
99
+ extraEnv: restoreDesired.extraEnv,
100
+ };
101
+ return routeBackupRequest(
102
+ request,
103
+ pathname,
104
+ method,
105
+ restoreConfig,
106
+ stoppedSnapshot.status === 'running',
107
+ await manager.dbStats(),
108
+ backupRunner
109
+ );
110
+ });
111
+ }
112
+ const database = await manager.dbStats();
113
+ const backupConfig = { ...managedConfig, extraEnv: desiredConfig.extraEnv };
114
+ const backup = await routeBackupRequest(
115
+ request,
116
+ pathname,
117
+ method,
118
+ backupConfig,
119
+ snapshot.status === 'running',
120
+ database,
121
+ backupRunner,
122
+ (extraEnv) => manager.setConfig({ extraEnv })
123
+ );
124
+ if (backup) return backup;
125
+ }
126
+
127
+ return routeDatabaseRequest(
128
+ request,
129
+ pathname,
130
+ method,
131
+ desiredConfig.dataPath,
132
+ origin,
133
+ allowedOrigins
134
+ );
135
+ }
136
+
137
+ function isWorkflowRuntimeRoute(pathname: string, method: string): boolean {
138
+ if (method === 'GET') return pathname === '/workflows/runtime';
139
+ if (method !== 'POST') return false;
140
+ return (
141
+ [
142
+ '/workflows/runtime/reload',
143
+ '/workflows/start',
144
+ '/workflows/recover',
145
+ '/workflows/archive',
146
+ '/workflows/cleanup',
147
+ ].includes(pathname) ||
148
+ /^\/workflows\/[^/]+\/(signal|resume-compensation|abandon-compensation)$/.test(pathname)
149
+ );
150
+ }
@@ -0,0 +1,39 @@
1
+ import type { WorkflowRuntimePort } from '../workflow/runtime';
2
+
3
+ export interface AgentOptions {
4
+ allowedOrigins: string[];
5
+ /** DNS-rebinding defense; compared by hostname only. */
6
+ allowedHosts?: string[];
7
+ /** On loopback, when set, changing requests must present this token. */
8
+ token?: string;
9
+ /** Network exposure: require token on every non-OPTIONS request. */
10
+ requireTokenForAll?: boolean;
11
+ }
12
+
13
+ export interface AgentFetchHandler {
14
+ (request: Request): Promise<Response>;
15
+ /** Release the handler's persistent SDK resources. */
16
+ close(): Promise<void>;
17
+ /** Synchronously close admission and latch the process manager, then drain resources. */
18
+ beginShutdown(): Promise<void>;
19
+ /** Close resources through the lifecycle gate, then stop the managed process. */
20
+ shutdown(): Promise<void>;
21
+ /** Send SIGKILL to the managed process after terminal grace is exhausted. */
22
+ forceShutdown(): void;
23
+ }
24
+
25
+ export interface RouteResponse {
26
+ status: number;
27
+ body: unknown;
28
+ }
29
+
30
+ export type JsonResponder = (
31
+ data: unknown,
32
+ status: number,
33
+ origin: string | null
34
+ ) => Response;
35
+
36
+ export interface HandlerResources {
37
+ runtime: WorkflowRuntimePort;
38
+ close(): Promise<void>;
39
+ }
@@ -0,0 +1,80 @@
1
+ import {
2
+ type WorkflowStateFilter,
3
+ type WorkflowStoreKind,
4
+ workflowExecution,
5
+ workflowExecutions,
6
+ workflowStats,
7
+ WORKFLOW_STATES,
8
+ } from '../workflows';
9
+ import type { RouteResponse } from './types';
10
+
11
+ function workflowKind(query: URLSearchParams): WorkflowStoreKind {
12
+ const kind = query.get('kind') ?? 'active';
13
+ if (kind !== 'active' && kind !== 'archive') {
14
+ throw new Error('Workflow kind must be "active" or "archive"');
15
+ }
16
+ return kind;
17
+ }
18
+
19
+ function integer(query: URLSearchParams, name: 'limit' | 'offset', fallback: number): number {
20
+ const raw = query.get(name);
21
+ if (raw === null) return fallback;
22
+ if (!/^\d+$/.test(raw)) throw new Error(`Workflow ${name} must be an integer`);
23
+ return Number(raw);
24
+ }
25
+
26
+ export function routeWorkflowReadRequest(
27
+ request: Request,
28
+ pathname: string,
29
+ method: string,
30
+ dataPath: string
31
+ ): RouteResponse | null {
32
+ if (pathname === '/workflows/stats' && method === 'GET') {
33
+ return { status: 200, body: { ok: true, ...workflowStats(dataPath) } };
34
+ }
35
+ if (pathname === '/workflows' && method === 'GET') {
36
+ const query = new URL(request.url).searchParams;
37
+ const allowed = new Set(['kind', 'workflowName', 'state', 'limit', 'offset']);
38
+ for (const key of query.keys()) {
39
+ if (!allowed.has(key)) throw new Error(`Unknown workflow option: ${key}`);
40
+ if (query.getAll(key).length !== 1) throw new Error(`Duplicate workflow option: ${key}`);
41
+ }
42
+ const state = query.get('state') || undefined;
43
+ if (
44
+ state &&
45
+ state !== 'compensation' &&
46
+ !(WORKFLOW_STATES as readonly string[]).includes(state)
47
+ ) {
48
+ throw new Error('Unknown workflow execution state');
49
+ }
50
+ return {
51
+ status: 200,
52
+ body: {
53
+ ok: true,
54
+ ...workflowExecutions(dataPath, {
55
+ kind: workflowKind(query),
56
+ workflowName: query.get('workflowName') || undefined,
57
+ state: state as WorkflowStateFilter | undefined,
58
+ limit: integer(query, 'limit', 50),
59
+ offset: integer(query, 'offset', 0),
60
+ }),
61
+ },
62
+ };
63
+ }
64
+ if (!pathname.startsWith('/workflows/') || method !== 'GET') return null;
65
+ const rawId = pathname.slice('/workflows/'.length);
66
+ if (!rawId || rawId.includes('/')) {
67
+ return { status: 404, body: { ok: false, error: 'Not found' } };
68
+ }
69
+ const query = new URL(request.url).searchParams;
70
+ for (const key of query.keys()) {
71
+ if (key !== 'kind') throw new Error(`Unknown workflow detail option: ${key}`);
72
+ if (query.getAll(key).length !== 1) {
73
+ throw new Error(`Duplicate workflow detail option: ${key}`);
74
+ }
75
+ }
76
+ const execution = workflowExecution(dataPath, decodeURIComponent(rawId), workflowKind(query));
77
+ return execution
78
+ ? { status: 200, body: { ok: true, execution } }
79
+ : { status: 404, body: { ok: false, error: 'Workflow execution not found' } };
80
+ }