diff --git a/apps/cli/src/ledger-cmd.test.ts b/apps/cli/src/ledger-cmd.test.ts index 315f8ec..aa80e18 100644 --- a/apps/cli/src/ledger-cmd.test.ts +++ b/apps/cli/src/ledger-cmd.test.ts @@ -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'; @@ -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); + }); +}); diff --git a/apps/cli/src/ledger-cmd.ts b/apps/cli/src/ledger-cmd.ts index c96fe44..81cb16c 100644 --- a/apps/cli/src/ledger-cmd.ts +++ b/apps/cli/src/ledger-cmd.ts @@ -2,18 +2,25 @@ // 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; @@ -21,6 +28,15 @@ export interface LedgerCmdDeps { 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 { @@ -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); @@ -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 { + const id = args[0]; + if (!id) { + err.write('Usage: deepcode ledger rollback \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( [ @@ -169,6 +267,7 @@ function usage(out: Writable): void { ' list [--kind changes|governance] [--limit N] Recent records (default)', ' show One record in full', ' export [--kind K] [--out ] Markdown digest', + ' rollback Undo one recorded change', '', `Stored under ${dirname(ledgerPath('', 'changes', ''))}`, '', diff --git a/docs/change-ledger.md b/docs/change-ledger.md index 9a3c99a..a992fd8 100644 --- a/docs/change-ledger.md +++ b/docs/change-ledger.md @@ -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 # one record in full deepcode ledger export --out audit.md +deepcode ledger rollback # undo one recorded change ``` ## Why it exists separately from sessions @@ -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 ` 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 diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b4116ac..9d535ca 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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'; diff --git a/packages/core/src/ledger/rollback.test.ts b/packages/core/src/ledger/rollback.test.ts new file mode 100644 index 0000000..2c21da8 --- /dev/null +++ b/packages/core/src/ledger/rollback.test.ts @@ -0,0 +1,243 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { applyWithCeremony } from '../runtime/apply-ceremony.js'; +import { captureSnapshot } from '../sessions/snapshots.js'; +import { planRollback } from './rollback.js'; +import type { LedgerRecord } from './index.js'; + +describe('planRollback', () => { + let cwd: string; + let sessionsRoot: string; + const sessionId = 'thread-test'; + + beforeEach(async () => { + cwd = await mkdtemp(join(tmpdir(), 'dc-rollback-cwd-')); + sessionsRoot = await mkdtemp(join(tmpdir(), 'dc-rollback-sessions-')); + }); + afterEach(async () => { + await rm(cwd, { recursive: true, force: true }); + await rm(sessionsRoot, { recursive: true, force: true }); + }); + + function record(overrides: Partial = {}): LedgerRecord { + return { + id: 'chg-1', + timestamp: '2026-08-08T00:00:00.000Z', + actor: 'agent', + tool: 'Edit', + threadId: sessionId, + paths: ['a.ts'], + summary: 'edited a.ts', + rollbackHint: { kind: 'snapshot', ref: '1' }, + ...overrides, + }; + } + + it('restores the pre-change contents through the ceremony', async () => { + 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 planned = await planRollback({ record: record(), sessionsRoot, cwd }); + expect(planned.status).toBe('ready'); + if (planned.status !== 'ready') return; + + const outcome = await applyWithCeremony(planned.plan, async () => 'accept'); + expect(outcome.status).toBe('applied'); + expect(await readFile(file, 'utf8')).toBe('original\n'); + }); + + it('changes nothing when the user rejects', async () => { + 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 planned = await planRollback({ record: record(), sessionsRoot, cwd }); + if (planned.status !== 'ready') throw new Error('expected a plan'); + await applyWithCeremony(planned.plan, async () => 'reject'); + expect(await readFile(file, 'utf8')).toBe('changed\n'); + }); + + it('deletes a file that did not exist before the change', async () => { + const file = join(cwd, 'new.ts'); + await captureSnapshot({ + sessionsRoot, + sessionId, + cwd, + filePath: file, + reason: 'pre-Write', + seq: 1, + }); + await writeFile(file, 'created\n'); + + const planned = await planRollback({ + record: record({ paths: ['new.ts'], summary: 'wrote new.ts' }), + sessionsRoot, + cwd, + }); + if (planned.status !== 'ready') throw new Error('expected a plan'); + expect(planned.plan.explanation.application).toContain('delete'); + await applyWithCeremony(planned.plan, async () => 'accept'); + await expect(readFile(file, 'utf8')).rejects.toThrow(); + }); + + describe('what it refuses to plan', () => { + it('a record with no rollback hint', async () => { + const planned = await planRollback({ + record: record({ rollbackHint: undefined }), + sessionsRoot, + cwd, + }); + expect(planned).toMatchObject({ status: 'unavailable' }); + if (planned.status === 'unavailable') expect(planned.reason).toContain('no rollback point'); + }); + + it('a record not tied to a session', async () => { + const planned = await planRollback({ + record: record({ threadId: undefined }), + sessionsRoot, + cwd, + }); + expect(planned).toMatchObject({ status: 'unavailable' }); + }); + + it('a snapshot that has aged out', async () => { + // Snapshots and ledger records expire on different schedules, so this is + // normal rather than corruption — the user gets a reason, not a stack. + const planned = await planRollback({ record: record(), sessionsRoot, cwd }); + expect(planned).toMatchObject({ status: 'unavailable' }); + if (planned.status === 'unavailable') expect(planned.reason).toContain('no longer available'); + }); + }); + + describe('conflict warnings', () => { + it('warns that later edits to the same file will be discarded', async () => { + const file = join(cwd, 'a.ts'); + await writeFile(file, 'v1\n'); + await captureSnapshot({ + sessionsRoot, + sessionId, + cwd, + filePath: file, + reason: 'pre-Edit', + seq: 1, + }); + await writeFile(file, 'v2\n'); + await captureSnapshot({ + sessionsRoot, + sessionId, + cwd, + filePath: file, + reason: 'post-Edit', + seq: 2, + }); + await captureSnapshot({ + sessionsRoot, + sessionId, + cwd, + filePath: file, + reason: 'pre-Edit', + seq: 3, + }); + await writeFile(file, 'v3\n'); + + const planned = await planRollback({ record: record(), sessionsRoot, cwd }); + if (planned.status !== 'ready') throw new Error('expected a plan'); + expect(planned.plan.warnings.join(' ')).toContain('later change'); + }); + + it('does not count the post-capture of the same call as a later edit', async () => { + // Otherwise every single-edit rollback would warn about itself and the + // warning would stop meaning anything. + const file = join(cwd, 'a.ts'); + await writeFile(file, 'v1\n'); + await captureSnapshot({ + sessionsRoot, + sessionId, + cwd, + filePath: file, + reason: 'pre-Edit', + seq: 1, + }); + await writeFile(file, 'v2\n'); + await captureSnapshot({ + sessionsRoot, + sessionId, + cwd, + filePath: file, + reason: 'post-Edit', + seq: 2, + }); + + const planned = await planRollback({ record: record(), sessionsRoot, cwd }); + if (planned.status !== 'ready') throw new Error('expected a plan'); + expect(planned.plan.warnings.join(' ')).not.toContain('later change'); + }); + + it('warns when the file was modified outside DeepCode', async () => { + const file = join(cwd, 'a.ts'); + await writeFile(file, 'v1\n'); + await captureSnapshot({ + sessionsRoot, + sessionId, + cwd, + filePath: file, + reason: 'pre-Edit', + seq: 1, + }); + await writeFile(file, 'v2\n'); + await captureSnapshot({ + sessionsRoot, + sessionId, + cwd, + filePath: file, + reason: 'post-Edit', + seq: 2, + }); + await writeFile(file, 'edited by hand\n'); + + const planned = await planRollback({ record: record(), sessionsRoot, cwd }); + if (planned.status !== 'ready') throw new Error('expected a plan'); + expect(planned.plan.warnings.join(' ')).toContain('outside DeepCode'); + }); + }); + + it('explains all four things before asking', async () => { + const file = join(cwd, 'a.ts'); + await writeFile(file, 'original\n'); + await captureSnapshot({ + sessionsRoot, + sessionId, + cwd, + filePath: file, + reason: 'pre-Edit', + seq: 1, + }); + + const planned = await planRollback({ record: record(), sessionsRoot, cwd }); + if (planned.status !== 'ready') throw new Error('expected a plan'); + const { explanation } = planned.plan; + expect(explanation.source).toContain('chg-1'); + expect(explanation.method).toBeTruthy(); + expect(explanation.application).toBeTruthy(); + expect(explanation.rollback).toBeTruthy(); + }); +}); diff --git a/packages/core/src/ledger/rollback.ts b/packages/core/src/ledger/rollback.ts new file mode 100644 index 0000000..8f75d1e --- /dev/null +++ b/packages/core/src/ledger/rollback.ts @@ -0,0 +1,139 @@ +// Planning a ledger rollback: find the checkpoint, work out what undoing it +// would cost, and hand the result to the No Silent Apply ceremony. +// Plan: docs/FLOATBOAT_ADOPTION_PLAN.md §2.B / §2.F + +import { createHash } from 'node:crypto'; +import { promises as fs } from 'node:fs'; +import { listSnapshots, restoreSnapshot, type Snapshot } from '../sessions/snapshots.js'; +import type { ApplyPlan } from '../runtime/apply-ceremony.js'; +import type { LedgerRecord } from './index.js'; + +export interface RollbackContext { + record: LedgerRecord; + sessionsRoot: string; + cwd: string; +} + +export type RollbackPlanResult = + | { status: 'ready'; plan: ApplyPlan; snapshot: Snapshot } + | { status: 'unavailable'; reason: string }; + +/** + * Build a rollback plan for a ledger record, or explain why there isn't one. + * + * Returns `unavailable` rather than throwing for the ordinary cases — no + * rollback hint, no session, a pruned snapshot. Those are expected states of an + * audit log, not errors, and the user needs the reason more than a stack trace. + */ +export async function planRollback(ctx: RollbackContext): Promise { + const { record } = ctx; + if (!record.rollbackHint || record.rollbackHint.ref === undefined) { + return { status: 'unavailable', reason: 'this record has no rollback point' }; + } + if (!record.threadId) { + return { status: 'unavailable', reason: 'this record is not tied to a session' }; + } + + const seq = Number(record.rollbackHint.ref); + if (!Number.isFinite(seq)) { + return { + status: 'unavailable', + reason: `unrecognised rollback ref "${record.rollbackHint.ref}"`, + }; + } + + let snapshots: Snapshot[]; + try { + snapshots = await listSnapshots({ sessionsRoot: ctx.sessionsRoot, sessionId: record.threadId }); + } catch (err) { + return { status: 'unavailable', reason: `could not read snapshots: ${(err as Error).message}` }; + } + + const target = snapshots.find((s) => s.seq === seq); + if (!target) { + // Snapshots and ledger records age out on different schedules, so this is a + // normal outcome rather than corruption. + return { status: 'unavailable', reason: `snapshot ${seq} is no longer available` }; + } + + const warnings = await detectConflicts(target, snapshots); + const preview = + target.kind === 'git' + ? [`restore tracked files changed since checkpoint ${target.gitRef ?? '(unknown)'}`] + : [`${target.existed === false ? 'delete' : 'restore'} ${target.filePath}`]; + + return { + status: 'ready', + snapshot: target, + plan: { + title: `Roll back ${record.id}: ${record.summary}`, + explanation: { + source: `ledger record ${record.id} (${record.timestamp})`, + method: + target.kind === 'git' + ? 'git diff between the pre-command checkpoint and the working tree' + : 'the file snapshot captured immediately before the change', + application: + target.kind === 'git' + ? 'git checkout of the changed files back to the checkpoint' + : target.existed === false + ? 'delete the file, which did not exist before the change' + : 'overwrite the file with its pre-change contents', + rollback: + 'this rollback is itself recorded in the governance ledger; use git to undo it if needed', + }, + preview, + warnings, + apply: () => restoreSnapshot(target), + }, + }; +} + +/** + * Conditions that make a rollback lossy. + * + * Undoing an old change is not the same operation as undoing the last one: any + * later edit to the same file gets discarded along with it. Applying that + * silently is precisely the failure the ceremony exists to prevent, so the + * plan carries the cost rather than the caller having to think of it. + */ +async function detectConflicts(target: Snapshot, all: Snapshot[]): Promise { + const warnings: string[] = []; + if (target.kind === 'git') { + warnings.push( + 'This restores every tracked file the command touched. Changes made to those files afterwards will be lost.', + ); + return warnings; + } + + const laterForSameFile = all.filter((s) => s.filePath === target.filePath && s.seq > target.seq); + // A `post-` capture of the same call is the change being undone, not a + // separate later edit, so it is not a conflict. + const laterEdits = laterForSameFile.filter((s) => !s.reason.startsWith('post-')); + if (laterEdits.length > 0) { + warnings.push( + `${laterEdits.length} later change(s) to ${target.filePath} in this session will be discarded too.`, + ); + } + + const newest = laterForSameFile.at(-1); + if (newest) { + const current = await hashFile(target.filePath); + if (current !== null && current !== newest.hash) { + warnings.push( + `${target.filePath} has been modified outside DeepCode since the last snapshot; those edits will be lost.`, + ); + } + } + + return warnings; +} + +async function hashFile(path: string): Promise { + try { + const content = await fs.readFile(path); + return createHash('sha256').update(content).digest('hex').slice(0, 16); + } catch { + return null; + } +} diff --git a/packages/core/src/runtime/apply-ceremony.test.ts b/packages/core/src/runtime/apply-ceremony.test.ts new file mode 100644 index 0000000..9c6765c --- /dev/null +++ b/packages/core/src/runtime/apply-ceremony.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + applyWithCeremony, + renderApplyPresentation, + type ApplyPlan, + type ApplyPresentation, +} from './apply-ceremony.js'; + +function plan(overrides: Partial> = {}): ApplyPlan { + return { + title: 'Apply the thing', + explanation: { + source: 'somewhere', + method: 'compared hashes', + application: 'overwrite the file', + rollback: 'git checkout', + }, + preview: ['restore a.ts'], + warnings: [], + apply: async () => 'applied', + ...overrides, + }; +} + +describe('applyWithCeremony', () => { + it('applies only after an explicit accept', async () => { + const apply = vi.fn(async () => 'done'); + const outcome = await applyWithCeremony(plan({ apply }), async () => 'accept'); + expect(outcome.status).toBe('applied'); + expect(outcome.result).toBe('done'); + expect(apply).toHaveBeenCalledOnce(); + }); + + it('does not apply on reject, and reject is not a failure', async () => { + // Selfware §6.3: after a reject the current version must still work, so + // this is an ordinary outcome rather than an error. + const apply = vi.fn(async () => 'done'); + const outcome = await applyWithCeremony(plan({ apply }), async () => 'reject'); + expect(outcome.status).toBe('rejected'); + expect(apply).not.toHaveBeenCalled(); + }); + + it('does not apply on defer', async () => { + const apply = vi.fn(async () => 'done'); + const outcome = await applyWithCeremony(plan({ apply }), async () => 'defer'); + expect(outcome.status).toBe('deferred'); + expect(apply).not.toHaveBeenCalled(); + }); + + it('shows the explanation, preview and warnings before asking', async () => { + let seen: ApplyPresentation | undefined; + await applyWithCeremony(plan({ warnings: ['data will be lost'] }), async (p) => { + seen = p; + return 'reject'; + }); + expect(seen?.explanation.source).toBe('somewhere'); + expect(seen?.preview).toEqual(['restore a.ts']); + expect(seen?.warnings).toEqual(['data will be lost']); + }); + + it('takes the rollback point before applying, not after', async () => { + const order: string[] = []; + const outcome = await applyWithCeremony( + plan({ + createRollbackPoint: async () => { + order.push('checkpoint'); + return 'ref-1'; + }, + apply: async () => { + order.push('apply'); + return 'done'; + }, + }), + async () => 'accept', + ); + expect(order).toEqual(['checkpoint', 'apply']); + expect(outcome.rollbackPoint).toBe('ref-1'); + }); + + it('refuses to apply when the rollback point cannot be created', async () => { + // The user accepted an operation described as reversible. Doing it + // irreversibly is a different operation than the one they agreed to. + const apply = vi.fn(async () => 'done'); + const outcome = await applyWithCeremony( + plan({ + apply, + createRollbackPoint: async () => { + throw new Error('disk full'); + }, + }), + async () => 'accept', + ); + expect(outcome.status).toBe('failed'); + expect(apply).not.toHaveBeenCalled(); + expect(outcome.error?.message).toContain('nothing was applied'); + }); + + it('reports a failing apply without throwing', async () => { + const outcome = await applyWithCeremony( + plan({ + apply: async () => { + throw new Error('boom'); + }, + }), + async () => 'accept', + ); + expect(outcome.status).toBe('failed'); + expect(outcome.error?.message).toBe('boom'); + }); + + it('asks exactly once', async () => { + const confirm = vi.fn(async () => 'accept' as const); + await applyWithCeremony(plan(), confirm); + expect(confirm).toHaveBeenCalledOnce(); + }); +}); + +describe('renderApplyPresentation', () => { + it('includes all four explanation fields', async () => { + const text = renderApplyPresentation({ + title: 'Roll back chg-1', + explanation: { + source: 'ledger record chg-1', + method: 'snapshot comparison', + application: 'overwrite a.ts', + rollback: 'recorded in the governance ledger', + }, + preview: ['restore a.ts'], + warnings: ['1 later change will be discarded'], + }); + expect(text).toContain('ledger record chg-1'); + expect(text).toContain('snapshot comparison'); + expect(text).toContain('overwrite a.ts'); + expect(text).toContain('governance ledger'); + expect(text).toContain('! 1 later change will be discarded'); + }); + + it('omits empty sections rather than printing empty headings', () => { + const text = renderApplyPresentation({ + title: 'x', + explanation: { source: 's', method: 'm', application: 'a', rollback: 'r' }, + preview: [], + warnings: [], + }); + expect(text).not.toContain('Warnings:'); + expect(text).not.toContain('Changes:'); + }); +}); diff --git a/packages/core/src/runtime/apply-ceremony.ts b/packages/core/src/runtime/apply-ceremony.ts new file mode 100644 index 0000000..0a4e68c --- /dev/null +++ b/packages/core/src/runtime/apply-ceremony.ts @@ -0,0 +1,123 @@ +// No Silent Apply — the four steps every high-impact application goes through. +// Plan: docs/FLOATBOAT_ADOPTION_PLAN.md §2.F +// +// Selfware §6.3 states it as: explain the update logic, show a summary, ask the +// user to accept/reject/defer, and apply only on accept — with a rollback point +// created first (§7). This module is that sequence, host-agnostic, so +// `ledger rollback`, plugin installs, and contract changes all get the same +// ceremony rather than four hand-rolled confirmation prompts that drift apart. +// +// The load-bearing decision: `confirm` is REQUIRED. There is no "assume yes" +// path and no default. A caller that cannot ask a human cannot apply — which is +// what makes "no silent apply" a property of the type signature rather than a +// convention people remember. + +export type ApplyDecision = 'accept' | 'reject' | 'defer'; + +/** Why an application is safe to accept — Selfware §6.3 step 1. */ +export interface ApplyExplanation { + /** Where the change comes from. */ + source: string; + /** How the current and proposed states were compared. */ + method: string; + /** What will physically happen on accept. */ + application: string; + /** How to get back, in the user's own hands. */ + rollback: string; +} + +/** What the user is shown before deciding. */ +export interface ApplyPresentation { + title: string; + explanation: ApplyExplanation; + /** Diff lines, a file list, or whatever makes the change concrete. */ + preview: string[]; + /** + * Conditions that make this riskier than usual — later edits that would be + * discarded, a file changed outside DeepCode. Never empty-by-omission: a + * caller that detects nothing passes `[]` deliberately. + */ + warnings: string[]; +} + +export interface ApplyPlan extends ApplyPresentation { + /** Create a restore point. Runs after accept, before `apply`. */ + createRollbackPoint?: () => Promise; + apply: () => Promise; +} + +export type ApplyConfirm = (presentation: ApplyPresentation) => Promise; + +export interface ApplyOutcome { + status: 'applied' | 'rejected' | 'deferred' | 'failed'; + result?: T; + /** Identifier of the restore point taken before applying, when one was. */ + rollbackPoint?: string; + error?: Error; +} + +/** + * Run the ceremony. Applies only on an explicit `accept`. + * + * Rejection is not a failure — Selfware §6.3 requires the current version stay + * working after a reject, so `rejected` and `deferred` are ordinary outcomes + * and neither throws. + */ +export async function applyWithCeremony( + plan: ApplyPlan, + confirm: ApplyConfirm, +): Promise> { + const decision = await confirm({ + title: plan.title, + explanation: plan.explanation, + preview: plan.preview, + warnings: plan.warnings, + }); + + if (decision === 'reject') return { status: 'rejected' }; + if (decision === 'defer') return { status: 'deferred' }; + + let rollbackPoint: string | undefined; + if (plan.createRollbackPoint) { + try { + rollbackPoint = await plan.createRollbackPoint(); + } catch (err) { + // Refuse rather than proceed unprotected. The user accepted an operation + // described as reversible; applying it irreversibly is not that operation. + return { + status: 'failed', + error: new Error( + `could not create a rollback point, so nothing was applied: ${(err as Error).message}`, + ), + }; + } + } + + try { + return { + status: 'applied', + result: await plan.apply(), + ...(rollbackPoint ? { rollbackPoint } : {}), + }; + } catch (err) { + return { status: 'failed', error: err as Error, ...(rollbackPoint ? { rollbackPoint } : {}) }; + } +} + +/** Render a presentation as plain text, so every host words it the same way. */ +export function renderApplyPresentation(p: ApplyPresentation): string { + const lines = [p.title, '']; + lines.push(` source : ${p.explanation.source}`); + lines.push(` compared : ${p.explanation.method}`); + lines.push(` applies : ${p.explanation.application}`); + lines.push(` rollback : ${p.explanation.rollback}`); + if (p.preview.length > 0) { + lines.push('', 'Changes:'); + for (const line of p.preview) lines.push(` ${line}`); + } + if (p.warnings.length > 0) { + lines.push('', 'Warnings:'); + for (const warning of p.warnings) lines.push(` ! ${warning}`); + } + return lines.join('\n') + '\n'; +}