@polderlabs/bizar 4.5.1 → 4.7.0

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 (94) hide show
  1. package/bizar-dash/dist/assets/__vite-browser-external-BIHI7g3E.js +0 -1
  2. package/bizar-dash/dist/assets/main-DAlLdW8I.css +1 -0
  3. package/bizar-dash/dist/assets/main-DGGq-iZI.js +361 -0
  4. package/bizar-dash/dist/assets/main-DGGq-iZI.js.map +1 -0
  5. package/bizar-dash/dist/assets/{mobile-O6ANdD4W.js → mobile-CWqPoGaT.js} +18 -19
  6. package/bizar-dash/dist/assets/mobile-CWqPoGaT.js.map +1 -0
  7. package/bizar-dash/dist/assets/{mobile-DQLFCjwJ.js → mobile-i4Uv9eW8.js} +1 -2
  8. package/bizar-dash/dist/assets/{mobile-DQLFCjwJ.js.map → mobile-i4Uv9eW8.js.map} +1 -1
  9. package/bizar-dash/dist/index.html +3 -3
  10. package/bizar-dash/dist/mobile.html +2 -2
  11. package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  12. package/bizar-dash/src/server/headroom.mjs +37 -35
  13. package/bizar-dash/src/server/logger.mjs +71 -0
  14. package/bizar-dash/src/server/memory-lightrag.mjs +84 -37
  15. package/bizar-dash/src/server/metrics.mjs +193 -0
  16. package/bizar-dash/src/server/mods-loader.mjs +13 -5
  17. package/bizar-dash/src/server/providers-store.mjs +73 -1
  18. package/bizar-dash/src/server/routes/chat.mjs +41 -0
  19. package/bizar-dash/src/server/routes/lightrag.mjs +7 -2
  20. package/bizar-dash/src/server/routes/memory.mjs +13 -4
  21. package/bizar-dash/src/server/routes/misc.mjs +2 -1
  22. package/bizar-dash/src/server/routes/overview.mjs +2 -1
  23. package/bizar-dash/src/server/schedules-runner.mjs +4 -4
  24. package/bizar-dash/src/server/server.mjs +101 -37
  25. package/bizar-dash/src/server/watcher.mjs +2 -2
  26. package/bizar-dash/src/web/App.tsx +40 -6
  27. package/bizar-dash/src/web/components/SearchModal.tsx +3 -2
  28. package/bizar-dash/src/web/components/Toast.tsx +1 -1
  29. package/bizar-dash/src/web/components/Topbar.tsx +1 -1
  30. package/bizar-dash/src/web/components/VirtualList.tsx +53 -0
  31. package/bizar-dash/src/web/components/chat/ChatThread.tsx +17 -11
  32. package/bizar-dash/src/web/hooks/useI18n.ts +13 -0
  33. package/bizar-dash/src/web/lib/api.ts +12 -11
  34. package/bizar-dash/src/web/lib/i18n.ts +25 -0
  35. package/bizar-dash/src/web/locales/en.json +52 -0
  36. package/bizar-dash/src/web/main.tsx +5 -0
  37. package/bizar-dash/src/web/styles/chat.css +2 -0
  38. package/bizar-dash/src/web/styles/main.css +52 -23
  39. package/bizar-dash/src/web/styles/tasks.css +2 -0
  40. package/bizar-dash/src/web/views/Activity.tsx +24 -17
  41. package/bizar-dash/src/web/views/History.tsx +90 -75
  42. package/bizar-dash/src/web/views/Memory.tsx +4 -3
  43. package/bizar-dash/src/web/views/MiniMaxUsage.tsx +3 -2
  44. package/bizar-dash/src/web/views/Overview.tsx +15 -10
  45. package/bizar-dash/src/web/views/Settings.tsx +9 -2
  46. package/bizar-dash/src/web/views/Skills.tsx +3 -2
  47. package/bizar-dash/src/web/views/Tasks.tsx +4 -3
  48. package/bizar-dash/src/web/views/memory/MemoryOverview.tsx +8 -6
  49. package/bizar-dash/tests/cli-bugfixes.test.mjs +151 -0
  50. package/bizar-dash/tests/cli-refactor.test.mjs +184 -0
  51. package/bizar-dash/tests/components/Button.test.tsx +41 -0
  52. package/bizar-dash/tests/components/Card.test.tsx +42 -0
  53. package/bizar-dash/tests/components/Modal.test.tsx +104 -0
  54. package/bizar-dash/tests/components/Spinner.test.tsx +32 -0
  55. package/bizar-dash/tests/components/StatusBadge.test.tsx +35 -0
  56. package/bizar-dash/tests/components/Toast.test.tsx +108 -0
  57. package/bizar-dash/tests/frontend-bugfixes.test.mjs +151 -0
  58. package/bizar-dash/tests/hooks/useModal.test.tsx +84 -0
  59. package/bizar-dash/tests/hooks/useToast.test.tsx +50 -0
  60. package/bizar-dash/tests/lib/i18n.test.ts +46 -0
  61. package/bizar-dash/tests/lib/utils.test.ts +194 -0
  62. package/bizar-dash/tests/logger.test.mjs +207 -0
  63. package/bizar-dash/tests/metrics.test.mjs +183 -0
  64. package/bizar-dash/tests/server-bugfixes.test.mjs +318 -0
  65. package/bizar-dash/tests/setup.ts +7 -0
  66. package/bizar-dash/vitest.config.ts +13 -0
  67. package/cli/artifact-cli.mjs +605 -0
  68. package/cli/artifact-render.mjs +621 -0
  69. package/cli/artifact-server.mjs +847 -0
  70. package/cli/artifact.mjs +38 -2089
  71. package/cli/bg.mjs +5 -13
  72. package/cli/bin.mjs +170 -1348
  73. package/cli/commands/artifact.mjs +20 -0
  74. package/cli/commands/dash.mjs +160 -0
  75. package/cli/commands/headroom.mjs +204 -0
  76. package/cli/commands/install.mjs +169 -0
  77. package/cli/commands/memory.mjs +25 -0
  78. package/cli/commands/minimax.mjs +265 -0
  79. package/cli/commands/mod.mjs +185 -0
  80. package/cli/commands/service.mjs +65 -0
  81. package/cli/commands/usage.mjs +109 -0
  82. package/cli/commands/util.mjs +306 -0
  83. package/cli/doctor.mjs +6 -16
  84. package/cli/memory.mjs +65 -21
  85. package/cli/provision.mjs +10 -15
  86. package/cli/service-controller.mjs +1 -11
  87. package/cli/service.mjs +1 -11
  88. package/cli/utils.mjs +41 -1
  89. package/install.sh +1 -2
  90. package/package.json +8 -3
  91. package/bizar-dash/dist/assets/main-eWZ4NlCL.css +0 -1
  92. package/bizar-dash/dist/assets/main-usWhlPWa.js +0 -362
  93. package/bizar-dash/dist/assets/main-usWhlPWa.js.map +0 -1
  94. package/bizar-dash/dist/assets/mobile-O6ANdD4W.js.map +0 -1
