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.
- package/README.md +201 -143
- package/dist/a2a/runtime.js +9 -4
- package/dist/artifact-store.d.ts +9 -0
- package/dist/artifact-store.js +67 -5
- package/dist/process-tree.js +137 -10
- package/dist/run-store.js +18 -3
- package/dist/session.js +70 -2
- package/dist/webui/app/sources.js +4 -4
- package/dist/webui/styles.js +12 -24
- package/package.json +2 -2
package/dist/artifact-store.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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);
|
package/dist/process-tree.js
CHANGED
|
@@ -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
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
if (hasExited(child))
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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 '.
|
|
956
|
-
return '
|
|
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
|
}
|