bunqueue-dashboard 0.0.35 → 0.0.37
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 +36 -16
- package/agent/backup/processExecutor.ts +90 -0
- package/agent/backup/resultValidation.ts +85 -0
- package/agent/backup/routes.ts +161 -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 +251 -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 +22 -21
- package/agent/logger.ts +121 -17
- package/agent/managedTarget.ts +82 -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 +135 -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 +185 -0
- package/agent/server/controlTarget.ts +116 -0
- package/agent/server/databaseRoutes.ts +165 -0
- package/agent/server/errors.ts +49 -0
- package/agent/server/handler.ts +155 -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 +161 -0
- package/agent/server/types.ts +46 -0
- package/agent/server/workflowReadRoutes.ts +80 -0
- package/agent/server.ts +26 -441
- 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 +163 -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 +14 -489
- package/dist/.vite/manifest.json +611 -250
- package/dist/assets/AddJob-CWkkVHmf.js +3 -0
- package/dist/assets/Alerts-Gh1pVbqj.js +1 -0
- package/dist/assets/{AreaChart-REK4ZsTC.js → AreaChart-w0ruozXH.js} +1 -1
- package/dist/assets/Benchmark-Dir2A1CQ.js +1 -0
- package/dist/assets/BulkAddJobs-Di_IgRSH.js +5 -0
- package/dist/assets/Button-BPqpRZJD.js +1 -0
- package/dist/assets/Card-C7NMhqUt.js +1 -0
- package/dist/assets/{CopilotPanel-COLw-atS.js → CopilotPanel-Db5nYpo1.js} +3 -3
- package/dist/assets/Cron-BRgWcBGk.js +1 -0
- package/dist/assets/CronManager-CbM0x_s0.js +1 -0
- package/dist/assets/Database-BeoEreW0.js +3 -0
- package/dist/assets/Diagnostics-Cw9iJ84f.js +1 -0
- package/dist/assets/Dlq-DfCg04Jd.js +1 -0
- package/dist/assets/DlqControl-BRCkUXvb.js +1 -0
- package/dist/assets/DlqPro-BHJwBYsN.js +1 -0
- package/dist/assets/Flows-BAzbFP_W.js +2 -0
- package/dist/assets/JobInspector-qvW_APO6.js +2 -0
- package/dist/assets/Jobs-URpnFjC6.js +1 -0
- package/dist/assets/JobsPro-pQ_RFSU3.js +1 -0
- package/dist/assets/Logs-C5jX2OPE.js +1 -0
- package/dist/assets/LogsPro-Cq_npmmS.js +1 -0
- package/dist/assets/{McpServer-CbaU52l2.js → McpServer-DtCTmQBu.js} +2 -2
- package/dist/assets/Metrics-BdxitePr.js +1 -0
- package/dist/assets/MetricsPro-orHfW0mg.js +1 -0
- package/dist/assets/Overview-DaNns1aj.js +1 -0
- package/dist/assets/PageHeader-BXlZSBcc.js +1 -0
- package/dist/assets/Pagination-Q-vf4hsX.js +1 -0
- package/dist/assets/QueueActions-jiTzndPH.js +1 -0
- package/dist/assets/QueueControl-l5bSdMgb.js +1 -0
- package/dist/assets/QueueDetail-URK2pjtH.js +1 -0
- package/dist/assets/QueueDetailPro-C9M5CV4K.js +1 -0
- package/dist/assets/Queues-DeLXlww2.js +1 -0
- package/dist/assets/QueuesOverview-DoQ-MUfT.js +1 -0
- package/dist/assets/S3Backup-D2agV9_z.js +1 -0
- package/dist/assets/S3BackupPro--WSZWTQn.js +3 -0
- package/dist/assets/ServerControl-QVd6m_iR.js +3 -0
- package/dist/assets/Settings-CiJRj_SM.js +1 -0
- package/dist/assets/StatusBadge-B0I7hNBX.js +1 -0
- package/dist/assets/Usage-DbHJgA4t.js +1 -0
- package/dist/assets/UsagePro-LoPo3k1L.js +1 -0
- package/dist/assets/Webhooks-CuzIJHwk.js +1 -0
- package/dist/assets/Workers-BpUgpwUH.js +1 -0
- package/dist/assets/WorkersPro-BYY0P1Pt.js +1 -0
- package/dist/assets/Workflows-ByJw4FFH.js +1 -0
- package/dist/assets/api-V_sVpHDo.js +1 -0
- package/dist/assets/bq-DYmbVkgR.js +1 -0
- package/dist/assets/cn-zFEt5SO8.js +1 -0
- package/dist/assets/connectionStore-CKFQrxDo.js +1 -0
- package/dist/assets/controlConnectionEpoch-JM4omZ6j.js +1 -0
- package/dist/assets/feedback-BnGE2oW2.js +1 -0
- package/dist/assets/{flowMutationSafety-CoOp94wS.js → flowMutationSafety-DObp9nJo.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-D64v2vNO.js +8 -0
- package/dist/assets/index-DOze4TKg.css +1 -0
- package/dist/assets/install-jXi1SOYY.js +3 -0
- package/dist/assets/{react-vendor-Bjui4wS4.js → react-vendor-CyjgmGTq.js} +3 -3
- package/dist/assets/responses-CBLyy1Ob.js +1 -0
- package/dist/assets/toastStore-B7n0zS0y.js +1 -0
- package/dist/assets/{upstreamPaths-D_LPsv7b.js → upstreamPaths-CDVsu6IM.js} +1 -1
- package/dist/assets/usePolledData-c53tx1jw.js +1 -0
- package/dist/assets/{useServerActionGuard-CItj3hvh.js → useServerActionGuard-OTv6_fU0.js} +1 -1
- package/dist/assets/useThroughputSeries-DWHQl7cM.js +1 -0
- package/dist/index.html +17 -6
- package/package.json +15 -4
- package/scripts/embedded.gen.ts +160 -134
- package/scripts/serve.ts +127 -484
- package/scripts/serveHandler.ts +237 -0
- package/scripts/servePolicy.ts +187 -0
- package/dist/assets/AddJob-CG28MSxU.js +0 -3
- package/dist/assets/Alerts-D9JojJrj.js +0 -1
- package/dist/assets/Benchmark-BVjUH9dG.js +0 -1
- package/dist/assets/BulkAddJobs-BHzUCMj_.js +0 -5
- package/dist/assets/Cron-DoEHDHwy.js +0 -1
- package/dist/assets/CronManager-vulcesqJ.js +0 -1
- package/dist/assets/Database-DlB_t04Z.js +0 -3
- package/dist/assets/Diagnostics-DddCUgeR.js +0 -1
- package/dist/assets/Dlq-DMKrsSMP.js +0 -1
- package/dist/assets/DlqControl-9m0Re_TH.js +0 -1
- package/dist/assets/DlqPro-Dmw-0wta.js +0 -1
- package/dist/assets/Flows-C3-9nXW4.js +0 -1
- package/dist/assets/JobInspector-DwIfABmI.js +0 -2
- package/dist/assets/Jobs-j8tTS3NC.js +0 -1
- package/dist/assets/JobsPro-CS__CJJl.js +0 -1
- package/dist/assets/Logs-BB-VDPA1.js +0 -1
- package/dist/assets/LogsPro-RNtSQZRT.js +0 -1
- package/dist/assets/Metrics-ivcddPwT.js +0 -1
- package/dist/assets/MetricsPro-xjZno4vg.js +0 -1
- package/dist/assets/Overview-D84aD2ph.js +0 -1
- package/dist/assets/Pagination-Bfi4vkpp.js +0 -1
- package/dist/assets/QueueActions-DTbD6uek.js +0 -1
- package/dist/assets/QueueControl-B5xK_SQ-.js +0 -1
- package/dist/assets/QueueDetail-BH59kwm9.js +0 -1
- package/dist/assets/QueueDetailPro-DoN25ndy.js +0 -1
- package/dist/assets/Queues-CKMJqFHX.js +0 -1
- package/dist/assets/QueuesOverview-BX1lzojl.js +0 -1
- package/dist/assets/S3Backup-Bz3GfoZ9.js +0 -1
- package/dist/assets/S3BackupPro-BC_6-uX-.js +0 -2
- package/dist/assets/ServerControl-CSSrN8G5.js +0 -3
- package/dist/assets/Settings-DkqmZUIS.js +0 -1
- package/dist/assets/Usage-Ba20p0xd.js +0 -1
- package/dist/assets/UsagePro-C2pQwjN9.js +0 -1
- package/dist/assets/Webhooks-CVuuvb0L.js +0 -1
- package/dist/assets/Workers-C1wPU6CS.js +0 -1
- package/dist/assets/WorkersPro-B0vEkC0T.js +0 -1
- package/dist/assets/Workflows-C8Qmkflf.js +0 -1
- package/dist/assets/cn-CMFYpumL.js +0 -1
- package/dist/assets/connectionStore-Cd9-3Fw0.js +0 -1
- package/dist/assets/index-BCNzbhhD.css +0 -1
- package/dist/assets/index-CAtsVmyh.js +0 -8
- package/dist/assets/install-D0eRLf9F.js +0 -3
- 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,185 @@
|
|
|
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 {
|
|
9
|
+
MANAGED_CONTROL_TARGET,
|
|
10
|
+
probeExternalHealth,
|
|
11
|
+
type ServerControlTarget,
|
|
12
|
+
} from './controlTarget';
|
|
13
|
+
import type { AgentLifecyclePort } from './lifecycle';
|
|
14
|
+
import type { RouteResponse } from './types';
|
|
15
|
+
|
|
16
|
+
const MAX_CONFIG_BODY_BYTES = 64 * 1024;
|
|
17
|
+
|
|
18
|
+
async function statusWithHealth(manager: ProcessManager, controlTarget: ServerControlTarget) {
|
|
19
|
+
const snapshot = manager.getStatus();
|
|
20
|
+
if (controlTarget.mode === 'external') {
|
|
21
|
+
const health = await probeExternalHealth(controlTarget);
|
|
22
|
+
return {
|
|
23
|
+
...snapshot,
|
|
24
|
+
managementMode: 'external' as const,
|
|
25
|
+
healthy: health.healthy,
|
|
26
|
+
version: health.version,
|
|
27
|
+
reachable: health.reachable,
|
|
28
|
+
externalUrl: controlTarget.url,
|
|
29
|
+
healthStatus: health.statusCode,
|
|
30
|
+
healthError: health.error,
|
|
31
|
+
db: null,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
let healthy = false;
|
|
35
|
+
let version: string | undefined;
|
|
36
|
+
if (snapshot.status === 'running') {
|
|
37
|
+
const port = snapshot.runningConfig?.httpPort ?? snapshot.config.httpPort;
|
|
38
|
+
try {
|
|
39
|
+
const response = await fetch(`http://127.0.0.1:${port}/health`, {
|
|
40
|
+
signal: AbortSignal.timeout(1500),
|
|
41
|
+
});
|
|
42
|
+
if (response.ok) {
|
|
43
|
+
const body: unknown = await response.json();
|
|
44
|
+
if (body !== null && typeof body === 'object' && !Array.isArray(body)) {
|
|
45
|
+
const health = body as Record<string, unknown>;
|
|
46
|
+
healthy = health.ok === true;
|
|
47
|
+
if (typeof health.version === 'string') version = health.version;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
} catch {
|
|
51
|
+
// The managed process is not healthy yet.
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const database = await manager.dbStats().catch(() => null);
|
|
55
|
+
return { ...snapshot, managementMode: 'managed' as const, healthy, version, db: database };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function routeControlRequest(
|
|
59
|
+
request: Request,
|
|
60
|
+
pathname: string,
|
|
61
|
+
method: string,
|
|
62
|
+
manager: ProcessManager,
|
|
63
|
+
runtime: WorkflowRuntimePort,
|
|
64
|
+
lifecycle?: AgentLifecyclePort,
|
|
65
|
+
controlTarget: ServerControlTarget = MANAGED_CONTROL_TARGET
|
|
66
|
+
): Promise<RouteResponse | null> {
|
|
67
|
+
if (pathname === '/control/status') {
|
|
68
|
+
return { status: 200, body: await statusWithHealth(manager, controlTarget) };
|
|
69
|
+
}
|
|
70
|
+
if (pathname === '/control/logs') {
|
|
71
|
+
return { status: 200, body: { lines: manager.getLogs() } };
|
|
72
|
+
}
|
|
73
|
+
if (pathname === '/control/start' && method === 'POST') {
|
|
74
|
+
assertManagedControl(controlTarget);
|
|
75
|
+
return coordinated(lifecycle, async () => {
|
|
76
|
+
await manager.start();
|
|
77
|
+
return { status: 200, body: await statusWithHealth(manager, controlTarget) };
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
if (pathname === '/control/stop' && method === 'POST') {
|
|
81
|
+
assertManagedControl(controlTarget);
|
|
82
|
+
return coordinated(lifecycle, async () => {
|
|
83
|
+
await stopWithRuntimeCleanup(manager, runtime);
|
|
84
|
+
return { status: 200, body: await statusWithHealth(manager, controlTarget) };
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
if (pathname === '/control/restart' && method === 'POST') {
|
|
88
|
+
assertManagedControl(controlTarget);
|
|
89
|
+
return coordinated(lifecycle, async () => {
|
|
90
|
+
const closeResult = await settle(() => runtime.close());
|
|
91
|
+
if (!closeResult.ok) {
|
|
92
|
+
await stopAfterCloseFailure(manager, workflowCloseError(closeResult.error));
|
|
93
|
+
}
|
|
94
|
+
await manager.restart();
|
|
95
|
+
return { status: 200, body: await statusWithHealth(manager, controlTarget) };
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
if (pathname === '/control/config' && method === 'GET') {
|
|
99
|
+
return { status: 200, body: manager.getConfig() };
|
|
100
|
+
}
|
|
101
|
+
if (pathname === '/control/config' && method === 'PUT') {
|
|
102
|
+
assertManagedControl(controlTarget);
|
|
103
|
+
const patch = validateConfigPatch(
|
|
104
|
+
await readLimitedJsonBody(request, {
|
|
105
|
+
scope: 'Agent configuration',
|
|
106
|
+
maxBytes: MAX_CONFIG_BODY_BYTES,
|
|
107
|
+
limitLabel: '64 KiB',
|
|
108
|
+
})
|
|
109
|
+
);
|
|
110
|
+
return { status: 200, body: manager.setConfig(patch) };
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function assertManagedControl(controlTarget: ServerControlTarget): void {
|
|
116
|
+
if (controlTarget.mode === 'external') {
|
|
117
|
+
throw new Error(
|
|
118
|
+
'Server lifecycle is disabled in external mode (BUNQUEUE_MANAGED=0); manage this broker with its external supervisor'
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function stopWithRuntimeCleanup(
|
|
124
|
+
manager: ProcessManager,
|
|
125
|
+
runtime: WorkflowRuntimePort
|
|
126
|
+
): Promise<void> {
|
|
127
|
+
const closeResult = await settle(() => runtime.close());
|
|
128
|
+
try {
|
|
129
|
+
await manager.stop();
|
|
130
|
+
} catch (stopFailure) {
|
|
131
|
+
if (!closeResult.ok) throw combinedShutdownError(closeResult.error, stopFailure);
|
|
132
|
+
throw stopFailure;
|
|
133
|
+
}
|
|
134
|
+
if (!closeResult.ok) throw workflowCloseError(closeResult.error);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function stopAfterCloseFailure(
|
|
138
|
+
manager: ProcessManager,
|
|
139
|
+
closeFailure: unknown
|
|
140
|
+
): Promise<never> {
|
|
141
|
+
try {
|
|
142
|
+
await manager.stop();
|
|
143
|
+
} catch (stopFailure) {
|
|
144
|
+
throw combinedShutdownError(closeFailure, stopFailure);
|
|
145
|
+
}
|
|
146
|
+
throw closeFailure;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function combinedShutdownError(closeFailure: unknown, stopFailure: unknown): Error {
|
|
150
|
+
return new WorkflowRuntimeUnavailableError(
|
|
151
|
+
`Workflow Engine shutdown failed (${message(closeFailure)}); ` +
|
|
152
|
+
`managed server stop also failed: ${message(stopFailure)}`
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function workflowCloseError(error: unknown): WorkflowRuntimeUnavailableError {
|
|
157
|
+
try {
|
|
158
|
+
if (error instanceof WorkflowRuntimeUnavailableError) return error;
|
|
159
|
+
} catch {
|
|
160
|
+
// A hostile thrown Proxy must still become an unavailable-runtime error.
|
|
161
|
+
}
|
|
162
|
+
return new WorkflowRuntimeUnavailableError(`Workflow Engine shutdown failed: ${message(error)}`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function message(error: unknown): string {
|
|
166
|
+
return safeErrorMessage(error);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function coordinated<T>(
|
|
170
|
+
lifecycle: AgentLifecyclePort | undefined,
|
|
171
|
+
operation: () => Promise<T>
|
|
172
|
+
): Promise<T> {
|
|
173
|
+
return lifecycle ? lifecycle.run(operation) : operation();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
type SettledOperation = { ok: true } | { ok: false; error: unknown };
|
|
177
|
+
|
|
178
|
+
async function settle(operation: () => Promise<unknown>): Promise<SettledOperation> {
|
|
179
|
+
try {
|
|
180
|
+
await operation();
|
|
181
|
+
return { ok: true };
|
|
182
|
+
} catch (error) {
|
|
183
|
+
return { ok: false, error };
|
|
184
|
+
}
|
|
185
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { safeErrorMessage } from '../errorMessage';
|
|
2
|
+
|
|
3
|
+
export type ServerManagementMode = 'managed' | 'external';
|
|
4
|
+
|
|
5
|
+
export interface ServerControlTarget {
|
|
6
|
+
mode: ServerManagementMode;
|
|
7
|
+
/** Canonical external HTTP base; present only in attach-only mode. */
|
|
8
|
+
url?: string;
|
|
9
|
+
/** Server bearer used only by the agent-side health probe. */
|
|
10
|
+
token?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ExternalHealthProbe {
|
|
14
|
+
reachable: boolean;
|
|
15
|
+
healthy: boolean;
|
|
16
|
+
statusCode: number | null;
|
|
17
|
+
version?: string;
|
|
18
|
+
error?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const MANAGED_CONTROL_TARGET: ServerControlTarget = Object.freeze({ mode: 'managed' });
|
|
22
|
+
|
|
23
|
+
/** Resolve the immutable lifecycle mode once, before the agent starts listening. */
|
|
24
|
+
export function resolveServerControlTarget(
|
|
25
|
+
env: Record<string, string | undefined> = process.env
|
|
26
|
+
): ServerControlTarget {
|
|
27
|
+
const configured = env.BUNQUEUE_MANAGED?.trim().toLowerCase();
|
|
28
|
+
if (!configured || ['1', 'true', 'yes', 'on'].includes(configured)) {
|
|
29
|
+
return MANAGED_CONTROL_TARGET;
|
|
30
|
+
}
|
|
31
|
+
if (!['0', 'false', 'no', 'off'].includes(configured)) {
|
|
32
|
+
throw new Error('BUNQUEUE_MANAGED must be 1/0, true/false, yes/no, or on/off');
|
|
33
|
+
}
|
|
34
|
+
return Object.freeze({
|
|
35
|
+
mode: 'external',
|
|
36
|
+
url: normalizeExternalUrl(env.BUNQUEUE_URL),
|
|
37
|
+
token: env.BUNQUEUE_TOKEN?.trim() || undefined,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function normalizeExternalUrl(value: string | undefined): string {
|
|
42
|
+
const candidate = value?.trim() || 'http://localhost:6790';
|
|
43
|
+
let parsed: URL;
|
|
44
|
+
try {
|
|
45
|
+
parsed = new URL(candidate);
|
|
46
|
+
} catch {
|
|
47
|
+
throw new Error('BUNQUEUE_URL must be an absolute HTTP(S) URL in external mode');
|
|
48
|
+
}
|
|
49
|
+
if (
|
|
50
|
+
(parsed.protocol !== 'http:' && parsed.protocol !== 'https:') ||
|
|
51
|
+
!parsed.hostname ||
|
|
52
|
+
parsed.username ||
|
|
53
|
+
parsed.password ||
|
|
54
|
+
parsed.search ||
|
|
55
|
+
parsed.hash
|
|
56
|
+
) {
|
|
57
|
+
throw new Error(
|
|
58
|
+
'BUNQUEUE_URL must be a credential-free HTTP(S) URL without query or fragment in external mode'
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
const path = parsed.pathname.replace(/\/+$/, '');
|
|
62
|
+
return `${parsed.origin}${path}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Probe an externally supervised broker without following credential-bearing redirects. */
|
|
66
|
+
export async function probeExternalHealth(target: ServerControlTarget): Promise<ExternalHealthProbe> {
|
|
67
|
+
if (target.mode !== 'external' || !target.url) {
|
|
68
|
+
throw new Error('External health probe requires an external server target');
|
|
69
|
+
}
|
|
70
|
+
let response: Response;
|
|
71
|
+
try {
|
|
72
|
+
response = await fetch(`${target.url}/health`, {
|
|
73
|
+
headers: target.token ? { Authorization: `Bearer ${target.token}` } : undefined,
|
|
74
|
+
redirect: 'error',
|
|
75
|
+
signal: AbortSignal.timeout(1500),
|
|
76
|
+
});
|
|
77
|
+
} catch (error) {
|
|
78
|
+
return {
|
|
79
|
+
reachable: false,
|
|
80
|
+
healthy: false,
|
|
81
|
+
statusCode: null,
|
|
82
|
+
error: safeErrorMessage(error),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
let body: unknown;
|
|
87
|
+
try {
|
|
88
|
+
body = await response.json();
|
|
89
|
+
} catch {
|
|
90
|
+
return {
|
|
91
|
+
reachable: true,
|
|
92
|
+
healthy: false,
|
|
93
|
+
statusCode: response.status,
|
|
94
|
+
error: `Health endpoint returned non-JSON HTTP ${response.status}`,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
const health = isRecord(body) ? body : {};
|
|
98
|
+
const healthy = response.ok && health.ok === true;
|
|
99
|
+
const version = typeof health.version === 'string' ? health.version : undefined;
|
|
100
|
+
const reportedStatus = typeof health.status === 'string' ? health.status : undefined;
|
|
101
|
+
return {
|
|
102
|
+
reachable: true,
|
|
103
|
+
healthy,
|
|
104
|
+
statusCode: response.status,
|
|
105
|
+
version,
|
|
106
|
+
error: healthy
|
|
107
|
+
? undefined
|
|
108
|
+
: reportedStatus
|
|
109
|
+
? `Health reported ${reportedStatus} (HTTP ${response.status})`
|
|
110
|
+
: `Health endpoint returned HTTP ${response.status}`,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
115
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
116
|
+
}
|