@@ -596,7 +596,8 @@ export const modsLoader = {
596
596
  const res = await fetch(modJsonUrl);
597
597
  if (!res.ok) return null;
598
598
  modJson = await res.json();
599
- } catch {
599
+ } catch (err) {
600
+ console.warn('swallowed in fetchModMetadata:', err.message);
600
601
  return null;
601
602
  }
602
603
  const id = modJson.id;
@@ -674,7 +675,8 @@ export const modsLoader = {
674
675
  let entries;
675
676
  try {
676
677
  entries = readdirSync(p, { withFileTypes: true });
677
- } catch {
678
+ } catch (err) {
679
+ console.warn('swallowed in mod walk:', err.message);
678
680
  return;
679
681
  }
680
682
  for (const e of entries) {
@@ -913,20 +915,26 @@ export const modsLoader = {
913
915
  try {
914
916
  out.agents = readdirSync(OPENCODE_AGENTS_DIR)
915
917
  .filter((f) => f.startsWith(prefix));
916
- } catch { /* ignore */ }
918
+ } catch (err) {
919
+ console.warn('swallowed in agents readdir:', err.message);
920
+ }
917
921
  }
918
922
  if (existsSync(OPENCODE_COMMANDS_DIR)) {
919
923
  try {
920
924
  out.commands = readdirSync(OPENCODE_COMMANDS_DIR)
921
925
  .filter((f) => f.startsWith(prefix));
922
- } catch { /* ignore */ }
926
+ } catch (err) {
927
+ console.warn('swallowed in commands readdir:', err.message);
928
+ }
923
929
  }
