whale-code 6.4.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 (319) hide show
  1. package/README.md +95 -0
  2. package/bin/swag-agent.js +9 -0
  3. package/bin/swagmanager-mcp.js +321 -0
  4. package/dist/cli/app.d.ts +26 -0
  5. package/dist/cli/app.js +64 -0
  6. package/dist/cli/chat/AgentSelector.d.ts +14 -0
  7. package/dist/cli/chat/AgentSelector.js +14 -0
  8. package/dist/cli/chat/ChatApp.d.ts +9 -0
  9. package/dist/cli/chat/ChatApp.js +267 -0
  10. package/dist/cli/chat/ChatInput.d.ts +39 -0
  11. package/dist/cli/chat/ChatInput.js +509 -0
  12. package/dist/cli/chat/MarkdownText.d.ts +10 -0
  13. package/dist/cli/chat/MarkdownText.js +20 -0
  14. package/dist/cli/chat/MessageList.d.ts +37 -0
  15. package/dist/cli/chat/MessageList.js +80 -0
  16. package/dist/cli/chat/ModelSelector.d.ts +20 -0
  17. package/dist/cli/chat/ModelSelector.js +73 -0
  18. package/dist/cli/chat/RewindViewer.d.ts +26 -0
  19. package/dist/cli/chat/RewindViewer.js +185 -0
  20. package/dist/cli/chat/StoreSelector.d.ts +14 -0
  21. package/dist/cli/chat/StoreSelector.js +24 -0
  22. package/dist/cli/chat/StreamingText.d.ts +12 -0
  23. package/dist/cli/chat/StreamingText.js +12 -0
  24. package/dist/cli/chat/SubagentPanel.d.ts +45 -0
  25. package/dist/cli/chat/SubagentPanel.js +110 -0
  26. package/dist/cli/chat/TeamPanel.d.ts +21 -0
  27. package/dist/cli/chat/TeamPanel.js +42 -0
  28. package/dist/cli/chat/ToolIndicator.d.ts +25 -0
  29. package/dist/cli/chat/ToolIndicator.js +436 -0
  30. package/dist/cli/chat/hooks/useAgentLoop.d.ts +39 -0
  31. package/dist/cli/chat/hooks/useAgentLoop.js +382 -0
  32. package/dist/cli/chat/hooks/useSlashCommands.d.ts +37 -0
  33. package/dist/cli/chat/hooks/useSlashCommands.js +387 -0
  34. package/dist/cli/commands/config-cmd.d.ts +10 -0
  35. package/dist/cli/commands/config-cmd.js +99 -0
  36. package/dist/cli/commands/doctor.d.ts +14 -0
  37. package/dist/cli/commands/doctor.js +172 -0
  38. package/dist/cli/commands/init.d.ts +16 -0
  39. package/dist/cli/commands/init.js +278 -0
  40. package/dist/cli/commands/mcp.d.ts +12 -0
  41. package/dist/cli/commands/mcp.js +162 -0
  42. package/dist/cli/login/LoginApp.d.ts +7 -0
  43. package/dist/cli/login/LoginApp.js +157 -0
  44. package/dist/cli/print-mode.d.ts +31 -0
  45. package/dist/cli/print-mode.js +202 -0
  46. package/dist/cli/serve-mode.d.ts +37 -0
  47. package/dist/cli/serve-mode.js +636 -0
  48. package/dist/cli/services/agent-definitions.d.ts +25 -0
  49. package/dist/cli/services/agent-definitions.js +91 -0
  50. package/dist/cli/services/agent-events.d.ts +178 -0
  51. package/dist/cli/services/agent-events.js +175 -0
  52. package/dist/cli/services/agent-loop.d.ts +90 -0
  53. package/dist/cli/services/agent-loop.js +762 -0
  54. package/dist/cli/services/agent-worker-base.d.ts +97 -0
  55. package/dist/cli/services/agent-worker-base.js +220 -0
  56. package/dist/cli/services/auth-service.d.ts +30 -0
  57. package/dist/cli/services/auth-service.js +160 -0
  58. package/dist/cli/services/background-processes.d.ts +126 -0
  59. package/dist/cli/services/background-processes.js +318 -0
  60. package/dist/cli/services/browser-auth.d.ts +24 -0
  61. package/dist/cli/services/browser-auth.js +180 -0
  62. package/dist/cli/services/claude-md-loader.d.ts +16 -0
  63. package/dist/cli/services/claude-md-loader.js +58 -0
  64. package/dist/cli/services/config-store.d.ts +47 -0
  65. package/dist/cli/services/config-store.js +79 -0
  66. package/dist/cli/services/debug-log.d.ts +10 -0
  67. package/dist/cli/services/debug-log.js +52 -0
  68. package/dist/cli/services/error-logger.d.ts +58 -0
  69. package/dist/cli/services/error-logger.js +269 -0
  70. package/dist/cli/services/file-history.d.ts +21 -0
  71. package/dist/cli/services/file-history.js +83 -0
  72. package/dist/cli/services/format-server-response.d.ts +16 -0
  73. package/dist/cli/services/format-server-response.js +440 -0
  74. package/dist/cli/services/git-context.d.ts +11 -0
  75. package/dist/cli/services/git-context.js +66 -0
  76. package/dist/cli/services/hooks.d.ts +85 -0
  77. package/dist/cli/services/hooks.js +258 -0
  78. package/dist/cli/services/interactive-tools.d.ts +125 -0
  79. package/dist/cli/services/interactive-tools.js +260 -0
  80. package/dist/cli/services/keybinding-manager.d.ts +52 -0
  81. package/dist/cli/services/keybinding-manager.js +115 -0
  82. package/dist/cli/services/local-tools.d.ts +22 -0
  83. package/dist/cli/services/local-tools.js +697 -0
  84. package/dist/cli/services/lsp-manager.d.ts +18 -0
  85. package/dist/cli/services/lsp-manager.js +717 -0
  86. package/dist/cli/services/mcp-client.d.ts +48 -0
  87. package/dist/cli/services/mcp-client.js +157 -0
  88. package/dist/cli/services/memory-manager.d.ts +16 -0
  89. package/dist/cli/services/memory-manager.js +57 -0
  90. package/dist/cli/services/model-manager.d.ts +18 -0
  91. package/dist/cli/services/model-manager.js +71 -0
  92. package/dist/cli/services/model-router.d.ts +26 -0
  93. package/dist/cli/services/model-router.js +149 -0
  94. package/dist/cli/services/permission-modes.d.ts +13 -0
  95. package/dist/cli/services/permission-modes.js +43 -0
  96. package/dist/cli/services/rewind.d.ts +84 -0
  97. package/dist/cli/services/rewind.js +194 -0
  98. package/dist/cli/services/ripgrep.d.ts +28 -0
  99. package/dist/cli/services/ripgrep.js +138 -0
  100. package/dist/cli/services/sandbox.d.ts +29 -0
  101. package/dist/cli/services/sandbox.js +97 -0
  102. package/dist/cli/services/server-tools.d.ts +61 -0
  103. package/dist/cli/services/server-tools.js +543 -0
  104. package/dist/cli/services/session-persistence.d.ts +23 -0
  105. package/dist/cli/services/session-persistence.js +99 -0
  106. package/dist/cli/services/subagent-worker.d.ts +19 -0
  107. package/dist/cli/services/subagent-worker.js +41 -0
  108. package/dist/cli/services/subagent.d.ts +47 -0
  109. package/dist/cli/services/subagent.js +647 -0
  110. package/dist/cli/services/system-prompt.d.ts +7 -0
  111. package/dist/cli/services/system-prompt.js +198 -0
  112. package/dist/cli/services/team-lead.d.ts +73 -0
  113. package/dist/cli/services/team-lead.js +512 -0
  114. package/dist/cli/services/team-state.d.ts +77 -0
  115. package/dist/cli/services/team-state.js +398 -0
  116. package/dist/cli/services/teammate.d.ts +31 -0
  117. package/dist/cli/services/teammate.js +689 -0
  118. package/dist/cli/services/telemetry.d.ts +61 -0
  119. package/dist/cli/services/telemetry.js +209 -0
  120. package/dist/cli/services/tools/agent-tools.d.ts +14 -0
  121. package/dist/cli/services/tools/agent-tools.js +347 -0
  122. package/dist/cli/services/tools/file-ops.d.ts +15 -0
  123. package/dist/cli/services/tools/file-ops.js +487 -0
  124. package/dist/cli/services/tools/search-tools.d.ts +8 -0
  125. package/dist/cli/services/tools/search-tools.js +186 -0
  126. package/dist/cli/services/tools/shell-exec.d.ts +10 -0
  127. package/dist/cli/services/tools/shell-exec.js +168 -0
  128. package/dist/cli/services/tools/task-manager.d.ts +28 -0
  129. package/dist/cli/services/tools/task-manager.js +209 -0
  130. package/dist/cli/services/tools/web-tools.d.ts +11 -0
  131. package/dist/cli/services/tools/web-tools.js +395 -0
  132. package/dist/cli/setup/SetupApp.d.ts +9 -0
  133. package/dist/cli/setup/SetupApp.js +191 -0
  134. package/dist/cli/shared/MatrixIntro.d.ts +4 -0
  135. package/dist/cli/shared/MatrixIntro.js +83 -0
  136. package/dist/cli/shared/Theme.d.ts +74 -0
  137. package/dist/cli/shared/Theme.js +127 -0
  138. package/dist/cli/shared/WhaleBanner.d.ts +10 -0
  139. package/dist/cli/shared/WhaleBanner.js +12 -0
  140. package/dist/cli/shared/markdown.d.ts +21 -0
  141. package/dist/cli/shared/markdown.js +756 -0
  142. package/dist/cli/status/StatusApp.d.ts +4 -0
  143. package/dist/cli/status/StatusApp.js +105 -0
  144. package/dist/cli/stores/StoreApp.d.ts +7 -0
  145. package/dist/cli/stores/StoreApp.js +81 -0
  146. package/dist/index.d.ts +15 -0
  147. package/dist/index.js +538 -0
  148. package/dist/local-agent/connection.d.ts +48 -0
  149. package/dist/local-agent/connection.js +332 -0
  150. package/dist/local-agent/discovery.d.ts +18 -0
  151. package/dist/local-agent/discovery.js +146 -0
  152. package/dist/local-agent/executor.d.ts +34 -0
  153. package/dist/local-agent/executor.js +241 -0
  154. package/dist/local-agent/index.d.ts +14 -0
  155. package/dist/local-agent/index.js +198 -0
  156. package/dist/node/adapters/base.d.ts +35 -0
  157. package/dist/node/adapters/base.js +10 -0
  158. package/dist/node/adapters/discord.d.ts +29 -0
  159. package/dist/node/adapters/discord.js +299 -0
  160. package/dist/node/adapters/email.d.ts +23 -0
  161. package/dist/node/adapters/email.js +218 -0
  162. package/dist/node/adapters/imessage.d.ts +17 -0
  163. package/dist/node/adapters/imessage.js +118 -0
  164. package/dist/node/adapters/slack.d.ts +26 -0
  165. package/dist/node/adapters/slack.js +259 -0
  166. package/dist/node/adapters/sms.d.ts +23 -0
  167. package/dist/node/adapters/sms.js +161 -0
  168. package/dist/node/adapters/telegram.d.ts +17 -0
  169. package/dist/node/adapters/telegram.js +101 -0
  170. package/dist/node/adapters/webchat.d.ts +27 -0
  171. package/dist/node/adapters/webchat.js +160 -0
  172. package/dist/node/adapters/whatsapp.d.ts +28 -0
  173. package/dist/node/adapters/whatsapp.js +230 -0
  174. package/dist/node/cli.d.ts +2 -0
  175. package/dist/node/cli.js +325 -0
  176. package/dist/node/config.d.ts +17 -0
  177. package/dist/node/config.js +31 -0
  178. package/dist/node/runtime.d.ts +50 -0
  179. package/dist/node/runtime.js +351 -0
  180. package/dist/server/handlers/__test-utils__/mock-supabase.d.ts +11 -0
  181. package/dist/server/handlers/__test-utils__/mock-supabase.js +393 -0
  182. package/dist/server/handlers/analytics.d.ts +17 -0
  183. package/dist/server/handlers/analytics.js +266 -0
  184. package/dist/server/handlers/api-keys.d.ts +6 -0
  185. package/dist/server/handlers/api-keys.js +221 -0
  186. package/dist/server/handlers/billing.d.ts +33 -0
  187. package/dist/server/handlers/billing.js +272 -0
  188. package/dist/server/handlers/browser.d.ts +10 -0
  189. package/dist/server/handlers/browser.js +517 -0
  190. package/dist/server/handlers/catalog.d.ts +99 -0
  191. package/dist/server/handlers/catalog.js +976 -0
  192. package/dist/server/handlers/comms.d.ts +254 -0
  193. package/dist/server/handlers/comms.js +588 -0
  194. package/dist/server/handlers/creations.d.ts +6 -0
  195. package/dist/server/handlers/creations.js +479 -0
  196. package/dist/server/handlers/crm.d.ts +89 -0
  197. package/dist/server/handlers/crm.js +538 -0
  198. package/dist/server/handlers/discovery.d.ts +6 -0
  199. package/dist/server/handlers/discovery.js +288 -0
  200. package/dist/server/handlers/embeddings.d.ts +92 -0
  201. package/dist/server/handlers/embeddings.js +197 -0
  202. package/dist/server/handlers/enrichment.d.ts +8 -0
  203. package/dist/server/handlers/enrichment.js +768 -0
  204. package/dist/server/handlers/image-gen.d.ts +6 -0
  205. package/dist/server/handlers/image-gen.js +409 -0
  206. package/dist/server/handlers/inventory.d.ts +319 -0
  207. package/dist/server/handlers/inventory.js +447 -0
  208. package/dist/server/handlers/kali.d.ts +10 -0
  209. package/dist/server/handlers/kali.js +210 -0
  210. package/dist/server/handlers/llm-providers.d.ts +6 -0
  211. package/dist/server/handlers/llm-providers.js +673 -0
  212. package/dist/server/handlers/local-agent.d.ts +6 -0
  213. package/dist/server/handlers/local-agent.js +118 -0
  214. package/dist/server/handlers/meta-ads.d.ts +111 -0
  215. package/dist/server/handlers/meta-ads.js +2279 -0
  216. package/dist/server/handlers/nodes.d.ts +33 -0
  217. package/dist/server/handlers/nodes.js +699 -0
  218. package/dist/server/handlers/operations.d.ts +138 -0
  219. package/dist/server/handlers/operations.js +131 -0
  220. package/dist/server/handlers/platform.d.ts +23 -0
  221. package/dist/server/handlers/platform.js +227 -0
  222. package/dist/server/handlers/supply-chain.d.ts +19 -0
  223. package/dist/server/handlers/supply-chain.js +327 -0
  224. package/dist/server/handlers/transcription.d.ts +17 -0
  225. package/dist/server/handlers/transcription.js +121 -0
  226. package/dist/server/handlers/video-gen.d.ts +6 -0
  227. package/dist/server/handlers/video-gen.js +466 -0
  228. package/dist/server/handlers/voice.d.ts +8 -0
  229. package/dist/server/handlers/voice.js +1146 -0
  230. package/dist/server/handlers/workflow-steps.d.ts +86 -0
  231. package/dist/server/handlers/workflow-steps.js +2349 -0
  232. package/dist/server/handlers/workflows.d.ts +7 -0
  233. package/dist/server/handlers/workflows.js +989 -0
  234. package/dist/server/index.d.ts +1 -0
  235. package/dist/server/index.js +2427 -0
  236. package/dist/server/lib/batch-client.d.ts +80 -0
  237. package/dist/server/lib/batch-client.js +467 -0
  238. package/dist/server/lib/code-worker-pool.d.ts +31 -0
  239. package/dist/server/lib/code-worker-pool.js +224 -0
  240. package/dist/server/lib/code-worker.d.ts +1 -0
  241. package/dist/server/lib/code-worker.js +188 -0
  242. package/dist/server/lib/compaction-service.d.ts +32 -0
  243. package/dist/server/lib/compaction-service.js +162 -0
  244. package/dist/server/lib/logger.d.ts +19 -0
  245. package/dist/server/lib/logger.js +46 -0
  246. package/dist/server/lib/otel.d.ts +38 -0
  247. package/dist/server/lib/otel.js +126 -0
  248. package/dist/server/lib/pg-rate-limiter.d.ts +21 -0
  249. package/dist/server/lib/pg-rate-limiter.js +86 -0
  250. package/dist/server/lib/prompt-sanitizer.d.ts +37 -0
  251. package/dist/server/lib/prompt-sanitizer.js +177 -0
  252. package/dist/server/lib/provider-capabilities.d.ts +85 -0
  253. package/dist/server/lib/provider-capabilities.js +190 -0
  254. package/dist/server/lib/provider-failover.d.ts +74 -0
  255. package/dist/server/lib/provider-failover.js +210 -0
  256. package/dist/server/lib/rate-limiter.d.ts +39 -0
  257. package/dist/server/lib/rate-limiter.js +147 -0
  258. package/dist/server/lib/server-agent-loop.d.ts +107 -0
  259. package/dist/server/lib/server-agent-loop.js +667 -0
  260. package/dist/server/lib/server-subagent.d.ts +78 -0
  261. package/dist/server/lib/server-subagent.js +203 -0
  262. package/dist/server/lib/session-checkpoint.d.ts +51 -0
  263. package/dist/server/lib/session-checkpoint.js +145 -0
  264. package/dist/server/lib/ssrf-guard.d.ts +13 -0
  265. package/dist/server/lib/ssrf-guard.js +240 -0
  266. package/dist/server/lib/supabase-client.d.ts +7 -0
  267. package/dist/server/lib/supabase-client.js +78 -0
  268. package/dist/server/lib/template-resolver.d.ts +31 -0
  269. package/dist/server/lib/template-resolver.js +215 -0
  270. package/dist/server/lib/utils.d.ts +16 -0
  271. package/dist/server/lib/utils.js +147 -0
  272. package/dist/server/local-agent-gateway.d.ts +82 -0
  273. package/dist/server/local-agent-gateway.js +426 -0
  274. package/dist/server/providers/anthropic.d.ts +20 -0
  275. package/dist/server/providers/anthropic.js +199 -0
  276. package/dist/server/providers/bedrock.d.ts +20 -0
  277. package/dist/server/providers/bedrock.js +194 -0
  278. package/dist/server/providers/gemini.d.ts +24 -0
  279. package/dist/server/providers/gemini.js +486 -0
  280. package/dist/server/providers/openai.d.ts +24 -0
  281. package/dist/server/providers/openai.js +522 -0
  282. package/dist/server/providers/registry.d.ts +32 -0
  283. package/dist/server/providers/registry.js +58 -0
  284. package/dist/server/providers/shared.d.ts +32 -0
  285. package/dist/server/providers/shared.js +124 -0
  286. package/dist/server/providers/types.d.ts +92 -0
  287. package/dist/server/providers/types.js +12 -0
  288. package/dist/server/proxy-handlers.d.ts +6 -0
  289. package/dist/server/proxy-handlers.js +89 -0
  290. package/dist/server/tool-router.d.ts +149 -0
  291. package/dist/server/tool-router.js +803 -0
  292. package/dist/server/validation.d.ts +24 -0
  293. package/dist/server/validation.js +301 -0
  294. package/dist/server/worker.d.ts +19 -0
  295. package/dist/server/worker.js +201 -0
  296. package/dist/setup.d.ts +8 -0
  297. package/dist/setup.js +181 -0
  298. package/dist/shared/agent-core.d.ts +157 -0
  299. package/dist/shared/agent-core.js +534 -0
  300. package/dist/shared/anthropic-types.d.ts +105 -0
  301. package/dist/shared/anthropic-types.js +7 -0
  302. package/dist/shared/api-client.d.ts +90 -0
  303. package/dist/shared/api-client.js +379 -0
  304. package/dist/shared/constants.d.ts +33 -0
  305. package/dist/shared/constants.js +80 -0
  306. package/dist/shared/sse-parser.d.ts +26 -0
  307. package/dist/shared/sse-parser.js +259 -0
  308. package/dist/shared/tool-dispatch.d.ts +52 -0
  309. package/dist/shared/tool-dispatch.js +191 -0
  310. package/dist/shared/types.d.ts +72 -0
  311. package/dist/shared/types.js +7 -0
  312. package/dist/updater.d.ts +25 -0
  313. package/dist/updater.js +140 -0
  314. package/dist/webchat/widget.d.ts +0 -0
  315. package/dist/webchat/widget.js +397 -0
  316. package/package.json +95 -0
  317. package/src/cli/services/builtin-skills/commit.md +19 -0
  318. package/src/cli/services/builtin-skills/review-pr.md +21 -0
  319. package/src/cli/services/builtin-skills/review.md +18 -0
