@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.
@@ -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
- const body = WorkspaceCreateSchema.parse(request.body ?? {});
27
- const workspace = await deps.workspaceManager.create(body.path, body.name);
28
- reply.code(201);
29
- return workspace;
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 environment = this.options.configStore.get().preferences.environmentVariables ?? {};
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
- this.logger.info({ workspaceId: options.workspaceId, folder: options.folder, binary: options.binaryPath }, "Launching OpenCode process");
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 reason = stderrBuffer || `Process exited with code ${code}`;
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?.({ workspaceId: options.workspaceId, code, signal, requested: managed.requestedStop });
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
- if (!line.trim())
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
- cleanupStreams();
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
- if (!line.trim())
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.4.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
- "dev": "cross-env CLI_UI_DEV_SERVER=http://localhost:3000 tsx src/index.ts",
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": {