924
930
  if (existsSync(OPENCODE_SKILLS_DIR)) {
925
931
  try {
926
932
  out.skills = readdirSync(OPENCODE_SKILLS_DIR, { withFileTypes: true })
927
933
  .filter((e) => e.isDirectory() && e.name.startsWith(skillPrefix))
928
934
  .map((e) => e.name);
929
- } catch { /* ignore */ }
935
+ } catch (err) {
936
+ console.warn('swallowed in skills readdir:', err.message);
937
+ }
930
938
  }
931
939
  return out;
932
940
  },
@@ -38,6 +38,7 @@ import {
38
38
  renameSync,
39
39
  mkdirSync,
40
40
  readdirSync,
41
+ statSync,
41
42
  } from 'node:fs';
42
43
  import { dirname, join } from 'node:path';
43
44
  import { homedir } from 'node:os';
@@ -66,13 +67,80 @@ function atomicWriteJson(filePath, data) {
66
67
  renameSync(tmp, filePath);
67
68
  }
68
69
 
70
+ // v5.0.0 — Bug S1: 1-second debounced cache for opencode.json reads.
71
+ // `list()`/`listAll()` and other consumers call `loadConfig()` on every
72
+ // WS poll (every ~5s per client) and on every route hit. With N clients
73
+ // that becomes O(N) reads per minute. The cache collapses all reads
74
+ // within a 1-second window to a single read. Writes invalidate so
75
+ // subsequent reads see the new state.
76
+ //
77
+ // The cache lives on `globalThis` so it is shared across all module
78
+ // instances of providers-store.mjs — tests and code that re-imports
79
+ // the module (e.g. via `?cb=` cache-bust) must see the same cache,
80
+ // otherwise a write on one instance becomes invisible to another.
81
+ //
82
+ // The cache also tracks the file's mtime+size and invalidates if the
83
+ // on-disk file has been modified externally (e.g. an editor saving
84
+ // opencode.json, or a test setup writing a fresh file). This prevents
85
+ // stale reads when the file changes outside our write paths.
86
+ const OPENCODE_JSON_CACHE_GLOBAL_KEY = '__bizar_opencode_json_cache__';
87
+ function _opencodeCacheSlot() {
88
+ if (!globalThis[OPENCODE_JSON_CACHE_GLOBAL_KEY]) {
89
+ globalThis[OPENCODE_JSON_CACHE_GLOBAL_KEY] = { entry: null, at: 0 };
90
+ }
91
+ return globalThis[OPENCODE_JSON_CACHE_GLOBAL_KEY];
92
+ }
93
+
94
+ const OPENCODE_JSON_CACHE_TTL_MS = 1000;
95
+
96
+ function _fileStamp(filePath) {
97
+ // Best-effort stat — if stat fails (file missing, etc.), the caller
98
+ // will re-read and re-populate the cache.
99
+ try {
100
+ const st = statSync(filePath);
101
+ return `${st.size}:${st.mtimeMs}`;
102
+ } catch {
103
+ return null;
104
+ }
105
+ }
106
+
107
+ function readOpencodeJsonCached(filePath = OPENCODE_JSON) {
108
+ const slot = _opencodeCacheSlot();
109
+ const now = Date.now();
110
+ if (slot.entry && slot.entry.filePath === filePath) {
111
+ const age = now - slot.at;
112
+ if (age < OPENCODE_JSON_CACHE_TTL_MS) {
113
+ // Fast path: still within TTL. Verify the file hasn't been
114
+ // modified externally (e.g. another process, an editor, or a
115
+ // test that writes the file directly). The stamp check is cheap
116
+ // and protects correctness when the file is touched outside
117
+ // our write paths.
118
+ const stamp = _fileStamp(filePath);
119
+ if (stamp === null || stamp === slot.entry.stamp) {
120
+ return slot.entry.data;
121
+ }
122
+ }
123
+ }
124
+ const data = safeReadJSON(filePath, {});
125
+ slot.entry = { filePath, data, stamp: _fileStamp(filePath) };
126
+ slot.at = now;
127
+ return data;
128
+ }
129
+
130
+ function invalidateOpencodeJsonCache() {
131
+ const slot = _opencodeCacheSlot();
132
+ slot.entry = null;
133
+ slot.at = 0;
134
+ }
135
+
69
136
  function loadConfig() {
70
- return safeReadJSON(OPENCODE_JSON, {});
137
+ return readOpencodeJsonCached(OPENCODE_JSON);
71
138
  }