@@ -0,0 +1,2427 @@
1
+ // server/index.ts — Unified Node.js agent server for Fly.io
2
+ // All CLI optimizations: prompt caching, retry, loop detection, parallel tools,
3
+ // model-aware context management, cost tracking, compaction block handling
4
+ //
5
+ // Shares agent-core with CLI via src/shared/agent-core.ts
6
+ import http from "node:http";
7
+ import { randomUUID, timingSafeEqual, createHash } from "node:crypto";
8
+ import Anthropic from "@anthropic-ai/sdk";
9
+ import { createLogger } from "./lib/logger.js";
10
+ const log = createLogger("server");
11
+ import { getMaxOutputTokens, sanitizeError, } from "../shared/agent-core.js";
12
+ import { MODELS } from "../shared/constants.js";
13
+ import { handleProxy } from "./proxy-handlers.js";
14
+ import { handleNodeRoutes, setNodeAgentInvoker } from "./handlers/nodes.js";
15
+ import { handleTranscribe } from "./handlers/transcription.js";
16
+ import { handleBillingRoutes, incrementUsage, checkPlanLimits } from "./handlers/billing.js";
17
+ import { generateCompaction } from "./lib/compaction-service.js";
18
+ import { initLocalAgentGateway, shutdownGateway as shutdownAgentGateway, getGatewayStats } from "./local-agent-gateway.js";
19
+ import { initSupabase, getServiceClient, createUserClient } from "./lib/supabase-client.js";
20
+ import { loadCheckpoint, markOrphaned } from "./lib/session-checkpoint.js";
21
+ import { rateLimiter } from "./lib/rate-limiter.js";
22
+ import { sanitizeAndLog } from "./lib/prompt-sanitizer.js";
23
+ import { processWorkflowSteps, processWaitingSteps, handleWebhookIngestion, executeInlineChain, setToolExecutor, setAgentExecutor, setTokenBroadcaster, setStepErrorBroadcaster, verifyGuestApprovalSignature, initWorkerPool, getPoolStats, shutdownPool, processScheduleTriggers, enforceWorkflowTimeouts, processEventTriggers, cleanupOrphanedSteps, processDlqRetries } from "./handlers/workflows.js";
24
+ import { runServerAgentLoop } from "./lib/server-agent-loop.js";
25
+ import { loadTools, loadUserTools, getToolsForAgent, executeTool, loadAgentConfig, setExtendedToolsCache, getExtendedToolsIndex, flushAuditLogs, } from "./tool-router.js";
26
+ import pg from "pg";
27
+ // ============================================================================
28
+ // PROCESS ERROR HANDLERS
29
+ // ============================================================================
30
+ process.on("unhandledRejection", (reason, _promise) => {
31
+ log.error({ err: reason }, "unhandled rejection");
32
+ });
33
+ process.on("uncaughtException", (err) => {
34
+ log.fatal({ err }, "uncaught exception");
35
+ process.exit(1);
36
+ });
37
+ // ============================================================================
38
+ // ENV CONFIG
39
+ // ============================================================================
40
+ const PORT = parseInt(process.env.PORT || "8080", 10);
41
+ const SUPABASE_URL = process.env.SUPABASE_URL;
42
+ const SUPABASE_SERVICE_ROLE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY;
43
+ const SERVICE_ROLE_JWT = process.env.SERVICE_ROLE_JWT || "";
44
+ const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
45
+ const ALLOWED_ORIGINS = (process.env.ALLOWED_ORIGINS || "http://localhost:3000,http://127.0.0.1:3000").split(",").map(s => s.trim());
46
+ const FLY_INTERNAL_SECRET = process.env.FLY_INTERNAL_SECRET || "";
47
+ // ============================================================================
48
+ // READINESS STATE
49
+ // ============================================================================
50
+ let pgListenReady = false;
51
+ let workerPoolReady = false;
52
+ function isReady() {
53
+ return workerPoolReady; // PG listen is optional (SSE only)
54
+ }
55
+ // Webchat agent invoker — set later to avoid circular deps (same as node agent invoker)
56
+ let webchatAgentInvoker = null;
57
+ // ============================================================================
58
+ // RATE LIMITING — Phase 7.2 token-bucket (see lib/rate-limiter.ts)
59
+ // ============================================================================
60
+ /** Check IP rate limit and send 429 with proper headers if exceeded */
61
+ function sendIpRateLimit(res, ip, headers) {
62
+ const result = rateLimiter.checkRequest(`ip:${ip}`, "unauthenticated");
63
+ if (result.allowed)
64
+ return false; // not rate-limited
65
+ res.writeHead(429, {
66
+ "Retry-After": String(Math.ceil(result.retryAfterMs / 1000) || 1),
67
+ "X-RateLimit-Remaining": "0",
68
+ "Content-Type": "application/json",
69
+ ...headers,
70
+ });
71
+ res.end(JSON.stringify({ error: "Too many requests" }));
72
+ return true; // was rate-limited
73
+ }
74
+ // Agent chat rate limiting — per-store + global concurrent cap
75
+ const agentChatLimiter = new Map();
76
+ const AGENT_CHAT_MAX_PER_MIN = 60;
77
+ const AGENT_CHAT_MAX_CONCURRENT = 10;
78
+ let agentChatConcurrent = 0;
79
+ // Periodically clean up stale entries from agentChatLimiter to prevent unbounded Map growth
80
+ setInterval(() => {
81
+ const now = Date.now();
82
+ for (const [key, entry] of agentChatLimiter) {
83
+ if (now - entry.windowStart > 120_000) {
84
+ agentChatLimiter.delete(key);
85
+ }
86
+ }
87
+ }, 60_000);
88
+ function checkAgentChatRateLimit(storeId) {
89
+ // Concurrent check
90
+ if (agentChatConcurrent >= AGENT_CHAT_MAX_CONCURRENT) {
91
+ return { allowed: false, error: "Too many concurrent agent sessions. Please wait." };
92
+ }
93
+ // Per-store per-minute check
94
+ const now = Date.now();
95
+ let entry = agentChatLimiter.get(storeId);
96
+ if (!entry || now - entry.windowStart > 60_000) {
97
+ entry = { count: 0, windowStart: now };
98
+ agentChatLimiter.set(storeId, entry);
99
+ }
100
+ entry.count++;
101
+ if (entry.count > AGENT_CHAT_MAX_PER_MIN) {
102
+ return { allowed: false, error: `Rate limit exceeded: ${AGENT_CHAT_MAX_PER_MIN}/min for agent chat` };
103
+ }
104
+ return { allowed: true };
105
+ }
106
+ // Timing-safe secret comparison to prevent timing attacks
107
+ // Hash both values to fixed length before comparing — avoids leaking secret length
108
+ function safeCompare(a, b) {
109
+ if (!a || !b)
110
+ return false;
111
+ const hashA = createHash("sha256").update(a).digest();
112
+ const hashB = createHash("sha256").update(b).digest();
113
+ return timingSafeEqual(hashA, hashB);
114
+ }
115
+ // Tool registry, user tools, executor, and agent loader are in ./tool-router.ts
116
+ // ============================================================================
117
+ // CORS
118
+ // ============================================================================
119
+ function getCorsHeaders(origin) {
120
+ const headers = {
121
+ "X-Content-Type-Options": "nosniff",
122
+ "X-Frame-Options": "DENY",
123
+ "X-XSS-Protection": "0",
124
+ "Referrer-Policy": "strict-origin-when-cross-origin",
125
+ };
126
+ if (ALLOWED_ORIGINS.includes("*")) {
127
+ headers["Access-Control-Allow-Origin"] = "*";
128
+ }
129
+ else if (origin && ALLOWED_ORIGINS.includes(origin)) {
130
+ headers["Access-Control-Allow-Origin"] = origin;
131
+ headers["Vary"] = "Origin";
132
+ }
133
+ // If origin doesn't match, no CORS header = browser blocks the request
134
+ headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS";
135
+ headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization, X-Store-Id";
136
+ return headers;
137
+ }
138
+ // ============================================================================
139
+ // PHASE 3: SSE STREAMING — real-time workflow run progress
140
+ // ============================================================================
141
+ // Map<runId, Set<ServerResponse>> for multiplexing SSE clients
142
+ const sseClients = new Map();
143
+ const MAX_SSE_CLIENTS_PER_RUN = 10;
144
+ const MAX_SSE_TOTAL_CLIENTS = 100;
145
+ /** Safe SSE write — returns false and calls cleanup if the connection is dead */
146
+ function safeSseWrite(res, data, cleanup) {
147
+ try {
148
+ if (res.destroyed || res.writableEnded) {
149
+ cleanup();
150
+ return false;
151
+ }
152
+ res.write(data);
153
+ return true;
154
+ }
155
+ catch {
156
+ cleanup();
157
+ return false;
158
+ }
159
+ }
160
+ function sendWorkflowSSE(res, data) {
161
+ try {
162
+ if (res.destroyed || res.writableEnded)
163
+ return;
164
+ res.write(`data: ${JSON.stringify(data)}\n\n`);
165
+ }
166
+ catch { /* client disconnected — benign */ }
167
+ }
168
+ function broadcastToRun(runId, data) {
169
+ const clients = sseClients.get(runId);
170
+ if (!clients?.size)
171
+ return;
172
+ // H6 FIX: Prune dead connections during broadcast
173
+ for (const res of clients) {
174
+ if (res.destroyed || res.writableEnded) {
175
+ clients.delete(res);
176
+ continue;
177
+ }
178
+ sendWorkflowSSE(res, data);
179
+ }
180
+ if (clients.size === 0)
181
+ sseClients.delete(runId);
182
+ }
183
+ function getTotalSseClients() {
184
+ let total = 0;
185
+ for (const clients of sseClients.values())
186
+ total += clients.size;
187
+ return total;
188
+ }
189
+ // H6 FIX: Periodic stale connection cleanup (every 60s)
190
+ const sseCleanupInterval = setInterval(() => {
191
+ for (const [rid, clients] of sseClients) {
192
+ for (const res of clients) {
193
+ if (res.destroyed || res.writableEnded) {
194
+ clients.delete(res);
195
+ }
196
+ }
197
+ if (clients.size === 0)
198
+ sseClients.delete(rid);
199
+ }
200
+ }, 60_000);
201
+ // pg LISTEN for real-time notifications
202
+ const DATABASE_URL = process.env.DATABASE_URL || "";
203
+ let pgClient = null;
204
+ let pgReconnectAttempts = 0;
205
+ const MAX_PG_RECONNECT_DELAY = 10_000; // 10s max — keep SSE reconnect snappy
206
+ async function setupPgListen() {
207
+ if (!DATABASE_URL) {
208
+ log.info("DATABASE_URL not set — SSE streaming disabled, using worker-only mode");
209
+ return;
210
+ }
211
+ try {
212
+ // Strip sslmode from URL (pg v8 treats sslmode=require as verify-full) and set ssl manually
213
+ const cleanUrl = DATABASE_URL.replace(/[?&]sslmode=[^&]*/g, "").replace(/\?$/, "");
214
+ pgClient = new pg.Client({ connectionString: cleanUrl, ssl: { rejectUnauthorized: false } });
215
+ await pgClient.connect();
216
+ await pgClient.query("LISTEN workflow_step_event");
217
+ await pgClient.query("LISTEN workflow_run_event");
218
+ await pgClient.query("LISTEN workflow_step_pending");
219
+ await pgClient.query("LISTEN workflow_event");
220
+ await pgClient.query("LISTEN automation_event");
221
+ // Reset reconnect counter on successful connection
222
+ pgReconnectAttempts = 0;
223
+ pgListenReady = true;
224
+ // Debounced event trigger processing — fires at most once per 100ms
225
+ let eventTriggerTimer = null;
226
+ function debouncedEventProcess() {
227
+ if (eventTriggerTimer)
228
+ return;
229
+ eventTriggerTimer = setTimeout(async () => {
230
+ eventTriggerTimer = null;
231
+ try {
232
+ const sb = getServiceClient();
233
+ const count = await processEventTriggers(sb);
234
+ if (count > 0)
235
+ log.info({ count }, "instant event processing");
236
+ }
237
+ catch (err) {
238
+ log.error({ err: err.message }, "event trigger processing error");
239
+ }
240
+ }, 100);
241
+ }
242
+ pgClient.on("notification", (msg) => {
243
+ if (!msg.payload)
244
+ return;
245
+ try {
246
+ const data = JSON.parse(msg.payload);
247
+ // Automation event — trigger immediate processing
248
+ if (msg.channel === "automation_event") {
249
+ debouncedEventProcess();
250
+ return;
251
+ }
252
+ const runId = data.run_id;
253
+ if (!runId)
254
+ return;
255
+ if (msg.channel === "workflow_step_event") {
256
+ broadcastToRun(runId, { type: "step_update", ...data });
257
+ }
258
+ else if (msg.channel === "workflow_run_event") {
259
+ broadcastToRun(runId, { type: "run_update", ...data });
260
+ }
261
+ else if (msg.channel === "workflow_event") {
262
+ broadcastToRun(runId, { type: "event", event_type: data.event_type, ...data });
263
+ }
264
+ else if (msg.channel === "workflow_step_pending") {
265
+ // Phase 3.1: NOTIFY-driven step execution — immediate pickup (~50ms vs 5s polling)
266
+ const sb = getServiceClient();
267
+ processWorkflowSteps(sb, 1).catch((err) => {
268
+ log.error({ err: err.message, runId }, "NOTIFY-driven step processing failed");
269
+ });
270
+ }
271
+ }
272
+ catch (err) {
273
+ log.error({ err: err.message }, "failed to parse pg notification");
274
+ }
275
+ });
276
+ pgClient.on("error", (err) => {
277
+ log.error({ err: err.message }, "pg-listen connection error");
278
+ pgClient = null;
279
+ // Reconnect with exponential backoff
280
+ pgReconnectAttempts++;
281
+ const delay = Math.min(1000 * Math.pow(2, pgReconnectAttempts - 1), MAX_PG_RECONNECT_DELAY);
282
+ log.warn({ delayMs: delay, attempt: pgReconnectAttempts }, "pg-listen reconnecting");
283
+ setTimeout(() => setupPgListen(), delay);
284
+ });
285
+ log.info("pg-listen active on workflow_step_event, workflow_run_event, workflow_step_pending, workflow_event, automation_event");
286
+ }
287
+ catch (err) {
288
+ log.error({ err: err.message }, "pg-listen failed to connect");
289
+ pgClient = null;
290
+ // Reconnect with exponential backoff on initial connection failure too
291
+ pgReconnectAttempts++;
292
+ const delay = Math.min(1000 * Math.pow(2, pgReconnectAttempts - 1), MAX_PG_RECONNECT_DELAY);
293
+ log.warn({ delayMs: delay, attempt: pgReconnectAttempts }, "pg-listen reconnecting");
294
+ setTimeout(() => setupPgListen(), delay);
295
+ }
296
+ }
297
+ // ============================================================================
298
+ // HELPERS
299
+ // ============================================================================
300
+ function getAnthropicClient(agent) {
301
+ const key = agent.api_key || ANTHROPIC_API_KEY;
302
+ return new Anthropic({ apiKey: key, timeout: 5 * 60 * 1000 }); // 5 min for tool-heavy requests
303
+ }
304
+ function sendSSE(res, event) {
305
+ try {
306
+ if (res.destroyed || res.writableEnded)
307
+ return;
308
+ res.write(`data: ${JSON.stringify(event)}\n\n`);
309
+ }
310
+ catch { /* client disconnected — benign */ }
311
+ }
312
+ function jsonResponse(res, status, data, corsHeaders) {
313
+ res.writeHead(status, { "Content-Type": "application/json", ...corsHeaders });
314
+ res.end(JSON.stringify(data));
315
+ }
316
+ async function readBody(req) {
317
+ // 50MB limit — proxy requests include full conversation history with base64 images
318
+ const MAX_BODY = 52_428_800;
319
+ return new Promise((resolve, reject) => {
320
+ const chunks = [];
321
+ let size = 0;
322
+ let rejected = false;
323
+ req.on("data", (chunk) => {
324
+ size += chunk.length;
325
+ if (size > MAX_BODY && !rejected) {
326
+ 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)"));
330
+ return;
331
+ }
332
+ if (!rejected)
333
+ chunks.push(chunk);
334
+ });
335
+ req.on("end", () => { if (!rejected)
336
+ resolve(Buffer.concat(chunks).toString("utf8")); });
337
+ req.on("error", reject);
338
+ });
339
+ }
340
+ // ============================================================================
341
+ // HISTORY COMPACTION
342
+ // ============================================================================
343
+ /**
344
+ * Compact conversation history to fit within a total character budget.
345
+ *
346
+ * Does NOT truncate individual messages or tool results — Anthropic's
347
+ * context_management API handles clearing old tool uses (clear_tool_uses_20250919)
348
+ * and compacting context (compact_20260112) when the context window grows.
349
+ *
350
+ * This function only enforces a total budget by walking newest→oldest and
351
+ * dropping the oldest messages that don't fit.
352
+ */
353
+ function compactHistory(history, maxHistoryChars) {
354
+ if (!history?.length)
355
+ return [];
356
+ let totalChars = 0;
357
+ const compacted = [];
358
+ for (let i = history.length - 1; i >= 0; i--) {
359
+ const msg = history[i];
360
+ const msgChars = JSON.stringify(msg.content).length;
361
+ if (totalChars + msgChars > maxHistoryChars)
362
+ break;
363
+ totalChars += msgChars;
364
+ compacted.unshift(msg);
365
+ }
366
+ // Ensure starts with user message
367
+ while (compacted.length > 0 && compacted[0].role !== "user")
368
+ compacted.shift();
369
+ return compacted;
370
+ }
371
+ // ============================================================================
372
+ // SHARED AGENT HELPERS — used by both SSE chat and channel agent paths
373
+ // ============================================================================
374
+ /** Build the full system prompt for an agent. Both SSE chat and channel paths
375
+ * call this so the prompt logic is never duplicated. */
376
+ async function buildAgentSystemPrompt(supabase, agent, storeId, message, tools, opts) {
377
+ // --- STATIC portion (cache-friendly, same across conversations) ---
378
+ // Sanitize the DB-stored agent system prompt to prevent injection attacks
379
+ const rawAgentPrompt = agent.system_prompt || "You are a helpful assistant.";
380
+ let systemPrompt = sanitizeAndLog(rawAgentPrompt, "buildAgentSystemPrompt", { agentId: agent.id });
381
+ if (storeId)
382
+ systemPrompt += `\n\nYou are operating for store_id: ${storeId}. Always include this in tool calls that require it.`;
383
+ if (!agent.can_modify)
384
+ systemPrompt += "\n\nIMPORTANT: You have read-only access. Do not attempt to modify any data.";
385
+ if (agent.tone && agent.tone !== "professional")
386
+ systemPrompt += `\n\nTone: Respond in a ${agent.tone} tone.`;
387
+ if (agent.verbosity === "concise")
388
+ systemPrompt += "\n\nBe concise — short answers, minimal explanation.";
389
+ else if (agent.verbosity === "verbose")
390
+ systemPrompt += "\n\nBe thorough — provide detailed answers with full context.";
391
+ if (agent.context_config) {
392
+ const ctx = agent.context_config;
393
+ if (ctx.includeLocations && ctx.locationIds?.length)
394
+ systemPrompt += `\n\nFocus on these locations: ${ctx.locationIds.join(", ")}`;
395
+ if (ctx.includeCustomers && ctx.customerSegments?.length)
396
+ systemPrompt += `\n\nFocus on these customer segments: ${ctx.customerSegments.join(", ")}`;
397
+ }
398
+ // Tool manifest — core tools (full schemas already loaded)
399
+ const toolNames = tools.map(t => t.name);
400
+ systemPrompt += `\n\n## Available Tools\nYou have ${toolNames.length} core tools available in this session:\n${toolNames.join(", ")}\n\nFor local machine operations, use the \`local_agent\` tool (exec, tools, discover actions). For cloud security tools, use \`kali\`. Do NOT reference CLI-local tools like read_file, write_file, edit_file, glob, grep, run_command — those are not available in this environment.`;
401
+ // Extended tools index — names + one-line descriptions only (saves ~20K tokens)
402
+ const extendedTools = opts?.extendedTools || [];
403
+ if (extendedTools.length > 0) {
404
+ systemPrompt += `\n\n## Extended Tools (call discover_tools to activate)\nThese tools are available but not loaded yet. Call \`discover_tools\` with the tool name(s) before using them:\n`;
405
+ for (const t of extendedTools) {
406
+ // First sentence only for compact display
407
+ const shortDesc = t.description.split(".")[0];
408
+ systemPrompt += `- **${t.name}**: ${shortDesc}\n`;
409
+ }
410
+ }
411
+ // --- DYNAMIC portion (changes per conversation, prepended to user message) ---
412
+ const dynamicParts = [];
413
+ // Client-provided session context (SSE chat sends this from SwiftUI/web)
414
+ if (opts?.clientContext && typeof opts.clientContext === "object") {
415
+ const context = opts.clientContext;
416
+ const ctxParts = [];
417
+ if (context.storeName)
418
+ ctxParts.push(`Store: ${context.storeName}`);
419
+ if (context.locationName) {
420
+ let loc = `Location: ${context.locationName}`;
421
+ if (context.locationAddress)
422
+ loc += ` (${context.locationAddress})`;
423
+ if (context.locationType)
424
+ loc += ` [${context.locationType}]`;
425
+ ctxParts.push(loc);
426
+ }
427
+ if (context.userName) {
428
+ ctxParts.push(`User: ${context.userName}${opts.userEmail ? ` (${opts.userEmail})` : ""}`);
429
+ }
430
+ else if (opts.userEmail) {
431
+ ctxParts.push(`User: ${opts.userEmail}`);
432
+ }
433
+ if (context.conversationType) {
434
+ ctxParts.push(`Channel: ${context.conversationTitle || context.conversationType} (${context.conversationType})`);
435
+ }
436
+ if (ctxParts.length) {
437
+ dynamicParts.push(`## Current Session Context\n${ctxParts.join("\n")}`);
438
+ }
439
+ }
440
+ // Parallel DB lookups: customer fetch + memory recall
441
+ const customerPromise = (opts?.senderContext?.customerId && storeId)
442
+ ? Promise.resolve(supabase.from("v_store_customers")
443
+ .select("first_name, last_name, email, phone, loyalty_tier, total_orders, total_spent, lifetime_value")
444
+ .eq("id", opts.senderContext.customerId).eq("store_id", storeId).single()).then(({ data }) => data).catch(() => null)
445
+ : Promise.resolve(null);
446
+ const memoryPromise = storeId
447
+ ? Promise.resolve(supabase.rpc("recall_memory", {
448
+ p_agent_id: agent.id,
449
+ p_query: message.substring(0, 200),
450
+ p_type: null,
451
+ p_limit: 5,
452
+ })).then(({ data }) => data).catch((err) => { log.warn({ err: err.message }, "memory recall failed"); return null; })
453
+ : Promise.resolve(null);
454
+ const [cust, memories] = await Promise.all([customerPromise, memoryPromise]);
455
+ // Channel-specific customer context injection
456
+ if (cust && opts?.senderContext?.customerId) {
457
+ dynamicParts.push(`## Current Customer\nName: ${cust.first_name} ${cust.last_name}\nEmail: ${cust.email || "N/A"}\nPhone: ${cust.phone || "N/A"}\nLoyalty: ${cust.loyalty_tier || "None"}\nOrders: ${cust.total_orders || 0}\nSpent: $${(cust.total_spent || 0).toFixed(2)}\nCustomer ID: ${opts.senderContext.customerId}`);
458
+ }
459
+ else if (opts?.senderContext) {
460
+ const sc = opts.senderContext;
461
+ dynamicParts.push(`## Sender\nID: ${sc.senderId}\nName: ${sc.senderName || "Unknown"}\nChannel: ${sc.channelType || "unknown"}${sc.channelName ? ` (${sc.channelName})` : ""}\n(No customer record matched — use CRM tools to look them up if needed)`);
462
+ }
463
+ // Memory recall — inject relevant memories (capped at 5, 100 chars each)
464
+ if (memories?.length) {
465
+ const memBlock = memories.map((m) => `- [${m.memory_type}] ${m.key}: ${JSON.stringify(m.value).substring(0, 100)}`).join("\n");
466
+ dynamicParts.push(`## Agent Memory\nRelevant memories from previous conversations:\n${memBlock}`);
467
+ }
468
+ const dynamicContext = dynamicParts.join("\n\n");
469
+ return { systemPrompt, dynamicContext };
470
+ }
471
+ /** Persist everything after an agent turn — messages, audit, memory, cost.
472
+ * Called by both SSE chat and channel paths so nothing is ever missed. */
473
+ async function persistAgentTurn(supabase, agent, opts) {
474
+ const { conversationId, storeId, agentId, agentModel, traceId, message, result, source, chatStartTime, chatEndTime, userId, userEmail, senderContext } = opts;
475
+ // ── Persist user + assistant messages to ai_messages ──
476
+ try {
477
+ await supabase.from("ai_messages").insert([
478
+ {
479
+ conversation_id: conversationId, role: "user",
480
+ content: [{ type: "text", text: message }],
481
+ token_count: Math.ceil(message.length / 4),
482
+ },
483
+ {
484
+ conversation_id: conversationId, role: "assistant",
485
+ content: [{ type: "text", text: result.finalText || "" }],
486
+ is_tool_use: result.toolCallCount > 0,
487
+ tool_names: result.toolsUsed?.length ? result.toolsUsed : null,
488
+ token_count: result.tokens.input + result.tokens.output,
489
+ },
490
+ ]);
491
+ }
492
+ catch (err) {
493
+ log.error({ err: err.message }, "message persist failed");
494
+ }
495
+ // ── Update conversation metadata ──
496
+ try {
497
+ await supabase.from("ai_conversations").update({
498
+ metadata: {
499
+ agentName: agent.name,
500
+ source,
501
+ model: agentModel,
502
+ lastTurnTokens: result.tokens.input + result.tokens.output,
503
+ lastToolCalls: result.toolCallCount,
504
+ lastDurationMs: chatEndTime - chatStartTime,
505
+ // Channel-specific (null when SSE chat — that's fine, no clutter)
506
+ ...(senderContext ? {
507
+ channel_type: senderContext.channelType || null,
508
+ channel_id: senderContext.channelId || null,
509
+ channel_name: senderContext.channelName || null,
510
+ sender_id: senderContext.senderId || null,
511
+ customer_id: senderContext.customerId || null,
512
+ customer_name: senderContext.customerName || null,
513
+ } : {}),
514
+ },
515
+ }).eq("id", conversationId);
516
+ }
517
+ catch (err) {
518
+ log.error({ err: err.message }, "conversation update failed");
519
+ }
520
+ // ── Audit log: user message ──
521
+ try {
522
+ await supabase.from("audit_logs").insert({
523
+ action: "chat.user_message",
524
+ severity: "info",
525
+ store_id: storeId || null,
526
+ resource_type: "chat_message",
527
+ resource_id: agentId,
528
+ request_id: traceId,
529
+ conversation_id: conversationId,
530
+ user_id: userId || null,
531
+ user_email: userEmail || null,
532
+ source,
533
+ details: {
534
+ message_preview: message.substring(0, 200),
535
+ agent_id: agentId,
536
+ model: agentModel,
537
+ conversation_id: conversationId,
538
+ ...(senderContext ? {
539
+ channel_type: senderContext.channelType || null,
540
+ sender_id: senderContext.senderId || null,
541
+ customer_id: senderContext.customerId || null,
542
+ } : {}),
543
+ },
544
+ });
545
+ }
546
+ catch (err) {
547
+ log.error({ err: err.message }, "audit user_message failed");
548
+ }
549
+ // ── Audit log: assistant response (OTEL-enriched) ──
550
+ 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({
555
+ action: "chat.assistant_response",
556
+ severity: "info",
557
+ store_id: storeId || null,
558
+ resource_type: "chat_message",
559
+ resource_id: agentId,
560
+ request_id: traceId,
561
+ conversation_id: conversationId,
562
+ duration_ms: chatEndTime - chatStartTime,
563
+ user_id: userId || null,
564
+ user_email: userEmail || null,
565
+ source,
566
+ input_tokens: result.tokens.input,
567
+ output_tokens: result.tokens.output,
568
+ total_cost: result.costUsd,
569
+ model: agentModel,
570
+ trace_id: traceId,
571
+ span_id: spanId,
572
+ span_kind: "INTERNAL",
573
+ service_name: "agent-server",
574
+ status_code: "OK",
575
+ start_time: new Date(chatStartTime).toISOString(),
576
+ end_time: new Date(chatEndTime).toISOString(),
577
+ details: {
578
+ response_preview: (result.finalText || "").substring(0, 500),
579
+ agent_id: agentId,
580
+ model: agentModel,
581
+ "gen_ai.request.model": agentModel,
582
+ "gen_ai.usage.input_tokens": result.tokens.input,
583
+ "gen_ai.usage.output_tokens": result.tokens.output,
584
+ "gen_ai.usage.cache_creation_tokens": result.tokens.cacheCreation || 0,
585
+ "gen_ai.usage.cache_read_tokens": result.tokens.cacheRead || 0,
586
+ "gen_ai.usage.cost": result.costUsd,
587
+ turn_count: result.turnCount || 1,
588
+ tool_calls: result.toolCallCount,
589
+ tool_names: result.toolsUsed,
590
+ conversation_id: conversationId,
591
+ session_cost_usd: result.costUsd,
592
+ cache_creation_tokens: result.tokens.cacheCreation || 0,
593
+ cache_read_tokens: result.tokens.cacheRead || 0,
594
+ // Cache efficiency metrics
595
+ cache_hit_rate: result.tokens.input > 0
596
+ ? Math.round((result.tokens.cacheRead || 0) / ((result.tokens.cacheRead || 0) + result.tokens.input) * 10000) / 100
597
+ : 0,
598
+ cache_cost_savings_pct: result.tokens.input > 0 && (result.tokens.cacheRead || 0) > 0
599
+ ? Math.round((result.tokens.cacheRead || 0) * 0.9 / ((result.tokens.cacheRead || 0) + result.tokens.input) * 10000) / 100
600
+ : 0,
601
+ loop_detector_stats: result.loopDetectorStats || null,
602
+ // Per-turn token breakdowns for cost attribution
603
+ turns: result.turns || [],
604
+ // Channel-specific telemetry — fully dynamic
605
+ ...(senderContext ? {
606
+ channel_type: senderContext.channelType || null,
607
+ channel_id: senderContext.channelId || null,
608
+ channel_name: senderContext.channelName || null,
609
+ sender_id: senderContext.senderId || null,
610
+ customer_id: senderContext.customerId || null,
611
+ customer_name: senderContext.customerName || null,
612
+ } : {}),
613
+ },
614
+ });
615
+ }
616
+ catch (err) {
617
+ log.error({ err: err.message }, "audit assistant_response failed");
618
+ }
619
+ // ── Memory extraction — awaited with retry ──
620
+ if (storeId && result.finalText && result.finalText.length > 50) {
621
+ try {
622
+ await extractAndStoreMemories(supabase, getAnthropicClient(agent), agentId, storeId, message, result.finalText);
623
+ }
624
+ catch (err1) {
625
+ // Retry once after 2s
626
+ try {
627
+ await new Promise(r => setTimeout(r, 2000));
628
+ await extractAndStoreMemories(supabase, getAnthropicClient(agent), agentId, storeId, message, result.finalText);
629
+ }
630
+ catch (err2) {
631
+ log.error({ err: err2.message }, "memory extract failed after retry");
632
+ await supabase.from("audit_logs").insert({
633
+ action: "memory.extraction_failed", severity: "warning",
634
+ store_id: storeId || null, resource_type: "agent_memory",
635
+ resource_id: agentId, conversation_id: conversationId,
636
+ details: { error: err2.message, user_message_preview: message.substring(0, 100) },
637
+ }).then(() => { });
638
+ }
639
+ }
640
+ }
641
+ // ── Cost budget tracking — awaited with retry ──
642
+ if (storeId && result.costUsd > 0) {
643
+ try {
644
+ await updateCostBudgets(supabase, storeId, agentId, result.costUsd);
645
+ }
646
+ catch (err1) {
647
+ try {
648
+ await new Promise(r => setTimeout(r, 1000));
649
+ await updateCostBudgets(supabase, storeId, agentId, result.costUsd);
650
+ }
651
+ catch (err2) {
652
+ log.error({ err: err2.message }, "cost budget update failed after retry");
653
+ // Flag conversation metadata so budget sync can be reconciled later
654
+ await supabase.from("ai_conversations").update({
655
+ metadata: { budget_sync_failed: true, failed_cost_usd: result.costUsd },
656
+ }).eq("id", conversationId).then(() => { });
657
+ }
658
+ }
659
+ }
660
+ }
661
+ // ============================================================================
662
+ // AGENT CHAT HANDLER
663
+ // ============================================================================
664
+ async function handleAgentChat(req, res, supabase, body, user, isServiceRole, token, corsHeaders) {
665
+ const { agentId, message, conversationHistory, source, conversationId, context, attachments } = body;
666
+ let storeId = body.storeId;
667
+ if (!agentId || !message) {
668
+ jsonResponse(res, 400, { error: "agentId and message required" }, corsHeaders);
669
+ return;
670
+ }
671
+ if (typeof message === "string" && message.length > 100_000) {
672
+ jsonResponse(res, 400, { error: "Message too long (max 100K characters)" }, corsHeaders);
673
+ return;
674
+ }
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
+ }
691
+ }
692
+ 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
+ // Agent chat rate limiting — per-store + concurrent cap
721
+ const rateLimitStoreId = storeId || agentId; // fallback to agentId if no store
722
+ const rateCheck = checkAgentChatRateLimit(rateLimitStoreId);
723
+ if (!rateCheck.allowed) {
724
+ res.writeHead(429, { "Content-Type": "application/json", ...corsHeaders });
725
+ res.end(JSON.stringify({ error: rateCheck.error }));
726
+ return;
727
+ }
728
+ agentChatConcurrent++;
729
+ try {
730
+ const userId = user?.id || body.userId || "";
731
+ const userEmail = user?.email || body.userEmail || null;
732
+ const agent = await loadAgentConfig(supabase, agentId, storeId || undefined);
733
+ if (!agent) {
734
+ jsonResponse(res, 404, { error: "Agent not found" }, corsHeaders);
735
+ return;
736
+ }
737
+ const { core: coreTools, extended: extendedTools } = await loadTools(supabase);
738
+ setExtendedToolsCache(extendedTools); // Populate discover_tools handler cache
739
+ const { rows: userToolRows, defs: userToolDefs } = storeId
740
+ ? await loadUserTools(supabase, storeId)
741
+ : { rows: [], defs: [] };
742
+ const tools = getToolsForAgent(agent, coreTools, userToolDefs);
743
+ const traceId = randomUUID();
744
+ const agentModel = agent.model || MODELS.SONNET;
745
+ // Resolve or create conversation
746
+ let activeConversationId;
747
+ if (conversationId) {
748
+ activeConversationId = conversationId;
749
+ }
750
+ else {
751
+ let conv = await supabase
752
+ .from("ai_conversations")
753
+ .insert({
754
+ store_id: storeId || null,
755
+ user_id: userId || null,
756
+ agent_id: agentId,
757
+ title: message.substring(0, 100),
758
+ metadata: { agentName: agent.name, source: source || "whale_chat" },
759
+ })
760
+ .select("id")
761
+ .single();
762
+ if (conv.error) {
763
+ log.error({ err: conv.error.message, details: conv.error.details, hint: conv.error.hint, storeId, userId, agentId }, "conversation create failed");
764
+ // Retry without user_id (may be FK constraint)
765
+ conv = await supabase
766
+ .from("ai_conversations")
767
+ .insert({
768
+ store_id: storeId || null,
769
+ agent_id: agentId,
770
+ title: message.substring(0, 100),
771
+ metadata: { agentName: agent.name, source: source || "whale_chat", userId, userEmail },
772
+ })
773
+ .select("id")
774
+ .single();
775
+ if (conv.error) {
776
+ log.error({ err: conv.error.message, details: conv.error.details, hint: conv.error.hint }, "conversation retry create failed");
777
+ }
778
+ }
779
+ activeConversationId = conv.data?.id || randomUUID();
780
+ log.info({ conversationId: activeConversationId, fromDb: !!conv.data?.id }, "conversation resolved");
781
+ }
782
+ // Build system prompt — shared helper ensures SSE chat + channel paths are identical
783
+ const { systemPrompt, dynamicContext } = await buildAgentSystemPrompt(supabase, agent, storeId, message, tools, {
784
+ clientContext: context, userId, userEmail, extendedTools: getExtendedToolsIndex(),
785
+ });
786
+ const anthropic = getAnthropicClient(agent);
787
+ const ctxCfg = agent.context_config;
788
+ const MAX_HISTORY_CHARS = ctxCfg?.max_history_chars || 400_000;
789
+ // Build user message — multi-modal if image attachments present
790
+ let userContent;
791
+ if (attachments?.length) {
792
+ const contentBlocks = [];
793
+ for (const att of attachments) {
794
+ if (att.type === "image" && att.media_type && att.data) {
795
+ contentBlocks.push({
796
+ type: "image",
797
+ source: { type: "base64", media_type: att.media_type, data: att.data },
798
+ });
799
+ }
800
+ }
801
+ contentBlocks.push({ type: "text", text: message });
802
+ userContent = contentBlocks;
803
+ }
804
+ else {
805
+ userContent = message;
806
+ }
807
+ // Prepend dynamic context to user message to keep system prompt static (cache-friendly)
808
+ const contextPrefix = dynamicContext ? `[Context]\n${dynamicContext}\n\n[User Message]\n` : "";
809
+ const finalUserContent = typeof userContent === "string"
810
+ ? contextPrefix + userContent
811
+ : [...(contextPrefix ? [{ type: "text", text: contextPrefix }] : []), ...userContent];
812
+ const messages = [
813
+ ...compactHistory(conversationHistory || [], MAX_HISTORY_CHARS),
814
+ { role: "user", content: finalUserContent },
815
+ ];
816
+ // Start SSE stream
817
+ res.writeHead(200, {
818
+ "Content-Type": "text/event-stream",
819
+ "Cache-Control": "no-cache",
820
+ Connection: "keep-alive",
821
+ ...corsHeaders,
822
+ });
823
+ // Client disconnect detection
824
+ let clientDisconnected = false;
825
+ req.on("close", () => { clientDisconnected = true; });
826
+ const maxDurationMs = 5 * 60 * 1000;
827
+ const startedAt = Date.now();
828
+ const chatStartTime = Date.now();
829
+ try {
830
+ const result = await runServerAgentLoop({
831
+ anthropic,
832
+ supabase,
833
+ model: agentModel,
834
+ systemPrompt,
835
+ messages,
836
+ tools,
837
+ extendedTools,
838
+ maxTurns: agent.max_tool_calls || 10,
839
+ temperature: agent.temperature ?? 0.7,
840
+ maxTokens: getMaxOutputTokens(agentModel, agent.max_tokens),
841
+ storeId,
842
+ traceId,
843
+ userId,
844
+ userEmail,
845
+ source,
846
+ conversationId: activeConversationId,
847
+ agentId,
848
+ executeTool: async (toolName, args, sourceOverride, onToolProgress) => {
849
+ const toolArgs = { ...args };
850
+ if (!toolArgs.store_id && storeId)
851
+ toolArgs.store_id = storeId;
852
+ return executeTool(supabase, toolName, toolArgs, storeId, traceId, userId, userEmail, sourceOverride || source, activeConversationId, userToolRows, agentId, onToolProgress, true);
853
+ },
854
+ onToolProgress: (name, progress) => sendSSE(res, { type: "tool_progress", name, progress }),
855
+ onText: (text) => sendSSE(res, { type: "text", text }),
856
+ onToolStart: (name, input) => {
857
+ // Only send when input is available — deduplicates streaming double-fire
858
+ // (sse-parser fires onToolStart twice: once on content_block_start without input,
859
+ // once on content_block_stop with parsed input)
860
+ if (input !== undefined) {
861
+ sendSSE(res, { type: "tool_start", name, input });
862
+ }
863
+ },
864
+ onToolResult: (name, success, r) => sendSSE(res, { type: "tool_result", name, success, result: r }),
865
+ onSubagentProgress: (evt) => {
866
+ sendSSE(res, { type: "subagent", subagentId: evt.subagentId, subagentEvent: evt.event, name: evt.toolName });
867
+ },
868
+ clientDisconnected: { get value() { return clientDisconnected; } },
869
+ startedAt,
870
+ maxDurationMs,
871
+ });
872
+ // Send usage SSE
873
+ sendSSE(res, {
874
+ type: "usage",
875
+ usage: {
876
+ input_tokens: result.tokens.input,
877
+ output_tokens: result.tokens.output,
878
+ cache_creation_tokens: result.tokens.cacheCreation,
879
+ cache_read_tokens: result.tokens.cacheRead,
880
+ cost_usd: result.costUsd,
881
+ },
882
+ });
883
+ // Persist everything — shared helper ensures SSE chat + channel paths are identical
884
+ await persistAgentTurn(supabase, agent, {
885
+ conversationId: activeConversationId,
886
+ storeId, agentId, agentModel, traceId, message, result,
887
+ source: source || "whale_chat",
888
+ chatStartTime, chatEndTime: Date.now(),
889
+ userId, userEmail,
890
+ });
891
+ sendSSE(res, { type: "done", conversationId: activeConversationId });
892
+ }
893
+ catch (err) {
894
+ sendSSE(res, { type: "error", error: sanitizeError(err) });
895
+ }
896
+ res.end();
897
+ }
898
+ finally {
899
+ agentChatConcurrent--;
900
+ }
901
+ }
902
+ // ============================================================================
903
+ // MEMORY EXTRACTION — extract key facts after agent conversation
904
+ // ============================================================================
905
+ async function extractAndStoreMemories(supabase, anthropic, agentId, storeId, userMessage, assistantResponse) {
906
+ const extraction = await anthropic.messages.create({
907
+ model: "claude-haiku-4-5-20251001",
908
+ max_tokens: 500,
909
+ system: `Extract key facts worth remembering from this conversation turn.
910
+ Return JSON array: [{"key": "short_key", "value": {"detail": "..."}, "type": "short_term|long_term|entity"}]
911
+ Rules:
912
+ - Only extract genuinely useful facts (preferences, decisions, corrections, entities)
913
+ - "entity" for people/businesses/products mentioned
914
+ - "long_term" for preferences, patterns, decisions
915
+ - "short_term" for context that may expire
916
+ - Return [] if nothing worth remembering
917
+ - Max 3 items per turn`,
918
+ messages: [{
919
+ role: "user",
920
+ content: `User: ${userMessage.substring(0, 500)}\n\nAssistant: ${assistantResponse.substring(0, 1000)}`
921
+ }],
922
+ });
923
+ const text = extraction.content.find(b => b.type === "text")?.text || "[]";
924
+ const match = text.match(/\[[\s\S]*\]/);
925
+ if (!match)
926
+ return;
927
+ let items;
928
+ try {
929
+ items = JSON.parse(match[0]);
930
+ }
931
+ catch {
932
+ return; // Malformed JSON from extraction
933
+ }
934
+ for (const item of items.slice(0, 3)) {
935
+ if (!item.key)
936
+ continue;
937
+ await supabase.rpc("store_memory", {
938
+ p_agent_id: agentId,
939
+ p_store_id: storeId,
940
+ p_type: item.type || "short_term",
941
+ p_key: item.key,
942
+ p_value: item.value || {},
943
+ });
944
+ }
945
+ }
946
+ // ============================================================================
947
+ // COST BUDGET TRACKING — increment active budgets after each conversation
948
+ // ============================================================================
949
+ // P0 FIX: Atomic cost budget increment via RPC (fixes TOCTOU race on concurrent updates)
950
+ async function updateCostBudgets(supabase, storeId, agentId, costUsd) {
951
+ const { error } = await supabase.rpc("increment_cost_budget", {
952
+ p_store_id: storeId,
953
+ p_agent_id: agentId,
954
+ p_cost_usd: costUsd,
955
+ });
956
+ if (error) {
957
+ console.error("[cost-budget] increment_cost_budget RPC failed:", error.message);
958
+ }
959
+ }
960
+ // ============================================================================
961
+ // HTTP SERVER
962
+ // ============================================================================
963
+ // Connection tracking for graceful shutdown draining
964
+ let activeRequests = 0;
965
+ const server = http.createServer(async (req, res) => {
966
+ activeRequests++;
967
+ res.on("close", () => { activeRequests--; });
968
+ const origin = req.headers.origin || "";
969
+ const corsHeaders = getCorsHeaders(origin);
970
+ // Health check — readiness-aware for Fly.io
971
+ if (req.method === "GET" && (req.url === "/" || req.url === "/health")) {
972
+ const ready = isReady();
973
+ const status = ready ? 200 : 503;
974
+ const agentStats = getGatewayStats();
975
+ jsonResponse(res, status, {
976
+ status: ready ? "ok" : "starting",
977
+ version: process.env.npm_package_version || "6.0.0",
978
+ uptime: Math.floor(process.uptime()),
979
+ pg_listen: pgListenReady,
980
+ worker_pool: workerPoolReady,
981
+ local_agents: agentStats.total_agents,
982
+ }, corsHeaders);
983
+ return;
984
+ }
985
+ // CORS preflight
986
+ if (req.method === "OPTIONS") {
987
+ res.writeHead(204, corsHeaders);
988
+ res.end();
989
+ return;
990
+ }
991
+ const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
992
+ const pathname = url.pathname;
993
+ // ================================================================
994
+ // Phase 3: SSE stream for workflow run progress
995
+ // GET /workflows/runs/:id/stream
996
+ // ================================================================
997
+ if (req.method === "GET" && pathname.match(/^\/workflows\/runs\/[a-f0-9-]+\/stream$/)) {
998
+ const runId = pathname.split("/")[3];
999
+ // Auth check
1000
+ const authHeader = req.headers.authorization;
1001
+ const token = authHeader?.startsWith("Bearer ") ? authHeader.substring(7) : "";
1002
+ const isInternal = safeCompare(token, FLY_INTERNAL_SECRET) || safeCompare(token, SUPABASE_SERVICE_ROLE_KEY) || safeCompare(token, SERVICE_ROLE_JWT);
1003
+ if (!isInternal && !token) {
1004
+ jsonResponse(res, 401, { error: "Missing authorization" }, corsHeaders);
1005
+ return;
1006
+ }
1007
+ if (!isInternal) {
1008
+ const sb = getServiceClient();
1009
+ const { data: { user: authUser }, error: authError } = await sb.auth.getUser(token);
1010
+ if (authError || !authUser) {
1011
+ jsonResponse(res, 401, { error: "Invalid or expired token" }, corsHeaders);
1012
+ return;
1013
+ }
1014
+ // P1 FIX: Verify user belongs to the run's store (prevent cross-store SSE snooping)
1015
+ const { data: sseRun } = await sb.from("workflow_runs")
1016
+ .select("store_id").eq("id", runId).single();
1017
+ if (sseRun) {
1018
+ const { data: membership } = await sb.from("store_members")
1019
+ .select("id").eq("store_id", sseRun.store_id).eq("user_id", authUser.id).single();
1020
+ if (!membership) {
1021
+ jsonResponse(res, 403, { error: "Not authorized to view this run" }, corsHeaders);
1022
+ return;
1023
+ }
1024
+ }
1025
+ }
1026
+ // H6 FIX: Enforce per-run and total client limits
1027
+ const existingClients = sseClients.get(runId)?.size || 0;
1028
+ if (existingClients >= MAX_SSE_CLIENTS_PER_RUN) {
1029
+ jsonResponse(res, 429, { error: "Too many SSE clients for this run" }, corsHeaders);
1030
+ return;
1031
+ }
1032
+ if (getTotalSseClients() >= MAX_SSE_TOTAL_CLIENTS) {
1033
+ jsonResponse(res, 429, { error: "Too many total SSE connections" }, corsHeaders);
1034
+ return;
1035
+ }
1036
+ // Start SSE stream
1037
+ res.writeHead(200, {
1038
+ "Content-Type": "text/event-stream",
1039
+ "Cache-Control": "no-cache",
1040
+ Connection: "keep-alive",
1041
+ ...corsHeaders,
1042
+ });
1043
+ // Send snapshot
1044
+ const sb = getServiceClient();
1045
+ const { data: run } = await sb.from("workflow_runs")
1046
+ .select("id, workflow_id, status, trigger_type, current_step_key, error_message, error_step_key, started_at, completed_at, duration_ms")
1047
+ .eq("id", runId).single();
1048
+ const { data: stepRuns } = await sb.from("workflow_step_runs")
1049
+ .select("id, step_key, step_type, status, error_message, duration_ms, started_at, completed_at")
1050
+ .eq("run_id", runId).order("created_at", { ascending: true });
1051
+ sendWorkflowSSE(res, { type: "snapshot", run, steps: stepRuns || [] });
1052
+ // Register client
1053
+ if (!sseClients.has(runId))
1054
+ sseClients.set(runId, new Set());
1055
+ sseClients.get(runId).add(res);
1056
+ // Cleanup on disconnect
1057
+ const cleanup = () => {
1058
+ clearInterval(heartbeat);
1059
+ const clients = sseClients.get(runId);
1060
+ if (clients) {
1061
+ clients.delete(res);
1062
+ if (clients.size === 0)
1063
+ sseClients.delete(runId);
1064
+ }
1065
+ };
1066
+ // Heartbeat — uses safeSseWrite so dead connections are cleaned up immediately
1067
+ const heartbeat = setInterval(() => {
1068
+ if (!safeSseWrite(res, `: heartbeat\n\n`, cleanup)) {
1069
+ clearInterval(heartbeat);
1070
+ }
1071
+ }, 15_000);
1072
+ req.on("close", cleanup);
1073
+ req.on("error", cleanup);
1074
+ return;
1075
+ }
1076
+ // ================================================================
1077
+ // Guest approval — signed URL, no auth required (GET)
1078
+ // GET /approvals/guest/:id?action=approve&expires=...&sig=...
1079
+ // ================================================================
1080
+ const guestApprovalMatch = pathname.match(/^\/approvals\/guest\/([a-f0-9-]+)$/);
1081
+ if (guestApprovalMatch && req.method === "GET") {
1082
+ const clientIp = req.headers["x-forwarded-for"]?.toString().split(",")[0]?.trim() || req.socket.remoteAddress || "unknown";
1083
+ if (sendIpRateLimit(res, clientIp, corsHeaders))
1084
+ return;
1085
+ const stepRunId = guestApprovalMatch[1];
1086
+ const urlParams = new URL(req.url || "", `http://${req.headers.host}`).searchParams;
1087
+ const action = urlParams.get("action") || "";
1088
+ const expires = urlParams.get("expires") || "";
1089
+ const sig = urlParams.get("sig") || "";
1090
+ if (!action || !expires || !sig) {
1091
+ jsonResponse(res, 400, { error: "Missing action, expires, or sig parameter" }, corsHeaders);
1092
+ return;
1093
+ }
1094
+ if (new Date(expires) < new Date()) {
1095
+ jsonResponse(res, 410, { error: "This approval link has expired" }, corsHeaders);
1096
+ return;
1097
+ }
1098
+ if (!verifyGuestApprovalSignature(stepRunId, action, expires, sig)) {
1099
+ jsonResponse(res, 403, { error: "Invalid signature" }, corsHeaders);
1100
+ return;
1101
+ }
1102
+ const guestSupabase = getServiceClient();
1103
+ const { data: approval } = await guestSupabase.from("workflow_approval_requests")
1104
+ .select("id, store_id, run_id, status").eq("step_run_id", stepRunId).limit(1);
1105
+ if (!approval?.length) {
1106
+ jsonResponse(res, 404, { error: "Approval not found" }, corsHeaders);
1107
+ return;
1108
+ }
1109
+ if (approval[0].status !== "pending") {
1110
+ jsonResponse(res, 409, { error: `Approval already ${approval[0].status}` }, corsHeaders);
1111
+ return;
1112
+ }
1113
+ const isApprove = action === "approve" || action === "approved";
1114
+ const { data: guestResult, error: guestErr } = await guestSupabase.rpc("respond_to_approval", {
1115
+ p_approval_id: approval[0].id,
1116
+ p_store_id: approval[0].store_id,
1117
+ p_response: isApprove ? "approved" : "rejected",
1118
+ p_response_data: { guest: true, action },
1119
+ p_responded_by: null,
1120
+ });
1121
+ if (guestErr) {
1122
+ jsonResponse(res, 500, { success: false, error: guestErr.message }, corsHeaders);
1123
+ return;
1124
+ }
1125
+ if (guestResult?.success && approval[0].run_id) {
1126
+ try {
1127
+ await executeInlineChain(guestSupabase, approval[0].run_id);
1128
+ }
1129
+ catch (err) {
1130
+ log.error({ err: err.message, runId: approval[0].run_id }, "inline chain failed after guest approval");
1131
+ }
1132
+ }
1133
+ res.writeHead(200, { "Content-Type": "text/html", ...corsHeaders });
1134
+ res.end(`<!DOCTYPE html><html><body style="font-family:system-ui;text-align:center;padding:40px">
1135
+ <h2>${isApprove ? "Approved" : "Rejected"}</h2>
1136
+ <p>Your response has been recorded. You can close this window.</p>
1137
+ </body></html>`);
1138
+ return;
1139
+ }
1140
+ // ================================================================
1141
+ // Webchat — anonymous access for embedded chat widgets
1142
+ // POST /webchat/channels/:id/messages — send message (+ agent auto-reply)
1143
+ // GET /webchat/channels/:id/history — load conversation history
1144
+ // GET /webchat/widget.js — serve compiled widget JS
1145
+ // ================================================================
1146
+ // Webchat CORS: allow any origin (widget is embedded on customer sites)
1147
+ const webchatCors = { ...corsHeaders, "Access-Control-Allow-Origin": "*" };
1148
+ const webchatMsgMatch = pathname.match(/^\/webchat\/channels\/([a-f0-9-]+)\/messages$/);
1149
+ if (webchatMsgMatch && req.method === "POST") {
1150
+ const clientIp = req.headers["x-forwarded-for"]?.toString().split(",")[0]?.trim() || req.socket.remoteAddress || "unknown";
1151
+ if (sendIpRateLimit(res, clientIp, webchatCors))
1152
+ return;
1153
+ let rawBody;
1154
+ try {
1155
+ rawBody = await readBody(req);
1156
+ }
1157
+ catch {
1158
+ jsonResponse(res, 413, { error: "Request body too large" }, webchatCors);
1159
+ return;
1160
+ }
1161
+ const channelId = webchatMsgMatch[1];
1162
+ let wcBody;
1163
+ try {
1164
+ wcBody = JSON.parse(rawBody);
1165
+ }
1166
+ catch {
1167
+ jsonResponse(res, 400, { error: "Invalid JSON" }, webchatCors);
1168
+ return;
1169
+ }
1170
+ if (!wcBody.content || typeof wcBody.content !== "string") {
1171
+ jsonResponse(res, 400, { error: "content (string) is required" }, webchatCors);
1172
+ return;
1173
+ }
1174
+ if (wcBody.content.length > 5000) {
1175
+ jsonResponse(res, 400, { error: "Message too long (max 5000 characters)" }, webchatCors);
1176
+ return;
1177
+ }
1178
+ const supabase = getServiceClient();
1179
+ // Verify channel exists and is a webchat channel
1180
+ const { data: channel } = await supabase
1181
+ .from("channels")
1182
+ .select("id, store_id, node_id, agent_id, type, config")
1183
+ .eq("id", channelId)
1184
+ .eq("type", "webchat")
1185
+ .single();
1186
+ if (!channel) {
1187
+ jsonResponse(res, 404, { error: "Webchat channel not found" }, webchatCors);
1188
+ return;
1189
+ }
1190
+ // Rate limit per store (best-effort)
1191
+ try {
1192
+ const planCheck = await checkPlanLimits(supabase, channel.store_id, "message");
1193
+ if (!planCheck.allowed) {
1194
+ jsonResponse(res, 429, { error: planCheck.reason || "Plan limit reached" }, webchatCors);
1195
+ return;
1196
+ }
1197
+ }
1198
+ catch { /* billing tables may not exist yet */ }
1199
+ const senderId = wcBody.sender_id || "anonymous";
1200
+ // Resolve conversation: reuse if sender had activity < 30 min ago, else new UUID
1201
+ let conversationId = wcBody.conversation_id || "";
1202
+ if (!conversationId) {
1203
+ const thirtyMinAgo = new Date(Date.now() - 30 * 60 * 1000).toISOString();
1204
+ const { data: recent } = await supabase
1205
+ .from("channel_messages")
1206
+ .select("conversation_id")
1207
+ .eq("channel_id", channelId)
1208
+ .eq("sender_id", senderId)
1209
+ .gt("created_at", thirtyMinAgo)
1210
+ .not("conversation_id", "is", null)
1211
+ .order("created_at", { ascending: false })
1212
+ .limit(1);
1213
+ conversationId = (recent?.length && recent[0].conversation_id) || randomUUID();
1214
+ }
1215
+ // Insert inbound message
1216
+ const { data: message, error: msgErr } = await supabase
1217
+ .from("channel_messages")
1218
+ .insert({
1219
+ store_id: channel.store_id,
1220
+ channel_id: channelId,
1221
+ direction: "inbound",
1222
+ sender_id: senderId,
1223
+ sender_name: wcBody.sender_name || "Visitor",
1224
+ content: wcBody.content,
1225
+ content_type: "text",
1226
+ metadata: { source: "webchat", ip: clientIp, widget_version: "1.0.0" },
1227
+ agent_id: channel.agent_id,
1228
+ conversation_id: conversationId,
1229
+ })
1230
+ .select("id, direction, content, conversation_id, created_at")
1231
+ .single();
1232
+ if (msgErr) {
1233
+ jsonResponse(res, 500, { error: msgErr.message }, webchatCors);
1234
+ return;
1235
+ }
1236
+ // Track usage + channel stats (best-effort)
1237
+ incrementUsage(supabase, channel.store_id, { messages_in: 1 }).catch(() => { });
1238
+ try {
1239
+ await supabase.rpc("increment_channel_stats", { p_channel_id: channelId });
1240
+ }
1241
+ catch { /* ok */ }
1242
+ // Auto-invoke agent if assigned
1243
+ let agentResponse = null;
1244
+ if (channel.agent_id && webchatAgentInvoker) {
1245
+ try {
1246
+ const result = await webchatAgentInvoker(supabase, channel.agent_id, wcBody.content, channel.store_id, conversationId);
1247
+ if (result.success && result.response) {
1248
+ const { data: outMsg } = await supabase
1249
+ .from("channel_messages")
1250
+ .insert({
1251
+ store_id: channel.store_id,
1252
+ channel_id: channelId,
1253
+ direction: "outbound",
1254
+ sender_id: "agent",
1255
+ sender_name: "AI Agent",
1256
+ content: result.response,
1257
+ content_type: "text",
1258
+ metadata: { agent_id: channel.agent_id, auto_response: true, source: "webchat" },
1259
+ agent_id: channel.agent_id,
1260
+ conversation_id: conversationId,
1261
+ })
1262
+ .select("id, direction, content, conversation_id, created_at")
1263
+ .single();
1264
+ agentResponse = outMsg;
1265
+ incrementUsage(supabase, channel.store_id, { messages_out: 1, agent_invocations: 1 }).catch(() => { });
1266
+ }
1267
+ }
1268
+ catch (err) {
1269
+ log.error({ err: err.message }, "webchat agent error");
1270
+ }
1271
+ }
1272
+ jsonResponse(res, 201, { success: true, message, agent_response: agentResponse, conversation_id: conversationId }, webchatCors);
1273
+ return;
1274
+ }
1275
+ // GET /webchat/channels/:id/history — load conversation history for widget
1276
+ const webchatHistoryMatch = pathname.match(/^\/webchat\/channels\/([a-f0-9-]+)\/history$/);
1277
+ if (webchatHistoryMatch && req.method === "GET") {
1278
+ const clientIp = req.headers["x-forwarded-for"]?.toString().split(",")[0]?.trim() || req.socket.remoteAddress || "unknown";
1279
+ if (sendIpRateLimit(res, clientIp, webchatCors))
1280
+ return;
1281
+ const channelId = webchatHistoryMatch[1];
1282
+ const params = url.searchParams;
1283
+ const senderId = params.get("sender_id") || "";
1284
+ const reqConvId = params.get("conversation_id") || "";
1285
+ if (!senderId) {
1286
+ jsonResponse(res, 400, { error: "sender_id query parameter required" }, webchatCors);
1287
+ return;
1288
+ }
1289
+ const supabase = getServiceClient();
1290
+ // Verify channel is webchat type
1291
+ const { data: wcChannel } = await supabase
1292
+ .from("channels")
1293
+ .select("id, type")
1294
+ .eq("id", channelId)
1295
+ .eq("type", "webchat")
1296
+ .single();
1297
+ if (!wcChannel) {
1298
+ jsonResponse(res, 404, { error: "Webchat channel not found" }, webchatCors);
1299
+ return;
1300
+ }
1301
+ // Get messages — by conversation_id if available, or by sender
1302
+ // P0 FIX: Always filter by sender_id to prevent cross-sender data leak
1303
+ let query = supabase
1304
+ .from("channel_messages")
1305
+ .select("id, direction, sender_id, sender_name, content, content_type, created_at")
1306
+ .eq("channel_id", channelId);
1307
+ // P0 FIX: Validate senderId against strict pattern to prevent PostgREST filter injection
1308
+ // Only allow alphanumeric characters, hyphens, and underscores (blocks .eq., .neq., dots, etc.)
1309
+ if (!/^[a-zA-Z0-9_-]+$/.test(senderId)) {
1310
+ jsonResponse(res, 400, { error: "Invalid sender_id format" }, webchatCors);
1311
+ return;
1312
+ }
1313
+ if (reqConvId) {
1314
+ // P0 FIX: Use parameterized .in() filter instead of string interpolation in .or()
1315
+ query = query.eq("conversation_id", reqConvId)
1316
+ .in("sender_id", [senderId, "agent"]);
1317
+ }
1318
+ else {
1319
+ query = query.in("sender_id", [senderId, "agent"]);
1320
+ }
1321
+ const { data: messages, error: histErr } = await query
1322
+ .order("created_at", { ascending: true })
1323
+ .limit(50);
1324
+ if (histErr) {
1325
+ jsonResponse(res, 500, { error: histErr.message }, webchatCors);
1326
+ return;
1327
+ }
1328
+ jsonResponse(res, 200, { success: true, messages: messages || [] }, webchatCors);
1329
+ return;
1330
+ }
1331
+ // GET /webchat/widget.js — serve compiled widget JavaScript
1332
+ if (pathname === "/webchat/widget.js" && req.method === "GET") {
1333
+ const fs = await import("node:fs");
1334
+ const path = await import("node:path");
1335
+ // Try multiple possible locations for the built widget file
1336
+ const possiblePaths = [
1337
+ path.join(import.meta.dirname || __dirname, "../../dist/webchat/widget.js"),
1338
+ path.join(import.meta.dirname || __dirname, "../../../dist/webchat/widget.js"),
1339
+ path.join(process.cwd(), "dist/webchat/widget.js"),
1340
+ ];
1341
+ let widgetJs = null;
1342
+ for (const p of possiblePaths) {
1343
+ try {
1344
+ widgetJs = fs.readFileSync(p, "utf-8");
1345
+ break;
1346
+ }
1347
+ catch { /* try next */ }
1348
+ }
1349
+ if (widgetJs) {
1350
+ res.writeHead(200, {
1351
+ "Content-Type": "application/javascript",
1352
+ "Cache-Control": "public, max-age=3600",
1353
+ ...webchatCors,
1354
+ });
1355
+ res.end(widgetJs);
1356
+ }
1357
+ else {
1358
+ res.writeHead(200, {
1359
+ "Content-Type": "application/javascript",
1360
+ ...webchatCors,
1361
+ });
1362
+ res.end('console.warn("[WhaleChat] Widget JS not built. Run: npx esbuild src/webchat/widget.ts --bundle --minify --outfile=dist/webchat/widget.js");');
1363
+ }
1364
+ return;
1365
+ }
1366
+ // ================================================================
1367
+ // Billing & Usage routes
1368
+ // ================================================================
1369
+ if (pathname.startsWith("/usage") || pathname.startsWith("/billing/")) {
1370
+ const authHeader = req.headers.authorization;
1371
+ const token = authHeader?.startsWith("Bearer ") ? authHeader.substring(7) : "";
1372
+ if (!token) {
1373
+ req.resume();
1374
+ jsonResponse(res, 401, { error: "Missing authorization" }, corsHeaders);
1375
+ return;
1376
+ }
1377
+ let rawBody;
1378
+ try {
1379
+ rawBody = await readBody(req);
1380
+ }
1381
+ catch {
1382
+ jsonResponse(res, 413, { error: "Request body too large" }, corsHeaders);
1383
+ return;
1384
+ }
1385
+ let billingBody = null;
1386
+ try {
1387
+ if (rawBody)
1388
+ billingBody = JSON.parse(rawBody);
1389
+ }
1390
+ catch {
1391
+ jsonResponse(res, 400, { error: "Invalid JSON" }, corsHeaders);
1392
+ return;
1393
+ }
1394
+ const supabase = getServiceClient();
1395
+ // Extract userId from JWT for checkout/portal endpoints
1396
+ let billingUserId;
1397
+ try {
1398
+ const payload = JSON.parse(Buffer.from(token.split(".")[1], "base64url").toString());
1399
+ billingUserId = payload.sub;
1400
+ }
1401
+ catch { /* not a JWT — service role key */ }
1402
+ const result = await handleBillingRoutes(pathname, req.method || "GET", billingBody, supabase, {
1403
+ userId: billingUserId,
1404
+ isServiceRole: safeCompare(token, SUPABASE_SERVICE_ROLE_KEY) || safeCompare(token, SERVICE_ROLE_JWT),
1405
+ });
1406
+ if (result) {
1407
+ jsonResponse(res, result.status, result.body, corsHeaders);
1408
+ }
1409
+ else {
1410
+ jsonResponse(res, 404, { error: `No route: ${req.method} ${pathname}` }, corsHeaders);
1411
+ }
1412
+ return;
1413
+ }
1414
+ // ================================================================
1415
+ // Node & Channel management routes (supports GET, POST, PUT, DELETE)
1416
+ // Must be before the method gate since GET /channels/:id/messages is valid
1417
+ // ================================================================
1418
+ if (pathname.startsWith("/nodes") || pathname.startsWith("/channels")) {
1419
+ const authHeader = req.headers.authorization;
1420
+ const token = authHeader?.startsWith("Bearer ") ? authHeader.substring(7) : "";
1421
+ if (!token) {
1422
+ req.resume();
1423
+ jsonResponse(res, 401, { error: "Missing authorization" }, corsHeaders);
1424
+ return;
1425
+ }
1426
+ let rawBody;
1427
+ try {
1428
+ rawBody = await readBody(req);
1429
+ }
1430
+ catch {
1431
+ jsonResponse(res, 413, { error: "Request body too large" }, corsHeaders);
1432
+ return;
1433
+ }
1434
+ let nodeBody = null;
1435
+ try {
1436
+ if (rawBody)
1437
+ nodeBody = JSON.parse(rawBody);
1438
+ }
1439
+ catch {
1440
+ jsonResponse(res, 400, { error: "Invalid JSON" }, corsHeaders);
1441
+ return;
1442
+ }
1443
+ const supabase = getServiceClient();
1444
+ let userId;
1445
+ const isServiceRole = safeCompare(token, SUPABASE_SERVICE_ROLE_KEY) || safeCompare(token, SERVICE_ROLE_JWT);
1446
+ if (!isServiceRole) {
1447
+ const { data: { user: authUser } } = await supabase.auth.getUser(token);
1448
+ if (authUser)
1449
+ userId = authUser.id;
1450
+ }
1451
+ const result = await handleNodeRoutes(pathname, req.method || "GET", nodeBody, supabase, {
1452
+ userId,
1453
+ isServiceRole,
1454
+ rawToken: token,
1455
+ }, url.searchParams);
1456
+ if (result) {
1457
+ jsonResponse(res, result.status, result.body, corsHeaders);
1458
+ }
1459
+ else {
1460
+ jsonResponse(res, 404, { error: `No route: ${req.method} ${pathname}` }, corsHeaders);
1461
+ }
1462
+ return;
1463
+ }
1464
+ if (req.method !== "POST" && req.method !== "DELETE" && req.method !== "PUT") {
1465
+ jsonResponse(res, 405, { error: "Method not allowed" }, corsHeaders);
1466
+ return;
1467
+ }
1468
+ try {
1469
+ // ================================================================
1470
+ // Phase 4.2: Resume conversation from checkpoint
1471
+ // POST /conversations/:id/resume
1472
+ // ================================================================
1473
+ const resumeMatch = pathname.match(/^\/conversations\/([a-f0-9-]+)\/resume$/);
1474
+ if (resumeMatch && req.method === "POST") {
1475
+ const convId = resumeMatch[1];
1476
+ const authHeader = req.headers.authorization;
1477
+ const token = authHeader?.startsWith("Bearer ") ? authHeader.substring(7) : "";
1478
+ const isInternal = safeCompare(token, FLY_INTERNAL_SECRET) || safeCompare(token, SUPABASE_SERVICE_ROLE_KEY) || safeCompare(token, SERVICE_ROLE_JWT);
1479
+ if (!isInternal && !token) {
1480
+ jsonResponse(res, 401, { error: "Missing authorization" }, corsHeaders);
1481
+ return;
1482
+ }
1483
+ const supabase = getServiceClient();
1484
+ if (!isInternal) {
1485
+ const { data: { user: authUser }, error: authError } = await supabase.auth.getUser(token);
1486
+ if (authError || !authUser) {
1487
+ jsonResponse(res, 401, { error: "Invalid or expired token" }, corsHeaders);
1488
+ return;
1489
+ }
1490
+ }
1491
+ // Drain the request body (even if we don't use it) to prevent Fly proxy stalls
1492
+ try {
1493
+ await readBody(req);
1494
+ }
1495
+ catch { /* body not needed */ }
1496
+ const checkpoint = await loadCheckpoint(supabase, convId);
1497
+ if (!checkpoint) {
1498
+ jsonResponse(res, 404, { error: "No checkpoint found for this conversation" }, corsHeaders);
1499
+ return;
1500
+ }
1501
+ // Parse messages back from serialized form
1502
+ let parsedMessages;
1503
+ try {
1504
+ parsedMessages = JSON.parse(checkpoint.messages);
1505
+ }
1506
+ catch {
1507
+ jsonResponse(res, 422, { error: "Checkpoint messages corrupted" }, corsHeaders);
1508
+ return;
1509
+ }
1510
+ jsonResponse(res, 200, {
1511
+ success: true,
1512
+ conversation_id: checkpoint.conversation_id,
1513
+ turn: checkpoint.turn,
1514
+ messages: parsedMessages,
1515
+ tokens_used: checkpoint.tokens_used,
1516
+ cost_so_far: checkpoint.cost_so_far,
1517
+ tools_used: checkpoint.tools_used,
1518
+ checkpointed_at: checkpoint.created_at,
1519
+ }, corsHeaders);
1520
+ return;
1521
+ }
1522
+ // ================================================================
1523
+ // Phase 2: Approval response endpoint
1524
+ // POST /approvals/:id/respond
1525
+ // ================================================================
1526
+ const approvalMatch = pathname.match(/^\/approvals\/([a-f0-9-]+)\/respond$/);
1527
+ if (approvalMatch) {
1528
+ const approvalId = approvalMatch[1];
1529
+ const authHeader = req.headers.authorization;
1530
+ const token = authHeader?.startsWith("Bearer ") ? authHeader.substring(7) : "";
1531
+ const isInternal = safeCompare(token, FLY_INTERNAL_SECRET) || safeCompare(token, SUPABASE_SERVICE_ROLE_KEY) || safeCompare(token, SERVICE_ROLE_JWT);
1532
+ if (!isInternal && !token) {
1533
+ jsonResponse(res, 401, { error: "Missing authorization" }, corsHeaders);
1534
+ return;
1535
+ }
1536
+ const supabase = getServiceClient();
1537
+ let userId = null;
1538
+ if (!isInternal) {
1539
+ const { data: { user: authUser }, error: authError } = await supabase.auth.getUser(token);
1540
+ if (authError || !authUser) {
1541
+ jsonResponse(res, 401, { error: "Invalid or expired token" }, corsHeaders);
1542
+ return;
1543
+ }
1544
+ userId = authUser.id;
1545
+ }
1546
+ let rawBody;
1547
+ try {
1548
+ rawBody = await readBody(req);
1549
+ }
1550
+ catch {
1551
+ jsonResponse(res, 413, { error: "Request body too large" }, corsHeaders);
1552
+ return;
1553
+ }
1554
+ let body;
1555
+ try {
1556
+ body = JSON.parse(rawBody);
1557
+ }
1558
+ catch {
1559
+ jsonResponse(res, 400, { error: "Invalid JSON" }, corsHeaders);
1560
+ return;
1561
+ }
1562
+ if (!body.status) {
1563
+ jsonResponse(res, 400, { error: "status required (approved/rejected)" }, corsHeaders);
1564
+ return;
1565
+ }
1566
+ // Get store_id from approval
1567
+ const { data: approval } = await supabase.from("workflow_approval_requests")
1568
+ .select("store_id, run_id").eq("id", approvalId).single();
1569
+ if (!approval) {
1570
+ jsonResponse(res, 404, { error: "Approval not found" }, corsHeaders);
1571
+ return;
1572
+ }
1573
+ // P1 FIX: Verify user has access to the approval's store
1574
+ if (!isInternal && userId) {
1575
+ const { data: approvalMembership } = await supabase.from("store_members")
1576
+ .select("id").eq("store_id", approval.store_id).eq("user_id", userId).single();
1577
+ if (!approvalMembership) {
1578
+ jsonResponse(res, 403, { error: "Not authorized to respond to this approval" }, corsHeaders);
1579
+ return;
1580
+ }
1581
+ }
1582
+ const { data: result, error } = await supabase.rpc("respond_to_approval", {
1583
+ p_approval_id: approvalId,
1584
+ p_store_id: approval.store_id,
1585
+ p_response: body.status,
1586
+ p_response_data: body.response_data || {},
1587
+ p_responded_by: userId || body.responded_by || null,
1588
+ });
1589
+ if (error) {
1590
+ jsonResponse(res, 500, { success: false, error: error.message }, corsHeaders);
1591
+ return;
1592
+ }
1593
+ // Inline resume — execute next step immediately
1594
+ if (result?.success && approval.run_id) {
1595
+ try {
1596
+ await executeInlineChain(supabase, approval.run_id);
1597
+ }
1598
+ catch (err) {
1599
+ log.error({ err: err.message }, "inline chain failed after approval");
1600
+ }
1601
+ }
1602
+ jsonResponse(res, result?.success ? 200 : 422, result, corsHeaders);
1603
+ return;
1604
+ }
1605
+ // ================================================================
1606
+ // Waitpoint completion — API endpoint
1607
+ // POST /waitpoints/:token/complete
1608
+ // ================================================================
1609
+ const waitpointMatch = pathname.match(/^\/waitpoints\/([a-f0-9-]+)\/complete$/);
1610
+ if (waitpointMatch) {
1611
+ const token = waitpointMatch[1];
1612
+ const authHeader = req.headers.authorization;
1613
+ const authToken = authHeader?.startsWith("Bearer ") ? authHeader.substring(7) : "";
1614
+ const isInternal = safeCompare(authToken, FLY_INTERNAL_SECRET) || safeCompare(authToken, SUPABASE_SERVICE_ROLE_KEY) || safeCompare(authToken, SERVICE_ROLE_JWT);
1615
+ if (!isInternal && !authToken) {
1616
+ jsonResponse(res, 401, { error: "Missing authorization" }, corsHeaders);
1617
+ return;
1618
+ }
1619
+ const supabase = getServiceClient();
1620
+ let rawBody;
1621
+ try {
1622
+ rawBody = await readBody(req);
1623
+ }
1624
+ catch {
1625
+ jsonResponse(res, 413, { error: "Request body too large" }, corsHeaders);
1626
+ return;
1627
+ }
1628
+ let body;
1629
+ try {
1630
+ body = JSON.parse(rawBody || "{}");
1631
+ }
1632
+ catch {
1633
+ jsonResponse(res, 400, { error: "Invalid JSON" }, corsHeaders);
1634
+ return;
1635
+ }
1636
+ // Find waitpoint
1637
+ const { data: wp } = await supabase.from("waitpoint_tokens")
1638
+ .select("id, run_id, step_run_id, store_id, expires_at, status")
1639
+ .eq("token", token).single();
1640
+ if (!wp) {
1641
+ jsonResponse(res, 404, { error: "Waitpoint token not found" }, corsHeaders);
1642
+ return;
1643
+ }
1644
+ if (wp.status === "completed") {
1645
+ jsonResponse(res, 409, { error: "Waitpoint already completed" }, corsHeaders);
1646
+ return;
1647
+ }
1648
+ if (new Date(wp.expires_at) < new Date()) {
1649
+ jsonResponse(res, 410, { error: "Waitpoint expired" }, corsHeaders);
1650
+ return;
1651
+ }
1652
+ // Complete it
1653
+ await supabase.from("waitpoint_tokens").update({
1654
+ status: "completed", completion_data: body.data || {}, completed_at: new Date().toISOString(),
1655
+ }).eq("id", wp.id);
1656
+ await supabase.from("workflow_step_runs").update({
1657
+ status: "pending", input: { waitpoint_completed: true, waitpoint_data: body.data || {} },
1658
+ }).eq("id", wp.step_run_id).eq("status", "waiting");
1659
+ // Inline resume
1660
+ try {
1661
+ await executeInlineChain(supabase, wp.run_id);
1662
+ }
1663
+ catch (err) {
1664
+ log.error({ err: err.message, runId: wp.run_id }, "inline chain failed after waitpoint");
1665
+ }
1666
+ jsonResponse(res, 200, { success: true, run_id: wp.run_id }, corsHeaders);
1667
+ return;
1668
+ }
1669
+ // ================================================================
1670
+ // Webhook ingestion — no auth required (uses HMAC verification)
1671
+ // POST /webhooks/:slug
1672
+ // ================================================================
1673
+ const webhookMatch = pathname.match(/^\/webhooks\/([a-zA-Z0-9_-]+)$/);
1674
+ if (webhookMatch) {
1675
+ const whClientIp = req.headers["x-forwarded-for"]?.toString().split(",")[0]?.trim() || req.socket.remoteAddress || "unknown";
1676
+ if (sendIpRateLimit(res, whClientIp, corsHeaders))
1677
+ return;
1678
+ const slug = webhookMatch[1];
1679
+ let rawBody;
1680
+ try {
1681
+ rawBody = await readBody(req);
1682
+ }
1683
+ catch {
1684
+ jsonResponse(res, 413, { error: "Request body too large" }, corsHeaders);
1685
+ return;
1686
+ }
1687
+ const supabase = getServiceClient();
1688
+ const headers = {};
1689
+ for (const [k, v] of Object.entries(req.headers)) {
1690
+ if (typeof v === "string")
1691
+ headers[k] = v;
1692
+ }
1693
+ const result = await handleWebhookIngestion(supabase, slug, rawBody, headers);
1694
+ // Phase 1: Inline execution for webhook-triggered workflows
1695
+ if (result.body.run_id && result.status === 200) {
1696
+ try {
1697
+ await executeInlineChain(supabase, result.body.run_id);
1698
+ }
1699
+ catch (err) {
1700
+ log.error({ err: err.message }, "webhook inline chain error");
1701
+ }
1702
+ }
1703
+ jsonResponse(res, result.status, result.body, corsHeaders);
1704
+ return;
1705
+ }
1706
+ // ================================================================
1707
+ // Fire event — service-role or internal auth
1708
+ // POST /events
1709
+ // ================================================================
1710
+ if (pathname === "/events") {
1711
+ const authHeader = req.headers.authorization;
1712
+ const token = authHeader?.startsWith("Bearer ") ? authHeader.substring(7) : "";
1713
+ const isInternal = safeCompare(token, FLY_INTERNAL_SECRET) || safeCompare(token, SUPABASE_SERVICE_ROLE_KEY) || safeCompare(token, SERVICE_ROLE_JWT);
1714
+ if (!isInternal && !token) {
1715
+ jsonResponse(res, 401, { error: "Missing authorization" }, corsHeaders);
1716
+ return;
1717
+ }
1718
+ const supabase = getServiceClient();
1719
+ // Verify user auth if not internal
1720
+ if (!isInternal) {
1721
+ const { data: { user: authUser }, error: authError } = await supabase.auth.getUser(token);
1722
+ if (authError || !authUser) {
1723
+ jsonResponse(res, 401, { error: "Invalid or expired token" }, corsHeaders);
1724
+ return;
1725
+ }
1726
+ }
1727
+ let rawBody;
1728
+ try {
1729
+ rawBody = await readBody(req);
1730
+ }
1731
+ catch {
1732
+ jsonResponse(res, 413, { error: "Request body too large" }, corsHeaders);
1733
+ return;
1734
+ }
1735
+ let body;
1736
+ try {
1737
+ body = JSON.parse(rawBody || "{}");
1738
+ }
1739
+ catch {
1740
+ jsonResponse(res, 400, { error: "Invalid JSON" }, corsHeaders);
1741
+ return;
1742
+ }
1743
+ if (!body.store_id || !body.event_type) {
1744
+ jsonResponse(res, 400, { error: "store_id and event_type required" }, corsHeaders);
1745
+ return;
1746
+ }
1747
+ // Idempotency: if client provides idempotency_key, check for duplicate
1748
+ if (body.idempotency_key) {
1749
+ const { data: existing } = await supabase.from("workflow_events")
1750
+ .select("id")
1751
+ .eq("idempotency_key", body.idempotency_key)
1752
+ .limit(1);
1753
+ if (existing && existing.length > 0) {
1754
+ jsonResponse(res, 200, { success: true, event_id: existing[0].id, deduplicated: true }, corsHeaders);
1755
+ return;
1756
+ }
1757
+ }
1758
+ const { data: eventId, error: fireErr } = await supabase.rpc("fire_event", {
1759
+ p_store_id: body.store_id,
1760
+ p_event_type: body.event_type,
1761
+ p_event_payload: body.payload || {},
1762
+ p_source: body.source || "api",
1763
+ });
1764
+ if (fireErr) {
1765
+ jsonResponse(res, 500, { success: false, error: fireErr.message }, corsHeaders);
1766
+ }
1767
+ else {
1768
+ jsonResponse(res, 200, { success: true, event_id: eventId }, corsHeaders);
1769
+ }
1770
+ return;
1771
+ }
1772
+ // ================================================================
1773
+ // Internal workflow processing — verified by internal secret
1774
+ // POST /workflows/process
1775
+ // ================================================================
1776
+ if (pathname === "/workflows/process") {
1777
+ const authHeader = req.headers.authorization;
1778
+ const token = authHeader?.startsWith("Bearer ") ? authHeader.substring(7) : "";
1779
+ if (!FLY_INTERNAL_SECRET || (!safeCompare(token, FLY_INTERNAL_SECRET) && !safeCompare(token, SUPABASE_SERVICE_ROLE_KEY) && !safeCompare(token, SERVICE_ROLE_JWT))) {
1780
+ jsonResponse(res, 401, { error: "Unauthorized" }, corsHeaders);
1781
+ return;
1782
+ }
1783
+ let rawBody;
1784
+ try {
1785
+ rawBody = await readBody(req);
1786
+ }
1787
+ catch {
1788
+ rawBody = "{}";
1789
+ }
1790
+ let body;
1791
+ try {
1792
+ body = JSON.parse(rawBody || "{}");
1793
+ }
1794
+ catch {
1795
+ jsonResponse(res, 400, { error: "Invalid JSON" }, corsHeaders);
1796
+ return;
1797
+ }
1798
+ const supabase = getServiceClient();
1799
+ const result = await processWorkflowSteps(supabase, body.batch_size || 10);
1800
+ jsonResponse(res, 200, { success: true, ...result }, corsHeaders);
1801
+ return;
1802
+ }
1803
+ // ================================================================
1804
+ // Start workflow run — service-role or user auth
1805
+ // POST /workflows/start
1806
+ // ================================================================
1807
+ if (pathname === "/workflows/start") {
1808
+ const authHeader = req.headers.authorization;
1809
+ const token = authHeader?.startsWith("Bearer ") ? authHeader.substring(7) : "";
1810
+ const isInternal = safeCompare(token, FLY_INTERNAL_SECRET) || safeCompare(token, SUPABASE_SERVICE_ROLE_KEY) || safeCompare(token, SERVICE_ROLE_JWT);
1811
+ if (!isInternal && !token) {
1812
+ jsonResponse(res, 401, { error: "Missing authorization" }, corsHeaders);
1813
+ return;
1814
+ }
1815
+ const supabase = getServiceClient();
1816
+ // Verify user auth if not internal
1817
+ if (!isInternal) {
1818
+ const { data: { user: authUser }, error: authError } = await supabase.auth.getUser(token);
1819
+ if (authError || !authUser) {
1820
+ jsonResponse(res, 401, { error: "Invalid or expired token" }, corsHeaders);
1821
+ return;
1822
+ }
1823
+ }
1824
+ let rawBody;
1825
+ try {
1826
+ rawBody = await readBody(req);
1827
+ }
1828
+ catch {
1829
+ jsonResponse(res, 413, { error: "Request body too large" }, corsHeaders);
1830
+ return;
1831
+ }
1832
+ let body;
1833
+ try {
1834
+ body = JSON.parse(rawBody);
1835
+ }
1836
+ catch {
1837
+ jsonResponse(res, 400, { error: "Invalid JSON" }, corsHeaders);
1838
+ return;
1839
+ }
1840
+ // P0 FIX: Verify the workflow belongs to the requesting store before starting a run
1841
+ if (body.workflow_id && body.store_id) {
1842
+ const { data: wfOwner, error: wfOwnerErr } = await supabase
1843
+ .from("workflows")
1844
+ .select("id")
1845
+ .eq("id", body.workflow_id)
1846
+ .eq("store_id", body.store_id)
1847
+ .single();
1848
+ if (wfOwnerErr || !wfOwner) {
1849
+ jsonResponse(res, 403, { error: "Workflow does not belong to this store" }, corsHeaders);
1850
+ return;
1851
+ }
1852
+ }
1853
+ const { data, error } = await supabase.rpc("start_workflow_run", {
1854
+ p_workflow_id: body.workflow_id,
1855
+ p_store_id: body.store_id,
1856
+ p_trigger_type: body.trigger_type || "api",
1857
+ p_trigger_payload: body.trigger_payload || {},
1858
+ p_idempotency_key: body.idempotency_key || null,
1859
+ });
1860
+ if (error) {
1861
+ jsonResponse(res, 500, { success: false, error: error.message }, corsHeaders);
1862
+ }
1863
+ else {
1864
+ // Phase 4: Set version_id if workflow has a published version
1865
+ // Phase 1: Inline execution for API-triggered workflows
1866
+ if (data?.success && data.run_id && !data.deduplicated) {
1867
+ try {
1868
+ const { data: wf } = await supabase.from("workflows")
1869
+ .select("published_version_id").eq("id", body.workflow_id).single();
1870
+ if (wf?.published_version_id) {
1871
+ await supabase.from("workflow_runs").update({ version_id: wf.published_version_id }).eq("id", data.run_id);
1872
+ }
1873
+ await executeInlineChain(supabase, data.run_id);
1874
+ }
1875
+ catch (err) {
1876
+ log.error({ err: err.message }, "start-inline chain error");
1877
+ }
1878
+ }
1879
+ jsonResponse(res, data?.success ? 200 : 422, data, corsHeaders);
1880
+ }
1881
+ return;
1882
+ }
1883
+ // ================================================================
1884
+ // Standard auth gate for all other POST routes
1885
+ // ================================================================
1886
+ log.info({ method: req.method, path: pathname, contentLength: req.headers["content-length"] || "?" }, "request");
1887
+ const authHeader = req.headers.authorization;
1888
+ if (!authHeader?.startsWith("Bearer ")) {
1889
+ req.resume(); // drain body to avoid Fly proxy stall
1890
+ jsonResponse(res, 401, { error: "Missing authorization" }, corsHeaders);
1891
+ return;
1892
+ }
1893
+ // Read body FIRST — before async auth calls (getUser, rate_limit).
1894
+ // Node.js request streams are paused until consumed. If we do async network
1895
+ // calls before reading, the TCP receive buffer fills up and Fly's proxy
1896
+ // stalls with "error writing a body to connection" on large requests.
1897
+ let rawBody;
1898
+ try {
1899
+ rawBody = await readBody(req);
1900
+ }
1901
+ catch {
1902
+ jsonResponse(res, 413, { error: "Request body too large (max 50MB)" }, corsHeaders);
1903
+ return;
1904
+ }
1905
+ const token = authHeader.substring(7);
1906
+ const supabase = getServiceClient();
1907
+ // Check service-role key
1908
+ let user = null;
1909
+ const isServiceRole = safeCompare(token, SUPABASE_SERVICE_ROLE_KEY) || safeCompare(token, SERVICE_ROLE_JWT);
1910
+ if (!isServiceRole) {
1911
+ try {
1912
+ const { data: { user: authUser }, error: authError, } = await supabase.auth.getUser(token);
1913
+ if (authError || !authUser) {
1914
+ jsonResponse(res, 401, { error: "Invalid or expired token" }, corsHeaders);
1915
+ return;
1916
+ }
1917
+ user = authUser;
1918
+ }
1919
+ catch (authErr) {
1920
+ log.error({ err: authErr.message }, "auth getUser failed");
1921
+ jsonResponse(res, 502, { error: "Auth service unavailable, please retry" }, corsHeaders);
1922
+ return;
1923
+ }
1924
+ }
1925
+ // Phase 7.2: In-memory token-bucket rate limiting (fast, before Supabase RPC)
1926
+ {
1927
+ const tier = isServiceRole ? "serviceRole" : user ? "authenticated" : "unauthenticated";
1928
+ const rateLimitKey = user ? `user:${user.id}` : `ip:${req.socket.remoteAddress || "unknown"}`;
1929
+ const memResult = rateLimiter.checkRequest(rateLimitKey, tier);
1930
+ if (!memResult.allowed) {
1931
+ res.writeHead(429, {
1932
+ "Retry-After": String(Math.ceil(memResult.retryAfterMs / 1000)),
1933
+ "X-RateLimit-Remaining": "0",
1934
+ "X-RateLimit-Bucket": memResult.bucket,
1935
+ "Content-Type": "application/json",
1936
+ ...corsHeaders,
1937
+ });
1938
+ res.end(JSON.stringify({ error: "Rate limit exceeded" }));
1939
+ return;
1940
+ }
1941
+ }
1942
+ // Persistent rate limiting via Supabase RPC (secondary check, skip for service-role) — 100 req/60s
1943
+ if (user) {
1944
+ const { data: rl } = await supabase.rpc("check_rate_limit", {
1945
+ p_user_id: user.id,
1946
+ p_window_seconds: 60,
1947
+ p_max_requests: 100,
1948
+ });
1949
+ if (rl?.[0] && !rl[0].allowed) {
1950
+ res.writeHead(429, {
1951
+ "Retry-After": String(rl[0].retry_after_seconds),
1952
+ "X-RateLimit-Remaining": "0",
1953
+ "Content-Type": "application/json",
1954
+ ...corsHeaders,
1955
+ });
1956
+ res.end(JSON.stringify({ error: "Rate limit exceeded" }));
1957
+ return;
1958
+ }
1959
+ }
1960
+ let body;
1961
+ try {
1962
+ body = JSON.parse(rawBody);
1963
+ }
1964
+ catch {
1965
+ jsonResponse(res, 400, { error: "Invalid JSON in request body" }, corsHeaders);
1966
+ return;
1967
+ }
1968
+ // Anthropic API proxy mode
1969
+ if (body.mode === "proxy") {
1970
+ await handleProxy(res, body, corsHeaders);
1971
+ return;
1972
+ }
1973
+ // Audio transcription mode (OpenAI Whisper)
1974
+ if (body.mode === "transcribe") {
1975
+ const { audio_base64, media_type, store_id } = body;
1976
+ if (!audio_base64 || !media_type) {
1977
+ jsonResponse(res, 400, { error: "audio_base64 and media_type required" }, corsHeaders);
1978
+ return;
1979
+ }
1980
+ const sid = store_id || body.storeId;
1981
+ if (!sid) {
1982
+ jsonResponse(res, 400, { error: "store_id required for transcription (needed to resolve OpenAI API key)" }, corsHeaders);
1983
+ return;
1984
+ }
1985
+ const result = await handleTranscribe(supabase, sid, audio_base64, media_type);
1986
+ jsonResponse(res, result.success ? 200 : 422, result, corsHeaders);
1987
+ return;
1988
+ }
1989
+ // Conversation compaction mode (Haiku summarization for non-Anthropic providers)
1990
+ if (body.mode === "compact") {
1991
+ const { messages: compactMessages, system_prompt } = body;
1992
+ if (!compactMessages || !Array.isArray(compactMessages) || !system_prompt) {
1993
+ jsonResponse(res, 400, { error: "messages (array) and system_prompt required" }, corsHeaders);
1994
+ return;
1995
+ }
1996
+ const anthropic = new Anthropic({ apiKey: ANTHROPIC_API_KEY });
1997
+ // Sanitize user-provided system prompt before passing to compaction
1998
+ const sanitizedCompactPrompt = sanitizeAndLog(system_prompt, "compactionEndpoint");
1999
+ const compactionResult = await generateCompaction({
2000
+ anthropic,
2001
+ messages: compactMessages,
2002
+ systemPrompt: sanitizedCompactPrompt,
2003
+ });
2004
+ jsonResponse(res, compactionResult.success ? 200 : 422, {
2005
+ success: compactionResult.success,
2006
+ compaction_content: compactionResult.content,
2007
+ error: compactionResult.error,
2008
+ }, corsHeaders);
2009
+ return;
2010
+ }
2011
+ // Direct tool execution mode
2012
+ if (body.mode === "tool") {
2013
+ const { tool_name, args, store_id, conversation_id, trace_id } = body;
2014
+ if (!tool_name) {
2015
+ jsonResponse(res, 400, { error: "tool_name required" }, corsHeaders);
2016
+ return;
2017
+ }
2018
+ // Phase 7.2: Per-tool rate limiting
2019
+ const toolUserId = user?.id || body.userId || "anon";
2020
+ const toolLimit = rateLimiter.checkToolLimit(toolUserId, tool_name);
2021
+ if (!toolLimit.allowed) {
2022
+ res.writeHead(429, {
2023
+ "Retry-After": String(Math.ceil(toolLimit.retryAfterMs / 1000)),
2024
+ "X-RateLimit-Remaining": "0",
2025
+ "X-RateLimit-Bucket": toolLimit.bucket,
2026
+ "Content-Type": "application/json",
2027
+ ...corsHeaders,
2028
+ });
2029
+ res.end(JSON.stringify({ error: `Tool rate limit exceeded for ${tool_name}` }));
2030
+ return;
2031
+ }
2032
+ // Load user tools if this is a user_tool__ prefixed call
2033
+ let utRows;
2034
+ if (tool_name.startsWith("user_tool__") && store_id) {
2035
+ const { rows } = await loadUserTools(supabase, store_id);
2036
+ utRows = rows;
2037
+ }
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);
2039
+ // Always 200 for tool results — success/failure is in the JSON body.
2040
+ // HTTP 500 causes MCP clients to throw before reading the error message.
2041
+ jsonResponse(res, 200, result, corsHeaders);
2042
+ return;
2043
+ }
2044
+ // Streaming tool execution mode (NDJSON) — for tools with live progress (kali, etc.)
2045
+ if (body.mode === "tool_stream") {
2046
+ const { tool_name, args, store_id } = body;
2047
+ if (!tool_name) {
2048
+ jsonResponse(res, 400, { error: "tool_name required" }, corsHeaders);
2049
+ return;
2050
+ }
2051
+ // Phase 7.2: Per-tool rate limiting (stream mode)
2052
+ const streamToolUserId = user?.id || body.userId || "anon";
2053
+ const streamToolLimit = rateLimiter.checkToolLimit(streamToolUserId, tool_name);
2054
+ if (!streamToolLimit.allowed) {
2055
+ res.writeHead(429, {
2056
+ "Retry-After": String(Math.ceil(streamToolLimit.retryAfterMs / 1000)),
2057
+ "X-RateLimit-Remaining": "0",
2058
+ "X-RateLimit-Bucket": streamToolLimit.bucket,
2059
+ "Content-Type": "application/json",
2060
+ ...corsHeaders,
2061
+ });
2062
+ res.end(JSON.stringify({ error: `Tool rate limit exceeded for ${tool_name}` }));
2063
+ return;
2064
+ }
2065
+ res.writeHead(200, {
2066
+ "Content-Type": "application/x-ndjson",
2067
+ "Transfer-Encoding": "chunked",
2068
+ ...corsHeaders,
2069
+ });
2070
+ const onToolProgress = (_name, progress) => {
2071
+ try {
2072
+ res.write(JSON.stringify({ type: "progress", progress }) + "\n");
2073
+ }
2074
+ catch { /* client disconnected */ }
2075
+ };
2076
+ 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);
2078
+ res.write(JSON.stringify({ type: "result", ...result }) + "\n");
2079
+ }
2080
+ catch (err) {
2081
+ res.write(JSON.stringify({ type: "result", success: false, error: sanitizeError(err) }) + "\n");
2082
+ }
2083
+ res.end();
2084
+ return;
2085
+ }
2086
+ // Agent chat mode (SSE)
2087
+ await handleAgentChat(req, res, supabase, body, user, isServiceRole, token, corsHeaders);
2088
+ }
2089
+ catch (err) {
2090
+ if (!res.headersSent) {
2091
+ jsonResponse(res, 500, { error: sanitizeError(err) }, corsHeaders);
2092
+ }
2093
+ }
2094
+ });
2095
+ // Inject tool executor into workflow engine (avoids circular dependency)
2096
+ setToolExecutor((supabase, toolName, args, storeId, traceId) => {
2097
+ // Store boundary validation: prevent workflows from accessing other stores
2098
+ if (args.store_id && args.store_id !== storeId) {
2099
+ return Promise.resolve({ success: false, error: "Store boundary violation: workflow cannot access other stores" });
2100
+ }
2101
+ args.store_id = storeId; // Force the workflow's store
2102
+ return executeTool(supabase, toolName, args, storeId, traceId, null, null, "workflow_engine");
2103
+ });
2104
+ // Inject agent executor for "agent" step type in workflows
2105
+ setAgentExecutor(async (supabase, agentId, prompt, storeId, maxTurns = 5, onToken, traceId) => {
2106
+ const agent = await loadAgentConfig(supabase, agentId, storeId);
2107
+ if (!agent)
2108
+ return { success: false, error: `Agent ${agentId} not found` };
2109
+ // Workflow agents get all tools (no lazy loading — they run headless)
2110
+ const { all: allTools } = await loadTools(supabase);
2111
+ const { rows: userToolRows, defs: userToolDefs } = await loadUserTools(supabase, storeId);
2112
+ const tools = getToolsForAgent(agent, allTools, userToolDefs);
2113
+ const agentModel = agent.model || MODELS.SONNET;
2114
+ // Sanitize the DB-stored agent system prompt to prevent injection attacks
2115
+ const rawWorkflowPrompt = agent.system_prompt || "You are a helpful assistant.";
2116
+ let systemPrompt = sanitizeAndLog(rawWorkflowPrompt, "workflowAgentExecutor", { agentId });
2117
+ systemPrompt += `\n\nYou are operating for store_id: ${storeId}. Always include this in tool calls that require it.`;
2118
+ if (!agent.can_modify)
2119
+ systemPrompt += "\n\nIMPORTANT: You have read-only access.";
2120
+ if (agent.tone && agent.tone !== "professional")
2121
+ systemPrompt += `\n\nTone: ${agent.tone}`;
2122
+ if (agent.verbosity === "concise")
2123
+ systemPrompt += "\n\nBe concise.";
2124
+ try {
2125
+ const result = await runServerAgentLoop({
2126
+ anthropic: getAnthropicClient(agent),
2127
+ supabase,
2128
+ model: agentModel,
2129
+ systemPrompt,
2130
+ messages: [{ role: "user", content: prompt }],
2131
+ tools,
2132
+ maxTurns,
2133
+ temperature: agent.temperature ?? 0.7,
2134
+ storeId,
2135
+ source: "workflow_agent",
2136
+ agentId,
2137
+ traceId,
2138
+ executeTool: async (toolName, args) => {
2139
+ const toolArgs = { ...args };
2140
+ if (!toolArgs.store_id)
2141
+ toolArgs.store_id = storeId;
2142
+ return executeTool(supabase, toolName, toolArgs, storeId, traceId, null, null, "workflow_agent", undefined, userToolRows, agentId, undefined, true);
2143
+ },
2144
+ enableStreaming: !!onToken,
2145
+ onText: onToken || undefined,
2146
+ maxDurationMs: 2 * 60 * 1000,
2147
+ });
2148
+ return { success: true, response: result.finalText || "(no response)" };
2149
+ }
2150
+ catch (err) {
2151
+ return { success: false, error: sanitizeError(err) };
2152
+ }
2153
+ });
2154
+ // Inject token broadcaster for real-time agent step streaming
2155
+ setTokenBroadcaster((runId, stepKey, token) => {
2156
+ broadcastToRun(runId, { type: "agent_token", run_id: runId, step_key: stepKey, token });
2157
+ });
2158
+ // Inject step error broadcaster for real-time workflow error surfacing
2159
+ setStepErrorBroadcaster((runId, data) => {
2160
+ broadcastToRun(runId, data);
2161
+ });
2162
+ // Shared agent invoker — used by channel messages, webchat, and any source
2163
+ // Uses shared helpers so it's IDENTICAL to CLI chat — same prompt, tools, memory, persistence, audit
2164
+ async function invokeAgentForChannel(supabase, agentId, message, storeId, conversationId, senderContext) {
2165
+ const agent = await loadAgentConfig(supabase, agentId, storeId);
2166
+ if (!agent)
2167
+ return { success: false, error: `Agent ${agentId} not found` };
2168
+ const { core: coreTools, extended: extendedTools } = await loadTools(supabase);
2169
+ setExtendedToolsCache(extendedTools);
2170
+ const { rows: userToolRows, defs: userToolDefs } = await loadUserTools(supabase, storeId);
2171
+ const tools = getToolsForAgent(agent, coreTools, userToolDefs);
2172
+ const agentModel = agent.model || MODELS.SONNET;
2173
+ // Build system prompt — shared helper, identical to SSE chat
2174
+ const { systemPrompt, dynamicContext } = await buildAgentSystemPrompt(supabase, agent, storeId, message, tools, { senderContext, extendedTools: getExtendedToolsIndex() });
2175
+ // Update ai_conversations with agent_id (fire-and-forget)
2176
+ if (conversationId) {
2177
+ Promise.resolve(supabase.from("ai_conversations").update({ agent_id: agentId }).eq("id", conversationId).is("agent_id", null)).catch(() => { });
2178
+ }
2179
+ // Load conversation history with size-based compaction (same as SSE chat)
2180
+ const ctxCfg = agent.context_config;
2181
+ const MAX_HISTORY_CHARS = ctxCfg?.max_history_chars || 400_000;
2182
+ let loadedHistory = [];
2183
+ if (conversationId) {
2184
+ try {
2185
+ const { data: history } = await supabase
2186
+ .from("ai_messages")
2187
+ .select("role, content")
2188
+ .eq("conversation_id", conversationId)
2189
+ .order("created_at", { ascending: true })
2190
+ .limit(50);
2191
+ if (history?.length) {
2192
+ const raw = [];
2193
+ for (const m of history) {
2194
+ if (m.role === "user" || m.role === "assistant") {
2195
+ raw.push({ role: m.role, content: m.content });
2196
+ }
2197
+ }
2198
+ loadedHistory = compactHistory(raw, MAX_HISTORY_CHARS);
2199
+ }
2200
+ }
2201
+ catch { /* history not critical */ }
2202
+ }
2203
+ // Prepend dynamic context to user message to keep system prompt static (cache-friendly)
2204
+ const contextPrefix = dynamicContext ? `[Context]\n${dynamicContext}\n\n[User Message]\n` : "";
2205
+ const finalMessage = contextPrefix + message;
2206
+ const messages = [...loadedHistory, { role: "user", content: finalMessage }];
2207
+ const traceId = randomUUID();
2208
+ const chatStartTime = Date.now();
2209
+ try {
2210
+ const result = await runServerAgentLoop({
2211
+ anthropic: getAnthropicClient(agent),
2212
+ supabase,
2213
+ model: agentModel,
2214
+ systemPrompt,
2215
+ messages,
2216
+ tools,
2217
+ extendedTools,
2218
+ maxTurns: Math.min(agent.max_tool_calls || 10, 15),
2219
+ temperature: agent.temperature ?? 0.7,
2220
+ storeId,
2221
+ source: "channel_agent",
2222
+ agentId,
2223
+ traceId,
2224
+ conversationId,
2225
+ executeTool: async (toolName, args) => {
2226
+ const toolArgs = { ...args };
2227
+ if (!toolArgs.store_id)
2228
+ toolArgs.store_id = storeId;
2229
+ // Pass sender context as user identity so node-triggered tool calls aren't anonymous
2230
+ const senderUserId = senderContext?.customerId || null;
2231
+ const senderUserLabel = senderContext?.customerName || senderContext?.senderName
2232
+ || (senderContext?.senderId ? `${senderContext.channelType}:${senderContext.senderId}` : null);
2233
+ return executeTool(supabase, toolName, toolArgs, storeId, traceId, senderUserId, senderUserLabel, "channel_agent", conversationId, userToolRows, agentId, undefined, true);
2234
+ },
2235
+ enableStreaming: false,
2236
+ maxDurationMs: 2 * 60 * 1000,
2237
+ });
2238
+ // Persist everything — shared helper, identical to SSE chat
2239
+ await persistAgentTurn(supabase, agent, {
2240
+ conversationId, storeId, agentId, agentModel, traceId, message, result,
2241
+ source: "channel_agent",
2242
+ chatStartTime, chatEndTime: Date.now(),
2243
+ userId: senderContext?.customerId || undefined,
2244
+ userEmail: senderContext?.customerName || senderContext?.senderName
2245
+ || (senderContext?.senderId ? `${senderContext.channelType}:${senderContext.senderId}` : undefined),
2246
+ senderContext,
2247
+ });
2248
+ return { success: true, response: result.finalText || "(no response)" };
2249
+ }
2250
+ catch (err) {
2251
+ return { success: false, error: err.message };
2252
+ }
2253
+ }
2254
+ // Wire both channel and webchat to the same invoker
2255
+ setNodeAgentInvoker(invokeAgentForChannel);
2256
+ webchatAgentInvoker = invokeAgentForChannel;
2257
+ // ============================================================================
2258
+ // PERSISTENT WORKFLOW WORKER LOOP (5-second interval)
2259
+ // ============================================================================
2260
+ // Phase 3.1: Increased from 5s to 15s — NOTIFY-driven execution handles the fast path
2261
+ // Worker loop is now a safety net for missed notifications
2262
+ const BASE_WORKER_INTERVAL_MS = 15_000;
2263
+ const MAX_WORKER_INTERVAL_MS = 60_000;
2264
+ let workerRunning = false;
2265
+ let consecutiveErrors = 0;
2266
+ let currentWorkerInterval = BASE_WORKER_INTERVAL_MS;
2267
+ async function workflowWorkerLoop() {
2268
+ if (workerRunning)
2269
+ return; // Prevent concurrent runs
2270
+ workerRunning = true;
2271
+ try {
2272
+ const supabase = getServiceClient();
2273
+ const [stepResult, waitingResolved] = await Promise.all([
2274
+ processWorkflowSteps(supabase, 10),
2275
+ processWaitingSteps(supabase),
2276
+ Promise.resolve(supabase.rpc("expire_pending_waitpoints")).then(() => { }).catch(e => log.warn({ err: e.message }, "expire_pending_waitpoints failed")), // Non-fatal
2277
+ ]);
2278
+ // Schedule triggers + timeout enforcement + event triggers + orphan cleanup + DLQ retries
2279
+ const [scheduled, timedOut, eventsProcessed, orphansCleaned, dlqRetried] = await Promise.all([
2280
+ processScheduleTriggers(supabase).catch(e => { log.warn({ err: e.message }, "processScheduleTriggers failed"); return 0; }),
2281
+ enforceWorkflowTimeouts(supabase).catch(e => { log.warn({ err: e.message }, "enforceWorkflowTimeouts failed"); return 0; }),
2282
+ processEventTriggers(supabase).catch(e => { log.warn({ err: e.message }, "processEventTriggers failed"); return 0; }),
2283
+ cleanupOrphanedSteps(supabase).catch(e => { log.warn({ err: e.message }, "cleanupOrphanedSteps failed"); return 0; }),
2284
+ processDlqRetries(supabase).catch(e => { log.warn({ err: e.message }, "processDlqRetries failed"); return 0; }),
2285
+ ]);
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");
2288
+ }
2289
+ // Reset backoff on success
2290
+ if (consecutiveErrors > 0) {
2291
+ consecutiveErrors = 0;
2292
+ if (currentWorkerInterval !== BASE_WORKER_INTERVAL_MS) {
2293
+ currentWorkerInterval = BASE_WORKER_INTERVAL_MS;
2294
+ clearInterval(workerInterval);
2295
+ workerInterval = setInterval(workflowWorkerLoop, currentWorkerInterval);
2296
+ log.info({ intervalMs: currentWorkerInterval }, "worker interval reset after recovery");
2297
+ }
2298
+ }
2299
+ }
2300
+ catch (err) {
2301
+ consecutiveErrors++;
2302
+ log.error({ err: sanitizeError(err), consecutiveErrors }, "worker error");
2303
+ // Exponential backoff: 5s → 10s → 20s → 40s → 60s (cap)
2304
+ if (consecutiveErrors >= 3) {
2305
+ const newInterval = Math.min(BASE_WORKER_INTERVAL_MS * Math.pow(2, consecutiveErrors - 2), MAX_WORKER_INTERVAL_MS);
2306
+ if (newInterval !== currentWorkerInterval) {
2307
+ currentWorkerInterval = newInterval;
2308
+ clearInterval(workerInterval);
2309
+ workerInterval = setInterval(workflowWorkerLoop, currentWorkerInterval);
2310
+ log.warn({ intervalMs: currentWorkerInterval, consecutiveErrors }, "worker interval increased due to repeated errors");
2311
+ }
2312
+ }
2313
+ }
2314
+ finally {
2315
+ workerRunning = false;
2316
+ }
2317
+ }
2318
+ let workerInterval = setInterval(workflowWorkerLoop, BASE_WORKER_INTERVAL_MS);
2319
+ // Initialize shared Supabase client with retry logic before server starts
2320
+ initSupabase(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY);
2321
+ server.listen(PORT, () => {
2322
+ log.info({ port: PORT, supabaseUrl: SUPABASE_URL, runtime: process.version, workerIntervalMs: BASE_WORKER_INTERVAL_MS }, "server listening");
2323
+ // Initialize code worker pool for fast code step execution
2324
+ try {
2325
+ initWorkerPool();
2326
+ const stats = getPoolStats();
2327
+ log.info({ workers: stats.total }, "code worker pool initialized");
2328
+ workerPoolReady = true;
2329
+ }
2330
+ catch (err) {
2331
+ log.error({ err: err.message }, "worker pool init failed");
2332
+ }
2333
+ // Initialize local agent WebSocket gateway
2334
+ try {
2335
+ initLocalAgentGateway(server);
2336
+ }
2337
+ catch (err) {
2338
+ log.error({ err: err.message }, "local-agent gateway init failed");
2339
+ }
2340
+ // Phase 3: Start pg LISTEN for real-time SSE streaming
2341
+ setupPgListen().catch((err) => {
2342
+ log.error({ err: err.message }, "pg-listen setup failed");
2343
+ });
2344
+ // Phase 4.2: Mark orphaned conversations as resumable
2345
+ const serverBootTime = new Date();
2346
+ markOrphaned(getServiceClient(), serverBootTime)
2347
+ .then((count) => {
2348
+ if (count > 0)
2349
+ log.info({ count }, "orphaned conversations marked resumable");
2350
+ })
2351
+ .catch((err) => {
2352
+ log.warn({ err: err.message }, "markOrphaned failed (table may not exist yet)");
2353
+ });
2354
+ });
2355
+ // ============================================================================
2356
+ // GRACEFUL SHUTDOWN
2357
+ // ============================================================================
2358
+ async function gracefulShutdown(signal) {
2359
+ log.info({ signal, activeRequests }, "received shutdown signal, shutting down gracefully");
2360
+ // 1. Stop accepting new connections
2361
+ server.close(() => {
2362
+ log.info("HTTP server closed");
2363
+ });
2364
+ // 2. Clear workflow worker intervals
2365
+ clearInterval(workerInterval);
2366
+ clearInterval(sseCleanupInterval);
2367
+ // 3. Close all SSE client connections
2368
+ for (const [, clients] of sseClients) {
2369
+ for (const res of clients) {
2370
+ try {
2371
+ res.end();
2372
+ }
2373
+ catch { /* client already disconnected — benign */ }
2374
+ }
2375
+ clients.clear();
2376
+ }
2377
+ sseClients.clear();
2378
+ // 3b. P1 FIX: Flush audit log buffer before shutdown (prevents data loss on crash)
2379
+ try {
2380
+ const sb = getServiceClient();
2381
+ await flushAuditLogs(sb);
2382
+ log.info("audit log buffer flushed");
2383
+ }
2384
+ catch (err) {
2385
+ log.error({ err: err.message }, "audit log flush error");
2386
+ }
2387
+ // 4. Shut down code worker pool
2388
+ try {
2389
+ shutdownPool();
2390
+ log.info("worker pool shut down");
2391
+ }
2392
+ catch (err) {
2393
+ log.error({ err: err.message }, "worker pool shutdown error");
2394
+ }
2395
+ // 4b. Shut down local agent gateway
2396
+ try {
2397
+ shutdownAgentGateway();
2398
+ }
2399
+ catch (err) {
2400
+ log.error({ err: err.message }, "agent gateway shutdown error");
2401
+ }
2402
+ // 4c. Shut down rate limiter
2403
+ rateLimiter.shutdown();
2404
+ // 5. Close pg LISTEN connection
2405
+ if (pgClient) {
2406
+ pgClient.end().catch(() => { });
2407
+ pgClient = null;
2408
+ log.info("pg LISTEN connection closed");
2409
+ }
2410
+ // 6. Wait for active requests to drain (up to 25 seconds)
2411
+ const drainStart = Date.now();
2412
+ const drainInterval = setInterval(() => {
2413
+ if (activeRequests <= 0 || Date.now() - drainStart > 25_000) {
2414
+ clearInterval(drainInterval);
2415
+ log.info({ activeRequests }, "drain complete, exiting");
2416
+ process.exit(activeRequests > 0 ? 1 : 0);
2417
+ }
2418
+ log.info({ activeRequests }, "waiting for active requests to drain...");
2419
+ }, 1000);
2420
+ // 7. Force exit after 30 seconds if graceful shutdown hangs (increased from 10s)
2421
+ setTimeout(() => {
2422
+ log.fatal({ activeRequests }, "graceful shutdown timed out after 30s, forcing exit");
2423
+ process.exit(1);
2424
+ }, 30_000).unref();
2425
+ }
2426
+ process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
2427
+ process.on("SIGINT", () => gracefulShutdown("SIGINT"));