whale-code 6.4.0 → 6.5.1

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 (187) hide show
  1. package/bin/swagmanager-mcp.js +51 -0
  2. package/dist/cli/app.js +30 -2
  3. package/dist/cli/chat/ChatApp.d.ts +4 -4
  4. package/dist/cli/chat/ChatApp.js +114 -44
  5. package/dist/cli/chat/ChatInput.d.ts +13 -6
  6. package/dist/cli/chat/ChatInput.js +433 -89
  7. package/dist/cli/chat/MemoryManager.d.ts +15 -0
  8. package/dist/cli/chat/MemoryManager.js +61 -0
  9. package/dist/cli/chat/MessageList.d.ts +8 -0
  10. package/dist/cli/chat/MessageList.js +1 -1
  11. package/dist/cli/chat/NodeManager.d.ts +30 -0
  12. package/dist/cli/chat/NodeManager.js +89 -0
  13. package/dist/cli/chat/NodeSelector.d.ts +19 -0
  14. package/dist/cli/chat/NodeSelector.js +37 -0
  15. package/dist/cli/chat/PlanApproval.d.ts +17 -0
  16. package/dist/cli/chat/PlanApproval.js +82 -0
  17. package/dist/cli/chat/SessionManager.d.ts +16 -0
  18. package/dist/cli/chat/SessionManager.js +43 -0
  19. package/dist/cli/chat/SlashMenu.d.ts +38 -0
  20. package/dist/cli/chat/SlashMenu.js +208 -0
  21. package/dist/cli/chat/StatusBar.d.ts +16 -0
  22. package/dist/cli/chat/StatusBar.js +22 -0
  23. package/dist/cli/chat/ThemeSelector.d.ts +14 -0
  24. package/dist/cli/chat/ThemeSelector.js +29 -0
  25. package/dist/cli/chat/ToolIndicator.d.ts +8 -0
  26. package/dist/cli/chat/ToolIndicator.js +33 -9
  27. package/dist/cli/chat/hooks/useAgentLoop.d.ts +2 -1
  28. package/dist/cli/chat/hooks/useAgentLoop.js +22 -17
  29. package/dist/cli/chat/hooks/useSlashCommands.d.ts +19 -0
  30. package/dist/cli/chat/hooks/useSlashCommands.js +254 -15
  31. package/dist/cli/commands/config-cmd.js +4 -25
  32. package/dist/cli/commands/db.d.ts +13 -0
  33. package/dist/cli/commands/db.js +243 -0
  34. package/dist/cli/commands/doctor.js +6 -9
  35. package/dist/cli/commands/mcp.js +1 -20
  36. package/dist/cli/services/agent-events.d.ts +22 -1
  37. package/dist/cli/services/agent-events.js +9 -0
  38. package/dist/cli/services/agent-loop.js +65 -8
  39. package/dist/cli/services/agent-worker-base.js +21 -6
  40. package/dist/cli/services/api-retry.d.ts +25 -0
  41. package/dist/cli/services/api-retry.js +91 -0
  42. package/dist/cli/services/auth-service.d.ts +1 -1
  43. package/dist/cli/services/auth-service.js +40 -19
  44. package/dist/cli/services/background-processes.js +26 -2
  45. package/dist/cli/services/config-store.d.ts +13 -1
  46. package/dist/cli/services/config-store.js +116 -13
  47. package/dist/cli/services/format-server-response.js +12 -6
  48. package/dist/cli/services/ink-resize-fix.d.ts +18 -0
  49. package/dist/cli/services/ink-resize-fix.js +66 -0
  50. package/dist/cli/services/interactive-tools.d.ts +14 -0
  51. package/dist/cli/services/interactive-tools.js +47 -2
  52. package/dist/cli/services/keybinding-manager.js +1 -1
  53. package/dist/cli/services/local-tools.js +35 -2
  54. package/dist/cli/services/server-tools.js +175 -3
  55. package/dist/cli/services/subagent.js +7 -6
  56. package/dist/cli/services/system-prompt.js +5 -3
  57. package/dist/cli/services/task-decomposer.d.ts +35 -0
  58. package/dist/cli/services/task-decomposer.js +199 -0
  59. package/dist/cli/services/team-lead.d.ts +18 -0
  60. package/dist/cli/services/team-lead.js +80 -0
  61. package/dist/cli/services/teammate.js +5 -5
  62. package/dist/cli/services/telemetry.d.ts +8 -2
  63. package/dist/cli/services/telemetry.js +116 -92
  64. package/dist/cli/services/tools/agent-tools.d.ts +1 -0
  65. package/dist/cli/services/tools/agent-tools.js +50 -4
  66. package/dist/cli/services/tools/file-ops.d.ts +2 -0
  67. package/dist/cli/services/tools/file-ops.js +85 -19
  68. package/dist/cli/services/tools/shell-exec.js +22 -12
  69. package/dist/cli/shared/Theme.d.ts +1 -2
  70. package/dist/cli/shared/Theme.js +1 -1
  71. package/dist/cli/shared/WhaleBanner.d.ts +4 -1
  72. package/dist/cli/shared/WhaleBanner.js +12 -8
  73. package/dist/cli/shared/markdown.d.ts +5 -4
  74. package/dist/cli/shared/markdown.js +376 -334
  75. package/dist/cli/shared/theme-manager.d.ts +27 -0
  76. package/dist/cli/shared/theme-manager.js +178 -0
  77. package/dist/cli/shared/theme-presets.d.ts +16 -0
  78. package/dist/cli/shared/theme-presets.js +265 -0
  79. package/dist/index.js +0 -51
  80. package/dist/node/adapters/imessage.d.ts +10 -0
  81. package/dist/node/adapters/imessage.js +45 -6
  82. package/dist/node/cli.js +459 -8
  83. package/dist/node/config.d.ts +17 -0
  84. package/dist/node/gateway-client.d.ts +55 -0
  85. package/dist/node/gateway-client.js +201 -0
  86. package/dist/node/portal/clipboard.d.ts +28 -0
  87. package/dist/node/portal/clipboard.js +183 -0
  88. package/dist/node/portal/discovery.d.ts +29 -0
  89. package/dist/node/portal/discovery.js +61 -0
  90. package/dist/node/portal/forward.d.ts +30 -0
  91. package/dist/node/portal/forward.js +90 -0
  92. package/dist/node/portal/index.d.ts +47 -0
  93. package/dist/node/portal/index.js +250 -0
  94. package/dist/node/portal/multiplexer.d.ts +48 -0
  95. package/dist/node/portal/multiplexer.js +207 -0
  96. package/dist/node/portal/permissions.d.ts +36 -0
  97. package/dist/node/portal/permissions.js +131 -0
  98. package/dist/node/portal/protocol.d.ts +140 -0
  99. package/dist/node/portal/protocol.js +193 -0
  100. package/dist/node/portal/screen.d.ts +18 -0
  101. package/dist/node/portal/screen.js +93 -0
  102. package/dist/node/portal/session.d.ts +68 -0
  103. package/dist/node/portal/session.js +127 -0
  104. package/dist/node/portal/shell.d.ts +26 -0
  105. package/dist/node/portal/shell.js +142 -0
  106. package/dist/node/portal/stream.d.ts +43 -0
  107. package/dist/node/portal/stream.js +90 -0
  108. package/dist/node/portal/transfer.d.ts +33 -0
  109. package/dist/node/portal/transfer.js +231 -0
  110. package/dist/node/portal/ui.d.ts +16 -0
  111. package/dist/node/portal/ui.js +148 -0
  112. package/dist/node/remote-desktop/compile-helper.d.ts +13 -0
  113. package/dist/node/remote-desktop/compile-helper.js +73 -0
  114. package/dist/node/remote-desktop/index.d.ts +67 -0
  115. package/dist/node/remote-desktop/index.js +220 -0
  116. package/dist/node/remote-desktop/protocol.d.ts +96 -0
  117. package/dist/node/remote-desktop/protocol.js +67 -0
  118. package/dist/node/runtime.d.ts +8 -1
  119. package/dist/node/runtime.js +117 -9
  120. package/dist/server/handlers/__test-utils__/test-db.d.ts +25 -0
  121. package/dist/server/handlers/__test-utils__/test-db.js +128 -0
  122. package/dist/server/handlers/api-keys.js +26 -2
  123. package/dist/server/handlers/browser.d.ts +0 -4
  124. package/dist/server/handlers/browser.js +0 -46
  125. package/dist/server/handlers/catalog.js +37 -14
  126. package/dist/server/handlers/clickhouse.d.ts +10 -0
  127. package/dist/server/handlers/clickhouse.js +215 -0
  128. package/dist/server/handlers/comms.d.ts +308 -4
  129. package/dist/server/handlers/comms.js +444 -11
  130. package/dist/server/handlers/creations.js +1 -1
  131. package/dist/server/handlers/crm.d.ts +54 -8
  132. package/dist/server/handlers/crm.js +353 -68
  133. package/dist/server/handlers/embeddings.js +3 -3
  134. package/dist/server/handlers/enrichment.js +39 -55
  135. package/dist/server/handlers/inventory.js +1 -1
  136. package/dist/server/handlers/kali.d.ts +9 -1
  137. package/dist/server/handlers/kali.js +50 -1
  138. package/dist/server/handlers/media.d.ts +8 -0
  139. package/dist/server/handlers/media.js +902 -0
  140. package/dist/server/handlers/meta-ads.js +6 -3
  141. package/dist/server/handlers/nodes.d.ts +2 -0
  142. package/dist/server/handlers/nodes.js +331 -40
  143. package/dist/server/handlers/operations.d.ts +4 -6
  144. package/dist/server/handlers/operations.js +99 -38
  145. package/dist/server/handlers/platform.js +224 -107
  146. package/dist/server/handlers/remove-bg.d.ts +6 -0
  147. package/dist/server/handlers/remove-bg.js +96 -0
  148. package/dist/server/handlers/storefront.d.ts +6 -0
  149. package/dist/server/handlers/storefront.js +477 -0
  150. package/dist/server/handlers/supply-chain.js +21 -3
  151. package/dist/server/handlers/workflow-steps.js +87 -31
  152. package/dist/server/handlers/workflows.js +4 -1
  153. package/dist/server/index.js +334 -88
  154. package/dist/server/lib/clickhouse-buffer.d.ts +48 -0
  155. package/dist/server/lib/clickhouse-buffer.js +175 -0
  156. package/dist/server/lib/clickhouse-client.d.ts +112 -0
  157. package/dist/server/lib/clickhouse-client.js +141 -0
  158. package/dist/server/lib/coa-renderer.d.ts +91 -0
  159. package/dist/server/lib/coa-renderer.js +411 -0
  160. package/dist/server/lib/compaction-service.js +46 -1
  161. package/dist/server/lib/pdf-renderer.d.ts +143 -0
  162. package/dist/server/lib/pdf-renderer.js +867 -0
  163. package/dist/server/lib/react-pdf-layout.d.ts +40 -0
  164. package/dist/server/lib/react-pdf-layout.js +437 -0
  165. package/dist/server/lib/server-agent-loop.d.ts +2 -0
  166. package/dist/server/lib/server-agent-loop.js +36 -17
  167. package/dist/server/lib/server-subagent.d.ts +3 -0
  168. package/dist/server/lib/server-subagent.js +9 -6
  169. package/dist/server/lib/supabase-client.js +51 -3
  170. package/dist/server/lib/template-resolver.js +14 -4
  171. package/dist/server/lib/utils.js +15 -0
  172. package/dist/server/local-agent-gateway.d.ts +44 -0
  173. package/dist/server/local-agent-gateway.js +389 -49
  174. package/dist/server/providers/anthropic.js +12 -2
  175. package/dist/server/providers/gemini.js +17 -2
  176. package/dist/server/proxy-handlers.js +151 -0
  177. package/dist/server/tool-router.d.ts +2 -2
  178. package/dist/server/tool-router.js +25 -35
  179. package/dist/shared/agent-core.d.ts +25 -2
  180. package/dist/shared/agent-core.js +66 -5
  181. package/dist/shared/api-client.js +54 -3
  182. package/dist/shared/sse-parser.d.ts +1 -1
  183. package/dist/shared/sse-parser.js +5 -2
  184. package/dist/shared/tool-dispatch.js +15 -1
  185. package/package.json +16 -10
  186. package/dist/server/handlers/__test-utils__/mock-supabase.d.ts +0 -11
  187. package/dist/server/handlers/__test-utils__/mock-supabase.js +0 -393