72
139
 
73
140
  function saveConfig(data) {
74
141
  mkdirSync(dirname(OPENCODE_JSON), { recursive: true });
75
142
  atomicWriteJson(OPENCODE_JSON, data);
143
+ invalidateOpencodeJsonCache();
76
144
  }
77
145
 
78
146
  // v4.6.0 — expose the on-disk load/save helpers so route modules can
@@ -81,6 +149,10 @@ function saveConfig(data) {
81
149
  // were not exported and config.mjs referenced them implicitly.
82
150
  export { loadConfig, saveConfig };
83
151
 
152
+ // v5.0.0 — expose cache helpers for tests and other consumers that need
153
+ // to force a re-read (e.g. settings-store after a write).
154
+ export { readOpencodeJsonCached, invalidateOpencodeJsonCache, OPENCODE_JSON_CACHE_TTL_MS };
155
+
84
156
  // ── v4.6.0 Backup-key rotation constants ────────────────────────────────────
85
157
  //
86
158
  // ERROR_COOLDOWN_THRESHOLD: after this many recorded errors on the same
@@ -22,6 +22,7 @@
22
22
  * - Upstream SSE error: clean up and return without crashing.
23
23
  */
24
24
  import { Router } from 'express';
25
+ import { warn as logWarn } from '../logger.mjs';
25
26
  import {
26
27
  existsSync,
27
28
  mkdirSync,
@@ -53,6 +54,38 @@ const SESSION_ID_RE = /^[A-Za-z0-9_-]{1,120}$/;
53
54
  const MAX_CHAT_SUBSCRIPTIONS = 50;
54
55
  let activeChatSubscriptions = 0;
55
56
 
57
+ /**
58
+ * v5.0.0 — Bug S3: per-chat-session backpressure cap on the SSE→WS
59
+ * forwarding pipeline. The upstream SSE can pump deltas faster than
60
+ * the WS broadcast can flush them if a connected WS client is slow.
61
+ * `safeSend()` in server.mjs already terminates slow WS clients based
62
+ * on the byte-level `bufferedAmount`, but we also need a defensive
63
+ * message-count cap so a single chat session can't accumulate an
64
+ * unbounded number of queued deltas in this process. When a session
65
+ * exceeds CHAT_DELTA_BUFFER_CAP, additional deltas are dropped with
66
+ * a warning log so operators see the issue.
67
+ */
68
+ const CHAT_DELTA_BUFFER_CAP = 1000;
69
+ const chatDeltaCounts = new Map(); // chatSessionId -> count since idle
70
+
71
+ function noteChatDelta(chatSessionId) {
72
+ const cur = chatDeltaCounts.get(chatSessionId) || 0;
73
+ if (cur >= CHAT_DELTA_BUFFER_CAP) {
74
+ logWarn('dropped delta for session: per-session cap exceeded; client is too slow', {
75
+ module: 'chat',
76
+ sessionId: chatSessionId,
77
+ cap: CHAT_DELTA_BUFFER_CAP,
78
+ });
79
+ return false;
80
+ }
81
+ chatDeltaCounts.set(chatSessionId, cur + 1);
82
+ return true;
83
+ }
84
+
85
+ function resetChatDeltaCount(chatSessionId) {
86
+ chatDeltaCounts.delete(chatSessionId);
87
+ }
88
+
56
89
  /**
57
90
  * @param {object} deps
58
91
  * @param {object} deps.state
@@ -489,6 +522,11 @@ async function streamOpencodeSession({
489
522
  // Forward text part deltas as chat:delta
490
523
  const textDelta = extractTextDelta(envelope);
491
524
  if (textDelta) {
525
+ // Bug S3 — drop the delta (with warning) if this session
526
+ // has hit the per-connection cap. The upstream SSE pump
527
+ // continues, but we stop forwarding to WS to avoid
528
+ // unbounded buffering here.
529
+ if (!noteChatDelta(chatSessionId)) return;
492
530
  broadcast({
493
531
  type: 'chat:delta',
494
532
  sessionId: chatSessionId,
@@ -501,6 +539,9 @@ async function streamOpencodeSession({
501
539
  onIdle(envelope) {
502
540
  if (done) return;
503
541
  done = true;
542
+ // Bug S3 — release the per-session delta counter on idle so
543
+ // the next prompt starts with a fresh budget.
544
+ resetChatDeltaCount(chatSessionId);
504
545
  // Fetch the final message list and extract the assistant reply.
505
546
  void (async () => {
506
547
  try {
@@ -27,6 +27,7 @@ import {
27
27
  LIGHTRAG_DEFAULT_EMBEDDING,
28
28
  } from '../memory-store.mjs';
29
29
  import { resolveLightRAGConfig } from '../memory-lightrag.mjs';
30
+ import { warn as logWarn } from '../logger.mjs';
30
31
  import { wrap } from './_shared.mjs';
31
32
 
32
33
  /**
@@ -127,13 +128,17 @@ export function createLightragRouter({ projectRoot }) {
127
128
  const content = readFileSync(logFile, 'utf8');
128
129
  const lines = content.split('\n');
129
130
  logTail.push(...lines.slice(-30));
130
- } catch { /* ignore */ }
131
+ } catch (err) {
132
+ logWarn('swallowed in lightrag log tail', { module: 'lightrag', err: err.message });
133
+ }
131
134
  }
