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
package/agent/manager.ts
CHANGED
|
@@ -1,490 +1,10 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
export interface ServerConfig {
|
|
12
|
-
/** Command to launch the server, e.g. "bunx bunqueue@2.8.57 start" or "bun run src/main.ts". */
|
|
13
|
-
command: string;
|
|
14
|
-
httpPort: number;
|
|
15
|
-
tcpPort: number;
|
|
16
|
-
dataPath: string;
|
|
17
|
-
extraEnv: Record<string, string>;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export interface LogLine {
|
|
21
|
-
seq: number;
|
|
22
|
-
ts: number;
|
|
23
|
-
stream: 'stdout' | 'stderr' | 'sys';
|
|
24
|
-
line: string;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/** On-disk footprint of the configured SQLite database (main + WAL + SHM). */
|
|
28
|
-
export interface DbStats {
|
|
29
|
-
path: string;
|
|
30
|
-
exists: boolean;
|
|
31
|
-
size: number;
|
|
32
|
-
walSize: number;
|
|
33
|
-
shmSize: number;
|
|
34
|
-
totalSize: number;
|
|
35
|
-
mtimeMs: number | null;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export interface StatusSnapshot {
|
|
39
|
-
status: Status;
|
|
40
|
-
pid: number | null;
|
|
41
|
-
startedAt: number | null;
|
|
42
|
-
exitCode: number | null;
|
|
43
|
-
/** Editable/desired config — takes effect on the next start/restart. */
|
|
44
|
-
config: ServerConfig;
|
|
45
|
-
/** Config the live process was actually launched with (null when stopped). */
|
|
46
|
-
runningConfig: ServerConfig | null;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
const MAX_LOGS = 800;
|
|
50
|
-
const STOP_TIMEOUT_MS = 8000;
|
|
51
|
-
/**
|
|
52
|
-
* Hard byte cap for a single log line. The ring buffer trims by COUNT, so a
|
|
53
|
-
* child that dumps a large blob without a newline (binary file, one-line JSON)
|
|
54
|
-
* would otherwise grow the reader's in-flight buffer — and then one retained
|
|
55
|
-
* LogLine — without limit.
|
|
56
|
-
*/
|
|
57
|
-
const MAX_LINE = 8192;
|
|
58
|
-
|
|
59
|
-
interface CancelablePipeReader {
|
|
60
|
-
cancel(reason?: unknown): Promise<void>;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function defaultConfig(): ServerConfig {
|
|
64
|
-
return {
|
|
65
|
-
command: process.env.BUNQUEUE_START_CMD || 'bunx bunqueue@2.8.57 start',
|
|
66
|
-
httpPort: Number(process.env.HTTP_PORT) || 6790,
|
|
67
|
-
tcpPort: Number(process.env.TCP_PORT) || 6789,
|
|
68
|
-
dataPath: process.env.BUNQUEUE_DATA_PATH || './data/bunq.db',
|
|
69
|
-
extraEnv: {},
|
|
70
|
-
};
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
const CONFIG_KEYS = new Set<keyof ServerConfig>([
|
|
74
|
-
'command',
|
|
75
|
-
'httpPort',
|
|
76
|
-
'tcpPort',
|
|
77
|
-
'dataPath',
|
|
78
|
-
'extraEnv',
|
|
79
|
-
]);
|
|
80
|
-
|
|
81
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
82
|
-
if (value === null || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
83
|
-
const proto = Object.getPrototypeOf(value);
|
|
84
|
-
return proto === Object.prototype || proto === null;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
function validatePort(name: 'httpPort' | 'tcpPort', value: unknown): number {
|
|
88
|
-
if (typeof value !== 'number' || !Number.isInteger(value) || value < 1 || value > 65_535) {
|
|
89
|
-
throw new Error(`${name} must be an integer between 1 and 65535`);
|
|
90
|
-
}
|
|
91
|
-
return value;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* Validate an untrusted PUT /control/config body before any field is merged.
|
|
96
|
-
* Returning a fresh object (including a fresh extraEnv) makes setConfig atomic:
|
|
97
|
-
* one invalid field cannot leave earlier fields applied, and the caller cannot
|
|
98
|
-
* mutate the accepted config later through a retained object reference.
|
|
99
|
-
*/
|
|
100
|
-
export function validateConfigPatch(value: unknown): Partial<ServerConfig> {
|
|
101
|
-
if (!isRecord(value)) throw new Error('Config must be an object');
|
|
102
|
-
|
|
103
|
-
for (const key of Object.keys(value)) {
|
|
104
|
-
if (!CONFIG_KEYS.has(key as keyof ServerConfig)) {
|
|
105
|
-
throw new Error(`Unknown config key: ${key}`);
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
const patch: Partial<ServerConfig> = {};
|
|
110
|
-
if (Object.hasOwn(value, 'command')) {
|
|
111
|
-
if (typeof value.command !== 'string' || value.command.trim() === '') {
|
|
112
|
-
throw new Error('command must be a non-empty string');
|
|
113
|
-
}
|
|
114
|
-
patch.command = value.command;
|
|
115
|
-
}
|
|
116
|
-
if (Object.hasOwn(value, 'httpPort')) {
|
|
117
|
-
patch.httpPort = validatePort('httpPort', value.httpPort);
|
|
118
|
-
}
|
|
119
|
-
if (Object.hasOwn(value, 'tcpPort')) {
|
|
120
|
-
patch.tcpPort = validatePort('tcpPort', value.tcpPort);
|
|
121
|
-
}
|
|
122
|
-
if (Object.hasOwn(value, 'dataPath')) {
|
|
123
|
-
if (typeof value.dataPath !== 'string') throw new Error('dataPath must be a string');
|
|
124
|
-
patch.dataPath = value.dataPath;
|
|
125
|
-
}
|
|
126
|
-
if (Object.hasOwn(value, 'extraEnv')) {
|
|
127
|
-
if (!isRecord(value.extraEnv)) {
|
|
128
|
-
throw new Error('extraEnv must be an object containing only string values');
|
|
129
|
-
}
|
|
130
|
-
const entries = Object.entries(value.extraEnv);
|
|
131
|
-
for (const [key, entry] of entries) {
|
|
132
|
-
if (typeof entry !== 'string') {
|
|
133
|
-
throw new Error(`extraEnv.${key} must be a string`);
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
// fromEntries defines "__proto__" as ordinary data instead of invoking the
|
|
137
|
-
// legacy Object.prototype setter via indexed assignment.
|
|
138
|
-
patch.extraEnv = Object.fromEntries(entries) as Record<string, string>;
|
|
139
|
-
}
|
|
140
|
-
return patch;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/** Validate the complete snapshot start() is about to pass to the child. */
|
|
144
|
-
function validateServerConfig(value: unknown): ServerConfig {
|
|
145
|
-
if (!isRecord(value)) throw new Error('Config must be an object');
|
|
146
|
-
const config = validateConfigPatch(value);
|
|
147
|
-
for (const key of CONFIG_KEYS) {
|
|
148
|
-
if (!Object.hasOwn(value, key)) {
|
|
149
|
-
throw new Error(`Missing config key: ${key}`);
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
return config as ServerConfig;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
function copyConfig(config: ServerConfig): ServerConfig {
|
|
156
|
-
return { ...config, extraEnv: { ...config.extraEnv } };
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
export class ProcessManager {
|
|
160
|
-
private proc: ReturnType<typeof Bun.spawn> | null = null;
|
|
161
|
-
private status: Status = 'stopped';
|
|
162
|
-
private startedAt: number | null = null;
|
|
163
|
-
private exitCode: number | null = null;
|
|
164
|
-
private config: ServerConfig = defaultConfig();
|
|
165
|
-
private runningConfig: ServerConfig | null = null;
|
|
166
|
-
private logs: LogLine[] = [];
|
|
167
|
-
private seq = 0;
|
|
168
|
-
/**
|
|
169
|
-
* Monotonic id for the current process generation. Every start() bumps it;
|
|
170
|
-
* onExit and stop() only mutate shared state when their captured token is
|
|
171
|
-
* still current, so a stop() awaiting an old process can't clobber a process
|
|
172
|
-
* a concurrent start() brought up (and vice-versa).
|
|
173
|
-
*/
|
|
174
|
-
private procToken = 0;
|
|
175
|
-
/**
|
|
176
|
-
* The stop() currently in flight (null when none). A stop() still owns a live
|
|
177
|
-
* child until its await resolves, so start() waits on this before spawning —
|
|
178
|
-
* otherwise a start racing a stop leaves two managed servers alive at once,
|
|
179
|
-
* fighting over the ports and the SQLite db.
|
|
180
|
-
*/
|
|
181
|
-
private stopping: Promise<StatusSnapshot> | null = null;
|
|
182
|
-
/** Latched by shutdown(): once set, start() refuses to spawn a new child. */
|
|
183
|
-
private shuttingDown = false;
|
|
184
|
-
/** Active stdout/stderr readers, grouped by the process generation they own. */
|
|
185
|
-
private pipeReaders = new Map<number, Set<CancelablePipeReader>>();
|
|
186
|
-
|
|
187
|
-
/**
|
|
188
|
-
* Actively break pending reader.read() calls for a finished generation. A
|
|
189
|
-
* descendant may inherit the child's pipe descriptor and keep it open after
|
|
190
|
-
* the managed process exits; token checks alone cannot release that reader.
|
|
191
|
-
*/
|
|
192
|
-
private cancelPipes(token: number): void {
|
|
193
|
-
const readers = this.pipeReaders.get(token);
|
|
194
|
-
if (!readers) return;
|
|
195
|
-
this.pipeReaders.delete(token);
|
|
196
|
-
for (const reader of readers) {
|
|
197
|
-
void reader.cancel('process generation ended').catch(() => {
|
|
198
|
-
/* already closed/cancelled */
|
|
199
|
-
});
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
getConfig(): ServerConfig {
|
|
204
|
-
return copyConfig(this.config);
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
/**
|
|
208
|
-
* Update the desired config. Editing while the server runs is allowed; the
|
|
209
|
-
* change applies on the next start/restart (the live process keeps using
|
|
210
|
-
* `runningConfig` until then). Ports/data-path of a live process cannot be
|
|
211
|
-
* changed in place.
|
|
212
|
-
*/
|
|
213
|
-
setConfig(patch: Partial<ServerConfig>): ServerConfig {
|
|
214
|
-
const valid = validateConfigPatch(patch);
|
|
215
|
-
this.config = {
|
|
216
|
-
...this.config,
|
|
217
|
-
...valid,
|
|
218
|
-
extraEnv: valid.extraEnv ?? this.config.extraEnv,
|
|
219
|
-
};
|
|
220
|
-
return copyConfig(this.config);
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
getStatus(): StatusSnapshot {
|
|
224
|
-
return {
|
|
225
|
-
status: this.status,
|
|
226
|
-
pid: this.proc?.pid ?? null,
|
|
227
|
-
startedAt: this.startedAt,
|
|
228
|
-
exitCode: this.exitCode,
|
|
229
|
-
config: copyConfig(this.config),
|
|
230
|
-
runningConfig: this.runningConfig ? copyConfig(this.runningConfig) : null,
|
|
231
|
-
};
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
/** Stat the configured SQLite db file plus its WAL/SHM sidecars. */
|
|
235
|
-
async dbStats(): Promise<DbStats> {
|
|
236
|
-
const path = this.config.dataPath;
|
|
237
|
-
const one = async (p: string): Promise<{ size: number; mtimeMs: number | null }> => {
|
|
238
|
-
try {
|
|
239
|
-
const f = Bun.file(p);
|
|
240
|
-
if (!(await f.exists())) return { size: 0, mtimeMs: null };
|
|
241
|
-
return { size: f.size, mtimeMs: f.lastModified };
|
|
242
|
-
} catch {
|
|
243
|
-
return { size: 0, mtimeMs: null };
|
|
244
|
-
}
|
|
245
|
-
};
|
|
246
|
-
const [main, wal, shm] = await Promise.all([
|
|
247
|
-
one(path),
|
|
248
|
-
one(`${path}-wal`),
|
|
249
|
-
one(`${path}-shm`),
|
|
250
|
-
]);
|
|
251
|
-
return {
|
|
252
|
-
path,
|
|
253
|
-
exists: main.mtimeMs !== null,
|
|
254
|
-
size: main.size,
|
|
255
|
-
walSize: wal.size,
|
|
256
|
-
shmSize: shm.size,
|
|
257
|
-
totalSize: main.size + wal.size + shm.size,
|
|
258
|
-
mtimeMs: main.mtimeMs,
|
|
259
|
-
};
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
getLogs(): LogLine[] {
|
|
263
|
-
return this.logs;
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
private push(stream: LogLine['stream'], line: string): void {
|
|
267
|
-
// Backstop byte cap (the reader also splits oversized chunks) so no single
|
|
268
|
-
// entry can pin an unbounded amount of memory in the ring buffer.
|
|
269
|
-
const capped = line.length > MAX_LINE ? `${line.slice(0, MAX_LINE)}…[truncated]` : line;
|
|
270
|
-
this.logs.push({ seq: this.seq++, ts: Date.now(), stream, line: capped });
|
|
271
|
-
// Amortized trim: only splice once the buffer overshoots by a slack margin, so
|
|
272
|
-
// steady-state logging isn't an O(n) array shift on every single line.
|
|
273
|
-
if (this.logs.length > MAX_LOGS + 256) this.logs.splice(0, this.logs.length - MAX_LOGS);
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
async start(): Promise<StatusSnapshot> {
|
|
277
|
-
if (this.shuttingDown) return this.getStatus();
|
|
278
|
-
if (this.status === 'running' || this.status === 'starting') return this.getStatus();
|
|
279
|
-
// Never spawn while a stop() still owns a live child: wait it out, then
|
|
280
|
-
// re-check the guards (a concurrent start may have won the race meanwhile).
|
|
281
|
-
if (this.stopping) await this.stopping;
|
|
282
|
-
if (this.shuttingDown) return this.getStatus();
|
|
283
|
-
// Read through a local: TS narrowed `this.status` at the guard above and
|
|
284
|
-
// doesn't widen it across the await, but a concurrent start() can have
|
|
285
|
-
// moved it in the meantime — that interleaving is exactly what this checks.
|
|
286
|
-
const statusAfterWait = this.status as Status;
|
|
287
|
-
if (statusAfterWait === 'running' || statusAfterWait === 'starting') return this.getStatus();
|
|
288
|
-
|
|
289
|
-
// Validate the COMPLETE launch snapshot BEFORE mutating status / burning a
|
|
290
|
-
// proc token. setConfig() already validates API patches, but defaultConfig()
|
|
291
|
-
// also reads environment values; e.g. HTTP_PORT=70000 must not reach spawn.
|
|
292
|
-
// Doing this later can wedge status at 'starting' and strand stale process
|
|
293
|
-
// metadata when validation throws.
|
|
294
|
-
const launchConfig = validateServerConfig(this.config);
|
|
295
|
-
const parts = launchConfig.command.trim().split(/\s+/);
|
|
296
|
-
const [cmd, ...args] = parts;
|
|
297
|
-
|
|
298
|
-
this.status = 'starting';
|
|
299
|
-
this.exitCode = null;
|
|
300
|
-
const token = ++this.procToken;
|
|
301
|
-
|
|
302
|
-
// extraEnv is spread FIRST so the explicit port/data-path config always
|
|
303
|
-
// wins: otherwise a user env var named HTTP_PORT silently moved the child
|
|
304
|
-
// while runningConfig (and the agent's /health probe) reported the old port.
|
|
305
|
-
const env: Record<string, string> = {
|
|
306
|
-
...(process.env as Record<string, string>),
|
|
307
|
-
...launchConfig.extraEnv,
|
|
308
|
-
HTTP_PORT: String(launchConfig.httpPort),
|
|
309
|
-
TCP_PORT: String(launchConfig.tcpPort),
|
|
310
|
-
BUNQUEUE_DATA_PATH: launchConfig.dataPath,
|
|
311
|
-
};
|
|
312
|
-
|
|
313
|
-
try {
|
|
314
|
-
this.proc = Bun.spawn([cmd, ...args], {
|
|
315
|
-
env,
|
|
316
|
-
stdout: 'pipe',
|
|
317
|
-
stderr: 'pipe',
|
|
318
|
-
onExit: (_p, code) => {
|
|
319
|
-
// Always tear down this generation's readers, even if shared state
|
|
320
|
-
// already belongs to a replacement process.
|
|
321
|
-
this.cancelPipes(token);
|
|
322
|
-
// Ignore the exit of a process a newer start() has already replaced.
|
|
323
|
-
if (this.procToken !== token) return;
|
|
324
|
-
this.exitCode = code ?? null;
|
|
325
|
-
this.status = 'stopped';
|
|
326
|
-
this.proc = null;
|
|
327
|
-
this.runningConfig = null;
|
|
328
|
-
this.push('sys', `process exited (code ${code ?? '?'})`);
|
|
329
|
-
},
|
|
330
|
-
});
|
|
331
|
-
} catch (e) {
|
|
332
|
-
this.status = 'stopped';
|
|
333
|
-
// Leave a consistent stopped snapshot: without this, a spawn failure
|
|
334
|
-
// racing an in-flight stop() (whose token is already stale, so its
|
|
335
|
-
// finalizer returns early) leaves getStatus() reporting the previous
|
|
336
|
-
// generation's dead pid and a non-null runningConfig.
|
|
337
|
-
this.proc = null;
|
|
338
|
-
this.runningConfig = null;
|
|
339
|
-
throw new Error(`Failed to spawn "${cmd}": ${(e as Error).message}`);
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
this.startedAt = Date.now();
|
|
343
|
-
this.status = 'running';
|
|
344
|
-
this.runningConfig = copyConfig(launchConfig);
|
|
345
|
-
this.push('sys', `started: ${launchConfig.command} (pid ${this.proc.pid})`);
|
|
346
|
-
// stdout/stderr are spawned as pipes, so they are ReadableStreams at
|
|
347
|
-
// runtime; Bun types them as a union that also includes a numeric fd.
|
|
348
|
-
void this.pipe(this.proc.stdout as unknown as ReadableStream<Uint8Array>, 'stdout', token);
|
|
349
|
-
void this.pipe(this.proc.stderr as unknown as ReadableStream<Uint8Array>, 'stderr', token);
|
|
350
|
-
return this.getStatus();
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
private async pipe(
|
|
354
|
-
stream: ReadableStream<Uint8Array>,
|
|
355
|
-
name: 'stdout' | 'stderr',
|
|
356
|
-
token: number
|
|
357
|
-
): Promise<void> {
|
|
358
|
-
const reader = stream.getReader();
|
|
359
|
-
let readers = this.pipeReaders.get(token);
|
|
360
|
-
if (!readers) {
|
|
361
|
-
readers = new Set();
|
|
362
|
-
this.pipeReaders.set(token, readers);
|
|
363
|
-
}
|
|
364
|
-
readers.add(reader);
|
|
365
|
-
const decoder = new TextDecoder();
|
|
366
|
-
let buffer = '';
|
|
367
|
-
try {
|
|
368
|
-
try {
|
|
369
|
-
while (true) {
|
|
370
|
-
const { done, value } = await reader.read();
|
|
371
|
-
// A process can exit while a descendant keeps its inherited pipe
|
|
372
|
-
// open. If a replacement has started by the time bytes arrive, those
|
|
373
|
-
// bytes belong to the obsolete generation and must never enter the
|
|
374
|
-
// current log view.
|
|
375
|
-
if (this.procToken !== token) return;
|
|
376
|
-
if (done) break;
|
|
377
|
-
buffer += decoder.decode(value, { stream: true });
|
|
378
|
-
let i: number;
|
|
379
|
-
while ((i = buffer.indexOf('\n')) !== -1) {
|
|
380
|
-
const line = buffer.slice(0, i);
|
|
381
|
-
buffer = buffer.slice(i + 1);
|
|
382
|
-
if (line.trim() && this.procToken === token) this.push(name, line);
|
|
383
|
-
}
|
|
384
|
-
// No newline in sight: flush in MAX_LINE slices instead of letting the
|
|
385
|
-
// buffer (and the eventual single LogLine) grow to the whole output.
|
|
386
|
-
while (buffer.length > MAX_LINE) {
|
|
387
|
-
if (this.procToken !== token) return;
|
|
388
|
-
this.push(name, buffer.slice(0, MAX_LINE));
|
|
389
|
-
buffer = buffer.slice(MAX_LINE);
|
|
390
|
-
}
|
|
391
|
-
}
|
|
392
|
-
} catch {
|
|
393
|
-
/* stream closed */
|
|
394
|
-
}
|
|
395
|
-
if (this.procToken !== token) return;
|
|
396
|
-
// Flush the final chunk when the stream ends without a trailing newline
|
|
397
|
-
// (e.g. a crash cause written via a bare write()) — otherwise the last,
|
|
398
|
-
// often most important, line never reaches the log buffer.
|
|
399
|
-
const tail = buffer + decoder.decode();
|
|
400
|
-
if (tail.trim() && this.procToken === token) this.push(name, tail);
|
|
401
|
-
} finally {
|
|
402
|
-
const activeReaders = this.pipeReaders.get(token);
|
|
403
|
-
activeReaders?.delete(reader);
|
|
404
|
-
if (activeReaders?.size === 0) this.pipeReaders.delete(token);
|
|
405
|
-
reader.releaseLock();
|
|
406
|
-
}
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
async stop(): Promise<StatusSnapshot> {
|
|
410
|
-
// Publish the in-flight stop so a racing start() can await it (see
|
|
411
|
-
// `stopping`). stopOnce() runs its synchronous prefix — including
|
|
412
|
-
// status='stopping' and the SIGTERM — before we return the promise.
|
|
413
|
-
const p = this.stopOnce();
|
|
414
|
-
this.stopping = p;
|
|
415
|
-
try {
|
|
416
|
-
return await p;
|
|
417
|
-
} finally {
|
|
418
|
-
if (this.stopping === p) this.stopping = null;
|
|
419
|
-
}
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
/**
|
|
423
|
-
* Stop the managed server and latch the manager closed, so a start() racing
|
|
424
|
-
* process shutdown (e.g. an in-flight restart() whose stop() resolves after
|
|
425
|
-
* SIGINT) cannot spawn a child that would be orphaned by process.exit().
|
|
426
|
-
*/
|
|
427
|
-
async shutdown(): Promise<StatusSnapshot> {
|
|
428
|
-
this.shuttingDown = true;
|
|
429
|
-
return this.stop();
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
private async stopOnce(): Promise<StatusSnapshot> {
|
|
433
|
-
if (!this.proc) {
|
|
434
|
-
this.status = 'stopped';
|
|
435
|
-
this.runningConfig = null;
|
|
436
|
-
return this.getStatus();
|
|
437
|
-
}
|
|
438
|
-
this.status = 'stopping';
|
|
439
|
-
this.push('sys', 'stopping (SIGTERM)…');
|
|
440
|
-
const proc = this.proc;
|
|
441
|
-
const token = this.procToken;
|
|
442
|
-
proc.kill();
|
|
443
|
-
// Timer cleared either way (this is the COMMON path): a pending one keeps
|
|
444
|
-
// the event loop alive for the full timeout after the race is decided.
|
|
445
|
-
let stopTimer: ReturnType<typeof setTimeout> | undefined;
|
|
446
|
-
const timedOut = await Promise.race([
|
|
447
|
-
proc.exited.then(() => false),
|
|
448
|
-
new Promise<boolean>((r) => {
|
|
449
|
-
stopTimer = setTimeout(() => r(true), STOP_TIMEOUT_MS);
|
|
450
|
-
}),
|
|
451
|
-
]).finally(() => clearTimeout(stopTimer));
|
|
452
|
-
// Always escalate to SIGKILL on the CAPTURED proc (never `this.proc`) so a
|
|
453
|
-
// child that ignores SIGTERM is still reaped even if a concurrent start()
|
|
454
|
-
// has since swapped in a new process — `proc` targets this generation only.
|
|
455
|
-
if (timedOut) {
|
|
456
|
-
this.push('sys', 'SIGTERM timed out, sending SIGKILL');
|
|
457
|
-
proc.kill(9);
|
|
458
|
-
// Bounded: a child wedged in uninterruptible sleep never resolves
|
|
459
|
-
// `exited` even after SIGKILL, and start() now awaits this promise, so an
|
|
460
|
-
// unbounded wait here would pin the manager at 'stopping' and hang every
|
|
461
|
-
// later start() forever. Give up on the wait (not on the kill) and let the
|
|
462
|
-
// state below finalize — the pid is already unusable. The timer is always
|
|
463
|
-
// cleared: a pending one keeps the event loop alive after the race is
|
|
464
|
-
// decided, which delays process exit for the whole timeout.
|
|
465
|
-
let reapTimer: ReturnType<typeof setTimeout> | undefined;
|
|
466
|
-
const reaped = await Promise.race([
|
|
467
|
-
proc.exited.then(() => true),
|
|
468
|
-
new Promise<boolean>((r) => {
|
|
469
|
-
reapTimer = setTimeout(() => r(false), STOP_TIMEOUT_MS);
|
|
470
|
-
}),
|
|
471
|
-
]).finally(() => clearTimeout(reapTimer));
|
|
472
|
-
if (!reaped) this.push('sys', `pid ${proc.pid} did not exit after SIGKILL — giving up on it`);
|
|
473
|
-
}
|
|
474
|
-
// onExit normally performs this cancellation. Repeat it idempotently for
|
|
475
|
-
// a process whose `exited` promise/callback never settles after SIGKILL.
|
|
476
|
-
this.cancelPipes(token);
|
|
477
|
-
// Only finalize shared state if no newer start() replaced this generation
|
|
478
|
-
// while we awaited — otherwise we'd null out the wrong (live) process.
|
|
479
|
-
if (this.procToken !== token) return this.getStatus();
|
|
480
|
-
this.status = 'stopped';
|
|
481
|
-
this.proc = null;
|
|
482
|
-
this.runningConfig = null;
|
|
483
|
-
return this.getStatus();
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
async restart(): Promise<StatusSnapshot> {
|
|
487
|
-
await this.stop();
|
|
488
|
-
return this.start();
|
|
489
|
-
}
|
|
490
|
-
}
|
|
1
|
+
/** Public compatibility entrypoint for managed bunqueue process supervision. */
|
|
2
|
+
export { validateConfigPatch } from './manager/config';
|
|
3
|
+
export { ProcessManager } from './manager/process';
|
|
4
|
+
export type {
|
|
5
|
+
DbStats,
|
|
6
|
+
LogLine,
|
|
7
|
+
ServerConfig,
|
|
8
|
+
Status,
|
|
9
|
+
StatusSnapshot,
|
|
10
|
+
} from './manager/types';
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import type { QueueMetricType } from 'bunqueue/client';
|
|
2
|
+
import type { ServerConfig } from '../manager';
|
|
3
|
+
import { assertManagedTarget, type ManagedTargetPolicy } from '../managedTarget';
|
|
4
|
+
import type {
|
|
5
|
+
ManagedRuntimeAdmission,
|
|
6
|
+
ManagedRuntimeSnapshot,
|
|
7
|
+
} from '../server/managedRuntime';
|
|
8
|
+
import type { RouteResponse } from '../server/types';
|
|
9
|
+
import type { QueueOperationsPort } from './types';
|
|
10
|
+
import { QueueOperationsUnavailableError } from './types';
|
|
11
|
+
import {
|
|
12
|
+
eventRetention,
|
|
13
|
+
exactJsonBody,
|
|
14
|
+
exactQuery,
|
|
15
|
+
metricsRange,
|
|
16
|
+
optionalMaxJobs,
|
|
17
|
+
requiredDeduplicationId,
|
|
18
|
+
validateQueueName,
|
|
19
|
+
} from './validation';
|
|
20
|
+
|
|
21
|
+
const READ_ROUTE = /^\/queue-operations\/([^/]+)\/(limits|deduplication|metrics)$/;
|
|
22
|
+
const MUTATION_ROUTE =
|
|
23
|
+
/^\/queue-operations\/([^/]+)\/(deduplication\/remove|events\/trim)$/;
|
|
24
|
+
|
|
25
|
+
export async function routeQueueOperationsRequest(
|
|
26
|
+
request: Request,
|
|
27
|
+
pathname: string,
|
|
28
|
+
method: string,
|
|
29
|
+
config: ServerConfig,
|
|
30
|
+
runtime: QueueOperationsPort,
|
|
31
|
+
running: boolean,
|
|
32
|
+
admission?: ManagedRuntimeAdmission,
|
|
33
|
+
targetPolicy?: ManagedTargetPolicy
|
|
34
|
+
): Promise<RouteResponse | null> {
|
|
35
|
+
const read = pathname.match(READ_ROUTE);
|
|
36
|
+
if (read && method === 'GET') {
|
|
37
|
+
const queue = decodedQueue(read[1]);
|
|
38
|
+
if (read[2] === 'limits') {
|
|
39
|
+
const query = pinnedQuery(request, config, ['target', 'maxJobs'], targetPolicy);
|
|
40
|
+
assertRunning(running);
|
|
41
|
+
const maxJobs = optionalMaxJobs(query);
|
|
42
|
+
return admitted(query, config, running, admission, targetPolicy, async ({ config: current }) =>
|
|
43
|
+
ok({ limits: await runtime.limits(current, queue, maxJobs) })
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
if (read[2] === 'deduplication') {
|
|
47
|
+
const query = pinnedQuery(request, config, ['target', 'deduplicationId'], targetPolicy);
|
|
48
|
+
const id = requiredDeduplicationId(query.get('deduplicationId'));
|
|
49
|
+
assertRunning(running);
|
|
50
|
+
return admitted(query, config, running, admission, targetPolicy, async ({ config: current }) =>
|
|
51
|
+
ok({ jobId: await runtime.deduplicationJobId(current, queue, id) })
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
const query = pinnedQuery(request, config, ['target', 'type', 'start', 'end'], targetPolicy);
|
|
55
|
+
const type = metricType(query.get('type'));
|
|
56
|
+
const range = metricsRange(query);
|
|
57
|
+
assertRunning(running);
|
|
58
|
+
return admitted(query, config, running, admission, targetPolicy, async ({ config: current }) =>
|
|
59
|
+
ok({ metrics: await runtime.metrics(current, queue, type, range.start, range.end) })
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const mutation = pathname.match(MUTATION_ROUTE);
|
|
64
|
+
if (mutation && method === 'POST') {
|
|
65
|
+
const queue = decodedQueue(mutation[1]);
|
|
66
|
+
const query = pinnedQuery(request, config, ['target'], targetPolicy);
|
|
67
|
+
assertRunning(running);
|
|
68
|
+
if (mutation[2] === 'deduplication/remove') {
|
|
69
|
+
const body = await exactJsonBody(request, ['deduplicationId']);
|
|
70
|
+
const id = requiredDeduplicationId(
|
|
71
|
+
typeof body.deduplicationId === 'string' ? body.deduplicationId : null
|
|
72
|
+
);
|
|
73
|
+
return admitted(query, config, running, admission, targetPolicy, async ({ config: current }) =>
|
|
74
|
+
ok({ removed: await runtime.removeDeduplicationKey(current, queue, id) })
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
const body = await exactJsonBody(request, ['maxLength']);
|
|
78
|
+
const retention = eventRetention(body.maxLength);
|
|
79
|
+
return admitted(query, config, running, admission, targetPolicy, async ({ config: current }) =>
|
|
80
|
+
ok({ removed: await runtime.trimEvents(current, queue, retention) })
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
if (pathname.startsWith('/queue-operations/')) {
|
|
84
|
+
return { status: 404, body: { ok: false, error: 'Unknown Queue operation' } };
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function admitted<T>(
|
|
90
|
+
query: URLSearchParams,
|
|
91
|
+
config: ServerConfig,
|
|
92
|
+
running: boolean,
|
|
93
|
+
admission: ManagedRuntimeAdmission | undefined,
|
|
94
|
+
targetPolicy: ManagedTargetPolicy | undefined,
|
|
95
|
+
operation: (snapshot: ManagedRuntimeSnapshot) => Promise<T>
|
|
96
|
+
): Promise<T> {
|
|
97
|
+
const execute = async (snapshot: ManagedRuntimeSnapshot) => {
|
|
98
|
+
assertManagedTarget(query, snapshot.config, targetPolicy);
|
|
99
|
+
assertRunning(snapshot.running);
|
|
100
|
+
return operation(snapshot);
|
|
101
|
+
};
|
|
102
|
+
return admission ? admission(execute) : execute({ config, running });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function pinnedQuery(
|
|
106
|
+
request: Request,
|
|
107
|
+
config: ServerConfig,
|
|
108
|
+
allowed: string[],
|
|
109
|
+
targetPolicy?: ManagedTargetPolicy
|
|
110
|
+
) {
|
|
111
|
+
const query = exactQuery(request.url, allowed);
|
|
112
|
+
assertManagedTarget(query, config, targetPolicy);
|
|
113
|
+
return query;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function decodedQueue(value: string): string {
|
|
117
|
+
return validateQueueName(decodeURIComponent(value));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function metricType(value: string | null): QueueMetricType {
|
|
121
|
+
if (value !== 'completed' && value !== 'failed') {
|
|
122
|
+
throw new Error('Metrics type must be completed or failed');
|
|
123
|
+
}
|
|
124
|
+
return value;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function assertRunning(running: boolean): void {
|
|
128
|
+
if (!running) {
|
|
129
|
+
throw new QueueOperationsUnavailableError('Start the managed Bunqueue server before using Queue operations');
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function ok(body: Record<string, unknown>): RouteResponse {
|
|
134
|
+
return { status: 200, body: { ok: true, ...body } };
|
|
135
|
+
}
|