nexus-agentd 0.3.0 → 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/LICENSE +22 -22
- package/README.md +201 -143
- package/dist/a2a/runtime.js +9 -4
- package/dist/artifact-store.d.ts +15 -0
- package/dist/artifact-store.js +97 -5
- package/dist/process-tree.js +137 -10
- package/dist/run-store.d.ts +38 -0
- package/dist/run-store.js +94 -3
- package/dist/server.js +55 -1
- package/dist/session/admin-runs.js +1 -0
- package/dist/session.d.ts +7 -1
- package/dist/session.js +82 -2
- package/dist/types.d.ts +2 -0
- package/dist/webui/app/sources.js +13 -12
- package/dist/webui/icons.d.ts +1 -0
- package/dist/webui/icons.js +1 -0
- package/dist/webui/index.js +11 -11
- package/dist/webui/markup.js +1 -0
- package/dist/webui/styles.js +64 -31
- package/nexus-agentd.example.json +38 -38
- 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,14 +395,45 @@ 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
|
}
|
|
402
|
+
/**
|
|
403
|
+
* Delete a single artifact: drop its payload file and record entry. The
|
|
404
|
+
* run record itself stays; its artifacts list shrinks via the views
|
|
405
|
+
* callback. Refused while the payload write is still in flight.
|
|
406
|
+
*/
|
|
407
|
+
async removeArtifact(runId, artifactId) {
|
|
408
|
+
this.assertInitialized();
|
|
409
|
+
const key = entryKey(runId, artifactId);
|
|
410
|
+
const entry = this.entries.get(key);
|
|
411
|
+
if (!entry)
|
|
412
|
+
return false;
|
|
413
|
+
if (this.inFlight.has(key))
|
|
414
|
+
return false;
|
|
415
|
+
const batch = this.pending.get(runId);
|
|
416
|
+
const queued = batch?.get(artifactId);
|
|
417
|
+
if (queued) {
|
|
418
|
+
this.queuedBytes -= queued.bytes.length;
|
|
419
|
+
batch.delete(artifactId);
|
|
420
|
+
if (!batch.size)
|
|
421
|
+
this.pending.delete(runId);
|
|
422
|
+
}
|
|
423
|
+
if (entry.filePath) {
|
|
424
|
+
await this.safeUnlink(entry.filePath);
|
|
425
|
+
this.totalBytes -= entry.bytes || 0;
|
|
426
|
+
}
|
|
427
|
+
this.entries.delete(key);
|
|
428
|
+
await this.writeManifest(runId);
|
|
429
|
+
this.emit(runId, this.viewsForRun(runId));
|
|
430
|
+
return true;
|
|
431
|
+
}
|
|
362
432
|
async flush() {
|
|
363
433
|
this.assertInitialized();
|
|
364
434
|
if (this.activeDrain)
|
|
365
435
|
await this.activeDrain;
|
|
366
|
-
if (this.pending.size || this.pendingDeletes.size) {
|
|
436
|
+
if (this.pending.size || this.pendingDeletes.size || this.pendingManifests.size) {
|
|
367
437
|
// A previous background attempt may have failed. Keep the
|
|
368
438
|
// snapshot dirty and let an explicit flush retry it.
|
|
369
439
|
this.lastError = undefined;
|
|
@@ -424,11 +494,28 @@ export class ArtifactStore {
|
|
|
424
494
|
}
|
|
425
495
|
async drainLoop() {
|
|
426
496
|
this.lastError = undefined;
|
|
427
|
-
while (this.pending.size || this.pendingDeletes.size) {
|
|
497
|
+
while (this.pending.size || this.pendingDeletes.size || this.pendingManifests.size) {
|
|
428
498
|
const runId = this.pendingDeletes.values().next().value;
|
|
429
499
|
if (runId) {
|
|
430
500
|
this.pendingDeletes.delete(runId);
|
|
431
|
-
|
|
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);
|
|
432
519
|
continue;
|
|
433
520
|
}
|
|
434
521
|
const batchEntry = this.pending.entries().next().value;
|
|
@@ -468,8 +555,13 @@ export class ArtifactStore {
|
|
|
468
555
|
const startedAt = performance.now();
|
|
469
556
|
const key = entryKey(item.runId, item.id);
|
|
470
557
|
let entry = this.entries.get(key);
|
|
471
|
-
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;
|
|
472
563
|
return;
|
|
564
|
+
}
|
|
473
565
|
const runDir = this.runDirectory(item.runId);
|
|
474
566
|
await this.io.mkdir(runDir, { recursive: true, mode: 0o700 });
|
|
475
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.d.ts
CHANGED
|
@@ -35,6 +35,32 @@ export interface RunListQuery {
|
|
|
35
35
|
limit?: number;
|
|
36
36
|
offset?: number;
|
|
37
37
|
}
|
|
38
|
+
export interface RunArtifactQuery {
|
|
39
|
+
agentId?: string;
|
|
40
|
+
/** 'all' keeps every kind; otherwise one of artifactKind() values. */
|
|
41
|
+
kind?: string;
|
|
42
|
+
query?: string;
|
|
43
|
+
limit?: number;
|
|
44
|
+
offset?: number;
|
|
45
|
+
}
|
|
46
|
+
export interface RunArtifactRow {
|
|
47
|
+
id: string;
|
|
48
|
+
runId: string;
|
|
49
|
+
agentId: string;
|
|
50
|
+
agentName: string;
|
|
51
|
+
runState: AgentdSessionState;
|
|
52
|
+
runStartedAt: number;
|
|
53
|
+
name?: string;
|
|
54
|
+
filename?: string;
|
|
55
|
+
mediaType?: string;
|
|
56
|
+
size?: number;
|
|
57
|
+
kind: string;
|
|
58
|
+
downloadable: boolean;
|
|
59
|
+
storageStatus?: string;
|
|
60
|
+
createdAt?: number;
|
|
61
|
+
}
|
|
62
|
+
/** Coarse bucket used by the console filter and list grouping. */
|
|
63
|
+
export declare function artifactKind(mediaType: string | undefined): "image" | "audio" | "video" | "file";
|
|
38
64
|
export declare class RunStore {
|
|
39
65
|
readonly filePath: string;
|
|
40
66
|
private readonly runs;
|
|
@@ -60,6 +86,13 @@ export declare class RunStore {
|
|
|
60
86
|
recordArtifacts(runId: string, artifacts: AgentdArtifact[]): StoredArtifactView[] | undefined;
|
|
61
87
|
artifactViews(runId: string): StoredArtifactView[] | undefined;
|
|
62
88
|
readArtifact(runId: string, artifactId: string): Promise<ArtifactReadResult | undefined>;
|
|
89
|
+
/** Flat, newest-first artifact index across all retained runs. */
|
|
90
|
+
listArtifacts(query?: RunArtifactQuery): {
|
|
91
|
+
artifacts: RunArtifactRow[];
|
|
92
|
+
total: number;
|
|
93
|
+
};
|
|
94
|
+
/** Delete one artifact's payload and record entry; the run record stays. */
|
|
95
|
+
removeArtifact(runId: string, artifactId: string): Promise<boolean>;
|
|
63
96
|
getOwnerKeyId(runId: string): string | undefined;
|
|
64
97
|
metrics(): {
|
|
65
98
|
historyBytes: number;
|
|
@@ -78,6 +111,11 @@ export declare class RunStore {
|
|
|
78
111
|
artifactStoreStats: ArtifactStoreMetrics;
|
|
79
112
|
};
|
|
80
113
|
setRetryOfRunId(runId: string, retryOfRunId?: string): AgentdRunDetail | undefined;
|
|
114
|
+
/**
|
|
115
|
+
* Remove a terminal run record and its stored artifacts. Active runs are
|
|
116
|
+
* refused so a live turn cannot lose its progress tracking.
|
|
117
|
+
*/
|
|
118
|
+
deleteRun(id: string): AgentdRunDetail | undefined;
|
|
81
119
|
get(id: string): AgentdRunDetail | undefined;
|
|
82
120
|
list(query?: RunListQuery): {
|
|
83
121
|
runs: AgentdRunView[];
|
package/dist/run-store.js
CHANGED
|
@@ -3,6 +3,17 @@ import { chmod, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promi
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { ArtifactStore, artifactStorePathForRunStore } from './artifact-store.js';
|
|
5
5
|
export { ArtifactStore, artifactStorePathForRunStore } from './artifact-store.js';
|
|
6
|
+
/** Coarse bucket used by the console filter and list grouping. */
|
|
7
|
+
export function artifactKind(mediaType) {
|
|
8
|
+
const type = String(mediaType || '').toLowerCase();
|
|
9
|
+
if (type.startsWith('image/'))
|
|
10
|
+
return 'image';
|
|
11
|
+
if (type.startsWith('video/'))
|
|
12
|
+
return 'video';
|
|
13
|
+
if (type.startsWith('audio/'))
|
|
14
|
+
return 'audio';
|
|
15
|
+
return 'file';
|
|
16
|
+
}
|
|
6
17
|
const STATES = new Set([
|
|
7
18
|
'created',
|
|
8
19
|
'running',
|
|
@@ -187,6 +198,55 @@ export class RunStore {
|
|
|
187
198
|
return undefined;
|
|
188
199
|
return this.artifactStore.readArtifact(runId, artifactId);
|
|
189
200
|
}
|
|
201
|
+
/** Flat, newest-first artifact index across all retained runs. */
|
|
202
|
+
listArtifacts(query = {}) {
|
|
203
|
+
this.assertInitialized();
|
|
204
|
+
const needle = String(query.query || '').trim().toLowerCase();
|
|
205
|
+
const limit = Math.min(200, Math.max(1, Number(query.limit) || 50));
|
|
206
|
+
const offset = Math.max(0, Math.floor(Number(query.offset) || 0));
|
|
207
|
+
const rows = [];
|
|
208
|
+
for (const run of this.runs.values()) {
|
|
209
|
+
for (const view of run.artifacts || []) {
|
|
210
|
+
if (!view.id)
|
|
211
|
+
continue;
|
|
212
|
+
if (query.agentId && run.agentId !== query.agentId)
|
|
213
|
+
continue;
|
|
214
|
+
const kind = artifactKind(view.mediaType);
|
|
215
|
+
if (query.kind && query.kind !== 'all' && kind !== query.kind)
|
|
216
|
+
continue;
|
|
217
|
+
if (needle) {
|
|
218
|
+
const haystack = `${view.name || ''} ${view.filename || ''}`.toLowerCase();
|
|
219
|
+
if (!haystack.includes(needle))
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
rows.push({
|
|
223
|
+
id: view.id,
|
|
224
|
+
runId: run.id,
|
|
225
|
+
agentId: run.agentId,
|
|
226
|
+
agentName: run.agentName,
|
|
227
|
+
runState: run.state,
|
|
228
|
+
runStartedAt: run.startedAt,
|
|
229
|
+
name: view.name,
|
|
230
|
+
filename: view.filename,
|
|
231
|
+
mediaType: view.mediaType,
|
|
232
|
+
size: view.size,
|
|
233
|
+
kind,
|
|
234
|
+
downloadable: view.downloadable === true,
|
|
235
|
+
storageStatus: view.storageStatus,
|
|
236
|
+
createdAt: view.createdAt
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
rows.sort((left, right) => (right.createdAt || 0) - (left.createdAt || 0));
|
|
241
|
+
return { artifacts: rows.slice(offset, offset + limit), total: rows.length };
|
|
242
|
+
}
|
|
243
|
+
/** Delete one artifact's payload and record entry; the run record stays. */
|
|
244
|
+
async removeArtifact(runId, artifactId) {
|
|
245
|
+
this.assertInitialized();
|
|
246
|
+
if (!this.runs.has(runId))
|
|
247
|
+
return false;
|
|
248
|
+
return this.artifactStore.removeArtifact(runId, artifactId);
|
|
249
|
+
}
|
|
190
250
|
getOwnerKeyId(runId) {
|
|
191
251
|
this.assertInitialized();
|
|
192
252
|
return this.runs.get(runId)?.ownerKeyId;
|
|
@@ -211,6 +271,22 @@ export class RunStore {
|
|
|
211
271
|
setRetryOfRunId(runId, retryOfRunId) {
|
|
212
272
|
return this.update(runId, { retryOfRunId });
|
|
213
273
|
}
|
|
274
|
+
/**
|
|
275
|
+
* Remove a terminal run record and its stored artifacts. Active runs are
|
|
276
|
+
* refused so a live turn cannot lose its progress tracking.
|
|
277
|
+
*/
|
|
278
|
+
deleteRun(id) {
|
|
279
|
+
this.assertInitialized();
|
|
280
|
+
const run = this.runs.get(id);
|
|
281
|
+
if (!run)
|
|
282
|
+
return undefined;
|
|
283
|
+
if (isActive(run.state))
|
|
284
|
+
return undefined;
|
|
285
|
+
this.runs.delete(id);
|
|
286
|
+
this.artifactStore.removeRun(id);
|
|
287
|
+
this.changed();
|
|
288
|
+
return publicDetail(run);
|
|
289
|
+
}
|
|
214
290
|
get(id) {
|
|
215
291
|
this.assertInitialized();
|
|
216
292
|
const run = this.runs.get(id);
|
|
@@ -366,16 +442,31 @@ export class RunStore {
|
|
|
366
442
|
const run = this.runs.get(runId);
|
|
367
443
|
if (!run)
|
|
368
444
|
return;
|
|
369
|
-
run.artifacts = structuredClone(views);
|
|
370
|
-
run.artifactCount = run.artifacts.length;
|
|
371
|
-
run.updatedAt = this.now();
|
|
372
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;
|
|
373
455
|
this.dirty = true;
|
|
374
456
|
return;
|
|
375
457
|
}
|
|
458
|
+
run.artifacts = structuredClone(views);
|
|
459
|
+
run.artifactCount = run.artifacts.length;
|
|
460
|
+
run.updatedAt = this.now();
|
|
376
461
|
this.changed();
|
|
377
462
|
}
|
|
378
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
|
+
}
|
|
379
470
|
export function runStorePathForConfig(configPath) {
|
|
380
471
|
const absolute = path.resolve(configPath);
|
|
381
472
|
const extension = path.extname(absolute);
|
package/dist/server.js
CHANGED
|
@@ -505,15 +505,68 @@ async function handleAdminRoute(context, url) {
|
|
|
505
505
|
await handleAdminRunAction(context, decodeURIComponent(runActionMatch[1]), runActionMatch[2]);
|
|
506
506
|
return;
|
|
507
507
|
}
|
|
508
|
+
if (url.pathname === '/v1/admin/artifacts' && request.method === 'GET') {
|
|
509
|
+
writeJson(response, 200, sessions.listArtifacts({
|
|
510
|
+
agentId: cleanQuery(url.searchParams.get('agentId')),
|
|
511
|
+
kind: cleanQuery(url.searchParams.get('kind')),
|
|
512
|
+
query: cleanQuery(url.searchParams.get('q')),
|
|
513
|
+
offset: boundedOffset(url.searchParams.get('offset')),
|
|
514
|
+
limit: boundedLimit(url.searchParams.get('limit'))
|
|
515
|
+
}));
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
508
518
|
const adminArtifactMatch = url.pathname.match(/^\/v1\/admin\/runs\/([^/]+)\/artifacts\/([^/]+)$/);
|
|
509
519
|
if (adminArtifactMatch && request.method === 'GET') {
|
|
510
520
|
const run = sessions.getRun(decodeURIComponent(adminArtifactMatch[1]));
|
|
511
521
|
await writeRunArtifact(response, sessions, run, decodeURIComponent(adminArtifactMatch[2]));
|
|
512
522
|
return;
|
|
513
523
|
}
|
|
524
|
+
if (adminArtifactMatch && request.method === 'DELETE') {
|
|
525
|
+
await assertEmptyJsonBody(request, config.maxRequestBytes);
|
|
526
|
+
const runId = decodeURIComponent(adminArtifactMatch[1]);
|
|
527
|
+
const artifactId = decodeURIComponent(adminArtifactMatch[2]);
|
|
528
|
+
const run = sessions.getRun(runId);
|
|
529
|
+
if (!isTerminalState(run.state)) {
|
|
530
|
+
throw new RequestError(409, 'Artifacts of active runs cannot be deleted; cancel the task first');
|
|
531
|
+
}
|
|
532
|
+
const deleted = await sessions.removeRunArtifact(runId, artifactId);
|
|
533
|
+
if (!deleted)
|
|
534
|
+
throw new RequestError(404, 'Agent Nexus artifact not found');
|
|
535
|
+
console.info(JSON.stringify({
|
|
536
|
+
level: 'info',
|
|
537
|
+
event: 'admin_artifact_deleted',
|
|
538
|
+
runId,
|
|
539
|
+
artifactId,
|
|
540
|
+
agentId: run.agentId,
|
|
541
|
+
requestId: String(response.getHeader('X-Request-Id') || '')
|
|
542
|
+
}));
|
|
543
|
+
writeJson(response, 200, { deleted: true, runId, artifactId });
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
514
546
|
const runMatch = url.pathname.match(/^\/v1\/admin\/runs\/([^/]+)$/);
|
|
515
|
-
if (runMatch && request.method === 'GET') {
|
|
547
|
+
if (runMatch && (request.method === 'GET' || request.method === 'DELETE')) {
|
|
516
548
|
const runId = decodeURIComponent(runMatch[1]);
|
|
549
|
+
if (request.method === 'DELETE') {
|
|
550
|
+
await assertEmptyJsonBody(request, config.maxRequestBytes);
|
|
551
|
+
const detail = sessions.getRun(runId);
|
|
552
|
+
if (!isTerminalState(detail.state)) {
|
|
553
|
+
throw new RequestError(409, 'Active runs cannot be deleted; cancel the task first');
|
|
554
|
+
}
|
|
555
|
+
const deleted = sessions.deleteRun(runId);
|
|
556
|
+
if (!deleted)
|
|
557
|
+
throw new RequestError(404, 'Agent Nexus run not found');
|
|
558
|
+
console.info(JSON.stringify({
|
|
559
|
+
level: 'info',
|
|
560
|
+
event: 'admin_run_record_deleted',
|
|
561
|
+
runId,
|
|
562
|
+
sessionId: detail.sessionId,
|
|
563
|
+
agentId: detail.agentId,
|
|
564
|
+
state: detail.state,
|
|
565
|
+
requestId: String(response.getHeader('X-Request-Id') || '')
|
|
566
|
+
}));
|
|
567
|
+
writeJson(response, 200, { deleted: true, id: runId });
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
517
570
|
const detail = sessions.getRun(runId);
|
|
518
571
|
writeJson(response, 200, {
|
|
519
572
|
...detail,
|
|
@@ -778,6 +831,7 @@ function buildRunControls(sessions, detail) {
|
|
|
778
831
|
return {
|
|
779
832
|
canCancel,
|
|
780
833
|
canRetry,
|
|
834
|
+
canDelete: !active,
|
|
781
835
|
...(pendingRequest ? { pendingRequest: structuredClone(pendingRequest) } : {}),
|
|
782
836
|
...(unavailableReason ? { unavailableReason } : {})
|
|
783
837
|
};
|
|
@@ -73,6 +73,7 @@ export class AdminRunManager {
|
|
|
73
73
|
return {
|
|
74
74
|
canCancel: current && active && Boolean(session?.runtimeAvailable()),
|
|
75
75
|
canRetry,
|
|
76
|
+
canDelete: isTerminal(run.state),
|
|
76
77
|
...(pendingRequest ? { pendingRequest } : {}),
|
|
77
78
|
...(unavailableReason ? { unavailableReason } : {})
|
|
78
79
|
};
|
package/dist/session.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { AgentDriver } from './drivers/index.js';
|
|
2
2
|
import { SessionEventLog } from './events.js';
|
|
3
|
-
import { RunStore, type RunListQuery } from './run-store.js';
|
|
3
|
+
import { RunStore, type RunListQuery, type RunArtifactQuery } from './run-store.js';
|
|
4
4
|
import { type AdminRetryReservationFactory, type AdminRunOperationResult, type AdminRunControls } from './session/admin-runs.js';
|
|
5
5
|
import type { AcpSessionSink, AgentSessionRuntime, AgentSessionSink } from './session-contract.js';
|
|
6
6
|
import type { AgentdAgentView, AgentdArtifact, AgentdConfig, AgentdEvent, AgentdEventType, AgentdInputAttachmentView, AgentdPendingRequest, AgentdPendingResponse, AgentdProtocol, AgentdSessionState, AgentdSessionView, AgentdTurnCompletionProof } from './types.js';
|
|
@@ -114,8 +114,14 @@ export declare class SessionManager {
|
|
|
114
114
|
};
|
|
115
115
|
getRun(id: string): import("./types.js").AgentdRunDetail;
|
|
116
116
|
getRunOwnerKeyId(id: string): string | undefined;
|
|
117
|
+
deleteRun(id: string): import("./types.js").AgentdRunDetail | undefined;
|
|
117
118
|
ownsRun(id: string, keyId: string): boolean;
|
|
118
119
|
getRunArtifacts(id: string): import("./types.js").AgentdRunArtifactView[];
|
|
120
|
+
listArtifacts(query?: RunArtifactQuery): {
|
|
121
|
+
artifacts: import("./run-store.js").RunArtifactRow[];
|
|
122
|
+
total: number;
|
|
123
|
+
};
|
|
124
|
+
removeRunArtifact(runId: string, artifactId: string): Promise<boolean | undefined>;
|
|
119
125
|
getStorageMetrics(): ReturnType<RunStore['metrics']> | undefined;
|
|
120
126
|
readRunArtifact(runId: string, artifactId: string): Promise<import("./artifact-store.js").ArtifactReadResult | undefined>;
|
|
121
127
|
getRunArtifact(runId: string, artifactId: string): Promise<import("./artifact-store.js").ArtifactReadResult | undefined>;
|