@@ -16,13 +16,14 @@ import { handleTranscribe } from "./handlers/transcription.js";
16
16
  import { handleBillingRoutes, incrementUsage, checkPlanLimits } from "./handlers/billing.js";
17
17
  import { generateCompaction } from "./lib/compaction-service.js";
18
18
  import { initLocalAgentGateway, shutdownGateway as shutdownAgentGateway, getGatewayStats } from "./local-agent-gateway.js";
19
- import { initSupabase, getServiceClient, createUserClient } from "./lib/supabase-client.js";
19
+ import { initSupabase, getServiceClient } from "./lib/supabase-client.js";
20
20
  import { loadCheckpoint, markOrphaned } from "./lib/session-checkpoint.js";
21
21
  import { rateLimiter } from "./lib/rate-limiter.js";
22
22
  import { sanitizeAndLog } from "./lib/prompt-sanitizer.js";
23
23
  import { processWorkflowSteps, processWaitingSteps, handleWebhookIngestion, executeInlineChain, setToolExecutor, setAgentExecutor, setTokenBroadcaster, setStepErrorBroadcaster, verifyGuestApprovalSignature, initWorkerPool, getPoolStats, shutdownPool, processScheduleTriggers, enforceWorkflowTimeouts, processEventTriggers, cleanupOrphanedSteps, processDlqRetries } from "./handlers/workflows.js";
