@neuralnomads/codenomad 0.2.0 → 0.2.2-dev

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/bin.js CHANGED
File without changes
@@ -5,7 +5,9 @@ export class EventBus extends EventEmitter {
5
5
  this.logger = logger;
6
6
  }
7
7
  publish(event) {
8
- this.logger?.debug({ event }, "Publishing workspace event");
8
+ if (event.type !== "instance.event" && event.type !== "instance.eventStatus") {
9
+ this.logger?.debug({ event }, "Publishing workspace event");
10
+ }
9
11
  return super.emit(event.type, event);
10
12
  }
11
13
  onEvent(listener) {
@@ -18,6 +20,8 @@ export class EventBus extends EventEmitter {
18
20
  this.on("config.appChanged", handler);
19
21
  this.on("config.binariesChanged", handler);
20
22
  this.on("instance.dataChanged", handler);
23
+ this.on("instance.event", handler);
24
+ this.on("instance.eventStatus", handler);
21
25
  return () => {
22
26
  this.off("workspace.created", handler);
23
27
  this.off("workspace.started", handler);
@@ -27,6 +31,8 @@ export class EventBus extends EventEmitter {
27
31
  this.off("config.appChanged", handler);
28
32
  this.off("config.binariesChanged", handler);
29
33
  this.off("instance.dataChanged", handler);
34
+ this.off("instance.event", handler);
35
+ this.off("instance.eventStatus", handler);
30
36
  };
31
37
  }
32
38
  }
package/dist/index.js CHANGED
@@ -13,6 +13,7 @@ import { BinaryRegistry } from "./config/binaries";
13
13
  import { FileSystemBrowser } from "./filesystem/browser";
14
14
  import { EventBus } from "./events/bus";
15
15
  import { InstanceStore } from "./storage/instance-store";
16
+ import { InstanceEventBridge } from "./workspaces/instance-events";
16
17
  import { createLogger } from "./logger";
17
18
  import { launchInBrowser } from "./launcher";
18
19
  const require = createRequire(import.meta.url);
@@ -81,6 +82,11 @@ async function main() {
81
82
  });
82
83
  const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot });
83
84
  const instanceStore = new InstanceStore();
85
+ const instanceEventBridge = new InstanceEventBridge({
86
+ workspaceManager,
87
+ eventBus,
88
+ logger: logger.child({ component: "instance-events" }),
89
+ });
84
90
  const serverMeta = {
85
91
  httpBaseUrl: `http://${options.host}:${options.port}`,
86
92
  eventsUrl: `/api/events`,
@@ -123,6 +129,7 @@ async function main() {
123
129
  logger.error({ err: error }, "Failed to stop HTTP server");
124
130
  }
125
131
  try {
132
+ instanceEventBridge.shutdown();
126
133
  await workspaceManager.shutdown();
127
134
  logger.info("Workspace manager shutdown complete");
128
135
  }
@@ -31,6 +31,12 @@ export function createHttpServer(deps) {
31
31
  });
32
32
  app.register(replyFrom, {
33
33
  contentTypesToEncode: [],
34
+ undici: {
35
+ connections: 16,
36
+ pipelining: 1,
37
+ bodyTimeout: 0,
38
+ headersTimeout: 0,
39
+ },
34
40
  });
35
41
  registerWorkspaceRoutes(app, { workspaceManager: deps.workspaceManager });
36
42
  registerConfigRoutes(app, { configStore: deps.configStore, binaryRegistry: deps.binaryRegistry });
