diff --git a/packages/codingcode/src/agent/agent.ts b/packages/codingcode/src/agent/agent.ts index 4dc4b6b8..aa5812f1 100644 --- a/packages/codingcode/src/agent/agent.ts +++ b/packages/codingcode/src/agent/agent.ts @@ -212,9 +212,6 @@ export const AgentLayer = Layer.effect( } } - // checkpoint baseline - yield* checkpoint.snapshotBaseline(state.cwd, sid, turnId); - const rulesText = yield* rules.getAllRules(state.cwd); // 出站队列挂在 sink 上,本回合是它的唯一读者 @@ -558,6 +555,11 @@ export const AgentLayer = Layer.effect( signal: abortSignal, activeProfile: profile?.name, model, + onBeforeMutation: checkpoint.snapshotBeforeWrite( + state.cwd, + sid, + state.currentTurnId + ), }, toolLookup ) @@ -591,9 +593,6 @@ export const AgentLayer = Layer.effect( } } - yield* checkpoint.snapshotFinal(projectPath, state.sessionId, state.currentTurnId); - yield* flushMemoryInBackground(state.sessionId, model, state.cwd); - if (lastResult) return lastResult; const maxErr = AgentError.maxStepsReached(maxSteps); @@ -628,9 +627,6 @@ export const AgentLayer = Layer.effect( error: { message: 'agent terminated without end frame', code: 'AGENT_TERMINATED' }, }) .pipe(Effect.ignore); - yield* checkpoint - .snapshotFinal(state.cwd, sid, state.currentTurnId) - .pipe(Effect.ignore); yield* flushMemoryInBackground(state.sessionId, model, state.cwd); }) ) diff --git a/packages/codingcode/src/checkpoint/checkpoint.ts b/packages/codingcode/src/checkpoint/checkpoint.ts index 24eabb03..77255569 100644 --- a/packages/codingcode/src/checkpoint/checkpoint.ts +++ b/packages/codingcode/src/checkpoint/checkpoint.ts @@ -1,21 +1,24 @@ import { Layer, Effect } from 'effect'; import { resolve } from 'path'; import { ShadowGit } from './shadow-git.js'; -import { ProjectLock } from './project-lock.js'; import { normalizePath } from '../util/path.js'; -import { commitMsg, toGitPath, ProjectCache } from './utils.js'; -import { getCompletedTurnsFor, getTurnRestorePlan, getRollbackToTurnPlan } from './turn-query.js'; +import { commitMsg, rollbackSnapshotMsg, toGitPath, ProjectCache } from './utils.js'; +import { + getTurnsWithBaseline, + getRollbackToTurnPlan, + findLatestRollbackSnapshot, + findRollbackSnapshot, +} from './turn-query.js'; import { emptyRollbackResult, executeRollback } from './rollback-engine.js'; import { CheckpointService } from './port.js'; // ---- Effect Service ---- +const shadowGitByProject = new ProjectCache(10); +const baselineTurnBySession = new Map(); export const CheckpointLayer = Layer.effect( CheckpointService, Effect.gen(function* () { - const shadowGitByProject = new ProjectCache(10); - const lockByProject = new ProjectCache(10); - function ensure(projectPath: string): ShadowGit { const normalized = normalizePath(projectPath); return shadowGitByProject.get(normalized, () => { @@ -25,95 +28,59 @@ export const CheckpointLayer = Layer.effect( }); } - function lockFor(projectPath: string): ProjectLock { - const normalized = normalizePath(projectPath); - return lockByProject.get(normalized, () => new ProjectLock(normalized)); - } - - function doSnapshotFinal(sg: ShadowGit, sessionId: string, turnId: number): void { - const lock = lockFor(sg.projectPath); - lock.lock(); - try { - sg.commit(commitMsg(sessionId, turnId, 'final')); - } finally { - lock.unlock(); - } + function latestBaselineTurn(sg: ShadowGit, sessionId: string): number { + const turns = getTurnsWithBaseline(sg, sessionId); + return turns.length > 0 ? turns[turns.length - 1]! : 0; } - function repairIncompleteTurn(sg: ShadowGit, sessionId: string): void { - const completed = getCompletedTurnsFor(sg, sessionId); - const candidate = completed.length > 0 ? completed[completed.length - 1]! + 1 : 1; - const baseline = sg.findCommitByMessage(commitMsg(sessionId, candidate, 'baseline')); - if (!baseline) return; - const final = sg.findCommitByMessage(commitMsg(sessionId, candidate, 'final')); - if (final) return; - doSnapshotFinal(sg, sessionId, candidate); - } - - function latestCompletedTurn(sg: ShadowGit, sessionId: string): number { - const completed = getCompletedTurnsFor(sg, sessionId); - return completed.length > 0 ? completed[completed.length - 1]! : 0; + function ensureRollbackSnapshot( + sg: ShadowGit, + sessionId: string, + rollbackTurnId: number + ): void { + if (findRollbackSnapshot(sg, sessionId, rollbackTurnId)) return; + sg.stageAll(); + if (sg.isTooLargeForSnapshot()) return; + sg.commit(rollbackSnapshotMsg(sessionId, rollbackTurnId)); } return { - snapshotBaseline: (projectPath: string, sessionId: string, turnId: number) => + snapshotBeforeWrite: (projectPath: string, sessionId: string, turnId: number) => Effect.sync(() => { + if (baselineTurnBySession.get(sessionId) === turnId) return; + baselineTurnBySession.set(sessionId, turnId); const sg = ensure(projectPath); - repairIncompleteTurn(sg, sessionId); + sg.stageAll(); if (sg.isTooLargeForSnapshot()) return; - const lock = lockFor(projectPath); - const msg = commitMsg(sessionId, turnId, 'baseline'); - lock.lock(); - try { - sg.commit(msg); - } finally { - lock.unlock(); - } - }), - - snapshotFinal: (projectPath: string, sessionId: string, turnId: number) => - Effect.sync(() => { - const sg = ensure(projectPath); - if (sg.isTooLargeForSnapshot()) return; - doSnapshotFinal(sg, sessionId, turnId); + sg.commit(commitMsg(sessionId, turnId)); }), getCheckpointDiff: (projectPath: string, sessionId: string, turnId?: number) => Effect.sync(() => { const sg = ensure(projectPath); - repairIncompleteTurn(sg, sessionId); - const latestTurnId = turnId ?? latestCompletedTurn(sg, sessionId); + sg.stageAll(); + const latestTurnId = turnId ?? latestBaselineTurn(sg, sessionId); if (latestTurnId === 0) { return { turnId: 0, files: [] }; } - const baseline = sg.findCommitByMessage(commitMsg(sessionId, latestTurnId, 'baseline')); - const final = sg.findCommitByMessage(commitMsg(sessionId, latestTurnId, 'final')); - if (!baseline || !final) return { turnId: latestTurnId, files: [] }; + const baseline = sg.findCommitByMessage(commitMsg(sessionId, latestTurnId)); + if (!baseline) return { turnId: latestTurnId, files: [] }; - const allChanges = sg.diffFiles(baseline, final); - const rawAllFiles = allChanges.map((c) => normalizePath(resolve(projectPath, c.file))); - const allFiles = [...new Set(rawAllFiles)]; - - const files = allFiles.map((f) => { + const files = sg.diffNameStatusAgainstWorkspace(baseline).map((c) => { + const f = normalizePath(resolve(projectPath, c.file)); const relPath = toGitPath(projectPath, f); - const diffResult = sg.git('diff', baseline, final, '--', relPath); - const rawPath = normalizePath(resolve(projectPath, relPath)); + const diffText = sg.git('diff', baseline, '--', relPath).stdout; let insertions = 0; let deletions = 0; - for (const line of diffResult.stdout.split('\n')) { + for (const line of diffText.split('\n')) { if (line.startsWith('+') && !line.startsWith('+++')) insertions++; else if (line.startsWith('-') && !line.startsWith('---')) deletions++; } return { path: f, - status: - allChanges.find( - (c) => - normalizePath(resolve(projectPath, c.file)).toLowerCase() === - rawPath.toLowerCase() - )?.status ?? 'M', - diff: diffResult.stdout, + status: c.status, + diff: diffText, insertions, deletions, }; @@ -130,56 +97,80 @@ export const CheckpointLayer = Layer.effect( ) => Effect.sync(() => { const sg = ensure(projectPath); - const targetTurnId = turnId ?? latestCompletedTurn(sg, sessionId); + sg.stageAll(); + const targetTurnId = turnId ?? latestBaselineTurn(sg, sessionId); if (targetTurnId === 0) return emptyRollbackResult(0); - const plan = getTurnRestorePlan(sg, sessionId, targetTurnId); + const plan = getRollbackToTurnPlan(sg, sessionId, targetTurnId); if (!plan) { return emptyRollbackResult(targetTurnId); } - return executeRollback(plan, files, sg, lockFor(projectPath)); + // files 为空 = 整轮:自动算该 baseline 以来的全部变更文件 + const selected = + files.length > 0 + ? files + : sg + .diffNameStatusAgainstWorkspace(plan.baseline) + .map((c) => resolve(projectPath, c.file)); + if (selected.length === 0) { + return { + reverted: true, + throughTurnId: targetTurnId, + affectedTurns: plan.affectedTurns, + selectedFiles: [], + }; + } + ensureRollbackSnapshot(sg, sessionId, latestBaselineTurn(sg, sessionId)); + return executeRollback(plan, selected, sg); }), previewRollbackDiff: (projectPath: string, sessionId: string, throughTurnId: number) => Effect.sync(() => { const sg = ensure(projectPath); + sg.stageAll(); const plan = getRollbackToTurnPlan(sg, sessionId, throughTurnId); if (!plan) { return { throughTurnId, affectedTurns: [], diff: '' }; } - const result = sg.git('diff', plan.baseline); + const diff = sg.git('diff', plan.baseline).stdout; return { throughTurnId, affectedTurns: plan.affectedTurns, - diff: result.stdout, + diff, }; }), - rollbackCodeToTurn: (projectPath: string, sessionId: string, throughTurnId: number) => + undoRollback: (projectPath: string, sessionId: string, files: string[]) => Effect.sync(() => { const sg = ensure(projectPath); - const plan = getRollbackToTurnPlan(sg, sessionId, throughTurnId); - if (!plan) { - return emptyRollbackResult(throughTurnId); + // 只允许撤销最近一次回退:快照的回退时刻必须就是当前最新轮, + // 否则期间开了新轮,撤销会把新轮改动一起冲掉 + const currentTurnId = latestBaselineTurn(sg, sessionId); + if (currentTurnId === 0) return emptyRollbackResult(0); + const snapshot = findLatestRollbackSnapshot(sg, sessionId); + if (!snapshot || snapshot.rollbackTurnId !== currentTurnId) { + return emptyRollbackResult(currentTurnId); } - const diffResult = sg.git('diff', '--name-only', plan.baseline); - const selectedFiles = diffResult.stdout - .trim() - .split('\n') - .filter(Boolean) - .map((f) => resolve(projectPath, f)); - - if (selectedFiles.length === 0) { - return { - reverted: true, - throughTurnId, - affectedTurns: plan.affectedTurns, - selectedFiles: [], - }; + let selected = files; + if (selected.length === 0) { + // 未指定文件 = 整轮撤销:范围 = 快照与 baseline 的差异(即那次回退改动过的文件) + const baseline = sg.findCommitByMessage(commitMsg(sessionId, currentTurnId)); + if (!baseline) return emptyRollbackResult(currentTurnId); + selected = sg + .diffFiles(baseline, snapshot.commit) + .map((c) => resolve(projectPath, c.file)); + if (selected.length === 0) return emptyRollbackResult(currentTurnId); } - return executeRollback(plan, selectedFiles, sg, lockFor(projectPath)); + sg.checkoutFiles(snapshot.commit, selected); + sg.stageAll(); + return { + reverted: true, + throughTurnId: currentTurnId, + affectedTurns: [], + selectedFiles: selected, + }; }), }; }) diff --git a/packages/codingcode/src/checkpoint/port.ts b/packages/codingcode/src/checkpoint/port.ts index 04e23db4..4251ac97 100644 --- a/packages/codingcode/src/checkpoint/port.ts +++ b/packages/codingcode/src/checkpoint/port.ts @@ -3,8 +3,7 @@ import type { Effect } from 'effect'; import type { CheckpointDiff, CodeRollbackResult, RollbackPreviewDiff } from './types.js'; export interface CheckpointShape { - snapshotBaseline(projectPath: string, sessionId: string, turnId: number): Effect.Effect; - snapshotFinal(projectPath: string, sessionId: string, turnId: number): Effect.Effect; + snapshotBeforeWrite(projectPath: string, sessionId: string, turnId: number): Effect.Effect; getCheckpointDiff( projectPath: string, sessionId: string, @@ -21,10 +20,10 @@ export interface CheckpointShape { sessionId: string, throughTurnId: number ): Effect.Effect; - rollbackCodeToTurn( + undoRollback( projectPath: string, sessionId: string, - throughTurnId: number + files: string[] ): Effect.Effect; } diff --git a/packages/codingcode/src/checkpoint/project-lock.ts b/packages/codingcode/src/checkpoint/project-lock.ts deleted file mode 100644 index 56c9c4ef..00000000 --- a/packages/codingcode/src/checkpoint/project-lock.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { openSync, closeSync, unlinkSync, mkdirSync } from 'fs'; -import { join, dirname } from 'path'; -import { projectDataDir } from '../util/path.js'; - -export class ProjectLock { - private readonly lockPath: string; - private locked = false; - - constructor(projectPath: string) { - this.lockPath = join(projectDataDir(projectPath), 'checkpoint', 'repo.lock'); - } - - lock(): void { - mkdirSync(dirname(this.lockPath), { recursive: true }); - for (let i = 0; ; i++) { - try { - const fd = openSync(this.lockPath, 'wx'); - closeSync(fd); - this.locked = true; - return; - } catch { - if (i > 500) throw new Error('ProjectLock timeout'); - } - } - } - - unlock(): void { - if (this.locked) { - try { - unlinkSync(this.lockPath); - } catch { - /* ignore */ - } - this.locked = false; - } - } -} diff --git a/packages/codingcode/src/checkpoint/rollback-engine.ts b/packages/codingcode/src/checkpoint/rollback-engine.ts index 76bbe4e2..96c2b7e6 100644 --- a/packages/codingcode/src/checkpoint/rollback-engine.ts +++ b/packages/codingcode/src/checkpoint/rollback-engine.ts @@ -1,5 +1,4 @@ import type { ShadowGit } from './shadow-git.js'; -import type { ProjectLock } from './project-lock.js'; import type { CodeRollbackResult, RestorePlan } from './types.js'; export function emptyRollbackResult(turnId: number): CodeRollbackResult { @@ -14,8 +13,7 @@ export function emptyRollbackResult(turnId: number): CodeRollbackResult { export function executeRollback( plan: RestorePlan, selectedFiles: string[], - sg: ShadowGit, - lock: ProjectLock + sg: ShadowGit ): CodeRollbackResult { if (selectedFiles.length === 0) { return { @@ -26,17 +24,15 @@ export function executeRollback( }; } - lock.lock(); - try { - sg.checkoutFiles(plan.baseline, selectedFiles); + sg.checkoutFiles(plan.baseline, selectedFiles); + // restore 只写工作区:被删的新文件会残留 index 条目(diff 面板会多出 D 行), + // 恢复的文件若是 untracked 也不会进 index(diff 面板丢文件)。stageAll 对齐。 + sg.stageAll(); - return { - reverted: true, - throughTurnId: plan.throughTurnId, - affectedTurns: plan.affectedTurns, - selectedFiles, - }; - } finally { - lock.unlock(); - } + return { + reverted: true, + throughTurnId: plan.throughTurnId, + affectedTurns: plan.affectedTurns, + selectedFiles, + }; } diff --git a/packages/codingcode/src/checkpoint/shadow-git.ts b/packages/codingcode/src/checkpoint/shadow-git.ts index e079f469..d0dd87d8 100644 --- a/packages/codingcode/src/checkpoint/shadow-git.ts +++ b/packages/codingcode/src/checkpoint/shadow-git.ts @@ -2,6 +2,7 @@ import { spawnSync } from 'child_process'; import { existsSync, mkdirSync, statSync, writeFileSync } from 'fs'; import { join } from 'path'; import { normalizePath, projectDataDir } from '../util/path.js'; +import { toGitPath } from './utils.js'; const NULL_DEVICE = process.platform === 'win32' ? 'NUL' : '/dev/null'; @@ -55,12 +56,16 @@ export class ShadowGit { this.run('config', 'user.email', 'checkpoint@agent.local'); } + stageAll(): void { + const result = this.run('add', '-A'); + if (result.status !== 0) { + throw new Error(`ShadowGit add failed: ${result.stderr}`); + } + } + commit(message: string): string { this.init(); - const addResult = this.run('add', '-A'); - if (addResult.status !== 0) { - throw new Error(`ShadowGit add failed: ${addResult.stderr}`); - } + this.stageAll(); const result = this.run('commit', '--allow-empty', '-m', message); if (result.status !== 0) throw new Error(`ShadowGit commit failed: ${result.stderr}`); const match = result.stdout.trim().match(/^\[[\w-]+ ([a-f0-9]+)\]/); @@ -72,9 +77,8 @@ export class ShadowGit { checkoutFiles(commit: string, files: string[]): void { if (files.length === 0) return; - // restore --source handles both existing files (restores content) and - // non-existing files at baseline (deletes them) — unlike checkout which errors - const result = this.run('restore', '--source', commit, '--', ...files); + const pathspecs = files.map((f) => toGitPath(this.projectPath, f)); + const result = this.run('restore', '--source', commit, '--', ...pathspecs); if (result.status !== 0) { throw new Error(`ShadowGit restore failed: ${result.stderr}`); } @@ -84,14 +88,12 @@ export class ShadowGit { diffFiles(commitA: string, commitB: string): Array<{ status: string; file: string }> { const result = this.run('diff', '--name-status', commitA, commitB); if (result.status !== 0 || !result.stdout.trim()) return []; - return result.stdout - .trim() - .split('\n') - .filter(Boolean) - .map((line) => { - const [status = '', ...rest] = line.split('\t'); - return { status, file: rest.join('\t') }; - }); + return this.parseNameStatus(result.stdout); + } + + diffNameStatusAgainstWorkspace(baseline: string): Array<{ status: string; file: string }> { + const result = this.run('diff', '--name-status', baseline); + return result.status === 0 ? this.parseNameStatus(result.stdout) : []; } findCommitByMessage(pattern: string): string | null { @@ -100,20 +102,16 @@ export class ShadowGit { return hash || null; } - /** Show file content from a commit. Returns null if file doesn't exist in that commit. */ - showFile(commit: string, file: string): string | null { - const result = this.run('show', `${commit}:${file}`); - if (result.status !== 0) return null; - return result.stdout; - } - /** Public git command wrapper — used by CheckpointService for diagnostics. */ git(...args: string[]): { stdout: string; stderr: string; status: number | null } { return this.run(...args); } isTooLargeForSnapshot(): boolean { - const result = this.run('ls-files', '-m', '-o', '--exclude-standard'); + const hasHead = this.run('rev-parse', '--verify', 'HEAD').status === 0; + const result = hasHead + ? this.run('diff', '--cached', '--name-only', 'HEAD') + : this.run('ls-files'); const files = result.stdout.trim().split('\n').filter(Boolean); let totalBytes = 0; for (const f of files) { @@ -128,6 +126,17 @@ export class ShadowGit { } // ---- Private ---- + private parseNameStatus(stdout: string): Array<{ status: string; file: string }> { + return stdout + .trim() + .split('\n') + .filter(Boolean) + .map((line) => { + const [status = '', ...rest] = line.split('\t'); + return { status, file: rest.join('\t') }; + }); + } + private run(...args: string[]): { stdout: string; stderr: string; status: number | null } { const result = spawnSync( 'git', diff --git a/packages/codingcode/src/checkpoint/turn-query.ts b/packages/codingcode/src/checkpoint/turn-query.ts index 95b1dff5..8542f22a 100644 --- a/packages/codingcode/src/checkpoint/turn-query.ts +++ b/packages/codingcode/src/checkpoint/turn-query.ts @@ -1,12 +1,12 @@ import type { ShadowGit } from './shadow-git.js'; import type { RestorePlan } from './types.js'; -import { shortSid, commitMsg } from './utils.js'; +import { shortSid, commitMsg, rollbackSnapshotMsg } from './utils.js'; -export function getCompletedTurnsFor(sg: ShadowGit, sessionId: string): number[] { +export function getTurnsWithBaseline(sg: ShadowGit, sessionId: string): number[] { const short = shortSid(sessionId); const result = sg.git('log', '--all', '--format=%s'); const ids = new Set(); - const re = new RegExp(`^turn-${short}-(\\d+)-final$`); + const re = new RegExp(`^turn-${short}-(\\d+)-baseline$`); for (const line of result.stdout.trim().split('\n')) { const m = line.match(re); if (m) ids.add(Number(m[1])); @@ -14,31 +14,16 @@ export function getCompletedTurnsFor(sg: ShadowGit, sessionId: string): number[] return [...ids].sort((a, b) => a - b); } -export function getTurnRestorePlan( - sg: ShadowGit, - sessionId: string, - turnId: number -): RestorePlan | null { - const baseline = sg.findCommitByMessage(commitMsg(sessionId, turnId, 'baseline')); - const final = sg.findCommitByMessage(commitMsg(sessionId, turnId, 'final')); - if (!baseline || !final) return null; - return { - throughTurnId: turnId, - affectedTurns: [], - baseline, - }; -} - export function getRollbackToTurnPlan( sg: ShadowGit, sessionId: string, throughTurnId: number ): RestorePlan | null { - const completedTurns = getCompletedTurnsFor(sg, sessionId); - const affectedTurns = completedTurns.filter((id) => id >= throughTurnId); + const turns = getTurnsWithBaseline(sg, sessionId); + const affectedTurns = turns.filter((id) => id >= throughTurnId); if (affectedTurns.length === 0) return null; - const baseline = sg.findCommitByMessage(commitMsg(sessionId, throughTurnId, 'baseline')); + const baseline = sg.findCommitByMessage(commitMsg(sessionId, throughTurnId)); if (!baseline) return null; return { @@ -47,3 +32,34 @@ export function getRollbackToTurnPlan( baseline, }; } + +/** 该会话全局 rollbackTurnId 最大的快照 = 最近一次回退前的状态 */ +export function findLatestRollbackSnapshot( + sg: ShadowGit, + sessionId: string +): { rollbackTurnId: number; commit: string } | null { + // 不用 --grep:git 默认 BRE 正则,[0-9]+ 的 + 是字面量,永远匹配不到。 + // 与 getTurnsWithBaseline 同构:--format 全量拉取,JS 侧 ERE 匹配。 + const short = shortSid(sessionId); + const result = sg.git('log', '--all', '--format=%H %s'); + const re = new RegExp(`^([a-f0-9]+) turn-${short}-(\\d+)-pre-rollback$`); + let best: { rollbackTurnId: number; commit: string } | null = null; + for (const line of result.stdout.trim().split('\n').filter(Boolean)) { + const m = line.match(re); + if (!m) continue; + const rollbackTurnId = Number(m[2]); + if (!best || rollbackTurnId > best.rollbackTurnId) { + best = { rollbackTurnId, commit: m[1]! }; + } + } + return best; +} + +/** 供 ensure 幂等寻址:当前轮的快照是否已存在 */ +export function findRollbackSnapshot( + sg: ShadowGit, + sessionId: string, + rollbackTurnId: number +): string | null { + return sg.findCommitByMessage(rollbackSnapshotMsg(sessionId, rollbackTurnId)); +} diff --git a/packages/codingcode/src/checkpoint/utils.ts b/packages/codingcode/src/checkpoint/utils.ts index 250b8371..6c1bec26 100644 --- a/packages/codingcode/src/checkpoint/utils.ts +++ b/packages/codingcode/src/checkpoint/utils.ts @@ -7,8 +7,12 @@ export function shortSid(sessionId: string): string { return createHash('sha256').update(sessionId).digest('hex').slice(0, 8); } -export function commitMsg(sessionId: string, turnId: number, suffix: string): string { - return `turn-${shortSid(sessionId)}-${turnId}-${suffix}`; +export function commitMsg(sessionId: string, turnId: number): string { + return `turn-${shortSid(sessionId)}-${turnId}-baseline`; +} + +export function rollbackSnapshotMsg(sessionId: string, rollbackTurnId: number): string { + return `turn-${shortSid(sessionId)}-${rollbackTurnId}-pre-rollback`; } export function toGitPath(projectPath: string, file: string): string { diff --git a/packages/codingcode/src/server/routes/sessions.ts b/packages/codingcode/src/server/routes/sessions.ts index 737505ca..8b0ba284 100644 --- a/packages/codingcode/src/server/routes/sessions.ts +++ b/packages/codingcode/src/server/routes/sessions.ts @@ -262,29 +262,47 @@ const turnCheckpointDiff: Handler = Effect.gen(function* () { ); }); +// 'latest' 或缺省 = 不带 turnId,由服务端取最近一个有 checkpoint 的回合 +function parseCheckpointTurnId(raw: string | undefined): number | undefined { + if (!raw || raw === 'latest') return undefined; + const n = parseInt(raw, 10); + return isNaN(n) ? undefined : n; +} + const revertFile: Handler = Effect.gen(function* () { - const { id } = yield* pathParams; + const { id, turnId: rawTurnId } = yield* pathParams; const body = yield* readJson<{ cwd: string; file: string }>(); const checkpoint = yield* CheckpointService; - const result = yield* checkpoint.revertCheckpointFiles(resolveCwd(body.cwd), id ?? '', undefined, [ - body.file, - ]); + const result = yield* checkpoint.revertCheckpointFiles( + resolveCwd(body.cwd), + id ?? '', + parseCheckpointTurnId(rawTurnId), + [body.file] + ); return json({ ok: true, result }); }); const revertFiles: Handler = Effect.gen(function* () { - const { id } = yield* pathParams; + const { id, turnId: rawTurnId } = yield* pathParams; const body = yield* readJson<{ cwd: string; files: string[] }>(); const checkpoint = yield* CheckpointService; const result = yield* checkpoint.revertCheckpointFiles( resolveCwd(body.cwd), id ?? '', - undefined, + parseCheckpointTurnId(rawTurnId), body.files ); return json({ ok: true, result }); }); +const undoRollback: Handler = Effect.gen(function* () { + const { id } = yield* pathParams; + const body = yield* readJson<{ cwd: string; files: string[] }>(); + const checkpoint = yield* CheckpointService; + const result = yield* checkpoint.undoRollback(resolveCwd(body.cwd), id ?? '', body.files); + return json({ ok: true, result }); +}); + const rollbackPreview: Handler = Effect.gen(function* () { const { id } = yield* pathParams; const params = yield* query; @@ -294,18 +312,6 @@ const rollbackPreview: Handler = Effect.gen(function* () { return json(yield* checkpoint.previewRollbackDiff(cwd, id ?? '', throughTurnId)); }); -const rollbackCode: Handler = Effect.gen(function* () { - const { id } = yield* pathParams; - const body = yield* readJson<{ cwd: string; throughTurnId: number }>(); - const checkpoint = yield* CheckpointService; - const result = yield* checkpoint.rollbackCodeToTurn( - resolveCwd(body.cwd), - id ?? '', - body.throughTurnId - ); - return json({ ok: true, result }); -}); - /** 回滚 context(+ 可选代码)后回吐新状态:turns / promptEstimate / usage 都要重算。 */ const rollbackState = ( cwd: string, @@ -340,7 +346,12 @@ const rollbackBoth: Handler = Effect.gen(function* () { const cwd = resolveCwd(body.cwd); const sessionId = id ?? ''; const checkpoint = yield* CheckpointService; - const codeResult = yield* checkpoint.rollbackCodeToTurn(cwd, sessionId, body.throughTurnId); + const codeResult = yield* checkpoint.revertCheckpointFiles( + cwd, + sessionId, + body.throughTurnId, + [] + ); return json(yield* rollbackState(cwd, sessionId, body.throughTurnId, codeResult)); }); @@ -378,10 +389,10 @@ export const addSessionsRoutes = (router: Router): Router => { HttpRouter.put('/api/sessions/:id/title', setTitle), HttpRouter.get('/api/sessions/:id/checkpoints/latest/diff', latestCheckpointDiff), HttpRouter.get('/api/sessions/:id/checkpoints/:turnId/diff', turnCheckpointDiff), - HttpRouter.post('/api/sessions/:id/checkpoints/latest/revert-file', revertFile), - HttpRouter.post('/api/sessions/:id/checkpoints/latest/revert-files', revertFiles), + HttpRouter.post('/api/sessions/:id/checkpoints/:turnId/revert-file', revertFile), + HttpRouter.post('/api/sessions/:id/checkpoints/:turnId/revert-files', revertFiles), + HttpRouter.post('/api/sessions/:id/checkpoints/undo-rollback', undoRollback), HttpRouter.get('/api/sessions/:id/rollback-preview', rollbackPreview), - HttpRouter.post('/api/sessions/:id/rollback-code-to-turn', rollbackCode), HttpRouter.post('/api/sessions/:id/rollback-context', rollbackContext), HttpRouter.post('/api/sessions/:id/rollback-both-to-turn', rollbackBoth), HttpRouter.post('/api/sessions/:id/fork', forkSession) diff --git a/packages/codingcode/src/tools/catalog.ts b/packages/codingcode/src/tools/catalog.ts index 093283be..d29ccada 100644 --- a/packages/codingcode/src/tools/catalog.ts +++ b/packages/codingcode/src/tools/catalog.ts @@ -42,6 +42,8 @@ function specToDefinition(spec: McpToolSpec): ToolDefinition { description: `[MCP:${spec.server}] ${spec.description || spec.name}`, parameters: z.fromJSONSchema(spec.inputSchema), concurrencySafe: spec.readOnlyHint, + // MCP 工具一律算写:无法静态判定其副作用,漏打的代价是丢改动 + mutatesWorkspace: true, execute: (args) => spec.execute(args as Record), }; } @@ -65,6 +67,7 @@ export function createToolCatalog( return { name: definition.name, concurrencySafe: definition.concurrencySafe ?? false, + mutatesWorkspace: definition.mutatesWorkspace ?? false, parse: (args: unknown) => definition.parameters.parse(args), execute: (args: unknown, ctx?: ToolExecCtx) => definition.execute(args, ctx) as Effect.Effect, diff --git a/packages/codingcode/src/tools/domains/bash/exec.ts b/packages/codingcode/src/tools/domains/bash/exec.ts index f452b257..e41b5a5a 100644 --- a/packages/codingcode/src/tools/domains/bash/exec.ts +++ b/packages/codingcode/src/tools/domains/bash/exec.ts @@ -8,6 +8,8 @@ import { AgentError } from '../../../util/error.js'; export const bashTool: ToolDefinition = { name: 'execute_command', concurrencySafe: false, + // 无法静态判定命令是否改工作区,一律算写 + mutatesWorkspace: true, description: 'Execute a shell command and return its output. Use for running tests, git, npm, build, and other CLI operations.', parameters: z.object({ diff --git a/packages/codingcode/src/tools/domains/fs/edit.ts b/packages/codingcode/src/tools/domains/fs/edit.ts index 3a6a45d5..7543a580 100644 --- a/packages/codingcode/src/tools/domains/fs/edit.ts +++ b/packages/codingcode/src/tools/domains/fs/edit.ts @@ -8,6 +8,7 @@ import type { ToolDefinition } from '../../types.js'; export const editFileTool: ToolDefinition = { name: 'edit_file', concurrencySafe: false, + mutatesWorkspace: true, description: 'Perform exact string replacement in a file. Provide the exact text to replace (old_string) and the new text (new_string). If old_string is not unique in the file, the edit will fail — narrow the match to make it unique.', parameters: z.object({ diff --git a/packages/codingcode/src/tools/domains/fs/write.ts b/packages/codingcode/src/tools/domains/fs/write.ts index d9c5c458..377bca87 100644 --- a/packages/codingcode/src/tools/domains/fs/write.ts +++ b/packages/codingcode/src/tools/domains/fs/write.ts @@ -8,6 +8,7 @@ import type { ToolDefinition } from '../../types.js'; export const writeFileTool: ToolDefinition = { name: 'write_file', concurrencySafe: false, + mutatesWorkspace: true, description: 'Write content to a file, creating parent directories if needed. Overwrites existing files.', parameters: z.object({ diff --git a/packages/codingcode/src/tools/domains/subagent/spawn.ts b/packages/codingcode/src/tools/domains/subagent/spawn.ts index 2b3bc723..a7171dda 100644 --- a/packages/codingcode/src/tools/domains/subagent/spawn.ts +++ b/packages/codingcode/src/tools/domains/subagent/spawn.ts @@ -9,6 +9,8 @@ import { SubagentRunRegistryService } from '../../../subagent/registry.js'; export const spawnAgentTool: ToolDefinition = { name: 'spawn_agent', concurrencySafe: true, + // 子代理共享工作区并异步写入,父回合须有快照兜底 + mutatesWorkspace: true, description: 'Start a subagent and return its session id immediately. The subagent runs in the same working directory ' + 'and shares your file system: keep the delegated write set disjoint from your own. Its result is appended ' + diff --git a/packages/codingcode/src/tools/tools.ts b/packages/codingcode/src/tools/tools.ts index 1d60aa45..4c96a52e 100644 --- a/packages/codingcode/src/tools/tools.ts +++ b/packages/codingcode/src/tools/tools.ts @@ -183,7 +183,14 @@ export const ToolExecutorLayer = Layer.effect( const waveResults = yield* Effect.forEach( splitWaves(toolCalls, lookup), - (wave) => Effect.forEach(wave, runTool, { concurrency: 'unbounded' }), + (wave) => + Effect.gen(function* () { + // 写类工具独占 wave 且 wave 间串行,故此处等价于"首次写之前"且无竞态 + if (opts.onBeforeMutation && wave.some((tc) => lookup(tc.name)?.mutatesWorkspace)) { + yield* opts.onBeforeMutation; + } + return yield* Effect.forEach(wave, runTool, { concurrency: 'unbounded' }); + }), { concurrency: 1 } ); diff --git a/packages/codingcode/src/tools/types.ts b/packages/codingcode/src/tools/types.ts index e756443a..a932db62 100644 --- a/packages/codingcode/src/tools/types.ts +++ b/packages/codingcode/src/tools/types.ts @@ -9,6 +9,8 @@ export interface ToolDefinition { name: string; /** 是否可与同批其它工具并发;省略即 false(fail-closed) */ concurrencySafe?: boolean; + /** 执行后会改动工作区(写文件 / bash / 派生子代理 / MCP);触发惰性 baseline 快照 */ + mutatesWorkspace?: boolean; description: string; parameters: z.ZodTypeAny; execute: (args: unknown, ctx?: ToolExecCtx) => Effect.Effect; @@ -28,13 +30,17 @@ export type ToolResult = { readonly id: string; readonly name: string } & ToolOu export interface ToolRunner { readonly name: string; readonly concurrencySafe: boolean; + readonly mutatesWorkspace: boolean; parse(args: unknown): unknown; execute(args: unknown, ctx?: ToolExecCtx): Effect.Effect; } export type ToolLookup = (name: string) => ToolRunner | undefined; -export type ToolExecOpts = Omit & { turnId?: number }; +export type ToolExecOpts = Omit & { + turnId?: number; + onBeforeMutation?: Effect.Effect; +}; export type ToolExecCall = ToolExecOpts & { sessionId?: string; callId?: string }; diff --git a/packages/codingcode/test/checkpoint/checkpoint-staged-diff.test.ts b/packages/codingcode/test/checkpoint/checkpoint-staged-diff.test.ts new file mode 100644 index 00000000..a412f2c5 --- /dev/null +++ b/packages/codingcode/test/checkpoint/checkpoint-staged-diff.test.ts @@ -0,0 +1,304 @@ +import { describe, expect, it } from 'vitest'; +import { Effect } from 'effect'; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { randomUUID } from 'crypto'; +import { CheckpointService } from '../../src/checkpoint/port.js'; +import { CheckpointLayer } from '../../src/checkpoint/checkpoint.js'; +import { ShadowGit } from '../../src/checkpoint/shadow-git.js'; +import { normalizePath } from '../../src/util/path.js'; +import { useTempProjectBase } from '../helpers/project-base.js'; + +useTempProjectBase(); + +interface DiffFile { + path: string; + status: string; + diff: string; + insertions: number; + deletions: number; +} + +/** 造一个项目:before.txt 已在 baseline 中,之后新建 after.txt 并修改 before.txt。 + * sessionId 每用例唯一:baseline 幂等状态是模块级共享的,共用 sessionId 会串味。 */ +function setupProject(): { projectPath: string; sessionId: string } { + const projectPath = join(tmpdir(), `codingcode-${randomUUID()}`); + mkdirSync(projectPath, { recursive: true }); + writeFileSync(join(projectPath, 'before.txt'), 'before\n', 'utf8'); + return { projectPath, sessionId: `staged-diff-${randomUUID()}` }; +} + +describe('staged diff(add 方案:新文件经 stageAll 进入 index)', () => { + it('getCheckpointDiff 覆盖新文件(status A、完整内容)与已跟踪修改(status M、增量)', async () => { + const { projectPath, sessionId } = setupProject(); + try { + const result = (await Effect.runPromise( + Effect.gen(function* () { + const checkpoint = yield* CheckpointService; + yield* checkpoint.snapshotBeforeWrite(projectPath, sessionId, 1); + writeFileSync(join(projectPath, 'after.txt'), 'after\n', 'utf8'); + writeFileSync(join(projectPath, 'before.txt'), 'before\nmodified\n', 'utf8'); + return yield* checkpoint.getCheckpointDiff(projectPath, sessionId); + }).pipe(Effect.provide(CheckpointLayer) as any) + )) as { turnId: number; files: DiffFile[] }; + + expect(result.turnId).toBe(1); + const afterPath = normalizePath(join(projectPath, 'after.txt')); + const beforePath = normalizePath(join(projectPath, 'before.txt')); + + const after = result.files.find((f) => f.path === afterPath)!; + expect(after).toBeDefined(); + expect(after.status).toBe('A'); + expect(after.diff).toContain('+after'); + expect(after.insertions).toBe(1); + expect(after.deletions).toBe(0); + + const before = result.files.find((f) => f.path === beforePath)!; + expect(before).toBeDefined(); + expect(before.status).toBe('M'); + expect(before.diff).toContain('+modified'); + expect(before.insertions).toBe(1); + } finally { + rmSync(projectPath, { recursive: true, force: true }); + } + }, 60000); + + it('revertCheckpointFiles 未指定文件时删除新文件并还原已跟踪文件', async () => { + const { projectPath, sessionId } = setupProject(); + try { + await Effect.runPromise( + Effect.gen(function* () { + const checkpoint = yield* CheckpointService; + yield* checkpoint.snapshotBeforeWrite(projectPath, sessionId, 1); + writeFileSync(join(projectPath, 'after.txt'), 'after\n', 'utf8'); + writeFileSync(join(projectPath, 'before.txt'), 'before\nmodified\n', 'utf8'); + return yield* checkpoint.revertCheckpointFiles(projectPath, sessionId, 1, []); + }).pipe(Effect.provide(CheckpointLayer) as any) + ); + + expect(existsSync(join(projectPath, 'after.txt'))).toBe(false); + expect(readFileSync(join(projectPath, 'before.txt'), 'utf8')).toBe('before\n'); + } finally { + rmSync(projectPath, { recursive: true, force: true }); + } + }, 60000); + + it('revertCheckpointFiles 只删选中的新文件,不影响其他文件', async () => { + const { projectPath, sessionId } = setupProject(); + try { + const result = (await Effect.runPromise( + Effect.gen(function* () { + const checkpoint = yield* CheckpointService; + yield* checkpoint.snapshotBeforeWrite(projectPath, sessionId, 1); + writeFileSync(join(projectPath, 'after.txt'), 'after\n', 'utf8'); + writeFileSync(join(projectPath, 'before.txt'), 'before\nmodified\n', 'utf8'); + return yield* checkpoint.revertCheckpointFiles(projectPath, sessionId, 1, [ + join(projectPath, 'after.txt'), + ]); + }).pipe(Effect.provide(CheckpointLayer) as any) + )) as { reverted: boolean; throughTurnId: number; selectedFiles: string[] }; + + expect(result.reverted).toBe(true); + expect(existsSync(join(projectPath, 'after.txt'))).toBe(false); + expect(readFileSync(join(projectPath, 'before.txt'), 'utf8')).toBe('before\nmodified\n'); + } finally { + rmSync(projectPath, { recursive: true, force: true }); + } + }, 60000); + + it('previewRollbackDiff 的 diff 覆盖新文件与已跟踪修改', async () => { + const { projectPath, sessionId } = setupProject(); + try { + const preview = (await Effect.runPromise( + Effect.gen(function* () { + const checkpoint = yield* CheckpointService; + yield* checkpoint.snapshotBeforeWrite(projectPath, sessionId, 1); + writeFileSync(join(projectPath, 'after.txt'), 'after\n', 'utf8'); + writeFileSync(join(projectPath, 'before.txt'), 'before\nmodified\n', 'utf8'); + return yield* checkpoint.previewRollbackDiff(projectPath, sessionId, 1); + }).pipe(Effect.provide(CheckpointLayer) as any) + )) as { throughTurnId: number; diff: string }; + + expect(preview.throughTurnId).toBe(1); + expect(preview.diff).toContain('after.txt'); + expect(preview.diff).toContain('+after'); + expect(preview.diff).toContain('+modified'); + } finally { + rmSync(projectPath, { recursive: true, force: true }); + } + }, 60000); + + it('回滚后的下一轮 baseline 正常提交,diff 归空', async () => { + const { projectPath, sessionId } = setupProject(); + try { + const result = (await Effect.runPromise( + Effect.gen(function* () { + const checkpoint = yield* CheckpointService; + yield* checkpoint.snapshotBeforeWrite(projectPath, sessionId, 1); + writeFileSync(join(projectPath, 'after.txt'), 'after\n', 'utf8'); + yield* checkpoint.revertCheckpointFiles(projectPath, sessionId, 1, []); + yield* checkpoint.snapshotBeforeWrite(projectPath, sessionId, 2); + return yield* checkpoint.getCheckpointDiff(projectPath, sessionId); + }).pipe(Effect.provide(CheckpointLayer) as any) + )) as { turnId: number; files: DiffFile[] }; + + expect(result.turnId).toBe(2); + expect(result.files).toEqual([]); + } finally { + rmSync(projectPath, { recursive: true, force: true }); + } + }, 60000); + + it('回退 → 撤销回退 → 再回退,闭环可逆', async () => { + const { projectPath, sessionId } = setupProject(); + const afterPath = join(projectPath, 'after.txt'); + const beforePath = join(projectPath, 'before.txt'); + try { + const run = (files: string[], op: 'revert' | 'undo') => + Effect.runPromise( + Effect.gen(function* () { + const checkpoint = yield* CheckpointService; + yield* checkpoint.snapshotBeforeWrite(projectPath, sessionId, 1); + writeFileSync(afterPath, 'agent\n', 'utf8'); + writeFileSync(beforePath, 'before\nagent\n', 'utf8'); + return op === 'revert' + ? yield* checkpoint.revertCheckpointFiles(projectPath, sessionId, 1, files) + : yield* checkpoint.undoRollback(projectPath, sessionId, files); + }).pipe(Effect.provide(CheckpointLayer) as any) + ) as Promise<{ reverted: boolean }>; + + // 回退:恢复 baseline + expect((await run([afterPath, beforePath], 'revert')).reverted).toBe(true); + expect(existsSync(afterPath)).toBe(false); + expect(readFileSync(beforePath, 'utf8')).toBe('before\n'); + + // 撤销回退:恢复 agent 完成态 + expect((await run([afterPath, beforePath], 'undo')).reverted).toBe(true); + expect(readFileSync(afterPath, 'utf8')).toBe('agent\n'); + expect(readFileSync(beforePath, 'utf8')).toBe('before\nagent\n'); + + // 再回退:又回 baseline,可反复 + expect((await run([afterPath, beforePath], 'revert')).reverted).toBe(true); + expect(existsSync(afterPath)).toBe(false); + expect(readFileSync(beforePath, 'utf8')).toBe('before\n'); + } finally { + rmSync(projectPath, { recursive: true, force: true }); + } + }, 60000); + + it('回退后继续对话几轮再回退同一目标轮:组合唯一,undo 恢复到最近一次回退前', async () => { + const { projectPath, sessionId } = setupProject(); + const afterPath = join(projectPath, 'after.txt'); + try { + // 第 1 轮:agent 产出 v1 + await Effect.runPromise( + Effect.gen(function* () { + const checkpoint = yield* CheckpointService; + yield* checkpoint.snapshotBeforeWrite(projectPath, sessionId, 1); + writeFileSync(afterPath, 'v1\n', 'utf8'); + return yield* checkpoint.revertCheckpointFiles(projectPath, sessionId, 1, [afterPath]); + }).pipe(Effect.provide(CheckpointLayer) as any) + ); + expect(existsSync(afterPath)).toBe(false); + + // 第 2 轮(回退后继续对话):agent 产出 v2 + await Effect.runPromise( + Effect.gen(function* () { + const checkpoint = yield* CheckpointService; + yield* checkpoint.snapshotBeforeWrite(projectPath, sessionId, 2); + writeFileSync(afterPath, 'v2\n', 'utf8'); + // 又回退第 1 轮 → 新快照(回退时刻当前轮2),内容为 v2 + return yield* checkpoint.revertCheckpointFiles(projectPath, sessionId, 1, [afterPath]); + }).pipe(Effect.provide(CheckpointLayer) as any) + ); + expect(existsSync(afterPath)).toBe(false); + + // 撤销:恢复到最近一次(v2)回退前的状态,而不是第一次的 v1 + await Effect.runPromise( + Effect.gen(function* () { + const checkpoint = yield* CheckpointService; + return yield* checkpoint.undoRollback(projectPath, sessionId, [afterPath]); + }).pipe(Effect.provide(CheckpointLayer) as any) + ); + expect(readFileSync(afterPath, 'utf8')).toBe('v2\n'); + } finally { + rmSync(projectPath, { recursive: true, force: true }); + } + }, 60000); + + it('undoRollback 未指定文件时整轮撤销:恢复全部被回退的文件', async () => { + const { projectPath, sessionId } = setupProject(); + const afterPath = join(projectPath, 'after.txt'); + const beforePath = join(projectPath, 'before.txt'); + try { + const result = (await Effect.runPromise( + Effect.gen(function* () { + const checkpoint = yield* CheckpointService; + yield* checkpoint.snapshotBeforeWrite(projectPath, sessionId, 1); + writeFileSync(afterPath, 'agent\n', 'utf8'); + writeFileSync(beforePath, 'before\nagent\n', 'utf8'); + // 空 files = 整轮回退 + yield* checkpoint.revertCheckpointFiles(projectPath, sessionId, 1, []); + // 空 files = 整轮撤销 + return yield* checkpoint.undoRollback(projectPath, sessionId, []); + }).pipe(Effect.provide(CheckpointLayer) as any) + )) as { reverted: boolean; selectedFiles: string[] }; + + expect(result.reverted).toBe(true); + expect(readFileSync(afterPath, 'utf8')).toBe('agent\n'); + expect(readFileSync(beforePath, 'utf8')).toBe('before\nagent\n'); + expect(result.selectedFiles).toContain(afterPath); + expect(result.selectedFiles).toContain(beforePath); + } finally { + rmSync(projectPath, { recursive: true, force: true }); + } + }, 60000); + + it('没有快照时 undoRollback 返回 reverted:false', async () => { + const { projectPath, sessionId } = setupProject(); + try { + const result = (await Effect.runPromise( + Effect.gen(function* () { + const checkpoint = yield* CheckpointService; + yield* checkpoint.snapshotBeforeWrite(projectPath, sessionId, 1); + writeFileSync(join(projectPath, 'after.txt'), 'after\n', 'utf8'); + return yield* checkpoint.undoRollback(projectPath, sessionId, [ + join(projectPath, 'after.txt'), + ]); + }).pipe(Effect.provide(CheckpointLayer) as any) + )) as { reverted: boolean }; + + expect(result.reverted).toBe(false); + } finally { + rmSync(projectPath, { recursive: true, force: true }); + } + }, 60000); +}); + +describe('ShadowGit.isTooLargeForSnapshot(stageAll 后的新数据源)', () => { + it('首个快照(无 HEAD)走 ls-files 全量分支,小项目不误报', async () => { + const { projectPath } = setupProject(); + try { + const sg = new ShadowGit(projectPath); + sg.init(); + sg.stageAll(); + expect(sg.isTooLargeForSnapshot()).toBe(false); + } finally { + rmSync(projectPath, { recursive: true, force: true }); + } + }, 60000); + + it('baseline 提交后 index 干净,diff --cached HEAD 为空不误报', async () => { + const { projectPath, sessionId } = setupProject(); + try { + const sg = new ShadowGit(projectPath); + sg.init(); + sg.commit(`turn-${sessionId}-1-baseline`); + sg.stageAll(); + expect(sg.isTooLargeForSnapshot()).toBe(false); + } finally { + rmSync(projectPath, { recursive: true, force: true }); + } + }, 60000); +}); diff --git a/packages/codingcode/test/checkpoint/project-lock.test.ts b/packages/codingcode/test/checkpoint/project-lock.test.ts deleted file mode 100644 index 5aa606ae..00000000 --- a/packages/codingcode/test/checkpoint/project-lock.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { describe, it, expect, afterEach } from 'vitest'; -import { existsSync, mkdirSync, rmSync } from 'fs'; -import { join } from 'path'; -import { tmpdir } from 'os'; -import { ProjectLock } from '../../src/checkpoint/project-lock.js'; -import { useTempProjectBase } from '../helpers/project-base.js'; - -useTempProjectBase(); - -describe('ProjectLock', () => { - const dirs: string[] = []; - - function tempDir(): string { - const dir = join(tmpdir(), `pl-test-${Math.random().toString(36).slice(2)}`); - mkdirSync(dir, { recursive: true }); - dirs.push(dir); - return dir; - } - - afterEach(() => { - for (const d of dirs.splice(0)) { - try { - rmSync(d, { recursive: true, force: true }); - } catch { - /* ignore */ - } - } - }); - - it('creates lock for a given project path', () => { - const dir = tempDir(); - const lock = new ProjectLock(dir); - expect(lock).toBeDefined(); - }); - - it('acquires and releases lock', () => { - const dir = tempDir(); - const lock = new ProjectLock(dir); - lock.lock(); - lock.unlock(); - }); - - it('prevents concurrent lock acquisition', () => { - const dir = tempDir(); - const lock1 = new ProjectLock(dir); - lock1.lock(); - try { - const lock2 = new ProjectLock(dir); - expect(() => lock2.lock()).toThrow('ProjectLock timeout'); - } finally { - lock1.unlock(); - } - }); - - it('can reacquire lock after release', () => { - const dir = tempDir(); - const lock = new ProjectLock(dir); - lock.lock(); - lock.unlock(); - lock.lock(); - lock.unlock(); - }); - - it('unlock is idempotent', () => { - const dir = tempDir(); - const lock = new ProjectLock(dir); - lock.unlock(); - lock.unlock(); - lock.lock(); - lock.unlock(); - lock.unlock(); - }); - - it('same project path produces same lock file', () => { - const dir = tempDir(); - const lock1 = new ProjectLock(dir); - const lock2 = new ProjectLock(dir + '/'); // trailing slash - lock1.lock(); - try { - expect(() => lock2.lock()).toThrow('ProjectLock timeout'); - } finally { - lock1.unlock(); - } - }); -}); diff --git a/packages/codingcode/test/checkpoint/turn-title-removal.test.ts b/packages/codingcode/test/checkpoint/turn-title-removal.test.ts index 11276ffc..34a8a518 100644 --- a/packages/codingcode/test/checkpoint/turn-title-removal.test.ts +++ b/packages/codingcode/test/checkpoint/turn-title-removal.test.ts @@ -23,9 +23,8 @@ describe('checkpoint turn title removal', () => { const checkpoints = (await Effect.runPromise( Effect.gen(function* () { const checkpoint = yield* CheckpointService; - yield* checkpoint.snapshotBaseline(projectPath, sessionId, 1); + yield* checkpoint.snapshotBeforeWrite(projectPath, sessionId, 1); writeFileSync(join(projectPath, 'after.txt'), 'after', 'utf8'); - yield* checkpoint.snapshotFinal(projectPath, sessionId, 1); return yield* checkpoint.getCheckpointDiff(projectPath, sessionId); }).pipe(Effect.provide(CheckpointLayer) as any) )) as { turnId: number; files: Array<{ path: string }> }; diff --git a/packages/codingcode/test/helpers/agent-harness.ts b/packages/codingcode/test/helpers/agent-harness.ts index e060c33b..2cf1e173 100644 --- a/packages/codingcode/test/helpers/agent-harness.ts +++ b/packages/codingcode/test/helpers/agent-harness.ts @@ -329,8 +329,7 @@ export function makeAgentLayer(mocks: HarnessMocks): Layer.Layer { mocks.sessionLayer ?? Layer.succeed(SessionService, session as any), Layer.succeed(ToolExecutorService, executor as any), Layer.succeed(CheckpointService, { - snapshotBaseline: () => Effect.void, - snapshotFinal: () => Effect.void, + snapshotBeforeWrite: () => Effect.void, } as any), Layer.succeed(ApprovalService, { evaluate: () => Effect.succeed({ type: 'allow', source: 'test' }), diff --git a/packages/codingcode/test/server/compact-route.test.ts b/packages/codingcode/test/server/compact-route.test.ts index 8d5de496..8cf393c6 100644 --- a/packages/codingcode/test/server/compact-route.test.ts +++ b/packages/codingcode/test/server/compact-route.test.ts @@ -91,8 +91,7 @@ const MockContextLayer = Layer.succeed(ContextService, { const MockCheckpointLayer = Layer.succeed(CheckpointService, { _tag: 'Checkpoint' as const, - snapshotBaseline: () => Effect.void, - snapshotFinal: () => Effect.void, + snapshotBeforeWrite: () => Effect.void, getCheckpointDiff: () => Effect.succeed({ turnId: 0, files: [] }), revertCheckpointFiles: () => Effect.succeed({ @@ -102,13 +101,6 @@ const MockCheckpointLayer = Layer.succeed(CheckpointService, { selectedFiles: [], }), previewRollbackDiff: () => Effect.succeed({ throughTurnId: 0, affectedTurns: [], diff: '' }), - rollbackCodeToTurn: () => - Effect.succeed({ - reverted: false, - throughTurnId: 0, - affectedTurns: [], - selectedFiles: [], - }), } as any); const TestLayer = Layer.mergeAll( diff --git a/packages/codingcode/test/server/index.test.ts b/packages/codingcode/test/server/index.test.ts index 1716e3b3..815c7d19 100644 --- a/packages/codingcode/test/server/index.test.ts +++ b/packages/codingcode/test/server/index.test.ts @@ -82,8 +82,7 @@ const MockContextLayer = Layer.succeed(ContextService, { const MockCheckpointLayer = Layer.succeed(CheckpointService, { _tag: 'Checkpoint' as const, - snapshotBaseline: () => Effect.void, - snapshotFinal: () => Effect.void, + snapshotBeforeWrite: () => Effect.void, getCheckpointDiff: () => Effect.succeed({ turnId: 0, files: [] }), revertCheckpointFiles: () => Effect.succeed({ @@ -93,13 +92,6 @@ const MockCheckpointLayer = Layer.succeed(CheckpointService, { selectedFiles: [], }), previewRollbackDiff: () => Effect.succeed({ throughTurnId: 0, affectedTurns: [], diff: '' }), - rollbackCodeToTurn: () => - Effect.succeed({ - reverted: false, - throughTurnId: 0, - affectedTurns: [], - selectedFiles: [], - }), } as any); const TestLayer = Layer.mergeAll( diff --git a/packages/codingcode/test/server/plan-file-route.test.ts b/packages/codingcode/test/server/plan-file-route.test.ts index 57703114..56ec8f37 100644 --- a/packages/codingcode/test/server/plan-file-route.test.ts +++ b/packages/codingcode/test/server/plan-file-route.test.ts @@ -100,8 +100,7 @@ const MockContextLayer = Layer.succeed(ContextService, { const MockCheckpointLayer = Layer.succeed(CheckpointService, { _tag: 'Checkpoint' as const, - snapshotBaseline: () => Effect.void, - snapshotFinal: () => Effect.void, + snapshotBeforeWrite: () => Effect.void, getCheckpointDiff: () => Effect.succeed({ turnId: 0, files: [] }), revertCheckpointFiles: () => Effect.succeed({ @@ -111,13 +110,6 @@ const MockCheckpointLayer = Layer.succeed(CheckpointService, { selectedFiles: [], }), previewRollbackDiff: () => Effect.succeed({ throughTurnId: 0, affectedTurns: [], diff: '' }), - rollbackCodeToTurn: () => - Effect.succeed({ - reverted: false, - throughTurnId: 0, - affectedTurns: [], - selectedFiles: [], - }), } as any); const TestLayer = Layer.mergeAll( diff --git a/packages/codingcode/test/subagent/runner-wiring.test.ts b/packages/codingcode/test/subagent/runner-wiring.test.ts index a3de392b..76759389 100644 --- a/packages/codingcode/test/subagent/runner-wiring.test.ts +++ b/packages/codingcode/test/subagent/runner-wiring.test.ts @@ -136,8 +136,7 @@ const AgentDeps = Layer.mergeAll( executeBatch: () => Effect.succeed([]), } as any), Layer.succeed(CheckpointService, { - snapshotBaseline: () => Effect.void, - snapshotFinal: () => Effect.void, + snapshotBeforeWrite: () => Effect.void, } as any), Layer.succeed(ApprovalService, { evaluate: () => Effect.succeed({ type: 'allow' }), diff --git a/packages/desktop/src/agent/MessageStream.tsx b/packages/desktop/src/agent/MessageStream.tsx index 0bad42f0..69393ee2 100644 --- a/packages/desktop/src/agent/MessageStream.tsx +++ b/packages/desktop/src/agent/MessageStream.tsx @@ -3,7 +3,7 @@ import { Copy, Check } from 'lucide-react'; import { useVirtualizer } from '@tanstack/react-virtual'; import { useShallow } from 'zustand/react/shallow'; import { useAgentStore } from '../stores/agent.store'; -import { useRollbackStore } from '../stores/rollback.store'; +import { useRollbackStore, TURN_SCOPE_REVERTED } from '../stores/rollback.store'; import MessageItem from '../shared/MessageItem'; import UnifiedDiffView from '../shared/UnifiedDiffView'; import type { Item } from '@shared/types'; @@ -22,8 +22,10 @@ interface TurnDiffPanelProps { uiTurnId: string; isInterrupted?: boolean; threadId: string; - onRevertFile: (uiTurnId: string, file: string) => void; - onRevertTurn: (uiTurnId: string, files: string[]) => void; + onRevertFile: (uiTurnId: string, file: string, checkpointTurnId: number) => void; + onRevertTurn: (uiTurnId: string, checkpointTurnId: number) => void; + onUndoRevertFile: (uiTurnId: string, file: string, checkpointTurnId: number) => void; + onUndoRevertTurn: (uiTurnId: string, checkpointTurnId: number) => void; } function getCheckpointKey( @@ -57,6 +59,8 @@ function TurnDiffPanel({ threadId, onRevertFile, onRevertTurn, + onUndoRevertFile, + onUndoRevertTurn, }: TurnDiffPanelProps) { const rawCheckpointDiffByTurnId = useRollbackStore((s) => s.checkpointDiffByTurnId); const rawTurnCheckpointMapping = useRollbackStore((s) => s.turnCheckpointMapping); @@ -75,7 +79,7 @@ function TurnDiffPanel({ const ckKey = getCheckpointKey(threadId, uiTurnId, checkpointDiffs, turnCheckpointMapping); const diff = ckKey ? checkpointDiffs[ckKey] : null; const revertedFiles = revertedFilesByTurnId[`${threadId}:${uiTurnId}`] ?? []; - const isTurnReverted = revertedFiles.includes('__scope_reverted__'); + const isTurnReverted = revertedFiles.includes(TURN_SCOPE_REVERTED); const [expandedFile, setExpandedFile] = useState(null); if (!diff || !diff.files || diff.files.length === 0) { @@ -124,20 +128,21 @@ function TurnDiffPanel({
@@ -156,9 +161,6 @@ function TurnDiffPanel({ {f.path.replace(/\\/g, '/')} - - {f.source === 'agent' ? 'Agent' : '未知'} - {isReverted && ( 已回退 )} @@ -175,16 +177,20 @@ function TurnDiffPanel({ { - await revertFile(threadId, file); + async (_uiTurnId: string, file: string, checkpointTurnId: number) => { + await revertFile(threadId, file, checkpointTurnId); }, [threadId, revertFile] ); const handleRevertTurn = useCallback( - async (_uiTurnId: string, files: string[]) => { - await revertFiles(threadId, files); + async (_uiTurnId: string, checkpointTurnId: number) => { + await revertFiles(threadId, [], checkpointTurnId); }, [threadId, revertFiles] ); + const handleUndoRevertFile = useCallback( + async (_uiTurnId: string, file: string, checkpointTurnId: number) => { + await undoRevertFiles(threadId, [file], checkpointTurnId); + }, + [threadId, undoRevertFiles] + ); + + const handleUndoRevertTurn = useCallback( + async (_uiTurnId: string, checkpointTurnId: number) => { + await undoRevertFiles(threadId, [], checkpointTurnId); + }, + [threadId, undoRevertFiles] + ); + const rollbackModal = showRollbackPanel && (
@@ -615,6 +636,8 @@ export default function MessageStream({ threadId }: MessageStreamProps) { threadId={threadId} onRevertFile={handleRevertFile} onRevertTurn={handleRevertTurn} + onUndoRevertFile={handleUndoRevertFile} + onUndoRevertTurn={handleUndoRevertTurn} />
)} diff --git a/packages/desktop/src/hooks/useAgent.ts b/packages/desktop/src/hooks/useAgent.ts index 6d6f4616..d6a5a40c 100644 --- a/packages/desktop/src/hooks/useAgent.ts +++ b/packages/desktop/src/hooks/useAgent.ts @@ -1,7 +1,7 @@ import { useEffect, useCallback, useRef } from 'react'; import { useAgentStore, type ModelEntry, type QueuedInput } from '../stores/agent.store'; import { useWorkspaceStore } from '../stores/workspace.store'; -import { useRollbackStore } from '../stores/rollback.store'; +import { useRollbackStore, TURN_SCOPE_REVERTED } from '../stores/rollback.store'; import { agentClient } from '../lib/core-api'; import { createStreamState, reduceFrame, type StreamEffects } from '../lib/frame-reducer'; import type { PermissionMode, ProfileName } from '@codingcode/sdk'; @@ -15,8 +15,8 @@ import { sendApprovalResponse, getCheckpointDiff, revertCheckpointFiles, + undoRollback, previewRollbackDiff, - rollbackCodeToTurn, rollbackContext, rollbackBothToTurn, forkSession, @@ -476,6 +476,7 @@ export function useAgentRollback() { const setThreadUsage = useAgentStore((s) => s.setThreadUsage); // Rollback store const revertedFilesByTurnId = useRollbackStore((s) => s.revertedFilesByTurnId); + const unmarkFileReverted = useRollbackStore((s) => s.unmarkFileReverted); const setCheckpointDiff = useRollbackStore((s) => s.setCheckpointDiff); const markFileReverted = useRollbackStore((s) => s.markFileReverted); const setTurnCheckpointMapping = useRollbackStore((s) => s.setTurnCheckpointMapping); @@ -499,7 +500,12 @@ export function useAgentRollback() { if (thread) { const completed = thread.turns.filter((t) => t.status === 'completed'); const last = completed[completed.length - 1]; - if (last && last.id !== String(diff.turnId)) { + // 只读回合没有 checkpoint,latest 会滞后;仅首次映射,避免把旧回合改动标到新回合 + if ( + last && + last.id !== String(diff.turnId) && + !useRollbackStore.getState().turnCheckpointMapping[threadId]?.[diff.turnId] + ) { setTurnCheckpointMapping(threadId, diff.turnId, last.id); } } @@ -510,9 +516,9 @@ export function useAgentRollback() { ); const revertFile = useCallback( - async (threadId: string, file: string) => { + async (threadId: string, file: string, checkpointTurnId?: number) => { const cwd = useAgentStore.getState().threads[threadId]?.cwd ?? workspace.rootPath; - const { result } = await revertCheckpointFiles(threadId, cwd, [file]); + const { result } = await revertCheckpointFiles(threadId, cwd, [file], checkpointTurnId); if (result.reverted) { markFileReverted(threadId, resolveUITurnId(threadId, result.throughTurnId), file); } @@ -522,20 +528,45 @@ export function useAgentRollback() { ); const revertFiles = useCallback( - async (threadId: string, files: string[]) => { + async (threadId: string, files: string[], checkpointTurnId?: number) => { const cwd = useAgentStore.getState().threads[threadId]?.cwd ?? workspace.rootPath; - const { result } = await revertCheckpointFiles(threadId, cwd, files); + const { result } = await revertCheckpointFiles(threadId, cwd, files, checkpointTurnId); if (result.reverted) { const uiId = resolveUITurnId(threadId, result.throughTurnId); for (const f of result.selectedFiles) { markFileReverted(threadId, uiId, f); } + // 未指定文件 = 整轮:打整轮哨兵,供“回退本轮修改”按钮切换为“撤销本轮回退” + if (files.length === 0) { + markFileReverted(threadId, uiId, TURN_SCOPE_REVERTED); + } } return result; }, [workspace.rootPath, markFileReverted, resolveUITurnId] ); + // 撤销回退:恢复到回退前存的快照,并清掉“已回退”标记,按钮恢复可点 + // turnId 只用于定位本地标记,后端按“最近一次回退”自行寻址 + const undoRevertFiles = useCallback( + async (threadId: string, files: string[], checkpointTurnId?: number) => { + const cwd = useAgentStore.getState().threads[threadId]?.cwd ?? workspace.rootPath; + const { result } = await undoRollback(threadId, cwd, files); + if (result.reverted) { + const uiId = resolveUITurnId(threadId, checkpointTurnId ?? result.throughTurnId); + for (const f of result.selectedFiles) { + unmarkFileReverted(threadId, uiId, f); + } + // 未指定文件 = 整轮撤销:unmark 哨兵会清掉该轮全部文件标记 + if (files.length === 0) { + unmarkFileReverted(threadId, uiId, TURN_SCOPE_REVERTED); + } + } + return result; + }, + [workspace.rootPath, unmarkFileReverted, resolveUITurnId] + ); + const previewRollback = useCallback( async (threadId: string, throughTurnId: number) => { const cwd = useAgentStore.getState().threads[threadId]?.cwd ?? workspace.rootPath; @@ -547,11 +578,10 @@ export function useAgentRollback() { const rollbackCode = useCallback( async (threadId: string, throughTurnId: number) => { - const cwd = useAgentStore.getState().threads[threadId]?.cwd ?? workspace.rootPath; - const { result } = await rollbackCodeToTurn(threadId, cwd, throughTurnId); - return result; + // files 为空 = 整轮回退,由后端自动算该轮全部变更文件 + return revertFiles(threadId, [], throughTurnId); }, - [workspace.rootPath] + [revertFiles] ); const rollbackCtx = useCallback( @@ -648,6 +678,7 @@ export function useAgentRollback() { loadCheckpointDiff, revertFile, revertFiles, + undoRevertFiles, previewRollback, rollbackCode, rollbackCtx, diff --git a/packages/desktop/src/lib/core-api.ts b/packages/desktop/src/lib/core-api.ts index 9df92e53..aee29de5 100644 --- a/packages/desktop/src/lib/core-api.ts +++ b/packages/desktop/src/lib/core-api.ts @@ -261,30 +261,36 @@ export function getCheckpointDiff( export function revertCheckpointFiles( sessionId: string, cwd: string, - files: string[] + files: string[], + turnId?: number ): Promise<{ ok: boolean; result: CodeRollbackResult }> { - return clients.sessions.revertCheckpointFiles({ sessionId, cwd, files }).then((result) => ({ - ok: true, - result, - })); + return clients.sessions + .revertCheckpointFiles({ sessionId, cwd, files, turnId }) + .then((result) => ({ + ok: true, + result, + })); } -export function previewRollbackDiff( +export function undoRollback( sessionId: string, cwd: string, - throughTurnId: number -): Promise { - return clients.sessions.previewRollbackDiff({ sessionId, cwd, throughTurnId }); + files: string[] +): Promise<{ ok: boolean; result: CodeRollbackResult }> { + return clients.sessions + .undoRollback({ sessionId, cwd, files }) + .then((result) => ({ + ok: true, + result, + })); } -export function rollbackCodeToTurn( +export function previewRollbackDiff( sessionId: string, cwd: string, throughTurnId: number -): Promise<{ ok: boolean; result: CodeRollbackResult }> { - return clients.sessions - .rollbackCodeToTurn({ sessionId, cwd, throughTurnId }) - .then((result) => ({ ok: true, result })); +): Promise { + return clients.sessions.previewRollbackDiff({ sessionId, cwd, throughTurnId }); } export function rollbackContext( diff --git a/packages/desktop/src/stores/rollback.store.ts b/packages/desktop/src/stores/rollback.store.ts index 59f54bca..7425e71c 100644 --- a/packages/desktop/src/stores/rollback.store.ts +++ b/packages/desktop/src/stores/rollback.store.ts @@ -11,10 +11,14 @@ interface RollbackState { interface RollbackActions { setCheckpointDiff: (threadId: string, turnId: string, diff: CheckpointDiff) => void; markFileReverted: (threadId: string, turnId: string, file: string) => void; + unmarkFileReverted: (threadId: string, turnId: string, file: string) => void; setTurnCheckpointMapping: (threadId: string, checkpointId: number, uiTurnId: string) => void; cleanupDeletedThreads: (incomingIds: Set) => void; } +/** 整轮回退的哨兵标记:存在于 revertedFilesByTurnId 即表示该轮被整轮回退 */ +export const TURN_SCOPE_REVERTED = '__scope_reverted__'; + export const useRollbackStore = create()( immer((set) => ({ checkpointDiffByTurnId: {}, @@ -37,6 +41,19 @@ export const useRollbackStore = create()( } }), + unmarkFileReverted: (threadId, turnId, file) => + set((s) => { + const key = `${threadId}:${turnId}`; + const list = s.revertedFilesByTurnId[key]; + if (!list) return; + if (file === TURN_SCOPE_REVERTED) { + // 整轮撤销:清掉该轮全部文件标记 + delete s.revertedFilesByTurnId[key]; + return; + } + s.revertedFilesByTurnId[key] = list.filter((f) => f !== file); + }), + setTurnCheckpointMapping: (threadId, checkpointId, uiTurnId) => set((s) => { if (!s.turnCheckpointMapping[threadId]) { diff --git a/packages/desktop/test/rollback-usage-reset.test.ts b/packages/desktop/test/rollback-usage-reset.test.ts index 631c34bc..ee67a90f 100644 --- a/packages/desktop/test/rollback-usage-reset.test.ts +++ b/packages/desktop/test/rollback-usage-reset.test.ts @@ -22,7 +22,6 @@ vi.mock('../src/lib/core-api', () => ({ revertFile: vi.fn(), revertFiles: vi.fn(), previewRollbackDiff: vi.fn(), - rollbackCodeToTurn: vi.fn(), forkSession: vi.fn(), listModels: vi.fn(), switchModel: vi.fn(), diff --git a/packages/desktop/test/thread-delete.test.ts b/packages/desktop/test/thread-delete.test.ts index 52efcdaf..242c1afa 100644 --- a/packages/desktop/test/thread-delete.test.ts +++ b/packages/desktop/test/thread-delete.test.ts @@ -20,7 +20,6 @@ vi.mock('../src/lib/core-api', () => ({ revertFile: vi.fn(), revertFiles: vi.fn(), previewRollbackDiff: vi.fn(), - rollbackCodeToTurn: vi.fn(), rollbackContext: vi.fn(), rollbackBothToTurn: vi.fn(), forkSession: vi.fn(), diff --git a/packages/sdk/src/contracts.ts b/packages/sdk/src/contracts.ts index 4c0454e7..877b9c85 100644 --- a/packages/sdk/src/contracts.ts +++ b/packages/sdk/src/contracts.ts @@ -79,17 +79,18 @@ export interface SessionClient { sessionId: string; cwd: string; files: string[]; + turnId?: number; }): Promise; - previewRollbackDiff(input: { + undoRollback(input: { sessionId: string; cwd: string; - throughTurnId: number; - }): Promise; - rollbackCodeToTurn(input: { + files: string[]; + }): Promise; + previewRollbackDiff(input: { sessionId: string; cwd: string; throughTurnId: number; - }): Promise; + }): Promise; rollbackContext(input: { sessionId: string; cwd: string; diff --git a/packages/sdk/src/http/sessions.ts b/packages/sdk/src/http/sessions.ts index f082f53e..9d98b931 100644 --- a/packages/sdk/src/http/sessions.ts +++ b/packages/sdk/src/http/sessions.ts @@ -64,26 +64,27 @@ export function createHttpSessionClient( ); }, - async revertCheckpointFiles({ sessionId, cwd, files }) { + async revertCheckpointFiles({ sessionId, cwd, files, turnId }) { + const segment = turnId != null ? String(turnId) : 'latest'; const res = await apiPost<{ ok: boolean; result: CodeRollbackResult }>( - `/api/sessions/${sessionId}/checkpoints/latest/revert-files`, + `/api/sessions/${sessionId}/checkpoints/${segment}/revert-files`, { cwd, files } ); return res.result; }, - async previewRollbackDiff({ sessionId, cwd, throughTurnId }) { - return apiGet( - `/api/sessions/${sessionId}/rollback-preview?cwd=${encodeURIComponent(cwd)}&throughTurnId=${throughTurnId}` + async undoRollback({ sessionId, cwd, files }) { + const res = await apiPost<{ ok: boolean; result: CodeRollbackResult }>( + `/api/sessions/${sessionId}/checkpoints/undo-rollback`, + { cwd, files } ); + return res.result; }, - async rollbackCodeToTurn({ sessionId, cwd, throughTurnId }) { - const res = await apiPost<{ ok: boolean; result: CodeRollbackResult }>( - `/api/sessions/${sessionId}/rollback-code-to-turn`, - { cwd, throughTurnId } + async previewRollbackDiff({ sessionId, cwd, throughTurnId }) { + return apiGet( + `/api/sessions/${sessionId}/rollback-preview?cwd=${encodeURIComponent(cwd)}&throughTurnId=${throughTurnId}` ); - return res.result; }, async rollbackContext({ sessionId, cwd, throughTurnId }) { diff --git a/packages/sdk/test/contracts.test.ts b/packages/sdk/test/contracts.test.ts index 7dcb8451..b5ae9638 100644 --- a/packages/sdk/test/contracts.test.ts +++ b/packages/sdk/test/contracts.test.ts @@ -48,7 +48,6 @@ describe('client contracts', () => { 'getCheckpointDiff', 'revertCheckpointFiles', 'previewRollbackDiff', - 'rollbackCodeToTurn', 'rollbackContext', 'rollbackBothToTurn', 'forkSession', @@ -63,7 +62,6 @@ describe('client contracts', () => { 'getCheckpointDiff', 'revertCheckpointFiles', 'previewRollbackDiff', - 'rollbackCodeToTurn', 'rollbackContext', 'rollbackBothToTurn', 'forkSession', diff --git a/packages/sdk/test/http-session-contracts.test.ts b/packages/sdk/test/http-session-contracts.test.ts index 7bcbf980..4ef8bb9a 100644 --- a/packages/sdk/test/http-session-contracts.test.ts +++ b/packages/sdk/test/http-session-contracts.test.ts @@ -59,25 +59,6 @@ describe('http session client 与 contracts 对齐', () => { expect(result).toEqual(codeResult); }); - it('rollbackCodeToTurn 解包 server 的 { ok, result } 信封', async () => { - const codeResult = { - reverted: true, - throughTurnId: 2, - affectedTurns: [2], - selectedFiles: [], - }; - stubFetch({ ok: true, result: codeResult }); - const client = createHttpSessionClient(createRequestHelpers('http://localhost:1')); - - const result = await client.rollbackCodeToTurn({ - sessionId: 's1', - cwd: '/tmp/p', - throughTurnId: 2, - }); - - expect(result).toEqual(codeResult); - }); - it('getCheckpointDiff 把 turnId 编进路径,缺省用 latest', async () => { const calls = stubFetch({ turnId: 0, files: [] }); const client = createHttpSessionClient(createRequestHelpers('http://localhost:1'));