24
24
  import { runServerAgentLoop } from "./lib/server-agent-loop.js";
25
- import { loadTools, loadUserTools, getToolsForAgent, executeTool, loadAgentConfig, setExtendedToolsCache, getExtendedToolsIndex, flushAuditLogs, } from "./tool-router.js";
25
+ import { loadTools, loadUserTools, getToolsForAgent, executeTool, loadAgentConfig, setExtendedToolsCache, getExtendedToolsIndex, flushSpans, } from "./tool-router.js";
26
+ import { queueSpan, auditRowToSpan, classifyErrorType } from "./lib/clickhouse-buffer.js";
26
27
  import pg from "pg";
27
28
  // ============================================================================
28
29
  // PROCESS ERROR HANDLERS
@@ -44,6 +45,9 @@ const SERVICE_ROLE_JWT = process.env.SERVICE_ROLE_JWT || "";
44
45
  const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
45
46
  const ALLOWED_ORIGINS = (process.env.ALLOWED_ORIGINS || "http://localhost:3000,http://127.0.0.1:3000").split(",").map(s => s.trim());
46
47
  const FLY_INTERNAL_SECRET = process.env.FLY_INTERNAL_SECRET || "";
48
+ if (!FLY_INTERNAL_SECRET) {
49
+ console.warn("[SECURITY] FLY_INTERNAL_SECRET is not set — internal endpoints are unprotected");
50
+ }
47
51
  // ============================================================================
48
52
  // READINESS STATE
49
53
  // ============================================================================
@@ -114,6 +118,76 @@ function safeCompare(a, b) {
114
118
  }
115
119
  // Tool registry, user tools, executor, and agent loader are in ./tool-router.ts
116
120
  // ============================================================================
121
+ // STORE CONTEXT — Server-derived store resolution (Apple-style)
122
+ // The server NEVER blindly trusts a client-supplied storeId. Instead it:
123
+ // 1. Resolves the user's stores from the `users` table via auth.uid()
124
+ // 2. Validates the client hint against the resolved set
125
+ // 3. Falls back to the user's first store if no hint provided
126
+ // 4. Returns null if no store can be resolved (caller must fail closed)
127
+ // ============================================================================
128
+ async function resolveAndValidateStoreId(supabase, clientStoreId, user, isServiceRole, _token, requestUserId) {
129
+ // Service-role callers: trusted ONLY for internal server-to-server calls
130
+ // (workflows, cron jobs) that have NO associated user. When a userId is
131
+ // present (e.g. MCP CLI using env-var service role key), we MUST still
132
+ // validate store membership to prevent cross-tenant access.
133
+ if (isServiceRole) {
134
+ const srUserId = user?.id || requestUserId;
135
+ if (!srUserId)
136
+ return clientStoreId || null; // true internal call — pass through
137
+ if (!clientStoreId)
138
+ return null; // user-context call without store hint
139
+ // Validate the user actually belongs to the requested store
140
+ const { data: membership } = await supabase
141
+ .from("store_members")
142
+ .select("id")
143
+ .eq("store_id", clientStoreId)
144
+ .eq("user_id", srUserId)
145
+ .limit(1)
146
+ .maybeSingle();
147
+ if (membership)
148
+ return clientStoreId;
149
+ // Fallback: check users table (legacy single-store pattern)
150
+ const { data: userRow } = await supabase
151
+ .from("users")
152
+ .select("store_id")
153
+ .eq("auth_user_id", srUserId)
154
+ .eq("store_id", clientStoreId)
155
+ .maybeSingle();
156
+ if (userRow)
157
+ return clientStoreId;
158
+ log.warn({ userId: srUserId, clientStoreId }, "resolveStoreId: service-role caller userId not authorized for store");
159
+ return null;
160
+ }
161
+ if (!user?.id)
162
+ return null;
163
+ // Resolve user's actual stores from the `users` table (auth_user_id = auth.uid())
164
+ const { data: userStores, error } = await supabase
165
+ .from("users")
166
+ .select("store_id")
167
+ .eq("auth_user_id", user.id)
168
+ .not("store_id", "is", null);
169
+ if (error || !userStores?.length) {
170
+ log.warn({ userId: user.id, error }, "resolveStoreId: user has no stores");
171
+ return null;
172
+ }
173
+ const storeIds = userStores.map((r) => r.store_id);
174
+ // If client provided a hint, validate it's in the user's set
175
+ if (clientStoreId) {
176
+ if (storeIds.includes(clientStoreId)) {
177
+ return clientStoreId;
178
+ }
179
+ log.warn({ userId: user.id, clientStoreId, userStores: storeIds }, "resolveStoreId: client storeId not in user's stores");
180
+ return null; // Reject — don't silently fall back
181
+ }
182
+ // No client hint — use first store (single-store users), or require explicit selection
183
+ if (storeIds.length === 1) {
184
+ return storeIds[0];
185
+ }
186
+ // Multi-store user without a hint — can't guess
187
+ log.warn({ userId: user.id, storeCount: storeIds.length }, "resolveStoreId: multi-store user must specify storeId");
188
+ return null;
189
+ }
190
+ // ============================================================================
117
191
  // CORS
