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