bunqueue-dashboard 0.0.47 → 1.0.2

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 (133) hide show
  1. package/README.md +11 -9
  2. package/agent/backup/routes.ts +20 -12
  3. package/agent/managedTarget.ts +3 -1
  4. package/agent/manager/commandLine.ts +33 -0
  5. package/agent/manager/errors.ts +4 -0
  6. package/agent/manager/process.ts +4 -2
  7. package/agent/manager.ts +1 -0
  8. package/agent/server/controlRoutes.ts +10 -0
  9. package/agent/server/controlStatus.ts +26 -5
  10. package/agent/server/errors.ts +3 -0
  11. package/agent/server/router.ts +7 -2
  12. package/agent/shutdown.ts +3 -2
  13. package/dist/.vite/manifest.json +538 -576
  14. package/dist/assets/AddJob-CiwWycYG.js +3 -0
  15. package/dist/assets/Alerts-DCKl_C8D.js +1 -0
  16. package/dist/assets/AreaChart-Db2UVnaK.js +1 -0
  17. package/dist/assets/Benchmark-DyHvEM8x.js +1 -0
  18. package/dist/assets/BulkAddJobs-GRMVnf0j.js +5 -0
  19. package/dist/assets/Button-_aSJvRoW.js +1 -0
  20. package/dist/assets/Card-egIDLwVk.js +1 -0
  21. package/dist/assets/Cron-XL0PdUEc.js +1 -0
  22. package/dist/assets/CronManager-Cqtg9Zr8.js +1 -0
  23. package/dist/assets/{Database-DV2pcDLL.js → Database-BBzyi7f0.js} +3 -3
  24. package/dist/assets/Diagnostics-E_icHggb.js +1 -0
  25. package/dist/assets/Dlq-DNs32CzV.js +1 -0
  26. package/dist/assets/DlqControl-DawwIsTI.js +1 -0
  27. package/dist/assets/DlqPro-BEdxFiNN.js +1 -0
  28. package/dist/assets/Fleet-Bkbq0dFB.js +1 -0
  29. package/dist/assets/Flows-DKZxI7ta.js +2 -0
  30. package/dist/assets/JobInspector-BARE52q3.js +2 -0
  31. package/dist/assets/Jobs-CKzKpQ7i.js +1 -0
  32. package/dist/assets/JobsPro-DxDw-akG.js +1 -0
  33. package/dist/assets/Logs-Bj60G_FP.js +1 -0
  34. package/dist/assets/LogsPro-Hv8xwXDK.js +1 -0
  35. package/dist/assets/{McpServer-fVy27Mof.js → McpServer-zxSMjG8K.js} +1 -1
  36. package/dist/assets/{Metrics-Ba46pdzT.js → Metrics-BBLdWOw1.js} +1 -1
  37. package/dist/assets/MetricsPro-D470b1aP.js +1 -0
  38. package/dist/assets/{Overview-DV2qo5v3.js → Overview-B_AOa5kW.js} +1 -1
  39. package/dist/assets/{PageHeader-CvIyaFOO.js → PageHeader-CQzNsw8r.js} +1 -1
  40. package/dist/assets/Pagination-CSkzhNW6.js +1 -0
  41. package/dist/assets/QueueActions-BBxRt-t0.js +1 -0
  42. package/dist/assets/QueueControl-DaZjeZnw.js +1 -0
  43. package/dist/assets/QueueDetail-Dgo9Ukmp.js +1 -0
  44. package/dist/assets/QueueDetailPro-D7U8ahp2.js +1 -0
  45. package/dist/assets/Queues-CS8LeZtJ.js +1 -0
  46. package/dist/assets/QueuesOverview-BySSnCm0.js +1 -0
  47. package/dist/assets/{S3Backup-DNABHR2Q.js → S3Backup-Ci4dJPP_.js} +1 -1
  48. package/dist/assets/S3BackupPro-UwWu0yxr.js +3 -0
  49. package/dist/assets/ServerControl-Ch1wZcKB.js +3 -0
  50. package/dist/assets/Settings-FOzwrB25.js +1 -0
  51. package/dist/assets/StatusBadge-DereLzOj.js +1 -0
  52. package/dist/assets/{Usage-1KxG1mJ9.js → Usage-DR90dCt0.js} +1 -1
  53. package/dist/assets/UsagePro-DAlE3oCE.js +1 -0
  54. package/dist/assets/Webhooks-BonYUgMo.js +1 -0
  55. package/dist/assets/Workers-D1Ead02h.js +1 -0
  56. package/dist/assets/WorkersPro-XuLxtU4A.js +1 -0
  57. package/dist/assets/Workflows-CqZZ96EK.js +1 -0
  58. package/dist/assets/{api-BgWE-705.js → api-D9aT0ND7.js} +1 -1
  59. package/dist/assets/bq-DR2BTISO.js +1 -0
  60. package/dist/assets/classicJobsData-Csj4iihY.js +1 -0
  61. package/dist/assets/connectionStore-CVjQyIZv.js +1 -0
  62. package/dist/assets/controlConnectionEpoch-ChAeMMLk.js +1 -0
  63. package/dist/assets/feedback-CtGN_kqB.js +1 -0
  64. package/dist/assets/form-DG3X6cLL.js +1 -0
  65. package/dist/assets/{icons-BVzwsfbG.js → icons-DH9SS1DA.js} +1 -1
  66. package/dist/assets/index-DZFZr0Mi.js +8 -0
  67. package/dist/assets/{index-C9yGKhQ8.css → index-DaEyLvTR.css} +1 -1
  68. package/dist/assets/react-vendor-CFL9l8LK.js +11 -0
  69. package/dist/assets/{cn-zFEt5SO8.js → rolldown-runtime-QTnfLwEv.js} +1 -1
  70. package/dist/assets/{toastStore-IZn6OPLZ.js → toastStore-XmMX7A5W.js} +1 -1
  71. package/dist/assets/useControlActionGuard-1i7aFA7E.js +1 -0
  72. package/dist/assets/usePolledData-CEyyLRYa.js +1 -0
  73. package/dist/assets/useServerActionGuard-CUx8TOAC.js +1 -0
  74. package/dist/assets/useThroughputSeries-CeFx-Mwq.js +1 -0
  75. package/dist/index.html +15 -15
  76. package/package.json +29 -16
  77. package/scripts/bunVersion.ts +1 -1
  78. package/scripts/embedded.gen.ts +154 -164
  79. package/scripts/serveHandler.ts +28 -1
  80. package/dist/assets/AddJob-FNaPbs_V.js +0 -3
  81. package/dist/assets/Alerts-vzIYtFpW.js +0 -1
  82. package/dist/assets/AreaChart-DEAalJ6P.js +0 -1
  83. package/dist/assets/Benchmark-C16tT4pF.js +0 -1
  84. package/dist/assets/BulkAddJobs-IiL1uud0.js +0 -5
  85. package/dist/assets/Button-Ch1W94fJ.js +0 -1
  86. package/dist/assets/Card-CWxrC8OY.js +0 -1
  87. package/dist/assets/CopilotPanel-FlAW3RlL.js +0 -44
  88. package/dist/assets/Cron-1DZTZWXO.js +0 -1
  89. package/dist/assets/CronManager-B6xLLgI5.js +0 -1
  90. package/dist/assets/Diagnostics-B6apZrC5.js +0 -1
  91. package/dist/assets/Dlq-MRmkQrNC.js +0 -1
  92. package/dist/assets/DlqControl-DNJPVOTF.js +0 -1
  93. package/dist/assets/DlqPro-CwtTDwGN.js +0 -1
  94. package/dist/assets/Fleet-Bdrj9bLI.js +0 -1
  95. package/dist/assets/Flows-1dC8F_wx.js +0 -2
  96. package/dist/assets/JobInspector-Ct9Ga-UT.js +0 -2
  97. package/dist/assets/Jobs-Chqv0RuL.js +0 -1
  98. package/dist/assets/JobsPro-CLzOhKAL.js +0 -1
  99. package/dist/assets/Logs-C5PNP9Wq.js +0 -1
  100. package/dist/assets/LogsPro-B3KwTSsn.js +0 -1
  101. package/dist/assets/MetricsPro-B9rD_KQc.js +0 -1
  102. package/dist/assets/Pagination-x-Cwgrul.js +0 -1
  103. package/dist/assets/QueueActions-BWnmNEqV.js +0 -1
  104. package/dist/assets/QueueControl-DqBvuDnF.js +0 -1
  105. package/dist/assets/QueueDetail-COSH9bJl.js +0 -1
  106. package/dist/assets/QueueDetailPro-ClKtyHIf.js +0 -1
  107. package/dist/assets/Queues-D2-R8nYh.js +0 -1
  108. package/dist/assets/QueuesOverview-tokquA3g.js +0 -1
  109. package/dist/assets/S3BackupPro-CDX5WQUY.js +0 -3
  110. package/dist/assets/ServerControl-BmccMNW9.js +0 -3
  111. package/dist/assets/Settings-UcujwvKv.js +0 -1
  112. package/dist/assets/StatusBadge-BoOVTnKu.js +0 -1
  113. package/dist/assets/UsagePro-BGLufsE7.js +0 -1
  114. package/dist/assets/Webhooks-DT2LK17V.js +0 -1
  115. package/dist/assets/Workers-BX1qspaQ.js +0 -1
  116. package/dist/assets/WorkersPro-BsUQVgQZ.js +0 -1
  117. package/dist/assets/Workflows-DZZA6aGW.js +0 -1
  118. package/dist/assets/bq-F5BbTG1T.js +0 -1
  119. package/dist/assets/connectionStore-BJLihGaC.js +0 -1
  120. package/dist/assets/controlConnectionEpoch-CZsS0wAb.js +0 -1
  121. package/dist/assets/dist-1f1fMHVO.js +0 -11
  122. package/dist/assets/dist-BlWBaCN6.js +0 -11
  123. package/dist/assets/dist-DZL8soAh.js +0 -79
  124. package/dist/assets/dist-DjvZiKhB.js +0 -2
  125. package/dist/assets/dist-DpZcbP9W.js +0 -11
  126. package/dist/assets/feedback-QDN1AsYt.js +0 -1
  127. package/dist/assets/form-C4oby9pI.js +0 -1
  128. package/dist/assets/index-8Ii-_Rml.js +0 -8
  129. package/dist/assets/react-vendor-CvwrP0tU.js +0 -11
  130. package/dist/assets/useControlActionGuard-B-bzKZKI.js +0 -1
  131. package/dist/assets/usePolledData-0CVelkl2.js +0 -1
  132. package/dist/assets/useServerActionGuard-uGePilQQ.js +0 -1
  133. package/dist/assets/useThroughputSeries-DgO8uicf.js +0 -1
