Skip to content
Merged
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
125 changes: 123 additions & 2 deletions apps/cli/src/ledger-cmd.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { FileLedger } from '@deepcode/core';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { FileLedger, captureSnapshot } from '@deepcode/core';
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { PassThrough } from 'node:stream';
Expand Down Expand Up @@ -128,3 +128,124 @@ describe('deepcode ledger', () => {
expect(err.text()).toContain('Usage:');
});
});

describe('deepcode ledger rollback', () => {
let cwd: string;
let home: string;
let sessionsRoot: string;
let ledger: FileLedger;
const sessionId = 'thread-cli';

beforeEach(async () => {
cwd = await mkdtemp(join(tmpdir(), 'dc-rb-cwd-'));
home = await mkdtemp(join(tmpdir(), 'dc-rb-home-'));
sessionsRoot = await mkdtemp(join(tmpdir(), 'dc-rb-sessions-'));
ledger = new FileLedger({ cwd, home });
});
afterEach(async () => {
for (const dir of [cwd, home, sessionsRoot]) await rm(dir, { recursive: true, force: true });
});

/** A recorded edit with a real snapshot behind it. */
async function recordedEdit(): Promise<{ id: string; file: string }> {
const file = join(cwd, 'a.ts');
await writeFile(file, 'original\n');
await captureSnapshot({
sessionsRoot,
sessionId,
cwd,
filePath: file,
reason: 'pre-Edit',
seq: 1,
});
await writeFile(file, 'changed\n');
const written = await ledger.append('changes', {
actor: 'agent',
tool: 'Edit',
threadId: sessionId,
paths: ['a.ts'],
summary: 'edited a.ts',
rollbackHint: { kind: 'snapshot', ref: '1' },
});
return { id: written!.id, file };
}

it('restores the file and records the rollback on the governance timeline', async () => {
const { id, file } = await recordedEdit();
const out = capture();
const code = await runLedgerCommand(['rollback', id], {
cwd,
home,
sessionsRoot,
output: out.stream,
confirm: async () => 'accept',
});
expect(code).toBe(0);
expect(await readFile(file, 'utf8')).toBe('original\n');

// An audit trail with an unlogged undo is not an audit trail.
const out2 = capture();
await runLedgerCommand(['list', '--kind', 'governance'], { cwd, home, output: out2.stream });
expect(out2.text()).toContain('rolled back');
});

it('changes nothing when the user declines', async () => {
const { id, file } = await recordedEdit();
const out = capture();
await runLedgerCommand(['rollback', id], {
cwd,
home,
sessionsRoot,
output: out.stream,
confirm: async () => 'reject',
});
expect(await readFile(file, 'utf8')).toBe('changed\n');
expect(out.text()).toContain('nothing was changed');
});

it('shows the explanation and preview before asking', async () => {
const { id } = await recordedEdit();
let shown = '';
await runLedgerCommand(['rollback', id], {
cwd,
home,
sessionsRoot,
output: capture().stream,
confirm: async (p) => {
shown = JSON.stringify(p);
return 'reject';
},
});
expect(shown).toContain('snapshot');
expect(shown).toContain('a.ts');
});

it('explains why a record cannot be rolled back instead of failing obscurely', async () => {
const written = await ledger.append('changes', {
actor: 'agent',
paths: ['a.ts'],
summary: 'edited a.ts',
});
const err = capture();
const code = await runLedgerCommand(['rollback', written!.id], {
cwd,
home,
sessionsRoot,
errOutput: err.stream,
});
expect(code).toBe(1);
expect(err.text()).toContain('no rollback point');
});

it('fails on an unknown id', async () => {
const err = capture();
expect(
await runLedgerCommand(['rollback', 'nope'], {
cwd,
home,
sessionsRoot,
errOutput: err.stream,
}),
).toBe(1);
});
});
101 changes: 100 additions & 1 deletion apps/cli/src/ledger-cmd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,41 @@
// Plan: docs/FLOATBOAT_ADOPTION_PLAN.md §2.B · Docs: docs/change-ledger.md

import {
FileLedger,
LEDGER_KINDS,
applyWithCeremony,
defaultSessionsDir,
findLedgerRecord,
ledgerPath,
planRollback,
readProjectLedger,
renderApplyPresentation,
renderLedgerMarkdown,
type ApplyConfirm,
type LedgerKind,
type LedgerRecord,
} from '@deepcode/core';
import { promises as fs } from 'node:fs';
import { homedir } from 'node:os';
import { dirname, resolve } from 'node:path';
import type { Writable } from 'node:stream';
import { createInterface } from 'node:readline/promises';
import type { Readable, Writable } from 'node:stream';

export interface LedgerCmdDeps {
cwd: string;
home?: string;
output?: Writable;
errOutput?: Writable;
json?: boolean;
/** Where sessions (and therefore snapshots) live; overridden in tests. */
sessionsRoot?: string;
/** Input for the rollback confirmation prompt. */
input?: Readable;
/**
* Substitute confirmation. Tests supply one; there is no default that says
* yes, because a No Silent Apply with an implicit yes is not one.
*/
confirm?: ApplyConfirm;
}

