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.
@@ -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,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
- 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);
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);
@@ -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
  }
@@ -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>;