From d12f6f01bd6a555f0b0fce254ea03c80e7ddd829 Mon Sep 17 00:00:00 2001 From: oratis Date: Sat, 8 Aug 2026 18:09:12 +0800 Subject: [PATCH] feat(core): add No Silent Apply and ledger rollback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Selfware §6.3 states the ceremony as explain / summarize / accept-reject-defer / apply-only-on-accept, with a rollback point taken first (§7). This makes it a reusable module rather than a prompt each caller hand-rolls, so ledger rollback, plugin installs, and contract changes cannot drift apart. The load-bearing decision is in the signature: `confirm` is required. There is no assume-yes path and no default, so a caller that cannot ask a human cannot apply. That makes "no silent apply" a property of the type rather than a convention people remember. If the rollback point cannot be created, nothing is applied. The user accepted an operation described as reversible; doing it irreversibly is a different operation than the one they agreed to. `deepcode ledger rollback ` uses it. Conflicts are surfaced before the decision, not resolved silently: later edits to the same file that would be discarded, modification outside DeepCode since the last snapshot, and Bash checkpoints restoring every tracked file the command touched. A `post-` capture of the same call is excluded — otherwise every single-edit rollback would warn about itself and the warning would stop meaning anything. The rollback is itself recorded on the governance timeline. An audit trail with an unlogged undo is not an audit trail. Missing hint, missing session, aged-out snapshot return a reason rather than throwing. Snapshots and ledger records expire on different schedules, so a record outliving its checkpoint is an expected state of an audit log. In the CLI prompt, anything other than an explicit yes is a no: a mistyped answer must not overwrite files. Co-Authored-By: Claude Opus 5 --- apps/cli/src/ledger-cmd.test.ts | 125 ++++++++- apps/cli/src/ledger-cmd.ts | 101 +++++++- docs/change-ledger.md | 33 +++ packages/core/src/index.ts | 13 + packages/core/src/ledger/rollback.test.ts | 243 ++++++++++++++++++ packages/core/src/ledger/rollback.ts | 139 ++++++++++ .../core/src/runtime/apply-ceremony.test.ts | 148 +++++++++++ packages/core/src/runtime/apply-ceremony.ts | 123 +++++++++ 8 files changed, 922 insertions(+), 3 deletions(-) create mode 100644 packages/core/src/ledger/rollback.test.ts create mode 100644 packages/core/src/ledger/rollback.ts create mode 100644 packages/core/src/runtime/apply-ceremony.test.ts create mode 100644 packages/core/src/runtime/apply-ceremony.ts 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'; +}