bunqueue-dashboard 0.0.39 → 0.0.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +5 -5
  2. package/agent/backup/resultValidation.ts +1 -1
  3. package/agent/index.ts +3 -0
  4. package/agent/manager/config.ts +1 -1
  5. package/agent/manager/process.ts +2 -2
  6. package/agent/manager/storageMode.ts +20 -0
  7. package/agent/manager.ts +1 -1
  8. package/agent/queue/routes.ts +57 -2
  9. package/agent/queue/runtime.ts +62 -1
  10. package/agent/queue/types.ts +31 -0
  11. package/agent/queue/validation.ts +16 -0
  12. package/agent/server/controlRoutes.ts +2 -93
  13. package/agent/server/controlStatus.ts +115 -0
  14. package/agent/workflows.ts +1 -1
  15. package/dist/.vite/manifest.json +120 -120
  16. package/dist/assets/{AddJob-d5KBvmN8.js → AddJob-DkbI_J5p.js} +1 -1
  17. package/dist/assets/{Alerts-5I44QTnQ.js → Alerts-CFj-u0iV.js} +1 -1
  18. package/dist/assets/{Benchmark-S1afsQRd.js → Benchmark-Ey2sq57q.js} +1 -1
  19. package/dist/assets/{BulkAddJobs-BO8LWUHE.js → BulkAddJobs-BwPoeSB6.js} +1 -1
  20. package/dist/assets/{CopilotPanel-DDkRd35p.js → CopilotPanel-CniuIIPn.js} +1 -1
  21. package/dist/assets/{Cron-X3Y5uISO.js → Cron-B5XFBvU-.js} +1 -1
  22. package/dist/assets/{CronManager-ugmYEAwY.js → CronManager-Bdg2BECM.js} +1 -1
  23. package/dist/assets/{Database-koy8haPX.js → Database-CB0oOEFB.js} +1 -1
  24. package/dist/assets/{Diagnostics-D6oqvkOs.js → Diagnostics-BDRftXQy.js} +1 -1
  25. package/dist/assets/{Dlq-D70Lzmsa.js → Dlq-CJO9itzJ.js} +1 -1
  26. package/dist/assets/{DlqControl-nUT5Zzkl.js → DlqControl-DZL2lHOU.js} +1 -1
  27. package/dist/assets/{DlqPro-VMuBKgP-.js → DlqPro-OqBlzCnN.js} +1 -1
  28. package/dist/assets/{Flows-CAG0nDnt.js → Flows-BItJzbVQ.js} +2 -2
  29. package/dist/assets/{JobInspector-CiS5HOSQ.js → JobInspector-CJHX63p_.js} +2 -2
  30. package/dist/assets/{Jobs-CkyRvzOT.js → Jobs-BwmTsjJx.js} +1 -1
  31. package/dist/assets/{JobsPro-B4-CSoq3.js → JobsPro-eFVAZJ10.js} +1 -1
  32. package/dist/assets/{Logs-DBTz2PTz.js → Logs-CXIf58M4.js} +1 -1
  33. package/dist/assets/{LogsPro-BCFeegvr.js → LogsPro-Xm6rylAd.js} +1 -1
  34. package/dist/assets/{McpServer-BKlTR1qr.js → McpServer-Rf3i6lRn.js} +2 -2
  35. package/dist/assets/{Metrics-Du__9Lbn.js → Metrics-Cj3VYxzL.js} +1 -1
  36. package/dist/assets/{MetricsPro-CqnaO6YX.js → MetricsPro-C63eY00A.js} +1 -1
  37. package/dist/assets/{Overview-udSSt7ZB.js → Overview-CUM3XG7A.js} +1 -1
  38. package/dist/assets/{QueueActions-D7cQrGvJ.js → QueueActions-b19K0vCT.js} +1 -1
  39. package/dist/assets/QueueControl-DusMoa05.js +1 -0
  40. package/dist/assets/{QueueDetail-C-ms6bMe.js → QueueDetail-CpFIG1PU.js} +1 -1
  41. package/dist/assets/{QueueDetailPro-DXEdsZx_.js → QueueDetailPro-BNlBOj5s.js} +1 -1
  42. package/dist/assets/{Queues-BUMwTVAf.js → Queues-CZ2qc7FY.js} +1 -1
  43. package/dist/assets/{QueuesOverview-Du8XLgN0.js → QueuesOverview-CBFqQ3N_.js} +1 -1
  44. package/dist/assets/{S3Backup-CDICSzxM.js → S3Backup-x4mOV-AO.js} +1 -1
  45. package/dist/assets/{S3BackupPro-CBidbI-s.js → S3BackupPro-BtvbPVg-.js} +3 -3
  46. package/dist/assets/{ServerControl-CPT0LpsC.js → ServerControl-C5nQ7CCp.js} +2 -2
  47. package/dist/assets/{Settings-CmxfSX_8.js → Settings-B5kCRSBH.js} +1 -1
  48. package/dist/assets/{Usage-B895tSxi.js → Usage-UYovs3D-.js} +1 -1
  49. package/dist/assets/{UsagePro-CASN77iV.js → UsagePro-bT82DS-d.js} +1 -1
  50. package/dist/assets/{Webhooks-CXbZOFFw.js → Webhooks-BjsUMJAn.js} +1 -1
  51. package/dist/assets/{Workers-BceQlUhX.js → Workers-CD4YZpca.js} +1 -1
  52. package/dist/assets/{WorkersPro-Dtml89jB.js → WorkersPro-hCPhfb1Z.js} +1 -1
  53. package/dist/assets/{Workflows-B8wmQN2X.js → Workflows-DOpeo-TP.js} +1 -1
  54. package/dist/assets/{api-81m1TwB7.js → api-BxYPEynh.js} +1 -1
  55. package/dist/assets/bq-C7wdABtE.js +1 -0
  56. package/dist/assets/{flowMutationSafety-6cP-l4Pa.js → flowMutationSafety-DSd3JLAV.js} +1 -1
  57. package/dist/assets/{index-TploGF7u.js → index-AUHH2nTG.js} +3 -3
  58. package/dist/assets/{index-B-QjN7pA.css → index-BK7fjWKK.css} +1 -1
  59. package/dist/assets/{install-BPp8nydm.js → install-C5y2ohOo.js} +2 -2
  60. package/dist/assets/{responses-C9lP49qu.js → responses-DiIfPXXq.js} +1 -1
  61. package/dist/assets/{upstreamPaths-ZeijhPPb.js → upstreamPaths-B_cyinGI.js} +1 -1
  62. package/dist/assets/{useThroughputSeries-_J7gWbcI.js → useThroughputSeries-y7Yyi7Zh.js} +1 -1
  63. package/dist/index.html +5 -5
  64. package/package.json +10 -7
  65. package/scripts/bunVersion.ts +10 -0
  66. package/scripts/embedded.gen.ts +148 -148
  67. package/scripts/serve.ts +3 -0
  68. package/dist/assets/QueueControl-BF638r_0.js +0 -1
  69. package/dist/assets/bq-BOfwNIlJ.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.0 npm client
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
@@ -117,7 +117,7 @@ contracts; it never patches Bunqueue internals.
117
117
  | **S3 backup** | Management ▸ S3 Backup | Configure, inspect, list, create and guarded-restore official snapshots |