118
192
  // ============================================================================
119
193
  function getCorsHeaders(origin) {
@@ -299,7 +373,7 @@ async function setupPgListen() {
299
373
  // ============================================================================
300
374
  function getAnthropicClient(agent) {
301
375
  const key = agent.api_key || ANTHROPIC_API_KEY;
302
- return new Anthropic({ apiKey: key, timeout: 5 * 60 * 1000 }); // 5 min for tool-heavy requests
376
+ return new Anthropic({ apiKey: key, timeout: 15 * 60 * 1000 }); // 15 min for tool-heavy requests
303
377
  }
304
378
  function sendSSE(res, event) {
305
379
  try {
@@ -313,9 +387,85 @@ function jsonResponse(res, status, data, corsHeaders) {
313
387
  res.writeHead(status, { "Content-Type": "application/json", ...corsHeaders });
314
388
  res.end(JSON.stringify(data));
315
389
  }
390
+ /**
391
+ * Strip large base64 data fields from a JSON string up to `keepFrom` offset.
392
+ * Uses a linear indexOf scan — safe on strings of any size (no regex stack overflow).
393
+ * Handles both `"data":"<base64>"` (Anthropic image blocks) and
394
+ * `"__IMAGE__<mime>__<base64>"` text values (Read tool marker format).
395
+ */
396
+ function stripLargeBase64Fields(raw, keepFrom) {
397
+ const MIN_DATA_LEN = 8_000;
398
+ // ── Pass 1: Strip "data":"<large base64>" fields (Anthropic image source blocks) ──
399
+ let result = raw;
400
+ {
401
+ const DATA_MARKER = '"data":"';
402
+ const parts = [];
403
+ let pos = 0;
404
+ while (pos < keepFrom) {
405
+ const idx = result.indexOf(DATA_MARKER, pos);
406
+ if (idx === -1 || idx >= keepFrom) {
407
+ parts.push(result.slice(pos, keepFrom));
408
+ pos = keepFrom;
409
+ break;
410
+ }
411
+ parts.push(result.slice(pos, idx + DATA_MARKER.length));
412
+ pos = idx + DATA_MARKER.length;
413
+ // Find closing quote (base64 has no backslashes, so simple scan is safe)
414
+ let end = pos;
415
+ while (end < result.length && result[end] !== '"')
416
+ end++;
417
+ if (end - pos >= MIN_DATA_LEN && end <= keepFrom) {
418
+ // Large data field in the prune zone — replace with empty
419
+ parts.push('"');
420
+ pos = end + 1; // skip past the original closing quote (already replaced)
421
+ }
422
+ // else: small field or near keepFrom boundary — leave intact
423
+ }
424
+ parts.push(result.slice(keepFrom)); // always keep tail intact
425
+ result = parts.join("");
426
+ }
427
+ // ── Pass 2: Strip __IMAGE__<mime>__<large base64> text markers ──
428
+ // If the client-side tool-dispatch regex failed to convert these to image
429
+ // content blocks, they stay as huge text strings in tool_result content.
430
+ // They appear in JSON as: "__IMAGE__image/png__iVBOR...very long..."
431
+ {
432
+ const IMG_MARKER = "__IMAGE__";
433
+ const adjustedKeepFrom = Math.min(keepFrom, result.length);
434
+ const parts = [];
435
+ let pos = 0;
436
+ while (pos < adjustedKeepFrom) {
437
+ const idx = result.indexOf(IMG_MARKER, pos);
438
+ if (idx === -1 || idx >= adjustedKeepFrom) {
439
+ parts.push(result.slice(pos, adjustedKeepFrom));
440
+ pos = adjustedKeepFrom;
441
+ break;
442
+ }
443
+ parts.push(result.slice(pos, idx));
444
+ // Find the end of this text value (closing quote of the JSON string)
445
+ let end = idx;
446
+ while (end < result.length && result[end] !== '"')
447
+ end++;
448
+ if (end - idx >= MIN_DATA_LEN && end <= adjustedKeepFrom) {
449
+ // Large __IMAGE__ marker in the prune zone — replace with placeholder
450
+ parts.push("[image pruned]");
451
+ pos = end; // land on closing quote; next iteration emits it
452
+ }
453
+ else {
454
+ // Small or near boundary — keep intact
455
+ parts.push(result.slice(idx, idx + IMG_MARKER.length));
456
+ pos = idx + IMG_MARKER.length;
457
+ }
458
+ }
459
+ parts.push(result.slice(adjustedKeepFrom));
460
+ result = parts.join("");
461
+ }
462
+ return result;
463
+ }
316
464
  async function readBody(req) {
317
- // 50MB limit — proxy requests include full conversation history with base64 images
318
- const MAX_BODY = 52_428_800;
465
+ // 500MB hard limit — conversation history with many large images can be very large.
466
+ // For bodies over 10MB we prune old base64 image data from the raw string before
467
+ // JSON.parse, so subsequent requests in the same conversation stay bounded.
468
+ const MAX_BODY = 524_288_000; // 500MB
319
469
  return new Promise((resolve, reject) => {
320
470
  const chunks = [];
321
471
  let size = 0;
@@ -324,16 +474,36 @@ async function readBody(req) {
324
474
  size += chunk.length;
325
475
  if (size > MAX_BODY && !rejected) {
326
476
  rejected = true;
327
- // Drain remaining data instead of destroying socket (avoids Fly proxy 502)
328
- req.resume();
329
- reject(new Error("Request body too large (max 50MB)"));
477
+ req.resume(); // drain to avoid Fly proxy 502
478
+ reject(new Error("Request body too large (max 500MB)"));
330
479
  return;
331
480
  }
332
481
  if (!rejected)
333
482
  chunks.push(chunk);
334
483
  });
335
- req.on("end", () => { if (!rejected)
336
- resolve(Buffer.concat(chunks).toString("utf8")); });
484
+ req.on("end", () => {
485
+ if (rejected)
486
+ return;
487
+ // Detect body truncation: if Content-Length was sent and we received less
488
+ const declaredLen = parseInt(req.headers["content-length"] || "0", 10);
489
+ if (declaredLen > 0 && size < declaredLen) {
490
+ log.error({ declaredLen, receivedLen: size }, "Request body truncated during transmission");
491
+ reject(new Error(`Body truncated: expected ${declaredLen} bytes, got ${size}`));
492
+ return;
493
+ }
494
+ let raw = Buffer.concat(chunks).toString("utf8");
495
+ // Strip old base64 data from history when body is large.
496
+ // Threshold lowered to 1MB — image-heavy conversations (e.g. reading
497
+ // product photos) easily exceed 10MB with just a few images.
498
+ // Keep the last 1MB intact (current user message with fresh images).
499
+ // Uses a linear scan to avoid String.replace() stack overflow on huge strings.
500
+ if (raw.length > 1_000_000) {
501
+ const keepFrom = Math.max(0, raw.length - 1_048_576); // keep last 1MB
502
+ raw = stripLargeBase64Fields(raw, keepFrom);
503
+ log.info({ originalBytes: size, prunedBytes: Buffer.byteLength(raw) }, "Pruned old base64 data from request body");
504
+ }
505
+ resolve(raw);
506
+ });
337
507
  req.on("error", reject);
338
508
  });
339
509
  }
@@ -517,9 +687,9 @@ async function persistAgentTurn(supabase, agent, opts) {
517
687
  catch (err) {
518
688
  log.error({ err: err.message }, "conversation update failed");
519
689
  }
520
- // ── Audit log: user message ──
690
+ // ── Telemetry: user message → ClickHouse ──
521
691
  try {
522
- await supabase.from("audit_logs").insert({
692
+ queueSpan(auditRowToSpan({
523
693
  action: "chat.user_message",
524
694
  severity: "info",
525
695
  store_id: storeId || null,
@@ -530,6 +700,13 @@ async function persistAgentTurn(supabase, agent, opts) {
530
700
  user_id: userId || null,
531
701
  user_email: userEmail || null,
532
702
  source,
703
+ service_name: "agent-server",
704
+ span_kind: "INTERNAL",
705
+ status_code: "OK",
706
+ start_time: new Date().toISOString(),
707
+ end_time: new Date().toISOString(),
708
+ duration_ms: 0,
709
+ input_bytes: typeof message === "string" ? message.length : 0,
533
710
  details: {
534
711
  message_preview: message.substring(0, 200),
535
712
  agent_id: agentId,
@@ -541,17 +718,14 @@ async function persistAgentTurn(supabase, agent, opts) {
541
718
  customer_id: senderContext.customerId || null,
542
719
  } : {}),
543
720
  },
544
- });
721
+ }));
545
722
  }
546
723
  catch (err) {
547
724
  log.error({ err: err.message }, "audit user_message failed");
548
725
  }
549
- // ── Audit log: assistant response (OTEL-enriched) ──
726
+ // ── Telemetry: assistant response ClickHouse ──
550
727
  try {
551
- const spanBytes = new Uint8Array(8);
552
- crypto.getRandomValues(spanBytes);
553
- const spanId = Array.from(spanBytes).map(b => b.toString(16).padStart(2, "0")).join("");
554
- await supabase.from("audit_logs").insert({
728
+ queueSpan(auditRowToSpan({
555
729
  action: "chat.assistant_response",
556
730
  severity: "info",
557
731
  store_id: storeId || null,
@@ -568,12 +742,13 @@ async function persistAgentTurn(supabase, agent, opts) {
568
742
  total_cost: result.costUsd,
569
743
  model: agentModel,
570
744
  trace_id: traceId,
571
- span_id: spanId,
572
745
  span_kind: "INTERNAL",
573
746
  service_name: "agent-server",
574
747
  status_code: "OK",
575
748
  start_time: new Date(chatStartTime).toISOString(),
576
749
  end_time: new Date(chatEndTime).toISOString(),
750
+ stop_reason: result.stopReason || undefined,
751
+ turn_number: result.turnCount || 1,
577
752
  details: {
578
753
  response_preview: (result.finalText || "").substring(0, 500),
579
754
  agent_id: agentId,
@@ -591,7 +766,6 @@ async function persistAgentTurn(supabase, agent, opts) {
591
766
  session_cost_usd: result.costUsd,
592
767
  cache_creation_tokens: result.tokens.cacheCreation || 0,
593
768
  cache_read_tokens: result.tokens.cacheRead || 0,
594
- // Cache efficiency metrics
595
769
  cache_hit_rate: result.tokens.input > 0
596
770
  ? Math.round((result.tokens.cacheRead || 0) / ((result.tokens.cacheRead || 0) + result.tokens.input) * 10000) / 100
597
771
  : 0,
@@ -599,9 +773,7 @@ async function persistAgentTurn(supabase, agent, opts) {
599
773
  ? Math.round((result.tokens.cacheRead || 0) * 0.9 / ((result.tokens.cacheRead || 0) + result.tokens.input) * 10000) / 100
600
774
  : 0,
601
775
  loop_detector_stats: result.loopDetectorStats || null,
602
- // Per-turn token breakdowns for cost attribution
603
776
  turns: result.turns || [],
604
- // Channel-specific telemetry — fully dynamic
605
777
  ...(senderContext ? {
606
778
  channel_type: senderContext.channelType || null,
607
779
  channel_id: senderContext.channelId || null,
@@ -611,7 +783,7 @@ async function persistAgentTurn(supabase, agent, opts) {
611
783
  customer_name: senderContext.customerName || null,
612
784
  } : {}),
613
785
  },
614
- });
786
+ }));
615
787
  }
616
788
  catch (err) {
617
789
  log.error({ err: err.message }, "audit assistant_response failed");
@@ -629,12 +801,17 @@ async function persistAgentTurn(supabase, agent, opts) {
629
801
  }
630
802
  catch (err2) {
631
803
  log.error({ err: err2.message }, "memory extract failed after retry");
632
- await supabase.from("audit_logs").insert({
804
+ queueSpan(auditRowToSpan({
633
805
  action: "memory.extraction_failed", severity: "warning",
634
806
  store_id: storeId || null, resource_type: "agent_memory",
635
807
  resource_id: agentId, conversation_id: conversationId,
808
+ user_id: userId || null, user_email: userEmail || null,
809
+ error_type: classifyErrorType(err2.message) || undefined,
810
+ service_name: "agent-server", span_kind: "INTERNAL", status_code: "ERROR",
811
+ start_time: new Date().toISOString(), end_time: new Date().toISOString(),
812
+ error_message: err2.message,
636
813
  details: { error: err2.message, user_message_preview: message.substring(0, 100) },
637
- }).then(() => { });
814
+ }));
638
815
  }
639
816
  }
640
817
  }
@@ -672,51 +849,29 @@ async function handleAgentChat(req, res, supabase, body, user, isServiceRole, to
672
849
  jsonResponse(res, 400, { error: "Message too long (max 100K characters)" }, corsHeaders);
673
850
  return;
674
851
  }
675
- // Fallback: resolve user's store when storeId not provided in request
676
- if (!storeId && user?.id && !isServiceRole) {
677
- try {
678
- const { data: userStores } = await supabase
679
- .from("user_stores")
680
- .select("store_id")
681
- .eq("user_id", user.id)
682
- .limit(1);
683
- if (userStores?.length) {
684
- storeId = userStores[0].store_id;
685
- log.info({ userId: user.id, storeId }, "resolved user store");
686
- }
687
- }
688
- catch (err) {
689
- log.error({ err }, "store resolution error");
690
- }
852
+ // Server-derived store resolution never trust client blindly
853
+ const resolvedStoreId = await resolveAndValidateStoreId(supabase, storeId, user, isServiceRole, token, body.userId);
854
+ // For service-role callers (workflows, internal), also try body.userId fallback
855
+ if (!resolvedStoreId && isServiceRole && body.userId) {
856
+ const { data: srStores } = await supabase
857
+ .from("users")
858
+ .select("store_id")
859
+ .eq("auth_user_id", body.userId)
860
+ .not("store_id", "is", null)
861
+ .limit(1);
862
+ if (srStores?.length) {
863
+ storeId = srStores[0].store_id;
864
+ }
865
+ }
866
+ else {
867
+ storeId = resolvedStoreId || undefined;
868
+ }
869
+ // Fail closed: non-service-role requests MUST have a resolved store
870
+ if (!storeId && !isServiceRole) {
871
+ jsonResponse(res, 400, { error: "storeId required for agent chat" }, corsHeaders);
872
+ return;
691
873
  }
692
874
  log.info({ storeId: storeId || "NONE", source: body.source || "unknown", isServiceRole, userId: user?.id || body.userId || "NONE" }, "agent-chat request");
693
- // Fallback: resolve store from body.userId for service-role requests (e.g. WhaleChat app)
694
- if (!storeId && !user?.id && body.userId && isServiceRole) {
695
- try {
696
- const { data: userStores } = await supabase
697
- .from("user_stores")
698
- .select("store_id")
699
- .eq("user_id", body.userId)
700
- .limit(1);
701
- if (userStores?.length) {
702
- storeId = userStores[0].store_id;
703
- log.info({ userId: body.userId, storeId }, "resolved userId store");
704
- }
705
- }
706
- catch (err) {
707
- log.error({ err }, "store resolution error");
708
- }
709
- }
710
- // Verify store access (skip for service_role)
711
- if (storeId && !isServiceRole) {
712
- const userClient = createUserClient(SUPABASE_URL, process.env.SUPABASE_ANON_KEY || "", token);
713
- const { data: storeAccess, error: storeErr } = await userClient
714
- .from("stores").select("id").eq("id", storeId).limit(1);
715
- if (storeErr || !storeAccess?.length) {
716
- jsonResponse(res, 403, { error: "Access denied to store" }, corsHeaders);
717
- return;
718
- }
719
- }
720
875
  // Agent chat rate limiting — per-store + concurrent cap
721
876
  const rateLimitStoreId = storeId || agentId; // fallback to agentId if no store
722
877
  const rateCheck = checkAgentChatRateLimit(rateLimitStoreId);
@@ -729,7 +884,7 @@ async function handleAgentChat(req, res, supabase, body, user, isServiceRole, to
729
884
  try {
730
885
  const userId = user?.id || body.userId || "";
731
886
  const userEmail = user?.email || body.userEmail || null;
732
- const agent = await loadAgentConfig(supabase, agentId, storeId || undefined);
887
+ const agent = await loadAgentConfig(supabase, agentId, storeId);
733
888
  if (!agent) {
734
889
  jsonResponse(res, 404, { error: "Agent not found" }, corsHeaders);
735
890
  return;
@@ -823,7 +978,7 @@ async function handleAgentChat(req, res, supabase, body, user, isServiceRole, to
823
978
  // Client disconnect detection
824
979
  let clientDisconnected = false;
825
980
  req.on("close", () => { clientDisconnected = true; });
826
- const maxDurationMs = 5 * 60 * 1000;
981
+ const maxDurationMs = 15 * 60 * 1000;
827
982
  const startedAt = Date.now();
828
983
  const chatStartTime = Date.now();
829
984
  try {
@@ -974,7 +1129,7 @@ const server = http.createServer(async (req, res) => {
974
1129
  const agentStats = getGatewayStats();
975
1130
  jsonResponse(res, status, {
976
1131
  status: ready ? "ok" : "starting",
977
- version: process.env.npm_package_version || "6.0.0",
1132
+ version: process.env.npm_package_version || "6.4.0",
978
1133
  uptime: Math.floor(process.uptime()),
979
1134
  pg_listen: pgListenReady,
980
1135
  worker_pool: workerPoolReady,
@@ -1899,7 +2054,7 @@ const server = http.createServer(async (req, res) => {
1899
2054
  rawBody = await readBody(req);
1900
2055
  }
1901
2056
  catch {
1902
- jsonResponse(res, 413, { error: "Request body too large (max 50MB)" }, corsHeaders);
2057
+ jsonResponse(res, 413, { error: "Request body too large (max 500MB)" }, corsHeaders);
1903
2058
  return;
1904
2059
  }
1905
2060
  const token = authHeader.substring(7);
@@ -1961,8 +2116,16 @@ const server = http.createServer(async (req, res) => {
1961
2116
  try {
1962
2117
  body = JSON.parse(rawBody);
1963
2118
  }
1964
- catch {
1965
- jsonResponse(res, 400, { error: "Invalid JSON in request body" }, corsHeaders);
2119
+ catch (parseErr) {
2120
+ const errMsg = parseErr instanceof Error ? parseErr.message : String(parseErr);
2121
+ const bodyLen = rawBody.length;
2122
+ const tail = bodyLen > 200 ? rawBody.slice(bodyLen - 200) : rawBody;
2123
+ log.error({ bodyLen, errMsg, tail }, "JSON parse failed on request body");
2124
+ jsonResponse(res, 400, {
2125
+ error: "Invalid JSON in request body",
2126
+ detail: errMsg,
2127
+ body_length: bodyLen,
2128
+ }, corsHeaders);
1966
2129
  return;
1967
2130
  }
1968
2131
  // Anthropic API proxy mode
@@ -2015,6 +2178,13 @@ const server = http.createServer(async (req, res) => {
2015
2178
  jsonResponse(res, 400, { error: "tool_name required" }, corsHeaders);
2016
2179
  return;
2017
2180
  }
2181
+ // Resolve and validate store_id (server-derived, not blindly trusted)
2182
+ const resolvedToolStoreId = await resolveAndValidateStoreId(supabase, store_id || body.storeId, user, isServiceRole, token, body.userId);
2183
+ if (!resolvedToolStoreId && !isServiceRole) {
2184
+ jsonResponse(res, 400, { error: "store_id required for tool execution" }, corsHeaders);
2185
+ return;
2186
+ }
2187
+ const validToolStoreId = resolvedToolStoreId || store_id;
2018
2188
  // Phase 7.2: Per-tool rate limiting
2019
2189
  const toolUserId = user?.id || body.userId || "anon";
2020
2190
  const toolLimit = rateLimiter.checkToolLimit(toolUserId, tool_name);
@@ -2031,11 +2201,11 @@ const server = http.createServer(async (req, res) => {
2031
2201
  }
2032
2202
  // Load user tools if this is a user_tool__ prefixed call
2033
2203
  let utRows;
2034
- if (tool_name.startsWith("user_tool__") && store_id) {
2035
- const { rows } = await loadUserTools(supabase, store_id);
2204
+ if (tool_name.startsWith("user_tool__") && validToolStoreId) {
2205
+ const { rows } = await loadUserTools(supabase, validToolStoreId);
2036
2206
  utRows = rows;
2037
2207
  }
2038
- const result = await executeTool(supabase, tool_name, (args || {}), store_id || undefined, trace_id || undefined, user?.id || body.userId || null, user?.email || body.userEmail || null, body.source || "whale-code", conversation_id || undefined, utRows);
2208
+ const result = await executeTool(supabase, tool_name, (args || {}), validToolStoreId || undefined, trace_id || undefined, user?.id || body.userId || null, user?.email || body.userEmail || null, body.source || "whale-code", conversation_id || undefined, utRows);
2039
2209
  // Always 200 for tool results — success/failure is in the JSON body.
2040
2210
  // HTTP 500 causes MCP clients to throw before reading the error message.
2041
2211
  jsonResponse(res, 200, result, corsHeaders);
@@ -2048,6 +2218,13 @@ const server = http.createServer(async (req, res) => {
2048
2218
  jsonResponse(res, 400, { error: "tool_name required" }, corsHeaders);
2049
2219
  return;
2050
2220
  }
2221
+ // Resolve and validate store_id
2222
+ const resolvedStreamStoreId = await resolveAndValidateStoreId(supabase, store_id || body.storeId, user, isServiceRole, token, body.userId);
2223
+ if (!resolvedStreamStoreId && !isServiceRole) {
2224
+ jsonResponse(res, 400, { error: "store_id required for tool execution" }, corsHeaders);
2225
+ return;
2226
+ }
2227
+ const validStreamStoreId = resolvedStreamStoreId || store_id;
2051
2228
  // Phase 7.2: Per-tool rate limiting (stream mode)
2052
2229
  const streamToolUserId = user?.id || body.userId || "anon";
2053
2230
  const streamToolLimit = rateLimiter.checkToolLimit(streamToolUserId, tool_name);
@@ -2069,12 +2246,19 @@ const server = http.createServer(async (req, res) => {
2069
2246
  });
2070
2247
  const onToolProgress = (_name, progress) => {
2071
2248
  try {
2072
- res.write(JSON.stringify({ type: "progress", progress }) + "\n");
2249
+ // Structured status events from kali — relay as type "status" for CLI ToolProgress handling
2250
+ const p = progress;
2251
+ if (p && p.type === "status" && p.progress) {
2252
+ res.write(JSON.stringify({ type: "status", progress: p.progress }) + "\n");
2253
+ }
2254
+ else {
2255
+ res.write(JSON.stringify({ type: "progress", progress }) + "\n");
2256
+ }
2073
2257
  }
2074
2258
  catch { /* client disconnected */ }
2075
2259
  };
2076
2260
  try {
2077
- const result = await executeTool(supabase, tool_name, (args || {}), store_id || undefined, body.trace_id || undefined, user?.id || body.userId || null, user?.email || body.userEmail || null, body.source || "whale-code-stream", body.conversation_id || undefined, undefined, undefined, onToolProgress);
2261
+ const result = await executeTool(supabase, tool_name, (args || {}), validStreamStoreId || undefined, body.trace_id || undefined, user?.id || body.userId || null, user?.email || body.userEmail || null, body.source || "whale-code-stream", body.conversation_id || undefined, undefined, undefined, onToolProgress);
2078
2262
  res.write(JSON.stringify({ type: "result", ...result }) + "\n");
2079
2263
  }
2080
2264
  catch (err) {
@@ -2083,6 +2267,55 @@ const server = http.createServer(async (req, res) => {
2083
2267
  res.end();
2084
2268
  return;
2085
2269
  }
2270
+ // CLI telemetry ingest — batch of spans from Whale Code CLI
2271
+ if (body.mode === "telemetry_ingest") {
2272
+ const spans = body.spans;
2273
+ if (!Array.isArray(spans) || spans.length === 0) {
2274
+ jsonResponse(res, 400, { error: "spans array required" }, corsHeaders);
2275
+ return;
2276
+ }
2277
+ // Cap at 500 spans per request to prevent abuse
2278
+ const batch = spans.slice(0, 500);
2279
+ let queued = 0;
2280
+ for (const raw of batch) {
2281
+ try {
2282
+ // Enforce store_id from auth context, not client-provided
2283
+ const resolvedStoreId = await resolveAndValidateStoreId(supabase, raw.store_id || body.store_id, user, isServiceRole, token, body.userId);
2284
+ raw.store_id = resolvedStoreId || raw.store_id || null;
2285
+ raw.user_id = raw.user_id || user?.id || body.userId || null;
2286
+ raw.user_email = raw.user_email || user?.email || body.userEmail || null;
2287
+ queueSpan(auditRowToSpan(raw));
2288
+ queued++;
2289
+ }
2290
+ catch {
2291
+ // Skip malformed spans
2292
+ }
2293
+ }
2294
+ // Also upsert ai_conversations row if conversation_id provided
2295
+ if (body.conversation_id && body.store_id) {
2296
+ try {
2297
+ const convStoreId = await resolveAndValidateStoreId(supabase, body.store_id, user, isServiceRole, token, body.userId);
2298
+ if (convStoreId) {
2299
+ await supabase.from("ai_conversations").upsert({
2300
+ id: body.conversation_id,
2301
+ store_id: convStoreId,
2302
+ user_id: user?.id || body.userId || null,
2303
+ title: body.conversation_title || "CLI Session",
2304
+ metadata: {
2305
+ source: body.source || "whale_cli",
2306
+ hostname: body.hostname,
2307
+ version: body.version,
2308
+ },
2309
+ }, { onConflict: "id" });
2310
+ }
2311
+ }
2312
+ catch {
2313
+ // Non-critical — spans still ingested
2314
+ }
2315
+ }
2316
+ jsonResponse(res, 200, { success: true, queued }, corsHeaders);
2317
+ return;
2318
+ }
2086
2319
  // Agent chat mode (SSE)
2087
2320
  await handleAgentChat(req, res, supabase, body, user, isServiceRole, token, corsHeaders);
2088
2321
  }
@@ -2255,6 +2488,19 @@ async function invokeAgentForChannel(supabase, agentId, message, storeId, conver
2255
2488
  setNodeAgentInvoker(invokeAgentForChannel);
2256
2489
  webchatAgentInvoker = invokeAgentForChannel;
2257
2490
  // ============================================================================
2491
+ // NODE OFFLINE DETECTION
2492
+ // ============================================================================
2493
+ async function enforceNodeOfflineStatus(supabase) {
2494
+ const threshold = new Date(Date.now() - 3 * 60_000).toISOString(); // 3 missed heartbeats (60s interval)
2495
+ const { data } = await supabase
2496
+ .from("nodes")
2497
+ .update({ status: "offline" })
2498
+ .eq("status", "online")
2499
+ .lt("last_heartbeat", threshold)
2500
+ .select("id");
2501
+ return data?.length || 0;
2502
+ }
2503
+ // ============================================================================
2258
2504
  // PERSISTENT WORKFLOW WORKER LOOP (5-second interval)
2259
2505
  // ============================================================================
2260
2506
  // Phase 3.1: Increased from 5s to 15s — NOTIFY-driven execution handles the fast path
@@ -2275,16 +2521,17 @@ async function workflowWorkerLoop() {
2275
2521
  processWaitingSteps(supabase),
2276
2522
  Promise.resolve(supabase.rpc("expire_pending_waitpoints")).then(() => { }).catch(e => log.warn({ err: e.message }, "expire_pending_waitpoints failed")), // Non-fatal
2277
2523
  ]);
2278
- // Schedule triggers + timeout enforcement + event triggers + orphan cleanup + DLQ retries
2279
- const [scheduled, timedOut, eventsProcessed, orphansCleaned, dlqRetried] = await Promise.all([
2524
+ // Schedule triggers + timeout enforcement + event triggers + orphan cleanup + DLQ retries + node offline detection
2525
+ const [scheduled, timedOut, eventsProcessed, orphansCleaned, dlqRetried, nodesOfflined] = await Promise.all([
2280
2526
  processScheduleTriggers(supabase).catch(e => { log.warn({ err: e.message }, "processScheduleTriggers failed"); return 0; }),
2281
2527
  enforceWorkflowTimeouts(supabase).catch(e => { log.warn({ err: e.message }, "enforceWorkflowTimeouts failed"); return 0; }),
2282
2528
  processEventTriggers(supabase).catch(e => { log.warn({ err: e.message }, "processEventTriggers failed"); return 0; }),
2283
2529
  cleanupOrphanedSteps(supabase).catch(e => { log.warn({ err: e.message }, "cleanupOrphanedSteps failed"); return 0; }),
2284
2530
  processDlqRetries(supabase).catch(e => { log.warn({ err: e.message }, "processDlqRetries failed"); return 0; }),
2531
+ enforceNodeOfflineStatus(supabase).catch(e => { log.warn({ err: e.message }, "enforceNodeOfflineStatus failed"); return 0; }),
2285
2532
  ]);
2286
- if (stepResult.processed > 0 || waitingResolved > 0 || scheduled > 0 || timedOut > 0 || eventsProcessed > 0 || stepResult.reclaimed > 0 || orphansCleaned > 0 || dlqRetried > 0) {
2287
- log.info({ processed: stepResult.processed, errors: stepResult.errors, reclaimed: stepResult.reclaimed || 0, waiting: waitingResolved, scheduled, timedOut, events: eventsProcessed, orphans: orphansCleaned, dlqRetries: dlqRetried }, "worker tick");
2533
+ if (stepResult.processed > 0 || waitingResolved > 0 || scheduled > 0 || timedOut > 0 || eventsProcessed > 0 || stepResult.reclaimed > 0 || orphansCleaned > 0 || dlqRetried > 0 || nodesOfflined > 0) {
2534
+ log.info({ processed: stepResult.processed, errors: stepResult.errors, reclaimed: stepResult.reclaimed || 0, waiting: waitingResolved, scheduled, timedOut, events: eventsProcessed, orphans: orphansCleaned, dlqRetries: dlqRetried, nodesOfflined }, "worker tick");
2288
2535
  }
2289
2536
  // Reset backoff on success
2290
2537
  if (consecutiveErrors > 0) {
@@ -2375,14 +2622,13 @@ async function gracefulShutdown(signal) {
2375
2622
  clients.clear();
2376
2623
  }
2377
2624
  sseClients.clear();
2378
- // 3b. P1 FIX: Flush audit log buffer before shutdown (prevents data loss on crash)
2625
+ // 3b. Flush ClickHouse span buffer before shutdown (prevents data loss on crash)
2379
2626
  try {
2380
- const sb = getServiceClient();
2381
- await flushAuditLogs(sb);
2382
- log.info("audit log buffer flushed");
2627
+ await flushSpans();
2628
+ log.info("span buffer flushed");
2383
2629
  }
2384
2630
  catch (err) {
2385
- log.error({ err: err.message }, "audit log flush error");
2631
+ log.error({ err: err.message }, "span buffer flush error");
2386
2632
  }
2387
2633
  // 4. Shut down code worker pool
2388
2634
  try {