@grackle-ai/server 0.0.2
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 +5 -0
- package/dist/adapter-manager.d.ts +18 -0
- package/dist/adapter-manager.d.ts.map +1 -0
- package/dist/adapter-manager.js +67 -0
- package/dist/adapter-manager.js.map +1 -0
- package/dist/adapters/adapter.d.ts +40 -0
- package/dist/adapters/adapter.d.ts.map +1 -0
- package/dist/adapters/adapter.js +2 -0
- package/dist/adapters/adapter.js.map +1 -0
- package/dist/adapters/docker.d.ts +26 -0
- package/dist/adapters/docker.d.ts.map +1 -0
- package/dist/adapters/docker.js +274 -0
- package/dist/adapters/docker.js.map +1 -0
- package/dist/adapters/local.d.ts +15 -0
- package/dist/adapters/local.d.ts.map +1 -0
- package/dist/adapters/local.js +57 -0
- package/dist/adapters/local.js.map +1 -0
- package/dist/adapters/powerline-transport.d.ts +7 -0
- package/dist/adapters/powerline-transport.d.ts.map +1 -0
- package/dist/adapters/powerline-transport.js +22 -0
- package/dist/adapters/powerline-transport.js.map +1 -0
- package/dist/api-key.d.ts +8 -0
- package/dist/api-key.d.ts.map +1 -0
- package/dist/api-key.js +58 -0
- package/dist/api-key.js.map +1 -0
- package/dist/crypto.d.ts +5 -0
- package/dist/crypto.d.ts.map +1 -0
- package/dist/crypto.js +74 -0
- package/dist/crypto.js.map +1 -0
- package/dist/db.d.ts +11 -0
- package/dist/db.d.ts.map +1 -0
- package/dist/db.js +140 -0
- package/dist/db.js.map +1 -0
- package/dist/env-registry.d.ts +20 -0
- package/dist/env-registry.d.ts.map +1 -0
- package/dist/env-registry.js +55 -0
- package/dist/env-registry.js.map +1 -0
- package/dist/finding-store.d.ts +9 -0
- package/dist/finding-store.d.ts.map +1 -0
- package/dist/finding-store.js +68 -0
- package/dist/finding-store.js.map +1 -0
- package/dist/grpc-service.d.ts +4 -0
- package/dist/grpc-service.d.ts.map +1 -0
- package/dist/grpc-service.js +594 -0
- package/dist/grpc-service.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +108 -0
- package/dist/index.js.map +1 -0
- package/dist/json-helpers.d.ts +7 -0
- package/dist/json-helpers.d.ts.map +1 -0
- package/dist/json-helpers.js +22 -0
- package/dist/json-helpers.js.map +1 -0
- package/dist/log-writer.d.ts +18 -0
- package/dist/log-writer.d.ts.map +1 -0
- package/dist/log-writer.js +44 -0
- package/dist/log-writer.js.map +1 -0
- package/dist/logger.d.ts +4 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +10 -0
- package/dist/logger.js.map +1 -0
- package/dist/paths.d.ts +7 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +12 -0
- package/dist/paths.js.map +1 -0
- package/dist/project-store.d.ts +11 -0
- package/dist/project-store.d.ts.map +1 -0
- package/dist/project-store.js +32 -0
- package/dist/project-store.js.map +1 -0
- package/dist/schema.d.ts +1199 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +82 -0
- package/dist/schema.js.map +1 -0
- package/dist/session-store.d.ts +22 -0
- package/dist/session-store.d.ts.map +1 -0
- package/dist/session-store.js +73 -0
- package/dist/session-store.js.map +1 -0
- package/dist/stream-hub.d.ts +14 -0
- package/dist/stream-hub.d.ts.map +1 -0
- package/dist/stream-hub.js +95 -0
- package/dist/stream-hub.js.map +1 -0
- package/dist/task-store.d.ts +28 -0
- package/dist/task-store.d.ts.map +1 -0
- package/dist/task-store.js +121 -0
- package/dist/task-store.js.map +1 -0
- package/dist/token-broker.d.ts +27 -0
- package/dist/token-broker.d.ts.map +1 -0
- package/dist/token-broker.js +76 -0
- package/dist/token-broker.js.map +1 -0
- package/dist/transcript.d.ts +5 -0
- package/dist/transcript.d.ts.map +1 -0
- package/dist/transcript.js +51 -0
- package/dist/transcript.js.map +1 -0
- package/dist/utils/exec.d.ts +17 -0
- package/dist/utils/exec.d.ts.map +1 -0
- package/dist/utils/exec.js +21 -0
- package/dist/utils/exec.js.map +1 -0
- package/dist/utils/ports.d.ts +3 -0
- package/dist/utils/ports.d.ts.map +1 -0
- package/dist/utils/ports.js +19 -0
- package/dist/utils/ports.js.map +1 -0
- package/dist/utils/sleep.d.ts +3 -0
- package/dist/utils/sleep.d.ts.map +1 -0
- package/dist/utils/sleep.js +5 -0
- package/dist/utils/sleep.js.map +1 -0
- package/dist/ws-bridge.d.ts +10 -0
- package/dist/ws-bridge.d.ts.map +1 -0
- package/dist/ws-bridge.js +846 -0
- package/dist/ws-bridge.js.map +1 -0
- package/package.json +50 -0
|
@@ -0,0 +1,846 @@
|
|
|
1
|
+
import { WebSocketServer, WebSocket } from "ws";
|
|
2
|
+
import { create } from "@bufbuild/protobuf";
|
|
3
|
+
import { grackle, powerline } from "@grackle-ai/common";
|
|
4
|
+
import * as envRegistry from "./env-registry.js";
|
|
5
|
+
import * as sessionStore from "./session-store.js";
|
|
6
|
+
import * as adapterManager from "./adapter-manager.js";
|
|
7
|
+
import * as streamHub from "./stream-hub.js";
|
|
8
|
+
import * as projectStore from "./project-store.js";
|
|
9
|
+
import * as taskStore from "./task-store.js";
|
|
10
|
+
import * as findingStore from "./finding-store.js";
|
|
11
|
+
import { v4 as uuid } from "uuid";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { LOGS_DIR, DEFAULT_RUNTIME, DEFAULT_MODEL } from "@grackle-ai/common";
|
|
14
|
+
import { grackleHome } from "./paths.js";
|
|
15
|
+
import * as logWriter from "./log-writer.js";
|
|
16
|
+
import { writeTranscript } from "./transcript.js";
|
|
17
|
+
import { safeParseJsonArray } from "./json-helpers.js";
|
|
18
|
+
import { logger } from "./logger.js";
|
|
19
|
+
const WS_PING_INTERVAL_MS = 30_000;
|
|
20
|
+
const WS_CLOSE_UNAUTHORIZED = 4001;
|
|
21
|
+
function slugify(text) {
|
|
22
|
+
return text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40);
|
|
23
|
+
}
|
|
24
|
+
let wssInstance = undefined;
|
|
25
|
+
/** Broadcast a message to all connected WS clients. */
|
|
26
|
+
export function broadcast(msg) {
|
|
27
|
+
if (!wssInstance)
|
|
28
|
+
return;
|
|
29
|
+
const data = JSON.stringify(msg);
|
|
30
|
+
for (const client of wssInstance.clients) {
|
|
31
|
+
if (client.readyState === 1 /* OPEN */) {
|
|
32
|
+
client.send(data);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/** Create a WebSocket server on top of an HTTP server that bridges JSON messages to gRPC operations. */
|
|
37
|
+
export function createWsBridge(httpServer, verifyApiKey) {
|
|
38
|
+
const wss = new WebSocketServer({ server: httpServer });
|
|
39
|
+
wssInstance = wss;
|
|
40
|
+
wss.on("connection", (ws, req) => {
|
|
41
|
+
const url = new URL(req.url || "/", "http://localhost");
|
|
42
|
+
const token = url.searchParams.get("token") || "";
|
|
43
|
+
if (!verifyApiKey(token)) {
|
|
44
|
+
ws.close(WS_CLOSE_UNAUTHORIZED, "Unauthorized");
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const subscriptions = new Map();
|
|
48
|
+
ws.on("message", async (data) => {
|
|
49
|
+
try {
|
|
50
|
+
const msg = JSON.parse(data.toString());
|
|
51
|
+
await handleMessage(ws, msg, subscriptions);
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
sendWs(ws, { type: "error", payload: { message: String(err) } });
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
ws.on("close", () => {
|
|
58
|
+
for (const sub of subscriptions.values()) {
|
|
59
|
+
sub.cancel();
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
const pingInterval = setInterval(() => {
|
|
63
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
64
|
+
ws.ping();
|
|
65
|
+
}
|
|
66
|
+
}, WS_PING_INTERVAL_MS);
|
|
67
|
+
ws.on("close", () => clearInterval(pingInterval));
|
|
68
|
+
});
|
|
69
|
+
return wss;
|
|
70
|
+
}
|
|
71
|
+
/** Map a database environment row to the WebSocket payload shape. */
|
|
72
|
+
function envRowToWs(r) {
|
|
73
|
+
return {
|
|
74
|
+
id: r.id,
|
|
75
|
+
displayName: r.displayName,
|
|
76
|
+
adapterType: r.adapterType,
|
|
77
|
+
defaultRuntime: r.defaultRuntime,
|
|
78
|
+
status: r.status,
|
|
79
|
+
bootstrapped: r.bootstrapped,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/** Broadcast the current environment list to all connected WebSocket clients. */
|
|
83
|
+
function broadcastEnvironments() {
|
|
84
|
+
broadcast({
|
|
85
|
+
type: "environments",
|
|
86
|
+
payload: { environments: envRegistry.listEnvironments().map(envRowToWs) },
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
/** Safely parse an adapter config string, returning an empty object on failure. */
|
|
90
|
+
function safeParseAdapterConfig(raw, environmentId) {
|
|
91
|
+
try {
|
|
92
|
+
const parsed = JSON.parse(raw || "{}");
|
|
93
|
+
if (typeof parsed === "object" && parsed !== null) {
|
|
94
|
+
return parsed;
|
|
95
|
+
}
|
|
96
|
+
logger.warn({ environmentId, raw }, "adapterConfig is not an object, using empty config");
|
|
97
|
+
return {};
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
logger.warn({ environmentId, raw, err }, "Failed to parse adapterConfig, using empty config");
|
|
101
|
+
return {};
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Auto-provisions and connects an environment if it is not already connected.
|
|
106
|
+
* Sends provision progress events over the WebSocket and updates the environment
|
|
107
|
+
* registry status. Returns the connection on success, or undefined on failure.
|
|
108
|
+
*/
|
|
109
|
+
async function autoProvisionEnvironment(ws, environmentId, env, logContext) {
|
|
110
|
+
let conn = adapterManager.getConnection(environmentId);
|
|
111
|
+
if (conn) {
|
|
112
|
+
return conn;
|
|
113
|
+
}
|
|
114
|
+
const adapter = adapterManager.getAdapter(env.adapterType);
|
|
115
|
+
if (!adapter) {
|
|
116
|
+
sendWs(ws, { type: "error", payload: { message: `No adapter for type: ${env.adapterType}` } });
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
logger.info({ environmentId, ...logContext }, "Auto-provisioning environment");
|
|
120
|
+
envRegistry.updateEnvironmentStatus(environmentId, "connecting");
|
|
121
|
+
broadcastEnvironments();
|
|
122
|
+
try {
|
|
123
|
+
const config = safeParseAdapterConfig(env.adapterConfig, environmentId);
|
|
124
|
+
const powerlineToken = env.powerlineToken || "";
|
|
125
|
+
for await (const provEvent of adapter.provision(environmentId, config, powerlineToken)) {
|
|
126
|
+
logger.info({ environmentId, stage: provEvent.stage, ...logContext }, "Auto-provision progress");
|
|
127
|
+
sendWs(ws, {
|
|
128
|
+
type: "provision_progress",
|
|
129
|
+
payload: { environmentId, stage: provEvent.stage, message: provEvent.message, progress: provEvent.progress },
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
conn = await adapter.connect(environmentId, config, powerlineToken);
|
|
133
|
+
adapterManager.setConnection(environmentId, conn);
|
|
134
|
+
envRegistry.updateEnvironmentStatus(environmentId, "connected");
|
|
135
|
+
broadcastEnvironments();
|
|
136
|
+
logger.info({ environmentId, ...logContext }, "Auto-provision complete");
|
|
137
|
+
sendWs(ws, {
|
|
138
|
+
type: "provision_progress",
|
|
139
|
+
payload: { environmentId, stage: "ready", message: "Environment connected", progress: 1 },
|
|
140
|
+
});
|
|
141
|
+
return conn;
|
|
142
|
+
}
|
|
143
|
+
catch (err) {
|
|
144
|
+
logger.error({ environmentId, ...logContext, err }, "Auto-provision failed");
|
|
145
|
+
envRegistry.updateEnvironmentStatus(environmentId, "error");
|
|
146
|
+
broadcastEnvironments();
|
|
147
|
+
sendWs(ws, {
|
|
148
|
+
type: "provision_progress",
|
|
149
|
+
payload: { environmentId, stage: "error", message: `Auto-provision failed: ${err}`, progress: 0 },
|
|
150
|
+
});
|
|
151
|
+
sendWs(ws, { type: "error", payload: { message: `Failed to auto-connect environment ${environmentId}: ${err}` } });
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
async function handleMessage(ws, msg, subscriptions) {
|
|
156
|
+
switch (msg.type) {
|
|
157
|
+
case "list_environments": {
|
|
158
|
+
const rows = envRegistry.listEnvironments();
|
|
159
|
+
sendWs(ws, {
|
|
160
|
+
type: "environments",
|
|
161
|
+
payload: { environments: rows.map(envRowToWs) },
|
|
162
|
+
});
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
case "list_sessions": {
|
|
166
|
+
const environmentId = msg.payload?.environmentId || "";
|
|
167
|
+
const status = msg.payload?.status || "";
|
|
168
|
+
const rows = sessionStore.listSessions(environmentId, status);
|
|
169
|
+
sendWs(ws, {
|
|
170
|
+
type: "sessions",
|
|
171
|
+
payload: {
|
|
172
|
+
sessions: rows.map((r) => ({
|
|
173
|
+
id: r.id,
|
|
174
|
+
environmentId: r.environmentId,
|
|
175
|
+
runtime: r.runtime,
|
|
176
|
+
status: r.status,
|
|
177
|
+
prompt: r.prompt,
|
|
178
|
+
startedAt: r.startedAt,
|
|
179
|
+
})),
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
break;
|
|
183
|
+
}
|
|
184
|
+
case "get_session_events": {
|
|
185
|
+
const sessionId = msg.payload?.sessionId;
|
|
186
|
+
if (!sessionId) {
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
const session = sessionStore.getSession(sessionId);
|
|
190
|
+
if (!session || !session.logPath) {
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
const entries = logWriter.readLog(session.logPath);
|
|
194
|
+
const events = entries.map((e) => ({
|
|
195
|
+
sessionId: e.session_id,
|
|
196
|
+
eventType: e.type,
|
|
197
|
+
timestamp: e.timestamp,
|
|
198
|
+
content: e.content,
|
|
199
|
+
}));
|
|
200
|
+
sendWs(ws, { type: "session_events", payload: { sessionId, events } });
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
case "subscribe": {
|
|
204
|
+
const sessionId = msg.payload?.sessionId;
|
|
205
|
+
if (!sessionId) {
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
// Cancel any existing subscription for this session
|
|
209
|
+
const subKey = `session:${sessionId}`;
|
|
210
|
+
const existing = subscriptions.get(subKey);
|
|
211
|
+
if (existing) {
|
|
212
|
+
subscriptions.delete(subKey);
|
|
213
|
+
existing.cancel();
|
|
214
|
+
}
|
|
215
|
+
const stream = streamHub.createStream(sessionId);
|
|
216
|
+
subscriptions.set(subKey, stream);
|
|
217
|
+
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
|
218
|
+
(async () => {
|
|
219
|
+
for await (const event of stream) {
|
|
220
|
+
sendWs(ws, {
|
|
221
|
+
type: "session_event",
|
|
222
|
+
payload: {
|
|
223
|
+
sessionId: event.sessionId,
|
|
224
|
+
eventType: event.type,
|
|
225
|
+
timestamp: event.timestamp,
|
|
226
|
+
content: event.content,
|
|
227
|
+
},
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
})();
|
|
231
|
+
break;
|
|
232
|
+
}
|
|
233
|
+
case "subscribe_all": {
|
|
234
|
+
// Cancel any existing global subscription
|
|
235
|
+
const existingGlobal = subscriptions.get("global");
|
|
236
|
+
if (existingGlobal) {
|
|
237
|
+
subscriptions.delete("global");
|
|
238
|
+
existingGlobal.cancel();
|
|
239
|
+
}
|
|
240
|
+
const stream = streamHub.createGlobalStream();
|
|
241
|
+
subscriptions.set("global", stream);
|
|
242
|
+
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
|
243
|
+
(async () => {
|
|
244
|
+
for await (const event of stream) {
|
|
245
|
+
sendWs(ws, {
|
|
246
|
+
type: "session_event",
|
|
247
|
+
payload: {
|
|
248
|
+
sessionId: event.sessionId,
|
|
249
|
+
eventType: event.type,
|
|
250
|
+
timestamp: event.timestamp,
|
|
251
|
+
content: event.content,
|
|
252
|
+
},
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
})();
|
|
256
|
+
break;
|
|
257
|
+
}
|
|
258
|
+
case "spawn": {
|
|
259
|
+
const environmentId = msg.payload?.environmentId;
|
|
260
|
+
const prompt = msg.payload?.prompt;
|
|
261
|
+
const model = msg.payload?.model || "";
|
|
262
|
+
const runtime = msg.payload?.runtime || "";
|
|
263
|
+
const branch = msg.payload?.branch || "";
|
|
264
|
+
const systemContext = msg.payload?.systemContext || "";
|
|
265
|
+
if (!environmentId || !prompt) {
|
|
266
|
+
sendWs(ws, { type: "error", payload: { message: "environmentId and prompt required" } });
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
const env = envRegistry.getEnvironment(environmentId);
|
|
270
|
+
if (!env) {
|
|
271
|
+
sendWs(ws, { type: "error", payload: { message: `Environment not found: ${environmentId}` } });
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
// Auto-provision the environment if not already connected
|
|
275
|
+
const conn = await autoProvisionEnvironment(ws, environmentId, env, {});
|
|
276
|
+
if (!conn) {
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
const sessionId = uuid();
|
|
280
|
+
const sessionRuntime = runtime || env.defaultRuntime || DEFAULT_RUNTIME;
|
|
281
|
+
const sessionModel = model || process.env.GRACKLE_DEFAULT_MODEL || DEFAULT_MODEL;
|
|
282
|
+
const logPath = join(grackleHome, LOGS_DIR, sessionId);
|
|
283
|
+
sessionStore.createSession(sessionId, environmentId, sessionRuntime, prompt, sessionModel, logPath);
|
|
284
|
+
logWriter.initLog(logPath);
|
|
285
|
+
sendWs(ws, { type: "spawned", payload: { sessionId } });
|
|
286
|
+
// Fire PowerLine spawn in background
|
|
287
|
+
const powerlineReq = create(powerline.SpawnRequestSchema, {
|
|
288
|
+
sessionId,
|
|
289
|
+
runtime: sessionRuntime,
|
|
290
|
+
prompt,
|
|
291
|
+
model: sessionModel,
|
|
292
|
+
maxTurns: 0,
|
|
293
|
+
branch,
|
|
294
|
+
worktreeBasePath: branch ? "/workspace" : "",
|
|
295
|
+
systemContext,
|
|
296
|
+
});
|
|
297
|
+
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
|
298
|
+
(async () => {
|
|
299
|
+
try {
|
|
300
|
+
sessionStore.updateSession(sessionId, "running");
|
|
301
|
+
for await (const event of conn.client.spawn(powerlineReq)) {
|
|
302
|
+
const sessionEvent = create(grackle.SessionEventSchema, {
|
|
303
|
+
sessionId,
|
|
304
|
+
type: event.type,
|
|
305
|
+
timestamp: event.timestamp,
|
|
306
|
+
content: event.content,
|
|
307
|
+
raw: event.raw,
|
|
308
|
+
});
|
|
309
|
+
logWriter.writeEvent(logPath, sessionEvent);
|
|
310
|
+
streamHub.publish(sessionEvent);
|
|
311
|
+
if (event.type === "status") {
|
|
312
|
+
if (event.content === "waiting_input") {
|
|
313
|
+
sessionStore.updateSessionStatus(sessionId, "waiting_input");
|
|
314
|
+
}
|
|
315
|
+
else if (event.content === "running") {
|
|
316
|
+
sessionStore.updateSessionStatus(sessionId, "running");
|
|
317
|
+
}
|
|
318
|
+
else if (event.content === "completed") {
|
|
319
|
+
sessionStore.updateSession(sessionId, "completed");
|
|
320
|
+
}
|
|
321
|
+
else if (event.content === "failed") {
|
|
322
|
+
sessionStore.updateSession(sessionId, "failed");
|
|
323
|
+
}
|
|
324
|
+
else if (event.content === "killed") {
|
|
325
|
+
sessionStore.updateSession(sessionId, "killed");
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
const current = sessionStore.getSession(sessionId);
|
|
330
|
+
if (current && !["completed", "failed", "killed"].includes(current.status)) {
|
|
331
|
+
sessionStore.updateSession(sessionId, "completed");
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
catch (err) {
|
|
335
|
+
sessionStore.updateSession(sessionId, "failed", undefined, String(err));
|
|
336
|
+
sendWs(ws, {
|
|
337
|
+
type: "session_event",
|
|
338
|
+
payload: {
|
|
339
|
+
sessionId,
|
|
340
|
+
eventType: "error",
|
|
341
|
+
timestamp: new Date().toISOString(),
|
|
342
|
+
content: `Spawn failed: ${err}`,
|
|
343
|
+
},
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
finally {
|
|
347
|
+
logWriter.endSession(logPath);
|
|
348
|
+
try {
|
|
349
|
+
writeTranscript(logPath);
|
|
350
|
+
}
|
|
351
|
+
catch {
|
|
352
|
+
/* non-critical */
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
})();
|
|
356
|
+
break;
|
|
357
|
+
}
|
|
358
|
+
case "send_input": {
|
|
359
|
+
const sessionId = msg.payload?.sessionId;
|
|
360
|
+
const text = msg.payload?.text;
|
|
361
|
+
if (!sessionId || !text) {
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
const session = sessionStore.getSession(sessionId);
|
|
365
|
+
if (!session) {
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
const conn = adapterManager.getConnection(session.environmentId);
|
|
369
|
+
if (!conn) {
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
await conn.client.sendInput(create(powerline.InputMessageSchema, { sessionId, text }));
|
|
373
|
+
break;
|
|
374
|
+
}
|
|
375
|
+
case "kill": {
|
|
376
|
+
const sessionId = msg.payload?.sessionId;
|
|
377
|
+
if (!sessionId) {
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
const session = sessionStore.getSession(sessionId);
|
|
381
|
+
if (!session) {
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
const conn = adapterManager.getConnection(session.environmentId);
|
|
385
|
+
if (conn) {
|
|
386
|
+
try {
|
|
387
|
+
await conn.client.kill(create(powerline.SessionIdSchema, { id: sessionId }));
|
|
388
|
+
}
|
|
389
|
+
catch (err) {
|
|
390
|
+
sendWs(ws, { type: "error", payload: { message: `Kill failed: ${err}` } });
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
sessionStore.updateSession(sessionId, "killed");
|
|
395
|
+
streamHub.publish(create(grackle.SessionEventSchema, {
|
|
396
|
+
sessionId,
|
|
397
|
+
type: "status",
|
|
398
|
+
timestamp: new Date().toISOString(),
|
|
399
|
+
content: "killed",
|
|
400
|
+
raw: "",
|
|
401
|
+
}));
|
|
402
|
+
break;
|
|
403
|
+
}
|
|
404
|
+
// ─── Projects ──────────────────────────────────────────
|
|
405
|
+
case "list_projects": {
|
|
406
|
+
const rows = projectStore.listProjects();
|
|
407
|
+
sendWs(ws, {
|
|
408
|
+
type: "projects",
|
|
409
|
+
payload: {
|
|
410
|
+
projects: rows.map((r) => ({
|
|
411
|
+
id: r.id,
|
|
412
|
+
name: r.name,
|
|
413
|
+
description: r.description,
|
|
414
|
+
repoUrl: r.repoUrl,
|
|
415
|
+
defaultEnvironmentId: r.defaultEnvironmentId,
|
|
416
|
+
status: r.status,
|
|
417
|
+
createdAt: r.createdAt,
|
|
418
|
+
})),
|
|
419
|
+
},
|
|
420
|
+
});
|
|
421
|
+
break;
|
|
422
|
+
}
|
|
423
|
+
case "create_project": {
|
|
424
|
+
const name = msg.payload?.name;
|
|
425
|
+
if (!name) {
|
|
426
|
+
sendWs(ws, { type: "error", payload: { message: "name required" } });
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
const id = slugify(name) || uuid().slice(0, 8);
|
|
430
|
+
projectStore.createProject(id, name, msg.payload?.description || "", msg.payload?.repoUrl || "", msg.payload?.defaultEnvironmentId || "");
|
|
431
|
+
const row = projectStore.getProject(id);
|
|
432
|
+
broadcast({ type: "project_created", payload: { project: row } });
|
|
433
|
+
break;
|
|
434
|
+
}
|
|
435
|
+
case "archive_project": {
|
|
436
|
+
const projectId = msg.payload?.projectId;
|
|
437
|
+
if (projectId)
|
|
438
|
+
projectStore.archiveProject(projectId);
|
|
439
|
+
broadcast({ type: "project_archived", payload: { projectId } });
|
|
440
|
+
break;
|
|
441
|
+
}
|
|
442
|
+
// ─── Tasks ─────────────────────────────────────────────
|
|
443
|
+
case "list_tasks": {
|
|
444
|
+
const projectId = msg.payload?.projectId;
|
|
445
|
+
if (!projectId)
|
|
446
|
+
return;
|
|
447
|
+
const rows = taskStore.listTasks(projectId);
|
|
448
|
+
sendWs(ws, {
|
|
449
|
+
type: "tasks",
|
|
450
|
+
payload: {
|
|
451
|
+
projectId,
|
|
452
|
+
tasks: rows.map((r) => ({
|
|
453
|
+
id: r.id,
|
|
454
|
+
projectId: r.projectId,
|
|
455
|
+
title: r.title,
|
|
456
|
+
description: r.description,
|
|
457
|
+
status: r.status,
|
|
458
|
+
branch: r.branch,
|
|
459
|
+
environmentId: r.environmentId,
|
|
460
|
+
sessionId: r.sessionId,
|
|
461
|
+
dependsOn: safeParseJsonArray(r.dependsOn),
|
|
462
|
+
reviewNotes: r.reviewNotes,
|
|
463
|
+
sortOrder: r.sortOrder,
|
|
464
|
+
createdAt: r.createdAt,
|
|
465
|
+
})),
|
|
466
|
+
},
|
|
467
|
+
});
|
|
468
|
+
break;
|
|
469
|
+
}
|
|
470
|
+
case "create_task": {
|
|
471
|
+
const projectId = msg.payload?.projectId;
|
|
472
|
+
const title = msg.payload?.title;
|
|
473
|
+
if (!projectId || !title) {
|
|
474
|
+
sendWs(ws, { type: "error", payload: { message: "projectId and title required" } });
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
const project = projectStore.getProject(projectId);
|
|
478
|
+
if (!project) {
|
|
479
|
+
sendWs(ws, { type: "error", payload: { message: `Project not found: ${projectId}` } });
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
const id = uuid().slice(0, 8);
|
|
483
|
+
taskStore.createTask(id, projectId, title, msg.payload?.description || "", msg.payload?.environmentId || project.defaultEnvironmentId, msg.payload?.dependsOn || [], slugify(project.name));
|
|
484
|
+
const row = taskStore.getTask(id);
|
|
485
|
+
broadcast({ type: "task_created", payload: { task: row ? { ...row, dependsOn: safeParseJsonArray(row.dependsOn) } : null } });
|
|
486
|
+
break;
|
|
487
|
+
}
|
|
488
|
+
case "start_task": {
|
|
489
|
+
const taskId = msg.payload?.taskId;
|
|
490
|
+
if (!taskId)
|
|
491
|
+
return;
|
|
492
|
+
const task = taskStore.getTask(taskId);
|
|
493
|
+
if (!task) {
|
|
494
|
+
sendWs(ws, { type: "error", payload: { message: `Task not found: ${taskId}` } });
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
if (!["pending", "assigned"].includes(task.status)) {
|
|
498
|
+
sendWs(ws, { type: "error", payload: { message: `Task cannot be started (status: ${task.status})` } });
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
if (!taskStore.areDependenciesMet(taskId)) {
|
|
502
|
+
sendWs(ws, { type: "error", payload: { message: "Task has unmet dependencies" } });
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
const project = projectStore.getProject(task.projectId);
|
|
506
|
+
if (!project) {
|
|
507
|
+
sendWs(ws, { type: "error", payload: { message: `Project not found: ${task.projectId}` } });
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
const environmentId = task.environmentId || project.defaultEnvironmentId;
|
|
511
|
+
const env = envRegistry.getEnvironment(environmentId);
|
|
512
|
+
if (!env) {
|
|
513
|
+
sendWs(ws, { type: "error", payload: { message: `Environment not found: ${environmentId}` } });
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
// Auto-provision the environment if not already connected
|
|
517
|
+
const conn = await autoProvisionEnvironment(ws, environmentId, env, { taskId });
|
|
518
|
+
if (!conn) {
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
const sessionId = uuid();
|
|
522
|
+
const runtime = msg.payload?.runtime || env.defaultRuntime || DEFAULT_RUNTIME;
|
|
523
|
+
const model = msg.payload?.model || process.env.GRACKLE_DEFAULT_MODEL || DEFAULT_MODEL;
|
|
524
|
+
const logPath = join(grackleHome, LOGS_DIR, sessionId);
|
|
525
|
+
const systemContext = [
|
|
526
|
+
`## Task: ${task.title}`,
|
|
527
|
+
task.description,
|
|
528
|
+
task.reviewNotes ? `## Review Feedback (from previous attempt)\n${task.reviewNotes}` : "",
|
|
529
|
+
`## Grackle Tools (MCP)`,
|
|
530
|
+
`You have a "grackle" MCP server with tools for coordinating with other agents:`,
|
|
531
|
+
`- **mcp__grackle__post_finding**: Share discoveries (architecture decisions, bugs, patterns) with other agents working on this project. Parameters: title (string), content (string), category (optional: architecture|api|bug|decision|dependency|pattern|general), tags (optional: string[]).`,
|
|
532
|
+
`- **mcp__grackle__query_findings**: Query findings posted by other agents. Findings from previous tasks are also in your system context above.`,
|
|
533
|
+
`IMPORTANT: When you complete your task, post at least one finding summarizing what you did and any key decisions made.`,
|
|
534
|
+
].filter(Boolean).join("\n\n");
|
|
535
|
+
sessionStore.createSession(sessionId, environmentId, runtime, task.title, model, logPath);
|
|
536
|
+
taskStore.setTaskSession(task.id, sessionId);
|
|
537
|
+
taskStore.markTaskStarted(task.id);
|
|
538
|
+
logWriter.initLog(logPath);
|
|
539
|
+
broadcast({ type: "task_started", payload: { taskId: task.id, sessionId, projectId: task.projectId } });
|
|
540
|
+
const powerlineReq = create(powerline.SpawnRequestSchema, {
|
|
541
|
+
sessionId,
|
|
542
|
+
runtime,
|
|
543
|
+
prompt: task.title,
|
|
544
|
+
model,
|
|
545
|
+
maxTurns: 0,
|
|
546
|
+
branch: task.branch,
|
|
547
|
+
worktreeBasePath: task.branch ? (process.env.GRACKLE_WORKTREE_BASE || "/workspace") : "",
|
|
548
|
+
systemContext,
|
|
549
|
+
projectId: task.projectId,
|
|
550
|
+
taskId: task.id,
|
|
551
|
+
});
|
|
552
|
+
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
|
553
|
+
(async () => {
|
|
554
|
+
try {
|
|
555
|
+
sessionStore.updateSession(sessionId, "running");
|
|
556
|
+
for await (const event of conn.client.spawn(powerlineReq)) {
|
|
557
|
+
const sessionEvent = create(grackle.SessionEventSchema, {
|
|
558
|
+
sessionId,
|
|
559
|
+
type: event.type,
|
|
560
|
+
timestamp: event.timestamp,
|
|
561
|
+
content: event.content,
|
|
562
|
+
raw: event.raw,
|
|
563
|
+
});
|
|
564
|
+
logWriter.writeEvent(logPath, sessionEvent);
|
|
565
|
+
streamHub.publish(sessionEvent);
|
|
566
|
+
// Intercept finding events and store + broadcast them
|
|
567
|
+
if (event.type === "finding" && task.projectId) {
|
|
568
|
+
try {
|
|
569
|
+
const data = JSON.parse(event.content);
|
|
570
|
+
const findingId = uuid();
|
|
571
|
+
findingStore.postFinding(findingId, task.projectId, task.id, sessionId, data.category || "general", data.title || "Untitled", data.content || "", data.tags || []);
|
|
572
|
+
broadcast({ type: "finding_posted", payload: { projectId: task.projectId, findingId } });
|
|
573
|
+
process.stderr.write(`[finding] Stored: ${findingId} "${data.title}" in ${task.projectId}\n`);
|
|
574
|
+
}
|
|
575
|
+
catch (err) {
|
|
576
|
+
process.stderr.write(`[finding] ERROR: ${err} (project=${task.projectId} task=${task.id})\n`);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
if (event.type === "status") {
|
|
580
|
+
if (event.content === "waiting_input")
|
|
581
|
+
sessionStore.updateSessionStatus(sessionId, "waiting_input");
|
|
582
|
+
else if (event.content === "running")
|
|
583
|
+
sessionStore.updateSessionStatus(sessionId, "running");
|
|
584
|
+
else if (event.content === "completed")
|
|
585
|
+
sessionStore.updateSession(sessionId, "completed");
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
const current = sessionStore.getSession(sessionId);
|
|
589
|
+
if (current && !["completed", "failed", "killed"].includes(current.status)) {
|
|
590
|
+
sessionStore.updateSession(sessionId, "completed");
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
catch (err) {
|
|
594
|
+
sessionStore.updateSession(sessionId, "failed", undefined, String(err));
|
|
595
|
+
}
|
|
596
|
+
finally {
|
|
597
|
+
logWriter.endSession(logPath);
|
|
598
|
+
try {
|
|
599
|
+
writeTranscript(logPath);
|
|
600
|
+
}
|
|
601
|
+
catch { /* non-critical */ }
|
|
602
|
+
// Auto-move task to review on completion
|
|
603
|
+
const t = taskStore.getTask(task.id);
|
|
604
|
+
if (t && t.status === "in_progress") {
|
|
605
|
+
const sess = sessionStore.getSession(sessionId);
|
|
606
|
+
if (sess?.status === "completed") {
|
|
607
|
+
taskStore.markTaskCompleted(task.id, "review");
|
|
608
|
+
}
|
|
609
|
+
else if (sess?.status === "failed") {
|
|
610
|
+
taskStore.markTaskCompleted(task.id, "failed");
|
|
611
|
+
}
|
|
612
|
+
broadcast({ type: "task_updated", payload: { taskId: task.id, projectId: task.projectId } });
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
})();
|
|
616
|
+
break;
|
|
617
|
+
}
|
|
618
|
+
case "approve_task": {
|
|
619
|
+
const taskId = msg.payload?.taskId;
|
|
620
|
+
if (!taskId)
|
|
621
|
+
return;
|
|
622
|
+
taskStore.markTaskCompleted(taskId, "done");
|
|
623
|
+
const task = taskStore.getTask(taskId);
|
|
624
|
+
const unblocked = task ? taskStore.checkAndUnblock(task.projectId) : [];
|
|
625
|
+
sendWs(ws, {
|
|
626
|
+
type: "task_approved",
|
|
627
|
+
payload: {
|
|
628
|
+
taskId,
|
|
629
|
+
unblockedTaskIds: unblocked.map((t) => t.id),
|
|
630
|
+
},
|
|
631
|
+
});
|
|
632
|
+
break;
|
|
633
|
+
}
|
|
634
|
+
case "reject_task": {
|
|
635
|
+
const taskId = msg.payload?.taskId;
|
|
636
|
+
const reviewNotes = msg.payload?.reviewNotes || "";
|
|
637
|
+
if (!taskId)
|
|
638
|
+
return;
|
|
639
|
+
const task = taskStore.getTask(taskId);
|
|
640
|
+
if (task) {
|
|
641
|
+
taskStore.updateTask(task.id, task.title, task.description, "assigned", task.environmentId, safeParseJsonArray(task.dependsOn), reviewNotes);
|
|
642
|
+
}
|
|
643
|
+
broadcast({ type: "task_rejected", payload: { taskId } });
|
|
644
|
+
break;
|
|
645
|
+
}
|
|
646
|
+
case "delete_task": {
|
|
647
|
+
const taskId = msg.payload?.taskId;
|
|
648
|
+
if (taskId)
|
|
649
|
+
taskStore.deleteTask(taskId);
|
|
650
|
+
broadcast({ type: "task_deleted", payload: { taskId } });
|
|
651
|
+
break;
|
|
652
|
+
}
|
|
653
|
+
// ─── Findings ──────────────────────────────────────────
|
|
654
|
+
case "list_findings": {
|
|
655
|
+
const projectId = msg.payload?.projectId;
|
|
656
|
+
if (!projectId)
|
|
657
|
+
return;
|
|
658
|
+
const rows = findingStore.queryFindings(projectId, msg.payload?.categories || undefined, msg.payload?.tags || undefined, msg.payload?.limit || undefined);
|
|
659
|
+
sendWs(ws, {
|
|
660
|
+
type: "findings",
|
|
661
|
+
payload: {
|
|
662
|
+
projectId,
|
|
663
|
+
findings: rows.map((r) => ({
|
|
664
|
+
id: r.id,
|
|
665
|
+
projectId: r.projectId,
|
|
666
|
+
taskId: r.taskId,
|
|
667
|
+
sessionId: r.sessionId,
|
|
668
|
+
category: r.category,
|
|
669
|
+
title: r.title,
|
|
670
|
+
content: r.content,
|
|
671
|
+
tags: safeParseJsonArray(r.tags),
|
|
672
|
+
createdAt: r.createdAt,
|
|
673
|
+
})),
|
|
674
|
+
},
|
|
675
|
+
});
|
|
676
|
+
break;
|
|
677
|
+
}
|
|
678
|
+
case "post_finding": {
|
|
679
|
+
const projectId = msg.payload?.projectId;
|
|
680
|
+
const title = msg.payload?.title;
|
|
681
|
+
if (!projectId || !title) {
|
|
682
|
+
sendWs(ws, { type: "error", payload: { message: "projectId and title required" } });
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
const id = uuid().slice(0, 8);
|
|
686
|
+
findingStore.postFinding(id, projectId, msg.payload?.taskId || "", msg.payload?.sessionId || "", msg.payload?.category || "general", title, msg.payload?.content || "", msg.payload?.tags || []);
|
|
687
|
+
sendWs(ws, { type: "finding_posted", payload: { id, projectId } });
|
|
688
|
+
break;
|
|
689
|
+
}
|
|
690
|
+
// ─── Diff ──────────────────────────────────────────────
|
|
691
|
+
case "get_task_diff": {
|
|
692
|
+
const taskId = msg.payload?.taskId;
|
|
693
|
+
if (!taskId)
|
|
694
|
+
return;
|
|
695
|
+
const task = taskStore.getTask(taskId);
|
|
696
|
+
if (!task || !task.branch) {
|
|
697
|
+
sendWs(ws, { type: "task_diff", payload: { taskId, error: "No branch" } });
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
const environmentId = task.environmentId || projectStore.getProject(task.projectId)?.defaultEnvironmentId;
|
|
701
|
+
if (!environmentId) {
|
|
702
|
+
sendWs(ws, { type: "task_diff", payload: { taskId, error: "No environment" } });
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
const conn = adapterManager.getConnection(environmentId);
|
|
706
|
+
if (!conn) {
|
|
707
|
+
sendWs(ws, { type: "task_diff", payload: { taskId, error: "Environment not connected" } });
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
try {
|
|
711
|
+
const diffResp = await conn.client.getDiff(create(powerline.DiffRequestSchema, {
|
|
712
|
+
branch: task.branch,
|
|
713
|
+
baseBranch: "main",
|
|
714
|
+
worktreeBasePath: "/workspace",
|
|
715
|
+
}));
|
|
716
|
+
sendWs(ws, {
|
|
717
|
+
type: "task_diff",
|
|
718
|
+
payload: {
|
|
719
|
+
taskId,
|
|
720
|
+
branch: task.branch,
|
|
721
|
+
diff: diffResp.diff,
|
|
722
|
+
changedFiles: [...diffResp.changedFiles],
|
|
723
|
+
additions: diffResp.additions,
|
|
724
|
+
deletions: diffResp.deletions,
|
|
725
|
+
},
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
catch (err) {
|
|
729
|
+
sendWs(ws, { type: "task_diff", payload: { taskId, error: String(err) } });
|
|
730
|
+
}
|
|
731
|
+
break;
|
|
732
|
+
}
|
|
733
|
+
case "provision_environment": {
|
|
734
|
+
const environmentId = msg.payload?.environmentId;
|
|
735
|
+
if (!environmentId) {
|
|
736
|
+
sendWs(ws, { type: "error", payload: { message: "environmentId required" } });
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
const env = envRegistry.getEnvironment(environmentId);
|
|
740
|
+
if (!env) {
|
|
741
|
+
sendWs(ws, { type: "error", payload: { message: `Environment not found: ${environmentId}` } });
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
const adapter = adapterManager.getAdapter(env.adapterType);
|
|
745
|
+
if (!adapter) {
|
|
746
|
+
sendWs(ws, { type: "error", payload: { message: `No adapter for type: ${env.adapterType}` } });
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
logger.info({ environmentId, adapterType: env.adapterType }, "Provisioning environment");
|
|
750
|
+
envRegistry.updateEnvironmentStatus(environmentId, "connecting");
|
|
751
|
+
broadcastEnvironments();
|
|
752
|
+
// Run provision in background, streaming progress to the requesting client
|
|
753
|
+
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
|
754
|
+
(async () => {
|
|
755
|
+
try {
|
|
756
|
+
const config = safeParseAdapterConfig(env.adapterConfig, environmentId);
|
|
757
|
+
const powerlineToken = env.powerlineToken || "";
|
|
758
|
+
logger.info({ environmentId, config }, "Starting adapter.provision");
|
|
759
|
+
for await (const event of adapter.provision(environmentId, config, powerlineToken)) {
|
|
760
|
+
logger.info({ environmentId, stage: event.stage, message: event.message }, "Provision progress");
|
|
761
|
+
sendWs(ws, {
|
|
762
|
+
type: "provision_progress",
|
|
763
|
+
payload: { environmentId, stage: event.stage, message: event.message, progress: event.progress },
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
logger.info({ environmentId }, "Provision complete, calling adapter.connect");
|
|
767
|
+
const conn = await adapter.connect(environmentId, config, powerlineToken);
|
|
768
|
+
adapterManager.setConnection(environmentId, conn);
|
|
769
|
+
envRegistry.updateEnvironmentStatus(environmentId, "connected");
|
|
770
|
+
logger.info({ environmentId }, "Environment connected");
|
|
771
|
+
sendWs(ws, {
|
|
772
|
+
type: "provision_progress",
|
|
773
|
+
payload: { environmentId, stage: "ready", message: "Environment connected", progress: 1 },
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
catch (err) {
|
|
777
|
+
logger.error({ environmentId, err }, "Provision failed");
|
|
778
|
+
envRegistry.updateEnvironmentStatus(environmentId, "error");
|
|
779
|
+
sendWs(ws, {
|
|
780
|
+
type: "provision_progress",
|
|
781
|
+
payload: { environmentId, stage: "error", message: `Connection failed: ${err}`, progress: 0 },
|
|
782
|
+
});
|
|
783
|
+
}
|
|
784
|
+
broadcastEnvironments();
|
|
785
|
+
})();
|
|
786
|
+
break;
|
|
787
|
+
}
|
|
788
|
+
case "stop_environment": {
|
|
789
|
+
const environmentId = msg.payload?.environmentId;
|
|
790
|
+
if (!environmentId) {
|
|
791
|
+
sendWs(ws, { type: "error", payload: { message: "environmentId required" } });
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
const env = envRegistry.getEnvironment(environmentId);
|
|
795
|
+
if (!env) {
|
|
796
|
+
sendWs(ws, { type: "error", payload: { message: `Environment not found: ${environmentId}` } });
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
const adapter = adapterManager.getAdapter(env.adapterType);
|
|
800
|
+
if (adapter) {
|
|
801
|
+
const config = safeParseAdapterConfig(env.adapterConfig, environmentId);
|
|
802
|
+
await adapter.stop(environmentId, config);
|
|
803
|
+
}
|
|
804
|
+
adapterManager.removeConnection(environmentId);
|
|
805
|
+
envRegistry.updateEnvironmentStatus(environmentId, "disconnected");
|
|
806
|
+
logger.info({ environmentId }, "Environment stopped");
|
|
807
|
+
broadcastEnvironments();
|
|
808
|
+
break;
|
|
809
|
+
}
|
|
810
|
+
case "remove_environment": {
|
|
811
|
+
const environmentId = msg.payload?.environmentId;
|
|
812
|
+
if (!environmentId) {
|
|
813
|
+
sendWs(ws, { type: "error", payload: { message: "environmentId required" } });
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
const env = envRegistry.getEnvironment(environmentId);
|
|
817
|
+
if (env) {
|
|
818
|
+
const adapter = adapterManager.getAdapter(env.adapterType);
|
|
819
|
+
if (adapter) {
|
|
820
|
+
const config = safeParseAdapterConfig(env.adapterConfig, environmentId);
|
|
821
|
+
try {
|
|
822
|
+
await adapter.destroy(environmentId, config);
|
|
823
|
+
}
|
|
824
|
+
catch { /* best-effort */ }
|
|
825
|
+
try {
|
|
826
|
+
await adapter.disconnect(environmentId);
|
|
827
|
+
}
|
|
828
|
+
catch { /* best-effort */ }
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
adapterManager.removeConnection(environmentId);
|
|
832
|
+
sessionStore.deleteByEnvironment(environmentId);
|
|
833
|
+
envRegistry.removeEnvironment(environmentId);
|
|
834
|
+
logger.info({ environmentId }, "Environment removed");
|
|
835
|
+
broadcast({ type: "environment_removed", payload: { environmentId } });
|
|
836
|
+
broadcastEnvironments();
|
|
837
|
+
break;
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
function sendWs(ws, msg) {
|
|
842
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
843
|
+
ws.send(JSON.stringify(msg));
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
//# sourceMappingURL=ws-bridge.js.map
|