Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand All @@ -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

Expand All @@ -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`.
23 changes: 23 additions & 0 deletions packages/cli/src/gc.ts
Original file line number Diff line number Diff line change
@@ -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]";
}
2 changes: 2 additions & 0 deletions packages/cli/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
17 changes: 17 additions & 0 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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",
"",
Expand Down Expand Up @@ -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));
Expand Down
20 changes: 20 additions & 0 deletions packages/cli/src/purge-run.ts
Original file line number Diff line number Diff line change
@@ -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]";
}
220 changes: 220 additions & 0 deletions packages/cli/src/storage.test.ts
Original file line number Diff line number Diff line change
@@ -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;
}
});
});
Loading