@@ -0,0 +1,147 @@
1
+ import { fetch } from "undici";
2
+ const INSTANCE_HOST = "127.0.0.1";
3
+ const RECONNECT_DELAY_MS = 1000;
4
+ export class InstanceEventBridge {
5
+ constructor(options) {
6
+ this.options = options;
7
+ this.streams = new Map();
8
+ const bus = this.options.eventBus;
9
+ bus.on("workspace.started", (event) => this.startStream(event.workspace.id));
10
+ bus.on("workspace.stopped", (event) => this.stopStream(event.workspaceId));
11
+ bus.on("workspace.error", (event) => this.stopStream(event.workspace.id));
12
+ }
13
+ shutdown() {
14
+ for (const [id, active] of this.streams) {
15
+ active.controller.abort();
16
+ this.publishStatus(id, "disconnected");
17
+ }
18
+ this.streams.clear();
19
+ }
20
+ startStream(workspaceId) {
21
+ if (this.streams.has(workspaceId)) {
22
+ return;
23
+ }
24
+ const controller = new AbortController();
25
+ const task = this.runStream(workspaceId, controller.signal)
26
+ .catch((error) => {
27
+ if (!controller.signal.aborted) {
28
+ this.options.logger.warn({ workspaceId, err: error }, "Instance event stream failed");
29
+ this.publishStatus(workspaceId, "error", error instanceof Error ? error.message : String(error));
30
+ }
31
+ })
32
+ .finally(() => {
33
+ const active = this.streams.get(workspaceId);
34
+ if (active?.controller === controller) {
35
+ this.streams.delete(workspaceId);
36
+ }
37
+ });
38
+ this.streams.set(workspaceId, { controller, task });
39
+ }
40
+ stopStream(workspaceId) {
41
+ const active = this.streams.get(workspaceId);
42
+ if (!active) {
43
+ return;
44
+ }
45
+ active.controller.abort();
46
+ this.streams.delete(workspaceId);
47
+ this.publishStatus(workspaceId, "disconnected");
48
+ }
49
+ async runStream(workspaceId, signal) {
50
+ while (!signal.aborted) {
51
+ const port = this.options.workspaceManager.getInstancePort(workspaceId);
52
+ if (!port) {
53
+ await this.delay(RECONNECT_DELAY_MS, signal);
54
+ continue;
55
+ }
56
+ this.publishStatus(workspaceId, "connecting");
57
+ try {
58
+ await this.consumeStream(workspaceId, port, signal);
59
+ }
60
+ catch (error) {
61
+ if (signal.aborted) {
62
+ break;
63
+ }
64
+ this.options.logger.warn({ workspaceId, err: error }, "Instance event stream disconnected");
65
+ this.publishStatus(workspaceId, "error", error instanceof Error ? error.message : String(error));
66
+ await this.delay(RECONNECT_DELAY_MS, signal);
67
+ }
68
+ }
69
+ }
70
+ async consumeStream(workspaceId, port, signal) {
71
+ const url = `http://${INSTANCE_HOST}:${port}/event`;
72
+ const response = await fetch(url, {
73
+ headers: { Accept: "text/event-stream" },
74
+ signal,
75
+ });
76
+ if (!response.ok || !response.body) {
77
+ throw new Error(`Instance event stream unavailable (${response.status})`);
78
+ }
79
+ this.publishStatus(workspaceId, "connected");
80
+ const reader = response.body.getReader();
81
+ const decoder = new TextDecoder();
82
+ let buffer = "";
83
+ while (!signal.aborted) {
84
+ const { done, value } = await reader.read();
85
+ if (done || !value) {
86
+ break;
87
+ }
88
+ buffer += decoder.decode(value, { stream: true });
89
+ buffer = this.flushEvents(buffer, workspaceId);
90
+ }
91
+ }
92
+ flushEvents(buffer, workspaceId) {
93
+ let separatorIndex = buffer.indexOf("\n\n");
94
+ while (separatorIndex >= 0) {
95
+ const chunk = buffer.slice(0, separatorIndex);
96
+ buffer = buffer.slice(separatorIndex + 2);
97
+ this.processChunk(chunk, workspaceId);
98
+ separatorIndex = buffer.indexOf("\n\n");
99
+ }
100
+ return buffer;
101
+ }
102
+ processChunk(chunk, workspaceId) {
103
+ const lines = chunk.split(/\r?\n/);
104
+ const dataLines = [];
105
+ for (const line of lines) {
106
+ if (line.startsWith(":")) {
107
+ continue;
108
+ }
109
+ if (line.startsWith("data:")) {
110
+ dataLines.push(line.slice(5).trimStart());
111
+ }
112
+ }
113
+ if (dataLines.length === 0) {
114
+ return;
115
+ }
116
+ const payload = dataLines.join("\n").trim();
117
+ if (!payload) {
118
+ return;
119
+ }
120
+ try {
121
+ const event = JSON.parse(payload);
122
+ this.options.eventBus.publish({ type: "instance.event", instanceId: workspaceId, event });
123
+ }
124
+ catch (error) {
125
+ this.options.logger.warn({ workspaceId, chunk: payload, err: error }, "Failed to parse instance SSE payload");
126
+ }
127
+ }
128
+ publishStatus(instanceId, status, reason) {
129
+ this.options.eventBus.publish({ type: "instance.eventStatus", instanceId, status, reason });
130
+ }
131
+ delay(duration, signal) {
132
+ if (duration <= 0) {
133
+ return Promise.resolve();
134
+ }
135
+ return new Promise((resolve) => {
136
+ const timeout = setTimeout(() => {
137
+ signal.removeEventListener("abort", onAbort);
138
+ resolve();
139
+ }, duration);
140
+ const onAbort = () => {
141
+ clearTimeout(timeout);
142
+ resolve();
143
+ };
144
+ signal.addEventListener("abort", onAbort, { once: true });
145
+ });
146
+ }
147
+ }
@@ -1,4 +1,5 @@
1
1
  import path from "path";
