src/agents-archive.ts
109 lines and 6 functions in DrDustinEdwards/capsid, at commit daa875b on 2026-10-24. This file on GitHub.
Functions
| Function | Lines | Complexity | Covered | CRAP | Mutation |
|---|---|---|---|---|---|
| archiveDue | 52 to 67 | 2 | 100% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| archiveStatements | 72 to 90 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| archiveRevokedAgents | 100 to 110 | 4 | 100% | OkCRAP 4 | No data: mutation testing has not run on this repo yet |
| listArchivedAgents | 114 to 117 | 2 | 100% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:116> | 116 to 116 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| archivedAgentByName | 120 to 122 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
Imports
It imports src/store-guards.ts. Imported by src/controls-plan.ts, src/tools/agents.ts, src/watcher.ts.
Source
import { auditStatement } from "./store-guards";
// Revoked agents leave the live inventory and keep their history (job_24e460427d6c;
// Dustin, 2026-10-02 and 2026-10-10: archive and hide, never delete an agent with
// history). migrations/0038_agents_archive.sql says what moves and why.
//
// Every row that records what an agent did carries the actor string agent:<name>
// itself, so moving the agents row breaks no join a reader of history needs. Three
// things still need the old row, and read it from here: the name stays reserved
// (mintAgent), the key's digest is still refused rather than tried as an operator key
// (resolveAgent), and a reviewer's past verdict is judged on the scopes it held
// (src/review.ts). Archived means revoked to every other reader.
// A revoked agent moves this long after revocation, so a revocation made by mistake can
// still be read in place for a week.
export const ARCHIVE_AFTER_DAYS = 7;
type ArchiveReason = "revoked-7-days" | "runner-job-ended";
// How many agents one pass moves. A pass is not the place to drain a backlog of
// hundreds; the next pass takes the rest.
const MAX_PER_PASS = 20;
// The audit identity of the pass that moves them.
const ARCHIVE_ACTION = "agents.archive";
export interface ArchivedAgent {
id: string;
name: string;
kind: string;
key_hash: string;
scopes: string;
created_by: string;
created_at: string;
revoked_at: string;
last_seen: string | null;
job_id: string | null;
reason: ArchiveReason;
audit_rows: number;
jobs: number;
archived_at: string;
}
interface DueRow {
id: string;
name: string;
reason: ArchiveReason;
}
/** The revoked agents due to move: 7 days after revocation, or a one-job key row
* (job_id set, an old runner key) whose job has ended (done, failed or superseded). Oldest revocation first. */
async function archiveDue(db: D1Database, now: Date): Promise<DueRow[]> {
const { results } = await db
.prepare(
`SELECT a.id AS id, a.name AS name,
CASE WHEN a.job_id IS NOT NULL AND j.status IN ('done', 'failed', 'superseded') THEN 'runner-job-ended' ELSE 'revoked-7-days' END AS reason
FROM agents a LEFT JOIN jobs j ON j.id = a.job_id
WHERE a.revoked_at IS NOT NULL
AND (datetime(a.revoked_at) <= datetime(?1, ?3)
OR (a.job_id IS NOT NULL AND j.status IN ('done', 'failed', 'superseded')))
ORDER BY a.revoked_at, a.name
LIMIT ?2`
)
.bind(now.toISOString(), MAX_PER_PASS, `-${ARCHIVE_AFTER_DAYS} days`)
.all<DueRow>();
return results ?? [];
}
/** The move of one agent, as one batch: copy it with its counts, delete it from agents,
* and write the audit row. Both data statements are guarded on the row still being
* revoked, so a row that changed between the read and the batch moves nothing. */
function archiveStatements(db: D1Database, actor: string, due: DueRow): D1PreparedStatement[] {
const agentActor = `agent:${due.name}`;
return [
db
.prepare(
`INSERT INTO agents_archive (id, name, kind, key_hash, scopes, created_by, created_at, revoked_at, last_seen, job_id, reason, audit_rows, jobs)
SELECT id, name, kind, key_hash, scopes, created_by, created_at, revoked_at, last_seen, job_id, ?2,
(SELECT COUNT(*) FROM audit_log WHERE actor = ?3),
(SELECT COUNT(*) FROM (
SELECT id FROM jobs WHERE posted_by = ?3 OR claimed_by = ?3
UNION SELECT job_id FROM job_outcomes WHERE agent = ?3
UNION SELECT job_id FROM job_claims WHERE agent = ?3))
FROM agents WHERE id = ?1 AND revoked_at IS NOT NULL`
)
.bind(due.id, due.reason, agentActor),
db.prepare("DELETE FROM agents WHERE id = ?1 AND revoked_at IS NOT NULL AND EXISTS (SELECT 1 FROM agents_archive WHERE id = ?1)").bind(due.id),
auditStatement(db, actor, ARCHIVE_ACTION, "agents", due.name, { reason: due.reason, id: due.id }),
];
}
export interface ArchivePass {
archived: Array<{ name: string; reason: ArchiveReason }>;
// Due, and moved nothing: the row changed between the read and the batch.
skipped: string[];
}
/** The watcher pass's archive step. Each agent moves in its own batch, so one that
* fails leaves the rest moved and says which. */
export async function archiveRevokedAgents(db: D1Database, actor: string, now: Date): Promise<ArchivePass> {
const out: ArchivePass = { archived: [], skipped: [] };
for (const due of await archiveDue(db, now)) {
const results = await db.batch(archiveStatements(db, actor, due));
// The DELETE is what takes the row out of the live table, so its count is the move.
// agents has no trigger to inflate it.
if ((results[1]?.meta?.changes ?? 0) === 1) out.archived.push({ name: due.name, reason: due.reason });
else out.skipped.push(due.name);
}
return out;
}
/** Archived agents, newest move first. The stored verifier never leaves this module:
* readers get its first twelve hex, the fingerprint agents list shows. */
export async function listArchivedAgents(db: D1Database, limit = 200): Promise<Array<Omit<ArchivedAgent, "key_hash"> & { fingerprint: string }>> {
const { results } = await db.prepare("SELECT * FROM agents_archive ORDER BY archived_at DESC, name LIMIT ?1").bind(limit).all<ArchivedAgent>();
return (results ?? []).map(({ key_hash, ...row }) => ({ ...row, fingerprint: key_hash.slice(0, 12) }));
}
/** An archived agent by name, or null. */
export async function archivedAgentByName(db: D1Database, name: string): Promise<ArchivedAgent | null> {
return db.prepare("SELECT * FROM agents_archive WHERE name = ?1").bind(name).first<ArchivedAgent>();
}