bunqueue-dashboard 0.0.38 → 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 (113) hide show
  1. package/README.md +17 -9
  2. package/agent/backup/resultValidation.ts +1 -1
  3. package/agent/backup/routes.ts +63 -17
  4. package/agent/db/query.ts +2 -1
  5. package/agent/index.ts +3 -0
  6. package/agent/manager/config.ts +2 -1
  7. package/agent/manager/process.ts +51 -5
  8. package/agent/manager/storageMode.ts +41 -0
  9. package/agent/manager/types.ts +2 -0
  10. package/agent/manager.ts +1 -0
  11. package/agent/queue/routes.ts +56 -2
  12. package/agent/queue/runtime.ts +67 -1
  13. package/agent/queue/types.ts +32 -0
  14. package/agent/queue/validation.ts +16 -0
  15. package/agent/server/controlRoutes.ts +55 -45
  16. package/agent/server/controlStatus.ts +115 -0
  17. package/agent/server/databaseRoutes.ts +47 -14
  18. package/agent/server/errors.ts +1 -0
  19. package/agent/server/lifecycle.ts +25 -8
  20. package/agent/server/managedRuntime.ts +27 -0
  21. package/agent/server/router.ts +90 -40
  22. package/agent/server/workflowReadRoutes.ts +28 -11
  23. package/agent/workflows.ts +1 -1
  24. package/dist/.vite/manifest.json +490 -476
  25. package/dist/assets/AddJob-DkbI_J5p.js +3 -0
  26. package/dist/assets/{Alerts-C2bAi-Kj.js → Alerts-CFj-u0iV.js} +1 -1
  27. package/dist/assets/{AreaChart-w0ruozXH.js → AreaChart-DEAalJ6P.js} +1 -1
  28. package/dist/assets/Benchmark-Ey2sq57q.js +1 -0
  29. package/dist/assets/{BulkAddJobs-BBWqQpP7.js → BulkAddJobs-BwPoeSB6.js} +1 -1
  30. package/dist/assets/{Button-BPqpRZJD.js → Button-Ch1W94fJ.js} +1 -1
  31. package/dist/assets/{Card-C7NMhqUt.js → Card-CWxrC8OY.js} +1 -1
  32. package/dist/assets/{CopilotPanel-Bhh-uCpN.js → CopilotPanel-CniuIIPn.js} +2 -2
  33. package/dist/assets/{Cron-pJJ5KXFG.js → Cron-B5XFBvU-.js} +1 -1
  34. package/dist/assets/CronManager-Bdg2BECM.js +1 -0
  35. package/dist/assets/{Database-DxiXsUl4.js → Database-CB0oOEFB.js} +1 -1
  36. package/dist/assets/Diagnostics-BDRftXQy.js +1 -0
  37. package/dist/assets/{Dlq-Bx4YXTp5.js → Dlq-CJO9itzJ.js} +1 -1
  38. package/dist/assets/{DlqControl-DJGhuyu3.js → DlqControl-DZL2lHOU.js} +1 -1
  39. package/dist/assets/{DlqPro-BhXIKtrt.js → DlqPro-OqBlzCnN.js} +1 -1
  40. package/dist/assets/Flows-BItJzbVQ.js +2 -0
  41. package/dist/assets/JobInspector-CJHX63p_.js +2 -0
  42. package/dist/assets/{Jobs-Blavwd1H.js → Jobs-BwmTsjJx.js} +1 -1
  43. package/dist/assets/{JobsPro-D8dH9um7.js → JobsPro-eFVAZJ10.js} +1 -1
  44. package/dist/assets/{Logs-B3dsGSF8.js → Logs-CXIf58M4.js} +1 -1
  45. package/dist/assets/{LogsPro-BCB7etG2.js → LogsPro-Xm6rylAd.js} +1 -1
  46. package/dist/assets/{McpServer-Cz1-GwD5.js → McpServer-Rf3i6lRn.js} +2 -2
  47. package/dist/assets/{Metrics-Bkuy6Ntb.js → Metrics-Cj3VYxzL.js} +1 -1
  48. package/dist/assets/{MetricsPro-BbJmy0oO.js → MetricsPro-C63eY00A.js} +1 -1
  49. package/dist/assets/{Overview-Ba5odzYe.js → Overview-CUM3XG7A.js} +1 -1
  50. package/dist/assets/{PageHeader-BXlZSBcc.js → PageHeader-CvIyaFOO.js} +1 -1
  51. package/dist/assets/{Pagination-Q-vf4hsX.js → Pagination-x-Cwgrul.js} +1 -1
  52. package/dist/assets/QueueActions-b19K0vCT.js +1 -0
  53. package/dist/assets/QueueControl-DusMoa05.js +1 -0
  54. package/dist/assets/QueueDetail-CpFIG1PU.js +1 -0
  55. package/dist/assets/QueueDetailPro-BNlBOj5s.js +1 -0
  56. package/dist/assets/{Queues-CGPgEkVm.js → Queues-CZ2qc7FY.js} +1 -1
  57. package/dist/assets/{QueuesOverview-ChC5Dtqh.js → QueuesOverview-CBFqQ3N_.js} +1 -1
  58. package/dist/assets/{S3Backup-5OKa8G13.js → S3Backup-x4mOV-AO.js} +1 -1
  59. package/dist/assets/S3BackupPro-BtvbPVg-.js +3 -0
  60. package/dist/assets/ServerControl-C5nQ7CCp.js +3 -0
  61. package/dist/assets/{Settings-C1HtCK6h.js → Settings-B5kCRSBH.js} +1 -1
  62. package/dist/assets/{StatusBadge-B0I7hNBX.js → StatusBadge-BoOVTnKu.js} +1 -1
  63. package/dist/assets/{Usage-QyZkUhgs.js → Usage-UYovs3D-.js} +1 -1
  64. package/dist/assets/{UsagePro-DMxrXxur.js → UsagePro-bT82DS-d.js} +1 -1
  65. package/dist/assets/{Webhooks-D0JQQf0q.js → Webhooks-BjsUMJAn.js} +1 -1
  66. package/dist/assets/{Workers-Cn8Bkgy1.js → Workers-CD4YZpca.js} +1 -1
  67. package/dist/assets/WorkersPro-hCPhfb1Z.js +1 -0
  68. package/dist/assets/Workflows-DOpeo-TP.js +1 -0
  69. package/dist/assets/{api-V_sVpHDo.js → api-BxYPEynh.js} +1 -1
  70. package/dist/assets/bq-C7wdABtE.js +1 -0
  71. package/dist/assets/{connectionStore-CKFQrxDo.js → connectionStore-CFuNYZ_8.js} +1 -1
  72. package/dist/assets/{controlConnectionEpoch-JM4omZ6j.js → controlConnectionEpoch-DzbuWOhe.js} +1 -1
  73. package/dist/assets/{feedback-BnGE2oW2.js → feedback-QDN1AsYt.js} +1 -1
  74. package/dist/assets/flowMutationSafety-DSd3JLAV.js +1 -0
  75. package/dist/assets/{form-tC20r4Ma.js → form-C4oby9pI.js} +1 -1
  76. package/dist/assets/{icons-CIvFfU2b.js → icons-BVzwsfbG.js} +1 -1
  77. package/dist/assets/{index-Cue53Myw.js → index-AUHH2nTG.js} +3 -3
  78. package/dist/assets/index-BK7fjWKK.css +1 -0
  79. package/dist/assets/install-C5y2ohOo.js +3 -0
  80. package/dist/assets/{react-vendor-CyjgmGTq.js → react-vendor-CvwrP0tU.js} +2 -2
  81. package/dist/assets/{responses-CBLyy1Ob.js → responses-DiIfPXXq.js} +1 -1
  82. package/dist/assets/{toastStore-B7n0zS0y.js → toastStore-B31o6Z5J.js} +1 -1
  83. package/dist/assets/{upstreamPaths-CDVsu6IM.js → upstreamPaths-B_cyinGI.js} +1 -1
  84. package/dist/assets/useControlActionGuard-DT7uudrj.js +1 -0
  85. package/dist/assets/{usePolledData-g80Z_UB-.js → usePolledData-CNtNtd0a.js} +1 -1
  86. package/dist/assets/useServerActionGuard-Db7PPgaI.js +1 -0
  87. package/dist/assets/{useThroughputSeries-CrsZYKbT.js → useThroughputSeries-y7Yyi7Zh.js} +1 -1
  88. package/dist/index.html +21 -18
  89. package/package.json +15 -7
  90. package/scripts/bunVersion.ts +10 -0
  91. package/scripts/embedded.gen.ts +152 -150
  92. package/scripts/serve.ts +3 -0
  93. package/scripts/serveHandler.ts +22 -1
  94. package/scripts/servePolicy.ts +12 -3
  95. package/dist/assets/AddJob-CC4bfHLH.js +0 -3
  96. package/dist/assets/Benchmark-ClJmYZoC.js +0 -1
  97. package/dist/assets/CronManager-lNZ4lHh1.js +0 -1
  98. package/dist/assets/Diagnostics-DLvCT7zI.js +0 -1
  99. package/dist/assets/Flows-Bb9j-YhZ.js +0 -2
  100. package/dist/assets/JobInspector-DfqBq4O5.js +0 -2
  101. package/dist/assets/QueueActions-B6M0ByGt.js +0 -1
  102. package/dist/assets/QueueControl-CWHsVfWU.js +0 -1
  103. package/dist/assets/QueueDetail-BClRbWC5.js +0 -1
  104. package/dist/assets/QueueDetailPro-CIimG0pX.js +0 -1
  105. package/dist/assets/S3BackupPro-BkwTXdtw.js +0 -3
  106. package/dist/assets/ServerControl-DJHKMaf9.js +0 -3
  107. package/dist/assets/WorkersPro-BOTuX9EP.js +0 -1
  108. package/dist/assets/Workflows-DqTKWPmT.js +0 -1
  109. package/dist/assets/bq-Bmysbg0r.js +0 -1
  110. package/dist/assets/flowMutationSafety-DObp9nJo.js +0 -1
  111. package/dist/assets/index-DOze4TKg.css +0 -1
  112. package/dist/assets/install-CFiYX86d.js +0 -3
  113. package/dist/assets/useServerActionGuard-OTv6_fU0.js +0 -1
