From 4668b3320e1882127223d24e2eeb0228162d6570 Mon Sep 17 00:00:00 2001 From: Dukeabadoon Date: Wed, 12 Aug 2026 14:15:03 +0800 Subject: [PATCH] feat(storage): add telic purge-run and gc commands Enable per-run deletion and orphan blob cleanup with dry-run support for gc, closing phase 5 storage lifecycle work. --- docs/API.md | 10 +- packages/cli/src/gc.ts | 23 ++++ packages/cli/src/index.test.ts | 2 + packages/cli/src/index.ts | 17 +++ packages/cli/src/purge-run.ts | 20 +++ packages/cli/src/storage.test.ts | 220 +++++++++++++++++++++++++++++++ packages/core/src/ledger.test.ts | 97 ++++++++++++++ packages/core/src/ledger.ts | 136 +++++++++++++++++++ 8 files changed, 522 insertions(+), 3 deletions(-) create mode 100644 packages/cli/src/gc.ts create mode 100644 packages/cli/src/purge-run.ts create mode 100644 packages/cli/src/storage.test.ts diff --git a/docs/API.md b/docs/API.md index 7b8824d..c257d63 100644 --- a/docs/API.md +++ b/docs/API.md @@ -280,6 +280,8 @@ telic status RUN_ID [--repo PATH] [--json] telic trace RUN_ID [--repo PATH] [--json] telic artifact RUN_ID ARTIFACT_ID [--repo PATH] [--json] telic replay RUN_ID [--repo PATH] [--json] +telic purge-run RUN_ID [--repo PATH] [--json] +telic gc [--repo PATH] [--dry-run] [--json] telic broker-gate [--repo PATH] telic mcp ``` @@ -303,7 +305,9 @@ budget must cover its unique required capabilities; a required `subagent.spawn` also needs a positive child budget. These are safety/storage limits, not an automatic retention policy. -There is no per-run deletion command yet. To remove all Telic state for a repository, first stop every Telic process using that state directory, confirm the path reported by `doctor --json`, and remove that directory with normal OS tools. This is irreversible and deletes all runs for that repository. +`telic purge-run RUN_ID` deletes one run's ledger rows and any blob bodies that become unreferenced. `telic gc` scans the content store for orphan blobs and removes them; pass `--dry-run` to list candidates without deleting files. + +To remove all Telic state for a repository, first stop every Telic process using that state directory, confirm the path reported by `doctor --json`, and remove that directory with normal OS tools. This is irreversible and deletes all runs for that repository. ## API limitations @@ -314,5 +318,5 @@ There is no per-run deletion command yet. To remove all Telic state for a reposi config and transport checks but lack real-host lifecycle certification. - State integrity is designed for local correctness, not hostile same-user tamper resistance. - Trace responses are indexed and paginated, but age-based retention, - automatic orphan-blob collection, and supported per-run deletion remain - release work. + and age-based retention remain release work. Per-run deletion and orphan-blob + collection are available via `telic purge-run` and `telic gc`. diff --git a/packages/cli/src/gc.ts b/packages/cli/src/gc.ts new file mode 100644 index 0000000..267e80d --- /dev/null +++ b/packages/cli/src/gc.ts @@ -0,0 +1,23 @@ +import type { CollectOrphanBlobsResult, SqliteLedger } from "@telic/core"; + +export interface GcCliIo { + stdout: (line: string) => void; +} + +export function runGc( + ledger: SqliteLedger, + options: { dryRun: boolean; json: boolean }, + io: GcCliIo, +): number { + const result: CollectOrphanBlobsResult = ledger.collectOrphanBlobs({ + dryRun: options.dryRun, + }); + io.stdout( + options.json ? JSON.stringify(result) : JSON.stringify(result, null, 2), + ); + return 0; +} + +export function gcUsage(): string { + return " telic gc [--repo PATH] [--dry-run] [--json]"; +} diff --git a/packages/cli/src/index.test.ts b/packages/cli/src/index.test.ts index bd55b1e..fb6efff 100644 --- a/packages/cli/src/index.test.ts +++ b/packages/cli/src/index.test.ts @@ -51,5 +51,7 @@ describe("Telic CLI", () => { const output = capture(); expect(await runCli(["--help"], output.io)).toBe(0); expect(output.stdout.join("\n")).toContain("telic doctor"); + expect(output.stdout.join("\n")).toContain("telic purge-run"); + expect(output.stdout.join("\n")).toContain("telic gc"); }); }); diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index e0e831c..e917b70 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -6,6 +6,8 @@ import { inspectRunReplay, SqliteLedger } from "@telic/core"; import { defaultStateDirectory, startStdioServer } from "@telic/mcp"; import { evaluateBrokerGate } from "./broker-gate.js"; +import { gcUsage, runGc } from "./gc.js"; +import { purgeRunUsage, runPurgeRun } from "./purge-run.js"; export interface CliIo { stdout: (line: string) => void; @@ -41,6 +43,8 @@ function usage(): string { " telic trace RUN_ID [--repo PATH] [--json]", " telic artifact RUN_ID ARTIFACT_ID [--repo PATH] [--json]", " telic replay RUN_ID [--repo PATH] [--json]", + purgeRunUsage(), + gcUsage(), " telic broker-gate [--repo PATH]", " telic mcp", "", @@ -153,11 +157,24 @@ export async function runCli( return 0; } + if (command === "gc") { + const dryRun = args.includes("--dry-run"); + const ledger = openExistingLedger(repository); + try { + return runGc(ledger, { dryRun, json }, io); + } finally { + ledger.close(); + } + } + const runId = args[0]; if (!runId || runId.startsWith("--")) throw new Error(`${command} requires RUN_ID`); const ledger = openExistingLedger(repository); try { + if (command === "purge-run") { + return runPurgeRun(ledger, runId, json, io); + } if (command === "replay") { const run = ledger.requireRun(runId); io.stdout(render(inspectRunReplay(ledger, run), json)); diff --git a/packages/cli/src/purge-run.ts b/packages/cli/src/purge-run.ts new file mode 100644 index 0000000..683f250 --- /dev/null +++ b/packages/cli/src/purge-run.ts @@ -0,0 +1,20 @@ +import type { PurgeRunResult, SqliteLedger } from "@telic/core"; + +export interface PurgeRunCliIo { + stdout: (line: string) => void; +} + +export function runPurgeRun( + ledger: SqliteLedger, + runId: string, + json: boolean, + io: PurgeRunCliIo, +): number { + const result: PurgeRunResult = ledger.purgeRun(runId); + io.stdout(json ? JSON.stringify(result) : JSON.stringify(result, null, 2)); + return 0; +} + +export function purgeRunUsage(): string { + return " telic purge-run RUN_ID [--repo PATH] [--json]"; +} diff --git a/packages/cli/src/storage.test.ts b/packages/cli/src/storage.test.ts new file mode 100644 index 0000000..a70383e --- /dev/null +++ b/packages/cli/src/storage.test.ts @@ -0,0 +1,220 @@ +import { existsSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { SqliteLedger } from "@telic/core"; +import type { ArtifactSubmission, RunRecord } from "@telic/core"; + +import { runGc } from "./gc.js"; +import { runCli } from "./index.js"; +import { runPurgeRun } from "./purge-run.js"; + +const ledgers: SqliteLedger[] = []; + +function createRun(runId = "00000000-0000-4000-8000-000000000001"): RunRecord { + return { + runId, + schemaVersion: "1.0", + repositoryRoot: "/repo", + requestedMode: "analyze_only", + topology: "standard", + escalationCount: 0, + priorRunId: null, + status: "running", + phase: "context_grounding", + resumePhase: null, + version: 1, + budgets: { + promptRevisionsRemaining: 1, + postExecutionRemediationsRemaining: 1, + }, + outcomeHint: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }; +} + +function createLedger(stateDirectory: string): SqliteLedger { + const ledger = new SqliteLedger(stateDirectory); + ledgers.push(ledger); + return ledger; +} + +function blobPathForDigest(ledger: SqliteLedger, digest: string): string { + const hex = digest.startsWith("sha256:") + ? digest.slice("sha256:".length) + : digest; + return join(ledger.blobDirectory, hex.slice(0, 2), hex.slice(2)); +} + +afterEach(() => { + for (const ledger of ledgers.splice(0)) ledger.close(); +}); + +describe("purge-run", () => { + it("deletes a run through the CLI module", () => { + const stateDirectory = mkdtempSync(join(tmpdir(), "telic-purge-run-")); + const ledger = createLedger(stateDirectory); + const run = createRun(); + const request: ArtifactSubmission = { + id: "request-1", + runId: run.runId, + type: "UserMessage", + schemaVersion: "1.0", + producer: "user", + body: { content: "purge via cli" }, + }; + ledger.createRun(run, [request]); + const digest = ledger.getArtifact(run.runId, request.id)?.sha256; + expect(digest).toBeTruthy(); + + const stdout: string[] = []; + expect( + runPurgeRun(ledger, run.runId, true, { + stdout: (line) => stdout.push(line), + }), + ).toBe(0); + const result = JSON.parse(stdout[0] ?? "{}") as { + deletedArtifactCount: number; + }; + expect(result.deletedArtifactCount).toBe(1); + expect(ledger.getRun(run.runId)).toBeNull(); + expect(existsSync(blobPathForDigest(ledger, digest!))).toBe(false); + }); +}); + +describe("gc", () => { + it("supports dry-run before deleting orphan blobs", () => { + const stateDirectory = mkdtempSync(join(tmpdir(), "telic-gc-")); + const ledger = createLedger(stateDirectory); + const run = createRun(); + const request: ArtifactSubmission = { + id: "request-1", + runId: run.runId, + type: "UserMessage", + schemaVersion: "1.0", + producer: "user", + body: { content: "gc via cli" }, + }; + ledger.createRun(run, [request]); + const digest = ledger.getArtifact(run.runId, request.id)?.sha256; + expect(digest).toBeTruthy(); + const blobPath = blobPathForDigest(ledger, digest!); + ledger.purgeRun(run.runId); + expect(existsSync(blobPath)).toBe(false); + writeFileSync(blobPath, "{}", { encoding: "utf8", mode: 0o600 }); + + const stdout: string[] = []; + expect( + runGc( + ledger, + { dryRun: true, json: true }, + { stdout: (line) => stdout.push(line) }, + ), + ).toBe(0); + const dryRun = JSON.parse(stdout[0] ?? "{}") as { + orphanDigests: string[]; + }; + expect(dryRun.orphanDigests).toEqual([digest]); + expect(existsSync(blobPath)).toBe(true); + + stdout.length = 0; + expect( + runGc( + ledger, + { dryRun: false, json: true }, + { stdout: (line) => stdout.push(line) }, + ), + ).toBe(0); + const removed = JSON.parse(stdout[0] ?? "{}") as { + removedDigests: string[]; + }; + expect(removed.removedDigests).toEqual([digest]); + expect(existsSync(blobPath)).toBe(false); + }); +}); + +describe("storage CLI integration", () => { + it("runs purge-run and gc with TELIC_STATE_DIR", async () => { + const repository = mkdtempSync(join(tmpdir(), "telic-storage-cli-repo-")); + const stateDirectory = mkdtempSync( + join(tmpdir(), "telic-storage-cli-state-"), + ); + const previousStateDir = process.env.TELIC_STATE_DIR; + process.env.TELIC_STATE_DIR = stateDirectory; + + const ledger = createLedger(stateDirectory); + const run = createRun(); + const request: ArtifactSubmission = { + id: "request-1", + runId: run.runId, + type: "UserMessage", + schemaVersion: "1.0", + producer: "user", + body: { content: "integration purge" }, + }; + ledger.createRun(run, [request]); + const digest = ledger.getArtifact(run.runId, request.id)?.sha256; + expect(digest).toBeTruthy(); + const blobPath = blobPathForDigest(ledger, digest!); + ledger.close(); + ledgers.pop(); + + try { + const purgeOutput: string[] = []; + expect( + await runCli(["purge-run", run.runId, "--repo", repository, "--json"], { + stdout: (line) => purgeOutput.push(line), + stderr: () => undefined, + }), + ).toBe(0); + expect(JSON.parse(purgeOutput[0] ?? "{}")).toMatchObject({ + runId: run.runId, + deletedArtifactCount: 1, + }); + expect(existsSync(blobPath)).toBe(false); + + const gcDryOutput: string[] = []; + expect( + await runCli(["gc", "--repo", repository, "--dry-run", "--json"], { + stdout: (line) => gcDryOutput.push(line), + stderr: () => undefined, + }), + ).toBe(0); + expect(JSON.parse(gcDryOutput[0] ?? "{}")).toMatchObject({ + dryRun: true, + orphanDigests: [], + }); + + writeFileSync(blobPath, "{}", { encoding: "utf8", mode: 0o600 }); + expect( + await runCli(["gc", "--repo", repository, "--dry-run", "--json"], { + stdout: (line) => gcDryOutput.push(line), + stderr: () => undefined, + }), + ).toBe(0); + expect(JSON.parse(gcDryOutput[1] ?? "{}")).toMatchObject({ + dryRun: true, + orphanDigests: [digest], + }); + expect(existsSync(blobPath)).toBe(true); + + const gcOutput: string[] = []; + expect( + await runCli(["gc", "--repo", repository, "--json"], { + stdout: (line) => gcOutput.push(line), + stderr: () => undefined, + }), + ).toBe(0); + expect(JSON.parse(gcOutput[0] ?? "{}")).toMatchObject({ + removedDigests: [digest], + }); + expect(existsSync(blobPath)).toBe(false); + } finally { + if (previousStateDir === undefined) delete process.env.TELIC_STATE_DIR; + else process.env.TELIC_STATE_DIR = previousStateDir; + } + }); +}); diff --git a/packages/core/src/ledger.test.ts b/packages/core/src/ledger.test.ts index f2c8be0..12eac47 100644 --- a/packages/core/src/ledger.test.ts +++ b/packages/core/src/ledger.test.ts @@ -1,5 +1,6 @@ import { chmodSync, + existsSync, mkdirSync, mkdtempSync, readFileSync, @@ -116,6 +117,13 @@ function createRun(): RunRecord { }; } +function blobPathForDigest(ledger: SqliteLedger, digest: string): string { + const hex = digest.startsWith("sha256:") + ? digest.slice("sha256:".length) + : digest; + return join(ledger.blobDirectory, hex.slice(0, 2), hex.slice(2)); +} + function createLedger(): SqliteLedger { const ledger = new SqliteLedger(mkdtempSync(join(tmpdir(), "telic-ledger-"))); ledgers.push(ledger); @@ -484,4 +492,93 @@ describe("SQLite ledger and content-addressed artifacts", () => { ); expect(ledger.requireRun("legacy-run").phase).toBe("agent_1_frame"); }); + + it("purgeRun removes run data and unreferenced blobs", () => { + const ledger = createLedger(); + const run = createRun(); + const request: ArtifactSubmission = { + id: "request-1", + runId: run.runId, + type: "UserMessage", + schemaVersion: "1.0", + producer: "user", + body: { content: "purge me" }, + }; + ledger.createRun(run, [request]); + const artifact = ledger.getArtifact(run.runId, request.id); + expect(artifact).not.toBeNull(); + const digest = artifact?.sha256; + expect(digest).toBeTruthy(); + + const result = ledger.purgeRun(run.runId); + expect(result.deletedArtifactCount).toBe(1); + expect(result.deletedTraceEventCount).toBe(1); + expect(result.deletedBlobDigests).toEqual([digest]); + expect(ledger.getRun(run.runId)).toBeNull(); + expect(existsSync(blobPathForDigest(ledger, digest!))).toBe(false); + }); + + it("purgeRun keeps blobs still referenced by another run", () => { + const ledger = createLedger(); + const sharedBody = { content: "shared blob" }; + const first = createRun(); + const second = { + ...createRun(), + runId: "00000000-0000-4000-8000-000000000002", + }; + const firstRequest: ArtifactSubmission = { + id: "request-1", + runId: first.runId, + type: "UserMessage", + schemaVersion: "1.0", + producer: "user", + body: sharedBody, + }; + const secondRequest: ArtifactSubmission = { + ...firstRequest, + id: "request-2", + runId: second.runId, + }; + ledger.createRun(first, [firstRequest]); + ledger.createRun(second, [secondRequest]); + const digest = ledger.getArtifact(first.runId, firstRequest.id)?.sha256; + expect(digest).toBeTruthy(); + + const result = ledger.purgeRun(first.runId); + expect(result.deletedBlobDigests).toEqual([]); + expect(existsSync(blobPathForDigest(ledger, digest!))).toBe(true); + expect(ledger.getRun(second.runId)).not.toBeNull(); + }); + + it("collectOrphanBlobs reports and removes orphan blobs", () => { + const ledger = createLedger(); + const run = createRun(); + const request: ArtifactSubmission = { + id: "request-1", + runId: run.runId, + type: "UserMessage", + schemaVersion: "1.0", + producer: "user", + body: { content: "gc me" }, + }; + ledger.createRun(run, [request]); + const digest = ledger.getArtifact(run.runId, request.id)?.sha256; + expect(digest).toBeTruthy(); + const blobPath = blobPathForDigest(ledger, digest!); + expect(existsSync(blobPath)).toBe(true); + ledger.purgeRun(run.runId); + expect(existsSync(blobPath)).toBe(false); + + writeFileSync(blobPath, "{}", { encoding: "utf8", mode: 0o600 }); + expect(existsSync(blobPath)).toBe(true); + + const dryRun = ledger.collectOrphanBlobs({ dryRun: true }); + expect(dryRun.orphanDigests).toEqual([digest]); + expect(dryRun.removedDigests).toEqual([]); + expect(existsSync(blobPath)).toBe(true); + + const removed = ledger.collectOrphanBlobs(); + expect(removed.removedDigests).toEqual([digest]); + expect(existsSync(blobPath)).toBe(false); + }); }); diff --git a/packages/core/src/ledger.ts b/packages/core/src/ledger.ts index 6c0de2e..040d343 100644 --- a/packages/core/src/ledger.ts +++ b/packages/core/src/ledger.ts @@ -7,6 +7,7 @@ import { mkdirSync, openSync, readFileSync, + readdirSync, realpathSync, renameSync, rmSync, @@ -141,6 +142,19 @@ export type SupportingArtifactQuota = errorMessage: string; }; +export interface PurgeRunResult { + runId: string; + deletedArtifactCount: number; + deletedTraceEventCount: number; + deletedBlobDigests: string[]; +} + +export interface CollectOrphanBlobsResult { + dryRun: boolean; + orphanDigests: string[]; + removedDigests: string[]; +} + export class SqliteLedger { readonly rootDirectory: string; readonly databasePath: string; @@ -855,6 +869,128 @@ export class SqliteLedger { })); } + purgeRun(runId: string): PurgeRunResult { + this.requireRun(runId); + const digests = ( + this.database + .prepare("SELECT DISTINCT sha256 FROM artifacts WHERE run_id = ?") + .all(runId) as Array<{ sha256: string }> + ).map((row) => row.sha256); + const deletedTraceEventCount = ( + this.database + .prepare("SELECT COUNT(*) AS count FROM trace_events WHERE run_id = ?") + .get(runId) as { count: number } + ).count; + const deletedArtifactCount = ( + this.database + .prepare("SELECT COUNT(*) AS count FROM artifacts WHERE run_id = ?") + .get(runId) as { count: number } + ).count; + + this.database.exec("BEGIN IMMEDIATE"); + try { + this.database + .prepare("DELETE FROM trace_events WHERE run_id = ?") + .run(runId); + this.database + .prepare("DELETE FROM artifacts WHERE run_id = ?") + .run(runId); + const deleted = this.database + .prepare("DELETE FROM runs WHERE run_id = ?") + .run(runId); + if (Number(deleted.changes) !== 1) { + throw new Error(`Run not found: ${runId}`); + } + this.database.exec("COMMIT"); + } catch (error) { + this.database.exec("ROLLBACK"); + throw error; + } + + const deletedBlobDigests: string[] = []; + for (const digest of digests) { + if (this.deleteBlobIfUnreferenced(digest)) { + deletedBlobDigests.push(digest); + } + } + + return { + runId, + deletedArtifactCount, + deletedTraceEventCount, + deletedBlobDigests, + }; + } + + collectOrphanBlobs( + options: { dryRun?: boolean } = {}, + ): CollectOrphanBlobsResult { + const dryRun = options.dryRun ?? false; + if (existsSync(this.blobDirectory)) { + this.assertBlobBoundary(); + } + const referenced = new Set( + ( + this.database + .prepare("SELECT DISTINCT sha256 FROM artifacts") + .all() as Array<{ sha256: string }> + ).map((row) => row.sha256), + ); + const orphanDigests = this.listBlobDigests().filter( + (digest) => !referenced.has(digest), + ); + const removedDigests: string[] = []; + if (!dryRun) { + for (const digest of orphanDigests) { + const path = this.blobPath(digest); + if (!existsSync(path)) continue; + const info = lstatSync(path); + if (!info.isFile() || info.isSymbolicLink()) { + throw new Error("Orphan blob path must be a regular file"); + } + rmSync(path); + removedDigests.push(digest); + } + } + return { dryRun, orphanDigests, removedDigests }; + } + + private listBlobDigests(): string[] { + if (!existsSync(this.blobDirectory)) return []; + const digests: string[] = []; + for (const prefix of readdirSync(this.blobDirectory)) { + const prefixPath = join(this.blobDirectory, prefix); + const prefixInfo = lstatSync(prefixPath); + if (!prefixInfo.isDirectory() || prefixInfo.isSymbolicLink()) continue; + for (const suffix of readdirSync(prefixPath)) { + const filePath = join(prefixPath, suffix); + const fileInfo = lstatSync(filePath); + if (!fileInfo.isFile() || fileInfo.isSymbolicLink()) continue; + digests.push(`sha256:${prefix}${suffix}`); + } + } + return digests; + } + + private isSha256Referenced(sha256: string): boolean { + const row = this.database + .prepare("SELECT 1 AS present FROM artifacts WHERE sha256 = ? LIMIT 1") + .get(sha256) as { present: number } | undefined; + return row !== undefined; + } + + private deleteBlobIfUnreferenced(sha256: string): boolean { + if (this.isSha256Referenced(sha256)) return false; + const path = this.blobPath(sha256); + if (!existsSync(path)) return false; + const info = lstatSync(path); + if (!info.isFile() || info.isSymbolicLink()) { + throw new Error("Artifact blob path must be a regular file"); + } + rmSync(path); + return true; + } + private blobPath(sha256: string): string { const digest = sha256.startsWith("sha256:") ? sha256.slice("sha256:".length)