nexus-agentd 0.3.1 → 0.4.0

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.
@@ -35,6 +35,7 @@ export class ArtifactStore {
35
35
  pending = new Map();
36
36
  inFlight = new Map();
37
37
  pendingDeletes = new Set();
38
+ pendingManifests = new Set();
38
39
  queuedBytes = 0;
39
40
  totalBytes = 0;
40
41
  activeDrain;
@@ -261,11 +262,49 @@ export class ArtifactStore {
261
262
  this.entries.set(entryKey(runId, normalized.id), entry);
262
263
  byId.set(normalized.id, next);
263
264
  }
264
- const views = Array.from(byId.values()).slice(0, this.maxArtifactsPerRun);
265
+ this.trimRunEntries(runId);
266
+ const views = this.viewsForRun(runId);
265
267
  this.emit(runId, views);
266
268
  this.scheduleDrain();
267
269
  return structuredClone(views);
268
270
  }
271
+ /**
272
+ * Caps how many artifacts a single run retains. `recordArtifacts` is
273
+ * incremental, so a protocol stream that reports a fresh id per event
274
+ * (screenshots, timestamped logs) would otherwise grow `entries` without
275
+ * bound and make every emit clone the whole run.
276
+ */
277
+ trimRunEntries(runId) {
278
+ const entries = Array.from(this.entries.values())
279
+ .filter((entry) => entry.runId === runId)
280
+ .sort((left, right) => left.view.createdAt - right.view.createdAt);
281
+ let excess = entries.length - this.maxArtifactsPerRun;
282
+ if (excess <= 0)
283
+ return;
284
+ for (const entry of entries) {
285
+ if (excess <= 0)
286
+ break;
287
+ // An in-flight payload cannot be unlinked while it is being
288
+ // written; it is the newest entry in practice, so a later call
289
+ // trims it instead.
290
+ if (this.inFlight.has(entryKey(runId, entry.id)))
291
+ continue;
292
+ this.dropEntry(entry);
293
+ excess -= 1;
294
+ }
295
+ }
296
+ dropEntry(entry) {
297
+ this.entries.delete(entryKey(entry.runId, entry.id));
298
+ this.removePending(entry.runId, entry.id);
299
+ this.totalBytes -= entry.bytes || 0;
300
+ if (entry.filePath) {
301
+ const filePath = entry.filePath;
302
+ void this.safeUnlink(filePath).catch(() => undefined);
303
+ }
304
+ // The manifest still lists the dropped entry; rewrite it on the next
305
+ // drain so a restart cannot resurrect an artifact with no payload.
306
+ this.pendingManifests.add(entry.runId);
307
+ }
269
308
  /** Merge display metadata from a RunStore update without dropping storage state. */
270
309
  mergeViews(runId, views, notify = true) {
271
310
  this.assertInitialized();
@@ -356,6 +395,7 @@ export class ArtifactStore {
356
395
  this.queuedBytes -= item.bytes.length;
357
396
  this.pending.delete(runId);
358
397
  }
398
+ this.pendingManifests.delete(runId);
359
399
  this.pendingDeletes.add(runId);
360
400
  this.scheduleDrain();
361
401
  }
