@luckydraw/cumulus 0.27.40 → 0.28.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/dist/gateway/adapters/webchat.d.ts +5 -7
- package/dist/gateway/adapters/webchat.d.ts.map +1 -1
- package/dist/gateway/adapters/webchat.js +204 -57
- package/dist/gateway/adapters/webchat.js.map +1 -1
- package/dist/gateway/config.d.ts +48 -0
- package/dist/gateway/config.d.ts.map +1 -1
- package/dist/gateway/config.js +90 -0
- package/dist/gateway/config.js.map +1 -1
- package/dist/gateway/daemon.d.ts.map +1 -1
- package/dist/gateway/daemon.js +268 -27
- package/dist/gateway/daemon.js.map +1 -1
- package/dist/gateway/gateway-agents-mcp.js +570 -31
- package/dist/gateway/gateway-agents-mcp.js.map +1 -1
- package/dist/gateway/hooks.d.ts +101 -0
- package/dist/gateway/hooks.d.ts.map +1 -0
- package/dist/gateway/hooks.js +200 -0
- package/dist/gateway/hooks.js.map +1 -0
- package/dist/gateway/scheduler.d.ts +50 -0
- package/dist/gateway/scheduler.d.ts.map +1 -0
- package/dist/gateway/scheduler.js +263 -0
- package/dist/gateway/scheduler.js.map +1 -0
- package/dist/gateway/server.d.ts +16 -1
- package/dist/gateway/server.d.ts.map +1 -1
- package/dist/gateway/server.js +451 -22
- package/dist/gateway/server.js.map +1 -1
- package/dist/gateway/static/blex.min.js +74 -17
- package/dist/gateway/static/chat.html +1 -4
- package/dist/gateway/static/dashboard.html +151 -0
- package/dist/gateway/static/dashboard.js +229 -0
- package/dist/gateway/static/widget.js +656 -460
- package/dist/index.d.ts +13 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -3
- package/dist/index.js.map +1 -1
- package/dist/lib/agentic-loop.d.ts +124 -0
- package/dist/lib/agentic-loop.d.ts.map +1 -0
- package/dist/lib/agentic-loop.js +338 -0
- package/dist/lib/agentic-loop.js.map +1 -0
- package/dist/lib/config.d.ts +28 -15
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/config.js +47 -41
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/gateway.d.ts +34 -3
- package/dist/lib/gateway.d.ts.map +1 -1
- package/dist/lib/gateway.js +584 -37
- package/dist/lib/gateway.js.map +1 -1
- package/dist/lib/huggingface-provider.d.ts +26 -0
- package/dist/lib/huggingface-provider.d.ts.map +1 -0
- package/dist/lib/huggingface-provider.js +338 -0
- package/dist/lib/huggingface-provider.js.map +1 -0
- package/dist/lib/provider.d.ts +71 -0
- package/dist/lib/provider.d.ts.map +1 -0
- package/dist/lib/provider.js +90 -0
- package/dist/lib/provider.js.map +1 -0
- package/dist/lib/tools/ask-user-question.d.ts +14 -0
- package/dist/lib/tools/ask-user-question.d.ts.map +1 -0
- package/dist/lib/tools/ask-user-question.js +74 -0
- package/dist/lib/tools/ask-user-question.js.map +1 -0
- package/dist/lib/tools/bash.d.ts +6 -0
- package/dist/lib/tools/bash.d.ts.map +1 -0
- package/dist/lib/tools/bash.js +85 -0
- package/dist/lib/tools/bash.js.map +1 -0
- package/dist/lib/tools/edit.d.ts +6 -0
- package/dist/lib/tools/edit.d.ts.map +1 -0
- package/dist/lib/tools/edit.js +79 -0
- package/dist/lib/tools/edit.js.map +1 -0
- package/dist/lib/tools/glob.d.ts +6 -0
- package/dist/lib/tools/glob.d.ts.map +1 -0
- package/dist/lib/tools/glob.js +91 -0
- package/dist/lib/tools/glob.js.map +1 -0
- package/dist/lib/tools/grep.d.ts +6 -0
- package/dist/lib/tools/grep.d.ts.map +1 -0
- package/dist/lib/tools/grep.js +110 -0
- package/dist/lib/tools/grep.js.map +1 -0
- package/dist/lib/tools/index.d.ts +29 -0
- package/dist/lib/tools/index.d.ts.map +1 -0
- package/dist/lib/tools/index.js +52 -0
- package/dist/lib/tools/index.js.map +1 -0
- package/dist/lib/tools/read.d.ts +6 -0
- package/dist/lib/tools/read.d.ts.map +1 -0
- package/dist/lib/tools/read.js +80 -0
- package/dist/lib/tools/read.js.map +1 -0
- package/dist/lib/tools/tool-search.d.ts +30 -0
- package/dist/lib/tools/tool-search.d.ts.map +1 -0
- package/dist/lib/tools/tool-search.js +108 -0
- package/dist/lib/tools/tool-search.js.map +1 -0
- package/dist/lib/tools/web-fetch.d.ts +9 -0
- package/dist/lib/tools/web-fetch.d.ts.map +1 -0
- package/dist/lib/tools/web-fetch.js +114 -0
- package/dist/lib/tools/web-fetch.js.map +1 -0
- package/dist/lib/tools/web-search.d.ts +12 -0
- package/dist/lib/tools/web-search.d.ts.map +1 -0
- package/dist/lib/tools/web-search.js +89 -0
- package/dist/lib/tools/web-search.js.map +1 -0
- package/dist/lib/tools/write.d.ts +6 -0
- package/dist/lib/tools/write.d.ts.map +1 -0
- package/dist/lib/tools/write.js +44 -0
- package/dist/lib/tools/write.js.map +1 -0
- package/package.json +3 -2
package/dist/gateway/server.js
CHANGED
|
@@ -10,9 +10,12 @@
|
|
|
10
10
|
* GET /api/threads — list all threads
|
|
11
11
|
* DELETE /api/thread/:name — delete thread (requires X-Confirm: delete)
|
|
12
12
|
* POST /api/migrate — sync thread + clone repo + set projectDir
|
|
13
|
+
* POST /api/media/upload — upload file to media directory (returns URL)
|
|
14
|
+
* GET /media/* — serve uploaded media files (no auth)
|
|
13
15
|
* GET /health — health check (no auth)
|
|
14
16
|
*/
|
|
15
17
|
import { execFileSync } from 'child_process';
|
|
18
|
+
import * as crypto from 'crypto';
|
|
16
19
|
import * as fs from 'fs';
|
|
17
20
|
import * as http from 'http';
|
|
18
21
|
import * as os from 'os';
|
|
@@ -21,11 +24,12 @@ import { WebSocketServer } from 'ws';
|
|
|
21
24
|
import { loadThreadConfig, saveThreadConfig } from '../lib/config.js';
|
|
22
25
|
import { loadEmbeddings, generateMissingEmbeddings } from '../lib/embeddings.js';
|
|
23
26
|
import { ExecutorRegistry } from '../lib/executor.js';
|
|
24
|
-
import { sendMessage, getOrCreateThread, clearThreadCache, } from '../lib/gateway.js';
|
|
27
|
+
import { sendMessage, getOrCreateThread, clearThreadCache, getActiveSubprocessCount, } from '../lib/gateway.js';
|
|
25
28
|
import { catchUpSegmentation, loadSegmentBoundaries, saveSegmentBoundaries, } from '../lib/segments.js';
|
|
26
29
|
import { listTemplates, scaffoldFromTemplate } from '../lib/templates.js';
|
|
27
|
-
import { serveStaticFile } from './adapters/webchat.js';
|
|
30
|
+
import { serveStaticFile, getMediaDir } from './adapters/webchat.js';
|
|
28
31
|
import { authenticate } from './auth.js';
|
|
32
|
+
import { handleEmailHook, handleFormHook, handleGenericWebhook, } from './hooks.js';
|
|
29
33
|
import { configureVapid, sendNotification, subscribe as pushSubscribe, unsubscribe as pushUnsubscribe, } from './push.js';
|
|
30
34
|
const threadQueues = new Map();
|
|
31
35
|
const threadBusy = new Map();
|
|
@@ -88,6 +92,7 @@ async function processMessage(threadName, item, opts) {
|
|
|
88
92
|
extraMcpServers: opts.extraMcpServers,
|
|
89
93
|
gatewayAgentsConfig: opts.gatewayAgentsConfig,
|
|
90
94
|
executorRegistry: opts.executorRegistry,
|
|
95
|
+
hfApiKey: opts.hfApiKey,
|
|
91
96
|
signal: ac.signal,
|
|
92
97
|
onToken: text => sendSSE('token', { text }),
|
|
93
98
|
onSegment: segment => sendSSE('segment', segment),
|
|
@@ -113,6 +118,83 @@ async function processMessage(threadName, item, opts) {
|
|
|
113
118
|
}
|
|
114
119
|
}
|
|
115
120
|
}
|
|
121
|
+
// ─── Webhook endpoints ──────────────────────────────────────────────────────
|
|
122
|
+
async function handleWebhook(type, name, req, res, pipelineOpts, hooksConfig) {
|
|
123
|
+
if (!hooksConfig) {
|
|
124
|
+
jsonResponse(res, 404, { error: 'Webhooks not configured' });
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
let body;
|
|
128
|
+
try {
|
|
129
|
+
body = await readBody(req);
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
jsonResponse(res, 400, { error: 'Could not read request body' });
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
const hookOpts = {
|
|
136
|
+
basePath: pipelineOpts.basePath,
|
|
137
|
+
claudePath: pipelineOpts.claudePath,
|
|
138
|
+
sharedMcpPort: pipelineOpts.sharedMcpPort,
|
|
139
|
+
projectRoot: pipelineOpts.projectRoot,
|
|
140
|
+
extraMcpServers: pipelineOpts.extraMcpServers,
|
|
141
|
+
gatewayAgentsConfig: pipelineOpts.gatewayAgentsConfig,
|
|
142
|
+
executorRegistry: pipelineOpts.executorRegistry,
|
|
143
|
+
broadcastToThread: pipelineOpts.broadcastToThread,
|
|
144
|
+
};
|
|
145
|
+
try {
|
|
146
|
+
switch (type) {
|
|
147
|
+
case 'email': {
|
|
148
|
+
if (!hooksConfig.email?.enabled) {
|
|
149
|
+
jsonResponse(res, 404, { error: 'Email hooks not enabled' });
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
const result = await handleEmailHook(body, req.headers, hooksConfig.email, hookOpts);
|
|
153
|
+
// Return 200 immediately — processing continues async
|
|
154
|
+
jsonResponse(res, 200, result);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
case 'form': {
|
|
158
|
+
if (!hooksConfig.form?.enabled) {
|
|
159
|
+
jsonResponse(res, 404, { error: 'Form hooks not enabled' });
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
const result = await handleFormHook(body, hooksConfig.form, hookOpts);
|
|
163
|
+
jsonResponse(res, 200, result);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
case 'webhook': {
|
|
167
|
+
if (!name) {
|
|
168
|
+
jsonResponse(res, 400, { error: 'Missing webhook name: POST /api/hooks/webhook/:name' });
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
const webhookConfig = hooksConfig.webhooks?.[name];
|
|
172
|
+
if (!webhookConfig) {
|
|
173
|
+
jsonResponse(res, 404, { error: `Webhook "${name}" not configured` });
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
const result = await handleGenericWebhook(name, body, webhookConfig, hookOpts);
|
|
177
|
+
jsonResponse(res, 200, result);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
default:
|
|
181
|
+
jsonResponse(res, 404, { error: `Unknown hook type: ${type}` });
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
catch (err) {
|
|
185
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
186
|
+
if (message.includes('Invalid webhook signature')) {
|
|
187
|
+
jsonResponse(res, 401, { error: 'Invalid webhook signature' });
|
|
188
|
+
}
|
|
189
|
+
else if (message.includes('Invalid JSON') || message.includes('Unexpected token')) {
|
|
190
|
+
jsonResponse(res, 400, { error: 'Invalid JSON body' });
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
console.error(`[Gateway] Webhook error (${type}):`, err);
|
|
194
|
+
jsonResponse(res, 500, { error: 'Webhook processing error' });
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
116
198
|
// ─── Agent endpoints ─────────────────────────────────────────────────────────
|
|
117
199
|
async function handleListAgents(res) {
|
|
118
200
|
try {
|
|
@@ -153,16 +235,11 @@ async function handleAgentInject(req, res, pipelineOpts) {
|
|
|
153
235
|
// Resolve target thread names
|
|
154
236
|
let targetNames;
|
|
155
237
|
if (targets === '__broadcast__') {
|
|
156
|
-
// Broadcast
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
targetNames = fs
|
|
162
|
-
.readdirSync(THREADS_DIR)
|
|
163
|
-
.filter(f => f.endsWith('.jsonl'))
|
|
164
|
-
.map(f => f.replace(/\.jsonl$/, ''))
|
|
165
|
-
.filter(name => name !== sender);
|
|
238
|
+
// Broadcast-all is disabled — agents must target specific threads
|
|
239
|
+
jsonResponse(res, 400, {
|
|
240
|
+
error: 'Broadcast to all agents is disabled. Use send_to_agent with specific targets.',
|
|
241
|
+
});
|
|
242
|
+
return;
|
|
166
243
|
}
|
|
167
244
|
else if (Array.isArray(targets)) {
|
|
168
245
|
targetNames = targets;
|
|
@@ -202,7 +279,7 @@ async function handleAgentInject(req, res, pipelineOpts) {
|
|
|
202
279
|
visibility,
|
|
203
280
|
});
|
|
204
281
|
// Ensure thread exists
|
|
205
|
-
await getOrCreateThread(targetName
|
|
282
|
+
await getOrCreateThread(targetName);
|
|
206
283
|
// Send as a message through the pipeline (fire-and-forget)
|
|
207
284
|
threadBusy.set(targetName, true);
|
|
208
285
|
const broadcast = pipelineOpts.broadcastToThread;
|
|
@@ -392,6 +469,18 @@ function parseRoute(url) {
|
|
|
392
469
|
query,
|
|
393
470
|
};
|
|
394
471
|
}
|
|
472
|
+
// Match /api/hooks/:type or /api/hooks/:type/:name
|
|
473
|
+
const hooksMatch = pathPart.match(/^\/api\/hooks\/([^/]+)(?:\/([^/]+))?$/);
|
|
474
|
+
if (hooksMatch) {
|
|
475
|
+
return {
|
|
476
|
+
path: `/api/hooks/:type${hooksMatch[2] ? '/:name' : ''}`,
|
|
477
|
+
params: {
|
|
478
|
+
type: decodeURIComponent(hooksMatch[1]),
|
|
479
|
+
...(hooksMatch[2] ? { name: decodeURIComponent(hooksMatch[2]) } : {}),
|
|
480
|
+
},
|
|
481
|
+
query,
|
|
482
|
+
};
|
|
483
|
+
}
|
|
395
484
|
return { path: pathPart, params: {}, query };
|
|
396
485
|
}
|
|
397
486
|
function readBody(req) {
|
|
@@ -407,9 +496,8 @@ function jsonResponse(res, status, data) {
|
|
|
407
496
|
res.end(JSON.stringify(data));
|
|
408
497
|
}
|
|
409
498
|
// ─── Route handlers ──────────────────────────────────────────────────────────
|
|
410
|
-
// Default threads directory — can be overridden via basePath in server options
|
|
411
499
|
let THREADS_DIR = path.join(os.homedir(), '.cumulus', 'threads');
|
|
412
|
-
/** Override the threads directory (used by
|
|
500
|
+
/** Override the threads directory (used by tests to isolate from production) */
|
|
413
501
|
export function setThreadsDir(dir) {
|
|
414
502
|
THREADS_DIR = dir;
|
|
415
503
|
}
|
|
@@ -773,6 +861,8 @@ async function handleUpdateThreadConfig(threadName, req, res) {
|
|
|
773
861
|
updates.devices = body.devices;
|
|
774
862
|
if (body.gitRemoteUrl !== undefined)
|
|
775
863
|
updates.gitRemoteUrl = body.gitRemoteUrl;
|
|
864
|
+
if (body.model !== undefined)
|
|
865
|
+
updates.model = body.model;
|
|
776
866
|
await saveThreadConfig(threadName, updates);
|
|
777
867
|
const updated = await loadThreadConfig(threadName);
|
|
778
868
|
jsonResponse(res, 200, { config: updated });
|
|
@@ -781,6 +871,200 @@ async function handleUpdateThreadConfig(threadName, req, res) {
|
|
|
781
871
|
jsonResponse(res, 500, { error: String(err) });
|
|
782
872
|
}
|
|
783
873
|
}
|
|
874
|
+
// ─── Pipeline dashboard handlers ───────────────────────────
|
|
875
|
+
const STAGE_ORDER = [
|
|
876
|
+
'prospect',
|
|
877
|
+
'outreach',
|
|
878
|
+
'warm-engagement',
|
|
879
|
+
'proposal',
|
|
880
|
+
'onboarding',
|
|
881
|
+
'active-client',
|
|
882
|
+
'archived',
|
|
883
|
+
];
|
|
884
|
+
async function handleGetDashboard(res) {
|
|
885
|
+
try {
|
|
886
|
+
const files = fs.readdirSync(THREADS_DIR);
|
|
887
|
+
const threads = [];
|
|
888
|
+
for (const file of files) {
|
|
889
|
+
if (!file.endsWith('.config.json'))
|
|
890
|
+
continue;
|
|
891
|
+
const threadName = file.replace('.config.json', '');
|
|
892
|
+
const config = await loadThreadConfig(threadName);
|
|
893
|
+
// Skip threads without pipeline metadata
|
|
894
|
+
if (!config.pipeline?.stage)
|
|
895
|
+
continue;
|
|
896
|
+
// Get last activity from JSONL last line timestamp (read last 1KB only)
|
|
897
|
+
const historyPath = path.join(THREADS_DIR, `${threadName}.jsonl`);
|
|
898
|
+
let lastActivity;
|
|
899
|
+
try {
|
|
900
|
+
const stats = fs.statSync(historyPath);
|
|
901
|
+
if (stats.size > 0) {
|
|
902
|
+
const fd = fs.openSync(historyPath, 'r');
|
|
903
|
+
const bufferSize = Math.min(1024, stats.size);
|
|
904
|
+
const buffer = Buffer.alloc(bufferSize);
|
|
905
|
+
fs.readSync(fd, buffer, 0, bufferSize, stats.size - bufferSize);
|
|
906
|
+
fs.closeSync(fd);
|
|
907
|
+
const lines = buffer.toString().trim().split('\n');
|
|
908
|
+
const lastLine = lines[lines.length - 1];
|
|
909
|
+
if (lastLine) {
|
|
910
|
+
try {
|
|
911
|
+
const msg = JSON.parse(lastLine);
|
|
912
|
+
lastActivity = msg.timestamp;
|
|
913
|
+
}
|
|
914
|
+
catch {
|
|
915
|
+
// Malformed last line — skip
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
catch {
|
|
921
|
+
// History file may not exist
|
|
922
|
+
}
|
|
923
|
+
threads.push({
|
|
924
|
+
name: threadName,
|
|
925
|
+
stage: config.pipeline.stage,
|
|
926
|
+
score: config.pipeline.score ?? 0,
|
|
927
|
+
lastContact: config.pipeline.lastContact,
|
|
928
|
+
lastActivity,
|
|
929
|
+
nextAction: config.pipeline.nextAction,
|
|
930
|
+
nextActionDate: config.pipeline.nextActionDate,
|
|
931
|
+
});
|
|
932
|
+
}
|
|
933
|
+
// Group by stage
|
|
934
|
+
const byStage = {};
|
|
935
|
+
for (const stage of STAGE_ORDER) {
|
|
936
|
+
byStage[stage] = threads.filter(t => t.stage === stage);
|
|
937
|
+
}
|
|
938
|
+
// Sort threads within each stage by score (descending)
|
|
939
|
+
for (const stage of Object.keys(byStage)) {
|
|
940
|
+
byStage[stage].sort((a, b) => b.score - a.score);
|
|
941
|
+
}
|
|
942
|
+
jsonResponse(res, 200, {
|
|
943
|
+
threads: byStage,
|
|
944
|
+
stats: {
|
|
945
|
+
total: threads.length,
|
|
946
|
+
byStage: Object.fromEntries(STAGE_ORDER.map(s => [s, byStage[s].length])),
|
|
947
|
+
},
|
|
948
|
+
});
|
|
949
|
+
}
|
|
950
|
+
catch (err) {
|
|
951
|
+
jsonResponse(res, 500, { error: String(err) });
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
async function handleUpdatePipeline(threadName, req, res) {
|
|
955
|
+
let body;
|
|
956
|
+
try {
|
|
957
|
+
const raw = await readBody(req);
|
|
958
|
+
body = JSON.parse(raw);
|
|
959
|
+
}
|
|
960
|
+
catch {
|
|
961
|
+
jsonResponse(res, 400, { error: 'Invalid JSON body' });
|
|
962
|
+
return;
|
|
963
|
+
}
|
|
964
|
+
// Validate stage if provided
|
|
965
|
+
if (body.stage !== undefined &&
|
|
966
|
+
!STAGE_ORDER.includes(body.stage)) {
|
|
967
|
+
jsonResponse(res, 400, { error: `Invalid stage. Must be one of: ${STAGE_ORDER.join(', ')}` });
|
|
968
|
+
return;
|
|
969
|
+
}
|
|
970
|
+
// Validate score if provided
|
|
971
|
+
if (body.score !== undefined) {
|
|
972
|
+
const score = Number(body.score);
|
|
973
|
+
if (isNaN(score) || score < 0 || score > 100) {
|
|
974
|
+
jsonResponse(res, 400, { error: 'Score must be a number between 0 and 100' });
|
|
975
|
+
return;
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
try {
|
|
979
|
+
// Read-merge-write to avoid clobbering sibling pipeline fields
|
|
980
|
+
const existing = await loadThreadConfig(threadName);
|
|
981
|
+
const pipelineUpdates = {};
|
|
982
|
+
for (const key of ['stage', 'score', 'lastContact', 'nextAction', 'nextActionDate']) {
|
|
983
|
+
if (body[key] !== undefined)
|
|
984
|
+
pipelineUpdates[key] = body[key];
|
|
985
|
+
}
|
|
986
|
+
existing.pipeline = { ...existing.pipeline, ...pipelineUpdates };
|
|
987
|
+
await saveThreadConfig(threadName, existing);
|
|
988
|
+
jsonResponse(res, 200, { pipeline: existing.pipeline, thread: threadName });
|
|
989
|
+
}
|
|
990
|
+
catch (err) {
|
|
991
|
+
jsonResponse(res, 500, { error: String(err) });
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
// ─── Schedule handlers ─────────────────────────────────────
|
|
995
|
+
async function handleListSchedules(threadName, res) {
|
|
996
|
+
try {
|
|
997
|
+
const config = await loadThreadConfig(threadName);
|
|
998
|
+
jsonResponse(res, 200, { schedules: config.schedules || [] });
|
|
999
|
+
}
|
|
1000
|
+
catch (err) {
|
|
1001
|
+
jsonResponse(res, 500, { error: String(err) });
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
async function handleCreateSchedule(threadName, req, res, scheduler) {
|
|
1005
|
+
let body;
|
|
1006
|
+
try {
|
|
1007
|
+
const raw = await readBody(req);
|
|
1008
|
+
body = JSON.parse(raw);
|
|
1009
|
+
}
|
|
1010
|
+
catch {
|
|
1011
|
+
jsonResponse(res, 400, { error: 'Invalid JSON body' });
|
|
1012
|
+
return;
|
|
1013
|
+
}
|
|
1014
|
+
if (!body.id || !body.trigger || !body.message) {
|
|
1015
|
+
jsonResponse(res, 400, { error: 'Missing required fields: id, trigger, message' });
|
|
1016
|
+
return;
|
|
1017
|
+
}
|
|
1018
|
+
if (body.trigger !== 'once' && body.trigger !== 'cron') {
|
|
1019
|
+
jsonResponse(res, 400, { error: 'trigger must be "once" or "cron"' });
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
1022
|
+
if (body.trigger === 'once' && !body.at) {
|
|
1023
|
+
jsonResponse(res, 400, { error: 'Once trigger requires "at" datetime field' });
|
|
1024
|
+
return;
|
|
1025
|
+
}
|
|
1026
|
+
if (body.trigger === 'cron' && !body.cron) {
|
|
1027
|
+
jsonResponse(res, 400, { error: 'Cron trigger requires "cron" expression field' });
|
|
1028
|
+
return;
|
|
1029
|
+
}
|
|
1030
|
+
try {
|
|
1031
|
+
const config = await loadThreadConfig(threadName);
|
|
1032
|
+
const schedules = config.schedules || [];
|
|
1033
|
+
// Replace if same ID exists
|
|
1034
|
+
const filtered = schedules.filter(s => s.id !== body.id);
|
|
1035
|
+
filtered.push({
|
|
1036
|
+
id: body.id,
|
|
1037
|
+
trigger: body.trigger,
|
|
1038
|
+
...(body.at ? { at: body.at } : {}),
|
|
1039
|
+
...(body.cron ? { cron: body.cron } : {}),
|
|
1040
|
+
message: body.message,
|
|
1041
|
+
});
|
|
1042
|
+
await saveThreadConfig(threadName, { schedules: filtered });
|
|
1043
|
+
scheduler?.reloadThread(threadName);
|
|
1044
|
+
jsonResponse(res, 200, { created: true, id: body.id, trigger: body.trigger });
|
|
1045
|
+
}
|
|
1046
|
+
catch (err) {
|
|
1047
|
+
jsonResponse(res, 500, { error: String(err) });
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
async function handleDeleteSchedule(threadName, scheduleId, res, scheduler) {
|
|
1051
|
+
try {
|
|
1052
|
+
const config = await loadThreadConfig(threadName);
|
|
1053
|
+
const schedules = config.schedules || [];
|
|
1054
|
+
const before = schedules.length;
|
|
1055
|
+
const filtered = schedules.filter(s => s.id !== scheduleId);
|
|
1056
|
+
if (filtered.length === before) {
|
|
1057
|
+
jsonResponse(res, 404, { error: `Schedule "${scheduleId}" not found` });
|
|
1058
|
+
return;
|
|
1059
|
+
}
|
|
1060
|
+
await saveThreadConfig(threadName, { schedules: filtered });
|
|
1061
|
+
scheduler?.reloadThread(threadName);
|
|
1062
|
+
jsonResponse(res, 200, { cancelled: true, id: scheduleId });
|
|
1063
|
+
}
|
|
1064
|
+
catch (err) {
|
|
1065
|
+
jsonResponse(res, 500, { error: String(err) });
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
784
1068
|
async function handleListTemplates(res) {
|
|
785
1069
|
try {
|
|
786
1070
|
const templates = listTemplates();
|
|
@@ -1002,16 +1286,95 @@ async function handleNotify(req, res) {
|
|
|
1002
1286
|
jsonResponse(res, 500, { error: String(err) });
|
|
1003
1287
|
}
|
|
1004
1288
|
}
|
|
1289
|
+
// ─── Media upload ────────────────────────────────────────────────────────────
|
|
1290
|
+
async function handleMediaUpload(req, res) {
|
|
1291
|
+
const mediaDir = getMediaDir();
|
|
1292
|
+
// Read raw body (binary)
|
|
1293
|
+
const chunks = [];
|
|
1294
|
+
for await (const chunk of req) {
|
|
1295
|
+
chunks.push(chunk);
|
|
1296
|
+
}
|
|
1297
|
+
const body = Buffer.concat(chunks);
|
|
1298
|
+
if (body.length === 0) {
|
|
1299
|
+
jsonResponse(res, 400, { error: 'Empty request body' });
|
|
1300
|
+
return;
|
|
1301
|
+
}
|
|
1302
|
+
// Max 50MB
|
|
1303
|
+
if (body.length > 50 * 1024 * 1024) {
|
|
1304
|
+
jsonResponse(res, 413, { error: 'File too large (max 50MB)' });
|
|
1305
|
+
return;
|
|
1306
|
+
}
|
|
1307
|
+
const contentType = req.headers['content-type'] || '';
|
|
1308
|
+
if (contentType.includes('multipart/form-data')) {
|
|
1309
|
+
const boundaryMatch = contentType.match(/boundary=(.+)/);
|
|
1310
|
+
if (!boundaryMatch) {
|
|
1311
|
+
jsonResponse(res, 400, { error: 'Missing multipart boundary' });
|
|
1312
|
+
return;
|
|
1313
|
+
}
|
|
1314
|
+
const boundary = boundaryMatch[1];
|
|
1315
|
+
const parts = parseMultipart(body, boundary);
|
|
1316
|
+
if (parts.length === 0) {
|
|
1317
|
+
jsonResponse(res, 400, { error: 'No file found in multipart body' });
|
|
1318
|
+
return;
|
|
1319
|
+
}
|
|
1320
|
+
const results = [];
|
|
1321
|
+
for (const part of parts) {
|
|
1322
|
+
const result = saveMediaFile(mediaDir, part.filename || 'upload', part.data);
|
|
1323
|
+
results.push(result);
|
|
1324
|
+
}
|
|
1325
|
+
jsonResponse(res, 200, { files: results });
|
|
1326
|
+
}
|
|
1327
|
+
else {
|
|
1328
|
+
// Raw binary upload — filename from query param or X-Filename header
|
|
1329
|
+
const urlObj = new URL(req.url || '/', `http://${req.headers.host}`);
|
|
1330
|
+
const filename = urlObj.searchParams.get('filename') || req.headers['x-filename'] || 'upload';
|
|
1331
|
+
const result = saveMediaFile(mediaDir, filename, body);
|
|
1332
|
+
jsonResponse(res, 200, result);
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
function parseMultipart(body, boundary) {
|
|
1336
|
+
const parts = [];
|
|
1337
|
+
const bodyStr = body.toString('binary');
|
|
1338
|
+
const sections = bodyStr.split(`--${boundary}`);
|
|
1339
|
+
for (const section of sections) {
|
|
1340
|
+
if (section === '' || section.startsWith('--'))
|
|
1341
|
+
continue;
|
|
1342
|
+
const headerEnd = section.indexOf('\r\n\r\n');
|
|
1343
|
+
if (headerEnd === -1)
|
|
1344
|
+
continue;
|
|
1345
|
+
const headers = section.slice(0, headerEnd);
|
|
1346
|
+
const dataStr = section.slice(headerEnd + 4);
|
|
1347
|
+
const trimmed = dataStr.endsWith('\r\n') ? dataStr.slice(0, -2) : dataStr;
|
|
1348
|
+
const filenameMatch = headers.match(/filename="([^"]+)"/);
|
|
1349
|
+
const ctMatch = headers.match(/Content-Type:\s*(.+)/i);
|
|
1350
|
+
parts.push({
|
|
1351
|
+
filename: filenameMatch?.[1],
|
|
1352
|
+
contentType: ctMatch?.[1]?.trim(),
|
|
1353
|
+
data: Buffer.from(trimmed, 'binary'),
|
|
1354
|
+
});
|
|
1355
|
+
}
|
|
1356
|
+
return parts;
|
|
1357
|
+
}
|
|
1358
|
+
function saveMediaFile(mediaDir, originalFilename, data) {
|
|
1359
|
+
const ext = path.extname(originalFilename).toLowerCase() || '.bin';
|
|
1360
|
+
const baseName = path
|
|
1361
|
+
.basename(originalFilename, ext)
|
|
1362
|
+
.replace(/[^a-zA-Z0-9_-]/g, '_')
|
|
1363
|
+
.slice(0, 64);
|
|
1364
|
+
const hash = crypto.createHash('md5').update(data).digest('hex').slice(0, 8);
|
|
1365
|
+
const filename = `${baseName}-${hash}${ext}`;
|
|
1366
|
+
fs.writeFileSync(path.join(mediaDir, filename), data);
|
|
1367
|
+
return {
|
|
1368
|
+
filename,
|
|
1369
|
+
url: `/media/${filename}`,
|
|
1370
|
+
size: data.length,
|
|
1371
|
+
};
|
|
1372
|
+
}
|
|
1005
1373
|
// ─── Server ──────────────────────────────────────────────────────────────────
|
|
1006
1374
|
export async function startGatewayServer(options) {
|
|
1007
1375
|
const { port, ...pipelineOpts } = options;
|
|
1008
1376
|
let currentApiKeys = options.apiKeys;
|
|
1009
|
-
|
|
1010
|
-
if (options.basePath) {
|
|
1011
|
-
const threadsDir = path.join(options.basePath, 'threads');
|
|
1012
|
-
fs.mkdirSync(threadsDir, { recursive: true });
|
|
1013
|
-
setThreadsDir(threadsDir);
|
|
1014
|
-
}
|
|
1377
|
+
let scheduler;
|
|
1015
1378
|
// Create or use provided executor registry
|
|
1016
1379
|
const executorRegistry = options.executorRegistry || new ExecutorRegistry();
|
|
1017
1380
|
pipelineOpts.executorRegistry = executorRegistry;
|
|
@@ -1036,7 +1399,11 @@ export async function startGatewayServer(options) {
|
|
|
1036
1399
|
const { path: routePath, params, query } = parseRoute(req.url || '/');
|
|
1037
1400
|
// Health check — no auth required
|
|
1038
1401
|
if (routePath === '/health' && req.method === 'GET') {
|
|
1039
|
-
jsonResponse(res, 200, {
|
|
1402
|
+
jsonResponse(res, 200, {
|
|
1403
|
+
status: 'ok',
|
|
1404
|
+
timestamp: new Date().toISOString(),
|
|
1405
|
+
activeSubprocesses: getActiveSubprocessCount(),
|
|
1406
|
+
});
|
|
1040
1407
|
return;
|
|
1041
1408
|
}
|
|
1042
1409
|
// Static files — no auth required (/chat, /widget.js)
|
|
@@ -1048,6 +1415,12 @@ export async function startGatewayServer(options) {
|
|
|
1048
1415
|
handleGetVapidKey(res, vapidConfig);
|
|
1049
1416
|
return;
|
|
1050
1417
|
}
|
|
1418
|
+
// POST /api/hooks/:type — inbound webhooks (no API key auth — uses signature verification)
|
|
1419
|
+
if ((routePath === '/api/hooks/:type' || routePath === '/api/hooks/:type/:name') &&
|
|
1420
|
+
req.method === 'POST') {
|
|
1421
|
+
await handleWebhook(params.type, params.name, req, res, pipelineOpts, options.hooks);
|
|
1422
|
+
return;
|
|
1423
|
+
}
|
|
1051
1424
|
// All other routes require auth
|
|
1052
1425
|
if (!authenticate(req, res, currentApiKeys))
|
|
1053
1426
|
return;
|
|
@@ -1098,6 +1471,34 @@ export async function startGatewayServer(options) {
|
|
|
1098
1471
|
await handleUpdateThreadConfig(params.name, req, res);
|
|
1099
1472
|
return;
|
|
1100
1473
|
}
|
|
1474
|
+
// PUT /api/thread/:name/pipeline — update pipeline metadata (for dashboard drag-drop)
|
|
1475
|
+
if (routePath === '/api/thread/:name/pipeline' && req.method === 'PUT') {
|
|
1476
|
+
await handleUpdatePipeline(params.name, req, res);
|
|
1477
|
+
return;
|
|
1478
|
+
}
|
|
1479
|
+
// GET /api/thread/:name/schedules — list schedules
|
|
1480
|
+
if (routePath === '/api/thread/:name/schedules' && req.method === 'GET') {
|
|
1481
|
+
await handleListSchedules(params.name, res);
|
|
1482
|
+
return;
|
|
1483
|
+
}
|
|
1484
|
+
// POST /api/thread/:name/schedules — create/update a schedule
|
|
1485
|
+
if (routePath === '/api/thread/:name/schedules' && req.method === 'POST') {
|
|
1486
|
+
await handleCreateSchedule(params.name, req, res, scheduler);
|
|
1487
|
+
return;
|
|
1488
|
+
}
|
|
1489
|
+
// DELETE /api/thread/:name/schedules/:id — cancel a schedule
|
|
1490
|
+
if (routePath.startsWith('/api/thread/:name/schedules/') && req.method === 'DELETE') {
|
|
1491
|
+
const scheduleId = routePath.replace('/api/thread/:name/schedules/', '');
|
|
1492
|
+
if (scheduleId) {
|
|
1493
|
+
await handleDeleteSchedule(params.name, decodeURIComponent(scheduleId), res, scheduler);
|
|
1494
|
+
return;
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
// GET /api/dashboard — pipeline dashboard data
|
|
1498
|
+
if (routePath === '/api/dashboard' && req.method === 'GET') {
|
|
1499
|
+
await handleGetDashboard(res);
|
|
1500
|
+
return;
|
|
1501
|
+
}
|
|
1101
1502
|
// GET /api/threads
|
|
1102
1503
|
if (routePath === '/api/threads' && req.method === 'GET') {
|
|
1103
1504
|
await handleListThreads(res, executorRegistry);
|
|
@@ -1113,6 +1514,26 @@ export async function startGatewayServer(options) {
|
|
|
1113
1514
|
await handleListTemplates(res);
|
|
1114
1515
|
return;
|
|
1115
1516
|
}
|
|
1517
|
+
// GET /api/models — list available models for per-thread selection
|
|
1518
|
+
if (routePath === '/api/models' && req.method === 'GET') {
|
|
1519
|
+
// Build models list with global default marked
|
|
1520
|
+
let models = (options.models || []).map(m => ({ ...m }));
|
|
1521
|
+
if (models.length === 0) {
|
|
1522
|
+
// Fallback: just Claude
|
|
1523
|
+
models = [
|
|
1524
|
+
{ id: 'claude', label: 'Claude (CLI)', provider: 'claude-cli', default: true },
|
|
1525
|
+
];
|
|
1526
|
+
}
|
|
1527
|
+
else {
|
|
1528
|
+
// Clear all defaults, then set the global default (or first if none specified)
|
|
1529
|
+
const globalDefault = options.model || models[0].id;
|
|
1530
|
+
for (const m of models) {
|
|
1531
|
+
m.default = m.id === globalDefault ? true : undefined;
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
jsonResponse(res, 200, { models });
|
|
1535
|
+
return;
|
|
1536
|
+
}
|
|
1116
1537
|
// GET /api/agents — list agents
|
|
1117
1538
|
if (routePath === '/api/agents' && req.method === 'GET') {
|
|
1118
1539
|
await handleListAgents(res);
|
|
@@ -1148,6 +1569,11 @@ export async function startGatewayServer(options) {
|
|
|
1148
1569
|
await handleNotify(req, res);
|
|
1149
1570
|
return;
|
|
1150
1571
|
}
|
|
1572
|
+
// POST /api/media/upload — upload files to media directory
|
|
1573
|
+
if (routePath === '/api/media/upload' && req.method === 'POST') {
|
|
1574
|
+
await handleMediaUpload(req, res);
|
|
1575
|
+
return;
|
|
1576
|
+
}
|
|
1151
1577
|
// 404
|
|
1152
1578
|
jsonResponse(res, 404, { error: 'Not found' });
|
|
1153
1579
|
}
|
|
@@ -1210,6 +1636,9 @@ export async function startGatewayServer(options) {
|
|
|
1210
1636
|
setBroadcastToThread: (fn) => {
|
|
1211
1637
|
pipelineOpts.broadcastToThread = fn;
|
|
1212
1638
|
},
|
|
1639
|
+
setScheduler: (s) => {
|
|
1640
|
+
scheduler = s;
|
|
1641
|
+
},
|
|
1213
1642
|
close: async () => {
|
|
1214
1643
|
// Abort any in-flight message processing
|
|
1215
1644
|
threadQueues.clear();
|