132
135
 
133
136
  let logSize = 0;
134
137
  try {
135
138
  if (existsSync(logFile)) logSize = statSync(logFile).size;
136
- } catch { /* ignore */ }
139
+ } catch (err) {
140
+ logWarn('swallowed in lightrag log stat', { module: 'lightrag', err: err.message });
141
+ }
137
142
 
138
143
  res.json({
139
144
  running: alive,
@@ -14,6 +14,7 @@ import { join, dirname } from 'node:path';
14
14
  import { fileURLToPath } from 'node:url';
15
15
  import { existsSync, mkdirSync, readFileSync, statSync, readdirSync } from 'node:fs';
16
16
  import { execFileSync } from 'node:child_process';
17
+ import { warn as logWarn } from '../logger.mjs';
17
18
 
18
19
  const SERVER_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
19
20
  const memoryStore = await import(`${SERVER_ROOT}/memory-store.mjs`).then((m) => m);
@@ -78,7 +79,9 @@ export function createMemoryRouter({ projectRoot }) {
78
79
  try {
79
80
  const data = JSON.parse(readFileSync(reindexMarker, 'utf8'));
80
81
  lastSecretScan = data.attemptedAt || null;
81
- } catch { /* ignore */ }
82
+ } catch (err) {
83
+ logWarn('swallowed in memory reindex marker read', { module: 'memory', err: err.message });
84
+ }
82
85
  }
83
86
 
84
87
  res.json({
@@ -486,7 +489,9 @@ export function createMemoryRouter({ projectRoot }) {
486
489
  conflicts.push({ relPath: note.relPath, reason: 'git conflict markers' });
487
490
  }
488
491
  }
489
- } catch { /* ignore */ }
492
+ } catch (err) {
493
+ logWarn('swallowed in memory conflict scan', { module: 'memory', err: err.message });
494
+ }
490
495
 
491
496
  res.json({ conflicts });
492
497
  }));
@@ -523,7 +528,9 @@ export function createMemoryRouter({ projectRoot }) {
523
528
  try {
524
529
  const { recordQuery } = await getMemoryLightrag();
525
530
  recordQuery(projectRoot, Date.now() - startedAt);
526
- } catch { /* ignore */ }
531
+ } catch (err) {
532
+ logWarn('swallowed in memory recordQuery', { module: 'memory', err: err.message });
533
+ }
527
534
  res.json({ ok: true, q, lexical, semantic });
528
535
  }));
529
536
 
@@ -605,7 +612,9 @@ export function createMemoryRouter({ projectRoot }) {
605
612
  const content = readFileSync(logFile, 'utf8');
606
613
  const lines = content.split('\n');
607
614
  logTail.push(...lines.slice(-30));
608
- } catch {}
615
+ } catch (err) {
616
+ logWarn('swallowed in memory log tail', { module: 'memory', err: err.message });
617
+ }
609
618
  }
610
619
 
611
620
  let lastError = null;
@@ -18,6 +18,7 @@ import { searchStore } from '../search-store.mjs';
18
18
  import { tailscaleStore } from '../tailscale-store.mjs';
19
19
  import { updateStore } from '../update-store.mjs';
20
20
  import { projectsStore } from '../projects-store.mjs';
21
+ import { error as logError } from '../logger.mjs';
21
22
  import { wrap } from './_shared.mjs';
22
23
 
