bunqueue-dashboard 0.0.39 → 0.0.41
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 +22 -8
- package/agent/backup/resultValidation.ts +1 -1
- package/agent/index.ts +3 -0
- package/agent/manager/config.ts +1 -1
- package/agent/manager/process.ts +2 -2
- package/agent/manager/storageMode.ts +39 -0
- package/agent/manager.ts +5 -1
- package/agent/queue/routes.ts +57 -2
- package/agent/queue/runtime.ts +62 -1
- package/agent/queue/types.ts +31 -0
- package/agent/queue/validation.ts +16 -0
- package/agent/server/controlRoutes.ts +2 -93
- package/agent/server/controlStatus.ts +120 -0
- package/agent/workflows.ts +1 -1
- package/dist/.vite/manifest.json +233 -216
- package/dist/assets/{AddJob-d5KBvmN8.js → AddJob-CFnESFEU.js} +1 -1
- package/dist/assets/{Alerts-5I44QTnQ.js → Alerts-CYwfGLSQ.js} +1 -1
- package/dist/assets/{Benchmark-S1afsQRd.js → Benchmark-BINaJvak.js} +1 -1
- package/dist/assets/{BulkAddJobs-BO8LWUHE.js → BulkAddJobs-vyGFvlO3.js} +1 -1
- package/dist/assets/{CopilotPanel-DDkRd35p.js → CopilotPanel-DjHfX9ta.js} +1 -1
- package/dist/assets/{Cron-X3Y5uISO.js → Cron-CSoEPVeq.js} +1 -1
- package/dist/assets/{CronManager-ugmYEAwY.js → CronManager-s4xjNhGD.js} +1 -1
- package/dist/assets/Database-x6iWCu3-.js +3 -0
- package/dist/assets/{Diagnostics-D6oqvkOs.js → Diagnostics-DFshXeHl.js} +1 -1
- package/dist/assets/{Dlq-D70Lzmsa.js → Dlq-Cayt1PEN.js} +1 -1
- package/dist/assets/{DlqControl-nUT5Zzkl.js → DlqControl-BBQc6uu9.js} +1 -1
- package/dist/assets/DlqPro-BmrnGQ-3.js +1 -0
- package/dist/assets/Fleet-Bdrj9bLI.js +1 -0
- package/dist/assets/{Flows-CAG0nDnt.js → Flows-CcNdf_jS.js} +2 -2
- package/dist/assets/{JobInspector-CiS5HOSQ.js → JobInspector-BMjVR96H.js} +2 -2
- package/dist/assets/{Jobs-CkyRvzOT.js → Jobs-NN0fJ3BQ.js} +1 -1
- package/dist/assets/{JobsPro-B4-CSoq3.js → JobsPro-D_l1c80Q.js} +1 -1
- package/dist/assets/{Logs-DBTz2PTz.js → Logs-BGeTppXO.js} +1 -1
- package/dist/assets/{LogsPro-BCFeegvr.js → LogsPro-D7vvgZmb.js} +1 -1
- package/dist/assets/{McpServer-BKlTR1qr.js → McpServer-AsaFGnYZ.js} +2 -2
- package/dist/assets/{Metrics-Du__9Lbn.js → Metrics-CvoY4uhX.js} +1 -1
- package/dist/assets/{MetricsPro-CqnaO6YX.js → MetricsPro-C01FjqvR.js} +1 -1
- package/dist/assets/{Overview-udSSt7ZB.js → Overview-WStfS5ee.js} +1 -1
- package/dist/assets/{QueueActions-D7cQrGvJ.js → QueueActions-D51CYOjo.js} +1 -1
- package/dist/assets/QueueControl-Ce-O1L4p.js +1 -0
- package/dist/assets/{QueueDetail-C-ms6bMe.js → QueueDetail-D5TKm4bv.js} +1 -1
- package/dist/assets/{QueueDetailPro-DXEdsZx_.js → QueueDetailPro-CFNQcKho.js} +1 -1
- package/dist/assets/{Queues-BUMwTVAf.js → Queues-DlznHsfh.js} +1 -1
- package/dist/assets/QueuesOverview-DGtapyV0.js +1 -0
- package/dist/assets/{S3Backup-CDICSzxM.js → S3Backup-CxwbLJGK.js} +1 -1
- package/dist/assets/{S3BackupPro-CBidbI-s.js → S3BackupPro-8y0J0-3_.js} +3 -3
- package/dist/assets/{ServerControl-CPT0LpsC.js → ServerControl-DpE7t-OF.js} +2 -2
- package/dist/assets/Settings-Bw5MtDTF.js +1 -0
- package/dist/assets/{Usage-B895tSxi.js → Usage-DpShi3FE.js} +1 -1
- package/dist/assets/{UsagePro-CASN77iV.js → UsagePro-D4cGbozz.js} +1 -1
- package/dist/assets/{Webhooks-CXbZOFFw.js → Webhooks-UYiTg6hn.js} +1 -1
- package/dist/assets/{Workers-BceQlUhX.js → Workers-D70B_uAI.js} +1 -1
- package/dist/assets/{WorkersPro-Dtml89jB.js → WorkersPro-YycNhm9w.js} +1 -1
- package/dist/assets/Workflows-BxlrgGEB.js +1 -0
- package/dist/assets/{api-81m1TwB7.js → api-BAGaBh6R.js} +1 -1
- package/dist/assets/bq-Qh0VFLKV.js +1 -0
- package/dist/assets/connectionStore-BJLihGaC.js +1 -0
- package/dist/assets/controlConnectionEpoch-CZsS0wAb.js +1 -0
- package/dist/assets/{flowMutationSafety-6cP-l4Pa.js → flowMutationSafety-DSd3JLAV.js} +1 -1
- package/dist/assets/index-ByvrmBif.js +8 -0
- package/dist/assets/index-CxhXRVH_.css +1 -0
- package/dist/assets/{install-BPp8nydm.js → install-C5y2ohOo.js} +2 -2
- package/dist/assets/{responses-C9lP49qu.js → responses-DiIfPXXq.js} +1 -1
- package/dist/assets/{toastStore-B31o6Z5J.js → toastStore-IZn6OPLZ.js} +1 -1
- package/dist/assets/{upstreamPaths-ZeijhPPb.js → upstreamPaths-B_cyinGI.js} +1 -1
- package/dist/assets/{useControlActionGuard-DT7uudrj.js → useControlActionGuard-B-bzKZKI.js} +1 -1
- package/dist/assets/usePolledData-0CVelkl2.js +1 -0
- package/dist/assets/{useServerActionGuard-Db7PPgaI.js → useServerActionGuard-uGePilQQ.js} +1 -1
- package/dist/assets/{useThroughputSeries-_J7gWbcI.js → useThroughputSeries-Cagm3qzB.js} +1 -1
- package/dist/index.html +8 -8
- package/package.json +13 -9
- package/scripts/bunVersion.ts +10 -0
- package/scripts/embedded.gen.ts +152 -150
- package/scripts/serve.ts +3 -0
- package/dist/assets/Database-koy8haPX.js +0 -3
- package/dist/assets/DlqPro-VMuBKgP-.js +0 -1
- package/dist/assets/QueueControl-BF638r_0.js +0 -1
- package/dist/assets/QueuesOverview-Du8XLgN0.js +0 -1
- package/dist/assets/Settings-CmxfSX_8.js +0 -1
- package/dist/assets/Workflows-B8wmQN2X.js +0 -1
- package/dist/assets/bq-BOfwNIlJ.js +0 -1
- package/dist/assets/connectionStore-CFuNYZ_8.js +0 -1
- package/dist/assets/controlConnectionEpoch-DzbuWOhe.js +0 -1
- package/dist/assets/index-B-QjN7pA.css +0 -1
- package/dist/assets/index-TploGF7u.js +0 -8
- package/dist/assets/usePolledData-CNtNtd0a.js +0 -1
package/README.md
CHANGED
|
@@ -96,7 +96,7 @@ server *process* through a separate guarded agent.
|
|
|
96
96
|
|
|
97
97
|
It uses Bunqueue's public HTTP API (`:6790`) for ordinary remote observability and a small local
|
|
98
98
|
**control agent** for process, FlowProducer, Workflow Engine, database, and backup operations. The
|
|
99
|
-
agent is pinned to the server it manages and uses the exact installable Bunqueue 2.9.
|
|
99
|
+
agent is pinned to the server it manages and uses the exact installable Bunqueue 2.9.2 npm client
|
|
100
100
|
contracts; it never patches Bunqueue internals.
|
|
101
101
|
|
|
102
102
|
## Features
|
|
@@ -104,6 +104,7 @@ contracts; it never patches Bunqueue internals.
|
|
|
104
104
|
| Area | Where | What you can do |
|
|
105
105
|
| --- | --- | --- |
|
|
106
106
|
| **Home** | Overview | Live health banner, throughput, queue health, recent activity |
|
|
107
|
+
| **Fleet** | Fleet | Probe and operate multiple Bunqueue APIs + paired agents; verify shared PostgreSQL target/namespace topology |
|
|
107
108
|
| **Server** | Control ▸ Server | **Start / stop / restart** the server process, edit its config, tail process logs |
|
|
108
109
|
| **Enqueue** | Control ▸ Add Job | Add jobs (single or bulk) with every option |
|
|
109
110
|
| **Inspect** | Control ▸ Job Inspector | Look up any job; promote / re-prioritize / delay; view data & result |
|
|
@@ -117,7 +118,7 @@ contracts; it never patches Bunqueue internals.
|
|
|
117
118
|
| **S3 backup** | Management ▸ S3 Backup | Configure, inspect, list, create and guarded-restore official snapshots |
|
|
118
119
|
| **Browse** | Queues / Jobs / DLQ / Cron / Metrics / Workers / Logs | Read-only browsing with basic actions |
|
|
119
120
|
|
|
120
|
-
> Job actions are gated by the v2.9.
|
|
121
|
+
> Job actions are gated by the v2.9.2 flow contract. Every DLQ retry and completed-job requeue is
|
|
121
122
|
> unavailable: the DLQ GET + POST sequence has no atomic generation/state/topology precondition and
|
|
122
123
|
> can target a job recreated under the same ID, while `retryCompleted` does not rebuild dependency
|
|
123
124
|
> registration or flow order. Cancel, Discard, Drain, Clean, Obliterate and DLQ Purge also fail
|
|
@@ -128,7 +129,7 @@ contracts; it never patches Bunqueue internals.
|
|
|
128
129
|
|
|
129
130
|
## Quick start
|
|
130
131
|
|
|
131
|
-
**Prerequisites:** [Bun](https://bun.sh)
|
|
132
|
+
**Prerequisites:** [Bun](https://bun.sh) 1.4.0 and a reachable bunqueue server (or let the control
|
|
132
133
|
agent start one for you from the **Server** page).
|
|
133
134
|
|
|
134
135
|
### Run from npm (no clone)
|
|
@@ -147,7 +148,7 @@ standalone binaries: `PORT` · `BIND_ADDR` · `BUNQUEUE_URL` · `AGENT_PORT` ·
|
|
|
147
148
|
Install it permanently instead of running via `bunx`:
|
|
148
149
|
|
|
149
150
|
```bash
|
|
150
|
-
bun add -g bunqueue-dashboard
|
|
151
|
+
bun add -g bunqueue-dashboard
|
|
151
152
|
bunqueue-dashboard
|
|
152
153
|
```
|
|
153
154
|
|
|
@@ -192,14 +193,22 @@ flowchart LR
|
|
|
192
193
|
- **Writes** through the same HTTP API, with every mutation shape-verified against the live server.
|
|
193
194
|
- **Process lifecycle**, the one thing HTTP can't do, is delegated to the local control agent.
|
|
194
195
|
|
|
196
|
+
For multiple brokers, create one named Dashboard profile and one paired control
|
|
197
|
+
agent per broker. The Fleet page probes all profiles without retargeting the
|
|
198
|
+
active UI, groups brokers that share an exact PostgreSQL target + namespace,
|
|
199
|
+
and drives each node's Start/Stop/Restart controls. Queue state is shared by
|
|
200
|
+
PostgreSQL; lifecycle, process logs/config, in-memory webhooks, and each agent's
|
|
201
|
+
Workflow Engine store remain node-local. See the
|
|
202
|
+
[Fleet guide](https://egeominotti.github.io/bunqueue-dashboard/docs/guide/fleet).
|
|
203
|
+
|
|
195
204
|
Two HTTP clients coexist on purpose: `src/lib/api.ts` (first-generation view pages) and
|
|
196
205
|
`src/lib/bq.ts` (the complete, shape-verified client used by every `Control ▸ *` page). See
|
|
197
206
|
[`docs/`](docs/README.md) for the full, source-verified reference.
|
|
198
207
|
|
|
199
208
|
## Configuration
|
|
200
209
|
|
|
201
|
-
The `VITE_*` dashboard defaults are build-time values and can **also**
|
|
202
|
-
|
|
210
|
+
The `VITE_*` dashboard defaults are build-time values and can **also** seed the first named profile
|
|
211
|
+
in **Settings**. Add up to 32 broker/agent profiles at runtime. The all-in-one server reads the non-`VITE_*` runtime variables below.
|
|
203
212
|
Copy [`.env.example`](.env.example) to `.env` to set defaults.
|
|
204
213
|
|
|
205
214
|
| Variable | Purpose | Default |
|
|
@@ -253,6 +262,8 @@ target-pinned Flow, Workflow, Queue and Backup agent operations.
|
|
|
253
262
|
| `bun run check` | Oxlint + Oxfmt validation (the CI gate) |
|
|
254
263
|
| `bun run check:fix` | Apply safe Oxlint fixes, then format with Oxfmt |
|
|
255
264
|
| `bun test` | Unit + agent-lifecycle tests |
|
|
265
|
+
| `bun run test:e2e` | Real Flow, Workflow, Queue SDK, and three-broker PostgreSQL runtime tests |
|
|
266
|
+
| `bun run test:e2e:postgres-fleet` | Three authenticated Bunqueue 2.9.2 brokers + three agents sharing disposable PostgreSQL 18.6 |
|
|
256
267
|
|
|
257
268
|
## Docker
|
|
258
269
|
|
|
@@ -299,8 +310,11 @@ The canonical gate must be green before a change is considered done; CI runs the
|
|
|
299
310
|
bun run quality # architecture, lint/build, size, docs, coverage, real E2E, packed-bin smoke, audit
|
|
300
311
|
```
|
|
301
312
|
|
|
302
|
-
The E2E stage starts disposable Bunqueue 2.9.
|
|
303
|
-
Workflow Engine, and Queue SDK operator bridges.
|
|
313
|
+
The E2E stage starts disposable Bunqueue 2.9.2 processes and exercises the complete FlowProducer,
|
|
314
|
+
Workflow Engine, and Queue SDK operator bridges. It also starts PostgreSQL 18.6 in Docker with
|
|
315
|
+
three authenticated brokers and paired agents, proving cross-broker enqueue/inspect/leased
|
|
316
|
+
pull+ack, pause/resume, cron, and rate-limit behavior. Run it alone with `bun run test:e2e` (Docker
|
|
317
|
+
is required for the PostgreSQL stage).
|
|
304
318
|
|
|
305
319
|
The blocking browser job builds the production bundle under `/e2e/dashboard`, starts an
|
|
306
320
|
authenticated disposable Bunqueue server, and drives the UI with Playwright on Chromium, Firefox,
|
package/agent/index.ts
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* BUNQUEUE_START_CMD, BUNQUEUE_MANAGED, BUNQUEUE_URL, BUNQUEUE_TOKEN,
|
|
13
13
|
* HTTP_PORT, TCP_PORT, BUNQUEUE_DATA_PATH
|
|
14
14
|
*/
|
|
15
|
+
import { assertRequiredBunVersion } from '../scripts/bunVersion';
|
|
15
16
|
import { logger } from './logger';
|
|
16
17
|
import { ProcessManager } from './manager';
|
|
17
18
|
import {
|
|
@@ -22,6 +23,8 @@ import {
|
|
|
22
23
|
} from './server';
|
|
23
24
|
import { installAgentShutdown } from './shutdown';
|
|
24
25
|
|
|
26
|
+
assertRequiredBunVersion();
|
|
27
|
+
|
|
25
28
|
const mgr = new ProcessManager();
|
|
26
29
|
const PORT = Number(process.env.AGENT_PORT) || 6800;
|
|
27
30
|
const allowedOrigins = resolveAllowedOrigins();
|
package/agent/manager/config.ts
CHANGED
|
@@ -10,7 +10,7 @@ const CONFIG_KEYS = new Set<keyof ServerConfig>([
|
|
|
10
10
|
|
|
11
11
|
export function defaultConfig(): ServerConfig {
|
|
12
12
|
return {
|
|
13
|
-
command: process.env.BUNQUEUE_START_CMD || 'bunx bunqueue@2.9.
|
|
13
|
+
command: process.env.BUNQUEUE_START_CMD || 'bunx bunqueue@2.9.2 start',
|
|
14
14
|
httpPort: Number(process.env.HTTP_PORT) || 6790,
|
|
15
15
|
tcpPort: Number(process.env.TCP_PORT) || 6789,
|
|
16
16
|
dataPath: process.env.BUNQUEUE_DATA_PATH || './data/bunq.db',
|
package/agent/manager/process.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { copyConfig, defaultConfig, validateConfigPatch, validateServerConfig } from './config';
|
|
2
2
|
import { ProcessLogs } from './logs';
|
|
3
3
|
import { databaseStats } from './storage';
|
|
4
|
-
import {
|
|
4
|
+
import { removeSqlitePaths, validateManagedStorage } from './storageMode';
|
|
5
5
|
import type {
|
|
6
6
|
DbStats,
|
|
7
7
|
LogLine,
|
|
@@ -110,7 +110,7 @@ export class ProcessManager {
|
|
|
110
110
|
}
|
|
111
111
|
|
|
112
112
|
const launchConfig = validateServerConfig(this.config);
|
|
113
|
-
const storageMode =
|
|
113
|
+
const storageMode = validateManagedStorage(launchConfig);
|
|
114
114
|
const [command, ...args] = launchConfig.command.trim().split(/\s+/);
|
|
115
115
|
this.status = 'starting';
|
|
116
116
|
this.exitCode = null;
|
|
@@ -12,6 +12,45 @@ export function managedStorageMode(config: ServerConfig): ManagedStorageMode {
|
|
|
12
12
|
return config.dataPath ? 'sqlite' : 'memory';
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
export function managedPostgresUrl(config: ServerConfig): string | undefined {
|
|
16
|
+
return (config.extraEnv.BUNQUEUE_POSTGRES_URL ?? process.env.BUNQUEUE_POSTGRES_URL)?.trim() || undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function managedPostgresNamespace(config: ServerConfig): string {
|
|
20
|
+
return (
|
|
21
|
+
(config.extraEnv.BUNQUEUE_POSTGRES_NAMESPACE ?? process.env.BUNQUEUE_POSTGRES_NAMESPACE)?.trim() ||
|
|
22
|
+
'default'
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Credential-free PostgreSQL cluster label safe to expose to authenticated operators. */
|
|
27
|
+
export function managedPostgresTarget(config: ServerConfig): string | undefined {
|
|
28
|
+
const configured = managedPostgresUrl(config);
|
|
29
|
+
if (!configured) return undefined;
|
|
30
|
+
try {
|
|
31
|
+
const parsed = new URL(configured);
|
|
32
|
+
if (
|
|
33
|
+
(parsed.protocol !== 'postgres:' && parsed.protocol !== 'postgresql:') ||
|
|
34
|
+
!parsed.hostname
|
|
35
|
+
) {
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
const database = decodeURIComponent(parsed.pathname.replace(/^\/+/, '')) || 'postgres';
|
|
39
|
+
return `${parsed.hostname}:${parsed.port || '5432'}/${database}`;
|
|
40
|
+
} catch {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Fail before spawning when Bunqueue would reject an incomplete PostgreSQL config. */
|
|
46
|
+
export function validateManagedStorage(config: ServerConfig): ManagedStorageMode {
|
|
47
|
+
const mode = managedStorageMode(config);
|
|
48
|
+
if (mode === 'postgres' && !managedPostgresUrl(config)) {
|
|
49
|
+
throw new Error('PostgreSQL storage requires BUNQUEUE_POSTGRES_URL');
|
|
50
|
+
}
|
|
51
|
+
return mode;
|
|
52
|
+
}
|
|
53
|
+
|
|
15
54
|
/** Non-SQLite drivers cannot be combined with Bunqueue's legacy SQLite path aliases. */
|
|
16
55
|
export function removeSqlitePaths(env: Record<string, string>): void {
|
|
17
56
|
delete env.BUNQUEUE_DATA_PATH;
|
package/agent/manager.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
/** Public compatibility entrypoint for managed bunqueue process supervision. */
|
|
2
2
|
export { validateConfigPatch } from './manager/config';
|
|
3
3
|
export { ProcessManager } from './manager/process';
|
|
4
|
-
export {
|
|
4
|
+
export {
|
|
5
|
+
managedPostgresNamespace,
|
|
6
|
+
managedPostgresTarget,
|
|
7
|
+
managedStorageMode,
|
|
8
|
+
} from './manager/storageMode';
|
|
5
9
|
export type {
|
|
6
10
|
DbStats,
|
|
7
11
|
LogLine,
|
package/agent/queue/routes.ts
CHANGED
|
@@ -13,13 +13,17 @@ import {
|
|
|
13
13
|
exactJsonBody,
|
|
14
14
|
exactQuery,
|
|
15
15
|
metricsRange,
|
|
16
|
+
optionalMaxCount,
|
|
16
17
|
optionalMaxJobs,
|
|
18
|
+
positiveSafeInteger,
|
|
17
19
|
requiredDeduplicationId,
|
|
20
|
+
requiredGroupId,
|
|
18
21
|
validateQueueName,
|
|
19
22
|
} from './validation';
|
|
20
23
|
|
|
21
|
-
const READ_ROUTE = /^\/queue-operations\/([^/]+)\/(limits|deduplication|metrics)$/;
|
|
22
|
-
const MUTATION_ROUTE =
|
|
24
|
+
const READ_ROUTE = /^\/queue-operations\/([^/]+)\/(limits|groups|deduplication|metrics)$/;
|
|
25
|
+
const MUTATION_ROUTE =
|
|
26
|
+
/^\/queue-operations\/([^/]+)\/(groups\/(?:rate-limit|concurrency)(?:\/remove)?|deduplication\/remove|events\/trim)$/;
|
|
23
27
|
|
|
24
28
|
export async function routeQueueOperationsRequest(
|
|
25
29
|
request: Request,
|
|
@@ -50,6 +54,21 @@ export async function routeQueueOperationsRequest(
|
|
|
50
54
|
ok({ jobId: await runtime.deduplicationJobId(current, queue, id) })
|
|
51
55
|
);
|
|
52
56
|
}
|
|
57
|
+
if (read[2] === 'groups') {
|
|
58
|
+
const query = pinnedQuery(
|
|
59
|
+
request,
|
|
60
|
+
config,
|
|
61
|
+
['target', 'groupId', 'maxJobs', 'maxCount'],
|
|
62
|
+
targetPolicy
|
|
63
|
+
);
|
|
64
|
+
const groupId = requiredGroupId(query.get('groupId'));
|
|
65
|
+
const maxJobs = optionalMaxJobs(query);
|
|
66
|
+
const maxCount = optionalMaxCount(query);
|
|
67
|
+
assertRunning(running);
|
|
68
|
+
return admitted(query, config, running, admission, targetPolicy, async ({ config: current }) =>
|
|
69
|
+
ok({ group: await runtime.group(current, queue, groupId, maxJobs, maxCount) })
|
|
70
|
+
);
|
|
71
|
+
}
|
|
53
72
|
const query = pinnedQuery(request, config, ['target', 'type', 'start', 'end'], targetPolicy);
|
|
54
73
|
const type = metricType(query.get('type'));
|
|
55
74
|
const range = metricsRange(query);
|
|
@@ -73,6 +92,42 @@ export async function routeQueueOperationsRequest(
|
|
|
73
92
|
ok({ removed: await runtime.removeDeduplicationKey(current, queue, id) })
|
|
74
93
|
);
|
|
75
94
|
}
|
|
95
|
+
if (mutation[2].startsWith('groups/')) {
|
|
96
|
+
const remove = mutation[2].endsWith('/remove');
|
|
97
|
+
const rateLimit = mutation[2].includes('/rate-limit');
|
|
98
|
+
const allowed = remove
|
|
99
|
+
? ['groupId']
|
|
100
|
+
: rateLimit
|
|
101
|
+
? ['groupId', 'max', 'duration']
|
|
102
|
+
: ['groupId', 'concurrency'];
|
|
103
|
+
const body = await exactJsonBody(request, allowed);
|
|
104
|
+
const groupId = requiredGroupId(body.groupId);
|
|
105
|
+
return admitted(query, config, running, admission, targetPolicy, async ({ config: current }) => {
|
|
106
|
+
if (remove) {
|
|
107
|
+
const removed = rateLimit
|
|
108
|
+
? await runtime.removeGroupRateLimit(current, queue, groupId)
|
|
109
|
+
: await runtime.removeGroupConcurrency(current, queue, groupId);
|
|
110
|
+
return ok({ removed });
|
|
111
|
+
}
|
|
112
|
+
if (rateLimit) {
|
|
113
|
+
await runtime.setGroupRateLimit(
|
|
114
|
+
current,
|
|
115
|
+
queue,
|
|
116
|
+
groupId,
|
|
117
|
+
positiveSafeInteger(body.max, 'max'),
|
|
118
|
+
positiveSafeInteger(body.duration, 'duration')
|
|
119
|
+
);
|
|
120
|
+
} else {
|
|
121
|
+
await runtime.setGroupConcurrency(
|
|
122
|
+
current,
|
|
123
|
+
queue,
|
|
124
|
+
groupId,
|
|
125
|
+
positiveSafeInteger(body.concurrency, 'concurrency')
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
return ok({ applied: true });
|
|
129
|
+
});
|
|
130
|
+
}
|
|
76
131
|
const body = await exactJsonBody(request, ['maxLength']);
|
|
77
132
|
const retention = eventRetention(body.maxLength);
|
|
78
133
|
return admitted(query, config, running, admission, targetPolicy, async ({ config: current }) =>
|
package/agent/queue/runtime.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Queue, type QueueMetricType, type QueueMetrics } from 'bunqueue/client';
|
|
2
2
|
import { managedAuthToken } from '../managedTarget';
|
|
3
3
|
import type { ServerConfig } from '../manager';
|
|
4
|
-
import type { QueueLimitSnapshot, QueueOperationsPort } from './types';
|
|
4
|
+
import type { QueueGroupSnapshot, QueueLimitSnapshot, QueueOperationsPort } from './types';
|
|
5
5
|
import { QueueOperationsUnavailableError } from './types';
|
|
6
6
|
|
|
7
7
|
export type QueueOperationsClient = Pick<
|
|
@@ -11,6 +11,16 @@ export type QueueOperationsClient = Pick<
|
|
|
11
11
|
| 'getGlobalConcurrency'
|
|
12
12
|
| 'getRateLimitTtl'
|
|
13
13
|
| 'isMaxed'
|
|
14
|
+
| 'getGroupJobsCount'
|
|
15
|
+
| 'getGroupsJobsCount'
|
|
16
|
+
| 'getGroupActiveCount'
|
|
17
|
+
| 'setGroupRateLimit'
|
|
18
|
+
| 'getGroupRateLimit'
|
|
19
|
+
| 'removeGroupRateLimit'
|
|
20
|
+
| 'getGroupRateLimitTtl'
|
|
21
|
+
| 'setGroupConcurrency'
|
|
22
|
+
| 'getGroupConcurrency'
|
|
23
|
+
| 'removeGroupConcurrency'
|
|
14
24
|
| 'getDeduplicationJobId'
|
|
15
25
|
| 'removeDeduplicationKey'
|
|
16
26
|
| 'removeDlqJob'
|
|
@@ -42,6 +52,57 @@ export class QueueOperationsRuntime implements QueueOperationsPort {
|
|
|
42
52
|
});
|
|
43
53
|
}
|
|
44
54
|
|
|
55
|
+
group(
|
|
56
|
+
config: ServerConfig,
|
|
57
|
+
queue: string,
|
|
58
|
+
groupId: string,
|
|
59
|
+
maxJobs?: number,
|
|
60
|
+
maxCount?: number
|
|
61
|
+
): Promise<QueueGroupSnapshot> {
|
|
62
|
+
return this.withQueue(config, queue, async (client) => {
|
|
63
|
+
const [jobs, active, totalGrouped, rateLimit, rateLimitTtl, concurrency] = await Promise.all([
|
|
64
|
+
client.getGroupJobsCount(groupId),
|
|
65
|
+
client.getGroupActiveCount(groupId),
|
|
66
|
+
client.getGroupsJobsCount(maxCount),
|
|
67
|
+
client.getGroupRateLimit(groupId),
|
|
68
|
+
client.getGroupRateLimitTtl(groupId, maxJobs),
|
|
69
|
+
client.getGroupConcurrency(groupId),
|
|
70
|
+
]);
|
|
71
|
+
return { jobs, active, totalGrouped, rateLimit, rateLimitTtl, concurrency };
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
setGroupRateLimit(
|
|
76
|
+
config: ServerConfig,
|
|
77
|
+
queue: string,
|
|
78
|
+
groupId: string,
|
|
79
|
+
max: number,
|
|
80
|
+
duration: number
|
|
81
|
+
): Promise<void> {
|
|
82
|
+
return this.withQueue(config, queue, (client) =>
|
|
83
|
+
client.setGroupRateLimit(groupId, max, duration)
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
removeGroupRateLimit(config: ServerConfig, queue: string, groupId: string): Promise<number> {
|
|
88
|
+
return this.withQueue(config, queue, (client) => client.removeGroupRateLimit(groupId));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
setGroupConcurrency(
|
|
92
|
+
config: ServerConfig,
|
|
93
|
+
queue: string,
|
|
94
|
+
groupId: string,
|
|
95
|
+
concurrency: number
|
|
96
|
+
): Promise<void> {
|
|
97
|
+
return this.withQueue(config, queue, (client) =>
|
|
98
|
+
client.setGroupConcurrency(groupId, concurrency)
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
removeGroupConcurrency(config: ServerConfig, queue: string, groupId: string): Promise<number> {
|
|
103
|
+
return this.withQueue(config, queue, (client) => client.removeGroupConcurrency(groupId));
|
|
104
|
+
}
|
|
105
|
+
|
|
45
106
|
deduplicationJobId(
|
|
46
107
|
config: ServerConfig,
|
|
47
108
|
queue: string,
|
package/agent/queue/types.ts
CHANGED
|
@@ -8,8 +8,39 @@ export interface QueueLimitSnapshot {
|
|
|
8
8
|
maxed: boolean;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
export interface QueueGroupSnapshot {
|
|
12
|
+
jobs: number;
|
|
13
|
+
active: number;
|
|
14
|
+
totalGrouped: number;
|
|
15
|
+
rateLimit: { max: number; duration: number } | null;
|
|
16
|
+
rateLimitTtl: number;
|
|
17
|
+
concurrency: number | null;
|
|
18
|
+
}
|
|
19
|
+
|
|
11
20
|
export interface QueueOperationsPort {
|
|
12
21
|
limits(config: ServerConfig, queue: string, maxJobs?: number): Promise<QueueLimitSnapshot>;
|
|
22
|
+
group(
|
|
23
|
+
config: ServerConfig,
|
|
24
|
+
queue: string,
|
|
25
|
+
groupId: string,
|
|
26
|
+
maxJobs?: number,
|
|
27
|
+
maxCount?: number
|
|
28
|
+
): Promise<QueueGroupSnapshot>;
|
|
29
|
+
setGroupRateLimit(
|
|
30
|
+
config: ServerConfig,
|
|
31
|
+
queue: string,
|
|
32
|
+
groupId: string,
|
|
33
|
+
max: number,
|
|
34
|
+
duration: number
|
|
35
|
+
): Promise<void>;
|
|
36
|
+
removeGroupRateLimit(config: ServerConfig, queue: string, groupId: string): Promise<number>;
|
|
37
|
+
setGroupConcurrency(
|
|
38
|
+
config: ServerConfig,
|
|
39
|
+
queue: string,
|
|
40
|
+
groupId: string,
|
|
41
|
+
concurrency: number
|
|
42
|
+
): Promise<void>;
|
|
43
|
+
removeGroupConcurrency(config: ServerConfig, queue: string, groupId: string): Promise<number>;
|
|
13
44
|
deduplicationJobId(
|
|
14
45
|
config: ServerConfig,
|
|
15
46
|
queue: string,
|
|
@@ -40,6 +40,22 @@ export function optionalMaxJobs(query: URLSearchParams): number | undefined {
|
|
|
40
40
|
return raw === null ? undefined : boundedInteger(raw, 'maxJobs', 0, 1_000_000);
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
export function optionalMaxCount(query: URLSearchParams): number | undefined {
|
|
44
|
+
const raw = query.get('maxCount');
|
|
45
|
+
return raw === null ? undefined : boundedInteger(raw, 'maxCount', 1, 1_000_000);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function requiredGroupId(value: unknown): string {
|
|
49
|
+
if (typeof value !== 'string' || !value || value.length > 256 || value.includes('\0')) {
|
|
50
|
+
throw new Error('groupId must be a non-empty string of at most 256 characters without NUL');
|
|
51
|
+
}
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function positiveSafeInteger(value: unknown, label: string): number {
|
|
56
|
+
return bodyInteger(value, label, 1, Number.MAX_SAFE_INTEGER);
|
|
57
|
+
}
|
|
58
|
+
|
|
43
59
|
export function metricsRange(query: URLSearchParams): { start: number; end: number } {
|
|
44
60
|
const start = optionalInteger(query.get('start'), 'start', 0, MAX_PAGE_INDEX, 0);
|
|
45
61
|
const rawEnd = query.get('end');
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { type ProcessManager, validateConfigPatch } from '../manager';
|
|
2
2
|
import { safeErrorMessage } from '../errorMessage';
|
|
3
3
|
import {
|
|
4
4
|
WorkflowRuntimeUnavailableError,
|
|
@@ -7,93 +7,14 @@ import {
|
|
|
7
7
|
import { readLimitedJsonBody } from './jsonBody';
|
|
8
8
|
import {
|
|
9
9
|
MANAGED_CONTROL_TARGET,
|
|
10
|
-
probeExternalHealth,
|
|
11
10
|
type ServerControlTarget,
|
|
12
11
|
} from './controlTarget';
|
|
12
|
+
import { statusWithHealth } from './controlStatus';
|
|
13
13
|
import type { AgentLifecyclePort } from './lifecycle';
|
|
14
14
|
import type { RouteResponse } from './types';
|
|
15
15
|
|
|
16
16
|
const MAX_CONFIG_BODY_BYTES = 64 * 1024;
|
|
17
17
|
|
|
18
|
-
async function statusWithHealth(
|
|
19
|
-
manager: ProcessManager,
|
|
20
|
-
controlTarget: ServerControlTarget,
|
|
21
|
-
retryOnProcessChange = true
|
|
22
|
-
) {
|
|
23
|
-
const snapshot = manager.getStatus();
|
|
24
|
-
const configRevision = snapshot.configRevision ?? manager.getConfigRevision?.() ?? 0;
|
|
25
|
-
if (controlTarget.mode === 'external') {
|
|
26
|
-
const health = await probeExternalHealth(controlTarget);
|
|
27
|
-
return {
|
|
28
|
-
...snapshot,
|
|
29
|
-
configRevision,
|
|
30
|
-
managementMode: 'external' as const,
|
|
31
|
-
healthy: health.healthy,
|
|
32
|
-
version: health.version,
|
|
33
|
-
reachable: health.reachable,
|
|
34
|
-
externalUrl: controlTarget.url,
|
|
35
|
-
healthStatus: health.statusCode,
|
|
36
|
-
healthError: health.error,
|
|
37
|
-
db: null,
|
|
38
|
-
};
|
|
39
|
-
}
|
|
40
|
-
let healthy = false;
|
|
41
|
-
let version: string | undefined;
|
|
42
|
-
if (snapshot.status === 'running') {
|
|
43
|
-
const port = snapshot.runningConfig?.httpPort ?? snapshot.config.httpPort;
|
|
44
|
-
try {
|
|
45
|
-
const response = await fetch(`http://127.0.0.1:${port}/health`, {
|
|
46
|
-
signal: AbortSignal.timeout(1500),
|
|
47
|
-
});
|
|
48
|
-
if (response.ok) {
|
|
49
|
-
const body: unknown = await response.json();
|
|
50
|
-
if (body !== null && typeof body === 'object' && !Array.isArray(body)) {
|
|
51
|
-
const health = body as Record<string, unknown>;
|
|
52
|
-
healthy = health.ok === true;
|
|
53
|
-
if (typeof health.version === 'string') version = health.version;
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
} catch {
|
|
57
|
-
// The managed process is not healthy yet.
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
const effectiveConfig = snapshot.runningConfig ?? snapshot.config;
|
|
61
|
-
const database = await statusDatabase(manager, effectiveConfig);
|
|
62
|
-
const current = manager.getStatus();
|
|
63
|
-
if (!sameProcessSnapshot(snapshot, current)) {
|
|
64
|
-
if (retryOnProcessChange) return statusWithHealth(manager, controlTarget, false);
|
|
65
|
-
const currentConfig = current.runningConfig ?? current.config;
|
|
66
|
-
return {
|
|
67
|
-
...current,
|
|
68
|
-
configRevision: current.configRevision ?? manager.getConfigRevision?.() ?? 0,
|
|
69
|
-
managementMode: 'managed' as const,
|
|
70
|
-
healthy: false,
|
|
71
|
-
version: undefined,
|
|
72
|
-
db: await statusDatabase(manager, currentConfig),
|
|
73
|
-
};
|
|
74
|
-
}
|
|
75
|
-
return {
|
|
76
|
-
...snapshot,
|
|
77
|
-
configRevision,
|
|
78
|
-
managementMode: 'managed' as const,
|
|
79
|
-
healthy,
|
|
80
|
-
version,
|
|
81
|
-
db: database,
|
|
82
|
-
};
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
async function statusDatabase(
|
|
86
|
-
manager: ProcessManager,
|
|
87
|
-
config: ReturnType<ProcessManager['getConfig']>
|
|
88
|
-
) {
|
|
89
|
-
try {
|
|
90
|
-
return managedStorageMode(config) === 'sqlite' ? await manager.dbStats(config.dataPath) : null;
|
|
91
|
-
} catch {
|
|
92
|
-
// Status must remain reachable so an invalid desired storage driver can be corrected.
|
|
93
|
-
return null;
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
|
|
97
18
|
export async function routeControlRequest(
|
|
98
19
|
request: Request,
|
|
99
20
|
pathname: string,
|
|
@@ -262,18 +183,6 @@ function coordinatedLease<T>(
|
|
|
262
183
|
return lifecycle ? lifecycle.lease(operation) : operation();
|
|
263
184
|
}
|
|
264
185
|
|
|
265
|
-
function sameProcessSnapshot(
|
|
266
|
-
initial: ReturnType<ProcessManager['getStatus']>,
|
|
267
|
-
current: ReturnType<ProcessManager['getStatus']>
|
|
268
|
-
): boolean {
|
|
269
|
-
return (
|
|
270
|
-
initial.status === current.status &&
|
|
271
|
-
initial.generation === current.generation &&
|
|
272
|
-
initial.pid === current.pid &&
|
|
273
|
-
initial.configRevision === current.configRevision
|
|
274
|
-
);
|
|
275
|
-
}
|
|
276
|
-
|
|
277
186
|
type SettledOperation = { ok: true } | { ok: false; error: unknown };
|
|
278
187
|
|
|
279
188
|
async function settle(operation: () => Promise<unknown>): Promise<SettledOperation> {
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import {
|
|
2
|
+
managedPostgresNamespace,
|
|
3
|
+
managedPostgresTarget,
|
|
4
|
+
managedStorageMode,
|
|
5
|
+
type ProcessManager,
|
|
6
|
+
} from '../manager';
|
|
7
|
+
import { probeExternalHealth, type ServerControlTarget } from './controlTarget';
|
|
8
|
+
|
|
9
|
+
export async function statusWithHealth(
|
|
10
|
+
manager: ProcessManager,
|
|
11
|
+
controlTarget: ServerControlTarget,
|
|
12
|
+
retryOnProcessChange = true
|
|
13
|
+
) {
|
|
14
|
+
const snapshot = manager.getStatus();
|
|
15
|
+
const configRevision = snapshot.configRevision ?? manager.getConfigRevision?.() ?? 0;
|
|
16
|
+
if (controlTarget.mode === 'external') {
|
|
17
|
+
const health = await probeExternalHealth(controlTarget);
|
|
18
|
+
return {
|
|
19
|
+
...snapshot,
|
|
20
|
+
configRevision,
|
|
21
|
+
managementMode: 'external' as const,
|
|
22
|
+
healthy: health.healthy,
|
|
23
|
+
version: health.version,
|
|
24
|
+
reachable: health.reachable,
|
|
25
|
+
externalUrl: controlTarget.url,
|
|
26
|
+
healthStatus: health.statusCode,
|
|
27
|
+
healthError: health.error,
|
|
28
|
+
db: null,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
let healthy = false;
|
|
32
|
+
let version: string | undefined;
|
|
33
|
+
if (snapshot.status === 'running') {
|
|
34
|
+
const port = snapshot.runningConfig?.httpPort ?? snapshot.config.httpPort;
|
|
35
|
+
try {
|
|
36
|
+
const response = await fetch(`http://127.0.0.1:${port}/health`, {
|
|
37
|
+
signal: AbortSignal.timeout(1500),
|
|
38
|
+
});
|
|
39
|
+
if (response.ok) {
|
|
40
|
+
const body: unknown = await response.json();
|
|
41
|
+
if (body !== null && typeof body === 'object' && !Array.isArray(body)) {
|
|
42
|
+
const health = body as Record<string, unknown>;
|
|
43
|
+
healthy = health.ok === true;
|
|
44
|
+
if (typeof health.version === 'string') version = health.version;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
} catch {
|
|
48
|
+
// The managed process is not healthy yet.
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const effectiveConfig = snapshot.runningConfig ?? snapshot.config;
|
|
52
|
+
const storageMode = statusStorageMode(effectiveConfig);
|
|
53
|
+
const database = await statusDatabase(manager, effectiveConfig);
|
|
54
|
+
const current = manager.getStatus();
|
|
55
|
+
if (!sameProcessSnapshot(snapshot, current)) {
|
|
56
|
+
if (retryOnProcessChange) return statusWithHealth(manager, controlTarget, false);
|
|
57
|
+
const currentConfig = current.runningConfig ?? current.config;
|
|
58
|
+
const currentStorageMode = statusStorageMode(currentConfig);
|
|
59
|
+
return {
|
|
60
|
+
...current,
|
|
61
|
+
configRevision: current.configRevision ?? manager.getConfigRevision?.() ?? 0,
|
|
62
|
+
managementMode: 'managed' as const,
|
|
63
|
+
healthy: false,
|
|
64
|
+
version: undefined,
|
|
65
|
+
storageMode: currentStorageMode,
|
|
66
|
+
postgresNamespace:
|
|
67
|
+
currentStorageMode === 'postgres' ? managedPostgresNamespace(currentConfig) : undefined,
|
|
68
|
+
postgresTarget:
|
|
69
|
+
currentStorageMode === 'postgres' ? managedPostgresTarget(currentConfig) : undefined,
|
|
70
|
+
db: await statusDatabase(manager, currentConfig),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
...snapshot,
|
|
75
|
+
configRevision,
|
|
76
|
+
managementMode: 'managed' as const,
|
|
77
|
+
healthy,
|
|
78
|
+
version,
|
|
79
|
+
storageMode,
|
|
80
|
+
postgresNamespace:
|
|
81
|
+
storageMode === 'postgres' ? managedPostgresNamespace(effectiveConfig) : undefined,
|
|
82
|
+
postgresTarget:
|
|
83
|
+
storageMode === 'postgres' ? managedPostgresTarget(effectiveConfig) : undefined,
|
|
84
|
+
db: database,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function statusDatabase(
|
|
89
|
+
manager: ProcessManager,
|
|
90
|
+
config: ReturnType<ProcessManager['getConfig']>
|
|
91
|
+
) {
|
|
92
|
+
try {
|
|
93
|
+
return managedStorageMode(config) === 'sqlite' ? await manager.dbStats(config.dataPath) : null;
|
|
94
|
+
} catch {
|
|
95
|
+
// Status must remain reachable so an invalid desired storage driver can be corrected.
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function statusStorageMode(
|
|
101
|
+
config: ReturnType<ProcessManager['getConfig']>
|
|
102
|
+
): ReturnType<typeof managedStorageMode> | undefined {
|
|
103
|
+
try {
|
|
104
|
+
return managedStorageMode(config);
|
|
105
|
+
} catch {
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function sameProcessSnapshot(
|
|
111
|
+
initial: ReturnType<ProcessManager['getStatus']>,
|
|
112
|
+
current: ReturnType<ProcessManager['getStatus']>
|
|
113
|
+
): boolean {
|
|
114
|
+
return (
|
|
115
|
+
initial.status === current.status &&
|
|
116
|
+
initial.generation === current.generation &&
|
|
117
|
+
initial.pid === current.pid &&
|
|
118
|
+
initial.configRevision === current.configRevision
|
|
119
|
+
);
|
|
120
|
+
}
|