export async function runLedgerCommand(args: string[], deps: LedgerCmdDeps): Promise<number> {
Expand All @@ -35,6 +51,8 @@ export async function runLedgerCommand(args: string[], deps: LedgerCmdDeps): Pro
return show(args.slice(1), deps, out, err);
case 'export':
return exportCmd(args.slice(1), deps, out, err);
case 'rollback':
return rollback(args.slice(1), deps, out, err);
default:
err.write(`Unknown subcommand "${sub}".\n\n`);
usage(err);
Expand Down Expand Up @@ -161,6 +179,86 @@ async function exportCmd(
return 0;
}

/**
* Undo one recorded change, through the full ceremony: explain, preview,
* confirm, restore, then record the rollback itself.
*/
async function rollback(
args: string[],
deps: LedgerCmdDeps,
out: Writable,
err: Writable,
): Promise<number> {
const id = args[0];
if (!id) {
err.write('Usage: deepcode ledger rollback <id>\n');
return 2;
}
const home = deps.home ?? homedir();
const found = await findLedgerRecord(deps.cwd, id, home);
if (!found) {
err.write(`No ledger record with id "${id}".\n`);
return 1;
}

const planned = await planRollback({
record: found.record,
sessionsRoot: deps.sessionsRoot ?? defaultSessionsDir(),
cwd: deps.cwd,
});
if (planned.status === 'unavailable') {
err.write(`Cannot roll back ${id}: ${planned.reason}.\n`);
return 1;
}

const confirm = deps.confirm ?? promptConfirm(deps, out);
const outcome = await applyWithCeremony(planned.plan, confirm);

switch (outcome.status) {
case 'applied': {
const restored = outcome.result ?? [];
out.write(`Rolled back ${id}: ${restored.length} file(s) restored.\n`);
// The rollback is itself a change, and belongs on the governance
// timeline — an audit trail with an unlogged undo is not an audit trail.
await new FileLedger({ cwd: deps.cwd, home }).append('governance', {
actor: 'user',
intent: `roll back ${id}`,
paths: restored,
summary: `rolled back ${id}: ${found.record.summary}`,
rollbackHint: { kind: 'manual', ref: 'use git to undo this rollback' },
});
return 0;
}
case 'rejected':
out.write('Rollback cancelled; nothing was changed.\n');
return 0;
case 'deferred':
out.write('Rollback deferred; nothing was changed.\n');
return 0;
case 'failed':
err.write(`Rollback failed: ${outcome.error?.message ?? 'unknown error'}\n`);
return 1;
}
}

/** Interactive accept/reject/defer prompt. */
function promptConfirm(deps: LedgerCmdDeps, out: Writable): ApplyConfirm {
return async (presentation) => {
out.write('\n' + renderApplyPresentation(presentation) + '\n');
const input = deps.input ?? process.stdin;
const rl = createInterface({ input, output: out as NodeJS.WritableStream });
try {
const answer = (await rl.question('Apply this rollback? [y/N/defer] ')).trim().toLowerCase();
if (answer === 'defer' || answer === 'd') return 'defer';
// Anything other than an explicit yes is a no. A mistyped answer must not
// overwrite the user's files.
return answer === 'y' || answer === 'yes' ? 'accept' : 'reject';
} finally {
rl.close();
}
};
}

function usage(out: Writable): void {
out.write(
[
Expand All @@ -169,6 +267,7 @@ function usage(out: Writable): void {
' list [--kind changes|governance] [--limit N] Recent records (default)',
' show <id> One record in full',
' export [--kind K] [--out <path>] Markdown digest',
' rollback <id> Undo one recorded change',
'',
`Stored under ${dirname(ledgerPath('<project>', 'changes', '<home>'))}`,
'',
Expand Down
33 changes: 33 additions & 0 deletions docs/change-ledger.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ The ledger records what the agent changed, why, and how to undo it.
deepcode ledger list # recent records
deepcode ledger show <id> # one record in full
deepcode ledger export --out audit.md
deepcode ledger rollback <id> # undo one recorded change
```

## Why it exists separately from sessions
Expand Down Expand Up @@ -91,6 +92,38 @@ Newest 5000 records per file, nothing older than 90 days; trimmed automatically
as records accumulate. A log that only grows is one somebody eventually deletes
wholesale — which loses the recent records too.

## Rolling back

`deepcode ledger rollback <id>` undoes one recorded change. It never applies
silently — every rollback goes through four steps:

1. **Explain** — where the change came from, how the states were compared, what
will physically happen, and how to get back afterwards.
2. **Preview** — the file that will be restored or deleted.
3. **Confirm** — accept, reject, or defer. Anything other than an explicit yes
is a no; a mistyped answer must not overwrite your files.
4. **Apply** — and record the rollback itself on the governance timeline. An
audit trail with an unlogged undo is not an audit trail.

### Conflicts are surfaced, not silently resolved

Undoing an _old_ change is not the same operation as undoing the last one. The
plan warns before you decide when:

- later changes to the same file in that session would be discarded along with
it (a `post-` capture of the same call doesn't count — otherwise every
single-edit rollback would warn about itself);
- the file was modified outside DeepCode since the last snapshot;
- the record is a `Bash` checkpoint, which restores **every** tracked file that
command touched.

### When it isn't possible

Snapshots and ledger records age out on different schedules, so a record can
outlive the checkpoint it points at. You get a reason — "snapshot 7 is no longer
available", "this record has no rollback point" — rather than a stack trace.
These are expected states of an audit log, not errors.

## It is not an authority

The ledger records decisions; it never influences one. Nothing in the permission
Expand Down
13 changes: 13 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -541,3 +541,16 @@ export {
ledgerKindForTool,
type ToolCallRecordInput,
} from './ledger/record-tool-call.js';

// No Silent Apply ceremony (plan §2.F)
export {
applyWithCeremony,
renderApplyPresentation,
type ApplyConfirm,
type ApplyDecision,
type ApplyExplanation,
type ApplyOutcome,
type ApplyPlan,
type ApplyPresentation,
} from './runtime/apply-ceremony.js';
export { planRollback, type RollbackContext, type RollbackPlanResult } from './ledger/rollback.js';
Loading
Loading