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.
- package/README.md +95 -0
- package/bin/swag-agent.js +9 -0
- package/bin/swagmanager-mcp.js +321 -0
- package/dist/cli/app.d.ts +26 -0
- package/dist/cli/app.js +64 -0
- package/dist/cli/chat/AgentSelector.d.ts +14 -0
- package/dist/cli/chat/AgentSelector.js +14 -0
- package/dist/cli/chat/ChatApp.d.ts +9 -0
- package/dist/cli/chat/ChatApp.js +267 -0
- package/dist/cli/chat/ChatInput.d.ts +39 -0
- package/dist/cli/chat/ChatInput.js +509 -0
- package/dist/cli/chat/MarkdownText.d.ts +10 -0
- package/dist/cli/chat/MarkdownText.js +20 -0
- package/dist/cli/chat/MessageList.d.ts +37 -0
- package/dist/cli/chat/MessageList.js +80 -0
- package/dist/cli/chat/ModelSelector.d.ts +20 -0
- package/dist/cli/chat/ModelSelector.js +73 -0
- package/dist/cli/chat/RewindViewer.d.ts +26 -0
- package/dist/cli/chat/RewindViewer.js +185 -0
- package/dist/cli/chat/StoreSelector.d.ts +14 -0
- package/dist/cli/chat/StoreSelector.js +24 -0
- package/dist/cli/chat/StreamingText.d.ts +12 -0
- package/dist/cli/chat/StreamingText.js +12 -0
- package/dist/cli/chat/SubagentPanel.d.ts +45 -0
- package/dist/cli/chat/SubagentPanel.js +110 -0
- package/dist/cli/chat/TeamPanel.d.ts +21 -0
- package/dist/cli/chat/TeamPanel.js +42 -0
- package/dist/cli/chat/ToolIndicator.d.ts +25 -0
- package/dist/cli/chat/ToolIndicator.js +436 -0
- package/dist/cli/chat/hooks/useAgentLoop.d.ts +39 -0
- package/dist/cli/chat/hooks/useAgentLoop.js +382 -0
- package/dist/cli/chat/hooks/useSlashCommands.d.ts +37 -0
- package/dist/cli/chat/hooks/useSlashCommands.js +387 -0
- package/dist/cli/commands/config-cmd.d.ts +10 -0
- package/dist/cli/commands/config-cmd.js +99 -0
- package/dist/cli/commands/doctor.d.ts +14 -0
- package/dist/cli/commands/doctor.js +172 -0
- package/dist/cli/commands/init.d.ts +16 -0
- package/dist/cli/commands/init.js +278 -0
- package/dist/cli/commands/mcp.d.ts +12 -0
- package/dist/cli/commands/mcp.js +162 -0
- package/dist/cli/login/LoginApp.d.ts +7 -0
- package/dist/cli/login/LoginApp.js +157 -0
- package/dist/cli/print-mode.d.ts +31 -0
- package/dist/cli/print-mode.js +202 -0
- package/dist/cli/serve-mode.d.ts +37 -0
- package/dist/cli/serve-mode.js +636 -0
- package/dist/cli/services/agent-definitions.d.ts +25 -0
- package/dist/cli/services/agent-definitions.js +91 -0
- package/dist/cli/services/agent-events.d.ts +178 -0
- package/dist/cli/services/agent-events.js +175 -0
- package/dist/cli/services/agent-loop.d.ts +90 -0
- package/dist/cli/services/agent-loop.js +762 -0
- package/dist/cli/services/agent-worker-base.d.ts +97 -0
- package/dist/cli/services/agent-worker-base.js +220 -0
- package/dist/cli/services/auth-service.d.ts +30 -0
- package/dist/cli/services/auth-service.js +160 -0
- package/dist/cli/services/background-processes.d.ts +126 -0
- package/dist/cli/services/background-processes.js +318 -0
- package/dist/cli/services/browser-auth.d.ts +24 -0
- package/dist/cli/services/browser-auth.js +180 -0
- package/dist/cli/services/claude-md-loader.d.ts +16 -0
- package/dist/cli/services/claude-md-loader.js +58 -0
- package/dist/cli/services/config-store.d.ts +47 -0
- package/dist/cli/services/config-store.js +79 -0
- package/dist/cli/services/debug-log.d.ts +10 -0
- package/dist/cli/services/debug-log.js +52 -0
- package/dist/cli/services/error-logger.d.ts +58 -0
- package/dist/cli/services/error-logger.js +269 -0
- package/dist/cli/services/file-history.d.ts +21 -0
- package/dist/cli/services/file-history.js +83 -0
- package/dist/cli/services/format-server-response.d.ts +16 -0
- package/dist/cli/services/format-server-response.js +440 -0
- package/dist/cli/services/git-context.d.ts +11 -0
- package/dist/cli/services/git-context.js +66 -0
- package/dist/cli/services/hooks.d.ts +85 -0
- package/dist/cli/services/hooks.js +258 -0
- package/dist/cli/services/interactive-tools.d.ts +125 -0
- package/dist/cli/services/interactive-tools.js +260 -0
- package/dist/cli/services/keybinding-manager.d.ts +52 -0
- package/dist/cli/services/keybinding-manager.js +115 -0
- package/dist/cli/services/local-tools.d.ts +22 -0
- package/dist/cli/services/local-tools.js +697 -0
- package/dist/cli/services/lsp-manager.d.ts +18 -0
- package/dist/cli/services/lsp-manager.js +717 -0
- package/dist/cli/services/mcp-client.d.ts +48 -0
- package/dist/cli/services/mcp-client.js +157 -0
- package/dist/cli/services/memory-manager.d.ts +16 -0
- package/dist/cli/services/memory-manager.js +57 -0
- package/dist/cli/services/model-manager.d.ts +18 -0
- package/dist/cli/services/model-manager.js +71 -0
- package/dist/cli/services/model-router.d.ts +26 -0
- package/dist/cli/services/model-router.js +149 -0
- package/dist/cli/services/permission-modes.d.ts +13 -0
- package/dist/cli/services/permission-modes.js +43 -0
- package/dist/cli/services/rewind.d.ts +84 -0
- package/dist/cli/services/rewind.js +194 -0
- package/dist/cli/services/ripgrep.d.ts +28 -0
- package/dist/cli/services/ripgrep.js +138 -0
- package/dist/cli/services/sandbox.d.ts +29 -0
- package/dist/cli/services/sandbox.js +97 -0
- package/dist/cli/services/server-tools.d.ts +61 -0
- package/dist/cli/services/server-tools.js +543 -0
- package/dist/cli/services/session-persistence.d.ts +23 -0
- package/dist/cli/services/session-persistence.js +99 -0
- package/dist/cli/services/subagent-worker.d.ts +19 -0
- package/dist/cli/services/subagent-worker.js +41 -0
- package/dist/cli/services/subagent.d.ts +47 -0
- package/dist/cli/services/subagent.js +647 -0
- package/dist/cli/services/system-prompt.d.ts +7 -0
- package/dist/cli/services/system-prompt.js +198 -0
- package/dist/cli/services/team-lead.d.ts +73 -0
- package/dist/cli/services/team-lead.js +512 -0
- package/dist/cli/services/team-state.d.ts +77 -0
- package/dist/cli/services/team-state.js +398 -0
- package/dist/cli/services/teammate.d.ts +31 -0
- package/dist/cli/services/teammate.js +689 -0
- package/dist/cli/services/telemetry.d.ts +61 -0
- package/dist/cli/services/telemetry.js +209 -0
- package/dist/cli/services/tools/agent-tools.d.ts +14 -0
- package/dist/cli/services/tools/agent-tools.js +347 -0
- package/dist/cli/services/tools/file-ops.d.ts +15 -0
- package/dist/cli/services/tools/file-ops.js +487 -0
- package/dist/cli/services/tools/search-tools.d.ts +8 -0
- package/dist/cli/services/tools/search-tools.js +186 -0
- package/dist/cli/services/tools/shell-exec.d.ts +10 -0
- package/dist/cli/services/tools/shell-exec.js +168 -0
- package/dist/cli/services/tools/task-manager.d.ts +28 -0
- package/dist/cli/services/tools/task-manager.js +209 -0
- package/dist/cli/services/tools/web-tools.d.ts +11 -0
- package/dist/cli/services/tools/web-tools.js +395 -0
- package/dist/cli/setup/SetupApp.d.ts +9 -0
- package/dist/cli/setup/SetupApp.js +191 -0
- package/dist/cli/shared/MatrixIntro.d.ts +4 -0
- package/dist/cli/shared/MatrixIntro.js +83 -0
- package/dist/cli/shared/Theme.d.ts +74 -0
- package/dist/cli/shared/Theme.js +127 -0
- package/dist/cli/shared/WhaleBanner.d.ts +10 -0
- package/dist/cli/shared/WhaleBanner.js +12 -0
- package/dist/cli/shared/markdown.d.ts +21 -0
- package/dist/cli/shared/markdown.js +756 -0
- package/dist/cli/status/StatusApp.d.ts +4 -0
- package/dist/cli/status/StatusApp.js +105 -0
- package/dist/cli/stores/StoreApp.d.ts +7 -0
- package/dist/cli/stores/StoreApp.js +81 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +538 -0
- package/dist/local-agent/connection.d.ts +48 -0
- package/dist/local-agent/connection.js +332 -0
- package/dist/local-agent/discovery.d.ts +18 -0
- package/dist/local-agent/discovery.js +146 -0
- package/dist/local-agent/executor.d.ts +34 -0
- package/dist/local-agent/executor.js +241 -0
- package/dist/local-agent/index.d.ts +14 -0
- package/dist/local-agent/index.js +198 -0
- package/dist/node/adapters/base.d.ts +35 -0
- package/dist/node/adapters/base.js +10 -0
- package/dist/node/adapters/discord.d.ts +29 -0
- package/dist/node/adapters/discord.js +299 -0
- package/dist/node/adapters/email.d.ts +23 -0
- package/dist/node/adapters/email.js +218 -0
- package/dist/node/adapters/imessage.d.ts +17 -0
- package/dist/node/adapters/imessage.js +118 -0
- package/dist/node/adapters/slack.d.ts +26 -0
- package/dist/node/adapters/slack.js +259 -0
- package/dist/node/adapters/sms.d.ts +23 -0
- package/dist/node/adapters/sms.js +161 -0
- package/dist/node/adapters/telegram.d.ts +17 -0
- package/dist/node/adapters/telegram.js +101 -0
- package/dist/node/adapters/webchat.d.ts +27 -0
- package/dist/node/adapters/webchat.js +160 -0
- package/dist/node/adapters/whatsapp.d.ts +28 -0
- package/dist/node/adapters/whatsapp.js +230 -0
- package/dist/node/cli.d.ts +2 -0
- package/dist/node/cli.js +325 -0
- package/dist/node/config.d.ts +17 -0
- package/dist/node/config.js +31 -0
- package/dist/node/runtime.d.ts +50 -0
- package/dist/node/runtime.js +351 -0
- package/dist/server/handlers/__test-utils__/mock-supabase.d.ts +11 -0
- package/dist/server/handlers/__test-utils__/mock-supabase.js +393 -0
- package/dist/server/handlers/analytics.d.ts +17 -0
- package/dist/server/handlers/analytics.js +266 -0
- package/dist/server/handlers/api-keys.d.ts +6 -0
- package/dist/server/handlers/api-keys.js +221 -0
- package/dist/server/handlers/billing.d.ts +33 -0
- package/dist/server/handlers/billing.js +272 -0
- package/dist/server/handlers/browser.d.ts +10 -0
- package/dist/server/handlers/browser.js +517 -0
- package/dist/server/handlers/catalog.d.ts +99 -0
- package/dist/server/handlers/catalog.js +976 -0
- package/dist/server/handlers/comms.d.ts +254 -0
- package/dist/server/handlers/comms.js +588 -0
- package/dist/server/handlers/creations.d.ts +6 -0
- package/dist/server/handlers/creations.js +479 -0
- package/dist/server/handlers/crm.d.ts +89 -0
- package/dist/server/handlers/crm.js +538 -0
- package/dist/server/handlers/discovery.d.ts +6 -0
- package/dist/server/handlers/discovery.js +288 -0
- package/dist/server/handlers/embeddings.d.ts +92 -0
- package/dist/server/handlers/embeddings.js +197 -0
- package/dist/server/handlers/enrichment.d.ts +8 -0
- package/dist/server/handlers/enrichment.js +768 -0
- package/dist/server/handlers/image-gen.d.ts +6 -0
- package/dist/server/handlers/image-gen.js +409 -0
- package/dist/server/handlers/inventory.d.ts +319 -0
- package/dist/server/handlers/inventory.js +447 -0
- package/dist/server/handlers/kali.d.ts +10 -0
- package/dist/server/handlers/kali.js +210 -0
- package/dist/server/handlers/llm-providers.d.ts +6 -0
- package/dist/server/handlers/llm-providers.js +673 -0
- package/dist/server/handlers/local-agent.d.ts +6 -0
- package/dist/server/handlers/local-agent.js +118 -0
- package/dist/server/handlers/meta-ads.d.ts +111 -0
- package/dist/server/handlers/meta-ads.js +2279 -0
- package/dist/server/handlers/nodes.d.ts +33 -0
- package/dist/server/handlers/nodes.js +699 -0
- package/dist/server/handlers/operations.d.ts +138 -0
- package/dist/server/handlers/operations.js +131 -0
- package/dist/server/handlers/platform.d.ts +23 -0
- package/dist/server/handlers/platform.js +227 -0
- package/dist/server/handlers/supply-chain.d.ts +19 -0
- package/dist/server/handlers/supply-chain.js +327 -0
- package/dist/server/handlers/transcription.d.ts +17 -0
- package/dist/server/handlers/transcription.js +121 -0
- package/dist/server/handlers/video-gen.d.ts +6 -0
- package/dist/server/handlers/video-gen.js +466 -0
- package/dist/server/handlers/voice.d.ts +8 -0
- package/dist/server/handlers/voice.js +1146 -0
- package/dist/server/handlers/workflow-steps.d.ts +86 -0
- package/dist/server/handlers/workflow-steps.js +2349 -0
- package/dist/server/handlers/workflows.d.ts +7 -0
- package/dist/server/handlers/workflows.js +989 -0
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.js +2427 -0
- package/dist/server/lib/batch-client.d.ts +80 -0
- package/dist/server/lib/batch-client.js +467 -0
- package/dist/server/lib/code-worker-pool.d.ts +31 -0
- package/dist/server/lib/code-worker-pool.js +224 -0
- package/dist/server/lib/code-worker.d.ts +1 -0
- package/dist/server/lib/code-worker.js +188 -0
- package/dist/server/lib/compaction-service.d.ts +32 -0
- package/dist/server/lib/compaction-service.js +162 -0
- package/dist/server/lib/logger.d.ts +19 -0
- package/dist/server/lib/logger.js +46 -0
- package/dist/server/lib/otel.d.ts +38 -0
- package/dist/server/lib/otel.js +126 -0
- package/dist/server/lib/pg-rate-limiter.d.ts +21 -0
- package/dist/server/lib/pg-rate-limiter.js +86 -0
- package/dist/server/lib/prompt-sanitizer.d.ts +37 -0
- package/dist/server/lib/prompt-sanitizer.js +177 -0
- package/dist/server/lib/provider-capabilities.d.ts +85 -0
- package/dist/server/lib/provider-capabilities.js +190 -0
- package/dist/server/lib/provider-failover.d.ts +74 -0
- package/dist/server/lib/provider-failover.js +210 -0
- package/dist/server/lib/rate-limiter.d.ts +39 -0
- package/dist/server/lib/rate-limiter.js +147 -0
- package/dist/server/lib/server-agent-loop.d.ts +107 -0
- package/dist/server/lib/server-agent-loop.js +667 -0
- package/dist/server/lib/server-subagent.d.ts +78 -0
- package/dist/server/lib/server-subagent.js +203 -0
- package/dist/server/lib/session-checkpoint.d.ts +51 -0
- package/dist/server/lib/session-checkpoint.js +145 -0
- package/dist/server/lib/ssrf-guard.d.ts +13 -0
- package/dist/server/lib/ssrf-guard.js +240 -0
- package/dist/server/lib/supabase-client.d.ts +7 -0
- package/dist/server/lib/supabase-client.js +78 -0
- package/dist/server/lib/template-resolver.d.ts +31 -0
- package/dist/server/lib/template-resolver.js +215 -0
- package/dist/server/lib/utils.d.ts +16 -0
- package/dist/server/lib/utils.js +147 -0
- package/dist/server/local-agent-gateway.d.ts +82 -0
- package/dist/server/local-agent-gateway.js +426 -0
- package/dist/server/providers/anthropic.d.ts +20 -0
- package/dist/server/providers/anthropic.js +199 -0
- package/dist/server/providers/bedrock.d.ts +20 -0
- package/dist/server/providers/bedrock.js +194 -0
- package/dist/server/providers/gemini.d.ts +24 -0
- package/dist/server/providers/gemini.js +486 -0
- package/dist/server/providers/openai.d.ts +24 -0
- package/dist/server/providers/openai.js +522 -0
- package/dist/server/providers/registry.d.ts +32 -0
- package/dist/server/providers/registry.js +58 -0
- package/dist/server/providers/shared.d.ts +32 -0
- package/dist/server/providers/shared.js +124 -0
- package/dist/server/providers/types.d.ts +92 -0
- package/dist/server/providers/types.js +12 -0
- package/dist/server/proxy-handlers.d.ts +6 -0
- package/dist/server/proxy-handlers.js +89 -0
- package/dist/server/tool-router.d.ts +149 -0
- package/dist/server/tool-router.js +803 -0
- package/dist/server/validation.d.ts +24 -0
- package/dist/server/validation.js +301 -0
- package/dist/server/worker.d.ts +19 -0
- package/dist/server/worker.js +201 -0
- package/dist/setup.d.ts +8 -0
- package/dist/setup.js +181 -0
- package/dist/shared/agent-core.d.ts +157 -0
- package/dist/shared/agent-core.js +534 -0
- package/dist/shared/anthropic-types.d.ts +105 -0
- package/dist/shared/anthropic-types.js +7 -0
- package/dist/shared/api-client.d.ts +90 -0
- package/dist/shared/api-client.js +379 -0
- package/dist/shared/constants.d.ts +33 -0
- package/dist/shared/constants.js +80 -0
- package/dist/shared/sse-parser.d.ts +26 -0
- package/dist/shared/sse-parser.js +259 -0
- package/dist/shared/tool-dispatch.d.ts +52 -0
- package/dist/shared/tool-dispatch.js +191 -0
- package/dist/shared/types.d.ts +72 -0
- package/dist/shared/types.js +7 -0
- package/dist/updater.d.ts +25 -0
- package/dist/updater.js +140 -0
- package/dist/webchat/widget.d.ts +0 -0
- package/dist/webchat/widget.js +397 -0
- package/package.json +95 -0
- package/src/cli/services/builtin-skills/commit.md +19 -0
- package/src/cli/services/builtin-skills/review-pr.md +21 -0
- 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"));
|