@hotmeshio/long-tail 0.1.11 → 0.1.13
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 +1 -1
- package/build/api/escalations.js +7 -2
- package/build/examples/seed.js +50 -0
- package/build/lib/db/schemas/001_schema.sql +281 -106
- package/build/lib/db/schemas/002_seed.sql +56 -39
- package/build/services/mcp/client/connection.d.ts +13 -0
- package/build/services/mcp/client/connection.js +62 -0
- package/build/services/mcp/client/tools.js +20 -7
- package/build/services/mcp/server.js +31 -0
- package/build/services/yaml-workflow/workers/register.js +24 -4
- package/build/system/mcp-servers/human-queue.js +31 -0
- package/docs/cloud.md +123 -0
- package/package.json +3 -3
- package/build/lib/db/schemas/003_workflow_discovery.sql +0 -39
- package/build/lib/db/schemas/004_query_router.sql +0 -38
- package/build/lib/db/schemas/004_workflow_sets.sql +0 -29
- package/build/lib/db/schemas/005_triage_router.sql +0 -37
- package/build/lib/db/schemas/005_unique_graph_topic.sql +0 -7
- package/build/lib/db/schemas/006_oauth.sql +0 -50
- package/build/lib/db/schemas/007_security.sql +0 -27
- package/build/lib/db/schemas/008_bot_accounts.sql +0 -30
- package/build/lib/db/schemas/009_audit_trail.sql +0 -7
- package/build/lib/db/schemas/010_credential_providers.sql +0 -4
- package/build/lib/db/schemas/011_system_workflow_configs.sql +0 -37
- package/build/lib/db/schemas/012_drop_modality.sql +0 -6
- package/build/lib/db/schemas/013_execute_as.sql +0 -9
- package/build/lib/db/schemas/014_ephemeral_credentials.sql +0 -16
- package/build/lib/db/schemas/015_knowledge.sql +0 -23
- package/build/lib/db/schemas/016_streamable_http.sql +0 -7
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
--
|
|
1
|
+
-- System seed data: built-in MCP server, system workflow configs, escalation chains.
|
|
2
|
+
-- Example workflow configs are seeded at runtime when examples: true.
|
|
2
3
|
|
|
3
|
-
-- ─── MCP
|
|
4
|
+
-- ─── Built-in MCP server ───────────────────────────────────────────────────
|
|
4
5
|
|
|
5
6
|
INSERT INTO lt_mcp_servers (name, description, transport_type, transport_config, auto_connect, status)
|
|
6
7
|
VALUES (
|
|
@@ -24,52 +25,68 @@ INSERT INTO lt_config_role_escalations (source_role, target_role) VALUES
|
|
|
24
25
|
('admin', 'superadmin')
|
|
25
26
|
ON CONFLICT DO NOTHING;
|
|
26
27
|
|
|
27
|
-
-- ───
|
|
28
|
+
-- ─── System workflow configs ────────────────────────────────────────────────
|
|
28
29
|
|
|
29
30
|
INSERT INTO lt_config_workflows
|
|
30
|
-
(workflow_type, task_queue, default_role, invocable, description, tool_tags
|
|
31
|
+
(workflow_type, task_queue, default_role, invocable, description, tool_tags)
|
|
31
32
|
VALUES
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
'
|
|
35
|
-
|
|
36
|
-
'
|
|
37
|
-
'{
|
|
33
|
+
('mcpQuery', 'long-tail-system', 'engineer', false,
|
|
34
|
+
'Dynamic MCP tool orchestration — LLM agentic loop with raw MCP tools',
|
|
35
|
+
'{}'),
|
|
36
|
+
('mcpTriage', 'long-tail-system', 'engineer', false,
|
|
37
|
+
'Dynamic MCP triage — LLM agentic loop for escalation remediation',
|
|
38
|
+
'{}'),
|
|
39
|
+
('mcpWorkflowBuilder', 'long-tail-system', 'engineer', false,
|
|
40
|
+
'Direct pipeline builder — LLM constructs DAG from tool schemas',
|
|
41
|
+
'{}'),
|
|
42
|
+
('mcpWorkflowPlanner', 'long-tail-system', 'engineer', false,
|
|
43
|
+
'Plan mode — decomposes specifications into multi-workflow sets',
|
|
44
|
+
'{}')
|
|
45
|
+
ON CONFLICT (workflow_type) DO NOTHING;
|
|
38
46
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
'
|
|
47
|
+
-- Query router (orchestrator entry point)
|
|
48
|
+
INSERT INTO lt_config_workflows
|
|
49
|
+
(workflow_type, task_queue, default_role, invocable, description, tool_tags, envelope_schema)
|
|
50
|
+
VALUES
|
|
51
|
+
('mcpQueryRouter', 'long-tail-system', 'engineer', true,
|
|
52
|
+
'Do anything with tools — browser automation, file operations, HTTP requests, database queries, document processing, and more',
|
|
53
|
+
'{}',
|
|
54
|
+
'{"data": {"prompt": "Describe what you want to accomplish using available tools..."}, "metadata": {"source": "dashboard"}}'::jsonb)
|
|
55
|
+
ON CONFLICT (workflow_type) DO NOTHING;
|
|
45
56
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
'
|
|
57
|
+
-- Deterministic execution (compiled YAML workflows)
|
|
58
|
+
INSERT INTO lt_config_workflows
|
|
59
|
+
(workflow_type, task_queue, default_role, invocable, description, tool_tags)
|
|
60
|
+
VALUES
|
|
61
|
+
('mcpDeterministic', 'long-tail-system', 'engineer', false,
|
|
62
|
+
'Deterministic execution — invokes matched compiled YAML workflows with extracted inputs',
|
|
63
|
+
'{}')
|
|
64
|
+
ON CONFLICT (workflow_type) DO NOTHING;
|
|
52
65
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
66
|
+
-- Triage router
|
|
67
|
+
INSERT INTO lt_config_workflows
|
|
68
|
+
(workflow_type, task_queue, default_role, invocable, description, tool_tags)
|
|
69
|
+
VALUES
|
|
70
|
+
('mcpTriageRouter', 'long-tail-system', 'engineer', false,
|
|
71
|
+
'Triage router — discovers compiled workflows for remediation, routes to deterministic or dynamic triage',
|
|
72
|
+
'{}')
|
|
73
|
+
ON CONFLICT (workflow_type) DO NOTHING;
|
|
59
74
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
'
|
|
75
|
+
-- Triage deterministic
|
|
76
|
+
INSERT INTO lt_config_workflows
|
|
77
|
+
(workflow_type, task_queue, default_role, invocable, description, tool_tags)
|
|
78
|
+
VALUES
|
|
79
|
+
('mcpTriageDeterministic', 'long-tail-system', 'engineer', false,
|
|
80
|
+
'Deterministic triage — invokes matched compiled workflows for escalation remediation',
|
|
81
|
+
'{}')
|
|
66
82
|
ON CONFLICT (workflow_type) DO NOTHING;
|
|
67
83
|
|
|
68
|
-
-- ─── Assign roles to all workflows
|
|
84
|
+
-- ─── Assign roles to all system workflows ──────────────────────────────────
|
|
69
85
|
|
|
70
86
|
INSERT INTO lt_config_roles (workflow_type, role)
|
|
71
|
-
SELECT
|
|
72
|
-
FROM
|
|
73
|
-
|
|
87
|
+
SELECT wt, unnest(ARRAY['reviewer', 'engineer', 'admin'])
|
|
88
|
+
FROM unnest(ARRAY[
|
|
89
|
+
'mcpQuery', 'mcpTriage', 'mcpWorkflowBuilder', 'mcpWorkflowPlanner',
|
|
90
|
+
'mcpQueryRouter', 'mcpDeterministic', 'mcpTriageRouter', 'mcpTriageDeterministic'
|
|
91
|
+
]) AS wt
|
|
74
92
|
ON CONFLICT (workflow_type, role) DO NOTHING;
|
|
75
|
-
|
|
@@ -5,6 +5,19 @@ import type { LTMcpServerRecord, LTMcpToolManifest } from '../../../types';
|
|
|
5
5
|
* when callServerTool is invoked with its name.
|
|
6
6
|
*/
|
|
7
7
|
export declare function registerBuiltinServer(name: string, factory: () => Promise<any>): void;
|
|
8
|
+
/**
|
|
9
|
+
* Dispatch a tool call directly to a built-in server's handler,
|
|
10
|
+
* bypassing MCP Client/Transport entirely. Returns null if the server
|
|
11
|
+
* or tool is not a built-in — caller should fall through to MCP transport.
|
|
12
|
+
*
|
|
13
|
+
* Each built-in server is lazily instantiated once and cached. Tool handlers
|
|
14
|
+
* are called via server._registeredTools[toolName].handler(args). This
|
|
15
|
+
* eliminates the InMemoryTransport bottleneck under concurrent load.
|
|
16
|
+
*/
|
|
17
|
+
export declare function dispatchBuiltinTool(serverId: string, toolName: string, args: Record<string, any>): Promise<{
|
|
18
|
+
dispatched: true;
|
|
19
|
+
result: any;
|
|
20
|
+
} | null>;
|
|
8
21
|
/**
|
|
9
22
|
* Connect to a registered MCP server.
|
|
10
23
|
* Creates the appropriate transport based on transport_type,
|
|
@@ -34,6 +34,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.registerBuiltinServer = registerBuiltinServer;
|
|
37
|
+
exports.dispatchBuiltinTool = dispatchBuiltinTool;
|
|
37
38
|
exports.connectToServer = connectToServer;
|
|
38
39
|
exports.disconnectFromServer = disconnectFromServer;
|
|
39
40
|
exports.resolveClient = resolveClient;
|
|
@@ -58,6 +59,12 @@ const clients = new Map();
|
|
|
58
59
|
* rather than external stdio/SSE connections.
|
|
59
60
|
*/
|
|
60
61
|
const builtinFactories = new Map();
|
|
62
|
+
/**
|
|
63
|
+
* Cached built-in McpServer instances -- keyed by canonical server name.
|
|
64
|
+
* Used by dispatchBuiltinTool() to call tool handlers directly without
|
|
65
|
+
* going through MCP Client/Transport. One instance per server.
|
|
66
|
+
*/
|
|
67
|
+
const builtinServers = new Map();
|
|
61
68
|
/**
|
|
62
69
|
* Register a built-in server factory so it can be auto-connected
|
|
63
70
|
* when callServerTool is invoked with its name.
|
|
@@ -65,6 +72,60 @@ const builtinFactories = new Map();
|
|
|
65
72
|
function registerBuiltinServer(name, factory) {
|
|
66
73
|
builtinFactories.set(name, factory);
|
|
67
74
|
}
|
|
75
|
+
/**
|
|
76
|
+
* Dispatch a tool call directly to a built-in server's handler,
|
|
77
|
+
* bypassing MCP Client/Transport entirely. Returns null if the server
|
|
78
|
+
* or tool is not a built-in — caller should fall through to MCP transport.
|
|
79
|
+
*
|
|
80
|
+
* Each built-in server is lazily instantiated once and cached. Tool handlers
|
|
81
|
+
* are called via server._registeredTools[toolName].handler(args). This
|
|
82
|
+
* eliminates the InMemoryTransport bottleneck under concurrent load.
|
|
83
|
+
*/
|
|
84
|
+
async function dispatchBuiltinTool(serverId, toolName, args) {
|
|
85
|
+
// Normalize and match against builtin factories
|
|
86
|
+
const norm = (s) => s.replace(/[^a-zA-Z0-9]/g, '').toLowerCase();
|
|
87
|
+
const normId = norm(serverId);
|
|
88
|
+
let matchedName = null;
|
|
89
|
+
for (const [name] of builtinFactories) {
|
|
90
|
+
const normName = norm(name);
|
|
91
|
+
if (normName === normId || normName.includes(normId) || normId.includes(normName)) {
|
|
92
|
+
matchedName = name;
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (!matchedName)
|
|
97
|
+
return null;
|
|
98
|
+
// Lazily create and cache the server instance
|
|
99
|
+
if (!builtinServers.has(matchedName)) {
|
|
100
|
+
const factory = builtinFactories.get(matchedName);
|
|
101
|
+
const server = await factory();
|
|
102
|
+
builtinServers.set(matchedName, server);
|
|
103
|
+
logger_1.loggerRegistry.info(`[lt-mcp:builtin] ${matchedName} ready (direct dispatch)`);
|
|
104
|
+
}
|
|
105
|
+
const server = builtinServers.get(matchedName);
|
|
106
|
+
const tool = server._registeredTools?.[toolName];
|
|
107
|
+
if (!tool?.handler)
|
|
108
|
+
return null;
|
|
109
|
+
// Call the handler directly — no transport, no JSON-RPC.
|
|
110
|
+
// Tool handlers return MCP-shaped responses: { content: [{ type: 'text', text: '...' }] }
|
|
111
|
+
// Parse the text content the same way callServerTool does.
|
|
112
|
+
const mcpResponse = await tool.handler(args);
|
|
113
|
+
let parsed = mcpResponse;
|
|
114
|
+
if (mcpResponse && Array.isArray(mcpResponse.content)) {
|
|
115
|
+
const textContent = mcpResponse.content.find((c) => c.type === 'text');
|
|
116
|
+
if (textContent && 'text' in textContent) {
|
|
117
|
+
try {
|
|
118
|
+
parsed = JSON.parse(textContent.text);
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
parsed = mcpResponse.isError ? { error: textContent.text } : textContent.text;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const isError = parsed && typeof parsed === 'object' && 'error' in parsed;
|
|
126
|
+
logger_1.loggerRegistry.debug(`[lt-mcp:builtin] ${matchedName}/${toolName} ok=${!isError} resultKeys=[${typeof parsed === 'object' && parsed ? Object.keys(parsed).join(',') : 'raw'}]`);
|
|
127
|
+
return { dispatched: true, result: parsed };
|
|
128
|
+
}
|
|
68
129
|
/**
|
|
69
130
|
* Connect to a registered MCP server.
|
|
70
131
|
* Creates the appropriate transport based on transport_type,
|
|
@@ -330,4 +391,5 @@ async function testConnection(transportType, transportConfig) {
|
|
|
330
391
|
*/
|
|
331
392
|
function clear() {
|
|
332
393
|
clients.clear();
|
|
394
|
+
builtinServers.clear();
|
|
333
395
|
}
|
|
@@ -59,13 +59,8 @@ function deriveAuthFromToolContext() {
|
|
|
59
59
|
*/
|
|
60
60
|
async function callServerTool(serverId, toolName, args, authContext) {
|
|
61
61
|
logger_1.loggerRegistry.debug(`[lt-mcp:call] entering ${serverId}/${toolName} argKeys=[${Object.keys(args).join(',')}]`);
|
|
62
|
-
|
|
63
|
-
if (!client) {
|
|
64
|
-
throw new Error(`MCP server ${serverId} is not connected`);
|
|
65
|
-
}
|
|
66
|
-
// Resolve auth: explicit authContext > ambient ToolContext > none
|
|
62
|
+
// Resolve auth context before dispatch — both paths need it
|
|
67
63
|
const resolvedAuth = authContext ?? deriveAuthFromToolContext();
|
|
68
|
-
// Inject auth context as a hidden _auth argument when available
|
|
69
64
|
const toolArgs = resolvedAuth?.userId || resolvedAuth?.delegationToken
|
|
70
65
|
? { ...args, _auth: { userId: resolvedAuth.userId, token: resolvedAuth.delegationToken } }
|
|
71
66
|
: args;
|
|
@@ -74,7 +69,25 @@ async function callServerTool(serverId, toolName, args, authContext) {
|
|
|
74
69
|
if (ctx?.principal.id) {
|
|
75
70
|
logger_1.loggerRegistry.debug(`[lt-mcp:audit] ${toolName} on ${serverId} by ${ctx.principal.type}:${ctx.principal.id}`);
|
|
76
71
|
}
|
|
77
|
-
|
|
72
|
+
// Direct dispatch for built-in servers — bypasses MCP Client/Transport.
|
|
73
|
+
// Each built-in server is a cached singleton; tool handlers are called
|
|
74
|
+
// as plain functions. No transport contention under concurrent load.
|
|
75
|
+
const builtin = await (0, connection_1.dispatchBuiltinTool)(serverId, toolName, toolArgs);
|
|
76
|
+
if (builtin) {
|
|
77
|
+
logger_1.loggerRegistry.debug(`[lt-mcp:call] leaving ${serverId}/${toolName} (builtin) resultKeys=[${typeof builtin.result === 'object' && builtin.result ? Object.keys(builtin.result).join(',') : 'raw'}]`);
|
|
78
|
+
return builtin.result;
|
|
79
|
+
}
|
|
80
|
+
// External servers — use MCP Client/Transport with timeout guard
|
|
81
|
+
const client = await (0, connection_1.resolveClient)(serverId);
|
|
82
|
+
if (!client) {
|
|
83
|
+
throw new Error(`MCP server ${serverId} is not connected`);
|
|
84
|
+
}
|
|
85
|
+
// Guard against hung transports: the MCP SDK timeout relies on the transport
|
|
86
|
+
// to respond, which fails when InMemoryTransport is saturated under concurrency.
|
|
87
|
+
// Promise.race ensures we throw on timeout regardless of transport state.
|
|
88
|
+
const callPromise = client.callTool({ name: toolName, arguments: toolArgs }, undefined, { timeout: defaults_1.MCP_TOOL_TIMEOUT_MS });
|
|
89
|
+
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error(`MCP tool ${serverId}/${toolName} timed out after ${defaults_1.MCP_TOOL_TIMEOUT_MS}ms`)), defaults_1.MCP_TOOL_TIMEOUT_MS));
|
|
90
|
+
const result = await Promise.race([callPromise, timeoutPromise]);
|
|
78
91
|
// Extract text content from MCP response
|
|
79
92
|
if (Array.isArray(result.content)) {
|
|
80
93
|
const textContent = result.content.find((c) => c.type === 'text');
|
|
@@ -64,6 +64,10 @@ const claimAndResolveSchema = zod_1.z.object({
|
|
|
64
64
|
resolver_id: zod_1.z.string().describe('Identifier for who/what is resolving'),
|
|
65
65
|
payload: zod_1.z.record(zod_1.z.any()).describe('Resolution payload data'),
|
|
66
66
|
});
|
|
67
|
+
const resolveEscalationSchema = zod_1.z.object({
|
|
68
|
+
escalation_id: zod_1.z.string().describe('The escalation ID to resolve'),
|
|
69
|
+
payload: zod_1.z.record(zod_1.z.any()).describe('Resolution payload data'),
|
|
70
|
+
});
|
|
67
71
|
const escalateAndWaitSchema = zod_1.z.object({
|
|
68
72
|
role: zod_1.z.string().describe('Target role for the escalation (e.g., "reviewer")'),
|
|
69
73
|
message: zod_1.z.string().describe('Description of what input is needed from the human'),
|
|
@@ -218,6 +222,33 @@ async function createHumanQueueServer(options) {
|
|
|
218
222
|
}],
|
|
219
223
|
};
|
|
220
224
|
});
|
|
225
|
+
// ── resolve_escalation ──────────────────────────────────────────────
|
|
226
|
+
server.registerTool('resolve_escalation', {
|
|
227
|
+
title: 'Resolve Escalation',
|
|
228
|
+
description: 'Resolve an already-claimed escalation with a payload. Use when the claim happened externally (e.g. via API).',
|
|
229
|
+
inputSchema: resolveEscalationSchema,
|
|
230
|
+
}, async (args) => {
|
|
231
|
+
const resolved = await escalationService.resolveEscalation(args.escalation_id, args.payload);
|
|
232
|
+
if (!resolved) {
|
|
233
|
+
return {
|
|
234
|
+
content: [{
|
|
235
|
+
type: 'text',
|
|
236
|
+
text: JSON.stringify({ error: 'Failed to resolve escalation' }),
|
|
237
|
+
}],
|
|
238
|
+
isError: true,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
return {
|
|
242
|
+
content: [{
|
|
243
|
+
type: 'text',
|
|
244
|
+
text: JSON.stringify({
|
|
245
|
+
escalation_id: resolved.id,
|
|
246
|
+
status: resolved.status,
|
|
247
|
+
resolved_at: resolved.resolved_at,
|
|
248
|
+
}),
|
|
249
|
+
}],
|
|
250
|
+
};
|
|
251
|
+
});
|
|
221
252
|
// ── escalate_and_wait ──────────────────────────────────────────────
|
|
222
253
|
server.registerTool('escalate_and_wait', {
|
|
223
254
|
title: 'Escalate and Wait',
|
|
@@ -40,6 +40,7 @@ const db_1 = require("../../../lib/db");
|
|
|
40
40
|
const logger_1 = require("../../../lib/logger");
|
|
41
41
|
const ephemeral_1 = require("../../iam/ephemeral");
|
|
42
42
|
const mcpClient = __importStar(require("../../mcp/client"));
|
|
43
|
+
const connection_1 = require("../../mcp/client/connection");
|
|
43
44
|
const yamlDb = __importStar(require("../db"));
|
|
44
45
|
const scope_1 = require("./scope");
|
|
45
46
|
const callbacks_1 = require("./callbacks");
|
|
@@ -167,14 +168,18 @@ async function registerWorkersForWorkflow(workflow) {
|
|
|
167
168
|
if (!serverId)
|
|
168
169
|
continue;
|
|
169
170
|
const storedArgs = activity.tool_arguments;
|
|
170
|
-
|
|
171
|
+
// For escalate_and_wait, resolve hookTopic at runtime from the activity
|
|
172
|
+
// context — multiple escalation workers share a single registered callback,
|
|
173
|
+
// so we look up by activity_id from the incoming metadata, not the static
|
|
174
|
+
// activity captured at registration time.
|
|
175
|
+
const staticHookTopic = hookTopicByEscalationTool.get(activity.activity_id);
|
|
171
176
|
// Identify keys that are wired via input_mappings. When a wired key
|
|
172
177
|
// resolves to nothing (upstream step failed/returned null), we must
|
|
173
178
|
// NOT fall back to stored tool_arguments — that would leak hardcoded
|
|
174
179
|
// values from the original execution trace.
|
|
175
180
|
const wiredKeys = new Set(Object.keys(activity.input_mappings || {}).filter(k => k !== '_scope' && k !== 'workflowName'));
|
|
176
181
|
if (toolName === 'escalate_and_wait') {
|
|
177
|
-
logger_1.loggerRegistry.info(`[yaml-workflow] escalate_and_wait worker: activityId=${activity.activity_id}, hookTopic=${
|
|
182
|
+
logger_1.loggerRegistry.info(`[yaml-workflow] escalate_and_wait worker: activityId=${activity.activity_id}, hookTopic=${staticHookTopic || 'NONE'}, mapKeys=[${[...hookTopicByEscalationTool.keys()].join(',')}]`);
|
|
178
183
|
}
|
|
179
184
|
workerConfigs.push({
|
|
180
185
|
topic: activity.topic,
|
|
@@ -201,7 +206,13 @@ async function registerWorkersForWorkflow(workflow) {
|
|
|
201
206
|
}
|
|
202
207
|
logger_1.loggerRegistry.debug(`[yaml-workflow:worker] merged mcp/${toolName} wf=${wfName} mergedKeys=[${Object.keys(mergedArgs).join(',')}]`);
|
|
203
208
|
// For escalate_and_wait: inject YAML signal routing so the MCP tool
|
|
204
|
-
// stores engine:'yaml' + hookTopic + jobId in the escalation metadata
|
|
209
|
+
// stores engine:'yaml' + hookTopic + jobId in the escalation metadata.
|
|
210
|
+
// Resolve hookTopic at runtime — multiple escalation workers share
|
|
211
|
+
// this callback, so we look up by the current activity_id from metadata.
|
|
212
|
+
const runtimeActivityId = data.metadata?.aid;
|
|
213
|
+
const yamlHookTopic = runtimeActivityId
|
|
214
|
+
? hookTopicByEscalationTool.get(runtimeActivityId) || staticHookTopic
|
|
215
|
+
: staticHookTopic;
|
|
205
216
|
if (yamlHookTopic) {
|
|
206
217
|
const jid = data.metadata?.jid;
|
|
207
218
|
mergedArgs._yaml_signal_routing = {
|
|
@@ -216,7 +227,16 @@ async function registerWorkersForWorkflow(workflow) {
|
|
|
216
227
|
}
|
|
217
228
|
const exchangedArgs = await (0, ephemeral_1.exchangeTokensInArgs)(mergedArgs);
|
|
218
229
|
const coercedArgs = coerceNumericObjects(exchangedArgs);
|
|
219
|
-
|
|
230
|
+
// Try direct dispatch for built-in servers (bypasses MCP transport).
|
|
231
|
+
// Falls through to mcpClient.callServerTool() for external servers.
|
|
232
|
+
let result;
|
|
233
|
+
const builtin = await (0, connection_1.dispatchBuiltinTool)(serverId, toolName, coercedArgs);
|
|
234
|
+
if (builtin) {
|
|
235
|
+
result = builtin.result;
|
|
236
|
+
}
|
|
237
|
+
else {
|
|
238
|
+
result = await mcpClient.callServerTool(serverId, toolName, coercedArgs);
|
|
239
|
+
}
|
|
220
240
|
if (result && typeof result === 'object' && 'error' in result) {
|
|
221
241
|
logger_1.loggerRegistry.error(`[yaml-workflow:worker] ${toolName} error: ${JSON.stringify(result).slice(0, 200)}`);
|
|
222
242
|
}
|
|
@@ -64,6 +64,10 @@ const claimAndResolveSchema = zod_1.z.object({
|
|
|
64
64
|
resolver_id: zod_1.z.string().describe('Identifier for who/what is resolving'),
|
|
65
65
|
payload: zod_1.z.record(zod_1.z.any()).describe('Resolution payload data'),
|
|
66
66
|
});
|
|
67
|
+
const resolveEscalationSchema = zod_1.z.object({
|
|
68
|
+
escalation_id: zod_1.z.string().describe('The escalation ID to resolve'),
|
|
69
|
+
payload: zod_1.z.record(zod_1.z.any()).describe('Resolution payload data'),
|
|
70
|
+
});
|
|
67
71
|
const escalateAndWaitSchema = zod_1.z.object({
|
|
68
72
|
role: zod_1.z.string().describe('Target role for the escalation (e.g., "reviewer")'),
|
|
69
73
|
message: zod_1.z.string().describe('Description of what input is needed from the human'),
|
|
@@ -218,6 +222,33 @@ async function createHumanQueueServer(options) {
|
|
|
218
222
|
}],
|
|
219
223
|
};
|
|
220
224
|
});
|
|
225
|
+
// ── resolve_escalation ──────────────────────────────────────────────
|
|
226
|
+
server.registerTool('resolve_escalation', {
|
|
227
|
+
title: 'Resolve Escalation',
|
|
228
|
+
description: 'Resolve an already-claimed escalation with a payload. Use when the claim happened externally (e.g. via API).',
|
|
229
|
+
inputSchema: resolveEscalationSchema,
|
|
230
|
+
}, async (args) => {
|
|
231
|
+
const resolved = await escalationService.resolveEscalation(args.escalation_id, args.payload);
|
|
232
|
+
if (!resolved) {
|
|
233
|
+
return {
|
|
234
|
+
content: [{
|
|
235
|
+
type: 'text',
|
|
236
|
+
text: JSON.stringify({ error: 'Failed to resolve escalation' }),
|
|
237
|
+
}],
|
|
238
|
+
isError: true,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
return {
|
|
242
|
+
content: [{
|
|
243
|
+
type: 'text',
|
|
244
|
+
text: JSON.stringify({
|
|
245
|
+
escalation_id: resolved.id,
|
|
246
|
+
status: resolved.status,
|
|
247
|
+
resolved_at: resolved.resolved_at,
|
|
248
|
+
}),
|
|
249
|
+
}],
|
|
250
|
+
};
|
|
251
|
+
});
|
|
221
252
|
// ── escalate_and_wait ──────────────────────────────────────────────
|
|
222
253
|
server.registerTool('escalate_and_wait', {
|
|
223
254
|
title: 'Escalate and Wait',
|
package/docs/cloud.md
CHANGED
|
@@ -270,3 +270,126 @@ services:
|
|
|
270
270
|
```
|
|
271
271
|
|
|
272
272
|
The combined `index.js` entry point (used in development and the demo) calls `start()` with both server and workers enabled. In production, split them into `api.js` and `worker.js` with different `start()` configs.
|
|
273
|
+
|
|
274
|
+
## PostgreSQL Performance Tuning
|
|
275
|
+
|
|
276
|
+
HotMesh's durable execution model is write-heavy. Every workflow creates a `jobs` row, and every field mutation creates rows in `jobs_attributes`. A simple 3-step workflow generates ~100 attribute rows per execution. At 1,000 concurrent workflows, that's 300K+ inserts in seconds.
|
|
277
|
+
|
|
278
|
+
The default Postgres configuration is tuned for mixed workloads on modest hardware. For Long Tail, the write-heavy profile needs specific adjustments.
|
|
279
|
+
|
|
280
|
+
### Determining Your Profile
|
|
281
|
+
|
|
282
|
+
Run a baseline throughput test to understand your bottleneck:
|
|
283
|
+
|
|
284
|
+
```bash
|
|
285
|
+
# Submit 100 minimal workflows, measure submit rate
|
|
286
|
+
time for i in $(seq 1 100); do
|
|
287
|
+
curl -s -X POST http://localhost:3000/api/workflows/basicEcho/invoke \
|
|
288
|
+
-H "Authorization: Bearer $TOKEN" \
|
|
289
|
+
-H 'Content-Type: application/json' \
|
|
290
|
+
-d '{"data":{"message":"test","sleepSeconds":0}}' > /dev/null
|
|
291
|
+
done
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
Then check where Postgres is spending time:
|
|
295
|
+
|
|
296
|
+
```sql
|
|
297
|
+
-- Check for write pressure (high buffers_checkpoint = WAL bottleneck)
|
|
298
|
+
SELECT * FROM pg_stat_bgwriter;
|
|
299
|
+
|
|
300
|
+
-- Check for connection saturation
|
|
301
|
+
SELECT count(*) as active, max_conn
|
|
302
|
+
FROM pg_stat_activity, (SELECT setting::int as max_conn FROM pg_settings WHERE name = 'max_connections') mc
|
|
303
|
+
WHERE state = 'active'
|
|
304
|
+
GROUP BY max_conn;
|
|
305
|
+
|
|
306
|
+
-- Check table bloat after burst writes
|
|
307
|
+
SELECT relname, n_live_tup, n_dead_tup,
|
|
308
|
+
round(100.0 * n_dead_tup / nullif(n_live_tup + n_dead_tup, 0), 1) as dead_pct
|
|
309
|
+
FROM pg_stat_user_tables
|
|
310
|
+
WHERE n_live_tup > 1000
|
|
311
|
+
ORDER BY n_dead_tup DESC LIMIT 10;
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
### Recommended Settings
|
|
315
|
+
|
|
316
|
+
| Parameter | Default | Recommended | Why |
|
|
317
|
+
|-----------|---------|-------------|-----|
|
|
318
|
+
| `shared_buffers` | 128MB | 25% of RAM (256MB–1GB) | Cache hot pages — `jobs_attributes` partitions are read/written constantly |
|
|
319
|
+
| `work_mem` | 4MB | 16MB | Workflow queries join across partitions; larger sort memory avoids disk spills |
|
|
320
|
+
| `maintenance_work_mem` | 64MB | 128MB–256MB | Speeds VACUUM on large `jobs_attributes` tables after burst writes |
|
|
321
|
+
| `wal_buffers` | -1 (auto) | 16MB | Write-heavy workloads saturate the default 8MB WAL buffer |
|
|
322
|
+
| `max_wal_size` | 1GB | 1GB–2GB | Prevents excessive checkpointing during sustained write bursts |
|
|
323
|
+
| `checkpoint_completion_target` | 0.9 | 0.9 | Spread checkpoint I/O over time — already optimal |
|
|
324
|
+
| `effective_cache_size` | 4GB | 50–75% of RAM | Query planner hint — tells Postgres how much OS cache to expect |
|
|
325
|
+
| `synchronous_commit` | on | off (dev/staging) | Trades durability for 2–5x write throughput. WAL is still written; only fsync is deferred. Acceptable for dev and staging. **Keep `on` in production** unless you understand the trade-off. |
|
|
326
|
+
| `max_connections` | 100 | 200 | HotMesh uses connection-per-worker; concurrent workflows can exhaust 100 connections |
|
|
327
|
+
|
|
328
|
+
### Docker Compose Configuration
|
|
329
|
+
|
|
330
|
+
```yaml
|
|
331
|
+
postgres:
|
|
332
|
+
image: postgres:16
|
|
333
|
+
command:
|
|
334
|
+
- postgres
|
|
335
|
+
- -c
|
|
336
|
+
- shared_buffers=256MB
|
|
337
|
+
- -c
|
|
338
|
+
- work_mem=16MB
|
|
339
|
+
- -c
|
|
340
|
+
- maintenance_work_mem=128MB
|
|
341
|
+
- -c
|
|
342
|
+
- wal_buffers=16MB
|
|
343
|
+
- -c
|
|
344
|
+
- max_wal_size=1GB
|
|
345
|
+
- -c
|
|
346
|
+
- checkpoint_completion_target=0.9
|
|
347
|
+
- -c
|
|
348
|
+
- effective_cache_size=512MB
|
|
349
|
+
- -c
|
|
350
|
+
- synchronous_commit=off
|
|
351
|
+
- -c
|
|
352
|
+
- max_connections=200
|
|
353
|
+
shm_size: 512m # Required: shared_buffers > 128MB needs larger /dev/shm
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
The `shm_size` setting is critical — Docker defaults to 64MB for `/dev/shm`, but `shared_buffers=256MB` requires at least that much shared memory. Without it, Postgres will fail to start or silently fall back to smaller buffers.
|
|
357
|
+
|
|
358
|
+
### Production (RDS / Cloud SQL)
|
|
359
|
+
|
|
360
|
+
For managed databases, apply the same parameters through parameter groups:
|
|
361
|
+
|
|
362
|
+
**AWS RDS:**
|
|
363
|
+
```
|
|
364
|
+
# Custom parameter group
|
|
365
|
+
shared_buffers = {DBInstanceClassMemory/4}
|
|
366
|
+
work_mem = 16384 # 16MB in KB
|
|
367
|
+
maintenance_work_mem = 262144
|
|
368
|
+
wal_buffers = 16384
|
|
369
|
+
max_wal_size = 2048 # 2GB in MB
|
|
370
|
+
synchronous_commit = on # Keep on for production
|
|
371
|
+
max_connections = 200
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
**GCP Cloud SQL:**
|
|
375
|
+
```
|
|
376
|
+
# Database flags
|
|
377
|
+
shared_buffers: 25% of instance RAM (auto-tuned by Cloud SQL)
|
|
378
|
+
work_mem: 16MB
|
|
379
|
+
maintenance_work_mem: 256MB
|
|
380
|
+
max_wal_size: 2GB
|
|
381
|
+
synchronous_commit: on
|
|
382
|
+
max_connections: 200
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
### Maintenance
|
|
386
|
+
|
|
387
|
+
After burst workloads, dead tuples accumulate in `jobs_attributes`. Autovacuum handles this, but for large bursts (10K+ workflows), consider:
|
|
388
|
+
|
|
389
|
+
```sql
|
|
390
|
+
-- Manual VACUUM after a load test or batch run
|
|
391
|
+
VACUUM ANALYZE durable.jobs_attributes;
|
|
392
|
+
VACUUM ANALYZE durable.engine_streams;
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
Long Tail includes a built-in maintenance cron that prunes completed workflow data. Configure it via the dashboard or API to keep table sizes manageable.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hotmeshio/long-tail",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.13",
|
|
4
4
|
"description": "Long Tail Workflows — Durable AI workflows with human-in-the-loop escalation. Powered by PostgreSQL.",
|
|
5
5
|
"main": "./build/index.js",
|
|
6
6
|
"types": "./build/index.d.ts",
|
|
@@ -59,9 +59,9 @@
|
|
|
59
59
|
"author": "luke.birdeau@gmail.com",
|
|
60
60
|
"license": "SEE LICENSE IN LICENSE",
|
|
61
61
|
"dependencies": {
|
|
62
|
-
"@anthropic-ai/sdk": "^0.
|
|
62
|
+
"@anthropic-ai/sdk": "^0.92.0",
|
|
63
63
|
"@aws-sdk/client-s3": "^3.1017.0",
|
|
64
|
-
"@hotmeshio/hotmesh": "^0.14.
|
|
64
|
+
"@hotmeshio/hotmesh": "^0.14.7",
|
|
65
65
|
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
66
66
|
"@opentelemetry/exporter-trace-otlp-proto": "^0.215.0",
|
|
67
67
|
"@opentelemetry/resources": "^2.5.1",
|
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
-- Workflow discovery: full-text search, original prompt, and category.
|
|
2
|
-
|
|
3
|
-
-- Original prompt that spawned this workflow (richest semantic signal)
|
|
4
|
-
ALTER TABLE lt_yaml_workflows ADD COLUMN IF NOT EXISTS original_prompt TEXT;
|
|
5
|
-
|
|
6
|
-
-- Capability category derived from tool usage patterns
|
|
7
|
-
ALTER TABLE lt_yaml_workflows ADD COLUMN IF NOT EXISTS category TEXT;
|
|
8
|
-
|
|
9
|
-
-- Full-text search vector, auto-maintained by trigger
|
|
10
|
-
ALTER TABLE lt_yaml_workflows ADD COLUMN IF NOT EXISTS search_vector TSVECTOR;
|
|
11
|
-
|
|
12
|
-
-- GIN index on search_vector for fast full-text search
|
|
13
|
-
CREATE INDEX IF NOT EXISTS idx_lt_yaml_workflows_search
|
|
14
|
-
ON lt_yaml_workflows USING GIN (search_vector);
|
|
15
|
-
|
|
16
|
-
-- Index on category for filtered queries
|
|
17
|
-
CREATE INDEX IF NOT EXISTS idx_lt_yaml_workflows_category
|
|
18
|
-
ON lt_yaml_workflows (category) WHERE category IS NOT NULL;
|
|
19
|
-
|
|
20
|
-
-- Trigger: rebuild search_vector from name, description, tags, original_prompt, category
|
|
21
|
-
CREATE OR REPLACE FUNCTION lt_yaml_workflows_search_vector_update()
|
|
22
|
-
RETURNS TRIGGER AS $$
|
|
23
|
-
BEGIN
|
|
24
|
-
NEW.search_vector :=
|
|
25
|
-
setweight(to_tsvector('english', coalesce(NEW.name, '')), 'A') ||
|
|
26
|
-
setweight(to_tsvector('english', coalesce(NEW.original_prompt, '')), 'A') ||
|
|
27
|
-
setweight(to_tsvector('english', coalesce(NEW.description, '')), 'B') ||
|
|
28
|
-
setweight(to_tsvector('english', coalesce(NEW.category, '')), 'C') ||
|
|
29
|
-
setweight(to_tsvector('english', coalesce(array_to_string(NEW.tags, ' '), '')), 'C');
|
|
30
|
-
RETURN NEW;
|
|
31
|
-
END;
|
|
32
|
-
$$ LANGUAGE plpgsql;
|
|
33
|
-
|
|
34
|
-
CREATE OR REPLACE TRIGGER trg_lt_yaml_workflows_search_vector
|
|
35
|
-
BEFORE INSERT OR UPDATE ON lt_yaml_workflows
|
|
36
|
-
FOR EACH ROW EXECUTE FUNCTION lt_yaml_workflows_search_vector_update();
|
|
37
|
-
|
|
38
|
-
-- Backfill search_vector for existing rows
|
|
39
|
-
UPDATE lt_yaml_workflows SET updated_at = NOW();
|
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
-- Split mcpQuery into router + dynamic + deterministic workflows.
|
|
2
|
-
-- mcpQueryRouter is the new entry point (orchestrator).
|
|
3
|
-
-- mcpQuery becomes dynamic-only (leaf).
|
|
4
|
-
-- mcpDeterministic invokes compiled YAML workflows (leaf).
|
|
5
|
-
|
|
6
|
-
-- Update existing mcpQuery: no longer directly invocable (called via router)
|
|
7
|
-
UPDATE lt_config_workflows
|
|
8
|
-
SET invocable = false,
|
|
9
|
-
description = 'Dynamic MCP tool orchestration — LLM agentic loop with raw MCP tools'
|
|
10
|
-
WHERE workflow_type = 'mcpQuery';
|
|
11
|
-
|
|
12
|
-
-- Add mcpQueryRouter (orchestrator — the new entry point)
|
|
13
|
-
INSERT INTO lt_config_workflows
|
|
14
|
-
(workflow_type, task_queue, default_role, invocable, description, tool_tags, envelope_schema)
|
|
15
|
-
VALUES
|
|
16
|
-
('mcpQueryRouter', 'long-tail-system', 'engineer', true,
|
|
17
|
-
'Do anything with tools — browser automation, file operations, HTTP requests, database queries, document processing, and more',
|
|
18
|
-
'{}',
|
|
19
|
-
'{"data": {"prompt": "Describe what you want to accomplish using available tools..."}, "metadata": {"source": "dashboard"}}'::jsonb)
|
|
20
|
-
ON CONFLICT (workflow_type) DO NOTHING;
|
|
21
|
-
|
|
22
|
-
-- Add mcpDeterministic (leaf — invokes compiled YAML workflows)
|
|
23
|
-
INSERT INTO lt_config_workflows
|
|
24
|
-
(workflow_type, task_queue, default_role, invocable, description, tool_tags)
|
|
25
|
-
VALUES
|
|
26
|
-
('mcpDeterministic', 'long-tail-system', 'engineer', false,
|
|
27
|
-
'Deterministic execution — invokes matched compiled YAML workflows with extracted inputs',
|
|
28
|
-
'{}')
|
|
29
|
-
ON CONFLICT (workflow_type) DO NOTHING;
|
|
30
|
-
|
|
31
|
-
-- Assign roles
|
|
32
|
-
INSERT INTO lt_config_roles (workflow_type, role)
|
|
33
|
-
SELECT 'mcpQueryRouter', unnest(ARRAY['reviewer', 'engineer', 'admin'])
|
|
34
|
-
ON CONFLICT (workflow_type, role) DO NOTHING;
|
|
35
|
-
|
|
36
|
-
INSERT INTO lt_config_roles (workflow_type, role)
|
|
37
|
-
SELECT 'mcpDeterministic', unnest(ARRAY['reviewer', 'engineer', 'admin'])
|
|
38
|
-
ON CONFLICT (workflow_type, role) DO NOTHING;
|