@neuralnomads/codenomad 0.4.0 → 0.5.1
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/background-processes/manager.js +364 -0
- package/dist/index.js +11 -10
- package/dist/opencode-config/README.md +32 -0
- package/dist/opencode-config/opencode.jsonc +3 -0
- package/dist/opencode-config/package.json +8 -0
- package/dist/opencode-config/plugin/codenomad.ts +32 -0
- package/dist/opencode-config/plugin/lib/background-process.ts +309 -0
- package/dist/opencode-config/plugin/lib/client.ts +165 -0
- package/dist/opencode-config.js +26 -0
- package/dist/plugins/channel.js +40 -0
- package/dist/plugins/handlers.js +17 -0
- package/dist/server/http-server.js +10 -0
- package/dist/server/routes/background-processes.js +60 -0
- package/dist/server/routes/plugin.js +52 -0
- package/dist/server/routes/workspaces.js +11 -4
- package/dist/workspaces/manager.js +125 -2
- package/dist/workspaces/runtime.js +60 -7
- package/package.json +4 -3
- package/public/assets/index-DByfuA7Q.css +1 -0
- package/public/assets/{loading-COcLzczZ.js → loading-DDkv7He2.js} +1 -1
- package/public/assets/main-lEyCX2HE.js +184 -0
- package/public/index.html +3 -3
- package/public/loading.html +3 -3
- package/public/assets/index-CkN21HIV.css +0 -1
- package/public/assets/main-CXiy0_wG.js +0 -184
- /package/public/assets/{index-Ct9Ys2qZ.js → index-SWVRNzDr.js} +0 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { PluginChannelManager } from "../../plugins/channel";
|
|
3
|
+
import { buildPingEvent, handlePluginEvent } from "../../plugins/handlers";
|
|
4
|
+
const PluginEventSchema = z.object({
|
|
5
|
+
type: z.string().min(1),
|
|
6
|
+
properties: z.record(z.unknown()).optional(),
|
|
7
|
+
});
|
|
8
|
+
export function registerPluginRoutes(app, deps) {
|
|
9
|
+
const channel = new PluginChannelManager(deps.logger.child({ component: "plugin-channel" }));
|
|
10
|
+
app.get("/workspaces/:id/plugin/events", (request, reply) => {
|
|
11
|
+
const workspace = deps.workspaceManager.get(request.params.id);
|
|
12
|
+
if (!workspace) {
|
|
13
|
+
reply.code(404).send({ error: "Workspace not found" });
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
reply.raw.setHeader("Content-Type", "text/event-stream");
|
|
17
|
+
reply.raw.setHeader("Cache-Control", "no-cache");
|
|
18
|
+
reply.raw.setHeader("Connection", "keep-alive");
|
|
19
|
+
reply.raw.flushHeaders?.();
|
|
20
|
+
reply.hijack();
|
|
21
|
+
const registration = channel.register(request.params.id, reply);
|
|
22
|
+
const heartbeat = setInterval(() => {
|
|
23
|
+
channel.send(request.params.id, buildPingEvent());
|
|
24
|
+
}, 15000);
|
|
25
|
+
const close = () => {
|
|
26
|
+
clearInterval(heartbeat);
|
|
27
|
+
registration.close();
|
|
28
|
+
reply.raw.end?.();
|
|
29
|
+
};
|
|
30
|
+
request.raw.on("close", close);
|
|
31
|
+
request.raw.on("error", close);
|
|
32
|
+
});
|
|
33
|
+
const handleWildcard = async (request, reply) => {
|
|
34
|
+
const workspaceId = request.params.id;
|
|
35
|
+
const workspace = deps.workspaceManager.get(workspaceId);
|
|
36
|
+
if (!workspace) {
|
|
37
|
+
reply.code(404).send({ error: "Workspace not found" });
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const suffix = request.params["*"] ?? "";
|
|
41
|
+
const normalized = suffix.replace(/^\/+/, "");
|
|
42
|
+
if (normalized === "event" && request.method === "POST") {
|
|
43
|
+
const parsed = PluginEventSchema.parse(request.body ?? {});
|
|
44
|
+
handlePluginEvent(workspaceId, parsed, { workspaceManager: deps.workspaceManager, eventBus: deps.eventBus, logger: deps.logger });
|
|
45
|
+
reply.code(204).send();
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
reply.code(404).send({ error: "Unknown plugin endpoint" });
|
|
49
|
+
};
|
|
50
|
+
app.all("/workspaces/:id/plugin/*", handleWildcard);
|
|
51
|
+
app.all("/workspaces/:id/plugin", handleWildcard);
|
|
52
|
+
}
|
|
@@ -23,10 +23,17 @@ export function registerWorkspaceRoutes(app, deps) {
|
|
|
23
23
|
return deps.workspaceManager.list();
|
|
24
24
|
});
|
|
25
25
|
app.post("/api/workspaces", async (request, reply) => {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
26
|
+
try {
|
|
27
|
+
const body = WorkspaceCreateSchema.parse(request.body ?? {});
|
|
28
|
+
const workspace = await deps.workspaceManager.create(body.path, body.name);
|
|
29
|
+
reply.code(201);
|
|
30
|
+
return workspace;
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
request.log.error({ err: error }, "Failed to create workspace");
|
|
34
|
+
const message = error instanceof Error ? error.message : "Failed to create workspace";
|
|
35
|
+
reply.code(400).type("text/plain").send(message);
|
|
36
|
+
}
|
|
30
37
|
});
|
|
31
38
|
app.get("/api/workspaces/:id", async (request, reply) => {
|
|
32
39
|
const workspace = deps.workspaceManager.get(request.params.id);
|
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
import path from "path";
|
|
2
2
|
import { spawnSync } from "child_process";
|
|
3
|
+
import { connect } from "net";
|
|
3
4
|
import { FileSystemBrowser } from "../filesystem/browser";
|
|
4
5
|
import { searchWorkspaceFiles } from "../filesystem/search";
|
|
5
6
|
import { clearWorkspaceSearchCache } from "../filesystem/search-cache";
|
|
6
7
|
import { WorkspaceRuntime } from "./runtime";
|
|
8
|
+
import { getOpencodeConfigDir } from "../opencode-config.js";
|
|
9
|
+
const STARTUP_STABILITY_DELAY_MS = 1500;
|
|
7
10
|
export class WorkspaceManager {
|
|
8
11
|
constructor(options) {
|
|
9
12
|
this.options = options;
|
|
10
13
|
this.workspaces = new Map();
|
|
11
14
|
this.runtime = new WorkspaceRuntime(this.options.eventBus, this.options.logger);
|
|
15
|
+
this.opencodeConfigDir = getOpencodeConfigDir();
|
|
12
16
|
}
|
|
13
17
|
list() {
|
|
14
18
|
return Array.from(this.workspaces.values());
|
|
@@ -63,15 +67,23 @@ export class WorkspaceManager {
|
|
|
63
67
|
}
|
|
64
68
|
this.workspaces.set(id, descriptor);
|
|
65
69
|
this.options.eventBus.publish({ type: "workspace.created", workspace: descriptor });
|
|
66
|
-
const
|
|
70
|
+
const preferences = this.options.configStore.get().preferences ?? {};
|
|
71
|
+
const userEnvironment = preferences.environmentVariables ?? {};
|
|
72
|
+
const environment = {
|
|
73
|
+
...userEnvironment,
|
|
74
|
+
OPENCODE_CONFIG_DIR: this.opencodeConfigDir,
|
|
75
|
+
CODENOMAD_INSTANCE_ID: id,
|
|
76
|
+
CODENOMAD_BASE_URL: this.options.getServerBaseUrl(),
|
|
77
|
+
};
|
|
67
78
|
try {
|
|
68
|
-
const { pid, port } = await this.runtime.launch({
|
|
79
|
+
const { pid, port, exitPromise, getLastOutput } = await this.runtime.launch({
|
|
69
80
|
workspaceId: id,
|
|
70
81
|
folder: workspacePath,
|
|
71
82
|
binaryPath: resolvedBinaryPath,
|
|
72
83
|
environment,
|
|
73
84
|
onExit: (info) => this.handleProcessExit(info.workspaceId, info),
|
|
74
85
|
});
|
|
86
|
+
await this.waitForWorkspaceReadiness({ workspaceId: id, port, exitPromise, getLastOutput });
|
|
75
87
|
descriptor.pid = pid;
|
|
76
88
|
descriptor.port = port;
|
|
77
89
|
descriptor.status = "ready";
|
|
@@ -189,6 +201,117 @@ export class WorkspaceManager {
|
|
|
189
201
|
}
|
|
190
202
|
return undefined;
|
|
191
203
|
}
|
|
204
|
+
async waitForWorkspaceReadiness(params) {
|
|
205
|
+
await Promise.race([
|
|
206
|
+
this.waitForPortAvailability(params.port),
|
|
207
|
+
params.exitPromise.then((info) => {
|
|
208
|
+
throw this.buildStartupError(params.workspaceId, "exited before becoming ready", info, params.getLastOutput());
|
|
209
|
+
}),
|
|
210
|
+
]);
|
|
211
|
+
await this.waitForInstanceHealth(params);
|
|
212
|
+
await Promise.race([
|
|
213
|
+
this.delay(STARTUP_STABILITY_DELAY_MS),
|
|
214
|
+
params.exitPromise.then((info) => {
|
|
215
|
+
throw this.buildStartupError(params.workspaceId, "exited shortly after start", info, params.getLastOutput());
|
|
216
|
+
}),
|
|
217
|
+
]);
|
|
218
|
+
}
|
|
219
|
+
async waitForInstanceHealth(params) {
|
|
220
|
+
const probeResult = await Promise.race([
|
|
221
|
+
this.probeInstance(params.workspaceId, params.port),
|
|
222
|
+
params.exitPromise.then((info) => {
|
|
223
|
+
throw this.buildStartupError(params.workspaceId, "exited during health checks", info, params.getLastOutput());
|
|
224
|
+
}),
|
|
225
|
+
]);
|
|
226
|
+
if (probeResult.ok) {
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
const latestOutput = params.getLastOutput().trim();
|
|
230
|
+
if (latestOutput) {
|
|
231
|
+
throw new Error(latestOutput);
|
|
232
|
+
}
|
|
233
|
+
const reason = probeResult.reason ?? "Health check failed";
|
|
234
|
+
throw new Error(`Workspace ${params.workspaceId} failed health check: ${reason}.`);
|
|
235
|
+
}
|
|
236
|
+
async probeInstance(workspaceId, port) {
|
|
237
|
+
const url = `http://127.0.0.1:${port}/project/current`;
|
|
238
|
+
try {
|
|
239
|
+
const response = await fetch(url);
|
|
240
|
+
if (!response.ok) {
|
|
241
|
+
const reason = `health probe returned HTTP ${response.status}`;
|
|
242
|
+
this.options.logger.debug({ workspaceId, status: response.status }, "Health probe returned server error");
|
|
243
|
+
return { ok: false, reason };
|
|
244
|
+
}
|
|
245
|
+
return { ok: true };
|
|
246
|
+
}
|
|
247
|
+
catch (error) {
|
|
248
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
249
|
+
this.options.logger.debug({ workspaceId, err: error }, "Health probe failed");
|
|
250
|
+
return { ok: false, reason };
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
buildStartupError(workspaceId, phase, exitInfo, lastOutput) {
|
|
254
|
+
const exitDetails = this.describeExit(exitInfo);
|
|
255
|
+
const trimmedOutput = lastOutput.trim();
|
|
256
|
+
const outputDetails = trimmedOutput ? ` Last output: ${trimmedOutput}` : "";
|
|
257
|
+
return new Error(`Workspace ${workspaceId} ${phase} (${exitDetails}).${outputDetails}`);
|
|
258
|
+
}
|
|
259
|
+
waitForPortAvailability(port, timeoutMs = 5000) {
|
|
260
|
+
return new Promise((resolve, reject) => {
|
|
261
|
+
const deadline = Date.now() + timeoutMs;
|
|
262
|
+
let settled = false;
|
|
263
|
+
let retryTimer = null;
|
|
264
|
+
const cleanup = () => {
|
|
265
|
+
settled = true;
|
|
266
|
+
if (retryTimer) {
|
|
267
|
+
clearTimeout(retryTimer);
|
|
268
|
+
retryTimer = null;
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
const tryConnect = () => {
|
|
272
|
+
if (settled) {
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
const socket = connect({ port, host: "127.0.0.1" }, () => {
|
|
276
|
+
cleanup();
|
|
277
|
+
socket.end();
|
|
278
|
+
resolve();
|
|
279
|
+
});
|
|
280
|
+
socket.once("error", () => {
|
|
281
|
+
socket.destroy();
|
|
282
|
+
if (settled) {
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
if (Date.now() >= deadline) {
|
|
286
|
+
cleanup();
|
|
287
|
+
reject(new Error(`Workspace port ${port} did not become ready within ${timeoutMs}ms`));
|
|
288
|
+
}
|
|
289
|
+
else {
|
|
290
|
+
retryTimer = setTimeout(() => {
|
|
291
|
+
retryTimer = null;
|
|
292
|
+
tryConnect();
|
|
293
|
+
}, 100);
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
};
|
|
297
|
+
tryConnect();
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
delay(durationMs) {
|
|
301
|
+
if (durationMs <= 0) {
|
|
302
|
+
return Promise.resolve();
|
|
303
|
+
}
|
|
304
|
+
return new Promise((resolve) => setTimeout(resolve, durationMs));
|
|
305
|
+
}
|
|
306
|
+
describeExit(info) {
|
|
307
|
+
if (info.signal) {
|
|
308
|
+
return `signal ${info.signal}`;
|
|
309
|
+
}
|
|
310
|
+
if (info.code !== null) {
|
|
311
|
+
return `code ${info.code}`;
|
|
312
|
+
}
|
|
313
|
+
return "unknown reason";
|
|
314
|
+
}
|
|
192
315
|
handleProcessExit(workspaceId, info) {
|
|
193
316
|
const workspace = this.workspaces.get(workspaceId);
|
|
194
317
|
if (!workspace)
|
|
@@ -11,8 +11,36 @@ export class WorkspaceRuntime {
|
|
|
11
11
|
this.validateFolder(options.folder);
|
|
12
12
|
const args = ["serve", "--port", "0", "--print-logs", "--log-level", "DEBUG"];
|
|
13
13
|
const env = { ...process.env, ...(options.environment ?? {}) };
|
|
14
|
+
let exitResolve = null;
|
|
15
|
+
const exitPromise = new Promise((resolveExit) => {
|
|
16
|
+
exitResolve = resolveExit;
|
|
17
|
+
});
|
|
18
|
+
// Store recent output for debugging - keep last 50 lines from each stream
|
|
19
|
+
const MAX_OUTPUT_LINES = 50;
|
|
20
|
+
const recentStdout = [];
|
|
21
|
+
const recentStderr = [];
|
|
22
|
+
const getLastOutput = () => {
|
|
23
|
+
const combined = [];
|
|
24
|
+
if (recentStderr.length > 0) {
|
|
25
|
+
combined.push("Error Stream");
|
|
26
|
+
combined.push(...recentStderr.slice(-10));
|
|
27
|
+
}
|
|
28
|
+
if (recentStdout.length > 0) {
|
|
29
|
+
combined.push("Output Stream");
|
|
30
|
+
combined.push(...recentStdout.slice(-10));
|
|
31
|
+
}
|
|
32
|
+
return combined.join("\n");
|
|
33
|
+
};
|
|
14
34
|
return new Promise((resolve, reject) => {
|
|
15
|
-
|
|
35
|
+
const commandLine = [options.binaryPath, ...args].join(" ");
|
|
36
|
+
this.logger.info({
|
|
37
|
+
workspaceId: options.workspaceId,
|
|
38
|
+
folder: options.folder,
|
|
39
|
+
binary: options.binaryPath,
|
|
40
|
+
args,
|
|
41
|
+
commandLine,
|
|
42
|
+
env,
|
|
43
|
+
}, "Launching OpenCode process");
|
|
16
44
|
const child = spawn(options.binaryPath, args, {
|
|
17
45
|
cwd: options.folder,
|
|
18
46
|
env,
|
|
@@ -47,12 +75,23 @@ export class WorkspaceRuntime {
|
|
|
47
75
|
cleanupStreams();
|
|
48
76
|
child.removeListener("error", handleError);
|
|
49
77
|
child.removeListener("exit", handleExit);
|
|
78
|
+
const exitInfo = {
|
|
79
|
+
workspaceId: options.workspaceId,
|
|
80
|
+
code,
|
|
81
|
+
signal,
|
|
82
|
+
requested: managed.requestedStop,
|
|
83
|
+
};
|
|
84
|
+
if (exitResolve) {
|
|
85
|
+
exitResolve(exitInfo);
|
|
86
|
+
exitResolve = null;
|
|
87
|
+
}
|
|
50
88
|
if (!portFound) {
|
|
51
|
-
const
|
|
89
|
+
const recentOutput = getLastOutput().trim();
|
|
90
|
+
const reason = recentOutput || stderrBuffer || `Process exited with code ${code}`;
|
|
52
91
|
reject(new Error(reason));
|
|
53
92
|
}
|
|
54
93
|
else {
|
|
55
|
-
options.onExit?.(
|
|
94
|
+
options.onExit?.(exitInfo);
|
|
56
95
|
}
|
|
57
96
|
};
|
|
58
97
|
const handleError = (error) => {
|
|
@@ -60,6 +99,10 @@ export class WorkspaceRuntime {
|
|
|
60
99
|
child.removeListener("exit", handleExit);
|
|
61
100
|
this.processes.delete(options.workspaceId);
|
|
62
101
|
this.logger.error({ workspaceId: options.workspaceId, err: error }, "Workspace runtime error");
|
|
102
|
+
if (exitResolve) {
|
|
103
|
+
exitResolve({ workspaceId: options.workspaceId, code: null, signal: null, requested: managed.requestedStop });
|
|
104
|
+
exitResolve = null;
|
|
105
|
+
}
|
|
63
106
|
reject(error);
|
|
64
107
|
};
|
|
65
108
|
child.on("error", handleError);
|
|
@@ -70,18 +113,23 @@ export class WorkspaceRuntime {
|
|
|
70
113
|
const lines = stdoutBuffer.split("\n");
|
|
71
114
|
stdoutBuffer = lines.pop() ?? "";
|
|
72
115
|
for (const line of lines) {
|
|
73
|
-
|
|
116
|
+
const trimmed = line.trim();
|
|
117
|
+
if (!trimmed)
|
|
74
118
|
continue;
|
|
119
|
+
recentStdout.push(trimmed);
|
|
120
|
+
if (recentStdout.length > MAX_OUTPUT_LINES) {
|
|
121
|
+
recentStdout.shift();
|
|
122
|
+
}
|
|
75
123
|
this.emitLog(options.workspaceId, "info", line);
|
|
76
124
|
if (!portFound) {
|
|
77
125
|
const portMatch = line.match(/opencode server listening on http:\/\/.+:(\d+)/i);
|
|
78
126
|
if (portMatch) {
|
|
79
127
|
portFound = true;
|
|
80
|
-
|
|
128
|
+
stopWarningTimer();
|
|
81
129
|
child.removeListener("error", handleError);
|
|
82
130
|
const port = parseInt(portMatch[1], 10);
|
|
83
131
|
this.logger.info({ workspaceId: options.workspaceId, port }, "Workspace runtime allocated port");
|
|
84
|
-
resolve({ pid: child.pid, port });
|
|
132
|
+
resolve({ pid: child.pid, port, exitPromise, getLastOutput });
|
|
85
133
|
}
|
|
86
134
|
}
|
|
87
135
|
}
|
|
@@ -92,8 +140,13 @@ export class WorkspaceRuntime {
|
|
|
92
140
|
const lines = stderrBuffer.split("\n");
|
|
93
141
|
stderrBuffer = lines.pop() ?? "";
|
|
94
142
|
for (const line of lines) {
|
|
95
|
-
|
|
143
|
+
const trimmed = line.trim();
|
|
144
|
+
if (!trimmed)
|
|
96
145
|
continue;
|
|
146
|
+
recentStderr.push(trimmed);
|
|
147
|
+
if (recentStderr.length > MAX_OUTPUT_LINES) {
|
|
148
|
+
recentStderr.shift();
|
|
149
|
+
}
|
|
97
150
|
this.emitLog(options.workspaceId, "error", line);
|
|
98
151
|
}
|
|
99
152
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@neuralnomads/codenomad",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "CodeNomad Server",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Neural Nomads",
|
|
@@ -16,10 +16,11 @@
|
|
|
16
16
|
"codenomad": "dist/bin.js"
|
|
17
17
|
},
|
|
18
18
|
"scripts": {
|
|
19
|
-
"build": "npm run build:ui && npm run prepare-ui && tsc -p tsconfig.json",
|
|
19
|
+
"build": "npm run build:ui && npm run prepare-ui && tsc -p tsconfig.json && npm run prepare-config",
|
|
20
20
|
"build:ui": "npm run build --prefix ../ui",
|
|
21
21
|
"prepare-ui": "node ./scripts/copy-ui-dist.mjs",
|
|
22
|
-
"
|
|
22
|
+
"prepare-config": "node ./scripts/copy-opencode-config.mjs",
|
|
23
|
+
"dev": "cross-env CODENOMAD_DEV=1 CLI_UI_DEV_SERVER=http://localhost:3000 tsx src/index.ts",
|
|
23
24
|
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
24
25
|
},
|
|
25
26
|
"dependencies": {
|