From 7a3e8f0fbc15dcebd6381f64ec3e3628c0c069b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Thu=E1=BA=ADn=20Ph=C3=A1t?= Date: Mon, 13 Apr 2026 20:47:04 +0700 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20[ENG-2070]=20dream=20undo=20?= =?UTF-8?q?=E2=80=94=20revert=20last=20dream=20from=20previousTexts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `brv dream --undo` to restore files changed by the last dream. Runs directly from CLI (no daemon/agent) using previousTexts stored in the dream log. Supports MERGE, TEMPORAL_UPDATE, CROSS_REFERENCE (skip), SYNTHESIZE, and PRUNE reversal with partial-failure resilience. --- src/oclif/commands/dream.ts | 56 +++ src/server/infra/dream/dream-undo.ts | 238 ++++++++++++ test/unit/infra/dream/dream-undo.test.ts | 452 +++++++++++++++++++++++ 3 files changed, 746 insertions(+) create mode 100644 src/server/infra/dream/dream-undo.ts create mode 100644 test/unit/infra/dream/dream-undo.test.ts diff --git a/src/oclif/commands/dream.ts b/src/oclif/commands/dream.ts index 548182fe6..66a2c65bd 100644 --- a/src/oclif/commands/dream.ts +++ b/src/oclif/commands/dream.ts @@ -2,8 +2,15 @@ import type {ITransportClient, TaskAck} from '@campfirein/brv-transport-client' import {Command, Flags} from '@oclif/core' import {randomUUID} from 'node:crypto' +import {join} from 'node:path' +import {BRV_DIR, CONTEXT_TREE_DIR} from '../../server/constants.js' import {type ProviderConfigResponse, TransportStateEventNames} from '../../server/core/domain/transport/schemas.js' +import {FileContextTreeManifestService} from '../../server/infra/context-tree/file-context-tree-manifest-service.js' +import {DreamLogStore} from '../../server/infra/dream/dream-log-store.js' +import {DreamStateService} from '../../server/infra/dream/dream-state-service.js' +import {undoLastDream} from '../../server/infra/dream/dream-undo.js' +import {resolveProject} from '../../server/infra/project/resolve-project.js' import {TaskEvents} from '../../shared/transport/events/index.js' import { type DaemonClientOptions, @@ -25,6 +32,9 @@ export default class Dream extends Command { '# Force dream (skip time/activity/queue gates, lock still checked)', '<%= config.bin %> <%= command.id %> --force', '', + '# Revert the last dream', + '<%= config.bin %> <%= command.id %> --undo', + '', '# JSON output', '<%= config.bin %> <%= command.id %> --format json', ] @@ -45,6 +55,10 @@ export default class Dream extends Command { max: MAX_TIMEOUT_SECONDS, min: MIN_TIMEOUT_SECONDS, }), + undo: Flags.boolean({ + default: false, + description: 'Revert the last dream', + }), } protected getDaemonClientOptions(): DaemonClientOptions { @@ -55,6 +69,11 @@ export default class Dream extends Command { const {flags: rawFlags} = await this.parse(Dream) const format = rawFlags.format === 'json' ? 'json' : 'text' + if (rawFlags.undo) { + await this.runUndo(format) + return + } + let providerContext: ProviderErrorContext | undefined try { @@ -113,6 +132,43 @@ export default class Dream extends Command { } } + private async runUndo(format: 'json' | 'text'): Promise { + const projectRoot = resolveProject()?.projectRoot ?? process.cwd() + const brvDir = join(projectRoot, BRV_DIR) + const contextTreeDir = join(brvDir, CONTEXT_TREE_DIR) + + try { + const result = await undoLastDream({ + contextTreeDir, + dreamLogStore: new DreamLogStore({baseDir: brvDir}), + dreamStateService: new DreamStateService({baseDir: brvDir}), + manifestService: new FileContextTreeManifestService({baseDirectory: projectRoot}), + }) + + if (format === 'json') { + writeJsonResponse({command: 'dream', data: {...result, status: 'undone'}, success: true}) + } else { + this.log(`Undone dream ${result.dreamId}`) + this.log(` Restored: ${result.restoredFiles.length} files`) + this.log(` Deleted: ${result.deletedFiles.length} files`) + this.log(` Restored archives: ${result.restoredArchives.length} files`) + if (result.errors.length > 0) { + this.log(` Errors: ${result.errors.length}`) + for (const e of result.errors) { + this.log(` - ${e}`) + } + } + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Undo failed' + if (format === 'json') { + writeJsonResponse({command: 'dream', data: {error: message, status: 'error'}, success: false}) + } else { + this.log(`Undo failed: ${message}`) + } + } + } + private async submitTask(props: { client: ITransportClient force: boolean diff --git a/src/server/infra/dream/dream-undo.ts b/src/server/infra/dream/dream-undo.ts new file mode 100644 index 000000000..40d787473 --- /dev/null +++ b/src/server/infra/dream/dream-undo.ts @@ -0,0 +1,238 @@ +/** + * Dream Undo — reverts the last dream's file changes using previousTexts from the dream log. + * + * Runs directly from CLI (no daemon/agent needed). Pure file I/O. + * Only undoes the LAST dream — not a history stack. + */ + +import {mkdir, unlink, writeFile} from 'node:fs/promises' +import {dirname, join} from 'node:path' + +import type {DreamLogEntry, DreamOperation} from './dream-log-schema.js' +import type {DreamState} from './dream-state-schema.js' + +export type DreamUndoDeps = { + archiveService?: {restoreEntry(stubPath: string, directory?: string): Promise} + contextTreeDir: string + dreamLogStore: { + getById(id: string): Promise + save(entry: DreamLogEntry): Promise + } + dreamStateService: { + read(): Promise + write(state: DreamState): Promise + } + manifestService: {buildManifest(dir?: string): Promise} +} + +export interface DreamUndoResult { + deletedFiles: string[] + dreamId: string + errors: string[] + restoredArchives: string[] + restoredFiles: string[] +} + +export async function undoLastDream(deps: DreamUndoDeps): Promise { + const {contextTreeDir, dreamLogStore, dreamStateService, manifestService} = deps + + // ── Precondition checks ───────────────────────────────────────────────── + const state = await dreamStateService.read() + if (!state.lastDreamLogId) { + throw new Error('No dream to undo') + } + + const log = await dreamLogStore.getById(state.lastDreamLogId) + if (!log) { + throw new Error(`Dream log not found: ${state.lastDreamLogId}`) + } + + if (log.status === 'undone') { + throw new Error(`Dream already undone: ${state.lastDreamLogId}`) + } + + if (log.status !== 'completed' && log.status !== 'partial') { + throw new Error(`Cannot undo dream with status: ${log.status}`) + } + + // ── Reverse operations ────────────────────────────────────────────────── + const result: DreamUndoResult = { + deletedFiles: [], + dreamId: log.id, + errors: [], + restoredArchives: [], + restoredFiles: [], + } + + // Track pending merges to remove (for PRUNE/SUGGEST_MERGE) + const mergesToRemove: Array<{mergeTarget: string; sourceFile: string}> = [] + + const reversed = [...log.operations].reverse() + for (const op of reversed) { + try { + // eslint-disable-next-line no-await-in-loop + await undoOperation(op, {contextTreeDir, deps, mergesToRemove, result}) + } catch (error) { + result.errors.push(error instanceof Error ? error.message : String(error)) + } + } + + // ── Post-undo: rebuild manifest ───────────────────────────────────────── + try { + await manifestService.buildManifest(contextTreeDir) + } catch { + // Manifest rebuild is best-effort + } + + // ── Post-undo: mark log as undone ─────────────────────────────────────── + const undoneLog: DreamLogEntry = { + completedAt: 'completedAt' in log ? (log.completedAt as number) : Date.now(), + id: log.id, + operations: log.operations, + startedAt: log.startedAt, + status: 'undone', + summary: log.summary, + trigger: log.trigger, + undoneAt: Date.now(), + } + await dreamLogStore.save(undoneLog) + + // ── Post-undo: rewind dream state ─────────────────────────────────────── + let {pendingMerges} = state + if (mergesToRemove.length > 0) { + pendingMerges = (pendingMerges ?? []).filter( + (pm) => !mergesToRemove.some((rm) => rm.sourceFile === pm.sourceFile && rm.mergeTarget === pm.mergeTarget), + ) + } + + await dreamStateService.write({ + ...state, + lastDreamAt: null, + pendingMerges, + totalDreams: Math.max(0, state.totalDreams - 1), + }) + + return result +} + +// ── Per-operation undo handlers ─────────────────────────────────────────────── + +type UndoContext = { + contextTreeDir: string + deps: DreamUndoDeps + mergesToRemove: Array<{mergeTarget: string; sourceFile: string}> + result: DreamUndoResult +} + +async function undoOperation(op: DreamOperation, ctx: UndoContext): Promise { + switch (op.type) { + case 'CONSOLIDATE': { + await undoConsolidate(op, ctx.contextTreeDir, ctx.result) + break + } + + case 'PRUNE': { + await undoPrune(op, ctx) + break + } + + case 'SYNTHESIZE': { + await undoSynthesize(op, ctx.contextTreeDir, ctx.result) + break + } + } +} + +async function undoConsolidate( + op: Extract, + contextTreeDir: string, + result: DreamUndoResult, +): Promise { + switch (op.action) { + case 'CROSS_REFERENCE': { + // Non-destructive — skip + break + } + + case 'MERGE': { + if (!op.previousTexts || Object.keys(op.previousTexts).length === 0) { + throw new Error(`Cannot undo MERGE: missing previousTexts for ${op.outputFile ?? op.inputFiles[0]}`) + } + + // Restore all source files from previousTexts + for (const [filePath, content] of Object.entries(op.previousTexts)) { + const fullPath = join(contextTreeDir, filePath) + // eslint-disable-next-line no-await-in-loop + await mkdir(dirname(fullPath), {recursive: true}) + // eslint-disable-next-line no-await-in-loop + await writeFile(fullPath, content, 'utf8') + result.restoredFiles.push(filePath) + } + + // Delete merged output if it wasn't an original source + if (op.outputFile && !op.previousTexts[op.outputFile]) { + await unlink(join(contextTreeDir, op.outputFile)).catch(() => {}) + result.deletedFiles.push(op.outputFile) + } + + break + } + + case 'TEMPORAL_UPDATE': { + if (!op.previousTexts || Object.keys(op.previousTexts).length === 0) { + throw new Error(`Cannot undo TEMPORAL_UPDATE: missing previousTexts for ${op.inputFiles[0]}`) + } + + for (const [filePath, content] of Object.entries(op.previousTexts)) { + const fullPath = join(contextTreeDir, filePath) + // eslint-disable-next-line no-await-in-loop + await mkdir(dirname(fullPath), {recursive: true}) + // eslint-disable-next-line no-await-in-loop + await writeFile(fullPath, content, 'utf8') + result.restoredFiles.push(filePath) + } + + break + } + } +} + +async function undoSynthesize( + op: Extract, + contextTreeDir: string, + result: DreamUndoResult, +): Promise { + // Delete the synthesized file + await unlink(join(contextTreeDir, op.outputFile)).catch(() => {}) + result.deletedFiles.push(op.outputFile) +} + +async function undoPrune( + op: Extract, + ctx: UndoContext, +): Promise { + switch (op.action) { + case 'ARCHIVE': { + if (!ctx.deps.archiveService) { + throw new Error(`Cannot undo PRUNE/ARCHIVE: no archive service available for ${op.file}`) + } + + const restored = await ctx.deps.archiveService.restoreEntry(op.file, ctx.contextTreeDir) + ctx.result.restoredArchives.push(restored) + break + } + + case 'KEEP': { + // No-op — nothing was changed + break + } + + case 'SUGGEST_MERGE': { + if (op.mergeTarget) { + ctx.mergesToRemove.push({mergeTarget: op.mergeTarget, sourceFile: op.file}) + } + + break + } + } +} diff --git a/test/unit/infra/dream/dream-undo.test.ts b/test/unit/infra/dream/dream-undo.test.ts new file mode 100644 index 000000000..07e971416 --- /dev/null +++ b/test/unit/infra/dream/dream-undo.test.ts @@ -0,0 +1,452 @@ +import {expect} from 'chai' +import {access, mkdir, readFile, writeFile} from 'node:fs/promises' +import {tmpdir} from 'node:os' +import {join} from 'node:path' +import {restore, type SinonStub, stub} from 'sinon' + +import type {DreamLogEntry} from '../../../../src/server/infra/dream/dream-log-schema.js' + +import {EMPTY_DREAM_STATE} from '../../../../src/server/infra/dream/dream-state-schema.js' +import {type DreamUndoDeps, undoLastDream} from '../../../../src/server/infra/dream/dream-undo.js' + +/** Build a completed dream log entry with optional operation overrides. */ +function completedLog(operations: DreamLogEntry['operations'] = []): DreamLogEntry { + return { + completedAt: Date.now(), + id: 'drm-1000', + operations, + startedAt: Date.now() - 1000, + status: 'completed', + summary: {consolidated: 0, errors: 0, flaggedForReview: 0, pruned: 0, synthesized: 0}, + trigger: 'cli', + } +} + +describe('undoLastDream', () => { + let ctxDir: string + let dreamLogStore: {getById: SinonStub; save: SinonStub} + let dreamStateService: {read: SinonStub; write: SinonStub} + let manifestService: {buildManifest: SinonStub} + let deps: DreamUndoDeps + + beforeEach(async () => { + ctxDir = join(tmpdir(), `brv-undo-test-${Date.now()}`) + await mkdir(ctxDir, {recursive: true}) + + dreamLogStore = { + getById: stub().resolves(completedLog()), + save: stub().resolves(), + } + dreamStateService = { + read: stub().resolves({...EMPTY_DREAM_STATE, lastDreamLogId: 'drm-1000', totalDreams: 1}), + write: stub().resolves(), + } + manifestService = { + buildManifest: stub().resolves({}), + } + + deps = {contextTreeDir: ctxDir, dreamLogStore, dreamStateService, manifestService} + }) + + afterEach(() => { + restore() + }) + + // ── Precondition checks ─────────────────────────────────────────────────── + + it('throws when no dream to undo (lastDreamLogId is null)', async () => { + dreamStateService.read.resolves({...EMPTY_DREAM_STATE}) + + try { + await undoLastDream(deps) + expect.fail('should have thrown') + } catch (error) { + expect((error as Error).message).to.include('No dream to undo') + } + }) + + it('throws when dream log not found', async () => { + dreamLogStore.getById.resolves(null) + + try { + await undoLastDream(deps) + expect.fail('should have thrown') + } catch (error) { + expect((error as Error).message).to.include('not found') + } + }) + + it('throws when dream already undone', async () => { + const undoneLog: DreamLogEntry = { + completedAt: Date.now(), + id: 'drm-1000', + operations: [], + startedAt: Date.now() - 1000, + status: 'undone', + summary: {consolidated: 0, errors: 0, flaggedForReview: 0, pruned: 0, synthesized: 0}, + trigger: 'cli', + undoneAt: Date.now(), + } + dreamLogStore.getById.resolves(undoneLog) + + try { + await undoLastDream(deps) + expect.fail('should have thrown') + } catch (error) { + expect((error as Error).message).to.include('already undone') + } + }) + + it('throws when dream status is error', async () => { + const errorLog: DreamLogEntry = { + completedAt: Date.now(), + error: 'boom', + id: 'drm-1000', + operations: [], + startedAt: Date.now() - 1000, + status: 'error', + summary: {consolidated: 0, errors: 0, flaggedForReview: 0, pruned: 0, synthesized: 0}, + trigger: 'cli', + } + dreamLogStore.getById.resolves(errorLog) + + try { + await undoLastDream(deps) + expect.fail('should have thrown') + } catch (error) { + expect((error as Error).message).to.include('Cannot undo') + } + }) + + it('allows undo of partial dreams', async () => { + const partialLog: DreamLogEntry = { + abortReason: 'Timeout', + completedAt: Date.now(), + id: 'drm-1000', + operations: [], + startedAt: Date.now() - 1000, + status: 'partial', + summary: {consolidated: 0, errors: 0, flaggedForReview: 0, pruned: 0, synthesized: 0}, + trigger: 'cli', + } + dreamLogStore.getById.resolves(partialLog) + + const result = await undoLastDream(deps) + expect(result.dreamId).to.equal('drm-1000') + }) + + // ── CONSOLIDATE/MERGE undo ──────────────────────────────────────────────── + + it('undoes MERGE: restores source files from previousTexts', async () => { + await mkdir(join(ctxDir, 'auth'), {recursive: true}) + await writeFile(join(ctxDir, 'auth/login.md'), 'Merged content') + + dreamLogStore.getById.resolves(completedLog([{ + action: 'MERGE', + inputFiles: ['auth/login.md', 'auth/login-v2.md'], + needsReview: true, + outputFile: 'auth/login.md', + previousTexts: { + 'auth/login-v2.md': 'Original login-v2 content', + 'auth/login.md': 'Original login content', + }, + reason: 'Redundant', + type: 'CONSOLIDATE', + }])) + + const result = await undoLastDream(deps) + + expect(result.restoredFiles).to.include('auth/login.md') + expect(result.restoredFiles).to.include('auth/login-v2.md') + + const login = await readFile(join(ctxDir, 'auth/login.md'), 'utf8') + expect(login).to.equal('Original login content') + const loginV2 = await readFile(join(ctxDir, 'auth/login-v2.md'), 'utf8') + expect(loginV2).to.equal('Original login-v2 content') + }) + + it('undoes MERGE: deletes output file when not in previousTexts', async () => { + await mkdir(join(ctxDir, 'auth'), {recursive: true}) + await writeFile(join(ctxDir, 'auth/combined.md'), 'Merged content') + + dreamLogStore.getById.resolves(completedLog([{ + action: 'MERGE', + inputFiles: ['auth/a.md', 'auth/b.md'], + needsReview: true, + outputFile: 'auth/combined.md', + previousTexts: { + 'auth/a.md': 'Content A', + 'auth/b.md': 'Content B', + }, + reason: 'Merge', + type: 'CONSOLIDATE', + }])) + + const result = await undoLastDream(deps) + + expect(result.deletedFiles).to.include('auth/combined.md') + expect(result.restoredFiles).to.include('auth/a.md') + expect(result.restoredFiles).to.include('auth/b.md') + + let exists = true + try { + await access(join(ctxDir, 'auth/combined.md')) + } catch { + exists = false + } + + expect(exists).to.be.false + }) + + // ── CONSOLIDATE/TEMPORAL_UPDATE undo ────────────────────────────────────── + + it('undoes TEMPORAL_UPDATE: restores original content', async () => { + await mkdir(join(ctxDir, 'api'), {recursive: true}) + await writeFile(join(ctxDir, 'api/rate-limits.md'), 'Updated content') + + dreamLogStore.getById.resolves(completedLog([{ + action: 'TEMPORAL_UPDATE', + inputFiles: ['api/rate-limits.md'], + needsReview: true, + previousTexts: {'api/rate-limits.md': 'Original rate limits'}, + reason: 'Outdated', + type: 'CONSOLIDATE', + }])) + + const result = await undoLastDream(deps) + + expect(result.restoredFiles).to.include('api/rate-limits.md') + const content = await readFile(join(ctxDir, 'api/rate-limits.md'), 'utf8') + expect(content).to.equal('Original rate limits') + }) + + // ── CONSOLIDATE/CROSS_REFERENCE undo ────────────────────────────────────── + + it('skips CROSS_REFERENCE (non-destructive)', async () => { + dreamLogStore.getById.resolves(completedLog([{ + action: 'CROSS_REFERENCE', + inputFiles: ['auth/jwt.md', 'auth/oauth.md'], + needsReview: false, + reason: 'Related', + type: 'CONSOLIDATE', + }])) + + const result = await undoLastDream(deps) + + expect(result.restoredFiles).to.be.empty + expect(result.deletedFiles).to.be.empty + }) + + // ── SYNTHESIZE undo (forward-compatible) ────────────────────────────────── + + it('undoes SYNTHESIZE: deletes created file', async () => { + await mkdir(join(ctxDir, 'auth'), {recursive: true}) + await writeFile(join(ctxDir, 'auth/overview.md'), 'Synthesized content') + + dreamLogStore.getById.resolves(completedLog([{ + action: 'CREATE', + confidence: 0.9, + needsReview: false, + outputFile: 'auth/overview.md', + sources: ['auth/jwt.md', 'auth/oauth.md'], + type: 'SYNTHESIZE', + }])) + + const result = await undoLastDream(deps) + + expect(result.deletedFiles).to.include('auth/overview.md') + let exists = true + try { + await access(join(ctxDir, 'auth/overview.md')) + } catch { + exists = false + } + + expect(exists).to.be.false + }) + + // ── PRUNE undo (forward-compatible) ─────────────────────────────────────── + + it('undoes PRUNE/ARCHIVE: calls archiveService.restoreEntry', async () => { + const archiveService = {restoreEntry: stub().resolves('auth/old-doc.md')} + + dreamLogStore.getById.resolves(completedLog([{ + action: 'ARCHIVE', + file: 'auth/old-doc.md', + needsReview: false, + reason: 'Stale', + type: 'PRUNE', + }])) + + const result = await undoLastDream({...deps, archiveService}) + + expect(archiveService.restoreEntry.calledOnce).to.be.true + expect(result.restoredArchives).to.include('auth/old-doc.md') + }) + + it('skips PRUNE/KEEP (no-op)', async () => { + dreamLogStore.getById.resolves(completedLog([{ + action: 'KEEP', + file: 'auth/important.md', + needsReview: false, + reason: 'Active', + type: 'PRUNE', + }])) + + const result = await undoLastDream(deps) + expect(result.restoredFiles).to.be.empty + expect(result.deletedFiles).to.be.empty + }) + + it('undoes PRUNE/SUGGEST_MERGE: removes from pendingMerges', async () => { + dreamStateService.read.resolves({ + ...EMPTY_DREAM_STATE, + lastDreamLogId: 'drm-1000', + pendingMerges: [ + {mergeTarget: 'auth/target.md', sourceFile: 'auth/source.md'}, + {mergeTarget: 'api/other.md', sourceFile: 'api/src.md'}, + ], + totalDreams: 1, + }) + + dreamLogStore.getById.resolves(completedLog([{ + action: 'SUGGEST_MERGE', + file: 'auth/source.md', + mergeTarget: 'auth/target.md', + needsReview: false, + reason: 'Similar', + type: 'PRUNE', + }])) + + await undoLastDream(deps) + + const writtenState = dreamStateService.write.firstCall.args[0] + expect(writtenState.pendingMerges).to.have.lengthOf(1) + expect(writtenState.pendingMerges[0].sourceFile).to.equal('api/src.md') + }) + + // ── Post-undo checks ───────────────────────────────────────────────────── + + it('marks dream log as undone with undoneAt', async () => { + const result = await undoLastDream(deps) + + expect(result.dreamId).to.equal('drm-1000') + + const savedLog = dreamLogStore.save.lastCall.args[0] + expect(savedLog.status).to.equal('undone') + expect(savedLog.undoneAt).to.be.a('number') + expect(savedLog.id).to.equal('drm-1000') + }) + + it('rewinds dream state: nulls lastDreamAt and decrements totalDreams', async () => { + dreamStateService.read.resolves({ + ...EMPTY_DREAM_STATE, + lastDreamLogId: 'drm-1000', + totalDreams: 3, + }) + + await undoLastDream(deps) + + const writtenState = dreamStateService.write.firstCall.args[0] + expect(writtenState.lastDreamAt).to.be.null + expect(writtenState.totalDreams).to.equal(2) + }) + + it('does not decrement totalDreams below zero', async () => { + dreamStateService.read.resolves({ + ...EMPTY_DREAM_STATE, + lastDreamLogId: 'drm-1000', + totalDreams: 0, + }) + + await undoLastDream(deps) + + const writtenState = dreamStateService.write.firstCall.args[0] + expect(writtenState.totalDreams).to.equal(0) + }) + + it('rebuilds manifest after undo', async () => { + await undoLastDream(deps) + expect(manifestService.buildManifest.calledOnce).to.be.true + }) + + // ── Mixed operations and partial failure ────────────────────────────────── + + it('reverses operations in reverse order', async () => { + await mkdir(join(ctxDir, 'auth'), {recursive: true}) + await mkdir(join(ctxDir, 'api'), {recursive: true}) + await writeFile(join(ctxDir, 'auth/login.md'), 'Merged') + await writeFile(join(ctxDir, 'api/config.md'), 'Updated') + + dreamLogStore.getById.resolves(completedLog([ + { + action: 'MERGE', + inputFiles: ['auth/login.md', 'auth/signup.md'], + needsReview: true, + outputFile: 'auth/login.md', + previousTexts: {'auth/login.md': 'Original login', 'auth/signup.md': 'Original signup'}, + reason: 'Merge', + type: 'CONSOLIDATE', + }, + { + action: 'TEMPORAL_UPDATE', + inputFiles: ['api/config.md'], + needsReview: true, + previousTexts: {'api/config.md': 'Original config'}, + reason: 'Update', + type: 'CONSOLIDATE', + }, + ])) + + const result = await undoLastDream(deps) + + expect(result.restoredFiles).to.have.lengthOf(3) + + const login = await readFile(join(ctxDir, 'auth/login.md'), 'utf8') + expect(login).to.equal('Original login') + const signup = await readFile(join(ctxDir, 'auth/signup.md'), 'utf8') + expect(signup).to.equal('Original signup') + const config = await readFile(join(ctxDir, 'api/config.md'), 'utf8') + expect(config).to.equal('Original config') + }) + + it('continues on error and collects errors', async () => { + await mkdir(join(ctxDir, 'api'), {recursive: true}) + await writeFile(join(ctxDir, 'api/config.md'), 'Updated') + + dreamLogStore.getById.resolves(completedLog([ + { + action: 'MERGE', + inputFiles: ['auth/a.md', 'auth/b.md'], + needsReview: true, + outputFile: 'auth/a.md', + // No previousTexts — will cause error + reason: 'Merge', + type: 'CONSOLIDATE', + }, + { + action: 'TEMPORAL_UPDATE', + inputFiles: ['api/config.md'], + needsReview: true, + previousTexts: {'api/config.md': 'Original'}, + reason: 'Update', + type: 'CONSOLIDATE', + }, + ])) + + const result = await undoLastDream(deps) + + expect(result.errors.length).to.be.greaterThan(0) + expect(result.restoredFiles).to.include('api/config.md') + }) + + it('returns empty result for dream with no operations', async () => { + const result = await undoLastDream(deps) + + expect(result.dreamId).to.equal('drm-1000') + expect(result.restoredFiles).to.be.empty + expect(result.deletedFiles).to.be.empty + expect(result.restoredArchives).to.be.empty + expect(result.errors).to.be.empty + }) +}) From 8713ebe8507f61ef908fec7dbaf4494738b608f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Thu=E1=BA=ADn=20Ph=C3=A1t?= Date: Mon, 13 Apr 2026 20:59:01 +0700 Subject: [PATCH 2/2] fix: [ENG-2070] address CC Reviewer feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes applied (6): - #1: Path traversal guard — safePath() validates all file paths stay within contextTreeDir before write/delete - #2: SYNTHESIZE/UPDATE throws instead of deleting pre-existing file (previousTexts not captured for synthesize operations) - #3: Remove unnecessary `as number` cast — TypeScript narrows log.completedAt after status checks - #5: Manifest rebuild errors now reported in result.errors instead of silently swallowed - #6: unlinkSafe() only swallows ENOENT, rethrows permission errors - #9: Added test for SYNTHESIZE/UPDATE error path Skipped (4): - #4: lastDreamLogId intentionally NOT cleared — keeping it allows "Dream already undone" error which is more informative than "No dream to undo" on double-undo - #7: resolveProject() fallback to cwd follows existing codebase pattern (curate/view.ts uses the same approach) - #8: No exhaustiveness check in switch — silent no-op is correct for undo of unknown future operation types (safer than crashing) - #10: No lastDreamLogId assertion in test — follows from #4 skip --- src/server/infra/dream/dream-undo.ts | 42 +++++++++++++++++++----- test/unit/infra/dream/dream-undo.test.ts | 24 ++++++++++++++ 2 files changed, 57 insertions(+), 9 deletions(-) diff --git a/src/server/infra/dream/dream-undo.ts b/src/server/infra/dream/dream-undo.ts index 40d787473..fb0d29fc3 100644 --- a/src/server/infra/dream/dream-undo.ts +++ b/src/server/infra/dream/dream-undo.ts @@ -6,7 +6,7 @@ */ import {mkdir, unlink, writeFile} from 'node:fs/promises' -import {dirname, join} from 'node:path' +import {dirname, resolve} from 'node:path' import type {DreamLogEntry, DreamOperation} from './dream-log-schema.js' import type {DreamState} from './dream-state-schema.js' @@ -80,13 +80,13 @@ export async function undoLastDream(deps: DreamUndoDeps): Promise { + try { + await unlink(filePath) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + } +} + +/** Resolve a relative path within contextTreeDir, rejecting traversal outside the tree. */ +function safePath(contextTreeDir: string, relativePath: string): string { + const full = resolve(contextTreeDir, relativePath) + if (!full.startsWith(contextTreeDir + '/') && full !== contextTreeDir) { + throw new Error(`Path traversal blocked: ${relativePath}`) + } + + return full +} + // ── Per-operation undo handlers ─────────────────────────────────────────────── type UndoContext = { @@ -161,7 +180,7 @@ async function undoConsolidate( // Restore all source files from previousTexts for (const [filePath, content] of Object.entries(op.previousTexts)) { - const fullPath = join(contextTreeDir, filePath) + const fullPath = safePath(contextTreeDir, filePath) // eslint-disable-next-line no-await-in-loop await mkdir(dirname(fullPath), {recursive: true}) // eslint-disable-next-line no-await-in-loop @@ -171,7 +190,7 @@ async function undoConsolidate( // Delete merged output if it wasn't an original source if (op.outputFile && !op.previousTexts[op.outputFile]) { - await unlink(join(contextTreeDir, op.outputFile)).catch(() => {}) + await unlinkSafe(safePath(contextTreeDir, op.outputFile)) result.deletedFiles.push(op.outputFile) } @@ -184,7 +203,7 @@ async function undoConsolidate( } for (const [filePath, content] of Object.entries(op.previousTexts)) { - const fullPath = join(contextTreeDir, filePath) + const fullPath = safePath(contextTreeDir, filePath) // eslint-disable-next-line no-await-in-loop await mkdir(dirname(fullPath), {recursive: true}) // eslint-disable-next-line no-await-in-loop @@ -202,8 +221,13 @@ async function undoSynthesize( contextTreeDir: string, result: DreamUndoResult, ): Promise { - // Delete the synthesized file - await unlink(join(contextTreeDir, op.outputFile)).catch(() => {}) + // UPDATE modified a pre-existing file — can't undo without previousTexts (not captured by SYNTHESIZE) + if (op.action === 'UPDATE') { + throw new Error(`Cannot undo SYNTHESIZE/UPDATE: previousTexts not captured for ${op.outputFile}`) + } + + // CREATE — delete the synthesized file + await unlinkSafe(safePath(contextTreeDir, op.outputFile)) result.deletedFiles.push(op.outputFile) } diff --git a/test/unit/infra/dream/dream-undo.test.ts b/test/unit/infra/dream/dream-undo.test.ts index 07e971416..153024f08 100644 --- a/test/unit/infra/dream/dream-undo.test.ts +++ b/test/unit/infra/dream/dream-undo.test.ts @@ -265,6 +265,30 @@ describe('undoLastDream', () => { expect(exists).to.be.false }) + it('throws for SYNTHESIZE/UPDATE (no previousTexts to restore)', async () => { + await mkdir(join(ctxDir, 'auth'), {recursive: true}) + await writeFile(join(ctxDir, 'auth/overview.md'), 'Updated content') + + dreamLogStore.getById.resolves(completedLog([{ + action: 'UPDATE', + confidence: 0.8, + needsReview: false, + outputFile: 'auth/overview.md', + sources: ['auth/jwt.md'], + type: 'SYNTHESIZE', + }])) + + const result = await undoLastDream(deps) + + // Error collected, file NOT deleted + expect(result.errors.length).to.be.greaterThan(0) + expect(result.errors[0]).to.include('SYNTHESIZE/UPDATE') + expect(result.deletedFiles).to.be.empty + + const content = await readFile(join(ctxDir, 'auth/overview.md'), 'utf8') + expect(content).to.equal('Updated content') + }) + // ── PRUNE undo (forward-compatible) ─────────────────────────────────────── it('undoes PRUNE/ARCHIVE: calls archiveService.restoreEntry', async () => {