@dyyz1993/codenomad 0.15.1 → 0.15.3

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.
@@ -1,7 +1,10 @@
1
1
  import crypto from "crypto";
2
+ const SESSION_TTL_MS = 24 * 60 * 60 * 1000;
3
+ const CLEANUP_INTERVAL_MS = 30 * 60 * 1000;
2
4
  export class SessionManager {
3
5
  constructor() {
4
6
  this.sessions = new Map();
7
+ this.cleanupTimer = setInterval(() => this.cleanupExpired(), CLEANUP_INTERVAL_MS);
5
8
  }
6
9
  createSession(username) {
7
10
  const id = crypto.randomBytes(32).toString("base64url");
@@ -14,4 +17,29 @@ export class SessionManager {
14
17
  return undefined;
15
18
  return this.sessions.get(id);
16
19
  }
20
+ validateSession(sessionId) {
21
+ const info = this.sessions.get(sessionId);
22
+ if (!info)
23
+ return null;
24
+ if (Date.now() - info.createdAt > SESSION_TTL_MS) {
25
+ this.sessions.delete(sessionId);
26
+ return null;
27
+ }
28
+ return info;
29
+ }
30
+ cleanupExpired() {
31
+ const now = Date.now();
32
+ for (const [id, info] of this.sessions) {
33
+ if (now - info.createdAt > SESSION_TTL_MS) {
34
+ this.sessions.delete(id);
35
+ }
36
+ }
37
+ }
38
+ shutdown() {
39
+ if (this.cleanupTimer) {
40
+ clearInterval(this.cleanupTimer);
41
+ this.cleanupTimer = undefined;
42
+ }
43
+ this.sessions.clear();
44
+ }
17
45
  }
@@ -149,7 +149,7 @@ export class BackgroundProcessManager {
149
149
  }
150
150
  async readOutput(workspaceId, processId, options) {
151
151
  const outputPath = this.getOutputPath(workspaceId, processId);
152
- if (!existsSync(outputPath)) {
152
+ if (!outputPath || !existsSync(outputPath)) {
153
153
  return { id: processId, content: "", truncated: false, sizeBytes: 0 };
154
154
  }
155
155
  const stats = await fs.stat(outputPath);
@@ -184,7 +184,7 @@ export class BackgroundProcessManager {
184
184
  }
185
185
  async streamOutput(workspaceId, processId, reply) {
186
186
  const outputPath = this.getOutputPath(workspaceId, processId);
187
- if (!existsSync(outputPath)) {
187
+ if (!outputPath || !existsSync(outputPath)) {
188
188
  reply.code(404).send({ error: "Output not found" });
189
189
  return;
190
190
  }
@@ -356,6 +356,9 @@ export class BackgroundProcessManager {
356
356
  }
357
357
  async ensureProcessDir(workspaceId, processId) {
358
358
  const root = await this.ensureWorkspaceDir(workspaceId);
359
+ if (!root) {
360
+ throw new Error("Workspace not found");
361
+ }
359
362
  const processDir = path.join(root, processId);
360
363
  await fs.mkdir(processDir, { recursive: true });
361
364
  return processDir;
@@ -363,7 +366,7 @@ export class BackgroundProcessManager {
363
366
  async ensureWorkspaceDir(workspaceId) {
364
367
  const workspace = this.deps.workspaceManager.get(workspaceId);
365
368
  if (!workspace) {
366
- throw new Error("Workspace not found");
369
+ return null;
367
370
  }
368
371
  const root = path.join(workspace.path, ROOT_DIR, workspaceId);
369
372
  await fs.mkdir(root, { recursive: true });
@@ -372,7 +375,7 @@ export class BackgroundProcessManager {
372
375
  getOutputPath(workspaceId, processId) {
373
376
  const workspace = this.deps.workspaceManager.get(workspaceId);
374
377
  if (!workspace) {
375
- throw new Error("Workspace not found");
378
+ return null;
376
379
  }
377
380
  return path.join(workspace.path, ROOT_DIR, workspaceId, processId, OUTPUT_FILE);
378
381
  }
@@ -382,6 +385,8 @@ export class BackgroundProcessManager {
382
385
  }
383
386
  async readIndex(workspaceId) {
384
387
  const indexPath = await this.getIndexPath(workspaceId);
388
+ if (!indexPath)
389
+ return [];
385
390
  if (!existsSync(indexPath))
386
391
  return [];
387
392
  try {
@@ -411,13 +416,15 @@ export class BackgroundProcessManager {
411
416
  }
412
417
  async writeIndex(workspaceId, records) {
413
418
  const indexPath = await this.getIndexPath(workspaceId);
419
+ if (!indexPath)
420
+ return;
414
421
  await fs.mkdir(path.dirname(indexPath), { recursive: true });
415
422
  await fs.writeFile(indexPath, JSON.stringify(records, null, 2));
416
423
  }
417
424
  async getIndexPath(workspaceId) {
418
425
  const workspace = this.deps.workspaceManager.get(workspaceId);
419
426
  if (!workspace) {
420
- throw new Error("Workspace not found");
427
+ return null;
421
428
  }
422
429
  return path.join(workspace.path, ROOT_DIR, workspaceId, INDEX_FILE);
423
430
  }
@@ -439,7 +446,7 @@ export class BackgroundProcessManager {
439
446
  }
440
447
  async getOutputSize(workspaceId, processId) {
441
448
  const outputPath = this.getOutputPath(workspaceId, processId);
442
- if (!existsSync(outputPath)) {
449
+ if (!outputPath || !existsSync(outputPath)) {
443
450
  return 0;
444
451
  }
445
452
  try {
@@ -475,6 +482,11 @@ export class BackgroundProcessManager {
475
482
  };
476
483
  }
477
484
  async finalizeRecord(workspaceId, record, completion) {
485
+ if (!this.deps.workspaceManager.get(workspaceId)) {
486
+ this.deps.logger.debug({ workspaceId, processId: record.id }, "Skipping finalizeRecord: workspace already removed");
487
+ this.running.delete(record.id);
488
+ return;
489
+ }
478
490
  if (this.shouldSendCompletionPrompt(record, completion)) {
479
491
  try {
480
492
  await this.sendCompletionPrompt(workspaceId, record);
@@ -5,10 +5,10 @@ describe("workspace search cache", () => {
5
5
  beforeEach(() => {
6
6
  clearWorkspaceSearchCache();
7
7
  });
8
- it("expires cached candidates after the TTL", () => {
8
+ it("expires cached candidates after the TTL", async () => {
9
9
  const workspacePath = "/tmp/workspace";
10
10
  const startTime = 1000;
11
- refreshWorkspaceCandidates(workspacePath, () => [createEntry("file-a")], startTime);
11
+ await refreshWorkspaceCandidates(workspacePath, async () => [createEntry("file-a")], startTime);
12
12
  const beforeExpiry = getWorkspaceCandidates(workspacePath, startTime + WORKSPACE_CANDIDATE_CACHE_TTL_MS - 1);
13
13
  assert.ok(beforeExpiry);
14
14
  assert.equal(beforeExpiry.length, 1);
@@ -16,13 +16,13 @@ describe("workspace search cache", () => {
16
16
  const afterExpiry = getWorkspaceCandidates(workspacePath, startTime + WORKSPACE_CANDIDATE_CACHE_TTL_MS + 1);
17
17
  assert.equal(afterExpiry, undefined);
18
18
  });
19
- it("replaces cached entries when manually refreshed", () => {
19
+ it("replaces cached entries when manually refreshed", async () => {
20
20
  const workspacePath = "/tmp/workspace";
21
- refreshWorkspaceCandidates(workspacePath, () => [createEntry("file-a")], 5000);
21
+ await refreshWorkspaceCandidates(workspacePath, async () => [createEntry("file-a")], 5000);
22
22
  const initial = getWorkspaceCandidates(workspacePath, 5001);
23
23
  assert.ok(initial);
24
24
  assert.equal(initial[0].name, "file-a");
25
- refreshWorkspaceCandidates(workspacePath, () => [createEntry("file-b")], 6000);
25
+ await refreshWorkspaceCandidates(workspacePath, async () => [createEntry("file-b")], 6000);
26
26
  const refreshed = getWorkspaceCandidates(workspacePath, 6001);
27
27
  assert.ok(refreshed);
28
28
  assert.equal(refreshed[0].name, "file-b");
@@ -13,9 +13,9 @@ export function getWorkspaceCandidates(rootDir, now = Date.now()) {
13
13
  }
14
14
  return cloneEntries(cached.candidates);
15
15
  }
16
- export function refreshWorkspaceCandidates(rootDir, builder, now = Date.now()) {
16
+ export async function refreshWorkspaceCandidates(rootDir, builder, now = Date.now()) {
17
17
  const key = normalizeKey(rootDir);
18
- const freshCandidates = builder();
18
+ const freshCandidates = await builder();
19
19
  if (!freshCandidates || freshCandidates.length === 0) {
20
20
  workspaceCandidateCache.delete(key);
21
21
  return [];
@@ -1,4 +1,4 @@
1
- import fs from "fs";
1
+ import { readdir, stat } from "node:fs/promises";
2
2
  import path from "path";
3
3
  import fuzzysort from "fuzzysort";
4
4
  import { clearWorkspaceSearchCache, getWorkspaceCandidates, refreshWorkspaceCandidates } from "./search-cache";
@@ -6,7 +6,7 @@ const DEFAULT_LIMIT = 100;
6
6
  const MAX_LIMIT = 200;
7
7
  const MAX_CANDIDATES = 8000;
8
8
  const IGNORED_DIRECTORIES = new Set([".git", ".hg", ".svn", "node_modules", "dist", "build", ".next", ".nuxt", ".turbo", ".cache", "coverage"].map((name) => name.toLowerCase()));
9
- export function searchWorkspaceFiles(rootDir, query, options = {}) {
9
+ export async function searchWorkspaceFiles(rootDir, query, options = {}) {
10
10
  const trimmedQuery = query.trim();
11
11
  if (!trimmedQuery) {
12
12
  throw new Error("Search query is required");
@@ -21,7 +21,7 @@ export function searchWorkspaceFiles(rootDir, query, options = {}) {
21
21
  entries = getWorkspaceCandidates(normalizedRoot);
22
22
  }
23
23
  if (!entries) {
24
- entries = refreshWorkspaceCandidates(normalizedRoot, () => collectCandidates(normalizedRoot));
24
+ entries = await refreshWorkspaceCandidates(normalizedRoot, () => collectCandidates(normalizedRoot));
25
25
  }
26
26
  }
27
27
  catch (error) {
@@ -45,7 +45,7 @@ export function searchWorkspaceFiles(rootDir, query, options = {}) {
45
45
  }
46
46
  return matches.map((match) => match.obj.entry);
47
47
  }
48
- function collectCandidates(rootDir) {
48
+ async function collectCandidates(rootDir) {
49
49
  const queue = [""];
50
50
  const entries = [];
51
51
  while (queue.length > 0 && entries.length < MAX_CANDIDATES) {
@@ -53,7 +53,7 @@ function collectCandidates(rootDir) {
53
53
  const absoluteDir = relativeDir ? path.join(rootDir, relativeDir) : rootDir;
54
54
  let dirents;
55
55
  try {
56
- dirents = fs.readdirSync(absoluteDir, { withFileTypes: true });
56
+ dirents = await readdir(absoluteDir, { withFileTypes: true });
57
57
  }
58
58
  catch {
59
59
  continue;
@@ -68,7 +68,7 @@ function collectCandidates(rootDir) {
68
68
  }
69
69
  let stats;
70
70
  try {
71
- stats = fs.statSync(absolutePath);
71
+ stats = await stat(absolutePath);
72
72
  }
73
73
  catch {
74
74
  continue;
package/dist/index.js CHANGED
@@ -232,7 +232,7 @@ async function main() {
232
232
  });
233
233
  const instanceStore = new InstanceStore(configLocation.instancesDir);
234
234
  const speechService = new SpeechService(settings, logger.child({ component: "speech" }));
235
- const sidecarManager = new SideCarManager({
235
+ const sidecarManager = await SideCarManager.create({
236
236
  settings,
237
237
  eventBus,
238
238
  logger: logger.child({ component: "sidecars" }),
@@ -3,6 +3,7 @@ let nextClientId = 0;
3
3
  const ConnectionQuerySchema = z.object({
4
4
  clientId: z.string().trim().min(1),
5
5
  connectionId: z.string().trim().min(1),
6
+ workspaceId: z.string().trim().optional(),
6
7
  });
7
8
  const PongBodySchema = ConnectionQuerySchema.extend({
8
9
  pingTs: z.number().optional(),
@@ -20,7 +21,32 @@ export function registerEventRoutes(app, deps) {
20
21
  reply.raw.setHeader("Connection", "keep-alive");
21
22
  reply.raw.flushHeaders?.();
22
23
  reply.hijack();
24
+ const filterWorkspaceId = connection.workspaceId;
25
+ const getEventWorkspaceId = (event) => {
26
+ switch (event.type) {
27
+ case "workspace.created":
28
+ case "workspace.started":
29
+ case "workspace.error":
30
+ return event.workspace?.id;
31
+ case "workspace.stopped":
32
+ return event.workspaceId;
33
+ case "workspace.log":
34
+ return event.entry.workspaceId;
35
+ case "instance.event":
36
+ case "instance.eventStatus":
37
+ case "instance.dataChanged":
38
+ return event.instanceId;
39
+ default:
40
+ return undefined;
41
+ }
42
+ };
23
43
  const send = (event) => {
44
+ if (filterWorkspaceId) {
45
+ const eventWsId = getEventWorkspaceId(event);
46
+ if (eventWsId && eventWsId !== filterWorkspaceId) {
47
+ return;
48
+ }
49
+ }
24
50
  deps.logger.debug({ clientId, type: event.type }, "SSE event dispatched");
25
51
  if (deps.logger.isLevelEnabled("trace")) {
26
52
  deps.logger.trace({ clientId, event }, "SSE event payload");
@@ -10,10 +10,10 @@ function validateBinaryPath(binaryPath) {
10
10
  }
11
11
  export function registerSettingsRoutes(app, deps) {
12
12
  // Full-document access
13
- app.get("/api/storage/config", async () => sanitizeConfigDoc(deps.settings.getDoc("config")));
13
+ app.get("/api/storage/config", async () => sanitizeConfigDoc(await deps.settings.getDoc("config")));
14
14
  app.patch("/api/storage/config", async (request, reply) => {
15
15
  try {
16
- return sanitizeConfigDoc(deps.settings.mergePatchDoc("config", request.body ?? {}));
16
+ return sanitizeConfigDoc(await deps.settings.mergePatchDoc("config", request.body ?? {}));
17
17
  }
18
18
  catch (error) {
19
19
  reply.code(400);
@@ -21,11 +21,11 @@ export function registerSettingsRoutes(app, deps) {
21
21
  }
22
22
  });
23
23
  app.get("/api/storage/config/:owner", async (request) => {
24
- return sanitizeConfigOwner(request.params.owner, deps.settings.getOwner("config", request.params.owner));
24
+ return sanitizeConfigOwner(request.params.owner, await deps.settings.getOwner("config", request.params.owner));
25
25
  });
26
26
  app.patch("/api/storage/config/:owner", async (request, reply) => {
27
27
  try {
28
- return sanitizeConfigOwner(request.params.owner, deps.settings.mergePatchOwner("config", request.params.owner, request.body ?? {}));
28
+ return sanitizeConfigOwner(request.params.owner, await deps.settings.mergePatchOwner("config", request.params.owner, request.body ?? {}));
29
29
  }
30
30
  catch (error) {
31
31
  reply.code(400);
@@ -35,7 +35,7 @@ export function registerSettingsRoutes(app, deps) {
35
35
  app.get("/api/storage/state", async () => deps.settings.getDoc("state"));
36
36
  app.patch("/api/storage/state", async (request, reply) => {
37
37
  try {
38
- return deps.settings.mergePatchDoc("state", request.body ?? {});
38
+ return await deps.settings.mergePatchDoc("state", request.body ?? {});
39
39
  }
40
40
  catch (error) {
41
41
  reply.code(400);
@@ -47,7 +47,7 @@ export function registerSettingsRoutes(app, deps) {
47
47
  });
48
48
  app.patch("/api/storage/state/:owner", async (request, reply) => {
49
49
  try {
50
- return deps.settings.mergePatchOwner("state", request.params.owner, request.body ?? {});
50
+ return await deps.settings.mergePatchOwner("state", request.params.owner, request.body ?? {});
51
51
  }
52
52
  catch (error) {
53
53
  reply.code(400);
@@ -23,7 +23,7 @@ function getSpeechErrorMessage(error, fallback) {
23
23
  return error instanceof Error ? error.message : fallback;
24
24
  }
25
25
  export function registerSpeechRoutes(app, deps) {
26
- app.get("/api/speech/capabilities", async () => deps.speechService.getCapabilities());
26
+ app.get("/api/speech/capabilities", async () => await deps.speechService.getCapabilities());
27
27
  app.post("/api/speech/transcribe", async (request, reply) => {
28
28
  try {
29
29
  const body = TranscribeBodySchema.parse(request.body ?? {});
@@ -77,7 +77,7 @@ export function registerWorkspaceRoutes(app, deps) {
77
77
  app.get("/api/workspaces/:id/files/search", async (request, reply) => {
78
78
  try {
79
79
  const query = WorkspaceFileSearchQuerySchema.parse(request.query ?? {});
80
- return deps.workspaceManager.searchFiles(request.params.id, query.q, {
80
+ return await deps.workspaceManager.searchFiles(request.params.id, query.q, {
81
81
  limit: query.limit,
82
82
  type: query.type,
83
83
  refresh: query.refresh,
@@ -3,15 +3,15 @@ function prettyLabel(p) {
3
3
  const last = parts[parts.length - 1] || p;
4
4
  return last || p;
5
5
  }
6
- function readUiBinaries(settings) {
7
- const ui = settings.getOwner("state", "ui");
6
+ async function readUiBinaries(settings) {
7
+ const ui = await settings.getOwner("state", "ui");
8
8
  const list = ui?.opencodeBinaries;
9
9
  if (!Array.isArray(list))
10
10
  return [];
11
11
  return list.filter((item) => item && typeof item === "object" && typeof item.path === "string");
12
12
  }
13
- function readDefaultBinaryPath(settings) {
14
- const server = settings.getOwner("config", "server");
13
+ async function readDefaultBinaryPath(settings) {
14
+ const server = await settings.getOwner("config", "server");
15
15
  const value = server?.opencodeBinary;
16
16
  return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
17
17
  }
@@ -19,12 +19,12 @@ export class BinaryResolver {
19
19
  constructor(settings) {
20
20
  this.settings = settings;
21
21
  }
22
- list() {
22
+ async list() {
23
23
  return readUiBinaries(this.settings);
24
24
  }
25
- resolveDefault() {
26
- const binaries = this.list();
27
- const configuredDefault = readDefaultBinaryPath(this.settings);
25
+ async resolveDefault() {
26
+ const binaries = await this.list();
27
+ const configuredDefault = await readDefaultBinaryPath(this.settings);
28
28
  const fallback = binaries[0]?.path;
29
29
  const path = configuredDefault ?? fallback ?? "opencode";
30
30
  const entry = binaries.find((b) => b.path === path);
@@ -51,46 +51,46 @@ export class SettingsService {
51
51
  this.configStore = new YamlDocStore(location.configYamlPath, logger.child({ component: "settings-config" }));
52
52
  this.stateStore = new YamlDocStore(location.stateYamlPath, logger.child({ component: "settings-state" }));
53
53
  }
54
- getDoc(kind) {
54
+ async getDoc(kind) {
55
55
  if (kind !== "config") {
56
56
  return this.stateStore.get();
57
57
  }
58
- const current = this.configStore.get();
58
+ const current = await this.configStore.get();
59
59
  const normalized = normalizeConfigDoc(current);
60
60
  if (!isDeepEqual(current, normalized)) {
61
- this.configStore.replace(normalized);
61
+ await this.configStore.replace(normalized);
62
62
  }
63
63
  return normalized;
64
64
  }
65
- mergePatchDoc(kind, patch) {
65
+ async mergePatchDoc(kind, patch) {
66
66
  const updated = kind === "config"
67
- ? this.configStore.replace(normalizeConfigDoc(this.configStore.mergePatch(patch)))
68
- : this.stateStore.mergePatch(patch);
69
- this.publish(kind, "*");
67
+ ? await this.configStore.replace(normalizeConfigDoc(await this.configStore.mergePatch(patch)))
68
+ : await this.stateStore.mergePatch(patch);
69
+ await this.publish(kind, "*");
70
70
  return updated;
71
71
  }
72
- getOwner(kind, owner) {
72
+ async getOwner(kind, owner) {
73
73
  if (kind !== "config") {
74
74
  return this.stateStore.getOwner(owner);
75
75
  }
76
76
  return owner === "server"
77
- ? normalizeServerConfigOwner(this.getDoc("config").server)
78
- : this.getDoc("config")[owner];
77
+ ? normalizeServerConfigOwner((await this.getDoc("config")).server)
78
+ : (await this.getDoc("config"))[owner];
79
79
  }
80
- mergePatchOwner(kind, owner, patch) {
80
+ async mergePatchOwner(kind, owner, patch) {
81
81
  const updated = kind === "config"
82
82
  ? owner === "server"
83
- ? this.configStore.replaceOwner(owner, normalizeServerConfigOwner(this.configStore.mergePatchOwner(owner, patch)))
84
- : this.configStore.mergePatchOwner(owner, patch)
85
- : this.stateStore.mergePatchOwner(owner, patch);
86
- this.publish(kind, owner, updated);
83
+ ? await this.configStore.replaceOwner(owner, normalizeServerConfigOwner(await this.configStore.mergePatchOwner(owner, patch)))
84
+ : await this.configStore.mergePatchOwner(owner, patch)
85
+ : await this.stateStore.mergePatchOwner(owner, patch);
86
+ await this.publish(kind, owner, updated);
87
87
  return updated;
88
88
  }
89
- publish(kind, owner, value) {
89
+ async publish(kind, owner, value) {
90
90
  if (!this.eventBus)
91
91
  return;
92
92
  const type = kind === "config" ? "storage.configChanged" : "storage.stateChanged";
93
- const nextValue = value ?? this.getOwner(kind, owner);
93
+ const nextValue = value ?? await this.getOwner(kind, owner);
94
94
  const payload = {
95
95
  type,
96
96
  owner,
@@ -1,4 +1,5 @@
1
- import fs from "fs";
1
+ import { access, mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { constants } from "node:fs";
2
3
  import path from "path";
3
4
  import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
4
5
  import { applyMergePatch, isPlainObject } from "./merge-patch";
@@ -20,17 +21,20 @@ export class YamlDocStore {
20
21
  this.cache = {};
21
22
  this.loaded = false;
22
23
  }
23
- load() {
24
+ async load() {
24
25
  if (this.loaded) {
25
26
  return this.cache;
26
27
  }
27
28
  try {
28
- if (!fs.existsSync(this.filePath)) {
29
+ try {
30
+ await access(this.filePath, constants.F_OK);
31
+ }
32
+ catch {
29
33
  this.cache = {};
30
34
  this.loaded = true;
31
35
  return this.cache;
32
36
  }
33
- const content = fs.readFileSync(this.filePath, "utf-8");
37
+ const content = await readFile(this.filePath, "utf-8");
34
38
  const parsed = parseYaml(content);
35
39
  this.cache = normalizeDoc(parsed);
36
40
  this.loaded = true;
@@ -43,51 +47,51 @@ export class YamlDocStore {
43
47
  return this.cache;
44
48
  }
45
49
  }
46
- get() {
50
+ async get() {
47
51
  return this.load();
48
52
  }
49
- replace(next) {
53
+ async replace(next) {
50
54
  const normalized = normalizeDoc(next);
51
55
  this.cache = normalized;
52
56
  this.loaded = true;
53
- this.persist();
57
+ await this.persist();
54
58
  return this.cache;
55
59
  }
56
- mergePatch(patch) {
60
+ async mergePatch(patch) {
57
61
  if (!isPlainObject(patch)) {
58
62
  throw new Error("Patch must be a JSON object");
59
63
  }
60
- const current = this.get();
64
+ const current = await this.get();
61
65
  const next = applyMergePatch(current, patch);
62
66
  return this.replace(next);
63
67
  }
64
- getOwner(owner) {
65
- const doc = this.get();
68
+ async getOwner(owner) {
69
+ const doc = await this.get();
66
70
  const value = doc?.[owner];
67
71
  return normalizeDoc(value);
68
72
  }
69
- replaceOwner(owner, value) {
70
- const doc = this.get();
73
+ async replaceOwner(owner, value) {
74
+ const doc = await this.get();
71
75
  const nextDoc = { ...doc, [owner]: normalizeDoc(value) };
72
- this.replace(nextDoc);
76
+ await this.replace(nextDoc);
73
77
  return nextDoc[owner];
74
78
  }
75
- mergePatchOwner(owner, patch) {
79
+ async mergePatchOwner(owner, patch) {
76
80
  if (!isPlainObject(patch)) {
77
81
  throw new Error("Patch must be a JSON object");
78
82
  }
79
- const doc = this.get();
83
+ const doc = await this.get();
80
84
  const currentOwner = normalizeDoc(doc?.[owner]);
81
85
  const nextOwner = normalizeDoc(applyMergePatch(currentOwner, patch));
82
86
  const nextDoc = { ...doc, [owner]: nextOwner };
83
- this.replace(nextDoc);
87
+ await this.replace(nextDoc);
84
88
  return nextOwner;
85
89
  }
86
- persist() {
90
+ async persist() {
87
91
  try {
88
- fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
92
+ await mkdir(path.dirname(this.filePath), { recursive: true });
89
93
  const yaml = stringifyYaml(this.cache);
90
- fs.writeFileSync(this.filePath, ensureTrailingNewline(yaml), "utf-8");
94
+ await writeFile(this.filePath, ensureTrailingNewline(yaml), "utf-8");
91
95
  }
92
96
  catch (error) {
93
97
  this.logger.warn({ err: error, filePath: this.filePath }, "Failed to persist YAML doc");
@@ -4,17 +4,21 @@ export class SideCarManager {
4
4
  this.options = options;
5
5
  this.configs = new Map();
6
6
  this.runtime = new Map();
7
- for (const record of this.loadConfiguredSideCars()) {
8
- this.configs.set(record.id, record);
9
- this.runtime.set(record.id, { status: "stopped" });
7
+ }
8
+ static async create(options) {
9
+ const instance = new SideCarManager(options);
10
+ for (const record of await instance.loadConfiguredSideCars()) {
11
+ instance.configs.set(record.id, record);
12
+ instance.runtime.set(record.id, { status: "stopped" });
10
13
  }
11
14
  queueMicrotask(() => {
12
- for (const record of this.configs.values()) {
13
- void this.refreshPortSideCar(record.id).catch((error) => {
14
- this.options.logger.warn({ sidecarId: record.id, err: error }, "Failed to probe sidecar port");
15
+ for (const record of instance.configs.values()) {
16
+ void instance.refreshPortSideCar(record.id).catch((error) => {
17
+ instance.options.logger.warn({ sidecarId: record.id, err: error }, "Failed to probe sidecar port");
15
18
  });
16
19
  }
17
20
  });
21
+ return instance;
18
22
  }
19
23
  async list() {
20
24
  await this.refreshPortStatuses();
@@ -45,7 +49,7 @@ export class SideCarManager {
45
49
  };
46
50
  this.configs.set(record.id, record);
47
51
  this.runtime.set(record.id, { status: "stopped" });
48
- this.persistConfigs();
52
+ await this.persistConfigs();
49
53
  await this.refreshPortSideCar(record.id);
50
54
  return this.toSideCar(record);
51
55
  }
@@ -56,7 +60,7 @@ export class SideCarManager {
56
60
  record.insecure = typeof input.insecure === "boolean" ? input.insecure : record.insecure;
57
61
  record.prefixMode = typeof input.prefixMode === "string" ? input.prefixMode : record.prefixMode;
58
62
  record.updatedAt = new Date().toISOString();
59
- this.persistConfigs();
63
+ await this.persistConfigs();
60
64
  await this.refreshPortSideCar(id);
61
65
  return this.toSideCar(record);
62
66
  }
@@ -66,7 +70,7 @@ export class SideCarManager {
66
70
  return false;
67
71
  this.configs.delete(id);
68
72
  this.runtime.delete(id);
69
- this.persistConfigs();
73
+ await this.persistConfigs();
70
74
  this.options.eventBus.publish({ type: "sidecar.removed", sidecarId: id });
71
75
  return true;
72
76
  }
@@ -140,12 +144,12 @@ export class SideCarManager {
140
144
  }
141
145
  return record;
142
146
  }
143
- persistConfigs() {
147
+ async persistConfigs() {
144
148
  const sidecars = Array.from(this.configs.values()).map((record) => ({ ...record }));
145
- this.options.settings.mergePatchOwner("config", "server", { sidecars });
149
+ await this.options.settings.mergePatchOwner("config", "server", { sidecars });
146
150
  }
147
- loadConfiguredSideCars() {
148
- const serverConfig = this.options.settings.getOwner("config", "server");
151
+ async loadConfiguredSideCars() {
152
+ const serverConfig = await this.options.settings.getOwner("config", "server");
149
153
  const list = Array.isArray(serverConfig?.sidecars) ? serverConfig.sidecars : [];
150
154
  const records = [];
151
155
  for (const item of list) {
@@ -23,27 +23,27 @@ export class SpeechService {
23
23
  this.settings = settings;
24
24
  this.logger = logger;
25
25
  }
26
- getCapabilities() {
27
- return this.createProvider().getCapabilities();
26
+ async getCapabilities() {
27
+ return (await this.createProvider()).getCapabilities();
28
28
  }
29
29
  async transcribe(input) {
30
- return this.createProvider().transcribe(input);
30
+ return (await this.createProvider()).transcribe(input);
31
31
  }
32
32
  async synthesize(input) {
33
- return this.createProvider().synthesize(input);
33
+ return (await this.createProvider()).synthesize(input);
34
34
  }
35
35
  async synthesizeStream(input) {
36
- return this.createProvider().synthesizeStream(input);
36
+ return (await this.createProvider()).synthesizeStream(input);
37
37
  }
38
- createProvider() {
39
- const settings = this.resolveSettings();
38
+ async createProvider() {
39
+ const settings = await this.resolveSettings();
40
40
  return new OpenAICompatibleSpeechProvider({
41
41
  settings,
42
42
  logger: this.logger.child({ provider: settings.provider }),
43
43
  });
44
44
  }
45
- resolveSettings() {
46
- const parsed = ServerSpeechSettingsSchema.parse(this.settings.getOwner("config", "server") ?? {});
45
+ async resolveSettings() {
46
+ const parsed = ServerSpeechSettingsSchema.parse(await this.settings.getOwner("config", "server") ?? {});
47
47
  const speech = parsed.speech ?? {};
48
48
  return {
49
49
  provider: speech.provider?.trim() || DEFAULT_PROVIDER,
@@ -3,10 +3,12 @@ import { Agent as UndiciAgent } from "undici";
3
3
  const INSTANCE_HOST = "127.0.0.1";
4
4
  const STREAM_AGENT = new UndiciAgent({ bodyTimeout: 0, headersTimeout: 0 });
5
5
  const RECONNECT_DELAY_MS = 1000;
6
+ const LOG_THROTTLE_MS = 50;
6
7
  export class InstanceEventBridge {
7
8
  constructor(options) {
8
9
  this.options = options;
9
10
  this.streams = new Map();
11
+ this.lastLogTime = new Map();
10
12
  const bus = this.options.eventBus;
11
13
  bus.on("workspace.started", (event) => this.startStream(event.workspace.id));
12
14
  bus.on("workspace.stopped", (event) => this.stopStream(event.workspaceId, "workspace stopped"));
@@ -46,6 +48,7 @@ export class InstanceEventBridge {
46
48
  }
47
49
  active.controller.abort();
48
50
  this.streams.delete(workspaceId);
51
+ this.lastLogTime.delete(workspaceId);
49
52
  this.publishStatus(workspaceId, "disconnected", reason);
50
53
  }
51
54
  async runStream(workspaceId, signal) {
@@ -151,6 +154,14 @@ export class InstanceEventBridge {
151
154
  if (this.options.logger.isLevelEnabled("trace")) {
152
155
  this.options.logger.trace({ workspaceId, event }, "Instance SSE event payload");
153
156
  }
157
+ if (event.type === "workspace.log") {
158
+ const now = Date.now();
159
+ const last = this.lastLogTime.get(workspaceId) ?? 0;
160
+ if (now - last < LOG_THROTTLE_MS) {
161
+ return;
162
+ }
163
+ this.lastLogTime.set(workspaceId, now);
164
+ }
154
165
  this.options.eventBus.publish({ type: "instance.event", instanceId: workspaceId, event });
155
166
  }
156
167
  catch (error) {
@@ -1,6 +1,8 @@
1
1
  import path from "path";
2
- import { spawnSync } from "child_process";
2
+ import { execFile } from "node:child_process";
3
+ import { promisify } from "node:util";
3
4
  import { connect } from "net";
5
+ const execFileAsync = promisify(execFile);
4
6
  import { FileSystemBrowser } from "../filesystem/browser";
5
7
  import { searchWorkspaceFiles } from "../filesystem/search";
6
8
  import { clearWorkspaceSearchCache } from "../filesystem/search-cache";
@@ -9,10 +11,30 @@ import { getOpencodeConfigDir } from "../opencode-config.js";
9
11
  import { OPENCODE_SERVER_BASE_URL_ENV, buildOpencodeBasicAuthHeader, OPENCODE_SERVER_PASSWORD_ENV, OPENCODE_SERVER_USERNAME_ENV, resolveOpencodeServerAuth, } from "./opencode-auth";
10
12
  const STARTUP_STABILITY_DELAY_MS = 1500;
11
13
  export class WorkspaceManager {
14
+ async acquireStartupSlot() {
15
+ if (this.activeStartups < this.MAX_CONCURRENT_STARTUPS) {
16
+ this.activeStartups++;
17
+ return;
18
+ }
19
+ return new Promise((resolve) => {
20
+ this.startupQueue.push(resolve);
21
+ });
22
+ }
23
+ releaseStartupSlot() {
24
+ this.activeStartups--;
25
+ const next = this.startupQueue.shift();
26
+ if (next) {
27
+ this.activeStartups++;
28
+ next();
29
+ }
30
+ }
12
31
  constructor(options) {
13
32
  this.options = options;
14
33
  this.workspaces = new Map();
15
34
  this.opencodeAuth = new Map();
35
+ this.MAX_CONCURRENT_STARTUPS = 3;
36
+ this.activeStartups = 0;
37
+ this.startupQueue = [];
16
38
  this.runtime = new WorkspaceRuntime(this.options.eventBus, this.options.logger);
17
39
  this.opencodeConfigDir = getOpencodeConfigDir();
18
40
  }
@@ -33,7 +55,7 @@ export class WorkspaceManager {
33
55
  const browser = new FileSystemBrowser({ rootDir: workspace.path });
34
56
  return browser.list(relativePath);
35
57
  }
36
- searchFiles(workspaceId, query, options) {
58
+ async searchFiles(workspaceId, query, options) {
37
59
  const workspace = this.requireWorkspace(workspaceId);
38
60
  return searchWorkspaceFiles(workspace.path, query, options);
39
61
  }
@@ -53,9 +75,10 @@ export class WorkspaceManager {
53
75
  browser.writeFile(relativePath, contents);
54
76
  }
55
77
  async create(folder, name) {
78
+ await this.acquireStartupSlot();
56
79
  const id = `${Date.now().toString(36)}`;
57
- const binary = this.options.binaryResolver.resolveDefault();
58
- const resolvedBinaryPath = this.resolveBinaryPath(binary.path);
80
+ const binary = await this.options.binaryResolver.resolveDefault();
81
+ const resolvedBinaryPath = await this.resolveBinaryPath(binary.path);
59
82
  const workspacePath = path.isAbsolute(folder) ? folder : path.resolve(this.options.rootDir, folder);
60
83
  clearWorkspaceSearchCache(workspacePath);
61
84
  this.options.logger.info({ workspaceId: id, folder: workspacePath, binary: resolvedBinaryPath }, "Creating workspace");
@@ -74,7 +97,7 @@ export class WorkspaceManager {
74
97
  };
75
98
  this.workspaces.set(id, descriptor);
76
99
  this.options.eventBus.publish({ type: "workspace.created", workspace: descriptor });
77
- const serverConfig = this.options.settings.getOwner("config", "server");
100
+ const serverConfig = await this.options.settings.getOwner("config", "server");
78
101
  const envVars = serverConfig?.environmentVariables;
79
102
  const userEnvironment = envVars && typeof envVars === "object" && !Array.isArray(envVars) ? envVars : {};
80
103
  const serverBaseUrl = this.options.getServerBaseUrl();
@@ -128,6 +151,9 @@ export class WorkspaceManager {
128
151
  this.options.logger.error({ workspaceId: id, err: error }, "Workspace failed to start");
129
152
  throw error;
130
153
  }
154
+ finally {
155
+ this.releaseStartupSlot();
156
+ }
131
157
  }
132
158
  async delete(id) {
133
159
  const workspace = this.workspaces.get(id);
@@ -175,7 +201,7 @@ export class WorkspaceManager {
175
201
  }
176
202
  return workspace;
177
203
  }
178
- resolveBinaryPath(identifier) {
204
+ async resolveBinaryPath(identifier) {
179
205
  if (!identifier) {
180
206
  return identifier;
181
207
  }
@@ -185,9 +211,23 @@ export class WorkspaceManager {
185
211
  }
186
212
  const locator = process.platform === "win32" ? "where" : "which";
187
213
  try {
188
- const result = spawnSync(locator, [identifier], { encoding: "utf8" });
189
- if (result.status === 0 && result.stdout) {
190
- const candidates = result.stdout
214
+ let stdout;
215
+ let stderr;
216
+ try {
217
+ const result = await execFileAsync(locator, [identifier], { encoding: "utf8" });
218
+ stdout = result.stdout;
219
+ stderr = result.stderr;
220
+ }
221
+ catch (err) {
222
+ stdout = err?.stdout ?? "";
223
+ stderr = err?.stderr ?? "";
224
+ if (!stdout && err?.code === "ENOENT") {
225
+ this.options.logger.warn({ identifier, err }, "Failed to resolve binary path via locator command");
226
+ return identifier;
227
+ }
228
+ }
229
+ if (stdout) {
230
+ const candidates = stdout
191
231
  .split(/\r?\n/)
192
232
  .map((line) => line.trim())
193
233
  .filter((line) => line.length > 0)
@@ -198,8 +238,8 @@ export class WorkspaceManager {
198
238
  return resolved;
199
239
  }
200
240
  }
201
- else if (result.error) {
202
- this.options.logger.warn({ identifier, err: result.error }, "Failed to resolve binary path via locator command");
241
+ if (stderr) {
242
+ this.options.logger.warn({ identifier, stderr }, "Locator command reported errors");
203
243
  }
204
244
  }
205
245
  catch (error) {
@@ -1,5 +1,6 @@
1
1
  import { spawn, spawnSync } from "child_process";
2
- import { existsSync, statSync } from "fs";
2
+ import { access, stat } from "node:fs/promises";
3
+ import { constants } from "node:fs";
3
4
  import path from "path";
4
5
  import { buildSpawnSpec, buildWslSignalSpec } from "./spawn";
5
6
  const SENSITIVE_ENV_KEY = /(PASSWORD|TOKEN|SECRET)/i;
@@ -22,7 +23,7 @@ export class WorkspaceRuntime {
22
23
  this.processes = new Map();
23
24
  }
24
25
  async launch(options) {
25
- this.validateFolder(options.folder);
26
+ await this.validateFolder(options.folder);
26
27
  const logLevel = typeof options.logLevel === "string" ? options.logLevel.toUpperCase() : "DEBUG";
27
28
  const args = ["serve", "--port", "0", "--print-logs", "--log-level", logLevel];
28
29
  const env = { ...process.env, ...(options.environment ?? {}) };
@@ -353,12 +354,15 @@ export class WorkspaceRuntime {
353
354
  };
354
355
  this.eventBus.publish({ type: "workspace.log", entry });
355
356
  }
356
- validateFolder(folder) {
357
+ async validateFolder(folder) {
357
358
  const resolved = path.resolve(folder);
358
- if (!existsSync(resolved)) {
359
+ try {
360
+ await access(resolved, constants.F_OK);
361
+ }
362
+ catch {
359
363
  throw new Error(`Folder does not exist: ${resolved}`);
360
364
  }
361
- const stats = statSync(resolved);
365
+ const stats = await stat(resolved);
362
366
  if (!stats.isDirectory()) {
363
367
  throw new Error(`Path is not a directory: ${resolved}`);
364
368
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dyyz1993/codenomad",
3
- "version": "0.15.1",
3
+ "version": "0.15.3",
4
4
  "description": "CodeNomad Server",
5
5
  "license": "MIT",
6
6
  "author": {
package/public/index.html CHANGED
@@ -30,8 +30,8 @@
30
30
  <link rel="modulepreload" crossorigin href="/assets/git-diff-vendor-CSgooKT_.js">
31
31
  <link rel="modulepreload" crossorigin href="/assets/monaco-viewer-YY9yfE-p.js">
32
32
  <link rel="modulepreload" crossorigin href="/assets/index-BCGPLzO4.js">
33
- <link rel="stylesheet" crossorigin href="/assets/git-diff-vendor-HAZkIolJ.css">
34
33
  <link rel="stylesheet" crossorigin href="/assets/index-DXI9DoVB.css">
34
+ <link rel="stylesheet" crossorigin href="/assets/git-diff-vendor-HAZkIolJ.css">
35
35
  <link rel="manifest" href="/manifest.webmanifest"><script id="vite-plugin-pwa:register-sw" src="/registerSW.js"></script>
36
36
  <meta name="theme-color" content="#1a1a1a">
37
37
  <link rel="icon" href="/favicon.ico" sizes="48x48">
@@ -20,8 +20,8 @@
20
20
  <link rel="modulepreload" crossorigin href="/assets/git-diff-vendor-CSgooKT_.js">
21
21
  <link rel="modulepreload" crossorigin href="/assets/monaco-viewer-YY9yfE-p.js">
22
22
  <link rel="modulepreload" crossorigin href="/assets/index-BCGPLzO4.js">
23
- <link rel="stylesheet" crossorigin href="/assets/index-DXI9DoVB.css">
24
23
  <link rel="stylesheet" crossorigin href="/assets/git-diff-vendor-HAZkIolJ.css">
24
+ <link rel="stylesheet" crossorigin href="/assets/index-DXI9DoVB.css">
25
25
  <link rel="stylesheet" crossorigin href="/assets/loading-CmEVQgyj.css">
26
26
  <link rel="manifest" href="/manifest.webmanifest"><script id="vite-plugin-pwa:register-sw" src="/registerSW.js"></script>
27
27
  <meta name="theme-color" content="#1a1a1a">