118
118
  | **Browse** | Queues / Jobs / DLQ / Cron / Metrics / Workers / Logs | Read-only browsing with basic actions |
119
119
 
120
- > Job actions are gated by the v2.9.0 flow contract. Every DLQ retry and completed-job requeue is
120
+ > Job actions are gated by the v2.9.2 flow contract. Every DLQ retry and completed-job requeue is
121
121
  > unavailable: the DLQ GET + POST sequence has no atomic generation/state/topology precondition and
122
122
  > can target a job recreated under the same ID, while `retryCompleted` does not rebuild dependency
123
123
  > registration or flow order. Cancel, Discard, Drain, Clean, Obliterate and DLQ Purge also fail
@@ -128,7 +128,7 @@ contracts; it never patches Bunqueue internals.
128
128
 
129
129
  ## Quick start
130
130
 
131
- **Prerequisites:** [Bun](https://bun.sh) ≥ 1.4.0 and a reachable bunqueue server (or let the control
131
+ **Prerequisites:** [Bun](https://bun.sh) 1.4.0 and a reachable bunqueue server (or let the control
132
132
  agent start one for you from the **Server** page).
133
133
 
134
134
  ### Run from npm (no clone)
@@ -147,7 +147,7 @@ standalone binaries: `PORT` · `BIND_ADDR` · `BUNQUEUE_URL` · `AGENT_PORT` ·
147
147
  Install it permanently instead of running via `bunx`:
148
148
 
149
149
  ```bash
150
- bun add -g bunqueue-dashboard # or: npm i -g bunqueue-dashboard (still runs on Bun)
150
+ bun add -g bunqueue-dashboard
151
151
  bunqueue-dashboard
152
152
  ```
153
153
 
@@ -299,7 +299,7 @@ The canonical gate must be green before a change is considered done; CI runs the
299
299
  bun run quality # architecture, lint/build, size, docs, coverage, real E2E, packed-bin smoke, audit
300
300
  ```
301
301
 
302
- The E2E stage starts disposable Bunqueue 2.9.0 processes and exercises the complete FlowProducer,
302
+ The E2E stage starts disposable Bunqueue 2.9.2 processes and exercises the complete FlowProducer,
303
303
  Workflow Engine, and Queue SDK operator bridges. Run it alone with `bun run test:e2e`.
304
304
 
305
305
  The blocking browser job builds the production bundle under `/e2e/dashboard`, starts an
@@ -81,5 +81,5 @@ function validIsoDate(value: string): boolean {
81
81
  }
82
82
 
83
83
  function invalid(label: string): never {
84
- throw new Error(`Bunqueue ${label} has an invalid 2.9.0 contract`);
84
+ throw new Error(`Bunqueue ${label} has an invalid 2.9.2 contract`);
85
85
  }
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();
@@ -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.0 start',
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',
@@ -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 { managedStorageMode, removeSqlitePaths } from './storageMode';
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 = managedStorageMode(launchConfig);
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,26 @@ 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
+ /** Fail before spawning when Bunqueue would reject an incomplete PostgreSQL config. */
27
+ export function validateManagedStorage(config: ServerConfig): ManagedStorageMode {
28
+ const mode = managedStorageMode(config);
29
+ if (mode === 'postgres' && !managedPostgresUrl(config)) {
30
+ throw new Error('PostgreSQL storage requires BUNQUEUE_POSTGRES_URL');
31
+ }
32
+ return mode;
33
+ }
34
+
15
35
  /** Non-SQLite drivers cannot be combined with Bunqueue's legacy SQLite path aliases. */
16
36
  export function removeSqlitePaths(env: Record<string, string>): void {
17
37
  delete env.BUNQUEUE_DATA_PATH;
package/agent/manager.ts CHANGED
@@ -1,7 +1,7 @@
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 { managedStorageMode } from './manager/storageMode';
4
+ export { managedPostgresNamespace, managedStorageMode } from './manager/storageMode';
5
5
  export type {
6
6
  DbStats,
7
7
  LogLine,
@@ -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 = /^\/queue-operations\/([^/]+)\/(deduplication\/remove|events\/trim)$/;
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 }) =>
@@ -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,
@@ -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 { managedStorageMode, type ProcessManager, validateConfigPatch } from '../manager';
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,115 @@
1
+ import {
2
+ managedPostgresNamespace,
3
+ managedStorageMode,
4
+ type ProcessManager,
5
+ } from '../manager';
6
+ import { probeExternalHealth, type ServerControlTarget } from './controlTarget';
7
+
8
+ export async function statusWithHealth(
9
+ manager: ProcessManager,
10
+ controlTarget: ServerControlTarget,
11
+ retryOnProcessChange = true
12
+ ) {
13
+ const snapshot = manager.getStatus();
14
+ const configRevision = snapshot.configRevision ?? manager.getConfigRevision?.() ?? 0;
15
+ if (controlTarget.mode === 'external') {
16
+ const health = await probeExternalHealth(controlTarget);
17
+ return {
18
+ ...snapshot,
19
+ configRevision,
20
+ managementMode: 'external' as const,
21
+ healthy: health.healthy,
22
+ version: health.version,
23
+ reachable: health.reachable,
24
+ externalUrl: controlTarget.url,
25
+ healthStatus: health.statusCode,
26
+ healthError: health.error,
27
+ db: null,
28
+ };
29
+ }
30
+ let healthy = false;
31
+ let version: string | undefined;
32
+ if (snapshot.status === 'running') {
33
+ const port = snapshot.runningConfig?.httpPort ?? snapshot.config.httpPort;
34
+ try {
35
+ const response = await fetch(`http://127.0.0.1:${port}/health`, {
36
+ signal: AbortSignal.timeout(1500),
37
+ });
38
+ if (response.ok) {
39
+ const body: unknown = await response.json();
40
+ if (body !== null && typeof body === 'object' && !Array.isArray(body)) {
41
+ const health = body as Record<string, unknown>;
42
+ healthy = health.ok === true;
43
+ if (typeof health.version === 'string') version = health.version;
44
+ }
45
+ }
46
+ } catch {
47
+ // The managed process is not healthy yet.
48
+ }
49
+ }
50
+ const effectiveConfig = snapshot.runningConfig ?? snapshot.config;
51
+ const storageMode = statusStorageMode(effectiveConfig);
52
+ const database = await statusDatabase(manager, effectiveConfig);
53
+ const current = manager.getStatus();
54
+ if (!sameProcessSnapshot(snapshot, current)) {
55
+ if (retryOnProcessChange) return statusWithHealth(manager, controlTarget, false);
56
+ const currentConfig = current.runningConfig ?? current.config;
57
+ const currentStorageMode = statusStorageMode(currentConfig);
58
+ return {
59
+ ...current,
60
+ configRevision: current.configRevision ?? manager.getConfigRevision?.() ?? 0,
61
+ managementMode: 'managed' as const,
62
+ healthy: false,
63
+ version: undefined,
64
+ storageMode: currentStorageMode,
65
+ postgresNamespace:
66
+ currentStorageMode === 'postgres' ? managedPostgresNamespace(currentConfig) : undefined,
67
+ db: await statusDatabase(manager, currentConfig),
68
+ };
69
+ }
70
+ return {
71
+ ...snapshot,
72
+ configRevision,
73
+ managementMode: 'managed' as const,
74
+ healthy,
75
+ version,
76
+ storageMode,
77
+ postgresNamespace:
78
+ storageMode === 'postgres' ? managedPostgresNamespace(effectiveConfig) : undefined,
79
+ db: database,
80
+ };
81
+ }
82
+
83
+ async function statusDatabase(
84
+ manager: ProcessManager,
85
+ config: ReturnType<ProcessManager['getConfig']>
86
+ ) {
87
+ try {
88
+ return managedStorageMode(config) === 'sqlite' ? await manager.dbStats(config.dataPath) : null;
89
+ } catch {
90
+ // Status must remain reachable so an invalid desired storage driver can be corrected.
91
+ return null;
92
+ }
93
+ }
94
+
95
+ function statusStorageMode(
96
+ config: ReturnType<ProcessManager['getConfig']>
97
+ ): ReturnType<typeof managedStorageMode> | undefined {
98
+ try {
99
+ return managedStorageMode(config);
100
+ } catch {
101
+ return undefined;
102
+ }
103
+ }
104
+
105
+ function sameProcessSnapshot(
106
+ initial: ReturnType<ProcessManager['getStatus']>,
107
+ current: ReturnType<ProcessManager['getStatus']>
108
+ ): boolean {
109
+ return (
110
+ initial.status === current.status &&
111
+ initial.generation === current.generation &&
112
+ initial.pid === current.pid &&
113
+ initial.configRevision === current.configRevision
114
+ );
115
+ }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Read-only facade for Bunqueue 2.9.0 Workflow Engine observability.
2
+ * Read-only facade for Bunqueue 2.9.2 Workflow Engine observability.
3
3
  * Persistence, decoding and validation live behind this stable public module.
4
4
  */
5
5
  export { workflowExecution } from './workflow/detail';