23
24
  /**
@@ -81,7 +82,7 @@ export function createMiscRouter({ state, broadcast }) {
81
82
  updateStore.applyWithProgress({
82
83
  packages,
83
84
  broadcast,
84
- }).catch((err) => console.error('[updates] error:', err));
85
+ }).catch((err) => logError('updates error', { module: 'updates', err: err?.message || String(err) }));
85
86
  res.json({ started: true, packages });
86
87
  });
87
88
 
@@ -15,6 +15,7 @@ import { agentsStore } from '../agents-store.mjs';
15
15
  import { schedulesStore } from '../schedules-store.mjs';
16
16
  import { providersStore, mcpsStore } from '../providers-store.mjs';
17
17
  import { modsLoader } from '../mods-loader.mjs';
18
+ import { error as logError } from '../logger.mjs';
18
19
  import {
19
20
  OPENCODE_JSON,
20
21
  readSettings,
@@ -56,7 +57,7 @@ export function createOverviewRouter({ state }) {
56
57
  },
57
58
  );
58
59
  child.on('error', (err) => {
59
- console.error('[dashboard] restart spawn failed:', err.message);
60
+ logError('restart spawn failed', { module: 'overview', err: err.message });
60
61
  });
61
62
  child.unref();
62
63
  process.exit(0);
@@ -34,8 +34,8 @@ function logLine(line) {
34
34
  try {
35
35
  mkdirSync(LOG_DIR, { recursive: true });
36
36
  appendFileSync(LOG_FILE, line + '\n', 'utf8');
37
- } catch {
38
- /* ignore */
37
+ } catch (err) {
38
+ console.warn('swallowed in schedule log append:', err.message);
39
39
  }
40
40
  }
41
41
 