2
+ import { spawnSync } from "child_process";
2
3
  import { FileSystemBrowser } from "../filesystem/browser";
3
4
  import { searchWorkspaceFiles } from "../filesystem/search";
4
5
  import { clearWorkspaceSearchCache } from "../filesystem/search-cache";
@@ -40,9 +41,10 @@ export class WorkspaceManager {
40
41
  async create(folder, name) {
41
42
  const id = `${Date.now().toString(36)}`;
42
43
  const binary = this.options.binaryRegistry.resolveDefault();
44
+ const resolvedBinaryPath = this.resolveBinaryPath(binary.path);
43
45
  const workspacePath = path.isAbsolute(folder) ? folder : path.resolve(this.options.rootDir, folder);
44
46
  clearWorkspaceSearchCache(workspacePath);
45
- this.options.logger.info({ workspaceId: id, folder: workspacePath, binary: binary.path }, "Creating workspace");
47
+ this.options.logger.info({ workspaceId: id, folder: workspacePath, binary: resolvedBinaryPath }, "Creating workspace");
46
48
  const proxyPath = `/workspaces/${id}/instance`;
47
49
  const descriptor = {
48
50
  id,
@@ -50,12 +52,15 @@ export class WorkspaceManager {
50
52
  name,
51
53
  status: "starting",
52
54
  proxyPath,
53
- binaryId: binary.id,
55
+ binaryId: resolvedBinaryPath,
54
56
  binaryLabel: binary.label,
55
57
  binaryVersion: binary.version,
56
58
  createdAt: new Date().toISOString(),
57
59
  updatedAt: new Date().toISOString(),
58
60
  };
61
+ if (!descriptor.binaryVersion) {
62
+ descriptor.binaryVersion = this.detectBinaryVersion(resolvedBinaryPath);
63
+ }
59
64
  this.workspaces.set(id, descriptor);
60
65
  this.options.eventBus.publish({ type: "workspace.created", workspace: descriptor });
61
66
  const environment = this.options.configStore.get().preferences.environmentVariables ?? {};
@@ -63,7 +68,7 @@ export class WorkspaceManager {
63
68
  const { pid, port } = await this.runtime.launch({
64
69
  workspaceId: id,
65
70
  folder: workspacePath,
66
- binaryPath: binary.path,
71
+ binaryPath: resolvedBinaryPath,
67
72
  environment,
68
73
  onExit: (info) => this.handleProcessExit(info.workspaceId, info),
69
74
  });
@@ -125,6 +130,65 @@ export class WorkspaceManager {
125
130
  }
126
131
  return workspace;
127
132
  }
133
+ resolveBinaryPath(identifier) {
134
+ if (!identifier) {
135
+ return identifier;
136
+ }
137
+ const looksLikePath = identifier.includes("/") || identifier.includes("\\") || identifier.startsWith(".");
138
+ if (path.isAbsolute(identifier) || looksLikePath) {
139
+ return identifier;
140
+ }
141
+ const locator = process.platform === "win32" ? "where" : "which";
142
+ try {
143
+ const result = spawnSync(locator, [identifier], { encoding: "utf8" });
144
+ if (result.status === 0 && result.stdout) {
145
+ const resolved = result.stdout
146
+ .split(/\r?\n/)
147
+ .map((line) => line.trim())
148
+ .find((line) => line.length > 0);
149
+ if (resolved) {
150
+ this.options.logger.debug({ identifier, resolved }, "Resolved binary path from system PATH");
151
+ return resolved;
152
+ }
153
+ }
154
+ else if (result.error) {
155
+ this.options.logger.warn({ identifier, err: result.error }, "Failed to resolve binary path via locator command");
156
+ }
157
+ }
158
+ catch (error) {
159
+ this.options.logger.warn({ identifier, err: error }, "Failed to resolve binary path from system PATH");
160
+ }
161
+ return identifier;
162
+ }
163
+ detectBinaryVersion(resolvedPath) {
164
+ if (!resolvedPath) {
165
+ return undefined;
166
+ }
167
+ try {
168
+ const result = spawnSync(resolvedPath, ["--version"], { encoding: "utf8" });
169
+ if (result.status === 0 && result.stdout) {
170
+ const line = result.stdout.split(/\r?\n/).find((entry) => entry.trim().length > 0);
171
+ if (line) {
172
+ const normalized = line.trim();
173
+ const versionMatch = normalized.match(/([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/);
174
+ if (versionMatch) {
175
+ const version = versionMatch[1];
176
+ this.options.logger.debug({ binary: resolvedPath, version }, "Detected binary version");
177
+ return version;
178
+ }
179
+ this.options.logger.debug({ binary: resolvedPath, reported: normalized }, "Binary reported version string");
180
+ return normalized;
181
+ }
182
+ }
183
+ else if (result.error) {
184
+ this.options.logger.warn({ binary: resolvedPath, err: result.error }, "Failed to read binary version");
185
+ }
186
+ }
187
+ catch (error) {
188
+ this.options.logger.warn({ binary: resolvedPath, err: error }, "Failed to detect binary version");
189
+ }
190
+ return undefined;
191
+ }
128
192
  handleProcessExit(workspaceId, info) {
129
193
  const workspace = this.workspaces.get(workspaceId);
130
194
  if (!workspace)
@@ -12,7 +12,7 @@ export class WorkspaceRuntime {
12
12
  const args = ["serve", "--port", "0", "--print-logs", "--log-level", "DEBUG"];
13
13
  const env = { ...process.env, ...(options.environment ?? {}) };
14
14
  return new Promise((resolve, reject) => {
15
- this.logger.info({ workspaceId: options.workspaceId, folder: options.folder }, "Launching OpenCode process");
15
+ this.logger.info({ workspaceId: options.workspaceId, folder: options.folder, binary: options.binaryPath }, "Launching OpenCode process");
16
16
  const child = spawn(options.binaryPath, args, {
17
17
  cwd: options.folder,
18
18
  env,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neuralnomads/codenomad",
3
- "version": "0.2.0",
3
+ "version": "0.2.2-dev",
4
4
  "description": "CodeNomad Server",
5
5
  "author": {
6
6
  "name": "Neural Nomads",