create-ironclaws 1.0.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 +101 -0
- package/bin/create.js +394 -0
- package/package.json +33 -0
- package/template/.env.example +38 -0
- package/template/CLAUDE.md +104 -0
- package/template/agent-credentials.yaml +33 -0
- package/template/agents.yaml +22 -0
- package/template/container/Dockerfile +70 -0
- package/template/container/Dockerfile.argus +34 -0
- package/template/container/agent-runner/package-lock.json +1524 -0
- package/template/container/agent-runner/package.json +23 -0
- package/template/container/agent-runner/src/index.ts +630 -0
- package/template/container/agent-runner/src/ipc-mcp-stdio.ts +339 -0
- package/template/container/agent-runner/tsconfig.json +15 -0
- package/template/container/build-argus.sh +25 -0
- package/template/container/build.sh +23 -0
- package/template/container/skills/agent-browser/SKILL.md +159 -0
- package/template/container/skills/agent-status/SKILL.md +69 -0
- package/template/container/skills/capabilities/SKILL.md +100 -0
- package/template/container/skills/edit-agent/SKILL.md +93 -0
- package/template/container/skills/slack-formatting/SKILL.md +92 -0
- package/template/container/skills/status/SKILL.md +104 -0
- package/template/container/tools/elastic_query.py +161 -0
- package/template/container/tools/gdrive_tool.py +185 -0
- package/template/container/tools/jira_tool.py +433 -0
- package/template/container/tools/slack_history_tool.py +144 -0
- package/template/container/tools/youtube_tool.py +174 -0
- package/template/docker-compose.yml +54 -0
- package/template/docs/how-it-works.md +496 -0
- package/template/eslint.config.js +32 -0
- package/template/groups/forge/CLAUDE.md +107 -0
- package/template/package-lock.json +5278 -0
- package/template/package.json +52 -0
- package/template/scripts/github-app-token.py +58 -0
- package/template/scripts/register-expense-agent.sh +121 -0
- package/template/scripts/run-migrations.ts +105 -0
- package/template/scripts/setup-onecli-secrets.sh +252 -0
- package/template/setup-agents.sh +142 -0
- package/template/src/channels/index.ts +13 -0
- package/template/src/channels/registry.test.ts +42 -0
- package/template/src/channels/registry.ts +28 -0
- package/template/src/channels/slack.test.ts +859 -0
- package/template/src/channels/slack.ts +373 -0
- package/template/src/claw-skill.test.ts +45 -0
- package/template/src/config.ts +94 -0
- package/template/src/container-runner.test.ts +221 -0
- package/template/src/container-runner.ts +1029 -0
- package/template/src/container-runtime.test.ts +149 -0
- package/template/src/container-runtime.ts +124 -0
- package/template/src/db-migration.test.ts +67 -0
- package/template/src/db.test.ts +484 -0
- package/template/src/db.ts +837 -0
- package/template/src/env.ts +42 -0
- package/template/src/formatting.test.ts +294 -0
- package/template/src/github-token.ts +48 -0
- package/template/src/google-token.ts +75 -0
- package/template/src/group-folder.test.ts +43 -0
- package/template/src/group-folder.ts +44 -0
- package/template/src/group-queue.test.ts +484 -0
- package/template/src/group-queue.ts +363 -0
- package/template/src/http-server.ts +343 -0
- package/template/src/index.ts +960 -0
- package/template/src/ipc-auth.test.ts +679 -0
- package/template/src/ipc.ts +548 -0
- package/template/src/logger.ts +16 -0
- package/template/src/mount-security.ts +421 -0
- package/template/src/network-policy.ts +119 -0
- package/template/src/remote-control.test.ts +397 -0
- package/template/src/remote-control.ts +224 -0
- package/template/src/router.ts +52 -0
- package/template/src/routing.test.ts +170 -0
- package/template/src/sender-allowlist.test.ts +216 -0
- package/template/src/sender-allowlist.ts +128 -0
- package/template/src/task-scheduler.test.ts +129 -0
- package/template/src/task-scheduler.ts +290 -0
- package/template/src/timezone.test.ts +73 -0
- package/template/src/timezone.ts +37 -0
- package/template/src/types.ts +114 -0
- package/template/src/worktree.ts +206 -0
- package/template/tsconfig.json +20 -0
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import { ChildProcess } from 'child_process';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
|
|
5
|
+
import { BASE_RETRY_MS, DATA_DIR, MAX_CONCURRENT_CONTAINERS, MAX_RETRIES } from './config.js';
|
|
6
|
+
import { logger } from './logger.js';
|
|
7
|
+
|
|
8
|
+
interface QueuedTask {
|
|
9
|
+
id: string;
|
|
10
|
+
groupJid: string;
|
|
11
|
+
fn: () => Promise<void>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
interface GroupState {
|
|
16
|
+
active: boolean;
|
|
17
|
+
idleWaiting: boolean;
|
|
18
|
+
isTaskContainer: boolean;
|
|
19
|
+
runningTaskId: string | null;
|
|
20
|
+
pendingMessages: boolean;
|
|
21
|
+
pendingTasks: QueuedTask[];
|
|
22
|
+
process: ChildProcess | null;
|
|
23
|
+
containerName: string | null;
|
|
24
|
+
groupFolder: string | null;
|
|
25
|
+
retryCount: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class GroupQueue {
|
|
29
|
+
private groups = new Map<string, GroupState>();
|
|
30
|
+
private activeCount = 0;
|
|
31
|
+
private waitingGroups: string[] = [];
|
|
32
|
+
private processMessagesFn: ((groupJid: string) => Promise<boolean>) | null =
|
|
33
|
+
null;
|
|
34
|
+
private shuttingDown = false;
|
|
35
|
+
|
|
36
|
+
private getGroup(groupJid: string): GroupState {
|
|
37
|
+
let state = this.groups.get(groupJid);
|
|
38
|
+
if (!state) {
|
|
39
|
+
state = {
|
|
40
|
+
active: false,
|
|
41
|
+
idleWaiting: false,
|
|
42
|
+
isTaskContainer: false,
|
|
43
|
+
runningTaskId: null,
|
|
44
|
+
pendingMessages: false,
|
|
45
|
+
pendingTasks: [],
|
|
46
|
+
process: null,
|
|
47
|
+
containerName: null,
|
|
48
|
+
groupFolder: null,
|
|
49
|
+
retryCount: 0,
|
|
50
|
+
};
|
|
51
|
+
this.groups.set(groupJid, state);
|
|
52
|
+
}
|
|
53
|
+
return state;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
setProcessMessagesFn(fn: (groupJid: string) => Promise<boolean>): void {
|
|
57
|
+
this.processMessagesFn = fn;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
enqueueMessageCheck(groupJid: string): void {
|
|
61
|
+
if (this.shuttingDown) return;
|
|
62
|
+
|
|
63
|
+
const state = this.getGroup(groupJid);
|
|
64
|
+
|
|
65
|
+
if (state.active) {
|
|
66
|
+
state.pendingMessages = true;
|
|
67
|
+
logger.debug({ groupJid }, 'Container active, message queued');
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (this.activeCount >= MAX_CONCURRENT_CONTAINERS) {
|
|
72
|
+
state.pendingMessages = true;
|
|
73
|
+
if (!this.waitingGroups.includes(groupJid)) {
|
|
74
|
+
this.waitingGroups.push(groupJid);
|
|
75
|
+
}
|
|
76
|
+
logger.debug(
|
|
77
|
+
{ groupJid, activeCount: this.activeCount },
|
|
78
|
+
'At concurrency limit, message queued',
|
|
79
|
+
);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
this.runForGroup(groupJid, 'messages').catch((err) =>
|
|
84
|
+
logger.error({ groupJid, err }, 'Unhandled error in runForGroup'),
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
enqueueTask(groupJid: string, taskId: string, fn: () => Promise<void>): void {
|
|
89
|
+
if (this.shuttingDown) return;
|
|
90
|
+
|
|
91
|
+
const state = this.getGroup(groupJid);
|
|
92
|
+
|
|
93
|
+
// Prevent double-queuing: check both pending and currently-running task
|
|
94
|
+
if (state.runningTaskId === taskId) {
|
|
95
|
+
logger.debug({ groupJid, taskId }, 'Task already running, skipping');
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (state.pendingTasks.some((t) => t.id === taskId)) {
|
|
99
|
+
logger.debug({ groupJid, taskId }, 'Task already queued, skipping');
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (state.active) {
|
|
104
|
+
state.pendingTasks.push({ id: taskId, groupJid, fn });
|
|
105
|
+
if (state.idleWaiting) {
|
|
106
|
+
this.closeStdin(groupJid);
|
|
107
|
+
}
|
|
108
|
+
logger.debug({ groupJid, taskId }, 'Container active, task queued');
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (this.activeCount >= MAX_CONCURRENT_CONTAINERS) {
|
|
113
|
+
state.pendingTasks.push({ id: taskId, groupJid, fn });
|
|
114
|
+
if (!this.waitingGroups.includes(groupJid)) {
|
|
115
|
+
this.waitingGroups.push(groupJid);
|
|
116
|
+
}
|
|
117
|
+
logger.debug(
|
|
118
|
+
{ groupJid, taskId, activeCount: this.activeCount },
|
|
119
|
+
'At concurrency limit, task queued',
|
|
120
|
+
);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Run immediately
|
|
125
|
+
this.runTask(groupJid, { id: taskId, groupJid, fn }).catch((err) =>
|
|
126
|
+
logger.error({ groupJid, taskId, err }, 'Unhandled error in runTask'),
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
registerProcess(
|
|
131
|
+
groupJid: string,
|
|
132
|
+
proc: ChildProcess,
|
|
133
|
+
containerName: string,
|
|
134
|
+
groupFolder?: string,
|
|
135
|
+
): void {
|
|
136
|
+
const state = this.getGroup(groupJid);
|
|
137
|
+
state.process = proc;
|
|
138
|
+
state.containerName = containerName;
|
|
139
|
+
if (groupFolder) state.groupFolder = groupFolder;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Mark the container as idle-waiting (finished work, waiting for IPC input).
|
|
144
|
+
* If tasks are pending, preempt the idle container immediately.
|
|
145
|
+
*/
|
|
146
|
+
notifyIdle(groupJid: string): void {
|
|
147
|
+
const state = this.getGroup(groupJid);
|
|
148
|
+
state.idleWaiting = true;
|
|
149
|
+
if (state.pendingTasks.length > 0) {
|
|
150
|
+
this.closeStdin(groupJid);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Send a follow-up message to the active container via IPC file.
|
|
156
|
+
* Returns true if the message was written, false if no active container.
|
|
157
|
+
*/
|
|
158
|
+
sendMessage(groupJid: string, text: string): boolean {
|
|
159
|
+
const state = this.getGroup(groupJid);
|
|
160
|
+
if (!state.active || !state.groupFolder || state.isTaskContainer)
|
|
161
|
+
return false;
|
|
162
|
+
state.idleWaiting = false; // Agent is about to receive work, no longer idle
|
|
163
|
+
|
|
164
|
+
const inputDir = path.join(DATA_DIR, 'ipc', state.groupFolder, 'input');
|
|
165
|
+
try {
|
|
166
|
+
fs.mkdirSync(inputDir, { recursive: true });
|
|
167
|
+
const filename = `${Date.now()}-${Math.random().toString(36).slice(2, 6)}.json`;
|
|
168
|
+
const filepath = path.join(inputDir, filename);
|
|
169
|
+
const tempPath = `${filepath}.tmp`;
|
|
170
|
+
fs.writeFileSync(tempPath, JSON.stringify({ type: 'message', text }));
|
|
171
|
+
fs.renameSync(tempPath, filepath);
|
|
172
|
+
return true;
|
|
173
|
+
} catch {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Signal the active container to wind down by writing a close sentinel.
|
|
180
|
+
*/
|
|
181
|
+
closeStdin(groupJid: string): void {
|
|
182
|
+
const state = this.getGroup(groupJid);
|
|
183
|
+
if (!state.active || !state.groupFolder) return;
|
|
184
|
+
|
|
185
|
+
const inputDir = path.join(DATA_DIR, 'ipc', state.groupFolder, 'input');
|
|
186
|
+
try {
|
|
187
|
+
fs.mkdirSync(inputDir, { recursive: true });
|
|
188
|
+
fs.writeFileSync(path.join(inputDir, '_close'), '');
|
|
189
|
+
} catch {
|
|
190
|
+
// ignore
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private async runForGroup(
|
|
195
|
+
groupJid: string,
|
|
196
|
+
reason: 'messages' | 'drain',
|
|
197
|
+
): Promise<void> {
|
|
198
|
+
const state = this.getGroup(groupJid);
|
|
199
|
+
state.active = true;
|
|
200
|
+
state.idleWaiting = false;
|
|
201
|
+
state.isTaskContainer = false;
|
|
202
|
+
state.pendingMessages = false;
|
|
203
|
+
this.activeCount++;
|
|
204
|
+
|
|
205
|
+
logger.debug(
|
|
206
|
+
{ groupJid, reason, activeCount: this.activeCount },
|
|
207
|
+
'Starting container for group',
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
try {
|
|
211
|
+
if (this.processMessagesFn) {
|
|
212
|
+
const success = await this.processMessagesFn(groupJid);
|
|
213
|
+
if (success) {
|
|
214
|
+
state.retryCount = 0;
|
|
215
|
+
} else {
|
|
216
|
+
this.scheduleRetry(groupJid, state);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
} catch (err) {
|
|
220
|
+
logger.error({ groupJid, err }, 'Error processing messages for group');
|
|
221
|
+
this.scheduleRetry(groupJid, state);
|
|
222
|
+
} finally {
|
|
223
|
+
state.active = false;
|
|
224
|
+
state.process = null;
|
|
225
|
+
state.containerName = null;
|
|
226
|
+
state.groupFolder = null;
|
|
227
|
+
this.activeCount--;
|
|
228
|
+
this.drainGroup(groupJid);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private async runTask(groupJid: string, task: QueuedTask): Promise<void> {
|
|
233
|
+
const state = this.getGroup(groupJid);
|
|
234
|
+
state.active = true;
|
|
235
|
+
state.idleWaiting = false;
|
|
236
|
+
state.isTaskContainer = true;
|
|
237
|
+
state.runningTaskId = task.id;
|
|
238
|
+
this.activeCount++;
|
|
239
|
+
|
|
240
|
+
logger.debug(
|
|
241
|
+
{ groupJid, taskId: task.id, activeCount: this.activeCount },
|
|
242
|
+
'Running queued task',
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
try {
|
|
246
|
+
await task.fn();
|
|
247
|
+
} catch (err) {
|
|
248
|
+
logger.error({ groupJid, taskId: task.id, err }, 'Error running task');
|
|
249
|
+
} finally {
|
|
250
|
+
state.active = false;
|
|
251
|
+
state.isTaskContainer = false;
|
|
252
|
+
state.runningTaskId = null;
|
|
253
|
+
state.process = null;
|
|
254
|
+
state.containerName = null;
|
|
255
|
+
state.groupFolder = null;
|
|
256
|
+
this.activeCount--;
|
|
257
|
+
this.drainGroup(groupJid);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private scheduleRetry(groupJid: string, state: GroupState): void {
|
|
262
|
+
state.retryCount++;
|
|
263
|
+
if (state.retryCount > MAX_RETRIES) {
|
|
264
|
+
logger.error(
|
|
265
|
+
{ groupJid, retryCount: state.retryCount },
|
|
266
|
+
'Max retries exceeded, dropping messages (will retry on next incoming message)',
|
|
267
|
+
);
|
|
268
|
+
state.retryCount = 0;
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const delayMs = BASE_RETRY_MS * Math.pow(2, state.retryCount - 1);
|
|
273
|
+
logger.info(
|
|
274
|
+
{ groupJid, retryCount: state.retryCount, delayMs },
|
|
275
|
+
'Scheduling retry with backoff',
|
|
276
|
+
);
|
|
277
|
+
setTimeout(() => {
|
|
278
|
+
if (!this.shuttingDown) {
|
|
279
|
+
this.enqueueMessageCheck(groupJid);
|
|
280
|
+
}
|
|
281
|
+
}, delayMs);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
private drainGroup(groupJid: string): void {
|
|
285
|
+
if (this.shuttingDown) return;
|
|
286
|
+
|
|
287
|
+
const state = this.getGroup(groupJid);
|
|
288
|
+
|
|
289
|
+
// Tasks first (they won't be re-discovered from SQLite like messages)
|
|
290
|
+
if (state.pendingTasks.length > 0) {
|
|
291
|
+
const task = state.pendingTasks.shift()!;
|
|
292
|
+
this.runTask(groupJid, task).catch((err) =>
|
|
293
|
+
logger.error(
|
|
294
|
+
{ groupJid, taskId: task.id, err },
|
|
295
|
+
'Unhandled error in runTask (drain)',
|
|
296
|
+
),
|
|
297
|
+
);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Then pending messages
|
|
302
|
+
if (state.pendingMessages) {
|
|
303
|
+
this.runForGroup(groupJid, 'drain').catch((err) =>
|
|
304
|
+
logger.error(
|
|
305
|
+
{ groupJid, err },
|
|
306
|
+
'Unhandled error in runForGroup (drain)',
|
|
307
|
+
),
|
|
308
|
+
);
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Nothing pending for this group; check if other groups are waiting for a slot
|
|
313
|
+
this.drainWaiting();
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
private drainWaiting(): void {
|
|
317
|
+
while (
|
|
318
|
+
this.waitingGroups.length > 0 &&
|
|
319
|
+
this.activeCount < MAX_CONCURRENT_CONTAINERS
|
|
320
|
+
) {
|
|
321
|
+
const nextJid = this.waitingGroups.shift()!;
|
|
322
|
+
const state = this.getGroup(nextJid);
|
|
323
|
+
|
|
324
|
+
// Prioritize tasks over messages
|
|
325
|
+
if (state.pendingTasks.length > 0) {
|
|
326
|
+
const task = state.pendingTasks.shift()!;
|
|
327
|
+
this.runTask(nextJid, task).catch((err) =>
|
|
328
|
+
logger.error(
|
|
329
|
+
{ groupJid: nextJid, taskId: task.id, err },
|
|
330
|
+
'Unhandled error in runTask (waiting)',
|
|
331
|
+
),
|
|
332
|
+
);
|
|
333
|
+
} else if (state.pendingMessages) {
|
|
334
|
+
this.runForGroup(nextJid, 'drain').catch((err) =>
|
|
335
|
+
logger.error(
|
|
336
|
+
{ groupJid: nextJid, err },
|
|
337
|
+
'Unhandled error in runForGroup (waiting)',
|
|
338
|
+
),
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
// If neither pending, skip this group
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
async shutdown(_gracePeriodMs: number): Promise<void> {
|
|
346
|
+
this.shuttingDown = true;
|
|
347
|
+
|
|
348
|
+
// Count active containers but don't kill them — they'll finish on their own
|
|
349
|
+
// via idle timeout or container timeout. The --rm flag cleans them up on exit.
|
|
350
|
+
// This prevents WhatsApp reconnection restarts from killing working agents.
|
|
351
|
+
const activeContainers: string[] = [];
|
|
352
|
+
for (const [_jid, state] of this.groups) {
|
|
353
|
+
if (state.process && !state.process.killed && state.containerName) {
|
|
354
|
+
activeContainers.push(state.containerName);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
logger.info(
|
|
359
|
+
{ activeCount: this.activeCount, detachedContainers: activeContainers },
|
|
360
|
+
'GroupQueue shutting down (containers detached, not killed)',
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP Server for NanoClaw
|
|
3
|
+
*
|
|
4
|
+
* Receives questionnaire questions via POST and routes them
|
|
5
|
+
* to the questionnaire-assistant OpenShell sandbox agent.
|
|
6
|
+
* Runs alongside the existing Slack connection.
|
|
7
|
+
*/
|
|
8
|
+
import http from 'http';
|
|
9
|
+
|
|
10
|
+
import { findExactMatch, saveQuestionnaireMetric } from './db.js';
|
|
11
|
+
import { readEnvFile } from './env.js';
|
|
12
|
+
import { logger } from './logger.js';
|
|
13
|
+
import { runContainerAgent, ContainerOutput } from './container-runner.js';
|
|
14
|
+
import type { RegisteredGroup } from './types.js';
|
|
15
|
+
|
|
16
|
+
// ─── Concurrency limiter for sandbox execs ───────────────────────────────────
|
|
17
|
+
const MAX_CONCURRENT_EXECS = 10;
|
|
18
|
+
let activeExecs = 0;
|
|
19
|
+
const execQueue: Array<() => void> = [];
|
|
20
|
+
|
|
21
|
+
function acquireExecSlot(): Promise<void> {
|
|
22
|
+
if (activeExecs < MAX_CONCURRENT_EXECS) {
|
|
23
|
+
activeExecs++;
|
|
24
|
+
return Promise.resolve();
|
|
25
|
+
}
|
|
26
|
+
return new Promise((resolve) => {
|
|
27
|
+
execQueue.push(() => {
|
|
28
|
+
activeExecs++;
|
|
29
|
+
resolve();
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function releaseExecSlot(): void {
|
|
35
|
+
activeExecs--;
|
|
36
|
+
const next = execQueue.shift();
|
|
37
|
+
if (next) next();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ─── Types ──────────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
interface QuestionnaireRequest {
|
|
43
|
+
question: string;
|
|
44
|
+
systemPrompt: string | null;
|
|
45
|
+
userEmail: string;
|
|
46
|
+
documentUrl: string;
|
|
47
|
+
documentId: string;
|
|
48
|
+
documentName: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface Citation {
|
|
52
|
+
source: string;
|
|
53
|
+
title: string;
|
|
54
|
+
url: string;
|
|
55
|
+
answer_source: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface QuestionnaireResponse {
|
|
59
|
+
answer: string;
|
|
60
|
+
citations: Citation[];
|
|
61
|
+
answer_source: string;
|
|
62
|
+
confidence: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
function jsonResponse(
|
|
68
|
+
res: http.ServerResponse,
|
|
69
|
+
statusCode: number,
|
|
70
|
+
body: unknown,
|
|
71
|
+
): void {
|
|
72
|
+
const json = JSON.stringify(body);
|
|
73
|
+
res.writeHead(statusCode, {
|
|
74
|
+
'Content-Type': 'application/json',
|
|
75
|
+
'Access-Control-Allow-Origin': '*',
|
|
76
|
+
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
77
|
+
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
|
78
|
+
});
|
|
79
|
+
res.end(json);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function readBody(req: http.IncomingMessage): Promise<string> {
|
|
83
|
+
return new Promise((resolve, reject) => {
|
|
84
|
+
const chunks: Buffer[] = [];
|
|
85
|
+
let size = 0;
|
|
86
|
+
const MAX_BODY = 1_048_576; // 1 MB
|
|
87
|
+
|
|
88
|
+
req.on('data', (chunk: Buffer) => {
|
|
89
|
+
size += chunk.length;
|
|
90
|
+
if (size > MAX_BODY) {
|
|
91
|
+
req.destroy();
|
|
92
|
+
reject(new Error('Request body too large'));
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
chunks.push(chunk);
|
|
96
|
+
});
|
|
97
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
|
|
98
|
+
req.on('error', reject);
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ─── Route handlers ─────────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
function handleStatus(
|
|
105
|
+
_req: http.IncomingMessage,
|
|
106
|
+
res: http.ServerResponse,
|
|
107
|
+
): void {
|
|
108
|
+
jsonResponse(res, 200, { status: 'ok', uptime: process.uptime() });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function handleQuestionnaireAnswer(
|
|
112
|
+
req: http.IncomingMessage,
|
|
113
|
+
res: http.ServerResponse,
|
|
114
|
+
authToken: string,
|
|
115
|
+
registeredGroups: () => Record<string, RegisteredGroup>,
|
|
116
|
+
): Promise<void> {
|
|
117
|
+
// ── Auth ────────────────────────────────────────────────────────────────
|
|
118
|
+
const authHeader = req.headers.authorization;
|
|
119
|
+
if (!authHeader || authHeader !== `Bearer ${authToken}`) {
|
|
120
|
+
jsonResponse(res, 401, { error: 'Unauthorized' });
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ── Parse body ─────────────────────────────────────────────────────────
|
|
125
|
+
let body: QuestionnaireRequest;
|
|
126
|
+
try {
|
|
127
|
+
const raw = await readBody(req);
|
|
128
|
+
body = JSON.parse(raw) as QuestionnaireRequest;
|
|
129
|
+
} catch (err) {
|
|
130
|
+
jsonResponse(res, 400, {
|
|
131
|
+
error: 'Invalid JSON body',
|
|
132
|
+
detail: err instanceof Error ? err.message : String(err),
|
|
133
|
+
});
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const { question, systemPrompt, userEmail, documentUrl, documentId, documentName } =
|
|
138
|
+
body;
|
|
139
|
+
|
|
140
|
+
if (!question || typeof question !== 'string') {
|
|
141
|
+
jsonResponse(res, 400, { error: 'Missing required field: question' });
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const startTime = Date.now();
|
|
146
|
+
|
|
147
|
+
// ── Exact-match cache ──────────────────────────────────────────────────
|
|
148
|
+
const cached = findExactMatch(question);
|
|
149
|
+
if (cached) {
|
|
150
|
+
logger.info({ question: question.slice(0, 80) }, 'Questionnaire cache hit');
|
|
151
|
+
jsonResponse(res, 200, cached);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ── Find the questionnaire-assistant group ─────────────────────────────
|
|
156
|
+
const groups = registeredGroups();
|
|
157
|
+
let group: RegisteredGroup | undefined;
|
|
158
|
+
for (const g of Object.values(groups)) {
|
|
159
|
+
if (g.folder === 'questionnaire-assistant') {
|
|
160
|
+
group = g;
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (!group) {
|
|
166
|
+
jsonResponse(res, 503, {
|
|
167
|
+
error: 'questionnaire-assistant agent is not registered',
|
|
168
|
+
});
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ── Run the agent (with concurrency limit) ─────────────────────────────
|
|
173
|
+
const prompt = `<question>${question}</question>\n<system_prompt>${systemPrompt || ''}</system_prompt>\n<user_email>${userEmail}</user_email>`;
|
|
174
|
+
|
|
175
|
+
// Wait for an exec slot
|
|
176
|
+
await acquireExecSlot();
|
|
177
|
+
logger.info(
|
|
178
|
+
{ question: question.slice(0, 60), activeExecs, queued: execQueue.length },
|
|
179
|
+
'Exec slot acquired',
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
// Use a Promise that resolves when onOutput fires — don't wait for container exit.
|
|
183
|
+
// Same pattern as Slack: respond immediately when the agent produces output.
|
|
184
|
+
try {
|
|
185
|
+
const resultText = await new Promise<string>((resolve, reject) => {
|
|
186
|
+
const timeout = setTimeout(() => {
|
|
187
|
+
reject(new Error('Agent timed out after 5 minutes'));
|
|
188
|
+
}, 300_000);
|
|
189
|
+
|
|
190
|
+
runContainerAgent(
|
|
191
|
+
group,
|
|
192
|
+
{
|
|
193
|
+
prompt,
|
|
194
|
+
groupFolder: 'questionnaire-assistant',
|
|
195
|
+
chatJid: 'http:questionnaire',
|
|
196
|
+
isMain: false,
|
|
197
|
+
assistantName: 'Questionnaire Assistant',
|
|
198
|
+
},
|
|
199
|
+
() => {}, // onProcess — not needed for HTTP
|
|
200
|
+
async (streamedOutput) => {
|
|
201
|
+
// First non-empty result resolves the promise immediately
|
|
202
|
+
if (streamedOutput.result) {
|
|
203
|
+
clearTimeout(timeout);
|
|
204
|
+
resolve(streamedOutput.result);
|
|
205
|
+
}
|
|
206
|
+
},
|
|
207
|
+
).catch((err) => {
|
|
208
|
+
clearTimeout(timeout);
|
|
209
|
+
reject(err);
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// ── Parse agent response ───────────────────────────────────────────────
|
|
214
|
+
let parsed: QuestionnaireResponse;
|
|
215
|
+
try {
|
|
216
|
+
parsed = JSON.parse(resultText.trim());
|
|
217
|
+
} catch {
|
|
218
|
+
try {
|
|
219
|
+
const jsonMatch = resultText.match(/\{[\s\S]*"answer"\s*:\s*"[\s\S]*\}/);
|
|
220
|
+
if (jsonMatch) {
|
|
221
|
+
parsed = JSON.parse(jsonMatch[0]);
|
|
222
|
+
} else {
|
|
223
|
+
throw new Error('No JSON found');
|
|
224
|
+
}
|
|
225
|
+
} catch {
|
|
226
|
+
parsed = {
|
|
227
|
+
answer: resultText,
|
|
228
|
+
citations: [],
|
|
229
|
+
answer_source: 'ai',
|
|
230
|
+
confidence: 'low',
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const responseTimeMs = Date.now() - startTime;
|
|
236
|
+
|
|
237
|
+
saveQuestionnaireMetric({
|
|
238
|
+
user_email: userEmail || null,
|
|
239
|
+
document_url: documentUrl || null,
|
|
240
|
+
document_id: documentId || null,
|
|
241
|
+
question,
|
|
242
|
+
answer: parsed.answer,
|
|
243
|
+
response_time_ms: responseTimeMs,
|
|
244
|
+
was_answered: parsed.answer ? 1 : 0,
|
|
245
|
+
answer_reference: parsed.answer_source || null,
|
|
246
|
+
category: ((parsed as unknown as Record<string, unknown>).category as string) || null,
|
|
247
|
+
confidence: parsed.confidence || null,
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
logger.info(
|
|
251
|
+
{
|
|
252
|
+
question: question.slice(0, 80),
|
|
253
|
+
responseTimeMs,
|
|
254
|
+
confidence: parsed.confidence,
|
|
255
|
+
},
|
|
256
|
+
'Questionnaire answered',
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
jsonResponse(res, 200, {
|
|
260
|
+
answer: parsed.answer,
|
|
261
|
+
citations: parsed.citations || [],
|
|
262
|
+
answer_source: parsed.answer_source || 'ai',
|
|
263
|
+
confidence: parsed.confidence || 'low',
|
|
264
|
+
});
|
|
265
|
+
} catch (err) {
|
|
266
|
+
const responseTimeMs = Date.now() - startTime;
|
|
267
|
+
logger.error(
|
|
268
|
+
{ err, question: question.slice(0, 80), responseTimeMs },
|
|
269
|
+
'Questionnaire request failed',
|
|
270
|
+
);
|
|
271
|
+
jsonResponse(res, 500, {
|
|
272
|
+
error: 'Internal server error',
|
|
273
|
+
detail: err instanceof Error ? err.message : String(err),
|
|
274
|
+
});
|
|
275
|
+
} finally {
|
|
276
|
+
releaseExecSlot();
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ─── Server ─────────────────────────────────────────────────────────────────
|
|
281
|
+
|
|
282
|
+
export function startHttpServer(opts: {
|
|
283
|
+
registeredGroups: () => Record<string, RegisteredGroup>;
|
|
284
|
+
}): void {
|
|
285
|
+
const envConfig = readEnvFile(['API_AUTH_TOKEN', 'HTTP_PORT']);
|
|
286
|
+
const authToken = envConfig.API_AUTH_TOKEN;
|
|
287
|
+
const port = parseInt(envConfig.HTTP_PORT || '3000', 10);
|
|
288
|
+
|
|
289
|
+
if (!authToken) {
|
|
290
|
+
logger.warn(
|
|
291
|
+
'API_AUTH_TOKEN not set — HTTP server will reject all authenticated requests',
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const server = http.createServer((req, res) => {
|
|
296
|
+
const url = new URL(req.url || '/', `http://localhost:${port}`);
|
|
297
|
+
const method = req.method?.toUpperCase() || 'GET';
|
|
298
|
+
|
|
299
|
+
// CORS preflight
|
|
300
|
+
if (method === 'OPTIONS') {
|
|
301
|
+
res.writeHead(204, {
|
|
302
|
+
'Access-Control-Allow-Origin': '*',
|
|
303
|
+
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
304
|
+
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
|
305
|
+
'Access-Control-Max-Age': '86400',
|
|
306
|
+
});
|
|
307
|
+
res.end();
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Route dispatch
|
|
312
|
+
if (url.pathname === '/status' && method === 'GET') {
|
|
313
|
+
handleStatus(req, res);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (url.pathname === '/api/questionnaire/answer' && method === 'POST') {
|
|
318
|
+
handleQuestionnaireAnswer(
|
|
319
|
+
req,
|
|
320
|
+
res,
|
|
321
|
+
authToken || '',
|
|
322
|
+
opts.registeredGroups,
|
|
323
|
+
).catch((err) => {
|
|
324
|
+
logger.error({ err }, 'Unhandled error in questionnaire handler');
|
|
325
|
+
if (!res.headersSent) {
|
|
326
|
+
jsonResponse(res, 500, { error: 'Internal server error' });
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// 404 for everything else
|
|
333
|
+
jsonResponse(res, 404, { error: 'Not found' });
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
server.listen(port, () => {
|
|
337
|
+
logger.info({ port }, 'HTTP server listening');
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
server.on('error', (err) => {
|
|
341
|
+
logger.error({ err, port }, 'HTTP server error');
|
|
342
|
+
});
|
|
343
|
+
}
|