@grackle-ai/server 0.74.1 → 0.75.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/event-processor.d.ts.map +1 -1
- package/dist/event-processor.js +6 -5
- package/dist/event-processor.js.map +1 -1
- package/dist/grpc-service.d.ts.map +1 -1
- package/dist/grpc-service.js +277 -10
- package/dist/grpc-service.js.map +1 -1
- package/dist/index.js +20 -2
- package/dist/index.js.map +1 -1
- package/dist/ws-bridge.d.ts +15 -17
- package/dist/ws-bridge.d.ts.map +1 -1
- package/dist/ws-bridge.js +15 -1538
- package/dist/ws-bridge.js.map +1 -1
- package/package.json +14 -14
package/dist/ws-bridge.js
CHANGED
|
@@ -1,37 +1,22 @@
|
|
|
1
1
|
import { WebSocketServer, WebSocket } from "ws";
|
|
2
2
|
import { create } from "@bufbuild/protobuf";
|
|
3
|
-
import {
|
|
4
|
-
import { envRegistry, sessionStore,
|
|
3
|
+
import { powerline } from "@grackle-ai/common";
|
|
4
|
+
import { envRegistry, sessionStore, workspaceStore, taskStore, grackleHome } from "@grackle-ai/database";
|
|
5
5
|
import * as adapterManager from "./adapter-manager.js";
|
|
6
6
|
import { reconnectOrProvision, } from "@grackle-ai/adapter-sdk";
|
|
7
7
|
import * as streamHub from "./stream-hub.js";
|
|
8
8
|
import * as tokenPush from "./token-push.js";
|
|
9
9
|
import { v4 as uuid } from "uuid";
|
|
10
10
|
import { join } from "node:path";
|
|
11
|
-
import { LOGS_DIR,
|
|
11
|
+
import { LOGS_DIR, DEFAULT_MCP_PORT, ROOT_TASK_ID, eventTypeToString, } from "@grackle-ai/common";
|
|
12
12
|
import { resolvePersona, fetchOrchestratorContext, SystemPromptBuilder, buildTaskPrompt } from "@grackle-ai/prompt";
|
|
13
|
-
import * as logWriter from "./log-writer.js";
|
|
14
13
|
import { logger } from "./logger.js";
|
|
15
14
|
import { processEventStream } from "./event-processor.js";
|
|
16
|
-
import
|
|
17
|
-
import { setWssInstance, envRowToWs } from "./ws-broadcast.js";
|
|
15
|
+
import { setWssInstance } from "./ws-broadcast.js";
|
|
18
16
|
import { emit } from "./event-bus.js";
|
|
19
17
|
import { recoverSuspendedSessions } from "./session-recovery.js";
|
|
20
|
-
import { clearReconnectState } from "./auto-reconnect.js";
|
|
21
18
|
import { buildMcpServersJson, toDialableHost } from "./grpc-service.js";
|
|
22
19
|
import { createScopedToken, loadOrCreateApiKey } from "@grackle-ai/auth";
|
|
23
|
-
import { getKnowledgeEmbedder, isKnowledgeEnabled } from "./knowledge-init.js";
|
|
24
|
-
import { knowledgeSearch, getNode as getKnowledgeNodeById, expandNode, listRecentNodes, } from "@grackle-ai/knowledge";
|
|
25
|
-
import { cleanupLifecycleStream } from "./lifecycle.js";
|
|
26
|
-
import * as streamRegistry from "./stream-registry.js";
|
|
27
|
-
import { reanimateAgent } from "./reanimate-agent.js";
|
|
28
|
-
import { ConnectError } from "@connectrpc/connect";
|
|
29
|
-
import { computeTaskStatus } from "./compute-task-status.js";
|
|
30
|
-
import { exec } from "./utils/exec.js";
|
|
31
|
-
import { formatGhError } from "./utils/format-gh-error.js";
|
|
32
|
-
const GH_CODESPACE_LIST_TIMEOUT_MS = 30_000;
|
|
33
|
-
const GH_CODESPACE_CREATE_TIMEOUT_MS = 300_000;
|
|
34
|
-
const GH_CODESPACE_LIST_LIMIT = 50;
|
|
35
20
|
const WS_PING_INTERVAL_MS = 30_000;
|
|
36
21
|
const WS_CLOSE_UNAUTHORIZED = 4001;
|
|
37
22
|
const WS_CLOSE_FORBIDDEN_ORIGIN = 4003;
|
|
@@ -39,15 +24,17 @@ const WS_CLOSE_FORBIDDEN_ORIGIN = 4003;
|
|
|
39
24
|
const LOOPBACK_HOSTNAMES = new Set([
|
|
40
25
|
"localhost",
|
|
41
26
|
"127.0.0.1",
|
|
27
|
+
"::1",
|
|
42
28
|
"[::1]",
|
|
43
29
|
]);
|
|
44
30
|
/**
|
|
45
|
-
* Check whether a WebSocket
|
|
31
|
+
* Check whether a WebSocket Origin header is allowed.
|
|
46
32
|
*
|
|
47
|
-
*
|
|
48
|
-
* -
|
|
49
|
-
* -
|
|
50
|
-
* - In
|
|
33
|
+
* Rules:
|
|
34
|
+
* - Missing origin (non-browser clients, extensions) → always allowed
|
|
35
|
+
* - Origin port must match the web server port
|
|
36
|
+
* - In local mode (`allowNetwork === false`), hostname must be loopback
|
|
37
|
+
* - In network mode (`allowNetwork === true`), any hostname on the right port is allowed
|
|
51
38
|
*/
|
|
52
39
|
export function isAllowedOrigin(origin, webPort, allowNetwork) {
|
|
53
40
|
if (origin === undefined) {
|
|
@@ -71,13 +58,13 @@ export function isAllowedOrigin(origin, webPort, allowNetwork) {
|
|
|
71
58
|
}
|
|
72
59
|
return LOOPBACK_HOSTNAMES.has(parsed.hostname);
|
|
73
60
|
}
|
|
74
|
-
/** Create a WebSocket server on top of an HTTP server
|
|
61
|
+
/** Create a WebSocket server on top of an HTTP server for real-time event streaming. */
|
|
75
62
|
export function createWsBridge(httpServer, options) {
|
|
76
63
|
const wss = new WebSocketServer({ server: httpServer });
|
|
77
64
|
setWssInstance(wss);
|
|
78
65
|
wss.on("connection", (ws, req) => {
|
|
79
66
|
const origin = req.headers.origin;
|
|
80
|
-
if (!isAllowedOrigin(origin, options.webPort, options.allowNetwork)) {
|
|
67
|
+
if (!isAllowedOrigin(origin, options.webPort ?? 0, options.allowNetwork ?? false)) {
|
|
81
68
|
logger.warn({ origin }, "Rejected WebSocket connection from disallowed origin");
|
|
82
69
|
ws.close(WS_CLOSE_FORBIDDEN_ORIGIN, "Forbidden origin");
|
|
83
70
|
return;
|
|
@@ -313,99 +300,8 @@ export async function startTaskSession(ws, task, options) {
|
|
|
313
300
|
});
|
|
314
301
|
return undefined;
|
|
315
302
|
}
|
|
316
|
-
/**
|
|
317
|
-
* Terminate a session: set STOPPED+killed, then close lifecycle FDs.
|
|
318
|
-
*
|
|
319
|
-
* Sets the session to STOPPED+killed first, then deletes the lifecycle
|
|
320
|
-
* stream. The orphan callback still sends the PowerLine kill request
|
|
321
|
-
* (even for already-terminal sessions) to ensure the process is stopped.
|
|
322
|
-
*/
|
|
323
|
-
async function terminateSession(sessionId) {
|
|
324
|
-
const session = sessionStore.getSession(sessionId);
|
|
325
|
-
if (!session) {
|
|
326
|
-
return;
|
|
327
|
-
}
|
|
328
|
-
// 1. Set STOPPED + killed BEFORE closing the lifecycle FD so the orphan
|
|
329
|
-
// callback sees the session is already terminal and skips.
|
|
330
|
-
if (!TERMINAL_SESSION_STATUSES.has(session.status)) {
|
|
331
|
-
sessionStore.updateSession(sessionId, SESSION_STATUS.STOPPED, undefined, undefined, END_REASON.KILLED);
|
|
332
|
-
streamHub.publish(create(grackle.SessionEventSchema, {
|
|
333
|
-
sessionId,
|
|
334
|
-
type: grackle.EventType.STATUS,
|
|
335
|
-
timestamp: new Date().toISOString(),
|
|
336
|
-
content: END_REASON.KILLED,
|
|
337
|
-
raw: "",
|
|
338
|
-
}));
|
|
339
|
-
if (session.taskId) {
|
|
340
|
-
const task = taskStore.getTask(session.taskId);
|
|
341
|
-
if (task) {
|
|
342
|
-
emit("task.updated", { taskId: task.id, workspaceId: task.workspaceId || "" });
|
|
343
|
-
}
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
// 2. Delete lifecycle stream — orphan callback sees session is already
|
|
347
|
-
// STOPPED and skips status change, but still kills the PowerLine process.
|
|
348
|
-
cleanupLifecycleStream(sessionId);
|
|
349
|
-
// 3. Close other subscriptions (pipe streams etc.)
|
|
350
|
-
const subs = streamRegistry.getSubscriptionsForSession(sessionId);
|
|
351
|
-
for (const sub of subs) {
|
|
352
|
-
streamRegistry.unsubscribe(sub.id);
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
303
|
async function handleMessage(ws, msg, subscriptions) {
|
|
356
304
|
switch (msg.type) {
|
|
357
|
-
case "list_environments": {
|
|
358
|
-
const rows = envRegistry.listEnvironments();
|
|
359
|
-
sendWs(ws, {
|
|
360
|
-
type: "environments",
|
|
361
|
-
payload: { environments: rows.map(envRowToWs) },
|
|
362
|
-
});
|
|
363
|
-
break;
|
|
364
|
-
}
|
|
365
|
-
case "list_sessions": {
|
|
366
|
-
const environmentId = msg.payload?.environmentId || "";
|
|
367
|
-
const status = msg.payload?.status || "";
|
|
368
|
-
const rows = sessionStore.listSessions(environmentId, status);
|
|
369
|
-
sendWs(ws, {
|
|
370
|
-
type: "sessions",
|
|
371
|
-
payload: {
|
|
372
|
-
sessions: rows.map((r) => ({
|
|
373
|
-
id: r.id,
|
|
374
|
-
environmentId: r.environmentId,
|
|
375
|
-
runtime: r.runtime,
|
|
376
|
-
status: r.status,
|
|
377
|
-
prompt: r.prompt,
|
|
378
|
-
startedAt: r.startedAt,
|
|
379
|
-
personaId: r.personaId,
|
|
380
|
-
inputTokens: r.inputTokens,
|
|
381
|
-
outputTokens: r.outputTokens,
|
|
382
|
-
costUsd: r.costUsd,
|
|
383
|
-
endReason: r.endReason || undefined,
|
|
384
|
-
})),
|
|
385
|
-
},
|
|
386
|
-
});
|
|
387
|
-
break;
|
|
388
|
-
}
|
|
389
|
-
case "get_session_events": {
|
|
390
|
-
const sessionId = msg.payload?.sessionId;
|
|
391
|
-
if (!sessionId) {
|
|
392
|
-
return;
|
|
393
|
-
}
|
|
394
|
-
const session = sessionStore.getSession(sessionId);
|
|
395
|
-
if (!session?.logPath) {
|
|
396
|
-
return;
|
|
397
|
-
}
|
|
398
|
-
const entries = logWriter.readLog(session.logPath);
|
|
399
|
-
const events = entries.map((e) => ({
|
|
400
|
-
sessionId: e.session_id,
|
|
401
|
-
eventType: e.type,
|
|
402
|
-
timestamp: e.timestamp,
|
|
403
|
-
content: e.content,
|
|
404
|
-
raw: e.raw || undefined,
|
|
405
|
-
}));
|
|
406
|
-
sendWs(ws, { type: "session_events", payload: { sessionId, events } });
|
|
407
|
-
break;
|
|
408
|
-
}
|
|
409
305
|
case "subscribe": {
|
|
410
306
|
const sessionId = msg.payload?.sessionId;
|
|
411
307
|
if (!sessionId) {
|
|
@@ -463,1429 +359,10 @@ async function handleMessage(ws, msg, subscriptions) {
|
|
|
463
359
|
})();
|
|
464
360
|
break;
|
|
465
361
|
}
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
const prompt = msg.payload?.prompt;
|
|
469
|
-
const branch = msg.payload?.branch || "";
|
|
470
|
-
const systemContext = msg.payload?.systemContext || "";
|
|
471
|
-
const spawnPersonaId = msg.payload?.personaId || "";
|
|
472
|
-
if (!environmentId || !prompt) {
|
|
473
|
-
sendWs(ws, {
|
|
474
|
-
type: "error",
|
|
475
|
-
payload: { message: "environmentId and prompt required" },
|
|
476
|
-
});
|
|
477
|
-
return;
|
|
478
|
-
}
|
|
479
|
-
// Resolve persona via cascade (request → app default)
|
|
480
|
-
let resolved;
|
|
481
|
-
try {
|
|
482
|
-
resolved = resolvePersona(spawnPersonaId);
|
|
483
|
-
}
|
|
484
|
-
catch (err) {
|
|
485
|
-
sendWs(ws, { type: "error", payload: { message: err.message } });
|
|
486
|
-
return;
|
|
487
|
-
}
|
|
488
|
-
const env = envRegistry.getEnvironment(environmentId);
|
|
489
|
-
if (!env) {
|
|
490
|
-
sendWs(ws, {
|
|
491
|
-
type: "error",
|
|
492
|
-
payload: { message: `Environment not found: ${environmentId}` },
|
|
493
|
-
});
|
|
494
|
-
return;
|
|
495
|
-
}
|
|
496
|
-
// Auto-provision the environment if not already connected
|
|
497
|
-
const conn = await autoProvisionEnvironment(ws, environmentId, env, {});
|
|
498
|
-
if (!conn) {
|
|
499
|
-
return;
|
|
500
|
-
}
|
|
501
|
-
const sessionId = uuid();
|
|
502
|
-
const { runtime: sessionRuntime, model: sessionModel, maxTurns, systemPrompt: spawnSystemPrompt } = resolved;
|
|
503
|
-
const logPath = join(grackleHome, LOGS_DIR, sessionId);
|
|
504
|
-
const builderPrompt = new SystemPromptBuilder({
|
|
505
|
-
personaPrompt: spawnSystemPrompt,
|
|
506
|
-
}).build();
|
|
507
|
-
const finalSystemContext = systemContext
|
|
508
|
-
? builderPrompt + "\n\n" + systemContext
|
|
509
|
-
: builderPrompt;
|
|
510
|
-
sessionStore.createSession(sessionId, environmentId, sessionRuntime, prompt, sessionModel, logPath);
|
|
511
|
-
sendWs(ws, { type: "spawned", payload: { sessionId } });
|
|
512
|
-
const powerlineReq = create(powerline.SpawnRequestSchema, {
|
|
513
|
-
sessionId,
|
|
514
|
-
runtime: sessionRuntime,
|
|
515
|
-
prompt,
|
|
516
|
-
model: sessionModel,
|
|
517
|
-
maxTurns,
|
|
518
|
-
branch,
|
|
519
|
-
worktreeBasePath: branch
|
|
520
|
-
? ((typeof msg.payload?.worktreeBasePath === "string" ? msg.payload.worktreeBasePath.trim() : "") || process.env.GRACKLE_WORKTREE_BASE || "/workspace")
|
|
521
|
-
: "",
|
|
522
|
-
systemContext: finalSystemContext,
|
|
523
|
-
});
|
|
524
|
-
processEventStream(conn.client.spawn(powerlineReq), {
|
|
525
|
-
sessionId,
|
|
526
|
-
logPath,
|
|
527
|
-
systemContext: finalSystemContext,
|
|
528
|
-
prompt,
|
|
529
|
-
});
|
|
530
|
-
break;
|
|
531
|
-
}
|
|
532
|
-
case "send_input": {
|
|
533
|
-
const sessionId = msg.payload?.sessionId;
|
|
534
|
-
const text = msg.payload?.text;
|
|
535
|
-
if (!sessionId || !text) {
|
|
536
|
-
sendWs(ws, {
|
|
537
|
-
type: "error",
|
|
538
|
-
payload: { message: "sessionId and text required" },
|
|
539
|
-
});
|
|
540
|
-
return;
|
|
541
|
-
}
|
|
542
|
-
const session = sessionStore.getSession(sessionId);
|
|
543
|
-
if (!session) {
|
|
544
|
-
sendWs(ws, {
|
|
545
|
-
type: "error",
|
|
546
|
-
payload: { message: `Session not found: ${sessionId}` },
|
|
547
|
-
});
|
|
548
|
-
return;
|
|
549
|
-
}
|
|
550
|
-
if (TERMINAL_SESSION_STATUSES.has(session.status)) {
|
|
551
|
-
sendWs(ws, {
|
|
552
|
-
type: "error",
|
|
553
|
-
payload: {
|
|
554
|
-
message: `Session ${sessionId} has ended (status: ${session.status})`,
|
|
555
|
-
},
|
|
556
|
-
});
|
|
557
|
-
return;
|
|
558
|
-
}
|
|
559
|
-
const conn = adapterManager.getConnection(session.environmentId);
|
|
560
|
-
if (!conn) {
|
|
561
|
-
sendWs(ws, {
|
|
562
|
-
type: "error",
|
|
563
|
-
payload: {
|
|
564
|
-
message: `Environment ${session.environmentId} is not connected`,
|
|
565
|
-
},
|
|
566
|
-
});
|
|
567
|
-
return;
|
|
568
|
-
}
|
|
569
|
-
// Record the user's input as a session event before forwarding to the agent
|
|
570
|
-
const userInputEvent = create(grackle.SessionEventSchema, {
|
|
571
|
-
sessionId,
|
|
572
|
-
type: grackle.EventType.USER_INPUT,
|
|
573
|
-
timestamp: new Date().toISOString(),
|
|
574
|
-
content: text,
|
|
575
|
-
});
|
|
576
|
-
if (session.logPath) {
|
|
577
|
-
logWriter.writeEvent(session.logPath, userInputEvent);
|
|
578
|
-
}
|
|
579
|
-
streamHub.publish(userInputEvent);
|
|
580
|
-
try {
|
|
581
|
-
await conn.client.sendInput(create(powerline.InputMessageSchema, { sessionId, text }));
|
|
582
|
-
}
|
|
583
|
-
catch (err) {
|
|
584
|
-
const errMessage = err instanceof Error ? err.message : String(err);
|
|
585
|
-
sendWs(ws, {
|
|
586
|
-
type: "error",
|
|
587
|
-
payload: { message: `Failed to send input: ${errMessage}` },
|
|
588
|
-
});
|
|
589
|
-
}
|
|
590
|
-
break;
|
|
591
|
-
}
|
|
592
|
-
case "kill": {
|
|
593
|
-
const sessionId = msg.payload?.sessionId;
|
|
594
|
-
if (!sessionId) {
|
|
595
|
-
return;
|
|
596
|
-
}
|
|
597
|
-
await terminateSession(sessionId);
|
|
598
|
-
break;
|
|
599
|
-
}
|
|
600
|
-
case "stop_task": {
|
|
601
|
-
const taskId = msg.payload?.taskId;
|
|
602
|
-
if (!taskId)
|
|
603
|
-
return;
|
|
604
|
-
if (taskId === ROOT_TASK_ID) {
|
|
605
|
-
sendWs(ws, { type: "error", payload: { message: "Cannot stop the system task" } });
|
|
606
|
-
return;
|
|
607
|
-
}
|
|
608
|
-
// Terminate all active sessions for this task via fd closure
|
|
609
|
-
const activeSessions = sessionStore.getActiveSessionsForTask(taskId);
|
|
610
|
-
for (const session of activeSessions) {
|
|
611
|
-
await terminateSession(session.id);
|
|
612
|
-
}
|
|
613
|
-
// Mark task complete (same as "complete_task" handler)
|
|
614
|
-
taskStore.markTaskComplete(taskId, TASK_STATUS.COMPLETE);
|
|
615
|
-
const stoppedTask = taskStore.getTask(taskId);
|
|
616
|
-
const unblocked = stoppedTask?.workspaceId ? taskStore.checkAndUnblock(stoppedTask.workspaceId) : [];
|
|
617
|
-
sendWs(ws, {
|
|
618
|
-
type: "task_completed",
|
|
619
|
-
payload: {
|
|
620
|
-
taskId,
|
|
621
|
-
unblockedTaskIds: unblocked.map((t) => t.id),
|
|
622
|
-
},
|
|
623
|
-
});
|
|
624
|
-
if (stoppedTask) {
|
|
625
|
-
emit("task.completed", { taskId, workspaceId: stoppedTask.workspaceId || "" });
|
|
626
|
-
}
|
|
627
|
-
break;
|
|
628
|
-
}
|
|
629
|
-
case "resume_agent": {
|
|
630
|
-
const resumeSessionId = msg.payload?.sessionId;
|
|
631
|
-
if (!resumeSessionId) {
|
|
632
|
-
sendWs(ws, { type: "error", payload: { message: "sessionId required" } });
|
|
633
|
-
return;
|
|
634
|
-
}
|
|
635
|
-
try {
|
|
636
|
-
reanimateAgent(resumeSessionId);
|
|
637
|
-
sendWs(ws, { type: "agent_resumed", payload: { sessionId: resumeSessionId } });
|
|
638
|
-
}
|
|
639
|
-
catch (err) {
|
|
640
|
-
const message = err instanceof ConnectError ? err.message : String(err);
|
|
641
|
-
sendWs(ws, { type: "error", payload: { message } });
|
|
642
|
-
}
|
|
643
|
-
break;
|
|
644
|
-
}
|
|
645
|
-
// ─── Workspaces ────────────────────────────────────────
|
|
646
|
-
case "list_workspaces": {
|
|
647
|
-
const filterEnvironmentId = msg.payload?.environmentId || undefined;
|
|
648
|
-
const rows = workspaceStore.listWorkspaces(filterEnvironmentId);
|
|
649
|
-
sendWs(ws, {
|
|
650
|
-
type: "workspaces",
|
|
651
|
-
payload: {
|
|
652
|
-
workspaces: rows.map((r) => ({
|
|
653
|
-
id: r.id,
|
|
654
|
-
name: r.name,
|
|
655
|
-
description: r.description,
|
|
656
|
-
repoUrl: r.repoUrl,
|
|
657
|
-
environmentId: r.environmentId,
|
|
658
|
-
defaultPersonaId: r.defaultPersonaId,
|
|
659
|
-
status: r.status,
|
|
660
|
-
useWorktrees: r.useWorktrees,
|
|
661
|
-
worktreeBasePath: r.worktreeBasePath,
|
|
662
|
-
createdAt: r.createdAt,
|
|
663
|
-
updatedAt: r.updatedAt,
|
|
664
|
-
})),
|
|
665
|
-
},
|
|
666
|
-
});
|
|
667
|
-
break;
|
|
668
|
-
}
|
|
669
|
-
case "create_workspace": {
|
|
670
|
-
const name = msg.payload?.name;
|
|
671
|
-
const requestId = typeof msg.payload?.requestId === "string"
|
|
672
|
-
? msg.payload.requestId
|
|
673
|
-
: "";
|
|
674
|
-
if (!name) {
|
|
675
|
-
sendWs(ws, {
|
|
676
|
-
type: "error",
|
|
677
|
-
payload: { message: "name required", requestId },
|
|
678
|
-
});
|
|
679
|
-
return;
|
|
680
|
-
}
|
|
681
|
-
const createEnvironmentId = msg.payload?.environmentId || "";
|
|
682
|
-
if (!createEnvironmentId) {
|
|
683
|
-
sendWs(ws, {
|
|
684
|
-
type: "error",
|
|
685
|
-
payload: { message: "environmentId required", requestId },
|
|
686
|
-
});
|
|
687
|
-
return;
|
|
688
|
-
}
|
|
689
|
-
if (!envRegistry.getEnvironment(createEnvironmentId)) {
|
|
690
|
-
sendWs(ws, {
|
|
691
|
-
type: "error",
|
|
692
|
-
payload: { message: `Environment not found: ${createEnvironmentId}`, requestId },
|
|
693
|
-
});
|
|
694
|
-
return;
|
|
695
|
-
}
|
|
696
|
-
const baseWorkspaceId = slugify(name) || uuid().slice(0, 8);
|
|
697
|
-
let id = baseWorkspaceId;
|
|
698
|
-
for (let attempt = 0; attempt < 10 && workspaceStore.getWorkspace(id); attempt++) {
|
|
699
|
-
id = `${baseWorkspaceId}-${uuid().slice(0, 4)}`;
|
|
700
|
-
}
|
|
701
|
-
if (workspaceStore.getWorkspace(id)) {
|
|
702
|
-
id = uuid();
|
|
703
|
-
}
|
|
704
|
-
// useWorktrees defaults to true when not specified
|
|
705
|
-
const createUseWorktrees = msg.payload?.useWorktrees ?? true;
|
|
706
|
-
try {
|
|
707
|
-
workspaceStore.createWorkspace(id, name, msg.payload?.description || "", msg.payload?.repoUrl || "", createEnvironmentId, createUseWorktrees, typeof msg.payload?.worktreeBasePath === "string" ? msg.payload.worktreeBasePath.trim() : "", msg.payload?.defaultPersonaId || "");
|
|
708
|
-
emit("workspace.created", { workspaceId: id, requestId });
|
|
709
|
-
}
|
|
710
|
-
catch (error) {
|
|
711
|
-
const message = error instanceof Error ? error.message : "Failed to create workspace";
|
|
712
|
-
sendWs(ws, {
|
|
713
|
-
type: "error",
|
|
714
|
-
payload: { message, requestId },
|
|
715
|
-
});
|
|
716
|
-
}
|
|
717
|
-
break;
|
|
718
|
-
}
|
|
719
|
-
case "archive_workspace": {
|
|
720
|
-
const workspaceId = msg.payload?.workspaceId;
|
|
721
|
-
if (workspaceId) {
|
|
722
|
-
workspaceStore.archiveWorkspace(workspaceId);
|
|
723
|
-
}
|
|
724
|
-
emit("workspace.archived", { workspaceId });
|
|
725
|
-
break;
|
|
726
|
-
}
|
|
727
|
-
case "update_workspace": {
|
|
728
|
-
const workspaceId = msg.payload?.workspaceId;
|
|
729
|
-
if (!workspaceId) {
|
|
730
|
-
sendWs(ws, { type: "error", payload: { message: "workspaceId required" } });
|
|
731
|
-
return;
|
|
732
|
-
}
|
|
733
|
-
const existing = workspaceStore.getWorkspace(workspaceId);
|
|
734
|
-
if (!existing) {
|
|
735
|
-
sendWs(ws, { type: "error", payload: { message: `Workspace not found: ${workspaceId}` } });
|
|
736
|
-
return;
|
|
737
|
-
}
|
|
738
|
-
const nameVal = typeof msg.payload?.name === "string" ? msg.payload.name : undefined;
|
|
739
|
-
if (nameVal?.trim() === "") {
|
|
740
|
-
sendWs(ws, { type: "error", payload: { message: "Workspace name cannot be empty" } });
|
|
741
|
-
return;
|
|
742
|
-
}
|
|
743
|
-
const descVal = typeof msg.payload?.description === "string" ? msg.payload.description : undefined;
|
|
744
|
-
const repoVal = typeof msg.payload?.repoUrl === "string" ? msg.payload.repoUrl : undefined;
|
|
745
|
-
const envVal = typeof msg.payload?.environmentId === "string" ? msg.payload.environmentId : undefined;
|
|
746
|
-
if (repoVal !== undefined && repoVal !== "" && !/^https?:\/\//i.test(repoVal)) {
|
|
747
|
-
sendWs(ws, { type: "error", payload: { message: "Repository URL must use http or https scheme" } });
|
|
748
|
-
return;
|
|
749
|
-
}
|
|
750
|
-
if (envVal !== undefined && !envRegistry.getEnvironment(envVal)) {
|
|
751
|
-
sendWs(ws, { type: "error", payload: { message: `Environment not found: ${envVal}` } });
|
|
752
|
-
return;
|
|
753
|
-
}
|
|
754
|
-
const worktreesVal = typeof msg.payload?.useWorktrees === "boolean" ? msg.payload.useWorktrees : undefined;
|
|
755
|
-
const worktreeBasePathVal = typeof msg.payload?.worktreeBasePath === "string" ? msg.payload.worktreeBasePath : undefined;
|
|
756
|
-
const defaultPersonaIdVal = typeof msg.payload?.defaultPersonaId === "string" ? msg.payload.defaultPersonaId : undefined;
|
|
757
|
-
workspaceStore.updateWorkspace(workspaceId, {
|
|
758
|
-
name: nameVal !== undefined ? nameVal.trim() : undefined,
|
|
759
|
-
description: descVal,
|
|
760
|
-
repoUrl: repoVal,
|
|
761
|
-
environmentId: envVal,
|
|
762
|
-
useWorktrees: worktreesVal,
|
|
763
|
-
worktreeBasePath: worktreeBasePathVal,
|
|
764
|
-
defaultPersonaId: defaultPersonaIdVal,
|
|
765
|
-
});
|
|
766
|
-
emit("workspace.updated", { workspaceId });
|
|
767
|
-
break;
|
|
768
|
-
}
|
|
769
|
-
// ─── Personas ──────────────────────────────────────────
|
|
770
|
-
case "list_personas": {
|
|
771
|
-
const rows = personaStore.listPersonas();
|
|
772
|
-
sendWs(ws, {
|
|
773
|
-
type: "personas",
|
|
774
|
-
payload: {
|
|
775
|
-
personas: rows.map((r) => ({
|
|
776
|
-
id: r.id,
|
|
777
|
-
name: r.name,
|
|
778
|
-
description: r.description,
|
|
779
|
-
systemPrompt: r.systemPrompt,
|
|
780
|
-
toolConfig: r.toolConfig,
|
|
781
|
-
runtime: r.runtime,
|
|
782
|
-
model: r.model,
|
|
783
|
-
maxTurns: r.maxTurns,
|
|
784
|
-
mcpServers: r.mcpServers,
|
|
785
|
-
createdAt: r.createdAt,
|
|
786
|
-
updatedAt: r.updatedAt,
|
|
787
|
-
type: r.type || "agent",
|
|
788
|
-
script: r.script || "",
|
|
789
|
-
})),
|
|
790
|
-
},
|
|
791
|
-
});
|
|
792
|
-
break;
|
|
793
|
-
}
|
|
794
|
-
case "create_persona": {
|
|
795
|
-
const personaName = msg.payload?.name;
|
|
796
|
-
if (!personaName) {
|
|
797
|
-
sendWs(ws, { type: "error", payload: { message: "name required" } });
|
|
798
|
-
return;
|
|
799
|
-
}
|
|
800
|
-
const personaType = msg.payload?.type || "agent";
|
|
801
|
-
if (personaType !== "agent" && personaType !== "script") {
|
|
802
|
-
sendWs(ws, { type: "error", payload: { message: `Invalid persona type: "${personaType}". Must be "agent" or "script".` } });
|
|
803
|
-
return;
|
|
804
|
-
}
|
|
805
|
-
const personaSystemPrompt = msg.payload?.systemPrompt || "";
|
|
806
|
-
const personaScript = msg.payload?.script || "";
|
|
807
|
-
if (personaType === "script") {
|
|
808
|
-
if (!personaScript) {
|
|
809
|
-
sendWs(ws, { type: "error", payload: { message: "script required for script personas" } });
|
|
810
|
-
return;
|
|
811
|
-
}
|
|
812
|
-
}
|
|
813
|
-
else {
|
|
814
|
-
if (!personaSystemPrompt) {
|
|
815
|
-
sendWs(ws, { type: "error", payload: { message: "systemPrompt required" } });
|
|
816
|
-
return;
|
|
817
|
-
}
|
|
818
|
-
}
|
|
819
|
-
let personaId = slugify(personaName) || uuid().slice(0, 8);
|
|
820
|
-
const MAX_ID_RETRIES = 10;
|
|
821
|
-
for (let i = 0; i < MAX_ID_RETRIES && personaStore.getPersona(personaId); i++) {
|
|
822
|
-
personaId = `${slugify(personaName) || "persona"}-${uuid().slice(0, 4)}`;
|
|
823
|
-
}
|
|
824
|
-
personaStore.createPersona(personaId, personaName, msg.payload?.description || "", personaSystemPrompt, msg.payload?.toolConfig || "{}", msg.payload?.runtime || "", msg.payload?.model || "", msg.payload?.maxTurns || 0, msg.payload?.mcpServers || "[]", personaType, personaScript);
|
|
825
|
-
emit("persona.created", { personaId });
|
|
826
|
-
break;
|
|
827
|
-
}
|
|
828
|
-
case "get_persona": {
|
|
829
|
-
const getPersonaId = msg.payload?.personaId;
|
|
830
|
-
if (!getPersonaId)
|
|
831
|
-
return;
|
|
832
|
-
const personaRow = personaStore.getPersona(getPersonaId);
|
|
833
|
-
if (!personaRow) {
|
|
834
|
-
sendWs(ws, {
|
|
835
|
-
type: "error",
|
|
836
|
-
payload: { message: `Persona not found: ${getPersonaId}` },
|
|
837
|
-
});
|
|
838
|
-
return;
|
|
839
|
-
}
|
|
840
|
-
sendWs(ws, { type: "persona", payload: { persona: personaRow } });
|
|
841
|
-
break;
|
|
842
|
-
}
|
|
843
|
-
case "update_persona": {
|
|
844
|
-
const updatePersonaId = msg.payload?.personaId;
|
|
845
|
-
if (!updatePersonaId) {
|
|
846
|
-
sendWs(ws, {
|
|
847
|
-
type: "error",
|
|
848
|
-
payload: { message: "personaId required" },
|
|
849
|
-
});
|
|
850
|
-
return;
|
|
851
|
-
}
|
|
852
|
-
const existingPersona = personaStore.getPersona(updatePersonaId);
|
|
853
|
-
if (!existingPersona) {
|
|
854
|
-
sendWs(ws, {
|
|
855
|
-
type: "error",
|
|
856
|
-
payload: { message: `Persona not found: ${updatePersonaId}` },
|
|
857
|
-
});
|
|
858
|
-
return;
|
|
859
|
-
}
|
|
860
|
-
personaStore.updatePersona(updatePersonaId, msg.payload?.name ?? existingPersona.name, msg.payload?.description ?? existingPersona.description, msg.payload?.systemPrompt ?? existingPersona.systemPrompt, msg.payload?.toolConfig ?? existingPersona.toolConfig, msg.payload?.runtime ?? existingPersona.runtime, msg.payload?.model ?? existingPersona.model, msg.payload?.maxTurns ?? existingPersona.maxTurns, msg.payload?.mcpServers ?? existingPersona.mcpServers, msg.payload?.type ?? existingPersona.type, msg.payload?.script ?? existingPersona.script);
|
|
861
|
-
emit("persona.updated", { personaId: updatePersonaId });
|
|
862
|
-
break;
|
|
863
|
-
}
|
|
864
|
-
case "delete_persona": {
|
|
865
|
-
const deletePersonaId = msg.payload?.personaId;
|
|
866
|
-
if (!deletePersonaId)
|
|
867
|
-
return;
|
|
868
|
-
personaStore.deletePersona(deletePersonaId);
|
|
869
|
-
emit("persona.deleted", { personaId: deletePersonaId });
|
|
870
|
-
break;
|
|
871
|
-
}
|
|
872
|
-
// ─── Settings ────────────────────────────────────────────
|
|
873
|
-
case "get_setting": {
|
|
874
|
-
const key = msg.payload?.key;
|
|
875
|
-
if (typeof key !== "string" || !key)
|
|
876
|
-
return;
|
|
877
|
-
if (!isAllowedSettingKey(key)) {
|
|
878
|
-
sendWs(ws, { type: "error", payload: { message: `Setting key not allowed: ${key}` } });
|
|
879
|
-
return;
|
|
880
|
-
}
|
|
881
|
-
const value = settingsStore.getSetting(key) ?? "";
|
|
882
|
-
sendWs(ws, { type: "setting", payload: { key, value } });
|
|
883
|
-
break;
|
|
884
|
-
}
|
|
885
|
-
case "set_setting": {
|
|
886
|
-
const key = msg.payload?.key;
|
|
887
|
-
const value = msg.payload?.value || "";
|
|
888
|
-
if (typeof key !== "string" || !key)
|
|
889
|
-
return;
|
|
890
|
-
if (!isAllowedSettingKey(key)) {
|
|
891
|
-
sendWs(ws, { type: "error", payload: { message: `Setting key not allowed: ${key}` } });
|
|
892
|
-
return;
|
|
893
|
-
}
|
|
894
|
-
// Validate persona exists and has required fields when setting default_persona_id
|
|
895
|
-
if (key === "default_persona_id" && value) {
|
|
896
|
-
const persona = personaStore.getPersona(value);
|
|
897
|
-
if (!persona) {
|
|
898
|
-
sendWs(ws, { type: "error", payload: { message: `Persona not found: ${value}` } });
|
|
899
|
-
return;
|
|
900
|
-
}
|
|
901
|
-
if (!persona.runtime || !persona.model) {
|
|
902
|
-
sendWs(ws, { type: "error", payload: { message: `Persona "${persona.name}" must have runtime and model configured` } });
|
|
903
|
-
return;
|
|
904
|
-
}
|
|
905
|
-
}
|
|
906
|
-
settingsStore.setSetting(key, value);
|
|
907
|
-
emit("setting.changed", { key, value });
|
|
908
|
-
break;
|
|
909
|
-
}
|
|
910
|
-
// ─── Tasks ─────────────────────────────────────────────
|
|
911
|
-
case "list_tasks": {
|
|
912
|
-
const workspaceId = msg.payload?.workspaceId || undefined;
|
|
913
|
-
const rows = taskStore.listTasks(workspaceId, {
|
|
914
|
-
search: msg.payload?.search || undefined,
|
|
915
|
-
status: msg.payload?.status || undefined,
|
|
916
|
-
});
|
|
917
|
-
const childIdsMap = taskStore.buildChildIdsMap(rows);
|
|
918
|
-
// Batch-fetch sessions for all tasks and group by taskId
|
|
919
|
-
const taskIds = rows.map((r) => r.id);
|
|
920
|
-
const allSessions = sessionStore.listSessionsByTaskIds(taskIds);
|
|
921
|
-
const sessionsByTask = new Map();
|
|
922
|
-
for (const s of allSessions) {
|
|
923
|
-
const arr = sessionsByTask.get(s.taskId) ?? [];
|
|
924
|
-
arr.push(s);
|
|
925
|
-
sessionsByTask.set(s.taskId, arr);
|
|
926
|
-
}
|
|
927
|
-
sendWs(ws, {
|
|
928
|
-
type: "tasks",
|
|
929
|
-
payload: {
|
|
930
|
-
workspaceId,
|
|
931
|
-
tasks: rows.map((r) => {
|
|
932
|
-
const taskSessions = sessionsByTask.get(r.id) ?? [];
|
|
933
|
-
const computed = computeTaskStatus(r.status, taskSessions);
|
|
934
|
-
return {
|
|
935
|
-
id: r.id,
|
|
936
|
-
workspaceId: r.workspaceId ?? undefined,
|
|
937
|
-
title: r.title,
|
|
938
|
-
description: r.description,
|
|
939
|
-
status: computed.status,
|
|
940
|
-
branch: r.branch,
|
|
941
|
-
latestSessionId: computed.latestSessionId,
|
|
942
|
-
dependsOn: safeParseJsonArray(r.dependsOn),
|
|
943
|
-
sortOrder: r.sortOrder,
|
|
944
|
-
createdAt: r.createdAt,
|
|
945
|
-
parentTaskId: r.parentTaskId,
|
|
946
|
-
depth: r.depth,
|
|
947
|
-
childTaskIds: childIdsMap.get(r.id) ?? [],
|
|
948
|
-
canDecompose: r.canDecompose,
|
|
949
|
-
defaultPersonaId: r.defaultPersonaId,
|
|
950
|
-
};
|
|
951
|
-
}),
|
|
952
|
-
},
|
|
953
|
-
});
|
|
954
|
-
break;
|
|
955
|
-
}
|
|
956
|
-
case "create_task": {
|
|
957
|
-
const workspaceId = msg.payload?.workspaceId || undefined;
|
|
958
|
-
const title = msg.payload?.title;
|
|
959
|
-
const requestId = typeof msg.payload?.requestId === "string"
|
|
960
|
-
? msg.payload.requestId
|
|
961
|
-
: "";
|
|
962
|
-
if (!title) {
|
|
963
|
-
sendWs(ws, {
|
|
964
|
-
type: "create_task_error",
|
|
965
|
-
payload: { message: "title required", requestId },
|
|
966
|
-
});
|
|
967
|
-
return;
|
|
968
|
-
}
|
|
969
|
-
let workspace;
|
|
970
|
-
if (workspaceId) {
|
|
971
|
-
workspace = workspaceStore.getWorkspace(workspaceId);
|
|
972
|
-
if (!workspace) {
|
|
973
|
-
sendWs(ws, {
|
|
974
|
-
type: "create_task_error",
|
|
975
|
-
payload: { message: `Workspace not found: ${workspaceId}`, requestId },
|
|
976
|
-
});
|
|
977
|
-
return;
|
|
978
|
-
}
|
|
979
|
-
}
|
|
980
|
-
const parentTaskId = msg.payload?.parentTaskId || "";
|
|
981
|
-
const rawCanDecompose = msg.payload?.canDecompose;
|
|
982
|
-
// Default to false (no decomposition rights) unless explicitly granted.
|
|
983
|
-
// Orchestrator/root processes that need fork() must opt in via canDecompose: true.
|
|
984
|
-
const canDecompose = typeof rawCanDecompose === "boolean" ? rawCanDecompose : false;
|
|
985
|
-
try {
|
|
986
|
-
const id = uuid().slice(0, 8);
|
|
987
|
-
taskStore.createTask(id, workspaceId, title, msg.payload?.description || "", msg.payload?.dependsOn || [], workspace ? slugify(workspace.name) : "", parentTaskId, canDecompose, msg.payload?.defaultPersonaId || "");
|
|
988
|
-
emit("task.created", { taskId: id, workspaceId, requestId });
|
|
989
|
-
}
|
|
990
|
-
catch (error) {
|
|
991
|
-
const message = error instanceof Error ? error.message : "Failed to create task";
|
|
992
|
-
sendWs(ws, {
|
|
993
|
-
type: "create_task_error",
|
|
994
|
-
payload: { message, requestId },
|
|
995
|
-
});
|
|
996
|
-
}
|
|
997
|
-
break;
|
|
998
|
-
}
|
|
999
|
-
case "update_task": {
|
|
1000
|
-
const updateTaskId = msg.payload?.taskId;
|
|
1001
|
-
if (!updateTaskId) {
|
|
1002
|
-
sendWs(ws, { type: "error", payload: { message: "taskId required" } });
|
|
1003
|
-
return;
|
|
1004
|
-
}
|
|
1005
|
-
const existingTask = taskStore.getTask(updateTaskId);
|
|
1006
|
-
if (!existingTask) {
|
|
1007
|
-
sendWs(ws, { type: "error", payload: { message: `Task not found: ${updateTaskId}` } });
|
|
1008
|
-
return;
|
|
1009
|
-
}
|
|
1010
|
-
// Late-bind: associate a running session with this task
|
|
1011
|
-
const lateBindSessionId = typeof msg.payload?.sessionId === "string" ? msg.payload.sessionId : "";
|
|
1012
|
-
if (lateBindSessionId) {
|
|
1013
|
-
const session = sessionStore.getSession(lateBindSessionId);
|
|
1014
|
-
if (!session) {
|
|
1015
|
-
sendWs(ws, { type: "error", payload: { message: `Session not found: ${lateBindSessionId}` } });
|
|
1016
|
-
return;
|
|
1017
|
-
}
|
|
1018
|
-
if (TERMINAL_SESSION_STATUSES.has(session.status)) {
|
|
1019
|
-
sendWs(ws, {
|
|
1020
|
-
type: "error",
|
|
1021
|
-
payload: { message: `Cannot bind terminal session ${lateBindSessionId} (status: ${session.status})` },
|
|
1022
|
-
});
|
|
1023
|
-
return;
|
|
1024
|
-
}
|
|
1025
|
-
// Verify the processor exists before mutating DB state to avoid partial updates
|
|
1026
|
-
if (!processorRegistry.get(lateBindSessionId)) {
|
|
1027
|
-
sendWs(ws, {
|
|
1028
|
-
type: "error",
|
|
1029
|
-
payload: { message: `No active event processor for session ${lateBindSessionId}` },
|
|
1030
|
-
});
|
|
1031
|
-
return;
|
|
1032
|
-
}
|
|
1033
|
-
sessionStore.setSessionTask(lateBindSessionId, updateTaskId);
|
|
1034
|
-
try {
|
|
1035
|
-
processorRegistry.lateBind(lateBindSessionId, updateTaskId, existingTask.workspaceId || undefined);
|
|
1036
|
-
}
|
|
1037
|
-
catch (err) {
|
|
1038
|
-
sendWs(ws, { type: "error", payload: { message: String(err) } });
|
|
1039
|
-
return;
|
|
1040
|
-
}
|
|
1041
|
-
emit("task.started", { taskId: updateTaskId, sessionId: lateBindSessionId, workspaceId: existingTask.workspaceId || "" });
|
|
1042
|
-
break;
|
|
1043
|
-
}
|
|
1044
|
-
// Only allow editing not_started tasks (non-late-bind path)
|
|
1045
|
-
if (existingTask.status !== TASK_STATUS.NOT_STARTED) {
|
|
1046
|
-
sendWs(ws, {
|
|
1047
|
-
type: "error",
|
|
1048
|
-
payload: { message: `Task ${updateTaskId} cannot be edited (status: ${existingTask.status})` },
|
|
1049
|
-
});
|
|
1050
|
-
return;
|
|
1051
|
-
}
|
|
1052
|
-
const updatedTitle = typeof msg.payload?.title === "string" && msg.payload.title.trim()
|
|
1053
|
-
? msg.payload.title.trim()
|
|
1054
|
-
: existingTask.title;
|
|
1055
|
-
const updatedDescription = typeof msg.payload?.description === "string"
|
|
1056
|
-
? msg.payload.description
|
|
1057
|
-
: existingTask.description;
|
|
1058
|
-
const updatedDependsOn = Array.isArray(msg.payload?.dependsOn)
|
|
1059
|
-
? [
|
|
1060
|
-
// Normalise: keep only non-empty strings, remove self-references and duplicates.
|
|
1061
|
-
...new Set(msg.payload.dependsOn
|
|
1062
|
-
.filter((d) => typeof d === "string" && d.trim() !== "")
|
|
1063
|
-
.filter((d) => d !== updateTaskId)),
|
|
1064
|
-
]
|
|
1065
|
-
: safeParseJsonArray(existingTask.dependsOn);
|
|
1066
|
-
const updatedDefaultPersonaId = typeof msg.payload?.defaultPersonaId === "string"
|
|
1067
|
-
? msg.payload.defaultPersonaId
|
|
1068
|
-
: undefined;
|
|
1069
|
-
taskStore.updateTask(updateTaskId, updatedTitle, updatedDescription, existingTask.status, updatedDependsOn, updatedDefaultPersonaId);
|
|
1070
|
-
emit("task.updated", { taskId: updateTaskId, workspaceId: existingTask.workspaceId || "" });
|
|
1071
|
-
break;
|
|
1072
|
-
}
|
|
1073
|
-
case "start_task": {
|
|
1074
|
-
const taskId = msg.payload?.taskId;
|
|
1075
|
-
if (!taskId)
|
|
1076
|
-
return;
|
|
1077
|
-
const task = taskStore.getTask(taskId);
|
|
1078
|
-
if (!task) {
|
|
1079
|
-
sendWs(ws, {
|
|
1080
|
-
type: "error",
|
|
1081
|
-
payload: { message: `Task not found: ${taskId}` },
|
|
1082
|
-
});
|
|
1083
|
-
return;
|
|
1084
|
-
}
|
|
1085
|
-
{
|
|
1086
|
-
const taskSessions = sessionStore.listSessionsForTask(taskId);
|
|
1087
|
-
const { status: effectiveStatus } = computeTaskStatus(task.status, taskSessions);
|
|
1088
|
-
if (taskId === ROOT_TASK_ID) {
|
|
1089
|
-
// Root task is always re-startable unless actively working
|
|
1090
|
-
if (effectiveStatus === TASK_STATUS.WORKING) {
|
|
1091
|
-
sendWs(ws, {
|
|
1092
|
-
type: "error",
|
|
1093
|
-
payload: { message: "System is already running" },
|
|
1094
|
-
});
|
|
1095
|
-
return;
|
|
1096
|
-
}
|
|
1097
|
-
}
|
|
1098
|
-
else if (![TASK_STATUS.NOT_STARTED, TASK_STATUS.FAILED].includes(effectiveStatus)) {
|
|
1099
|
-
sendWs(ws, {
|
|
1100
|
-
type: "error",
|
|
1101
|
-
payload: {
|
|
1102
|
-
message: `Task cannot be started (status: ${effectiveStatus})`,
|
|
1103
|
-
},
|
|
1104
|
-
});
|
|
1105
|
-
return;
|
|
1106
|
-
}
|
|
1107
|
-
}
|
|
1108
|
-
if (!taskStore.areDependenciesMet(taskId)) {
|
|
1109
|
-
sendWs(ws, {
|
|
1110
|
-
type: "error",
|
|
1111
|
-
payload: { message: "Task has unmet dependencies" },
|
|
1112
|
-
});
|
|
1113
|
-
return;
|
|
1114
|
-
}
|
|
1115
|
-
const startError = await startTaskSession(ws, task, {
|
|
1116
|
-
personaId: msg.payload?.personaId || undefined,
|
|
1117
|
-
environmentId: msg.payload?.environmentId || undefined,
|
|
1118
|
-
notes: msg.payload?.notes || undefined,
|
|
1119
|
-
});
|
|
1120
|
-
if (startError) {
|
|
1121
|
-
sendWs(ws, { type: "error", payload: { message: startError } });
|
|
1122
|
-
}
|
|
362
|
+
default:
|
|
363
|
+
// ignore unknown messages
|
|
1123
364
|
break;
|
|
1124
|
-
}
|
|
1125
|
-
case "complete_task": {
|
|
1126
|
-
const taskId = msg.payload?.taskId;
|
|
1127
|
-
if (!taskId)
|
|
1128
|
-
return;
|
|
1129
|
-
if (taskId === ROOT_TASK_ID) {
|
|
1130
|
-
sendWs(ws, { type: "error", payload: { message: "Cannot complete the system task" } });
|
|
1131
|
-
return;
|
|
1132
|
-
}
|
|
1133
|
-
taskStore.markTaskComplete(taskId, TASK_STATUS.COMPLETE);
|
|
1134
|
-
const task = taskStore.getTask(taskId);
|
|
1135
|
-
const unblocked = task?.workspaceId ? taskStore.checkAndUnblock(task.workspaceId) : [];
|
|
1136
|
-
sendWs(ws, {
|
|
1137
|
-
type: "task_completed",
|
|
1138
|
-
payload: {
|
|
1139
|
-
taskId,
|
|
1140
|
-
unblockedTaskIds: unblocked.map((t) => t.id),
|
|
1141
|
-
},
|
|
1142
|
-
});
|
|
1143
|
-
if (task) {
|
|
1144
|
-
emit("task.completed", { taskId, workspaceId: task.workspaceId || "" });
|
|
1145
|
-
}
|
|
1146
|
-
break;
|
|
1147
|
-
}
|
|
1148
|
-
case "resume_task": {
|
|
1149
|
-
const taskId = msg.payload?.taskId;
|
|
1150
|
-
if (!taskId)
|
|
1151
|
-
return;
|
|
1152
|
-
const task = taskStore.getTask(taskId);
|
|
1153
|
-
if (!task) {
|
|
1154
|
-
sendWs(ws, { type: "error", payload: { message: `Task not found: ${taskId}` } });
|
|
1155
|
-
return;
|
|
1156
|
-
}
|
|
1157
|
-
const latestSession = sessionStore.getLatestSessionForTask(taskId);
|
|
1158
|
-
if (!latestSession) {
|
|
1159
|
-
sendWs(ws, { type: "error", payload: { message: `Task ${taskId} has no sessions to resume` } });
|
|
1160
|
-
return;
|
|
1161
|
-
}
|
|
1162
|
-
if (![SESSION_STATUS.STOPPED, SESSION_STATUS.SUSPENDED].includes(latestSession.status)) {
|
|
1163
|
-
sendWs(ws, {
|
|
1164
|
-
type: "error",
|
|
1165
|
-
payload: { message: `Latest session ${latestSession.id} is not resumable (status: ${latestSession.status})` },
|
|
1166
|
-
});
|
|
1167
|
-
return;
|
|
1168
|
-
}
|
|
1169
|
-
if (!latestSession.runtimeSessionId) {
|
|
1170
|
-
sendWs(ws, {
|
|
1171
|
-
type: "error",
|
|
1172
|
-
payload: { message: `Latest session ${latestSession.id} has no runtime session ID — cannot resume` },
|
|
1173
|
-
});
|
|
1174
|
-
return;
|
|
1175
|
-
}
|
|
1176
|
-
const env = envRegistry.getEnvironment(latestSession.environmentId);
|
|
1177
|
-
if (!env) {
|
|
1178
|
-
sendWs(ws, { type: "error", payload: { message: `Environment not found: ${latestSession.environmentId}` } });
|
|
1179
|
-
return;
|
|
1180
|
-
}
|
|
1181
|
-
const conn = await autoProvisionEnvironment(ws, latestSession.environmentId, env, { taskId });
|
|
1182
|
-
if (!conn) {
|
|
1183
|
-
return;
|
|
1184
|
-
}
|
|
1185
|
-
const powerlineReq = create(powerline.ResumeRequestSchema, {
|
|
1186
|
-
sessionId: latestSession.id,
|
|
1187
|
-
runtimeSessionId: latestSession.runtimeSessionId,
|
|
1188
|
-
runtime: latestSession.runtime,
|
|
1189
|
-
});
|
|
1190
|
-
const logPath = latestSession.logPath || join(grackleHome, LOGS_DIR, latestSession.id);
|
|
1191
|
-
processEventStream(conn.client.resume(powerlineReq), {
|
|
1192
|
-
sessionId: latestSession.id,
|
|
1193
|
-
logPath,
|
|
1194
|
-
workspaceId: task.workspaceId ?? undefined,
|
|
1195
|
-
taskId: task.id,
|
|
1196
|
-
});
|
|
1197
|
-
emit("task.started", { taskId: task.id, sessionId: latestSession.id, workspaceId: task.workspaceId || "" });
|
|
1198
|
-
break;
|
|
1199
|
-
}
|
|
1200
|
-
case "delete_task": {
|
|
1201
|
-
const taskId = msg.payload?.taskId;
|
|
1202
|
-
if (!taskId)
|
|
1203
|
-
return;
|
|
1204
|
-
if (taskId === ROOT_TASK_ID) {
|
|
1205
|
-
sendWs(ws, { type: "error", payload: { message: "Cannot delete the system task" } });
|
|
1206
|
-
return;
|
|
1207
|
-
}
|
|
1208
|
-
const deletedTask = taskStore.getTask(taskId);
|
|
1209
|
-
if (!deletedTask) {
|
|
1210
|
-
sendWs(ws, { type: "error", payload: { message: `Task not found: ${taskId}` } });
|
|
1211
|
-
return;
|
|
1212
|
-
}
|
|
1213
|
-
const children = taskStore.getChildren(taskId);
|
|
1214
|
-
if (children.length > 0) {
|
|
1215
|
-
sendWs(ws, {
|
|
1216
|
-
type: "error",
|
|
1217
|
-
payload: {
|
|
1218
|
-
message: "Cannot delete task with children. Delete children first.",
|
|
1219
|
-
},
|
|
1220
|
-
});
|
|
1221
|
-
return;
|
|
1222
|
-
}
|
|
1223
|
-
// Terminate all active sessions via fd closure before deleting the task
|
|
1224
|
-
const deleteTaskSessions = sessionStore.getActiveSessionsForTask(taskId);
|
|
1225
|
-
for (const activeSession of deleteTaskSessions) {
|
|
1226
|
-
await terminateSession(activeSession.id);
|
|
1227
|
-
}
|
|
1228
|
-
const changes = taskStore.deleteTask(taskId);
|
|
1229
|
-
if (changes === 0) {
|
|
1230
|
-
logger.error({ taskId }, "deleteTask returned 0 changes despite task existing");
|
|
1231
|
-
sendWs(ws, { type: "error", payload: { message: `Failed to delete task ${taskId}: no rows affected` } });
|
|
1232
|
-
return;
|
|
1233
|
-
}
|
|
1234
|
-
emit("task.deleted", { taskId, workspaceId: deletedTask.workspaceId || "" });
|
|
1235
|
-
break;
|
|
1236
|
-
}
|
|
1237
|
-
// ─── Task Sessions ─────────────────────────────────────
|
|
1238
|
-
case "get_task_sessions": {
|
|
1239
|
-
const taskId = msg.payload?.taskId;
|
|
1240
|
-
if (typeof taskId !== "string" || taskId.length === 0) {
|
|
1241
|
-
return;
|
|
1242
|
-
}
|
|
1243
|
-
const taskSessions = sessionStore.listSessionsForTask(taskId);
|
|
1244
|
-
sendWs(ws, {
|
|
1245
|
-
type: "task_sessions",
|
|
1246
|
-
payload: {
|
|
1247
|
-
taskId,
|
|
1248
|
-
sessions: taskSessions.map((r) => ({
|
|
1249
|
-
id: r.id,
|
|
1250
|
-
environmentId: r.environmentId,
|
|
1251
|
-
runtime: r.runtime,
|
|
1252
|
-
status: r.status,
|
|
1253
|
-
prompt: r.prompt,
|
|
1254
|
-
startedAt: r.startedAt,
|
|
1255
|
-
endedAt: r.endedAt ?? "",
|
|
1256
|
-
error: r.error ?? "",
|
|
1257
|
-
personaId: r.personaId,
|
|
1258
|
-
inputTokens: r.inputTokens,
|
|
1259
|
-
outputTokens: r.outputTokens,
|
|
1260
|
-
costUsd: r.costUsd,
|
|
1261
|
-
endReason: r.endReason || undefined,
|
|
1262
|
-
})),
|
|
1263
|
-
},
|
|
1264
|
-
});
|
|
1265
|
-
break;
|
|
1266
|
-
}
|
|
1267
|
-
// ─── Usage ────────────────────────────────────────────
|
|
1268
|
-
case "get_usage": {
|
|
1269
|
-
const scope = msg.payload?.scope || "";
|
|
1270
|
-
const id = msg.payload?.id || "";
|
|
1271
|
-
if (!id || !scope) {
|
|
1272
|
-
return;
|
|
1273
|
-
}
|
|
1274
|
-
let usage = { inputTokens: 0, outputTokens: 0, costUsd: 0, sessionCount: 0 };
|
|
1275
|
-
if (scope === "session") {
|
|
1276
|
-
const s = sessionStore.getSession(id);
|
|
1277
|
-
if (s) {
|
|
1278
|
-
usage = { inputTokens: s.inputTokens, outputTokens: s.outputTokens, costUsd: s.costUsd, sessionCount: 1 };
|
|
1279
|
-
}
|
|
1280
|
-
}
|
|
1281
|
-
else if (scope === "task") {
|
|
1282
|
-
usage = sessionStore.aggregateUsage({ taskId: id });
|
|
1283
|
-
}
|
|
1284
|
-
else if (scope === "task_tree") {
|
|
1285
|
-
const descendants = taskStore.getDescendants(id);
|
|
1286
|
-
usage = sessionStore.aggregateUsage({ taskIds: [id, ...descendants.map((d) => d.id)] });
|
|
1287
|
-
}
|
|
1288
|
-
else if (scope === "workspace") {
|
|
1289
|
-
const wsTasks = taskStore.listTasks(id);
|
|
1290
|
-
if (wsTasks.length > 0) {
|
|
1291
|
-
usage = sessionStore.aggregateUsage({ taskIds: wsTasks.map((t) => t.id) });
|
|
1292
|
-
}
|
|
1293
|
-
}
|
|
1294
|
-
else if (scope === "environment") {
|
|
1295
|
-
usage = sessionStore.aggregateUsage({ environmentId: id });
|
|
1296
|
-
}
|
|
1297
|
-
sendWs(ws, { type: "usage_stats", payload: { scope, id, ...usage } });
|
|
1298
|
-
break;
|
|
1299
|
-
}
|
|
1300
|
-
// ─── Findings ──────────────────────────────────────────
|
|
1301
|
-
case "list_findings": {
|
|
1302
|
-
const workspaceId = msg.payload?.workspaceId;
|
|
1303
|
-
if (!workspaceId)
|
|
1304
|
-
return;
|
|
1305
|
-
const rows = findingStore.queryFindings(workspaceId, msg.payload?.categories || undefined, msg.payload?.tags || undefined, msg.payload?.limit || undefined);
|
|
1306
|
-
sendWs(ws, {
|
|
1307
|
-
type: "findings",
|
|
1308
|
-
payload: {
|
|
1309
|
-
workspaceId,
|
|
1310
|
-
findings: rows.map((r) => ({
|
|
1311
|
-
id: r.id,
|
|
1312
|
-
workspaceId: r.workspaceId,
|
|
1313
|
-
taskId: r.taskId,
|
|
1314
|
-
sessionId: r.sessionId,
|
|
1315
|
-
category: r.category,
|
|
1316
|
-
title: r.title,
|
|
1317
|
-
content: r.content,
|
|
1318
|
-
tags: safeParseJsonArray(r.tags),
|
|
1319
|
-
createdAt: r.createdAt,
|
|
1320
|
-
})),
|
|
1321
|
-
},
|
|
1322
|
-
});
|
|
1323
|
-
break;
|
|
1324
|
-
}
|
|
1325
|
-
case "post_finding": {
|
|
1326
|
-
const workspaceId = msg.payload?.workspaceId;
|
|
1327
|
-
const title = msg.payload?.title;
|
|
1328
|
-
if (!workspaceId || !title) {
|
|
1329
|
-
sendWs(ws, {
|
|
1330
|
-
type: "error",
|
|
1331
|
-
payload: { message: "workspaceId and title required" },
|
|
1332
|
-
});
|
|
1333
|
-
return;
|
|
1334
|
-
}
|
|
1335
|
-
const id = uuid().slice(0, 8);
|
|
1336
|
-
findingStore.postFinding(id, workspaceId, msg.payload?.taskId || "", msg.payload?.sessionId || "", msg.payload?.category || "general", title, msg.payload?.content || "", msg.payload?.tags || []);
|
|
1337
|
-
sendWs(ws, { type: "finding_posted", payload: { id, workspaceId } });
|
|
1338
|
-
break;
|
|
1339
|
-
}
|
|
1340
|
-
case "provision_environment": {
|
|
1341
|
-
const environmentId = msg.payload?.environmentId;
|
|
1342
|
-
if (!environmentId) {
|
|
1343
|
-
sendWs(ws, {
|
|
1344
|
-
type: "error",
|
|
1345
|
-
payload: { message: "environmentId required" },
|
|
1346
|
-
});
|
|
1347
|
-
return;
|
|
1348
|
-
}
|
|
1349
|
-
// Manual provision overrides auto-reconnect
|
|
1350
|
-
clearReconnectState(environmentId);
|
|
1351
|
-
const env = envRegistry.getEnvironment(environmentId);
|
|
1352
|
-
if (!env) {
|
|
1353
|
-
sendWs(ws, {
|
|
1354
|
-
type: "error",
|
|
1355
|
-
payload: { message: `Environment not found: ${environmentId}` },
|
|
1356
|
-
});
|
|
1357
|
-
return;
|
|
1358
|
-
}
|
|
1359
|
-
const adapter = adapterManager.getAdapter(env.adapterType);
|
|
1360
|
-
if (!adapter) {
|
|
1361
|
-
sendWs(ws, {
|
|
1362
|
-
type: "error",
|
|
1363
|
-
payload: { message: `No adapter for type: ${env.adapterType}` },
|
|
1364
|
-
});
|
|
1365
|
-
return;
|
|
1366
|
-
}
|
|
1367
|
-
logger.info({ environmentId, adapterType: env.adapterType }, "Provisioning environment");
|
|
1368
|
-
envRegistry.updateEnvironmentStatus(environmentId, "connecting");
|
|
1369
|
-
emit("environment.changed", {});
|
|
1370
|
-
// Run provision in background, broadcasting progress to all connected clients
|
|
1371
|
-
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
|
1372
|
-
(async () => {
|
|
1373
|
-
try {
|
|
1374
|
-
const config = safeParseAdapterConfig(env.adapterConfig, environmentId);
|
|
1375
|
-
config.defaultRuntime = env.defaultRuntime;
|
|
1376
|
-
const powerlineToken = env.powerlineToken || "";
|
|
1377
|
-
for await (const event of reconnectOrProvision(environmentId, adapter, config, powerlineToken, !!env.bootstrapped)) {
|
|
1378
|
-
logger.info({ environmentId, stage: event.stage, message: event.message }, "Provision progress");
|
|
1379
|
-
emit("environment.provision_progress", {
|
|
1380
|
-
environmentId,
|
|
1381
|
-
stage: event.stage,
|
|
1382
|
-
message: event.message,
|
|
1383
|
-
progress: event.progress,
|
|
1384
|
-
});
|
|
1385
|
-
}
|
|
1386
|
-
logger.info({ environmentId }, "Provision complete, calling adapter.connect");
|
|
1387
|
-
const conn = await adapter.connect(environmentId, config, powerlineToken);
|
|
1388
|
-
adapterManager.setConnection(environmentId, conn);
|
|
1389
|
-
// Push stored tokens to newly connected environment
|
|
1390
|
-
await tokenPush.pushToEnv(environmentId);
|
|
1391
|
-
envRegistry.updateEnvironmentStatus(environmentId, "connected");
|
|
1392
|
-
envRegistry.markBootstrapped(environmentId);
|
|
1393
|
-
// Auto-recover suspended sessions (fire-and-forget)
|
|
1394
|
-
recoverSuspendedSessions(environmentId, conn).catch((err) => {
|
|
1395
|
-
logger.error({ environmentId, err }, "Session recovery failed");
|
|
1396
|
-
});
|
|
1397
|
-
logger.info({ environmentId }, "Environment connected");
|
|
1398
|
-
emit("environment.provision_progress", {
|
|
1399
|
-
environmentId,
|
|
1400
|
-
stage: "ready",
|
|
1401
|
-
message: "Environment connected",
|
|
1402
|
-
progress: 1,
|
|
1403
|
-
});
|
|
1404
|
-
}
|
|
1405
|
-
catch (err) {
|
|
1406
|
-
logger.error({ environmentId, err }, "Provision failed");
|
|
1407
|
-
envRegistry.updateEnvironmentStatus(environmentId, "error");
|
|
1408
|
-
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
1409
|
-
emit("environment.provision_progress", {
|
|
1410
|
-
environmentId,
|
|
1411
|
-
stage: "error",
|
|
1412
|
-
message: `Connection failed: ${errorMessage}`,
|
|
1413
|
-
progress: 0,
|
|
1414
|
-
});
|
|
1415
|
-
}
|
|
1416
|
-
emit("environment.changed", {});
|
|
1417
|
-
})();
|
|
1418
|
-
break;
|
|
1419
|
-
}
|
|
1420
|
-
case "stop_environment": {
|
|
1421
|
-
const environmentId = msg.payload?.environmentId;
|
|
1422
|
-
if (!environmentId) {
|
|
1423
|
-
sendWs(ws, {
|
|
1424
|
-
type: "error",
|
|
1425
|
-
payload: { message: "environmentId required" },
|
|
1426
|
-
});
|
|
1427
|
-
return;
|
|
1428
|
-
}
|
|
1429
|
-
const env = envRegistry.getEnvironment(environmentId);
|
|
1430
|
-
if (!env) {
|
|
1431
|
-
sendWs(ws, {
|
|
1432
|
-
type: "error",
|
|
1433
|
-
payload: { message: `Environment not found: ${environmentId}` },
|
|
1434
|
-
});
|
|
1435
|
-
return;
|
|
1436
|
-
}
|
|
1437
|
-
const adapter = adapterManager.getAdapter(env.adapterType);
|
|
1438
|
-
if (adapter) {
|
|
1439
|
-
const config = safeParseAdapterConfig(env.adapterConfig, environmentId);
|
|
1440
|
-
await adapter.stop(environmentId, config);
|
|
1441
|
-
}
|
|
1442
|
-
adapterManager.removeConnection(environmentId);
|
|
1443
|
-
envRegistry.updateEnvironmentStatus(environmentId, "disconnected");
|
|
1444
|
-
logger.info({ environmentId }, "Environment stopped");
|
|
1445
|
-
emit("environment.changed", {});
|
|
1446
|
-
break;
|
|
1447
|
-
}
|
|
1448
|
-
case "add_environment": {
|
|
1449
|
-
const displayName = msg.payload?.displayName || "";
|
|
1450
|
-
const adapterType = msg.payload?.adapterType || "";
|
|
1451
|
-
if (!displayName || !adapterType) {
|
|
1452
|
-
sendWs(ws, {
|
|
1453
|
-
type: "error",
|
|
1454
|
-
payload: { message: "displayName and adapterType required" },
|
|
1455
|
-
});
|
|
1456
|
-
return;
|
|
1457
|
-
}
|
|
1458
|
-
if (!adapterManager.getAdapter(adapterType)) {
|
|
1459
|
-
sendWs(ws, {
|
|
1460
|
-
type: "error",
|
|
1461
|
-
payload: { message: `Unknown adapter type: ${adapterType}` },
|
|
1462
|
-
});
|
|
1463
|
-
return;
|
|
1464
|
-
}
|
|
1465
|
-
const baseEnvId = slugify(displayName) || uuid().slice(0, 8);
|
|
1466
|
-
let id = baseEnvId;
|
|
1467
|
-
for (let attempt = 0; attempt < 10 && envRegistry.getEnvironment(id); attempt++) {
|
|
1468
|
-
id = `${baseEnvId}-${uuid().slice(0, 4)}`;
|
|
1469
|
-
}
|
|
1470
|
-
if (envRegistry.getEnvironment(id)) {
|
|
1471
|
-
id = uuid();
|
|
1472
|
-
}
|
|
1473
|
-
const rawAdapterConfig = msg.payload?.adapterConfig;
|
|
1474
|
-
let adapterConfig;
|
|
1475
|
-
if (rawAdapterConfig === undefined || rawAdapterConfig === null) {
|
|
1476
|
-
adapterConfig = "{}";
|
|
1477
|
-
}
|
|
1478
|
-
else if (typeof rawAdapterConfig === "string") {
|
|
1479
|
-
const normalized = rawAdapterConfig.trim() === "" ? "{}" : rawAdapterConfig;
|
|
1480
|
-
try {
|
|
1481
|
-
JSON.parse(normalized);
|
|
1482
|
-
}
|
|
1483
|
-
catch {
|
|
1484
|
-
sendWs(ws, {
|
|
1485
|
-
type: "error",
|
|
1486
|
-
payload: { message: "adapterConfig string is not valid JSON" },
|
|
1487
|
-
});
|
|
1488
|
-
return;
|
|
1489
|
-
}
|
|
1490
|
-
adapterConfig = normalized;
|
|
1491
|
-
}
|
|
1492
|
-
else if (typeof rawAdapterConfig === "object") {
|
|
1493
|
-
adapterConfig = JSON.stringify(rawAdapterConfig);
|
|
1494
|
-
}
|
|
1495
|
-
else {
|
|
1496
|
-
sendWs(ws, {
|
|
1497
|
-
type: "error",
|
|
1498
|
-
payload: {
|
|
1499
|
-
message: "adapterConfig must be an object or JSON string",
|
|
1500
|
-
},
|
|
1501
|
-
});
|
|
1502
|
-
return;
|
|
1503
|
-
}
|
|
1504
|
-
envRegistry.addEnvironment(id, displayName, adapterType, adapterConfig);
|
|
1505
|
-
logger.info({ id, displayName, adapterType }, "Environment added via WebSocket");
|
|
1506
|
-
emit("environment.added", { environmentId: id });
|
|
1507
|
-
emit("environment.changed", {});
|
|
1508
|
-
break;
|
|
1509
|
-
}
|
|
1510
|
-
case "update_environment": {
|
|
1511
|
-
const environmentId = msg.payload?.environmentId;
|
|
1512
|
-
if (!environmentId) {
|
|
1513
|
-
sendWs(ws, { type: "error", payload: { message: "environmentId required" } });
|
|
1514
|
-
return;
|
|
1515
|
-
}
|
|
1516
|
-
const existing = envRegistry.getEnvironment(environmentId);
|
|
1517
|
-
if (!existing) {
|
|
1518
|
-
sendWs(ws, { type: "error", payload: { message: `Environment not found: ${environmentId}` } });
|
|
1519
|
-
return;
|
|
1520
|
-
}
|
|
1521
|
-
const nameVal = typeof msg.payload?.displayName === "string" ? msg.payload.displayName : undefined;
|
|
1522
|
-
if (nameVal?.trim() === "") {
|
|
1523
|
-
sendWs(ws, { type: "error", payload: { message: "Environment name cannot be empty" } });
|
|
1524
|
-
return;
|
|
1525
|
-
}
|
|
1526
|
-
let configVal;
|
|
1527
|
-
const rawConfig = msg.payload?.adapterConfig;
|
|
1528
|
-
if (rawConfig !== undefined) {
|
|
1529
|
-
if (rawConfig === null) {
|
|
1530
|
-
configVal = "{}";
|
|
1531
|
-
}
|
|
1532
|
-
else if (typeof rawConfig === "string") {
|
|
1533
|
-
const normalized = rawConfig.trim() === "" ? "{}" : rawConfig;
|
|
1534
|
-
let parsed;
|
|
1535
|
-
try {
|
|
1536
|
-
parsed = JSON.parse(normalized);
|
|
1537
|
-
}
|
|
1538
|
-
catch {
|
|
1539
|
-
sendWs(ws, { type: "error", payload: { message: "adapterConfig string is not valid JSON" } });
|
|
1540
|
-
return;
|
|
1541
|
-
}
|
|
1542
|
-
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
1543
|
-
sendWs(ws, { type: "error", payload: { message: "adapterConfig must be a JSON object" } });
|
|
1544
|
-
return;
|
|
1545
|
-
}
|
|
1546
|
-
configVal = normalized;
|
|
1547
|
-
}
|
|
1548
|
-
else if (typeof rawConfig === "object") {
|
|
1549
|
-
if (Array.isArray(rawConfig)) {
|
|
1550
|
-
sendWs(ws, { type: "error", payload: { message: "adapterConfig must be a JSON object" } });
|
|
1551
|
-
return;
|
|
1552
|
-
}
|
|
1553
|
-
configVal = JSON.stringify(rawConfig);
|
|
1554
|
-
}
|
|
1555
|
-
else {
|
|
1556
|
-
sendWs(ws, { type: "error", payload: { message: "adapterConfig must be a JSON object" } });
|
|
1557
|
-
return;
|
|
1558
|
-
}
|
|
1559
|
-
}
|
|
1560
|
-
const trimmedName = nameVal !== undefined ? nameVal.trim() : undefined;
|
|
1561
|
-
if (trimmedName === undefined && configVal === undefined) {
|
|
1562
|
-
sendWs(ws, { type: "error", payload: { message: "No updatable fields provided" } });
|
|
1563
|
-
return;
|
|
1564
|
-
}
|
|
1565
|
-
envRegistry.updateEnvironment(environmentId, {
|
|
1566
|
-
displayName: trimmedName,
|
|
1567
|
-
adapterConfig: configVal,
|
|
1568
|
-
});
|
|
1569
|
-
logger.info({ environmentId, displayName: trimmedName }, "Environment updated via WebSocket");
|
|
1570
|
-
emit("environment.changed", {});
|
|
1571
|
-
break;
|
|
1572
|
-
}
|
|
1573
|
-
case "remove_environment": {
|
|
1574
|
-
const environmentId = msg.payload?.environmentId;
|
|
1575
|
-
if (!environmentId) {
|
|
1576
|
-
sendWs(ws, {
|
|
1577
|
-
type: "error",
|
|
1578
|
-
payload: { message: "environmentId required" },
|
|
1579
|
-
});
|
|
1580
|
-
return;
|
|
1581
|
-
}
|
|
1582
|
-
clearReconnectState(environmentId);
|
|
1583
|
-
// Block deletion if workspaces still reference this environment
|
|
1584
|
-
const wsCount = workspaceStore.countWorkspacesByEnvironment(environmentId);
|
|
1585
|
-
if (wsCount > 0) {
|
|
1586
|
-
sendWs(ws, {
|
|
1587
|
-
type: "error",
|
|
1588
|
-
payload: {
|
|
1589
|
-
message: `Cannot remove environment: ${wsCount} active workspace(s) still reference it. Archive or reparent them first.`,
|
|
1590
|
-
},
|
|
1591
|
-
});
|
|
1592
|
-
return;
|
|
1593
|
-
}
|
|
1594
|
-
const env = envRegistry.getEnvironment(environmentId);
|
|
1595
|
-
if (env) {
|
|
1596
|
-
const adapter = adapterManager.getAdapter(env.adapterType);
|
|
1597
|
-
if (adapter) {
|
|
1598
|
-
const config = safeParseAdapterConfig(env.adapterConfig, environmentId);
|
|
1599
|
-
try {
|
|
1600
|
-
await adapter.destroy(environmentId, config);
|
|
1601
|
-
}
|
|
1602
|
-
catch {
|
|
1603
|
-
/* best-effort */
|
|
1604
|
-
}
|
|
1605
|
-
try {
|
|
1606
|
-
await adapter.disconnect(environmentId);
|
|
1607
|
-
}
|
|
1608
|
-
catch {
|
|
1609
|
-
/* best-effort */
|
|
1610
|
-
}
|
|
1611
|
-
}
|
|
1612
|
-
}
|
|
1613
|
-
adapterManager.removeConnection(environmentId);
|
|
1614
|
-
sessionStore.deleteByEnvironment(environmentId);
|
|
1615
|
-
envRegistry.removeEnvironment(environmentId);
|
|
1616
|
-
logger.info({ environmentId }, "Environment removed");
|
|
1617
|
-
emit("environment.removed", { environmentId });
|
|
1618
|
-
emit("environment.changed", {});
|
|
1619
|
-
break;
|
|
1620
|
-
}
|
|
1621
|
-
// ─── Codespaces ─────────────────────────────────────
|
|
1622
|
-
case "list_codespaces": {
|
|
1623
|
-
try {
|
|
1624
|
-
const result = await exec("gh", [
|
|
1625
|
-
"codespace",
|
|
1626
|
-
"list",
|
|
1627
|
-
"--json",
|
|
1628
|
-
"name,repository,state,gitStatus",
|
|
1629
|
-
"--limit",
|
|
1630
|
-
String(GH_CODESPACE_LIST_LIMIT),
|
|
1631
|
-
], { timeout: GH_CODESPACE_LIST_TIMEOUT_MS });
|
|
1632
|
-
const codespaces = JSON.parse(result.stdout || "[]");
|
|
1633
|
-
sendWs(ws, { type: "codespaces_list", payload: { codespaces } });
|
|
1634
|
-
}
|
|
1635
|
-
catch (err) {
|
|
1636
|
-
logger.warn({ err }, "Failed to list codespaces");
|
|
1637
|
-
sendWs(ws, {
|
|
1638
|
-
type: "codespaces_list",
|
|
1639
|
-
payload: {
|
|
1640
|
-
codespaces: [],
|
|
1641
|
-
error: formatGhError(err, "list codespaces"),
|
|
1642
|
-
},
|
|
1643
|
-
});
|
|
1644
|
-
}
|
|
1645
|
-
break;
|
|
1646
|
-
}
|
|
1647
|
-
case "create_codespace": {
|
|
1648
|
-
const repo = msg.payload?.repo;
|
|
1649
|
-
if (typeof repo !== "string" || repo.trim().length === 0) {
|
|
1650
|
-
sendWs(ws, {
|
|
1651
|
-
type: "codespace_create_error",
|
|
1652
|
-
payload: { message: "repo required" },
|
|
1653
|
-
});
|
|
1654
|
-
return;
|
|
1655
|
-
}
|
|
1656
|
-
const trimmedRepo = repo.trim();
|
|
1657
|
-
const machine = typeof msg.payload?.machine === "string"
|
|
1658
|
-
? msg.payload.machine.trim()
|
|
1659
|
-
: "";
|
|
1660
|
-
const createArgs = ["codespace", "create", "--repo", trimmedRepo];
|
|
1661
|
-
if (machine) {
|
|
1662
|
-
createArgs.push("--machine", machine);
|
|
1663
|
-
}
|
|
1664
|
-
try {
|
|
1665
|
-
const result = await exec("gh", createArgs, {
|
|
1666
|
-
timeout: GH_CODESPACE_CREATE_TIMEOUT_MS,
|
|
1667
|
-
});
|
|
1668
|
-
const codespaceName = result.stdout.trim();
|
|
1669
|
-
sendWs(ws, {
|
|
1670
|
-
type: "codespace_created",
|
|
1671
|
-
payload: { name: codespaceName, repository: trimmedRepo },
|
|
1672
|
-
});
|
|
1673
|
-
}
|
|
1674
|
-
catch (err) {
|
|
1675
|
-
logger.error({ err, repo }, "Failed to create codespace");
|
|
1676
|
-
sendWs(ws, {
|
|
1677
|
-
type: "codespace_create_error",
|
|
1678
|
-
payload: { message: formatGhError(err, "create codespace") },
|
|
1679
|
-
});
|
|
1680
|
-
}
|
|
1681
|
-
break;
|
|
1682
|
-
}
|
|
1683
|
-
// ─── Tokens ───────────────────────────────────────────
|
|
1684
|
-
case "list_tokens": {
|
|
1685
|
-
const items = tokenStore.listTokens();
|
|
1686
|
-
sendWs(ws, {
|
|
1687
|
-
type: "tokens",
|
|
1688
|
-
payload: {
|
|
1689
|
-
tokens: items.map((t) => ({
|
|
1690
|
-
name: t.name,
|
|
1691
|
-
tokenType: t.type,
|
|
1692
|
-
envVar: t.envVar || "",
|
|
1693
|
-
filePath: t.filePath || "",
|
|
1694
|
-
expiresAt: t.expiresAt || "",
|
|
1695
|
-
})),
|
|
1696
|
-
},
|
|
1697
|
-
});
|
|
1698
|
-
break;
|
|
1699
|
-
}
|
|
1700
|
-
case "set_token": {
|
|
1701
|
-
const name = msg.payload?.name;
|
|
1702
|
-
const value = msg.payload?.value;
|
|
1703
|
-
if (!name || !value) {
|
|
1704
|
-
sendWs(ws, {
|
|
1705
|
-
type: "error",
|
|
1706
|
-
payload: { message: "name and value required" },
|
|
1707
|
-
});
|
|
1708
|
-
return;
|
|
1709
|
-
}
|
|
1710
|
-
tokenStore.setToken({
|
|
1711
|
-
name,
|
|
1712
|
-
type: msg.payload?.tokenType || "env_var",
|
|
1713
|
-
envVar: msg.payload?.envVar || "",
|
|
1714
|
-
filePath: msg.payload?.filePath || "",
|
|
1715
|
-
value,
|
|
1716
|
-
expiresAt: msg.payload?.expiresAt || "",
|
|
1717
|
-
});
|
|
1718
|
-
await tokenPush.pushToAll();
|
|
1719
|
-
emit("token.changed", {});
|
|
1720
|
-
break;
|
|
1721
|
-
}
|
|
1722
|
-
case "delete_token": {
|
|
1723
|
-
const tokenName = msg.payload?.name;
|
|
1724
|
-
if (!tokenName) {
|
|
1725
|
-
sendWs(ws, { type: "error", payload: { message: "name required" } });
|
|
1726
|
-
return;
|
|
1727
|
-
}
|
|
1728
|
-
tokenStore.deleteToken(tokenName);
|
|
1729
|
-
await tokenPush.pushToAll();
|
|
1730
|
-
emit("token.changed", {});
|
|
1731
|
-
break;
|
|
1732
|
-
}
|
|
1733
|
-
case "get_credential_providers": {
|
|
1734
|
-
const config = credentialProviders.getCredentialProviders();
|
|
1735
|
-
sendWs(ws, {
|
|
1736
|
-
type: "credential_providers",
|
|
1737
|
-
payload: config,
|
|
1738
|
-
});
|
|
1739
|
-
break;
|
|
1740
|
-
}
|
|
1741
|
-
case "set_credential_providers": {
|
|
1742
|
-
if (!credentialProviders.isValidCredentialProviderConfig(msg.payload)) {
|
|
1743
|
-
sendWs(ws, { type: "error", payload: { message: "invalid credential provider config" } });
|
|
1744
|
-
return;
|
|
1745
|
-
}
|
|
1746
|
-
credentialProviders.setCredentialProviders(msg.payload);
|
|
1747
|
-
emit("credential.providers_changed", credentialProviders.getCredentialProviders());
|
|
1748
|
-
break;
|
|
1749
|
-
}
|
|
1750
|
-
// ── Knowledge Graph ─────────────────────────────────────────
|
|
1751
|
-
case "knowledge.search": {
|
|
1752
|
-
const embedder = getKnowledgeEmbedder();
|
|
1753
|
-
if (!embedder) {
|
|
1754
|
-
sendWs(ws, { type: "knowledge.search.result", payload: { error: "Knowledge graph not available", results: [] } });
|
|
1755
|
-
return;
|
|
1756
|
-
}
|
|
1757
|
-
try {
|
|
1758
|
-
const query = msg.payload?.query || "";
|
|
1759
|
-
const rawLimit = Number(msg.payload?.limit) || 10;
|
|
1760
|
-
const limit = Math.max(1, Math.min(50, Math.floor(rawLimit)));
|
|
1761
|
-
const workspaceId = msg.payload?.workspaceId !== undefined ? msg.payload.workspaceId : undefined;
|
|
1762
|
-
const results = await knowledgeSearch(query, embedder, { limit, workspaceId });
|
|
1763
|
-
sendWs(ws, {
|
|
1764
|
-
type: "knowledge.search.result",
|
|
1765
|
-
payload: {
|
|
1766
|
-
results: results.map((r) => ({
|
|
1767
|
-
score: r.score,
|
|
1768
|
-
node: formatKnowledgeNode(r.node),
|
|
1769
|
-
edges: r.edges.map(formatKnowledgeEdge),
|
|
1770
|
-
})),
|
|
1771
|
-
},
|
|
1772
|
-
});
|
|
1773
|
-
}
|
|
1774
|
-
catch (err) {
|
|
1775
|
-
logger.error({ err }, "Knowledge search failed");
|
|
1776
|
-
sendWs(ws, { type: "knowledge.search.result", payload: { error: "Search failed", results: [] } });
|
|
1777
|
-
}
|
|
1778
|
-
break;
|
|
1779
|
-
}
|
|
1780
|
-
case "knowledge.getNode": {
|
|
1781
|
-
if (!isKnowledgeEnabled()) {
|
|
1782
|
-
sendWs(ws, { type: "knowledge.getNode.result", payload: { error: "Knowledge graph not available" } });
|
|
1783
|
-
return;
|
|
1784
|
-
}
|
|
1785
|
-
try {
|
|
1786
|
-
const id = msg.payload?.id || "";
|
|
1787
|
-
const result = await getKnowledgeNodeById(id);
|
|
1788
|
-
if (!result) {
|
|
1789
|
-
sendWs(ws, { type: "knowledge.getNode.result", payload: { error: `Node not found: ${id}` } });
|
|
1790
|
-
return;
|
|
1791
|
-
}
|
|
1792
|
-
sendWs(ws, {
|
|
1793
|
-
type: "knowledge.getNode.result",
|
|
1794
|
-
payload: {
|
|
1795
|
-
node: formatKnowledgeNode(result.node),
|
|
1796
|
-
edges: result.edges.map(formatKnowledgeEdge),
|
|
1797
|
-
},
|
|
1798
|
-
});
|
|
1799
|
-
}
|
|
1800
|
-
catch (err) {
|
|
1801
|
-
logger.error({ err }, "Knowledge getNode failed");
|
|
1802
|
-
sendWs(ws, { type: "knowledge.getNode.result", payload: { error: "Failed to get node" } });
|
|
1803
|
-
}
|
|
1804
|
-
break;
|
|
1805
|
-
}
|
|
1806
|
-
case "knowledge.expand": {
|
|
1807
|
-
if (!isKnowledgeEnabled()) {
|
|
1808
|
-
sendWs(ws, { type: "knowledge.expand.result", payload: { error: "Knowledge graph not available", nodes: [], edges: [] } });
|
|
1809
|
-
return;
|
|
1810
|
-
}
|
|
1811
|
-
try {
|
|
1812
|
-
const id = msg.payload?.id || "";
|
|
1813
|
-
const rawDepth = Number(msg.payload?.depth) || 1;
|
|
1814
|
-
const depth = Math.max(1, Math.min(5, Math.floor(rawDepth)));
|
|
1815
|
-
const rawEdgeTypes = msg.payload?.edgeTypes;
|
|
1816
|
-
const edgeTypes = rawEdgeTypes?.length ? rawEdgeTypes : undefined;
|
|
1817
|
-
const result = await expandNode(id, { depth, edgeTypes });
|
|
1818
|
-
sendWs(ws, {
|
|
1819
|
-
type: "knowledge.expand.result",
|
|
1820
|
-
payload: {
|
|
1821
|
-
nodes: result.nodes.map(formatKnowledgeNode),
|
|
1822
|
-
edges: result.edges.map(formatKnowledgeEdge),
|
|
1823
|
-
},
|
|
1824
|
-
});
|
|
1825
|
-
}
|
|
1826
|
-
catch (err) {
|
|
1827
|
-
logger.error({ err }, "Knowledge expand failed");
|
|
1828
|
-
sendWs(ws, { type: "knowledge.expand.result", payload: { error: "Expand failed", nodes: [], edges: [] } });
|
|
1829
|
-
}
|
|
1830
|
-
break;
|
|
1831
|
-
}
|
|
1832
|
-
case "knowledge.listRecent": {
|
|
1833
|
-
if (!isKnowledgeEnabled()) {
|
|
1834
|
-
sendWs(ws, { type: "knowledge.listRecent.result", payload: { error: "Knowledge graph not available", nodes: [], edges: [] } });
|
|
1835
|
-
return;
|
|
1836
|
-
}
|
|
1837
|
-
try {
|
|
1838
|
-
const rawLimit = Number(msg.payload?.limit) || 20;
|
|
1839
|
-
const limit = Math.max(1, Math.min(100, Math.floor(rawLimit)));
|
|
1840
|
-
const workspaceId = msg.payload?.workspaceId !== undefined ? msg.payload.workspaceId : undefined;
|
|
1841
|
-
const result = await listRecentNodes(limit, workspaceId);
|
|
1842
|
-
sendWs(ws, {
|
|
1843
|
-
type: "knowledge.listRecent.result",
|
|
1844
|
-
payload: {
|
|
1845
|
-
nodes: result.nodes.map(formatKnowledgeNode),
|
|
1846
|
-
edges: result.edges.map(formatKnowledgeEdge),
|
|
1847
|
-
},
|
|
1848
|
-
});
|
|
1849
|
-
}
|
|
1850
|
-
catch (err) {
|
|
1851
|
-
logger.error({ err }, "Knowledge listRecent failed");
|
|
1852
|
-
sendWs(ws, { type: "knowledge.listRecent.result", payload: { error: "Failed to list recent", nodes: [], edges: [] } });
|
|
1853
|
-
}
|
|
1854
|
-
break;
|
|
1855
|
-
}
|
|
1856
|
-
}
|
|
1857
|
-
}
|
|
1858
|
-
/** Format a KnowledgeNode for WS transport (omit embedding). */
|
|
1859
|
-
function formatKnowledgeNode(node) {
|
|
1860
|
-
const base = {
|
|
1861
|
-
id: node.id,
|
|
1862
|
-
kind: node.kind,
|
|
1863
|
-
workspaceId: node.workspaceId,
|
|
1864
|
-
createdAt: node.createdAt,
|
|
1865
|
-
updatedAt: node.updatedAt,
|
|
1866
|
-
};
|
|
1867
|
-
if (node.kind === "reference") {
|
|
1868
|
-
base.sourceType = node.sourceType;
|
|
1869
|
-
base.sourceId = node.sourceId;
|
|
1870
|
-
base.label = node.label;
|
|
1871
365
|
}
|
|
1872
|
-
else {
|
|
1873
|
-
base.category = node.category;
|
|
1874
|
-
base.title = node.title;
|
|
1875
|
-
base.content = node.content;
|
|
1876
|
-
base.tags = node.tags;
|
|
1877
|
-
}
|
|
1878
|
-
return base;
|
|
1879
|
-
}
|
|
1880
|
-
/** Format a KnowledgeEdge for WS transport. */
|
|
1881
|
-
function formatKnowledgeEdge(edge) {
|
|
1882
|
-
return {
|
|
1883
|
-
fromId: edge.fromId,
|
|
1884
|
-
toId: edge.toId,
|
|
1885
|
-
type: edge.type,
|
|
1886
|
-
...(edge.metadata ? { metadata: edge.metadata } : {}),
|
|
1887
|
-
createdAt: edge.createdAt,
|
|
1888
|
-
};
|
|
1889
366
|
}
|
|
1890
367
|
function sendWs(ws, msg) {
|
|
1891
368
|
if (ws.readyState === WebSocket.OPEN) {
|