package/README.md CHANGED
@@ -26,7 +26,7 @@ The live demo uses sample data. The docs explain how to connect and operate a re
26
26
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
27
27
  [![Documentation](https://img.shields.io/badge/docs-online-db2777)](https://egeominotti.github.io/bunqueue-dashboard/docs/)
28
28
  [![Live demo](https://img.shields.io/badge/live%20demo-online-22c55e)](https://egeominotti.github.io/bunqueue-dashboard/)
29
- ![Status: Beta](https://img.shields.io/badge/status-beta-f59e0b)
29
+ ![Status: Stable](https://img.shields.io/badge/status-stable-22c55e)
30
30
 
31
31
  ![React 19](https://img.shields.io/badge/React-19-149ECA?logo=react&logoColor=white)
32
32
  ![Vite 8](https://img.shields.io/badge/Vite-8-646CFF?logo=vite&logoColor=white)
@@ -36,9 +36,11 @@ The live demo uses sample data. The docs explain how to connect and operate a re
36
36
 
37
37
  <br />
38
38
 
39
- <img src="docs/public/tour.gif" alt="A guided tour of the bunqueue dashboard, clicking through the overview, queues, jobs, the dead-letter queue, flows, the SQLite inspector, and the AI Copilot" width="900" />
39
+ <a href="https://www.youtube.com/watch?v=1bQRFXGClcc" title="Watch the full bunqueue dashboard tour on YouTube"><img src="docs/public/video-tour.png" alt="Watch the narrated 4K tour of bunqueue dashboard on YouTube" width="900" /></a>
40
40
 
41
- <sub>A quick tour. <a href="https://egeominotti.github.io/bunqueue-dashboard/">Try it live →</a></sub>
41
+ ### [▶ Watch the full tour on YouTube](https://www.youtube.com/watch?v=1bQRFXGClcc)
42
+
43
+ <sub>A narrated 4K walkthrough (7:45, English with subtitles) recorded against a real bunqueue server under live traffic. <a href="https://egeominotti.github.io/bunqueue-dashboard/">Or try the live demo →</a></sub>
42
44
 
43
45
  <br /><br />
44
46
 
@@ -58,9 +60,9 @@ The live demo uses sample data. The docs explain how to connect and operate a re
58
60
 
59
61
  ---
60
62
 
61
- > ⚠️ **Beta.** bunqueue dashboard is under active development, interfaces and
62
- > behavior may change between releases. Review it before relying on it for
63
- > unattended production use.
63
+ > ✅ **Stable (1.x).** The dashboard follows [Semantic Versioning](https://semver.org/):
64
+ > breaking changes to its configuration, routes or agent API only ship in a new
65
+ > major version.
64
66
 
65
67
  ## 📚 Documentation
66
68
 
@@ -129,12 +131,12 @@ contracts; it never patches Bunqueue internals.
129
131
  > registration or flow order. Cancel, Discard, Drain, Clean, Obliterate and DLQ Purge also fail
130
132
  > closed. Bunqueue 2.9's `Queue.removeDlqJob()` also accepts only queue + job ID, so custom-ID reuse
131
133
  > prevents the dashboard from proving that a later deletion still targets the observed generation.
132
- > DLQ `maxAge`/`maxEntries` are read-only, auto-retry can only be disabled, and Copilot's only
133
- > mutations are Promote, Pause and Resume.
134
+ > DLQ `maxAge`/`maxEntries` are read-only, and auto-retry can only be disabled. The in-app Copilot
135
+ > is switched off in official builds (`VITE_ENABLE_COPILOT=1` opts a custom build back in).
134
136
 
135
137
  ## Quick start
136
138
 
137
- **Prerequisites:** [Bun](https://bun.sh) 1.4.2 and a reachable bunqueue server (or let the control
139
+ **Prerequisites:** [Bun](https://bun.sh) 1.4.4 and a reachable bunqueue server (or let the control
138
140
  agent start one for you from the **Server** page).
139
141
 
140
142
  ### Run from npm (no clone)
@@ -190,18 +190,26 @@ function assertDatabaseSnapshot(value: unknown, current: DbStats): void {
190
190
  throw new Error('A database snapshot is required to authorize restore');
191
191
  }
192
192
  const expected = value as Record<string, unknown>;
193
- const allowed = ['path', 'exists', 'size', 'walSize', 'shmSize', 'totalSize', 'mtimeMs'];
194
- const unknown = Object.keys(expected).find((key) => !allowed.includes(key));
193
+ const allowed = ['path', 'exists', 'size', 'walSize', 'shmSize', 'totalSize', 'mtimeMs'] as const;
194
+ const unknown = Object.keys(expected).find((key) => !(allowed as readonly string[]).includes(key));
195
195
  if (unknown) throw new Error(`Unknown database snapshot option: ${unknown}`);
196
- if (
197
- expected.path !== current.path ||
198
- expected.exists !== current.exists ||
199
- expected.size !== current.size ||
200
- expected.walSize !== current.walSize ||
201
- expected.shmSize !== current.shmSize ||
202
- expected.totalSize !== current.totalSize ||
203
- expected.mtimeMs !== current.mtimeMs
204
- ) {
205
- throw new Error('Database changed after restore confirmation; reload status and confirm again');
196
+ // Compare what reflects the data. The -shm file is SQLite's shared-memory WAL
197
+ // index: it appears and disappears as connections open and close (the last one
198
+ // deletes it) without any write, so it, and the total that includes it, would
199
+ // reject valid confirmations. Writes still move the main file or the WAL.
200
+ const compared = ['path', 'exists', 'size', 'walSize', 'mtimeMs'] as const;
201
+ // Name every field that moved so a rejected restore is diagnosable.
202
+ const changed = compared
203
+ .filter((field) => expected[field] !== current[field])
204
+ .map((field) => `${field} ${shown(expected[field])} → ${shown(current[field])}`);
205
+ if (changed.length > 0) {
206
+ throw new Error(
207
+ `Database changed after restore confirmation (${changed.join(', ')}); reload status and confirm again`
208
+ );
206
209
  }
207
210
  }
211
+
212
+ function shown(value: unknown): string {
213
+ const text = String(value);
214
+ return text.length > 120 ? `${text.slice(0, 117)}...` : text;
215
+ }
@@ -72,7 +72,9 @@ function assertLocalTarget(target: string, config: ServerConfig, label: string):
72
72
  } catch {
73
73
  throw new Error(`${label} must be an absolute local HTTP URL`);
74
74
  }
75
- const local = ['localhost', '127.0.0.1', '::1'].includes(url.hostname);
75
+ // URL keeps IPv6 brackets in hostname (`[::1]`), so compare without them.
76
+ const host = url.hostname.replace(/^\[(.*)\]$/, '$1');
77
+ const local = ['localhost', '127.0.0.1', '::1'].includes(host);
76
78
  const port = Number(url.port || (url.protocol === 'https:' ? 443 : 80));
77
79
  if (!local || port !== config.httpPort || (url.protocol !== 'http:' && url.protocol !== 'https:')) {
78
80
  throw new Error(
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Split the managed launch command into argv without a shell. Whitespace
3
+ * separates arguments; a balanced pair of double or single quotes groups an
4
+ * argument that contains spaces (`"C:\Program Files\bun\bun.exe" run main.ts`).
5
+ * Backslashes are literal so Windows paths need no escaping. A command with an
6
+ * unbalanced quote (an apostrophe in a path) keeps the legacy whitespace split,
7
+ * so a configuration that launched before still launches the same way.
8
+ */
9
+ export function splitCommandLine(command: string): string[] {
10
+ const args: string[] = [];
11
+ let current = '';
12
+ let quote: '"' | "'" | null = null;
13
+ let inArgument = false;
14
+ for (const char of command.trim()) {
15
+ if (quote) {
16
+ if (char === quote) quote = null;
17
+ else current += char;
18
+ } else if (char === '"' || char === "'") {
19
+ quote = char;
20
+ inArgument = true;
21
+ } else if (/\s/.test(char)) {
22
+ if (inArgument) args.push(current);
23
+ current = '';
24
+ inArgument = false;
25
+ } else {
26
+ current += char;
27
+ inArgument = true;
28
+ }
29
+ }
30
+ if (quote) return command.trim().split(/\s+/);
31
+ if (inArgument) args.push(current);
32
+ return args;
33
+ }
@@ -0,0 +1,4 @@
1
+ /** The managed child survived SIGKILL: a server-side failure, not a bad request. */
2
+ export class ManagedProcessStuckError extends Error {
3
+ override name = 'ManagedProcessStuckError';
4
+ }
@@ -1,5 +1,7 @@
1
1
  import { copyConfig, defaultConfig, validateConfigPatch, validateServerConfig } from './config';
2
2
  import { ProcessLogs } from './logs';
3
+ import { splitCommandLine } from './commandLine';
4
+ import { ManagedProcessStuckError } from './errors';
3
5
  import type { ConfigStore } from './configStore';
4
6
  import { databaseStats } from './storage';
5
7
  import { removeSqlitePaths, validateManagedStorage } from './storageMode';
@@ -118,7 +120,7 @@ export class ProcessManager {
118
120
 
119
121
  const launchConfig = validateServerConfig(this.config);
120
122
  const storageMode = validateManagedStorage(launchConfig);
121
- const [command, ...args] = launchConfig.command.trim().split(/\s+/);
123
+ const [command, ...args] = splitCommandLine(launchConfig.command);
122
124
  this.status = 'starting';
123
125
  this.exitCode = null;
124
126
  const token = ++this.procToken;
@@ -219,7 +221,7 @@ export class ProcessManager {
219
221
  if (!(await exitsWithin(process, this.stopTimeoutMs))) {
220
222
  const message = `pid ${process.pid} did not exit after SIGKILL`;
221
223
  this.output.push('sys', `${message} — retaining failed-process tracking`);
222
- throw new Error(message);
224
+ throw new ManagedProcessStuckError(message);
223
225
  }
224
226
  }
225
227
  this.output.cancel(token);
package/agent/manager.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  /** Public compatibility entrypoint for managed bunqueue process supervision. */
2
2
  export { validateConfigPatch } from './manager/config';
3
+ export { ManagedProcessStuckError } from './manager/errors';
3
4
  export { ProcessManager } from './manager/process';
4
5
  export {
5
6
  managedPostgresNamespace,
@@ -123,6 +123,16 @@ export function assertManagedControl(controlTarget: ServerControlTarget): void {
123
123
  }
124
124
  }
125
125
 
126
+ /**
127
+ * Release the Workflow Engine's read-write SQLite connection before the data
128
+ * file is replaced. A broker that crashed (or was killed externally) leaves the
129
+ * engine open; after a restore it would keep writing to the unlinked file.
130
+ */
131
+ export async function closeWorkflowRuntime(runtime: WorkflowRuntimePort): Promise<void> {
132
+ const closeResult = await settle(() => runtime.close());
133
+ if (!closeResult.ok) throw workflowCloseError(closeResult.error);
134
+ }
135
+
126
136
  async function stopWithRuntimeCleanup(
127
137
  manager: ProcessManager,
128
138
  runtime: WorkflowRuntimePort
@@ -4,6 +4,7 @@ import {
4
4
  managedStorageMode,
5
5
  type ProcessManager,
6
6
  } from '../manager';
7
+ import { managedConnection } from '../managedConnection';
7
8
  import { probeExternalHealth, type ServerControlTarget } from './controlTarget';
8
9
 
9
10
  export async function statusWithHealth(
@@ -30,12 +31,10 @@ export async function statusWithHealth(
30
31
  }
31
32
  let healthy = false;
32
33
  let version: string | undefined;
34
+ const effectiveConfig = snapshot.runningConfig ?? snapshot.config;
33
35
  if (snapshot.status === 'running') {
34
- const port = snapshot.runningConfig?.httpPort ?? snapshot.config.httpPort;
35
36
  try {
36
- const response = await fetch(`http://127.0.0.1:${port}/health`, {
37
- signal: AbortSignal.timeout(1500),
38
- });
37
+ const response = await fetchManagedHealth(effectiveConfig);
39
38
  if (response.ok) {
40
39
  const body: unknown = await response.json();
41
40
  if (body !== null && typeof body === 'object' && !Array.isArray(body)) {
@@ -48,7 +47,6 @@ export async function statusWithHealth(
48
47
  // The managed process is not healthy yet.
49
48
  }
50
49
  }
51
- const effectiveConfig = snapshot.runningConfig ?? snapshot.config;
52
50
  const storageMode = statusStorageMode(effectiveConfig);
53
51
  const database = await statusDatabase(manager, effectiveConfig);
54
52
  const current = manager.getStatus();
@@ -85,6 +83,29 @@ export async function statusWithHealth(
85
83
  };
86
84
  }
87
85
 
86
+ /**
87
+ * Bunqueue serves HTTPS on its HTTP port once server TLS is configured, so the
88
+ * probe follows the same TLS resolution (and private CA) as the agent's TCP
89
+ * clients; a plain-HTTP probe would report a healthy TLS server as down forever.
90
+ */
91
+ async function fetchManagedHealth(
92
+ config: ReturnType<ProcessManager['getConfig']>
93
+ ): Promise<Response> {
94
+ let tls: ReturnType<typeof managedConnection>['tls'];
95
+ try {
96
+ tls = managedConnection(config).tls;
97
+ } catch {
98
+ // Invalid TLS settings are reported by the routes that use them.
99
+ tls = undefined;
100
+ }
101
+ const caFile = typeof tls === 'object' ? tls.caFile : undefined;
102
+ const ca = caFile ? await Bun.file(caFile).text() : undefined;
103
+ return fetch(`${tls ? 'https' : 'http'}://127.0.0.1:${config.httpPort}/health`, {
104
+ signal: AbortSignal.timeout(1500),
105
+ ...(ca ? { tls: { ca } } : {}),
106
+ });
107
+ }
108
+
88
109
  async function statusDatabase(
89
110
  manager: ProcessManager,
90
111
  config: ReturnType<ProcessManager['getConfig']>
@@ -4,6 +4,7 @@ import {
4
4
  MissingDbError,
5
5
  } from '../db';
6
6
  import { DbReadBusyError, DbReadUnavailableError } from '../db/types';
7
+ import { ManagedProcessStuckError } from '../manager';
7
8
  import { safeErrorMessage } from '../errorMessage';
8
9
  import { QueueOperationsUnavailableError } from '../queue/types';
9
10
  import { WorkflowRuntimeUnavailableError } from '../workflow/runtime';
@@ -15,6 +16,7 @@ const CONFLICT_MESSAGES = [
15
16
  'Another backup operation is already running',
16
17
  'Another maintenance operation is already running',
17
18
  'Cannot start the managed Bunqueue server',
19
+ 'Cannot start while the previous managed process',
18
20
  'Cannot restart the managed Bunqueue server',
19
21
  'BUNQUEUE_WORKFLOW_QUEUE_NAME must',
20
22
  'BUNQUEUE_WORKFLOW_CONCURRENCY must',
@@ -26,6 +28,7 @@ const CONFLICT_MESSAGES = [
26
28
  export function errorStatus(error: unknown): number {
27
29
  try {
28
30
  if (error instanceof AgentLifecycleClosedError) return 503;
31
+ if (error instanceof ManagedProcessStuckError) return 500;
29
32
  if (error instanceof MissingDbError) return 404;
30
33
  if (error instanceof DbReadBusyError) return 429;
31
34
  if (error instanceof DbReadUnavailableError) return 503;
@@ -10,7 +10,7 @@ import { routeQueueOperationsRequest } from '../queue/routes';
10
10
  import type { QueueOperationsPort } from '../queue/types';
11
11
  import type { WorkflowRuntimePort } from '../workflow/runtime';
12
12
  import { routeWorkflowRuntimeRequest } from '../workflow/runtimeRoutes';
13
- import { assertManagedControl, routeControlRequest } from './controlRoutes';
13
+ import { assertManagedControl, closeWorkflowRuntime, routeControlRequest } from './controlRoutes';
14
14
  import type { ServerControlTarget } from './controlTarget';
15
15
  import { routeDatabaseRequest } from './databaseRoutes';
16
16
  import type { AgentLifecyclePort } from './lifecycle';
@@ -123,11 +123,16 @@ export async function routeAgentRequest(
123
123
  return sqliteOnly('S3 backup', currentStorageMode);
124
124
  }
125
125
  return manager.withStoppedMaintenance('restoring a backup', async () => {
126
+ // Measure what the operator confirmed before our own cleanup: releasing
127
+ // the Workflow Engine's connection checkpoints the WAL, which changes the
128
+ // files without any other writer and would void a valid confirmation.
129
+ const confirmedDatabase = await manager.dbStats(restoreConfig.dataPath);
130
+ await closeWorkflowRuntime(runtime);
126
131
  return executePreparedBackupRequest(
127
132
  prepared,
128
133
  restoreConfig,
129
134
  current.status === 'running',
130
- await manager.dbStats(restoreConfig.dataPath),
135
+ confirmedDatabase,
131
136
  backupRunner,
132
137
  undefined,
133
138
  managedTargetPolicy
package/agent/shutdown.ts CHANGED
@@ -2,7 +2,8 @@ import { logger } from './logger';
2
2
  import { safeErrorMessage } from './errorMessage';
3
3
  import type { AgentFetchHandler } from './server';
4
4
 
5
- const DEFAULT_GRACE_MS = 30_000;
5
+ /** Upper bound for a graceful agent drain; supervisors must wait at least this long. */
6
+ export const AGENT_SHUTDOWN_GRACE_MS = 30_000;
6
7
 
7
8
  export interface AgentShutdownOptions {
8
9
  /** Stop listeners synchronously; returned promises drain in-flight HTTP requests. */
@@ -28,7 +29,7 @@ export function createAgentShutdown(
28
29
  options: AgentShutdownOptions = {}
29
30
  ): (signal: string) => void {
30
31
  const exit = options.exit ?? ((code: number) => process.exit(code));
31
- const graceMs = options.graceMs ?? DEFAULT_GRACE_MS;
32
+ const graceMs = options.graceMs ?? AGENT_SHUTDOWN_GRACE_MS;
32
33
  let shuttingDown = false;
33
34
  let finished = false;
34
35
  let graceTimer: ReturnType<typeof setTimeout> | null = null;