@@ -7,54 +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(manager: ProcessManager, controlTarget: ServerControlTarget) {
19
- const snapshot = manager.getStatus();
20
- if (controlTarget.mode === 'external') {
21
- const health = await probeExternalHealth(controlTarget);
22
- return {
23
- ...snapshot,
24
- managementMode: 'external' as const,
25
- healthy: health.healthy,
26
- version: health.version,
27
- reachable: health.reachable,
28
- externalUrl: controlTarget.url,
29
- healthStatus: health.statusCode,
30
- healthError: health.error,
31
- db: null,
32
- };
33
- }
34
- let healthy = false;
35
- let version: string | undefined;
36
- if (snapshot.status === 'running') {
37
- const port = snapshot.runningConfig?.httpPort ?? snapshot.config.httpPort;
38
- try {
39
- const response = await fetch(`http://127.0.0.1:${port}/health`, {
40
- signal: AbortSignal.timeout(1500),
41
- });
42
- if (response.ok) {
43
- const body: unknown = await response.json();
44
- if (body !== null && typeof body === 'object' && !Array.isArray(body)) {
45
- const health = body as Record<string, unknown>;
46
- healthy = health.ok === true;
47
- if (typeof health.version === 'string') version = health.version;
48
- }
49
- }
50
- } catch {
51
- // The managed process is not healthy yet.
52
- }
53
- }
54
- const database = await manager.dbStats().catch(() => null);
55
- return { ...snapshot, managementMode: 'managed' as const, healthy, version, db: database };
56
- }
57
-
58
18
  export async function routeControlRequest(
59
19
  request: Request,
60
20
  pathname: string,
@@ -65,7 +25,10 @@ export async function routeControlRequest(
65
25
  controlTarget: ServerControlTarget = MANAGED_CONTROL_TARGET
66
26
  ): Promise<RouteResponse | null> {
67
27
  if (pathname === '/control/status') {
68
- return { status: 200, body: await statusWithHealth(manager, controlTarget) };
28
+ return coordinatedLease(lifecycle, async () => ({
29
+ status: 200,
30
+ body: await statusWithHealth(manager, controlTarget),
31
+ }));
69
32
  }
70
33
  if (pathname === '/control/logs') {
71
34
  return { status: 200, body: { lines: manager.getLogs() } };
@@ -96,22 +59,62 @@ export async function routeControlRequest(
96
59
  });
97
60
  }
98
61
  if (pathname === '/control/config' && method === 'GET') {
99
- return { status: 200, body: manager.getConfig() };
62
+ return { status: 200, body: configSnapshot(manager) };
100
63
  }
101
64
  if (pathname === '/control/config' && method === 'PUT') {
102
65
  assertManagedControl(controlTarget);
103
- const patch = validateConfigPatch(
66
+ const update = configUpdate(
104
67
  await readLimitedJsonBody(request, {
105
68
  scope: 'Agent configuration',
106
69
  maxBytes: MAX_CONFIG_BODY_BYTES,
107
70
  limitLabel: '64 KiB',
108
71
  })
109
72
  );
110
- return { status: 200, body: manager.setConfig(patch) };
73
+ return coordinated(lifecycle, async () => {
74
+ manager.setConfig(update.patch, update.expectedRevision);
75
+ return { status: 200, body: configSnapshot(manager) };
76
+ });
111
77
  }
112
78
  return null;
113
79
  }
114
80
 
81
+ function configSnapshot(manager: ProcessManager) {
82
+ return { ...manager.getConfig(), configRevision: manager.getConfigRevision?.() ?? 0 };
83
+ }
84
+
85
+ function configUpdate(value: unknown): {
86
+ patch: ReturnType<typeof validateConfigPatch>;
87
+ expectedRevision?: number;
88
+ } {
89
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
90
+ return { patch: validateConfigPatch(value) };
91
+ }
92
+ const input = { ...(value as Record<string, unknown>) };
93
+ const hasExpectedRevision = Object.hasOwn(input, 'expectedRevision');
94
+ const hasConfigRevision = Object.hasOwn(input, 'configRevision');
95
+ const expected = hasExpectedRevision
96
+ ? input.expectedRevision
97
+ : hasConfigRevision
98
+ ? input.configRevision
99
+ : undefined;
100
+ if (
101
+ hasExpectedRevision &&
102
+ hasConfigRevision &&
103
+ input.expectedRevision !== input.configRevision
104
+ ) {
105
+ throw new Error('expectedRevision and configRevision must match');
106
+ }
107
+ delete input.expectedRevision;
108
+ delete input.configRevision;
109
+ if (expected !== undefined && (!Number.isSafeInteger(expected) || (expected as number) < 0)) {
110
+ throw new Error('expectedRevision must be a non-negative safe integer');
111
+ }
112
+ return {
113
+ patch: validateConfigPatch(input),
114
+ ...(expected === undefined ? {} : { expectedRevision: expected as number }),
115
+ };
116
+ }
117
+
115
118
  export function assertManagedControl(controlTarget: ServerControlTarget): void {
116
119
  if (controlTarget.mode === 'external') {
117
120
  throw new Error(
@@ -173,6 +176,13 @@ function coordinated<T>(
173
176
  return lifecycle ? lifecycle.run(operation) : operation();
174
177
  }
175
178
 
179
+ function coordinatedLease<T>(
180
+ lifecycle: AgentLifecyclePort | undefined,
181
+ operation: () => Promise<T>
182
+ ): Promise<T> {
183
+ return lifecycle ? lifecycle.lease(operation) : operation();
184
+ }
185
+
176
186
  type SettledOperation = { ok: true } | { ok: false; error: unknown };
177
187
 
178
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
+ }
@@ -10,6 +10,7 @@ import {
10
10
  } from '../db';
11
11
  import { corsHeaders } from './policy';
12
12
  import { readLimitedJsonBody } from './jsonBody';
13
+ import type { ManagedDatabaseAdmission } from './managedRuntime';
13
14
  import type { RouteResponse } from './types';
14
15
 
15
16
  const MAX_QUERY_BODY_BYTES = 64 * 1024;
@@ -100,46 +101,58 @@ export async function routeDatabaseRequest(
100
101
  method: string,
101
102
  dataPath: string,
102
103
  origin: string | null,
103
- allowedOrigins: string[]
104
+ allowedOrigins: string[],
105
+ admission?: ManagedDatabaseAdmission
104
106
  ): Promise<RouteResponse | Response | null> {
105
107
  if (pathname === '/db/info' && method === 'GET') {
106
- return { status: 200, body: { ok: true, ...dbInfo(dataPath) } };
108
+ return withDatabase(dataPath, admission, (path) => ({
109
+ status: 200,
110
+ body: { ok: true, ...dbInfo(path) },
111
+ }));
107
112
  }
108
113
  if (pathname === '/db/tables' && method === 'GET') {
109
- return { status: 200, body: { ok: true, tables: dbTables(dataPath) } };
114
+ return withDatabase(dataPath, admission, (path) => ({
115
+ status: 200,
116
+ body: { ok: true, tables: dbTables(path) },
117
+ }));
110
118
  }
111
119
  if (pathname.startsWith('/db/tables/') && method === 'GET') {
112
120
  const segments = pathname.slice('/db/tables/'.length).split('/');
113
121
  const table = decodeURIComponent(segments[0] ?? '');
114
122
  const subresource = segments[1];
115
123
  if (segments.length === 2 && subresource === 'export') {
116
- return exportTable(request, table, dataPath, origin, allowedOrigins);
124
+ return withDatabase(dataPath, admission, (path) =>
125
+ exportTable(request, table, path, origin, allowedOrigins)
126
+ );
117
127
  }
118
128
  if (segments.length === 2 && subresource === 'schema') {
119
- return { status: 200, body: { ok: true, ...dbSchema(dataPath, table) } };
129
+ return withDatabase(dataPath, admission, (path) => ({
130
+ status: 200,
131
+ body: { ok: true, ...dbSchema(path, table) },
132
+ }));
120
133
  }
121
134
  if (segments.length === 2 && subresource === 'cell') {
122
135
  const query = new URL(request.url).searchParams;
123
136
  const rowid = query.get('rowid');
124
137
  if (rowid === null) throw new Error('rowid is required');
125
- return {
138
+ return withDatabase(dataPath, admission, (path) => ({
126
139
  status: 200,
127
140
  body: {
128
141
  ok: true,
129
- ...dbCell(dataPath, table, rowid, query.get('column') ?? ''),
142
+ ...dbCell(path, table, rowid, query.get('column') ?? ''),
130
143
  },
131
- };
144
+ }));
132
145
  }
133
146
  if (segments.length !== 1) {
134
147
  return { status: 404, body: { ok: false, error: 'Not found' } };
135
148
  }
136
149
  const query = new URL(request.url).searchParams;
137
- return {
150
+ return withDatabase(dataPath, admission, (path) => ({
138
151
  status: 200,
139
152
  body: {
140
153
  ok: true,
141
154
  ...dbRows(
142
- dataPath,
155
+ path,
143
156
  table,
144
157
  Number(query.get('limit')) || 50,
145
158
  Number(query.get('offset')) || 0,
@@ -148,7 +161,7 @@ export async function routeDatabaseRequest(
148
161
  browseFilter(query)
149
162
  ),
150
163
  },
151
- };
164
+ }));
152
165
  }
153
166
  if (pathname === '/db/query' && method === 'POST') {
154
167
  const { sql } = (await readLimitedJsonBody(request, {
@@ -156,10 +169,30 @@ export async function routeDatabaseRequest(
156
169
  maxBytes: MAX_QUERY_BODY_BYTES,
157
170
  limitLabel: '64 KiB',
158
171
  })) as { sql?: string };
159
- return {
172
+ return withDatabase(dataPath, admission, async (path) => ({
160
173
  status: 200,
161
- body: { ok: true, ...(await queryWithTimeout(dataPath, sql ?? '')) },
162
- };
174
+ body: { ok: true, ...(await queryWithTimeout(path, sql ?? '')) },
175
+ }));
163
176
  }
164
177
  return null;
165
178
  }
179
+
180
+ function withDatabase<T>(
181
+ dataPath: string,
182
+ admission: ManagedDatabaseAdmission | undefined,
183
+ operation: (path: string) => T | Promise<T>
184
+ ): Promise<T | RouteResponse> {
185
+ return admission
186
+ ? admission((snapshot) =>
187
+ snapshot.storageMode === 'sqlite'
188
+ ? operation(snapshot.dataPath)
189
+ : {
190
+ status: 409,
191
+ body: {
192
+ ok: false,
193
+ error: `Database inspection requires Bunqueue SQLite storage; ${snapshot.storageMode} is active`,
194
+ },
195
+ }
196
+ )
197
+ : Promise.resolve(operation(dataPath));
198
+ }
@@ -19,6 +19,7 @@ const CONFLICT_MESSAGES = [
19
19
  'BUNQUEUE_WORKFLOW_CONCURRENCY must',
20
20
  'Managed Bunqueue server restarted',
21
21
  'Server lifecycle is disabled in external mode',
22
+ 'Configuration changed since revision',
22
23
  ];
23
24
 
24
25
  export function errorStatus(error: unknown): number {
@@ -18,6 +18,7 @@ export class AgentLifecycleGate implements AgentLifecyclePort {
18
18
  private leases = new Set<Promise<void>>();
19
19
  private closed = false;
20
20
  private closing: Promise<void> | null = null;
21
+ private pendingTransitions = 0;
21
22
 
22
23
  assertOpen(): void {
23
24
  if (this.closed) throw new AgentLifecycleClosedError();
@@ -29,14 +30,25 @@ export class AgentLifecycleGate implements AgentLifecyclePort {
29
30
  } catch (error) {
30
31
  return Promise.reject(error);
31
32
  }
32
- const result = this.barrier.then(operation);
33
33
  const batch = this.leases;
34
- const completion = result.then(
35
- () => undefined,
36
- () => undefined
37
- );
34
+ let complete!: () => void;
35
+ const completion = new Promise<void>((resolve) => {
36
+ complete = resolve;
37
+ });
38
+ // Publish the lease before invoking user code. Its synchronous prefix may
39
+ // re-enter run()/close(), which must observe this lease and wait for it.
38
40
  batch.add(completion);
39
- void completion.then(() => batch.delete(completion));
41
+ let result: Promise<T>;
42
+ if (this.pendingTransitions === 0) {
43
+ try {
44
+ result = Promise.resolve(operation());
45
+ } catch (error) {
46
+ result = Promise.reject(error);
47
+ }
48
+ } else {
49
+ result = this.barrier.then(operation);
50
+ }
51
+ void result.then(complete, complete).then(() => batch.delete(completion));
40
52
  return result;
41
53
  }
42
54
 
@@ -60,12 +72,17 @@ export class AgentLifecycleGate implements AgentLifecyclePort {
60
72
  const precedingBarrier = this.barrier;
61
73
  const precedingLeases = this.leases;
62
74
  this.leases = new Set();
75
+ this.pendingTransitions += 1;
63
76
  const result = precedingBarrier
64
77
  .then(() => Promise.all(precedingLeases))
65
78
  .then(operation);
66
79
  this.barrier = result.then(
67
- () => undefined,
68
- () => undefined
80
+ () => {
81
+ this.pendingTransitions -= 1;
82
+ },
83
+ () => {
84
+ this.pendingTransitions -= 1;
85
+ }
69
86
  );
70
87
  return result;
71
88
  }
@@ -1,4 +1,6 @@
1
1
  import type { ProcessManager, ServerConfig, StatusSnapshot } from '../manager';
2
+ import { managedStorageMode } from '../manager';
3
+ import type { ManagedStorageMode } from '../manager/storageMode';
2
4
  import type { AgentLifecyclePort } from './lifecycle';
3
5
 
4
6
  export interface ManagedRuntimeSnapshot {
@@ -10,6 +12,15 @@ export type ManagedRuntimeAdmission = <T>(
10
12
  operation: (snapshot: ManagedRuntimeSnapshot) => Promise<T>
11
13
  ) => Promise<T>;
12
14
 
15
+ export interface ManagedDatabaseSnapshot {
16
+ dataPath: string;
17
+ storageMode: ManagedStorageMode;
18
+ }
19
+
20
+ export type ManagedDatabaseAdmission = <T>(
21
+ operation: (snapshot: ManagedDatabaseSnapshot) => T | Promise<T>
22
+ ) => Promise<T>;
23
+
13
24
  /**
14
25
  * Admit SDK work against one managed-process generation. The operation and its
15
26
  * final state/config check share the same lifecycle section as start/stop.
@@ -32,6 +43,22 @@ export function managedRuntimeAdmission(
32
43
  });
33
44
  }
34
45
 
46
+ /** Pin DB-backed observability to the live process config for one reader lease. */
47
+ export function managedDatabaseAdmission(
48
+ manager: ProcessManager,
49
+ lifecycle: AgentLifecyclePort
50
+ ): ManagedDatabaseAdmission {
51
+ return (operation) =>
52
+ lifecycle.lease(async () => {
53
+ const snapshot = manager.getStatus();
54
+ const config = snapshot.runningConfig ?? snapshot.config;
55
+ return operation({
56
+ dataPath: config.dataPath,
57
+ storageMode: managedStorageMode(config),
58
+ });
59
+ });
60
+ }
61
+
35
62
  function generationChanged(initial: StatusSnapshot, current: StatusSnapshot): boolean {
36
63
  return (
37
64
  initial.status === 'running' &&
@@ -1,8 +1,11 @@
1
- import { routeBackupRequest } from '../backup/routes';
1
+ import {
2
+ executePreparedBackupRequest,
3
+ prepareBackupRequest,
4
+ } from '../backup/routes';
2
5
  import type { BackupRunnerPort } from '../backup/runner';
3
6
  import { routeFlowRequest } from '../flow/routes';
4
7
  import type { ManagedTargetPolicy } from '../managedTarget';
5
- import type { ProcessManager } from '../manager';
8
+ import { managedStorageMode, type ProcessManager } from '../manager';
6
9
  import { routeQueueOperationsRequest } from '../queue/routes';
7
10
  import type { QueueOperationsPort } from '../queue/types';
8
11
  import type { WorkflowRuntimePort } from '../workflow/runtime';
@@ -11,7 +14,7 @@ import { assertManagedControl, routeControlRequest } from './controlRoutes';
11
14
  import type { ServerControlTarget } from './controlTarget';
12
15
  import { routeDatabaseRequest } from './databaseRoutes';
13
16
  import type { AgentLifecyclePort } from './lifecycle';
14
- import { managedRuntimeAdmission } from './managedRuntime';
17
+ import { managedDatabaseAdmission, managedRuntimeAdmission } from './managedRuntime';
15
18
  import type { RouteResponse } from './types';
16
19
  import { routeWorkflowReadRequest } from './workflowReadRoutes';
17
20
 
@@ -44,7 +47,9 @@ export async function routeAgentRequest(
44
47
 
45
48
  const snapshot = manager.getStatus();
46
49
  const managedConfig = snapshot.runningConfig ?? snapshot.config;
50
+ const storageMode = managedStorageMode(managedConfig);
47
51
  const admission = managedRuntimeAdmission(manager, lifecycle, snapshot);
52
+ const databaseAdmission = managedDatabaseAdmission(manager, lifecycle);
48
53
  if (pathname.startsWith('/flows/')) {
49
54
  const flow = await routeFlowRequest(
50
55
  request,
@@ -73,14 +78,16 @@ export async function routeAgentRequest(
73
78
  if (workflowRuntime) return workflowRuntime;
74
79
  }
75
80
 
76
- const desiredConfig = manager.getConfig();
77
- const workflowRead = routeWorkflowReadRequest(
78
- request,
79
- pathname,
80
- method,
81
- managedConfig.dataPath
82
- );
83
- if (workflowRead) return workflowRead;
81
+ if (isWorkflowReadRoute(pathname, method)) {
82
+ const workflowRead = await routeWorkflowReadRequest(
83
+ request,
84
+ pathname,
85
+ method,
86
+ managedConfig.dataPath,
87
+ databaseAdmission
88
+ );
89
+ if (workflowRead) return workflowRead;
90
+ }
84
91
 
85
92
  if (pathname.startsWith('/queue-operations/')) {
86
93
  const queueOperation = await routeQueueOperationsRequest(
@@ -97,54 +104,93 @@ export async function routeAgentRequest(
97
104
  }
98
105
 
99
106
  if (pathname.startsWith('/backup/')) {
107
+ if (storageMode !== 'sqlite') return sqliteOnly('S3 backup', storageMode);
100
108
  if (pathname === '/backup/restore' && method === 'POST') {
101
109
  assertManagedControl(controlTarget);
102
- return manager.withStoppedMaintenance('restoring a backup', async () => {
103
- const stoppedSnapshot = manager.getStatus();
104
- const restoreDesired = manager.getConfig();
105
- const restoreConfig = {
106
- ...(stoppedSnapshot.runningConfig ?? stoppedSnapshot.config),
107
- extraEnv: restoreDesired.extraEnv,
108
- };
109
- return routeBackupRequest(
110
- request,
111
- pathname,
112
- method,
113
- restoreConfig,
114
- stoppedSnapshot.status === 'running',
115
- await manager.dbStats(),
116
- backupRunner,
117
- undefined,
118
- managedTargetPolicy
119
- );
110
+ const prepared = await prepareBackupRequest(
111
+ request,
112
+ pathname,
113
+ method,
114
+ managedConfig,
115
+ managedTargetPolicy
116
+ );
117
+ if (!prepared || prepared.operation !== 'restore') return null;
118
+ return lifecycle.run(async () => {
119
+ const current = manager.getStatus();
120
+ const restoreConfig = current.runningConfig ?? current.config;
121
+ const currentStorageMode = managedStorageMode(restoreConfig);
122
+ if (currentStorageMode !== 'sqlite') {
123
+ return sqliteOnly('S3 backup', currentStorageMode);
124
+ }
125
+ return manager.withStoppedMaintenance('restoring a backup', async () => {
126
+ return executePreparedBackupRequest(
127
+ prepared,
128
+ restoreConfig,
129
+ current.status === 'running',
130
+ await manager.dbStats(restoreConfig.dataPath),
131
+ backupRunner,
132
+ undefined,
133
+ managedTargetPolicy
134
+ );
135
+ });
120
136
  });
121
137
  }
122
- const database = await manager.dbStats();
123
- const backupConfig = { ...managedConfig, extraEnv: desiredConfig.extraEnv };
124
- const backup = await routeBackupRequest(
138
+ const prepared = await prepareBackupRequest(
125
139
  request,
126
140
  pathname,
127
141
  method,
128
- backupConfig,
129
- snapshot.status === 'running',
130
- database,
131
- backupRunner,
132
- (extraEnv) => manager.setConfig({ extraEnv }),
142
+ managedConfig,
133
143
  managedTargetPolicy
134
144
  );
135
- if (backup) return backup;
145
+ if (prepared) {
146
+ const execute = async () => {
147
+ const current = manager.getStatus();
148
+ const desired = manager.getConfig();
149
+ const effective = current.runningConfig ?? current.config;
150
+ const backupConfig = { ...effective, extraEnv: desired.extraEnv };
151
+ const currentStorageMode = managedStorageMode(backupConfig);
152
+ if (currentStorageMode !== 'sqlite') {
153
+ return sqliteOnly('S3 backup', currentStorageMode);
154
+ }
155
+ return executePreparedBackupRequest(
156
+ prepared,
157
+ backupConfig,
158
+ current.status === 'running',
159
+ await manager.dbStats(backupConfig.dataPath),
160
+ backupRunner,
161
+ (extraEnv) => {
162
+ manager.setConfig({ extraEnv });
163
+ },
164
+ managedTargetPolicy
165
+ );
166
+ };
167
+ return prepared.operation === 'configure'
168
+ ? lifecycle.run(execute)
169
+ : lifecycle.lease(execute);
170
+ }
136
171
  }
137
172
 
173
+ if ((pathname === '/db' || pathname.startsWith('/db/')) && storageMode !== 'sqlite') {
174
+ return sqliteOnly('Database inspection', storageMode);
175
+ }
138
176
  return routeDatabaseRequest(
139
177
  request,
140
178
  pathname,
141
179
  method,
142
- desiredConfig.dataPath,
180
+ managedConfig.dataPath,
143
181
  origin,
144
- allowedOrigins
182
+ allowedOrigins,
183
+ databaseAdmission
145
184
  );
146
185
  }
147
186
 
187
+ function sqliteOnly(feature: string, storageMode: 'memory' | 'postgres'): RouteResponse {
188
+ return {
189
+ status: 409,
190
+ body: { ok: false, error: `${feature} requires Bunqueue SQLite storage; ${storageMode} is active` },
191
+ };
192
+ }
193
+
148
194
  function isWorkflowRuntimeRoute(pathname: string, method: string): boolean {
149
195
  if (method === 'GET') return pathname === '/workflows/runtime';
150
196
  if (method !== 'POST') return false;
@@ -159,3 +205,7 @@ function isWorkflowRuntimeRoute(pathname: string, method: string): boolean {
159
205
  /^\/workflows\/[^/]+\/(signal|resume-compensation|abandon-compensation)$/.test(pathname)
160
206
  );
161
207
  }
208
+
209
+ function isWorkflowReadRoute(pathname: string, method: string): boolean {
210
+ return method === 'GET' && (pathname === '/workflows' || pathname.startsWith('/workflows/'));
211
+ }