@@ -393,7 +433,7 @@ export class ArtifactStore {
393
433
  this.assertInitialized();
394
434
  if (this.activeDrain)
395
435
  await this.activeDrain;
396
- if (this.pending.size || this.pendingDeletes.size) {
436
+ if (this.pending.size || this.pendingDeletes.size || this.pendingManifests.size) {
397
437
  // A previous background attempt may have failed. Keep the
398
438
  // snapshot dirty and let an explicit flush retry it.
399
439
  this.lastError = undefined;
@@ -454,11 +494,28 @@ export class ArtifactStore {
454
494
  }
455
495
  async drainLoop() {
456
496
  this.lastError = undefined;
457
- while (this.pending.size || this.pendingDeletes.size) {
497
+ while (this.pending.size || this.pendingDeletes.size || this.pendingManifests.size) {
458
498
  const runId = this.pendingDeletes.values().next().value;
459
499
  if (runId) {
460
500
  this.pendingDeletes.delete(runId);
461
- await this.deleteRunDirectory(runId);
501
+ try {
502
+ await this.deleteRunDirectory(runId);
503
+ }
504
+ catch (error) {
505
+ // Keep the delete queued: dropping it would leave the
506
+ // directory on disk with no record left to remove it.
507
+ this.pendingDeletes.add(runId);
508
+ throw error;
509
+ }
510
+ continue;
511
+ }
512
+ const manifestRunId = this.pendingManifests.values().next().value;
513
+ if (manifestRunId) {
514
+ this.pendingManifests.delete(manifestRunId);
515
+ // removeRun drops the whole directory; rewriting a manifest
516
+ // afterwards would recreate it.
517
+ if (this.runIds.has(manifestRunId))
518
+ await this.writeManifest(manifestRunId);
462
519
  continue;
463
520
  }
464
521
  const batchEntry = this.pending.entries().next().value;
@@ -498,8 +555,13 @@ export class ArtifactStore {
498
555
  const startedAt = performance.now();
499
556
  const key = entryKey(item.runId, item.id);
500
557
  let entry = this.entries.get(key);
501
- if (!entry)
558
+ if (!entry) {
559
+ // The entry was dropped after this batch left `pending`, so
560
+ // removeRun and dropEntry could no longer see it there. Releasing
561
+ // the budget here is the only remaining place that can.
562
+ this.queuedBytes -= item.bytes.length;
502
563
  return;
564
+ }
503
565
  const runDir = this.runDirectory(item.runId);
504
566
  await this.io.mkdir(runDir, { recursive: true, mode: 0o700 });
505
567
  await this.ensureDirectory(runDir, false);
@@ -1,23 +1,45 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  const DEFAULT_GRACE_MS = 2_000;
3
3
  const FORCE_KILL_WAIT_MS = 1_000;
4
+ // Enumerating costs about a second on an idle machine and considerably more
5
+ // when the gateway is busy. The sweep runs on every teardown — including a
6
+ // failed ACP startup, which callers bound at five seconds — so it is capped
7
+ // short: an incomplete sweep beats a teardown that never returns.
8
+ const PROCESS_TABLE_TIMEOUT_MS = 1_200;
9
+ const PROCESS_TABLE_TTL_MS = 1_000;
10
+ // Windows reports a killed process as gone from `tasklist` slightly before the
11
+ // kernel closes the handles it held, so callers that delete the workspace or
12
+ // temp directory right after a teardown can still see EBUSY.
13
+ const PROCESS_EXIT_SETTLE_MS = 150;
14
+ let processTableCache;
4
15
  export async function terminateProcessTree(child, options = {}) {
5
16
  const pid = child.pid;
6
17
  if (!pid)
7
18
  return;
8
19
  const graceMs = Math.max(0, options.graceMs ?? DEFAULT_GRACE_MS);
9
20
  if (process.platform === 'win32') {
10
- // Once a Windows process has exited taskkill can no longer address its
11
- // descendants by the parent's PID. Callers should tear down before
12
- // exit when they need Windows tree semantics.
13
- if (hasExited(child))
14
- return;
15
- await taskkill(pid, false);
16
- if (await waitForExit(child, graceMs))
17
- return;
18
- await taskkill(pid, true);
19
- if (await waitForExit(child, FORCE_KILL_WAIT_MS))
21
+ // `taskkill` without /F only posts WM_CLOSE, which console processes
22
+ // never handle: the graceful pass always fails and burns the whole
23
+ // grace period before the forced retry. Force the tree kill directly.
24
+ if (!hasExited(child)) {
25
+ await taskkill(pid, true);
26
+ if (!(await waitForExit(child, Math.max(graceMs, FORCE_KILL_WAIT_MS)))) {
27
+ // A forced kill can still lose the race on a loaded machine,
28
+ // where the process takes longer than the wait to disappear.
29
+ await taskkill(pid, true);
30
+ await waitForExit(child, FORCE_KILL_WAIT_MS);
31
+ }
32
+ }
33
+ // A forced `taskkill /T` walks the tree as it exists at that instant,
34
+ // so a child spawned moments before the leader died survives as an
35
+ // orphan holding ports, file locks and inherited pipes — and a leader
36
+ // that exited before we got here cannot be addressed by taskkill at
37
+ // all. Sweep by parent id in both cases.
38
+ await terminateOrphanedDescendants(pid);
39
+ if (hasExited(child)) {
40
+ await delay(PROCESS_EXIT_SETTLE_MS);
20
41
  return;
42
+ }
21
43
  throw new Error(`Failed to terminate process ${pid}`);
22
44
  }
23
45
  if (options.ownsProcessGroup === true) {
@@ -106,6 +128,111 @@ function taskkill(pid, force) {
106
128
  killer.once('exit', finish);
107
129
  });
108
130
  }
131
+ /**
132
+ * Kills every process that still records `pid` as an ancestor.
133
+ *
134
+ * `taskkill /T` walks the live process tree, so it cannot reach children once
135
+ * their parent has exited. Windows keeps the original parent id on the child
136
+ * after that, which is enough to find the survivors. Best-effort by design: an
137
+ * unreadable process table just leaves the sweep incomplete.
138
+ */
139
+ async function terminateOrphanedDescendants(pid) {
140
+ const doomed = collectDescendants(await windowsProcessTable(), pid);
141
+ if (!doomed.length)
142
+ return;
143
+ for (const target of doomed)
144
+ await taskkill(target, true);
145
+ // Killing a parent can strand its own children, so rescan for what is left.
146
+ const remaining = collectDescendants(await windowsProcessTable(true), pid);
147
+ for (const survivor of remaining)
148
+ await taskkill(survivor, true);
149
+ }
150
+ function collectDescendants(table, root) {
151
+ if (!table)
152
+ return [];
153
+ const found = [];
154
+ const pending = [...(table.get(root) || [])];
155
+ while (pending.length) {
156
+ const next = pending.shift();
157
+ if (found.includes(next))
158
+ continue;
159
+ found.push(next);
160
+ pending.push(...(table.get(next) || []));
161
+ }
162
+ return found;
163
+ }
164
+ /**
165
+ * Snapshots the Windows process table as parent id -> child ids. Enumerating
166
+ * costs about a second, and a teardown that arrives alongside others (probing
167
+ * every driver at startup, shutting a session down) asks the same question, so
168
+ * concurrent callers share one snapshot. `refresh` bypasses it when the answer
169
+ * has to describe the state after a kill.
170
+ */
171
+ function windowsProcessTable(refresh = false) {
172
+ const now = Date.now();
173
+ if (!refresh && processTableCache && now - processTableCache.at < PROCESS_TABLE_TTL_MS) {
174
+ return processTableCache.promise;
175
+ }
176
+ const promise = snapshotProcessTable();
177
+ processTableCache = { at: now, promise };
178
+ return promise;
179
+ }
180
+ function snapshotProcessTable() {
181
+ return new Promise((resolve) => {
182
+ let lister;
183
+ try {
184
+ lister = spawn('powershell.exe', [
185
+ '-NoProfile',
186
+ '-NonInteractive',
187
+ '-Command',
188
+ 'Get-CimInstance Win32_Process | ForEach-Object { "$($_.ProcessId) $($_.ParentProcessId)" }'
189
+ ], { stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true });
190
+ }
191
+ catch {
192
+ resolve(undefined);
193
+ return;
194
+ }
195
+ let output = '';
196
+ let settled = false;
197
+ const finish = (value) => {
198
+ if (settled)
199
+ return;
200
+ settled = true;
201
+ clearTimeout(timer);
202
+ resolve(value);
203
+ };
204
+ const timer = setTimeout(() => {
205
+ try {
206
+ lister.kill();
207
+ }
208
+ catch { }
209
+ finish(undefined);
210
+ }, PROCESS_TABLE_TIMEOUT_MS);
211
+ lister.stdout?.on('data', (chunk) => {
212
+ output = `${output}${String(chunk)}`.slice(-4_000_000);
213
+ });
214
+ lister.once('error', () => finish(undefined));
215
+ lister.once('exit', (code) => {
216
+ finish(code === 0 ? parseProcessTable(output) : undefined);
217
+ });
218
+ });
219
+ }
220
+ function parseProcessTable(output) {
221
+ const table = new Map();
222
+ for (const line of output.split(/\r?\n/)) {
223
+ const match = /^(\d+)\s+(\d+)$/.exec(line.trim());
224
+ if (!match)
225
+ continue;
226
+ const child = Number(match[1]);
227
+ const parent = Number(match[2]);
228
+ const siblings = table.get(parent);
229
+ if (siblings)
230
+ siblings.push(child);
231
+ else
232
+ table.set(parent, [child]);
233
+ }
234
+ return table;
235
+ }
109
236
  function delay(ms) {
110
237
  return new Promise((resolve) => setTimeout(resolve, ms));
111
238
  }
package/dist/run-store.js CHANGED
@@ -442,16 +442,31 @@ export class RunStore {
442
442
  const run = this.runs.get(runId);
443
443
  if (!run)
444
444
  return;
445
- run.artifacts = structuredClone(views);
446
- run.artifactCount = run.artifacts.length;
447
- run.updatedAt = this.now();
448
445
  if (!this.initialized) {
446
+ // The artifact store prunes during its own init and emits as it
447
+ // goes, but at that point it only knows about manifest-backed
448
+ // entries — the rest of runs.json has not been restored yet.
449
+ // Replacing wholesale would drop metadata-only artifacts for good,
450
+ // while skipping the update would lose the storage status the
451
+ // prune just decided (expired/evicted), which restoreRun then
452
+ // misreads as metadata_only. Merge both sides instead.
453
+ run.artifacts = mergeArtifactViews(run.artifacts, views);
454
+ run.artifactCount = run.artifacts.length;
449
455
  this.dirty = true;
450
456
  return;
451
457
  }
458
+ run.artifacts = structuredClone(views);
459
+ run.artifactCount = run.artifacts.length;
460
+ run.updatedAt = this.now();
452
461
  this.changed();
453
462
  }
454
463
  }
464
+ function mergeArtifactViews(existing, incoming) {
465
+ const merged = new Map(existing.map((view) => [view.id, view]));
466
+ for (const view of incoming)
467
+ merged.set(view.id, view);
468
+ return Array.from(merged.values());
469
+ }
455
470
  export function runStorePathForConfig(configPath) {
456
471
  const absolute = path.resolve(configPath);
457
472
  const extension = path.extname(absolute);
package/dist/session.js CHANGED
@@ -936,9 +936,46 @@ function mediaTypeForPath(path) {
936
936
  case '.txt':
937
937
  case '.log':
938
938
  case '.md':
939
+ case '.markdown':
940
+ case '.conf':
941
+ case '.ini':
942
+ case '.toml':
943
+ case '.env':
939
944
  return 'text/plain';
940
945
  case '.json':
941
946
  return 'application/json';
947
+ case '.html':
948
+ case '.htm':
949
+ return 'text/html';
950
+ case '.css':
951
+ return 'text/css';
952
+ case '.js':
953
+ case '.mjs':
954
+ case '.cjs':
955
+ return 'text/javascript';
956
+ case '.ts':
957
+ case '.tsx':
958
+ case '.jsx':
959
+ case '.py':
960
+ case '.rb':
961
+ case '.go':
962
+ case '.rs':
963
+ case '.java':
964
+ case '.c':
965
+ case '.h':
966
+ case '.cpp':
967
+ case '.sh':
968
+ case '.bash':
969
+ case '.ps1':
970
+ case '.sql':
971
+ return 'text/plain';
972
+ case '.yaml':
973
+ case '.yml':
974
+ return 'text/yaml';
975
+ case '.xml':
976
+ return 'application/xml';
977
+ case '.csv':
978
+ return 'text/csv';
942
979
  case '.pdf':
943
980
  return 'application/pdf';
944
981
  case '.png':
@@ -952,10 +989,41 @@ function mediaTypeForPath(path) {
952
989
  return 'image/webp';
953
990
  case '.svg':
954
991
  return 'image/svg+xml';
955
- case '.csv':
956
- return 'text/csv';
992
+ case '.bmp':
993
+ return 'image/bmp';
994
+ case '.ico':
995
+ return 'image/x-icon';
996
+ case '.wav':
997
+ return 'audio/wav';
998
+ case '.mp3':
999
+ return 'audio/mpeg';
1000
+ case '.ogg':
1001
+ case '.oga':
1002
+ return 'audio/ogg';
1003
+ case '.m4a':
1004
+ return 'audio/mp4';
1005
+ case '.aac':
1006
+ return 'audio/aac';
1007
+ case '.flac':
1008
+ return 'audio/flac';
1009
+ case '.mp4':
1010
+ return 'video/mp4';
1011
+ case '.webm':
1012
+ return 'video/webm';
1013
+ case '.mov':
1014
+ return 'video/quicktime';
1015
+ case '.mkv':
1016
+ return 'video/x-matroska';
957
1017
  case '.zip':
958
1018
  return 'application/zip';
1019
+ case '.doc':
1020
+ return 'application/msword';
1021
+ case '.docx':
1022
+ return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
1023
+ case '.xlsx':
1024
+ return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
1025
+ case '.pptx':
1026
+ return 'application/vnd.openxmlformats-officedocument.presentationml.presentation';
959
1027
  default:
960
1028
  return 'application/octet-stream';
961
1029
  }