@@ -288,8 +288,8 @@ export const schedulesRunner = {
288
288
  const projectIds = new Set(reg.projects.map((project) => project.id));
289
289
  try {
290
290
  if (schedulesStore.list('default').length > 0) projectIds.add('default');
291
- } catch {
292
- /* ignore */
291
+ } catch (err) {
292
+ console.warn('swallowed in schedules due-list:', err.message);
293
293
  }
294
294
  let skipped = 0;
295
295
  for (const projectId of projectIds) {
@@ -24,6 +24,7 @@ import { agentsStore } from './agents-store.mjs';
24
24
  import { tasksStore } from './tasks-store.mjs';
25
25
  import { schedulesStore } from './schedules-store.mjs';
26
26
  import { providersStore, mcpsStore } from './providers-store.mjs';
27
+ import { readOpencodeJsonCached } from './providers-store.mjs';
27
28
  import { homedir } from 'node:os';
28
29
  import { startBgPoller, stopBgPoller } from './bg-poller.mjs';
29
30
  import { startBgRetryLoop, stopBgRetryLoop } from './bg-retry.mjs';
@@ -38,6 +39,21 @@ import { buildAllowedRootsFromSettings, resolveSafePath } from './lib/path-safe.
38
39
  import { V2EventBus } from './v2-event-bus.mjs';
39
40
  import { loadOrCreateAuth, V2_DEFAULT_PORT } from './v2-auth-file.mjs';
40
41
  import { createV2Router } from './routes-v2/index.mjs';
42
+ import { counter, gauge, render as renderMetrics } from './metrics.mjs';
43
+
44
+ // v4.7.0 — Prometheus-style HTTP metrics. Bound to the server-wide
45
+ // registry; render() emits the text exposition format consumed by
46
+ // `GET /metrics`. Counter is keyed by {method, route, status} where
47
+ // `route` is the matched Express pattern (e.g. `/api/tasks/:id`) to
48
+ // keep cardinality bounded; unmatched 404s collapse to `unmatched`.
49
+ const httpRequestsTotal = counter(
50
+ 'http_requests_total',
51
+ 'Count of HTTP requests handled by the dashboard server.',
52
+ );
53
+ const wsClientsGauge = gauge(
54
+ 'ws_clients',
55
+ 'Number of currently-connected WebSocket clients (snapshot + /ws/logs).',
56
+ );
41
57
 
42
58
  let processHandlersInstalled = false;
43
59
  let v2Bus = null;
@@ -160,6 +176,46 @@ export async function createServer({
160
176
  next();
161
177
  });
162
178
 
179
+ // v4.7.0 — Prometheus scrape endpoint. Mounted BEFORE the auth
180
+ // middleware and OUTSIDE /api/* so scrapers don't need a bearer
181
+ // token. Returns text/plain in the standard 0.0.4 exposition
182
+ // format so both Prometheus and `curl http://host/metrics` work.
183
+ app.get('/metrics', (_req, res) => {
184
+ res.set('Content-Type', 'text/plain; version=0.0.4; charset=utf-8');
185
+ res.send(renderMetrics());
186
+ });
187
+
188
+ // v4.7.0 — Per-request counter. Uses the matched Express route
189
+ // pattern (e.g. `/api/tasks/:id`) when available; falls back to
190
+ // `unmatched` for 404s so the cardinality stays bounded. Hooked off
191
+ // `finish` so the final status code is captured even after error
192
+ // handlers run.
193
+ app.use((req, res, next) => {
194
+ res.on('finish', () => {
195
+ const route = req.route?.path ? `${req.baseUrl || ''}${req.route.path}` : 'unmatched';
196
+ httpRequestsTotal.inc({
197
+ method: req.method,
198
+ route,
199
+ status: String(res.statusCode),
200
+ });
201
+ });
202
+ next();
203
+ });
204
+
205
+ // v4.7.0 — Cache-Control headers for live JSON endpoints. The
206
+ // frontend pulls these every few seconds, so we use `no-cache` (which
207
+ // allows conditional revalidation) rather than `no-store` (which
208
+ // forbids it). Static assets already get `max-age=1y` via
209
+ // express.static above; this only fills the gap for the live API.
210
+ app.use('/api/settings', (req, res, next) => {
211
+ res.setHeader('Cache-Control', 'no-cache');
212
+ next();
213
+ });
214
+ app.use('/api/snapshot', (req, res, next) => {
215
+ res.setHeader('Cache-Control', 'no-cache');
216
+ next();
217
+ });
218
+
163
219
  app.use(
164
220
  (
165
221
  err,
@@ -281,8 +337,8 @@ export async function createServer({
281
337
  if (client.bufferedAmount > WS_BACKPRESSURE_LIMIT_BYTES) {
282
338
  try {
283
339
  client.terminate();
284
- } catch {
285
- /* ignore */
340
+ } catch (err) {
341
+ console.warn('swallowed in terminate (backpressure):', err.message);
286
342
  }
287
343
  return false;
288
344
  }
@@ -396,8 +452,8 @@ export async function createServer({
396
452
  try {
397
453
  socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
398
454
  socket.destroy();
399
- } catch {
400
- /* ignore */
455
+ } catch (err) {
456
+ console.warn('swallowed in 403 destroy:', err.message);
401
457
  }
402
458
  return;
403
459
  }
@@ -405,8 +461,8 @@ export async function createServer({
405
461
  try {
406
462
  socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
407
463
  socket.destroy();
408
- } catch {
409
- /* ignore */
464
+ } catch (err) {
465
+ console.warn('swallowed in 401 destroy:', err.message);
410
466
  }
411
467
  return;
412
468
  }
@@ -420,8 +476,8 @@ export async function createServer({
420
476
  if (client.isAlive === false) {
421
477
  try {
422
478
  client.terminate();
423
- } catch {
424
- /* ignore */
479
+ } catch (err) {
480
+ console.warn('swallowed in heartbeat terminate:', err.message);
425
481
  }
426
482
  return;
427
483
  }
@@ -431,8 +487,8 @@ export async function createServer({
431
487
  } catch {
432
488
  try {
433
489
  client.terminate();
434
- } catch {
435
- /* ignore */
490
+ } catch (err) {
491
+ console.warn('swallowed in ping-fail terminate:', err.message);
436
492
  }
437
493
  }
438
494
  });
@@ -518,9 +574,16 @@ export async function createServer({
518
574
 
519
575
  wss.on('connection', (ws, req) => {
520
576
  ws.isAlive = true;
577
+ wsClientsGauge.set(wss.clients.size);
521
578
  ws.on('pong', () => {
522
579
  ws.isAlive = true;
523
580
  });
581
+ ws.on('close', () => {
582
+ wsClientsGauge.set(wss.clients.size);
583
+ });
584
+ ws.on('error', () => {
585
+ wsClientsGauge.set(wss.clients.size);
586
+ });
524
587
  const path = req.url
525
588
  ? (() => {
526
589
  try {
@@ -577,8 +640,8 @@ export async function createServer({
577
640
  }
578
641
  fileSize = newSize;
579
642
  }
580
- } catch {
581
- /* ignore */
643
+ } catch (err) {
644
+ console.warn('swallowed in sendLogChunk:', err.message);
582
645
  }
583
646
  }
584
647
 
@@ -617,8 +680,8 @@ export async function createServer({
617
680
  data: buildSnapshotSafe(state, opencodeConfigDir),
618
681
  }),
619
682
  );
620
- } catch {
621
- /* ignore */
683
+ } catch (err) {
684
+ console.warn('swallowed in ws snapshot send:', err.message);
622
685
  }
623
686
 
624
687
  ws.on('message', (raw) => {
@@ -678,34 +741,34 @@ export async function createServer({
678
741
  function close() {
679
742
  try {
680
743
  stopBgPoller();
681
- } catch {
682
- /* ignore */
744
+ } catch (err) {
745
+ console.warn('swallowed in stopBgPoller:', err.message);
683
746
  }
684
747
  try {
685
748
  stopBgRetryLoop();
686
- } catch {
687
- /* ignore */
749
+ } catch (err) {
750
+ console.warn('swallowed in stopBgRetryLoop:', err.message);
688
751
  }
689
752
  try {
690
753
  watcher.stop();
691
- } catch {
692
- /* ignore */
754
+ } catch (err) {
755
+ console.warn('swallowed in watcher.stop:', err.message);
693
756
  }
694
757
  try {
695
758
  wss.clients.forEach((c) => c.terminate());
696
759
  wss.close();
697
- } catch {
698
- /* ignore */
760
+ } catch (err) {
761
+ console.warn('swallowed in wss.close:', err.message);
699
762
  }
700
763
  try {
701
764
  clearInterval(heartbeatInterval);
702
- } catch {
703
- /* ignore */
765
+ } catch (err) {
766
+ console.warn('swallowed in clearInterval:', err.message);
704
767
  }
705
768
  try {
706
769
  server.close();
707
- } catch {
708
- /* ignore */
770
+ } catch (err) {
771
+ console.warn('swallowed in server.close:', err.message);
709
772
  }
710
773
  currentBroadcast = () => {};
711
774
  }
@@ -723,14 +786,15 @@ function buildSnapshotSafe(state, opencodeConfigDir) {
723
786
 
724
787
  function buildSnapshot(state, opencodeConfigDir) {
725
788
  const cfgFile = join(opencodeConfigDir, 'opencode.json');
726
- let cfg = null;
727
- if (existsSync(cfgFile)) {
728
- try {
729
- cfg = JSON.parse(readFileSync(cfgFile, 'utf8'));
730
- } catch {
731
- cfg = null;
732
- }
733
- }
789
+ // v5.0.0 Bug S2: read opencode.json via the 1s-debounced cache in
790
+ // providers-store.mjs. WS clients connect on snapshot delivery; with N
791
+ // clients the readFileSync here was the main per-connection blocker.
792
+ // The cache collapses all reads within a 1-second window to a single
793
+ // disk hit. Writes (settings change, providers-store mutations)
794
+ // invalidate the cache, so the snapshot always reflects the latest
795
+ // state within at most 1s.
796
+ const exists = existsSync(cfgFile);
797
+ const cfg = readOpencodeJsonCached();
734
798
  const activeProject = projectsStore.active();
735
799
  return {
736
800
  overview: state.getOverview(),
@@ -740,9 +804,9 @@ function buildSnapshot(state, opencodeConfigDir) {
740
804
  activeProject,
741
805
  config: {
742
806
  path: cfgFile,
743
- data: cfg,
744
- raw: cfg ? JSON.stringify(cfg, null, 2) : '',
745
- exists: existsSync(cfgFile),
807
+ data: exists ? cfg : null,
808
+ raw: exists && cfg ? JSON.stringify(cfg, null, 2) : '',
809
+ exists,
746
810
  },
747
811
  settings: readSettings(),
748
812
  tasks: activeProject ? tasksStore.loadTasks(activeProject.id) : [],
@@ -69,8 +69,8 @@ export function createWatcher({ paths, onChange, options = {} }) {
69
69
  async stop() {
70
70
  try {
71
71
  await watcher.close();
72
- } catch {
73
- /* ignore */
72
+ } catch (err) {
73
+ console.warn('swallowed in watcher.close:', err.message);
74
74
  }
75
75
  },
76
76
  /** Force a synthetic broadcast — useful after a self-mutation. */