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,447 @@
|
|
|
1
|
+
// server/handlers/inventory.ts — Inventory management handlers
|
|
2
|
+
export async function handleInventory(sb, args, storeId) {
|
|
3
|
+
const sid = storeId;
|
|
4
|
+
switch (args.action) {
|
|
5
|
+
case "adjust": {
|
|
6
|
+
const productId = args.product_id;
|
|
7
|
+
const locationId = args.location_id;
|
|
8
|
+
const adjustment = args.adjustment;
|
|
9
|
+
const reason = args.reason || "Manual adjustment";
|
|
10
|
+
const { data: product } = await sb.from("products").select("name, sku").eq("id", productId).eq("store_id", sid).single();
|
|
11
|
+
const { data: location } = await sb.from("locations").select("name").eq("id", locationId).eq("store_id", sid).single();
|
|
12
|
+
// Atomic RPC — row-locked, includes audit trail
|
|
13
|
+
const { data: result, error: rpcError } = await sb.rpc("atomic_inventory_adjust", {
|
|
14
|
+
p_store_id: sid, p_product_id: productId, p_location_id: locationId,
|
|
15
|
+
p_adjustment: adjustment, p_reason: reason,
|
|
16
|
+
});
|
|
17
|
+
if (rpcError)
|
|
18
|
+
return { success: false, error: rpcError.message };
|
|
19
|
+
const rpcResult = result;
|
|
20
|
+
if (!rpcResult.success)
|
|
21
|
+
return { success: false, error: rpcResult.error || "Adjust failed" };
|
|
22
|
+
const d = rpcResult.data || {};
|
|
23
|
+
const sign = adjustment >= 0 ? "+" : "";
|
|
24
|
+
return {
|
|
25
|
+
success: true,
|
|
26
|
+
data: {
|
|
27
|
+
intent: `Adjust inventory for ${product?.name || 'product'} at ${location?.name || 'location'}: ${sign}${adjustment} units`,
|
|
28
|
+
product: product ? { id: productId, name: product.name, sku: product.sku } : { id: productId },
|
|
29
|
+
location: location ? { id: locationId, name: location.name } : { id: locationId },
|
|
30
|
+
adjustment, reason,
|
|
31
|
+
before_state: { quantity: d.before },
|
|
32
|
+
after_state: { quantity: d.after },
|
|
33
|
+
change: { from: d.before, to: d.after, delta: adjustment }
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
case "set": {
|
|
38
|
+
const productId = args.product_id;
|
|
39
|
+
const locationId = args.location_id;
|
|
40
|
+
const newQty = args.quantity;
|
|
41
|
+
const { data: product } = await sb.from("products").select("name, sku").eq("id", productId).eq("store_id", sid).single();
|
|
42
|
+
const { data: location } = await sb.from("locations").select("name").eq("id", locationId).eq("store_id", sid).single();
|
|
43
|
+
// Atomic RPC — row-locked, includes audit trail
|
|
44
|
+
const { data: result, error: rpcError } = await sb.rpc("atomic_inventory_set", {
|
|
45
|
+
p_store_id: sid, p_product_id: productId, p_location_id: locationId,
|
|
46
|
+
p_quantity: newQty, p_reason: `Set to ${newQty}`,
|
|
47
|
+
});
|
|
48
|
+
if (rpcError)
|
|
49
|
+
return { success: false, error: rpcError.message };
|
|
50
|
+
const rpcResult = result;
|
|
51
|
+
if (!rpcResult.success)
|
|
52
|
+
return { success: false, error: rpcResult.error || "Set failed" };
|
|
53
|
+
const d = rpcResult.data || {};
|
|
54
|
+
const delta = d.delta || 0;
|
|
55
|
+
const sign = delta >= 0 ? "+" : "";
|
|
56
|
+
return {
|
|
57
|
+
success: true,
|
|
58
|
+
data: {
|
|
59
|
+
intent: `Set inventory for ${product?.name || 'product'} at ${location?.name || 'location'} to ${newQty} units`,
|
|
60
|
+
product: product ? { id: productId, name: product.name, sku: product.sku } : { id: productId },
|
|
61
|
+
location: location ? { id: locationId, name: location.name } : { id: locationId },
|
|
62
|
+
before_state: { quantity: d.before },
|
|
63
|
+
after_state: { quantity: d.after },
|
|
64
|
+
change: { from: d.before, to: d.after, delta, description: `${sign}${delta} units` }
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
case "transfer": {
|
|
69
|
+
// C3 FIX: Use atomic RPC with row-level locking instead of separate upserts
|
|
70
|
+
const qty = args.quantity;
|
|
71
|
+
const productId = args.product_id;
|
|
72
|
+
const fromLocationId = args.from_location_id;
|
|
73
|
+
const toLocationId = args.to_location_id;
|
|
74
|
+
const { data: product } = await sb.from("products").select("name, sku").eq("id", productId).eq("store_id", sid).single();
|
|
75
|
+
const { data: fromLocation } = await sb.from("locations").select("name").eq("id", fromLocationId).eq("store_id", sid).single();
|
|
76
|
+
const { data: toLocation } = await sb.from("locations").select("name").eq("id", toLocationId).eq("store_id", sid).single();
|
|
77
|
+
// Atomic transfer — row-locked, transactional, no race conditions
|
|
78
|
+
const { data: result, error: rpcError } = await sb.rpc("atomic_inventory_transfer", {
|
|
79
|
+
p_store_id: sid,
|
|
80
|
+
p_product_id: productId,
|
|
81
|
+
p_from_location_id: fromLocationId,
|
|
82
|
+
p_to_location_id: toLocationId,
|
|
83
|
+
p_quantity: qty,
|
|
84
|
+
});
|
|
85
|
+
if (rpcError) {
|
|
86
|
+
return { success: false, error: rpcError.message };
|
|
87
|
+
}
|
|
88
|
+
const rpcResult = result;
|
|
89
|
+
if (!rpcResult.success) {
|
|
90
|
+
return { success: false, error: rpcResult.error || "Transfer failed" };
|
|
91
|
+
}
|
|
92
|
+
const transferData = rpcResult.data || {};
|
|
93
|
+
return {
|
|
94
|
+
success: true,
|
|
95
|
+
data: {
|
|
96
|
+
intent: `Transfer ${qty} units of ${product?.name || 'product'} from ${fromLocation?.name || 'source'} to ${toLocation?.name || 'destination'}`,
|
|
97
|
+
product: product ? { id: productId, name: product.name, sku: product.sku } : { id: productId },
|
|
98
|
+
from_location: fromLocation ? { id: fromLocationId, name: fromLocation.name } : { id: fromLocationId },
|
|
99
|
+
to_location: toLocation ? { id: toLocationId, name: toLocation.name } : { id: toLocationId },
|
|
100
|
+
quantity_transferred: qty,
|
|
101
|
+
before_state: { from_quantity: transferData.source_before, to_quantity: transferData.dest_before, total: (transferData.source_before || 0) + (transferData.dest_before || 0) },
|
|
102
|
+
after_state: { from_quantity: transferData.source_after, to_quantity: transferData.dest_after, total: (transferData.source_after || 0) + (transferData.dest_after || 0) }
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
case "bulk_set": {
|
|
107
|
+
const items = args.items;
|
|
108
|
+
if (!items || !Array.isArray(items) || items.length === 0)
|
|
109
|
+
return { success: false, error: "items array is required" };
|
|
110
|
+
const results = [];
|
|
111
|
+
for (const item of items) {
|
|
112
|
+
const { data: result, error: rpcError } = await sb.rpc("atomic_inventory_set", {
|
|
113
|
+
p_store_id: sid, p_product_id: item.product_id, p_location_id: item.location_id,
|
|
114
|
+
p_quantity: item.quantity, p_reason: "Bulk set",
|
|
115
|
+
});
|
|
116
|
+
if (rpcError) {
|
|
117
|
+
results.push({ product_id: item.product_id, location_id: item.location_id, ok: "no", error: rpcError.message });
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
const r = result;
|
|
121
|
+
if (!r.success) {
|
|
122
|
+
results.push({ product_id: item.product_id, location_id: item.location_id, ok: "no", error: r.error });
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
results.push({ product_id: item.product_id, location_id: item.location_id, ok: "yes", before: r.data?.before, after: r.data?.after });
|
|
126
|
+
}
|
|
127
|
+
const succeeded = results.filter(r => r.ok === "yes").length;
|
|
128
|
+
return { success: true, data: { total: items.length, succeeded, failed: items.length - succeeded, results } };
|
|
129
|
+
}
|
|
130
|
+
case "bulk_adjust": {
|
|
131
|
+
const items = args.items;
|
|
132
|
+
if (!items || !Array.isArray(items) || items.length === 0)
|
|
133
|
+
return { success: false, error: "items array is required" };
|
|
134
|
+
const results = [];
|
|
135
|
+
for (const item of items) {
|
|
136
|
+
const { data: result, error: rpcError } = await sb.rpc("atomic_inventory_adjust", {
|
|
137
|
+
p_store_id: sid, p_product_id: item.product_id, p_location_id: item.location_id,
|
|
138
|
+
p_adjustment: item.adjustment, p_reason: "Bulk adjustment",
|
|
139
|
+
});
|
|
140
|
+
if (rpcError) {
|
|
141
|
+
results.push({ product_id: item.product_id, location_id: item.location_id, ok: "no", error: rpcError.message });
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
const r = result;
|
|
145
|
+
if (!r.success) {
|
|
146
|
+
results.push({ product_id: item.product_id, location_id: item.location_id, ok: "no", error: r.error });
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
results.push({ product_id: item.product_id, location_id: item.location_id, ok: "yes", before: r.data?.before, after: r.data?.after });
|
|
150
|
+
}
|
|
151
|
+
const succeeded = results.filter(r => r.ok === "yes").length;
|
|
152
|
+
return { success: true, data: { total: items.length, succeeded, failed: items.length - succeeded, results } };
|
|
153
|
+
}
|
|
154
|
+
case "bulk_clear": {
|
|
155
|
+
const locationId = args.location_id;
|
|
156
|
+
if (!locationId)
|
|
157
|
+
return { success: false, error: "location_id is required" };
|
|
158
|
+
// Fetch items with stock > 0, then use atomic_inventory_set for audit trail
|
|
159
|
+
const { data: items, error: fetchErr } = await sb.from("inventory")
|
|
160
|
+
.select("product_id, quantity").eq("store_id", sid).eq("location_id", locationId).gt("quantity", 0);
|
|
161
|
+
if (fetchErr)
|
|
162
|
+
return { success: false, error: fetchErr.message };
|
|
163
|
+
if (!items || items.length === 0)
|
|
164
|
+
return { success: true, data: { location_id: locationId, items_cleared: 0 } };
|
|
165
|
+
const results = [];
|
|
166
|
+
for (const item of items) {
|
|
167
|
+
const { data: result, error: rpcError } = await sb.rpc("atomic_inventory_set", {
|
|
168
|
+
p_store_id: sid, p_product_id: item.product_id, p_location_id: locationId,
|
|
169
|
+
p_quantity: 0, p_reason: "Bulk clear",
|
|
170
|
+
});
|
|
171
|
+
if (rpcError) {
|
|
172
|
+
results.push({ product_id: item.product_id, success: false, error: rpcError.message });
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
const r = result;
|
|
176
|
+
if (!r.success) {
|
|
177
|
+
results.push({ product_id: item.product_id, success: false, error: r.error });
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
results.push({ product_id: item.product_id, success: true, before: r.data?.before });
|
|
181
|
+
}
|
|
182
|
+
const succeeded = results.filter(r => r.success).length;
|
|
183
|
+
return { success: true, data: { location_id: locationId, items_cleared: succeeded, total: items.length, results } };
|
|
184
|
+
}
|
|
185
|
+
default:
|
|
186
|
+
return { success: false, error: `Unknown inventory action: ${args.action}` };
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
// Resolve location name/slug to UUID — accepts UUID passthrough, name, or slug
|
|
190
|
+
async function resolveLocationId(sb, sid, input) {
|
|
191
|
+
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(input)) {
|
|
192
|
+
const { data } = await sb.from("locations").select("id, name").eq("id", input).eq("store_id", sid).single();
|
|
193
|
+
return data;
|
|
194
|
+
}
|
|
195
|
+
const clean = input.replace(/-/g, " ");
|
|
196
|
+
const { data } = await sb.from("locations").select("id, name").eq("store_id", sid)
|
|
197
|
+
.or(`name.ilike.%${clean}%,slug.ilike.%${input}%`).limit(1);
|
|
198
|
+
return data?.[0] || null;
|
|
199
|
+
}
|
|
200
|
+
export async function handleInventoryQuery(sb, args, storeId) {
|
|
201
|
+
const sid = storeId;
|
|
202
|
+
switch (args.action) {
|
|
203
|
+
case "summary": {
|
|
204
|
+
const { data, error } = await sb.from("inventory")
|
|
205
|
+
.select("*, product:products(name, sku), location:locations(name)")
|
|
206
|
+
.eq("store_id", sid).limit(1000);
|
|
207
|
+
if (error)
|
|
208
|
+
return { success: false, error: error.message };
|
|
209
|
+
const byLocation = {};
|
|
210
|
+
for (const row of data || []) {
|
|
211
|
+
const locId = row.location_id;
|
|
212
|
+
if (!byLocation[locId])
|
|
213
|
+
byLocation[locId] = { location_id: locId, location_name: row.location?.name || locId, items: 0, total_qty: 0 };
|
|
214
|
+
byLocation[locId].items++;
|
|
215
|
+
byLocation[locId].total_qty += row.quantity || 0;
|
|
216
|
+
}
|
|
217
|
+
return { success: true, data: { total_items: data?.length || 0, by_location: Object.values(byLocation) } };
|
|
218
|
+
}
|
|
219
|
+
case "velocity": {
|
|
220
|
+
const days = args.days || 30;
|
|
221
|
+
const categoryId = args.category_id;
|
|
222
|
+
const productId = args.product_id;
|
|
223
|
+
const locationId = args.location_id;
|
|
224
|
+
const limit = args.limit || 50;
|
|
225
|
+
const { data, error } = await sb.rpc("get_product_velocity", {
|
|
226
|
+
p_store_id: sid, p_days: days, p_location_id: locationId || null,
|
|
227
|
+
p_category_id: categoryId || null, p_product_id: productId || null, p_limit: limit
|
|
228
|
+
});
|
|
229
|
+
if (error)
|
|
230
|
+
return { success: false, error: error.message };
|
|
231
|
+
const products = (data || []).map((row) => ({
|
|
232
|
+
productId: row.product_id, name: row.product_name, sku: row.product_sku,
|
|
233
|
+
category: row.category_name, locationId: row.location_id, locationName: row.location_name,
|
|
234
|
+
totalQty: row.units_sold, totalRevenue: row.revenue, orderCount: row.order_count,
|
|
235
|
+
velocityPerDay: row.daily_velocity, revenuePerDay: row.daily_revenue,
|
|
236
|
+
currentStock: row.current_stock, daysOfStock: row.days_of_stock,
|
|
237
|
+
avgPrice: row.avg_unit_price, stockAlert: row.stock_status
|
|
238
|
+
}));
|
|
239
|
+
return { success: true, data: { days, filters: { categoryId, locationId, productId }, products } };
|
|
240
|
+
}
|
|
241
|
+
case "by_location": {
|
|
242
|
+
let q = sb.from("inventory").select("product_id, location_id, quantity, product:products(name, sku, category:categories!primary_category_id(name)), location:locations(name)")
|
|
243
|
+
.eq("store_id", sid);
|
|
244
|
+
if (args.location_id || args.location) {
|
|
245
|
+
const locInput = (args.location_id || args.location);
|
|
246
|
+
const loc = await resolveLocationId(sb, sid, locInput);
|
|
247
|
+
if (!loc)
|
|
248
|
+
return { success: false, error: `Location not found: "${locInput}". Use locations tool to list available locations.` };
|
|
249
|
+
q = q.eq("location_id", loc.id);
|
|
250
|
+
}
|
|
251
|
+
if (args.limit)
|
|
252
|
+
q = q.limit(args.limit);
|
|
253
|
+
const { data, error } = await q.order("quantity", { ascending: false });
|
|
254
|
+
if (error)
|
|
255
|
+
return { success: false, error: error.message };
|
|
256
|
+
const flattened = (data || []).map((row) => ({
|
|
257
|
+
product_id: row.product_id,
|
|
258
|
+
product_name: row.product?.name || "—",
|
|
259
|
+
product_sku: row.product?.sku || "—",
|
|
260
|
+
category: row.product?.category?.name || null,
|
|
261
|
+
location_id: row.location_id,
|
|
262
|
+
location_name: row.location?.name || "—",
|
|
263
|
+
quantity: row.quantity,
|
|
264
|
+
}));
|
|
265
|
+
return { success: true, count: flattened.length, data: flattened };
|
|
266
|
+
}
|
|
267
|
+
case "in_stock": {
|
|
268
|
+
let inStockQ = sb.from("inventory")
|
|
269
|
+
.select("product_id, location_id, quantity, product:products(name, sku, category:categories!primary_category_id(name)), location:locations(name)")
|
|
270
|
+
.eq("store_id", sid).gt("quantity", 0);
|
|
271
|
+
if (args.location_id || args.location) {
|
|
272
|
+
const locInput = (args.location_id || args.location);
|
|
273
|
+
const loc = await resolveLocationId(sb, sid, locInput);
|
|
274
|
+
if (!loc)
|
|
275
|
+
return { success: false, error: `Location not found: "${locInput}". Use locations tool to list available locations.` };
|
|
276
|
+
inStockQ = inStockQ.eq("location_id", loc.id);
|
|
277
|
+
}
|
|
278
|
+
if (args.limit)
|
|
279
|
+
inStockQ = inStockQ.limit(args.limit);
|
|
280
|
+
const { data, error } = await inStockQ.order("quantity", { ascending: false });
|
|
281
|
+
if (error)
|
|
282
|
+
return { success: false, error: error.message };
|
|
283
|
+
const flattened = (data || []).map((row) => ({
|
|
284
|
+
product_id: row.product_id,
|
|
285
|
+
product_name: row.product?.name || "—",
|
|
286
|
+
product_sku: row.product?.sku || "—",
|
|
287
|
+
category: row.product?.category?.name || null,
|
|
288
|
+
location_id: row.location_id,
|
|
289
|
+
location_name: row.location?.name || "—",
|
|
290
|
+
quantity: row.quantity,
|
|
291
|
+
}));
|
|
292
|
+
return { success: true, count: flattened.length, data: flattened };
|
|
293
|
+
}
|
|
294
|
+
case "by_category": {
|
|
295
|
+
// Pre-grouped inventory by category — compact summary the LLM can use directly
|
|
296
|
+
let catQ = sb.from("inventory")
|
|
297
|
+
.select("product_id, location_id, quantity, product:products!inner(name, sku, status, primary_category_id, category:categories!primary_category_id(name, parent_id)), location:locations(name)")
|
|
298
|
+
.eq("store_id", sid).gt("quantity", 0)
|
|
299
|
+
.eq("product.status", "published");
|
|
300
|
+
// Optional location filter
|
|
301
|
+
let locationName = null;
|
|
302
|
+
if (args.location_id || args.location) {
|
|
303
|
+
const locInput = (args.location_id || args.location);
|
|
304
|
+
const loc = await resolveLocationId(sb, sid, locInput);
|
|
305
|
+
if (!loc)
|
|
306
|
+
return { success: false, error: `Location not found: "${locInput}". Use locations tool to list available locations.` };
|
|
307
|
+
catQ = catQ.eq("location_id", loc.id);
|
|
308
|
+
locationName = loc.name;
|
|
309
|
+
}
|
|
310
|
+
const { data, error } = await catQ;
|
|
311
|
+
// Optional category filter — includes sub-categories
|
|
312
|
+
let categoryFilter = null;
|
|
313
|
+
if (args.category) {
|
|
314
|
+
const catInput = args.category;
|
|
315
|
+
const isUuid = /^[0-9a-f]{8}-/i.test(catInput);
|
|
316
|
+
let parentId = null;
|
|
317
|
+
if (isUuid) {
|
|
318
|
+
parentId = catInput;
|
|
319
|
+
}
|
|
320
|
+
else {
|
|
321
|
+
const { data: cats } = await sb.from("categories").select("id").ilike("name", `%${catInput}%`).eq("store_id", sid).limit(1);
|
|
322
|
+
if (cats?.length)
|
|
323
|
+
parentId = cats[0].id;
|
|
324
|
+
}
|
|
325
|
+
if (parentId) {
|
|
326
|
+
const { data: children } = await sb.from("categories").select("id").eq("parent_id", parentId).eq("store_id", sid);
|
|
327
|
+
categoryFilter = new Set([parentId, ...(children || []).map(c => c.id)]);
|
|
328
|
+
}
|
|
329
|
+
else {
|
|
330
|
+
// Category name didn't match anything — filter out everything
|
|
331
|
+
categoryFilter = new Set();
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
if (error)
|
|
335
|
+
return { success: false, error: error.message };
|
|
336
|
+
// Group by category
|
|
337
|
+
const catMap = {};
|
|
338
|
+
for (const row of data || []) {
|
|
339
|
+
// Apply category filter if set
|
|
340
|
+
if (categoryFilter) {
|
|
341
|
+
const prodCatId = row.product?.primary_category_id;
|
|
342
|
+
if (!prodCatId || !categoryFilter.has(prodCatId))
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
const cat = row.product?.category?.name || "Uncategorized";
|
|
346
|
+
const pName = row.product?.name || "—";
|
|
347
|
+
const pSku = row.product?.sku || "—";
|
|
348
|
+
const locName = row.location?.name || "—";
|
|
349
|
+
const qty = row.quantity || 0;
|
|
350
|
+
if (!catMap[cat])
|
|
351
|
+
catMap[cat] = { products: {}, total_qty: 0 };
|
|
352
|
+
catMap[cat].total_qty += qty;
|
|
353
|
+
const pKey = row.product_id;
|
|
354
|
+
if (!catMap[cat].products[pKey])
|
|
355
|
+
catMap[cat].products[pKey] = { name: pName, sku: pSku, total_qty: 0, locations: [] };
|
|
356
|
+
catMap[cat].products[pKey].total_qty += qty;
|
|
357
|
+
if (!catMap[cat].products[pKey].locations.includes(locName))
|
|
358
|
+
catMap[cat].products[pKey].locations.push(locName);
|
|
359
|
+
}
|
|
360
|
+
// Format as pre-rendered markdown — the CLI formatter drops nested arrays,
|
|
361
|
+
// so we render products-per-category here to prevent the agent from losing
|
|
362
|
+
// the product→category mapping and reconstructing it incorrectly.
|
|
363
|
+
const categories = Object.entries(catMap)
|
|
364
|
+
.sort((a, b) => b[1].total_qty - a[1].total_qty)
|
|
365
|
+
.map(([cat, info]) => ({
|
|
366
|
+
category: cat,
|
|
367
|
+
total_qty: Math.round(info.total_qty * 100) / 100,
|
|
368
|
+
product_count: Object.keys(info.products).length,
|
|
369
|
+
products: Object.values(info.products)
|
|
370
|
+
.sort((a, b) => b.total_qty - a.total_qty)
|
|
371
|
+
.map(p => ({ name: p.name, sku: p.sku, qty: Math.round(p.total_qty * 100) / 100 })),
|
|
372
|
+
}));
|
|
373
|
+
const totalProducts = categories.reduce((s, c) => s + c.product_count, 0);
|
|
374
|
+
const header = locationName ? `Inventory at ${locationName}` : "Inventory by Category";
|
|
375
|
+
const lines = [
|
|
376
|
+
`## ${header}${args.category ? ` — ${args.category}` : ""}`,
|
|
377
|
+
`${categories.length} categories, ${totalProducts} products in stock\n`,
|
|
378
|
+
];
|
|
379
|
+
for (const cat of categories) {
|
|
380
|
+
lines.push(`### ${cat.category} (${cat.product_count} products, ${cat.total_qty} units)`);
|
|
381
|
+
lines.push("| Product | SKU | Qty |");
|
|
382
|
+
lines.push("| --- | --- | ---: |");
|
|
383
|
+
for (const p of cat.products) {
|
|
384
|
+
lines.push(`| ${p.name} | ${p.sku} | ${p.qty} |`);
|
|
385
|
+
}
|
|
386
|
+
lines.push("");
|
|
387
|
+
}
|
|
388
|
+
lines.push("This is the COMPLETE inventory breakdown. Every in-stock product is listed under its correct category. Do NOT re-fetch individual categories or call in_stock — all data is here.");
|
|
389
|
+
return { success: true, data: lines.join("\n") };
|
|
390
|
+
}
|
|
391
|
+
case "out_of_stock": {
|
|
392
|
+
// Find all published products with zero or no inventory across all locations
|
|
393
|
+
const { data: products, error: pErr } = await sb.from("products")
|
|
394
|
+
.select("id, name, sku, category:categories!primary_category_id(name)").eq("store_id", sid).eq("status", "published");
|
|
395
|
+
if (pErr)
|
|
396
|
+
return { success: false, error: pErr.message };
|
|
397
|
+
const { data: inv } = await sb.from("inventory")
|
|
398
|
+
.select("product_id, quantity").eq("store_id", sid);
|
|
399
|
+
// Sum stock per product
|
|
400
|
+
const stockMap = {};
|
|
401
|
+
for (const row of inv || []) {
|
|
402
|
+
stockMap[row.product_id] = (stockMap[row.product_id] || 0) + (row.quantity || 0);
|
|
403
|
+
}
|
|
404
|
+
const outOfStock = (products || [])
|
|
405
|
+
.filter(p => !stockMap[p.id] || stockMap[p.id] <= 0)
|
|
406
|
+
.map(p => ({ product_id: p.id, name: p.name, sku: p.sku, category: p.category?.name || null, total_stock: stockMap[p.id] || 0 }));
|
|
407
|
+
return { success: true, count: outOfStock.length, total_products: products?.length || 0, data: outOfStock };
|
|
408
|
+
}
|
|
409
|
+
default:
|
|
410
|
+
return { success: false, error: `Unknown inventory_query action: ${args.action}` };
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
export async function handleInventoryAudit(sb, args, storeId) {
|
|
414
|
+
const sid = storeId;
|
|
415
|
+
switch (args.action) {
|
|
416
|
+
case "start": {
|
|
417
|
+
const { data, error } = await sb.from("inventory_audits")
|
|
418
|
+
.insert({ store_id: sid, location_id: args.location_id, status: "in_progress", started_at: new Date().toISOString() })
|
|
419
|
+
.select().single();
|
|
420
|
+
return error ? { success: false, error: error.message } : { success: true, data };
|
|
421
|
+
}
|
|
422
|
+
case "count": {
|
|
423
|
+
// Verify the parent audit belongs to this store before updating items
|
|
424
|
+
const { data: audit } = await sb.from("inventory_audits").select("id").eq("id", args.audit_id).eq("store_id", sid).single();
|
|
425
|
+
if (!audit)
|
|
426
|
+
return { success: false, error: "Audit not found" };
|
|
427
|
+
const { data, error } = await sb.from("inventory_audit_items")
|
|
428
|
+
.update({ counted_quantity: args.counted })
|
|
429
|
+
.eq("audit_id", args.audit_id).eq("product_id", args.product_id).select().single();
|
|
430
|
+
return error ? { success: false, error: error.message } : { success: true, data };
|
|
431
|
+
}
|
|
432
|
+
case "complete": {
|
|
433
|
+
const { data, error } = await sb.from("inventory_audits")
|
|
434
|
+
.update({ status: "completed", completed_at: new Date().toISOString() })
|
|
435
|
+
.eq("id", args.audit_id).eq("store_id", sid).select().single();
|
|
436
|
+
return error ? { success: false, error: error.message } : { success: true, data };
|
|
437
|
+
}
|
|
438
|
+
case "summary": {
|
|
439
|
+
const { data, error } = await sb.from("inventory_audits")
|
|
440
|
+
.select("*, items:inventory_audit_items(*)").eq("store_id", sid)
|
|
441
|
+
.order("created_at", { ascending: false }).limit(args.limit || 5);
|
|
442
|
+
return error ? { success: false, error: error.message } : { success: true, data };
|
|
443
|
+
}
|
|
444
|
+
default:
|
|
445
|
+
return { success: false, error: `Unknown inventory_audit action: ${args.action}` };
|
|
446
|
+
}
|
|
447
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { SupabaseClient } from "@supabase/supabase-js";
|
|
2
|
+
export interface KaliProgressEvent {
|
|
3
|
+
type: "stdout" | "stderr";
|
|
4
|
+
data: string;
|
|
5
|
+
}
|
|
6
|
+
export declare function handleKali(_sb: SupabaseClient, args: Record<string, unknown>, _storeId?: string, onToolProgress?: (progress: KaliProgressEvent) => void): Promise<{
|
|
7
|
+
success: boolean;
|
|
8
|
+
data?: unknown;
|
|
9
|
+
error?: string;
|
|
10
|
+
}>;
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// server/handlers/kali.ts — Kali Linux remote execution
|
|
2
|
+
// Proxies commands to a dedicated Fly.io Kali machine via internal network.
|
|
3
|
+
// Pattern: same as browser.ts — external execution environment, handler is a thin proxy.
|
|
4
|
+
// Supports NDJSON streaming for exec actions via exec_stream on kali-box.
|
|
5
|
+
const KALI_BOX_URL = process.env.KALI_BOX_URL || "http://kali-box.internal:8080";
|
|
6
|
+
const KALI_AUTH_TOKEN = process.env.KALI_AUTH_TOKEN || "";
|
|
7
|
+
const MAX_OUTPUT_CHARS = 500 * 1024; // 500KB safety cap — context_management handles limits
|
|
8
|
+
const VALID_ACTIONS = new Set([
|
|
9
|
+
"exec", "exec_stream", "exec_bg", "bg_status", "bg_kill", "bg_list",
|
|
10
|
+
"upload", "download", "info", "sessions", "reset",
|
|
11
|
+
]);
|
|
12
|
+
const FORWARD_KEYS = [
|
|
13
|
+
"command", "session_id", "timeout", "job_id",
|
|
14
|
+
"path", "content", "encoding", "tail",
|
|
15
|
+
];
|
|
16
|
+
function truncate(text) {
|
|
17
|
+
if (!text || text.length <= MAX_OUTPUT_CHARS)
|
|
18
|
+
return text;
|
|
19
|
+
return text.substring(0, MAX_OUTPUT_CHARS) + `\n...[truncated, ${text.length} total chars]`;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Read NDJSON lines from a ReadableStream (Node.js or web).
|
|
23
|
+
* Yields parsed JSON objects, one per newline-delimited line.
|
|
24
|
+
*/
|
|
25
|
+
async function* readNDJSON(body) {
|
|
26
|
+
const reader = body.getReader();
|
|
27
|
+
const decoder = new TextDecoder();
|
|
28
|
+
let buffer = "";
|
|
29
|
+
try {
|
|
30
|
+
while (true) {
|
|
31
|
+
const { done, value } = await reader.read();
|
|
32
|
+
if (done)
|
|
33
|
+
break;
|
|
34
|
+
buffer += decoder.decode(value, { stream: true });
|
|
35
|
+
// Process complete lines
|
|
36
|
+
let newlineIdx;
|
|
37
|
+
while ((newlineIdx = buffer.indexOf("\n")) !== -1) {
|
|
38
|
+
const line = buffer.substring(0, newlineIdx).trim();
|
|
39
|
+
buffer = buffer.substring(newlineIdx + 1);
|
|
40
|
+
if (line) {
|
|
41
|
+
try {
|
|
42
|
+
yield JSON.parse(line);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// Skip malformed JSON lines
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
// Process any remaining buffer
|
|
51
|
+
const remaining = buffer.trim();
|
|
52
|
+
if (remaining) {
|
|
53
|
+
try {
|
|
54
|
+
yield JSON.parse(remaining);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
// Skip malformed final fragment
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
reader.releaseLock();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
export async function handleKali(_sb, args, _storeId, onToolProgress) {
|
|
66
|
+
const action = args.action;
|
|
67
|
+
if (!action) {
|
|
68
|
+
return { success: false, error: "action is required" };
|
|
69
|
+
}
|
|
70
|
+
if (!VALID_ACTIONS.has(action)) {
|
|
71
|
+
return { success: false, error: `Unknown action: ${action}. Valid: ${[...VALID_ACTIONS].join(", ")}` };
|
|
72
|
+
}
|
|
73
|
+
if ((action === "exec" || action === "exec_stream" || action === "exec_bg") && !args.command) {
|
|
74
|
+
return { success: false, error: "command is required for exec/exec_bg" };
|
|
75
|
+
}
|
|
76
|
+
if (!KALI_AUTH_TOKEN) {
|
|
77
|
+
return { success: false, error: "KALI_AUTH_TOKEN not configured on whale-agent" };
|
|
78
|
+
}
|
|
79
|
+
// Build payload — only forward recognized keys
|
|
80
|
+
const payload = { action };
|
|
81
|
+
for (const key of FORWARD_KEYS) {
|
|
82
|
+
if (args[key] !== undefined)
|
|
83
|
+
payload[key] = args[key];
|
|
84
|
+
}
|
|
85
|
+
// Command timeout + HTTP buffer
|
|
86
|
+
const cmdTimeout = Math.min(args.timeout || 30000, 600000);
|
|
87
|
+
const httpTimeout = cmdTimeout + 10000; // 10s buffer over command timeout
|
|
88
|
+
// ── Streaming path: exec actions use exec_stream on kali-box ──────
|
|
89
|
+
if ((action === "exec" || action === "exec_stream") && onToolProgress) {
|
|
90
|
+
payload.action = "exec_stream";
|
|
91
|
+
payload.timeout = cmdTimeout;
|
|
92
|
+
try {
|
|
93
|
+
const controller = new AbortController();
|
|
94
|
+
const timer = setTimeout(() => controller.abort(), httpTimeout);
|
|
95
|
+
const resp = await fetch(KALI_BOX_URL, {
|
|
96
|
+
method: "POST",
|
|
97
|
+
headers: {
|
|
98
|
+
"Content-Type": "application/json",
|
|
99
|
+
Authorization: `Bearer ${KALI_AUTH_TOKEN}`,
|
|
100
|
+
},
|
|
101
|
+
body: JSON.stringify(payload),
|
|
102
|
+
signal: controller.signal,
|
|
103
|
+
});
|
|
104
|
+
clearTimeout(timer);
|
|
105
|
+
if (!resp.ok) {
|
|
106
|
+
const text = await resp.text().catch(() => "");
|
|
107
|
+
return { success: false, error: `Kali box HTTP ${resp.status}: ${text.substring(0, 500)}` };
|
|
108
|
+
}
|
|
109
|
+
if (!resp.body) {
|
|
110
|
+
return { success: false, error: "Kali box returned no response body for exec_stream" };
|
|
111
|
+
}
|
|
112
|
+
// Read NDJSON stream — emit progress for each chunk, accumulate for final result
|
|
113
|
+
let stdout = "";
|
|
114
|
+
let stderr = "";
|
|
115
|
+
let doneEvent = null;
|
|
116
|
+
for await (const line of readNDJSON(resp.body)) {
|
|
117
|
+
if (line.type === "stdout") {
|
|
118
|
+
const data = String(line.data || "");
|
|
119
|
+
stdout += data;
|
|
120
|
+
// Filter out the CWD marker line from progress events
|
|
121
|
+
if (!data.includes("__KALI_END_")) {
|
|
122
|
+
onToolProgress({ type: "stdout", data });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
else if (line.type === "stderr") {
|
|
126
|
+
const data = String(line.data || "");
|
|
127
|
+
stderr += data;
|
|
128
|
+
onToolProgress({ type: "stderr", data });
|
|
129
|
+
}
|
|
130
|
+
else if (line.type === "done") {
|
|
131
|
+
doneEvent = line;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
// Strip CWD marker from accumulated stdout (same as non-streaming path)
|
|
135
|
+
const markerIdx = stdout.lastIndexOf("__KALI_END_");
|
|
136
|
+
if (markerIdx !== -1) {
|
|
137
|
+
stdout = stdout.substring(0, markerIdx);
|
|
138
|
+
}
|
|
139
|
+
// Build final result (same shape as non-streaming exec)
|
|
140
|
+
const exitCode = doneEvent ? doneEvent.exit_code ?? -1 : -1;
|
|
141
|
+
const result = {
|
|
142
|
+
success: exitCode === 0,
|
|
143
|
+
stdout: truncate(stdout.replace(/\s+$/, "")),
|
|
144
|
+
stderr: truncate(stderr.replace(/\s+$/, "")),
|
|
145
|
+
exit_code: exitCode,
|
|
146
|
+
killed: doneEvent?.killed ?? false,
|
|
147
|
+
cwd: doneEvent?.cwd ?? "",
|
|
148
|
+
duration_ms: doneEvent?.duration_ms ?? 0,
|
|
149
|
+
};
|
|
150
|
+
return { success: exitCode === 0, data: result };
|
|
151
|
+
}
|
|
152
|
+
catch (err) {
|
|
153
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
154
|
+
return { success: false, error: `Kali box streaming request timed out after ${httpTimeout}ms` };
|
|
155
|
+
}
|
|
156
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
157
|
+
if (msg.includes("ECONNREFUSED") || msg.includes("ENOTFOUND") || msg.includes("fetch failed")) {
|
|
158
|
+
return {
|
|
159
|
+
success: false,
|
|
160
|
+
error: `Cannot reach kali-box at ${KALI_BOX_URL}. Ensure the kali-box Fly.io app is running (fly status -a kali-box).`,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
return { success: false, error: `Kali box streaming error: ${msg}` };
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
// ── Standard path: non-exec actions or no progress callback ───────
|
|
167
|
+
if (action === "exec")
|
|
168
|
+
payload.timeout = cmdTimeout;
|
|
169
|
+
try {
|
|
170
|
+
const controller = new AbortController();
|
|
171
|
+
const timer = setTimeout(() => controller.abort(), httpTimeout);
|
|
172
|
+
const resp = await fetch(KALI_BOX_URL, {
|
|
173
|
+
method: "POST",
|
|
174
|
+
headers: {
|
|
175
|
+
"Content-Type": "application/json",
|
|
176
|
+
Authorization: `Bearer ${KALI_AUTH_TOKEN}`,
|
|
177
|
+
},
|
|
178
|
+
body: JSON.stringify(payload),
|
|
179
|
+
signal: controller.signal,
|
|
180
|
+
});
|
|
181
|
+
clearTimeout(timer);
|
|
182
|
+
if (!resp.ok) {
|
|
183
|
+
const text = await resp.text().catch(() => "");
|
|
184
|
+
return { success: false, error: `Kali box HTTP ${resp.status}: ${text.substring(0, 500)}` };
|
|
185
|
+
}
|
|
186
|
+
const result = (await resp.json());
|
|
187
|
+
// Truncate large text fields before returning to agent
|
|
188
|
+
if (typeof result.stdout === "string")
|
|
189
|
+
result.stdout = truncate(result.stdout);
|
|
190
|
+
if (typeof result.stderr === "string")
|
|
191
|
+
result.stderr = truncate(result.stderr);
|
|
192
|
+
if (typeof result.content === "string")
|
|
193
|
+
result.content = truncate(result.content);
|
|
194
|
+
return { success: result.success !== false, data: result };
|
|
195
|
+
}
|
|
196
|
+
catch (err) {
|
|
197
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
198
|
+
return { success: false, error: `Kali box request timed out after ${httpTimeout}ms` };
|
|
199
|
+
}
|
|
200
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
201
|
+
// Friendly message for connection failures
|
|
202
|
+
if (msg.includes("ECONNREFUSED") || msg.includes("ENOTFOUND") || msg.includes("fetch failed")) {
|
|
203
|
+
return {
|
|
204
|
+
success: false,
|
|
205
|
+
error: `Cannot reach kali-box at ${KALI_BOX_URL}. Ensure the kali-box Fly.io app is running (fly status -a kali-box).`,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
return { success: false, error: `Kali box error: ${msg}` };
|
|
209
|
+
}
|
|
210
|
+
}
|