mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
Merge pull request #191 from nwparker/cloud/code-checkpoints
feat(native-chat): per-turn code checkpoints; Rewind can also restore files
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
// Git plumbing for worktree checkpoints. Every command here reads, or writes only objects, refs
|
||||
// under refs/orca/checkpoints/ and private temp index files: the user's HEAD, branch, index and
|
||||
// stash list are never touched by a snapshot.
|
||||
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import type { Stats } from 'node:fs'
|
||||
import { copyFile, readdir, rm, stat, utimes } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { gitExecFileAsync } from '../command-runner/git-exec-file'
|
||||
|
||||
export const CHECKPOINT_REF_ROOT = 'refs/orca/checkpoints/'
|
||||
|
||||
/** Identity for checkpoint commits, so repos without user.name/user.email still snapshot. */
|
||||
const CHECKPOINT_IDENTITY = {
|
||||
GIT_AUTHOR_NAME: 'Orca',
|
||||
GIT_AUTHOR_EMAIL: 'checkpoints@orca.invalid',
|
||||
GIT_COMMITTER_NAME: 'Orca',
|
||||
GIT_COMMITTER_EMAIL: 'checkpoints@orca.invalid'
|
||||
}
|
||||
|
||||
export type CheckpointGitOptions = {
|
||||
env?: Record<string, string>
|
||||
stdin?: string
|
||||
signal?: AbortSignal
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
export type CheckpointGit = (args: string[], options?: CheckpointGitOptions) => Promise<string>
|
||||
|
||||
export type CheckpointRepo = {
|
||||
/** The worktree root; every path below is relative to it. */
|
||||
root: string
|
||||
/** The worktree's own git dir (a linked worktree has its own index and temp files there). */
|
||||
gitDir: string
|
||||
/** The user's index file. Copied, never written, by a snapshot. */
|
||||
indexFile: string
|
||||
git: CheckpointGit
|
||||
}
|
||||
|
||||
export class WorktreeCheckpointError extends Error {
|
||||
constructor(
|
||||
readonly reason:
|
||||
| 'not-a-repo'
|
||||
| 'not-found'
|
||||
| 'merge-conflicts'
|
||||
| 'blocked-path'
|
||||
| 'unsafe-path'
|
||||
| 'snapshot-failed'
|
||||
/** Files changed before the restore failed; `undoRef` puts them back. */
|
||||
| 'partial',
|
||||
message: string,
|
||||
readonly paths: string[] = [],
|
||||
readonly undoRef?: string
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'WorktreeCheckpointError'
|
||||
}
|
||||
}
|
||||
|
||||
export function gitExitCode(error: unknown): number | null {
|
||||
const code = error && typeof error === 'object' && 'code' in error ? error.code : null
|
||||
return typeof code === 'number' ? code : null
|
||||
}
|
||||
|
||||
export function defaultCheckpointGit(cwd: string): CheckpointGit {
|
||||
return async (args, options = {}) => {
|
||||
const { stdout } = await gitExecFileAsync(args, {
|
||||
cwd,
|
||||
// Plumbing only: no optional locks, so a snapshot never contends with the user's own git.
|
||||
env: {
|
||||
...process.env,
|
||||
GIT_OPTIONAL_LOCKS: '0',
|
||||
...CHECKPOINT_IDENTITY,
|
||||
...options.env
|
||||
},
|
||||
...(options.stdin !== undefined ? { stdin: options.stdin } : {}),
|
||||
...(options.signal ? { signal: options.signal } : {}),
|
||||
timeout: options.timeoutMs ?? 120_000,
|
||||
maxBuffer: 256 * 1024 * 1024
|
||||
})
|
||||
return stdout
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolves the worktree that contains `directory`; throws `not-a-repo` for plain folders and bare repos. */
|
||||
export async function openCheckpointRepo(
|
||||
directory: string,
|
||||
gitFor: (cwd: string) => CheckpointGit = defaultCheckpointGit
|
||||
): Promise<CheckpointRepo> {
|
||||
let out: string
|
||||
try {
|
||||
out = await gitFor(directory)([
|
||||
'rev-parse',
|
||||
'--path-format=absolute',
|
||||
'--show-toplevel',
|
||||
'--git-dir',
|
||||
'--git-path',
|
||||
'index'
|
||||
])
|
||||
} catch (error) {
|
||||
throw new WorktreeCheckpointError(
|
||||
'not-a-repo',
|
||||
`${directory} is not inside a git worktree: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
}
|
||||
const [root, gitDir, indexFile] = out.split('\n').map((line) => line.trim())
|
||||
if (!root || !gitDir || !indexFile) {
|
||||
throw new WorktreeCheckpointError('not-a-repo', `${directory} has no worktree root.`)
|
||||
}
|
||||
return { root, gitDir, indexFile, git: gitFor(root) }
|
||||
}
|
||||
|
||||
export async function resolveCommit(repo: CheckpointRepo, rev: string): Promise<string | null> {
|
||||
try {
|
||||
const out = await repo.git(['rev-parse', '-q', '--verify', `${rev}^{commit}`])
|
||||
return out.trim() || null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const TEMP_INDEX_PREFIX = 'orca-checkpoint-'
|
||||
const STALE_TEMP_INDEX_MS = 60 * 60 * 1000
|
||||
const swept = new Set<string>()
|
||||
|
||||
/** Removes temp indexes a crashed process left behind (once per git dir per process). */
|
||||
async function sweepStaleTempIndexes(gitDir: string): Promise<void> {
|
||||
if (swept.has(gitDir)) {
|
||||
return
|
||||
}
|
||||
swept.add(gitDir)
|
||||
const names = await readdir(gitDir).catch(() => [])
|
||||
for (const name of names) {
|
||||
if (!name.startsWith(TEMP_INDEX_PREFIX)) {
|
||||
continue
|
||||
}
|
||||
const path = join(gitDir, name)
|
||||
const info = await stat(path).catch(() => null)
|
||||
// ctime, not mtime: a live temp index carries the user index's (possibly old) mtime.
|
||||
if (info && Date.now() - Math.max(info.ctimeMs, info.mtimeMs) > STALE_TEMP_INDEX_MS) {
|
||||
await rm(path, { force: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Runs `run` against a private copy of the user's index (stat data included, so `add -A` stays
|
||||
* incremental on large repos). The copy is deleted afterwards; the user's index is only read. */
|
||||
export async function withTempIndex<T>(
|
||||
repo: CheckpointRepo,
|
||||
seedFromUserIndex: boolean,
|
||||
run: (env: Record<string, string>) => Promise<T>
|
||||
): Promise<T> {
|
||||
await sweepStaleTempIndexes(repo.gitDir)
|
||||
const file = `${repo.gitDir}/${TEMP_INDEX_PREFIX}${process.pid}-${randomBytes(6).toString('hex')}.index`
|
||||
try {
|
||||
if (seedFromUserIndex) {
|
||||
await seedTempIndex(repo.indexFile, file)
|
||||
}
|
||||
return await run({ GIT_INDEX_FILE: file })
|
||||
} finally {
|
||||
await rm(file, { force: true })
|
||||
await rm(`${file}.lock`, { force: true })
|
||||
}
|
||||
}
|
||||
|
||||
/** Copies the user's index keeping its mtime. Git treats an entry as "racily clean" (and rehashes
|
||||
* the file) only when the entry is not older than the index file itself; a copy stamped "now"
|
||||
* would make a same-size edit made in the index's last second look unchanged and drop it from
|
||||
* the snapshot. The mtime is read before the copy, so a concurrent rewrite only makes the copy
|
||||
* look older, which means more rehashing, never less. */
|
||||
async function seedTempIndex(indexFile: string, file: string): Promise<void> {
|
||||
let source: Stats
|
||||
try {
|
||||
source = await stat(indexFile)
|
||||
} catch (error) {
|
||||
// An unborn repo has no index yet; the empty temp index is the right seed.
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
await copyFile(indexFile, file)
|
||||
await utimes(file, source.atime, source.mtime)
|
||||
}
|
||||
|
||||
export type TreeChange = {
|
||||
status: 'A' | 'D' | 'M' | 'T'
|
||||
path: string
|
||||
}
|
||||
|
||||
/** File-level differences from `fromTree` to `toTree`, renames split into delete + add.
|
||||
* Submodule pointers are left out: a checkpoint never moves a submodule. */
|
||||
export async function diffTrees(
|
||||
repo: CheckpointRepo,
|
||||
fromTree: string,
|
||||
toTree: string
|
||||
): Promise<TreeChange[]> {
|
||||
if (fromTree === toTree) {
|
||||
return []
|
||||
}
|
||||
const out = await repo.git(['diff-tree', '-r', '-z', '--no-renames', '--raw', fromTree, toTree])
|
||||
const fields = out.split('\0')
|
||||
const changes: TreeChange[] = []
|
||||
for (let index = 0; index + 1 < fields.length; index += 2) {
|
||||
const header = fields[index]
|
||||
const path = fields[index + 1]
|
||||
if (!header.startsWith(':') || path === undefined) {
|
||||
continue
|
||||
}
|
||||
const [oldMode, newMode, , , status] = header.slice(1).split(' ')
|
||||
if (oldMode === '160000' || newMode === '160000') {
|
||||
continue
|
||||
}
|
||||
const kind = status?.charAt(0)
|
||||
if (kind === 'A' || kind === 'D' || kind === 'M' || kind === 'T') {
|
||||
changes.push({ status: kind, path })
|
||||
}
|
||||
}
|
||||
return changes
|
||||
}
|
||||
|
||||
/** Paths git would refuse anyway; checked again so a bad tree can never reach outside the root. */
|
||||
export function isSafeRelativePath(path: string): boolean {
|
||||
if (!path || path.startsWith('/') || path.includes('\0') || /^[A-Za-z]:/.test(path)) {
|
||||
return false
|
||||
}
|
||||
return path.split(/[\\/]/).every((part) => part !== '' && part !== '..' && part !== '.git')
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
import { chmod, mkdir, mkdtemp, readFile, rm, stat, symlink, writeFile } from 'node:fs/promises'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { runProcess } from '@orca/process-host'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
openCheckpointRepo,
|
||||
type CheckpointRepo,
|
||||
WorktreeCheckpointError
|
||||
} from './worktree-checkpoint-git'
|
||||
import {
|
||||
captureWorktreeCheckpoint,
|
||||
CHECKPOINT_MAX_UNTRACKED_BYTES,
|
||||
readWorktreeCheckpoint
|
||||
} from './worktree-checkpoint-snapshot'
|
||||
import { previewWorktreeRestore, restoreWorktreeCheckpoint } from './worktree-checkpoint-restore'
|
||||
import { assertUndoCovers } from './worktree-checkpoint-restore-guards'
|
||||
import { listCheckpointRefs, pruneCheckpointRefs } from './worktree-checkpoint-retention'
|
||||
|
||||
let dir: string
|
||||
let repo: CheckpointRepo
|
||||
|
||||
async function git(...args: string[]): Promise<string> {
|
||||
const result = await runProcess({ program: 'git', args, cwd: dir })
|
||||
if (result.code !== 0) {
|
||||
throw new Error(result.stderr)
|
||||
}
|
||||
return result.stdout.trim()
|
||||
}
|
||||
const put = async (path: string, text: string | Buffer) => {
|
||||
await mkdir(join(dir, path, '..'), { recursive: true })
|
||||
await writeFile(join(dir, path), text)
|
||||
}
|
||||
const read = (path: string) => readFile(join(dir, path), 'utf8')
|
||||
const T1 = 'refs/orca/checkpoints/s1/turn/t1'
|
||||
const NOW = 1_800_000_000_000
|
||||
const capture = (ref = T1) => captureWorktreeCheckpoint(repo, ref, { kind: 'turn', createdAt: NOW })
|
||||
const restore = async (ref = T1) => {
|
||||
const target = await readWorktreeCheckpoint(repo, ref)
|
||||
return restoreWorktreeCheckpoint(repo, target!, {
|
||||
ref: 'refs/orca/checkpoints/s1/undo/u1',
|
||||
now: 2
|
||||
})
|
||||
}
|
||||
async function userState() {
|
||||
return {
|
||||
head: await git('rev-parse', 'HEAD'),
|
||||
branch: await git('branch', '--show-current'),
|
||||
staged: await git('diff', '--cached', '--name-status'),
|
||||
stash: await git('stash', 'list'),
|
||||
status: await git('status', '--porcelain=v1', '-uall')
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'orca-checkpoint-'))
|
||||
await git('init', '-q')
|
||||
await git('symbolic-ref', 'HEAD', 'refs/heads/main')
|
||||
await git('config', 'user.name', 'Audit')
|
||||
await git('config', 'user.email', 'audit@example.test')
|
||||
await git('config', 'commit.gpgsign', 'true')
|
||||
await git('config', 'core.hooksPath', '/nonexistent')
|
||||
await put('.gitignore', 'ignored/\n*.log\n')
|
||||
await put('a.txt', 'a1\n')
|
||||
await put('keep/b.txt', 'b1\n')
|
||||
await git('add', '.')
|
||||
await git('-c', 'commit.gpgsign=false', 'commit', '-qm', 'base')
|
||||
repo = await openCheckpointRepo(dir)
|
||||
})
|
||||
afterEach(async () => {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('worktree checkpoints', () => {
|
||||
it('snapshots without touching HEAD, branch, index, stash or status', async () => {
|
||||
await put('a.txt', 'a2\n')
|
||||
await git('add', 'a.txt')
|
||||
await put('a.txt', 'a3\n')
|
||||
await put('new.txt', 'untracked\n')
|
||||
const before = await userState()
|
||||
const checkpoint = await capture()
|
||||
expect(await userState()).toEqual(before)
|
||||
expect(checkpoint.head).toBe(before.head)
|
||||
expect(await git('show', `${checkpoint.commit}:a.txt`)).toBe('a3')
|
||||
expect(await git('show', `${checkpoint.commit}:new.txt`)).toBe('untracked')
|
||||
expect(await git('show', `${checkpoint.indexTree}:a.txt`)).toBe('a2')
|
||||
expect(await git('for-each-ref', '--format=%(refname)')).toContain(T1)
|
||||
expect(existsSync(join(dir, '.git', 'index.lock'))).toBe(false)
|
||||
})
|
||||
|
||||
it('sees a same-size edit made in the same second the index was written (racy git)', async () => {
|
||||
// Git rehashes an entry whose stat matches only when it is not older than the index file. A
|
||||
// temp index stamped "now" (a later second) would trust the stale stat and drop the edit.
|
||||
// Racy git is about real file-system seconds: start early in one so the edit shares it.
|
||||
// ast-grep-ignore: test-no-polling-loop, test-no-wall-clock -- the file system clock is real
|
||||
while (Date.now() % 1000 > 300) {
|
||||
// ast-grep-ignore: test-no-real-time-wait -- the file system clock can't be faked
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
}
|
||||
await put('a.txt', 'r1\n')
|
||||
await git('add', 'a.txt')
|
||||
await put('a.txt', 's1\n')
|
||||
// ast-grep-ignore: test-no-real-time-wait -- the snapshot must run in a later second
|
||||
await new Promise((resolve) => setTimeout(resolve, 1100))
|
||||
const checkpoint = await capture()
|
||||
expect(await git('show', `${checkpoint.commit}:a.txt`)).toBe('s1')
|
||||
})
|
||||
|
||||
it('restores untracked, deleted, binary, renamed and modified files', async () => {
|
||||
const binary = Buffer.from([0, 1, 2, 255, 254, 0, 10])
|
||||
await put('bin.dat', binary)
|
||||
await put('untracked.txt', 'u1\n')
|
||||
await capture()
|
||||
await put('a.txt', 'changed\n')
|
||||
await rm(join(dir, 'untracked.txt'))
|
||||
await put('bin.dat', Buffer.from([9, 9]))
|
||||
await mkdir(join(dir, 'moved'))
|
||||
await git('mv', 'keep/b.txt', 'moved/b.txt')
|
||||
await put('agent-new/deep.txt', 'agent\n')
|
||||
const preview = await previewWorktreeRestore(repo, (await readWorktreeCheckpoint(repo, T1))!)
|
||||
expect(preview.changes.map((c) => `${c.status} ${c.path}`).sort()).toEqual([
|
||||
'A keep/b.txt',
|
||||
'A untracked.txt',
|
||||
'D agent-new/deep.txt',
|
||||
'D moved/b.txt',
|
||||
'M a.txt',
|
||||
'M bin.dat'
|
||||
])
|
||||
const result = await restore()
|
||||
expect(result.changes).toHaveLength(6)
|
||||
expect(await read('a.txt')).toBe('a1\n')
|
||||
expect(await read('untracked.txt')).toBe('u1\n')
|
||||
expect(await readFile(join(dir, 'bin.dat'))).toEqual(binary)
|
||||
expect(await read('keep/b.txt')).toBe('b1\n')
|
||||
expect(existsSync(join(dir, 'moved'))).toBe(false)
|
||||
expect(existsSync(join(dir, 'agent-new'))).toBe(false)
|
||||
// The staged rename is gone from the index too: HEAD never moved, so staging comes back.
|
||||
expect(result.indexRestored).toBe(true)
|
||||
expect(await git('diff', '--cached', '--name-status')).toBe('')
|
||||
expect(await git('status', '--porcelain=v1')).toBe('?? bin.dat\n?? untracked.txt'.trim())
|
||||
expect(
|
||||
(await previewWorktreeRestore(repo, (await readWorktreeCheckpoint(repo, T1))!)).changes
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('leaves gitignored files alone', async () => {
|
||||
await put('ignored/cache.bin', 'before\n')
|
||||
await put('debug.log', 'log1\n')
|
||||
await capture()
|
||||
await put('ignored/cache.bin', 'after\n')
|
||||
await put('ignored/new.bin', 'new\n')
|
||||
await put('debug.log', 'log2\n')
|
||||
await put('a.txt', 'changed\n')
|
||||
await restore()
|
||||
expect(await read('ignored/cache.bin')).toBe('after\n')
|
||||
expect(await read('ignored/new.bin')).toBe('new\n')
|
||||
expect(await read('debug.log')).toBe('log2\n')
|
||||
expect(await read('a.txt')).toBe('a1\n')
|
||||
})
|
||||
|
||||
it('brings back a dirty index while HEAD is unchanged', async () => {
|
||||
await put('a.txt', 'staged\n')
|
||||
await git('add', 'a.txt')
|
||||
await put('a.txt', 'staged-then-edited\n')
|
||||
await capture()
|
||||
await git('reset', '-q')
|
||||
await put('a.txt', 'agent\n')
|
||||
const result = await restore()
|
||||
expect(result.indexRestored).toBe(true)
|
||||
expect(await git('show', ':a.txt')).toBe('staged')
|
||||
expect(await read('a.txt')).toBe('staged-then-edited\n')
|
||||
})
|
||||
|
||||
it('keeps commits made after the checkpoint and leaves the index when HEAD moved', async () => {
|
||||
await capture()
|
||||
const base = await git('rev-parse', 'HEAD')
|
||||
await put('a.txt', 'committed\n')
|
||||
await git('-c', 'commit.gpgsign=false', 'commit', '-qam', 'agent commit')
|
||||
const agentHead = await git('rev-parse', 'HEAD')
|
||||
const result = await restore()
|
||||
expect(result.headMoved).toBe(true)
|
||||
expect(result.indexRestored).toBe(false)
|
||||
expect(await git('rev-parse', 'HEAD')).toBe(agentHead)
|
||||
expect(agentHead).not.toBe(base)
|
||||
expect(await read('a.txt')).toBe('a1\n')
|
||||
})
|
||||
|
||||
it('undoes a restore, including an ignored file the restore overwrote', async () => {
|
||||
await put('later-ignored.txt', 'v1\n')
|
||||
await capture()
|
||||
await put('.gitignore', 'ignored/\n*.log\nlater-ignored.txt\n')
|
||||
await put('later-ignored.txt', 'precious\n')
|
||||
await put('a.txt', 'agent\n')
|
||||
const result = await restore()
|
||||
expect(await read('later-ignored.txt')).toBe('v1\n')
|
||||
const undo = await readWorktreeCheckpoint(repo, result.undo.ref)
|
||||
await restoreWorktreeCheckpoint(repo, undo!, {
|
||||
ref: 'refs/orca/checkpoints/s1/undo/u2',
|
||||
now: 3
|
||||
})
|
||||
expect(await read('later-ignored.txt')).toBe('precious\n')
|
||||
expect(await read('a.txt')).toBe('agent\n')
|
||||
expect(await read('.gitignore')).toContain('later-ignored.txt')
|
||||
})
|
||||
|
||||
it('restores file modes and symlinks', async () => {
|
||||
await put('run.sh', '#!/bin/sh\n')
|
||||
await chmod(join(dir, 'run.sh'), 0o755)
|
||||
await symlink('a.txt', join(dir, 'link'))
|
||||
await capture()
|
||||
await chmod(join(dir, 'run.sh'), 0o644)
|
||||
await rm(join(dir, 'link'))
|
||||
await put('link', 'now a file\n')
|
||||
await restore()
|
||||
expect((await stat(join(dir, 'run.sh'))).mode & 0o111).not.toBe(0)
|
||||
expect(await read('link')).toBe('a1\n')
|
||||
})
|
||||
|
||||
it('refuses while merge conflicts are unresolved, before writing anything', async () => {
|
||||
await capture()
|
||||
await git('checkout', '-qb', 'other')
|
||||
await put('a.txt', 'other\n')
|
||||
await git('-c', 'commit.gpgsign=false', 'commit', '-qam', 'other')
|
||||
await git('checkout', '-q', 'main')
|
||||
await put('a.txt', 'main\n')
|
||||
await git('-c', 'commit.gpgsign=false', 'commit', '-qam', 'main')
|
||||
await runProcess({ program: 'git', args: ['merge', 'other'], cwd: dir })
|
||||
const conflicted = await read('a.txt')
|
||||
await expect(restore()).rejects.toMatchObject({
|
||||
reason: 'merge-conflicts'
|
||||
})
|
||||
expect(await read('a.txt')).toBe(conflicted)
|
||||
expect(await git('for-each-ref', 'refs/orca/checkpoints/s1/undo/')).toBe('')
|
||||
})
|
||||
|
||||
it('refuses when a folder now sits where the checkpoint has a file', async () => {
|
||||
await capture()
|
||||
await rm(join(dir, 'a.txt'))
|
||||
await put('a.txt/inside.log', 'ignored content\n')
|
||||
await expect(restore()).rejects.toMatchObject({ reason: 'blocked-path' })
|
||||
expect(await read('a.txt/inside.log')).toBe('ignored content\n')
|
||||
})
|
||||
|
||||
it('refuses when an ignored file now sits where the checkpoint has a folder', async () => {
|
||||
await put('cache/x.txt', 'tracked then\n')
|
||||
await capture()
|
||||
await rm(join(dir, 'cache'), { recursive: true })
|
||||
await put('.gitignore', 'ignored/\n*.log\ncache\n')
|
||||
await put('cache', 'precious ignored file\n')
|
||||
await expect(restore()).rejects.toMatchObject({ reason: 'blocked-path' })
|
||||
expect(await read('cache')).toBe('precious ignored file\n')
|
||||
})
|
||||
|
||||
it('snapshots past an unreadable file and refuses to overwrite it', async () => {
|
||||
await put('secret.txt', 'v1\n')
|
||||
await capture()
|
||||
await put('secret.txt', 'unreadable now\n')
|
||||
await chmod(join(dir, 'secret.txt'), 0o000)
|
||||
await put('a.txt', 'agent\n')
|
||||
try {
|
||||
const later = await capture('refs/orca/checkpoints/s1/turn/t2')
|
||||
expect(await git('show', `${later.commit}:a.txt`)).toBe('agent')
|
||||
await expect(restore()).rejects.toMatchObject({ reason: 'blocked-path' })
|
||||
expect(await read('a.txt')).toBe('agent\n')
|
||||
} finally {
|
||||
await chmod(join(dir, 'secret.txt'), 0o644)
|
||||
}
|
||||
expect(await read('secret.txt')).toBe('unreadable now\n')
|
||||
})
|
||||
|
||||
it('leaves large untracked files out of snapshots and alone on restore', async () => {
|
||||
await capture()
|
||||
await put('big.bin', Buffer.alloc(CHECKPOINT_MAX_UNTRACKED_BYTES + 1, 7))
|
||||
await put('a.txt', 'agent\n')
|
||||
const later = await capture('refs/orca/checkpoints/s1/turn/t2')
|
||||
await expect(git('cat-file', '-e', `${later.commit}:big.bin`)).rejects.toThrow()
|
||||
await restore()
|
||||
expect((await stat(join(dir, 'big.bin'))).size).toBe(CHECKPOINT_MAX_UNTRACKED_BYTES + 1)
|
||||
expect(await read('a.txt')).toBe('a1\n')
|
||||
})
|
||||
|
||||
it('leaves skip-worktree and assume-unchanged files alone', async () => {
|
||||
await capture()
|
||||
await git('update-index', '--skip-worktree', 'a.txt')
|
||||
await git('update-index', '--assume-unchanged', 'keep/b.txt')
|
||||
await put('a.txt', 'local only\n')
|
||||
await put('keep/b.txt', 'local too\n')
|
||||
await put('new.txt', 'agent\n')
|
||||
const result = await restore()
|
||||
expect(result.changes.map((c) => c.path)).toEqual(['new.txt'])
|
||||
expect(await read('a.txt')).toBe('local only\n')
|
||||
expect(await read('keep/b.txt')).toBe('local too\n')
|
||||
expect(await git('ls-files', '-v', 'a.txt')).toBe('S a.txt')
|
||||
})
|
||||
|
||||
it('names its undo point when a restore fails partway', async () => {
|
||||
await put('locked/keep.txt', 'k\n')
|
||||
await put('locked/gone.txt', 'g\n')
|
||||
await capture()
|
||||
await rm(join(dir, 'locked/gone.txt'))
|
||||
await put('extra.txt', 'agent\n')
|
||||
await chmod(join(dir, 'locked'), 0o555)
|
||||
try {
|
||||
const error = await restore().then(
|
||||
() => null,
|
||||
(failure: unknown) => failure
|
||||
)
|
||||
expect(error).toBeInstanceOf(WorktreeCheckpointError)
|
||||
const undoRef = error instanceof WorktreeCheckpointError ? error.undoRef : undefined
|
||||
expect(error).toMatchObject({ reason: 'partial' })
|
||||
expect(existsSync(join(dir, 'extra.txt'))).toBe(false)
|
||||
await chmod(join(dir, 'locked'), 0o755)
|
||||
const undo = await readWorktreeCheckpoint(repo, undoRef ?? '')
|
||||
await restoreWorktreeCheckpoint(repo, undo!, {
|
||||
ref: 'refs/orca/checkpoints/s1/undo/u9',
|
||||
now: NOW
|
||||
})
|
||||
expect(await read('extra.txt')).toBe('agent\n')
|
||||
} finally {
|
||||
await chmod(join(dir, 'locked'), 0o755)
|
||||
}
|
||||
})
|
||||
|
||||
it('never writes into a nested repository the agent created', async () => {
|
||||
await put('sub/a.txt', 'checkpointed\n')
|
||||
await capture()
|
||||
await git('-C', join(dir, 'sub'), 'init', '-q')
|
||||
await put('sub/a.txt', 'uncommitted work in the nested repo\n')
|
||||
await put('a.txt', 'agent\n')
|
||||
await expect(restore()).rejects.toMatchObject({ reason: 'blocked-path' })
|
||||
expect(await read('sub/a.txt')).toBe('uncommitted work in the nested repo\n')
|
||||
expect(await read('a.txt')).toBe('agent\n')
|
||||
})
|
||||
|
||||
it('refuses when a path on disk is missing from the undo tree', async () => {
|
||||
await put('present.txt', 'on disk\n')
|
||||
const empty = (await git('hash-object', '-t', 'tree', '-w', '--stdin')).trim()
|
||||
await expect(
|
||||
assertUndoCovers(repo, empty, [{ status: 'M', path: 'present.txt' }])
|
||||
).rejects.toMatchObject({ reason: 'blocked-path', paths: ['present.txt'] })
|
||||
await expect(
|
||||
assertUndoCovers(repo, empty, [{ status: 'A', path: 'absent.txt' }])
|
||||
).resolves.toBeUndefined()
|
||||
// A saved file next to an unsaved one whose name holds a newline: answers must not shift.
|
||||
await put('x', 'saved\n')
|
||||
await put('x\ny', 'not saved\n')
|
||||
await git('add', 'x')
|
||||
const tree = await git('write-tree')
|
||||
await expect(
|
||||
assertUndoCovers(repo, tree, [
|
||||
{ status: 'M', path: 'x\ny' },
|
||||
{ status: 'M', path: 'x' }
|
||||
])
|
||||
).rejects.toMatchObject({ reason: 'blocked-path', paths: ['x\ny'] })
|
||||
})
|
||||
|
||||
it('works on an unborn branch', async () => {
|
||||
await rm(join(dir, '.git'), { recursive: true })
|
||||
await git('init', '-q')
|
||||
repo = await openCheckpointRepo(dir)
|
||||
await capture()
|
||||
await put('a.txt', 'agent\n')
|
||||
await put('fresh.txt', 'agent\n')
|
||||
await restore()
|
||||
expect(await read('a.txt')).toBe('a1\n')
|
||||
expect(existsSync(join(dir, 'fresh.txt'))).toBe(false)
|
||||
})
|
||||
|
||||
it('prunes beyond the newest N and anything past the max age', async () => {
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
await capture(`refs/orca/checkpoints/s1/turn/t${index}`)
|
||||
}
|
||||
await capture('refs/orca/checkpoints/s2/turn/x')
|
||||
const deleted = await pruneCheckpointRefs(repo, {
|
||||
prefix: 'refs/orca/checkpoints/s1/turn/',
|
||||
keep: 3,
|
||||
maxAgeMs: 60_000,
|
||||
now: NOW
|
||||
})
|
||||
expect(deleted).toHaveLength(2)
|
||||
expect(await listCheckpointRefs(repo, 'refs/orca/checkpoints/s1/turn/')).toHaveLength(3)
|
||||
expect(await listCheckpointRefs(repo, 'refs/orca/checkpoints/s2/')).toHaveLength(1)
|
||||
await pruneCheckpointRefs(repo, {
|
||||
prefix: 'refs/orca/checkpoints/s1/turn/',
|
||||
keep: 50,
|
||||
maxAgeMs: -60_000,
|
||||
now: NOW
|
||||
})
|
||||
expect(await listCheckpointRefs(repo, 'refs/orca/checkpoints/')).toEqual([])
|
||||
await capture('refs/orca/checkpoints/s1/turn/fresh')
|
||||
await pruneCheckpointRefs(repo, {
|
||||
prefix: 'refs/orca/checkpoints/s1/turn/',
|
||||
keep: 0,
|
||||
maxAgeMs: -60_000,
|
||||
now: NOW,
|
||||
protect: ['refs/orca/checkpoints/s1/turn/fresh']
|
||||
})
|
||||
expect(await listCheckpointRefs(repo, 'refs/orca/checkpoints/')).toEqual([
|
||||
'refs/orca/checkpoints/s1/turn/fresh'
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects plain folders', async () => {
|
||||
const plain = await mkdtemp(join(tmpdir(), 'orca-checkpoint-plain-'))
|
||||
try {
|
||||
await expect(openCheckpointRepo(plain)).rejects.toMatchObject({
|
||||
reason: 'not-a-repo'
|
||||
})
|
||||
} finally {
|
||||
await rm(plain, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,191 @@
|
||||
// Checks a restore runs before it writes anything. Each one refuses (or narrows the restore) where
|
||||
// writing would lose a file nothing else has a copy of.
|
||||
|
||||
import { constants } from 'node:fs'
|
||||
import { access, lstat } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
type CheckpointRepo,
|
||||
diffTrees,
|
||||
isSafeRelativePath,
|
||||
type TreeChange,
|
||||
WorktreeCheckpointError
|
||||
} from './worktree-checkpoint-git'
|
||||
import type { WorktreeCheckpoint } from './worktree-checkpoint-snapshot'
|
||||
|
||||
export async function assertNoConflicts(repo: CheckpointRepo): Promise<void> {
|
||||
const unmerged = await repo.git(['ls-files', '-u', '-z'])
|
||||
if (unmerged.length > 0) {
|
||||
throw new WorktreeCheckpointError(
|
||||
'merge-conflicts',
|
||||
'The worktree has unresolved merge conflicts; resolve or abort them first.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Entries git keeps off the disk (sparse checkout, skip-worktree) or doesn't look at
|
||||
* (assume-unchanged). A snapshot holds their index version, not the file, so restore leaves them. */
|
||||
export async function hiddenIndexPaths(repo: CheckpointRepo): Promise<Set<string>> {
|
||||
const out = await repo.git(['ls-files', '-v', '-z'])
|
||||
const hidden = new Set<string>()
|
||||
for (const entry of out.split('\0')) {
|
||||
const tag = entry.charAt(0)
|
||||
if (entry.length > 2 && (tag === 'S' || (tag >= 'a' && tag <= 'z'))) {
|
||||
hidden.add(entry.slice(2))
|
||||
}
|
||||
}
|
||||
return hidden
|
||||
}
|
||||
|
||||
export async function restoreChanges(
|
||||
repo: CheckpointRepo,
|
||||
fromTree: string,
|
||||
toTree: string,
|
||||
hidden: Set<string>
|
||||
): Promise<TreeChange[]> {
|
||||
return (await diffTrees(repo, fromTree, toTree)).filter((change) => !hidden.has(change.path))
|
||||
}
|
||||
|
||||
/** Paths the checkpoint writes that exist on disk but not in the working tree's snapshot (ignored,
|
||||
* or too large to snapshot): the undo checkpoint must hold them before they are overwritten. */
|
||||
export function overwrittenUnsnapshottedPaths(repo: CheckpointRepo, target: WorktreeCheckpoint) {
|
||||
return async (tree: string): Promise<string[]> => {
|
||||
const added = (await diffTrees(repo, tree, target.tree)).filter((c) => c.status === 'A')
|
||||
const present: string[] = []
|
||||
for (const change of added) {
|
||||
const absolute = join(repo.root, change.path)
|
||||
// An unreadable file can't be saved; assertWritable refuses to overwrite it instead.
|
||||
const stat = isSafeRelativePath(change.path) ? await lstat(absolute).catch(() => null) : null
|
||||
if (
|
||||
stat &&
|
||||
(stat.isSymbolicLink() ||
|
||||
(await access(absolute, constants.R_OK).then(
|
||||
() => true,
|
||||
() => false
|
||||
)))
|
||||
) {
|
||||
present.push(change.path)
|
||||
}
|
||||
}
|
||||
return present
|
||||
}
|
||||
}
|
||||
|
||||
async function hasGitDir(
|
||||
repo: CheckpointRepo,
|
||||
folder: string,
|
||||
cache: Map<string, boolean>
|
||||
): Promise<boolean> {
|
||||
let known = cache.get(folder)
|
||||
if (known === undefined) {
|
||||
known = (await lstat(join(repo.root, folder, '.git')).catch(() => null)) !== null
|
||||
cache.set(folder, known)
|
||||
}
|
||||
return known
|
||||
}
|
||||
|
||||
/** Refuses before any write when a path to write is a folder or an unreadable file now, when a
|
||||
* leading folder of it is now a file the restore doesn't delete itself (an ignored file would be
|
||||
* replaced with no copy), or when any path is unsafe. */
|
||||
export async function assertWritable(repo: CheckpointRepo, changes: TreeChange[]): Promise<void> {
|
||||
const unsafe = changes.filter((change) => !isSafeRelativePath(change.path))
|
||||
if (unsafe.length > 0) {
|
||||
throw new WorktreeCheckpointError(
|
||||
'unsafe-path',
|
||||
'The checkpoint names paths outside the worktree.',
|
||||
unsafe.map((change) => change.path)
|
||||
)
|
||||
}
|
||||
const deleted = new Set(changes.filter((c) => c.status === 'D').map((c) => c.path))
|
||||
const folders = new Map<string, boolean>()
|
||||
const isNonFolder = async (path: string): Promise<boolean> => {
|
||||
let known = folders.get(path)
|
||||
if (known === undefined) {
|
||||
const stat = await lstat(join(repo.root, path)).catch(() => null)
|
||||
known = stat !== null && !stat.isDirectory()
|
||||
folders.set(path, known)
|
||||
}
|
||||
return known
|
||||
}
|
||||
const gitDirs = new Map<string, boolean>()
|
||||
const blocked: string[] = []
|
||||
for (const change of changes) {
|
||||
const parts = change.path.split('/')
|
||||
// A nested repository owns its files; the restore never writes or deletes inside one.
|
||||
if (change.status === 'D') {
|
||||
for (let depth = 1; depth < parts.length; depth += 1) {
|
||||
if (await hasGitDir(repo, parts.slice(0, depth).join('/'), gitDirs)) {
|
||||
blocked.push(change.path)
|
||||
break
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
const absolute = join(repo.root, change.path)
|
||||
const stat = await lstat(absolute).catch(() => null)
|
||||
const unreadable =
|
||||
stat?.isFile() === true &&
|
||||
(await access(absolute, constants.R_OK).then(
|
||||
() => false,
|
||||
() => true
|
||||
))
|
||||
let leadingFile = false
|
||||
for (let depth = 1; depth < parts.length && !leadingFile; depth += 1) {
|
||||
const prefix = parts.slice(0, depth).join('/')
|
||||
leadingFile = !deleted.has(prefix) && (await isNonFolder(prefix))
|
||||
}
|
||||
let nestedRepo = false
|
||||
for (let depth = 1; depth < parts.length && !nestedRepo; depth += 1) {
|
||||
const prefix = parts.slice(0, depth).join('/')
|
||||
nestedRepo = await hasGitDir(repo, prefix, gitDirs)
|
||||
}
|
||||
if (stat?.isDirectory() || unreadable || leadingFile || nestedRepo) {
|
||||
blocked.push(change.path)
|
||||
}
|
||||
}
|
||||
if (blocked.length > 0) {
|
||||
throw new WorktreeCheckpointError(
|
||||
'blocked-path',
|
||||
'Something now sits where the checkpoint writes a file.',
|
||||
blocked
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Known gap: a tracked file that is unreadable now and that the restore deletes is covered by its
|
||||
* stale index blob, so the undo point holds its last staged content, not what is on disk.
|
||||
*
|
||||
* Every path the restore writes or deletes that exists on disk must be in the undo tree, or the
|
||||
* restore could destroy work nothing saved. `git add` can skip a path without failing (a nested
|
||||
* repo with no commits, a file it couldn't read), so this checks the tree itself. */
|
||||
export async function assertUndoCovers(
|
||||
repo: CheckpointRepo,
|
||||
undoTree: string,
|
||||
changes: TreeChange[]
|
||||
): Promise<void> {
|
||||
const present: string[] = []
|
||||
for (const change of changes) {
|
||||
if (await lstat(join(repo.root, change.path)).catch(() => null)) {
|
||||
present.push(change.path)
|
||||
}
|
||||
}
|
||||
if (present.length === 0) {
|
||||
return
|
||||
}
|
||||
const lined = present.filter((path) => !path.includes('\n'))
|
||||
const out = await repo.git(['cat-file', '--batch-check=%(objecttype)'], {
|
||||
stdin: lined.map((path) => `${undoTree}:${path}\n`).join('')
|
||||
})
|
||||
const answers = out.split('\n')
|
||||
// batch-check is line-based: a path with a newline would shift every answer after it, so such a
|
||||
// path is never asked about and counts as unsaved.
|
||||
const saved = new Set(lined.filter((_, index) => answers[index]?.endsWith('blob')))
|
||||
const missing = present.filter((path) => !saved.has(path))
|
||||
if (missing.length > 0) {
|
||||
throw new WorktreeCheckpointError(
|
||||
'blocked-path',
|
||||
'Some files the restore would change could not be saved first.',
|
||||
missing
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
// Puts the working tree back to a checkpoint. Before anything is written, every file the restore
|
||||
// would change or delete is saved in an "undo" checkpoint (ignored files it would overwrite too),
|
||||
// so the restore itself can always be undone. HEAD and the branch never move: commits made after
|
||||
// the checkpoint stay on the branch. The staged state comes back only while HEAD is unchanged.
|
||||
|
||||
import { realpath, rm, rmdir } from 'node:fs/promises'
|
||||
import { dirname, join, relative, sep } from 'node:path'
|
||||
import {
|
||||
type CheckpointRepo,
|
||||
resolveCommit,
|
||||
type TreeChange,
|
||||
withTempIndex,
|
||||
WorktreeCheckpointError
|
||||
} from './worktree-checkpoint-git'
|
||||
import {
|
||||
assertNoConflicts,
|
||||
assertUndoCovers,
|
||||
assertWritable,
|
||||
hiddenIndexPaths,
|
||||
overwrittenUnsnapshottedPaths,
|
||||
restoreChanges
|
||||
} from './worktree-checkpoint-restore-guards'
|
||||
import {
|
||||
captureWorktreeTree,
|
||||
type WorktreeCheckpoint,
|
||||
writeWorktreeCheckpoint
|
||||
} from './worktree-checkpoint-snapshot'
|
||||
|
||||
export type WorktreeRestorePreview = {
|
||||
/** Files that differ between the working tree now and the checkpoint. */
|
||||
changes: TreeChange[]
|
||||
/** HEAD moved since the checkpoint (for example the agent committed); those commits stay. */
|
||||
headMoved: boolean
|
||||
}
|
||||
|
||||
export type WorktreeRestoreResult = WorktreeRestorePreview & {
|
||||
/** The checkpoint of the working tree as it was just before this restore. */
|
||||
undo: WorktreeCheckpoint
|
||||
indexRestored: boolean
|
||||
}
|
||||
|
||||
export async function previewWorktreeRestore(
|
||||
repo: CheckpointRepo,
|
||||
target: WorktreeCheckpoint
|
||||
): Promise<WorktreeRestorePreview> {
|
||||
const now = await captureWorktreeTree(repo)
|
||||
return {
|
||||
changes: await restoreChanges(repo, now.tree, target.tree, await hiddenIndexPaths(repo)),
|
||||
headMoved: now.head !== target.head
|
||||
}
|
||||
}
|
||||
|
||||
/** Deletes a file the checkpoint doesn't have, refusing to follow a symlinked parent folder. */
|
||||
async function removeWorktreeFile(rootReal: string, root: string, path: string): Promise<void> {
|
||||
const absolute = join(root, path)
|
||||
const parentReal = await realpath(dirname(absolute)).catch(() => null)
|
||||
if (!parentReal) {
|
||||
return
|
||||
}
|
||||
const fromRoot = relative(rootReal, parentReal)
|
||||
if (fromRoot.startsWith('..') || fromRoot.startsWith(sep)) {
|
||||
throw new WorktreeCheckpointError('unsafe-path', `${path} resolves outside the worktree.`, [
|
||||
path
|
||||
])
|
||||
}
|
||||
await rm(absolute, { force: true })
|
||||
// Drop folders the deletion left empty, never the root and never a folder with anything left.
|
||||
let folder = dirname(absolute)
|
||||
while (folder.length > root.length && folder.startsWith(root)) {
|
||||
try {
|
||||
await rmdir(folder)
|
||||
} catch {
|
||||
break
|
||||
}
|
||||
folder = dirname(folder)
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreIndexEntries(
|
||||
repo: CheckpointRepo,
|
||||
fromTree: string,
|
||||
toTree: string,
|
||||
hidden: Set<string>
|
||||
): Promise<void> {
|
||||
if (fromTree === toTree) {
|
||||
return
|
||||
}
|
||||
const out = await repo.git(['diff-tree', '-r', '-z', '--no-renames', '--raw', fromTree, toTree])
|
||||
const fields = out.split('\0')
|
||||
const records: string[] = []
|
||||
for (let index = 0; index + 1 < fields.length; index += 2) {
|
||||
const header = fields[index]
|
||||
if (!header.startsWith(':')) {
|
||||
continue
|
||||
}
|
||||
const [, newMode, , newObject] = header.slice(1).split(' ')
|
||||
const path = fields[index + 1]
|
||||
// Rewriting an entry drops its skip-worktree/assume-unchanged bit; those stay as they are.
|
||||
if (path === undefined || hidden.has(path)) {
|
||||
continue
|
||||
}
|
||||
// A path the staged tree lacks arrives as mode 000000 with an all-zero id: --index-info removes it.
|
||||
records.push(`${newMode === '000000' ? '0' : newMode} ${newObject}\t${path}`)
|
||||
}
|
||||
if (records.length > 0) {
|
||||
await repo.git(['update-index', '-z', '--index-info'], {
|
||||
stdin: `${records.join('\0')}\0`
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function applyChanges(
|
||||
repo: CheckpointRepo,
|
||||
target: WorktreeCheckpoint,
|
||||
changes: TreeChange[]
|
||||
): Promise<void> {
|
||||
const rootReal = await realpath(repo.root)
|
||||
// Deletes first, so a file can take the place of a folder (or a case-only rename) it replaces.
|
||||
for (const change of changes) {
|
||||
if (change.status === 'D') {
|
||||
await removeWorktreeFile(rootReal, repo.root, change.path)
|
||||
}
|
||||
}
|
||||
const writes = changes.filter((change) => change.status !== 'D').map((change) => change.path)
|
||||
if (writes.length > 0) {
|
||||
await withTempIndex(repo, false, async (env) => {
|
||||
await repo.git(['read-tree', target.tree], { env })
|
||||
// checkout-index refuses symlinked leading paths itself; -f replaces what is there.
|
||||
await repo.git(['checkout-index', '-f', '-z', '--stdin'], {
|
||||
env,
|
||||
stdin: `${writes.join('\0')}\0`
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export async function restoreWorktreeCheckpoint(
|
||||
repo: CheckpointRepo,
|
||||
target: WorktreeCheckpoint,
|
||||
undo: { ref: string; labels?: Record<string, string>; now: number }
|
||||
): Promise<WorktreeRestoreResult> {
|
||||
await assertNoConflicts(repo)
|
||||
const hidden = await hiddenIndexPaths(repo)
|
||||
// Saving the undo point first is what makes the restore safe: a failure below leaves it to restore.
|
||||
const before = await captureWorktreeTree(repo, {
|
||||
forceInclude: overwrittenUnsnapshottedPaths(repo, target)
|
||||
})
|
||||
const changes = await restoreChanges(repo, before.tree, target.tree, hidden)
|
||||
await assertWritable(repo, changes)
|
||||
await assertUndoCovers(repo, before.tree, changes)
|
||||
const undoCheckpoint = await writeWorktreeCheckpoint(repo, undo.ref, before, {
|
||||
kind: 'undo',
|
||||
createdAt: undo.now,
|
||||
...(undo.labels ? { labels: undo.labels } : {})
|
||||
})
|
||||
try {
|
||||
await applyChanges(repo, target, changes)
|
||||
} catch (error) {
|
||||
throw new WorktreeCheckpointError(
|
||||
'partial',
|
||||
`Some files were not restored: ${error instanceof Error ? error.message : String(error)}`,
|
||||
[],
|
||||
undoCheckpoint.ref
|
||||
)
|
||||
}
|
||||
const head = await resolveCommit(repo, 'HEAD')
|
||||
const headMoved = head !== target.head
|
||||
let indexRestored = false
|
||||
if (!headMoved && target.indexTree && before.indexTree) {
|
||||
// Same commit as at the checkpoint: put back exactly what was staged then. Only entries that
|
||||
// differ are rewritten, so the rest keep their stat data and the next status stays fast. Files
|
||||
// are already restored, so a busy index (another git holding index.lock) only loses staging.
|
||||
try {
|
||||
await restoreIndexEntries(repo, before.indexTree, target.indexTree, hidden)
|
||||
indexRestored = true
|
||||
} catch {
|
||||
indexRestored = false
|
||||
}
|
||||
}
|
||||
return { changes, headMoved, undo: undoCheckpoint, indexRestored }
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// Keeps checkpoint refs bounded: the newest N under one prefix, and nothing older than a max age
|
||||
// anywhere under refs/orca/checkpoints/. Deleting a ref only drops its reachability; git gc frees
|
||||
// the objects later on its own schedule.
|
||||
|
||||
import { type CheckpointRepo, CHECKPOINT_REF_ROOT } from './worktree-checkpoint-git'
|
||||
|
||||
type RefAge = { ref: string; committedAt: number }
|
||||
|
||||
async function listRefs(repo: CheckpointRepo, prefix: string): Promise<RefAge[]> {
|
||||
const out = await repo.git([
|
||||
'for-each-ref',
|
||||
'--sort=-committerdate',
|
||||
'--format=%(committerdate:unix) %(refname)',
|
||||
prefix
|
||||
])
|
||||
return out
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const space = line.indexOf(' ')
|
||||
return {
|
||||
committedAt: Number(line.slice(0, space)) * 1000,
|
||||
ref: line.slice(space + 1)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function listCheckpointRefs(repo: CheckpointRepo, prefix: string): Promise<string[]> {
|
||||
return (await listRefs(repo, prefix)).map((entry) => entry.ref)
|
||||
}
|
||||
|
||||
export async function deleteCheckpointRefs(repo: CheckpointRepo, refs: string[]): Promise<void> {
|
||||
const owned = refs.filter((ref) => ref.startsWith(CHECKPOINT_REF_ROOT))
|
||||
if (owned.length === 0) {
|
||||
return
|
||||
}
|
||||
await repo.git(['update-ref', '--stdin'], {
|
||||
stdin: owned.map((ref) => `delete ${ref}\n`).join('')
|
||||
})
|
||||
}
|
||||
|
||||
/** Deletes all but the newest `keep` refs under `prefix`, plus any checkpoint older than `maxAgeMs`. */
|
||||
export async function pruneCheckpointRefs(
|
||||
repo: CheckpointRepo,
|
||||
input: {
|
||||
prefix: string
|
||||
keep: number
|
||||
maxAgeMs: number
|
||||
now: number
|
||||
/** Never deleted here, whatever its date says (a clock that ran ahead must not prune the
|
||||
* checkpoint just written). */
|
||||
protect?: string[]
|
||||
}
|
||||
): Promise<string[]> {
|
||||
if (!input.prefix.startsWith(CHECKPOINT_REF_ROOT)) {
|
||||
throw new Error(`checkpoint prefixes live under ${CHECKPOINT_REF_ROOT}: ${input.prefix}`)
|
||||
}
|
||||
const overCount = (await listRefs(repo, input.prefix)).slice(input.keep).map((entry) => entry.ref)
|
||||
const cutoff = input.now - input.maxAgeMs
|
||||
const tooOld = (await listRefs(repo, CHECKPOINT_REF_ROOT))
|
||||
.filter((entry) => entry.committedAt < cutoff)
|
||||
.map((entry) => entry.ref)
|
||||
const kept = new Set(input.protect ?? [])
|
||||
const doomed = [...new Set([...overCount, ...tooOld])].filter((ref) => !kept.has(ref))
|
||||
await deleteCheckpointRefs(repo, doomed)
|
||||
return doomed
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
// A checkpoint is a stash-like commit kept under refs/orca/checkpoints/: its tree is the working
|
||||
// tree (tracked, plus untracked files .gitignore doesn't exclude), its first parent is HEAD and its
|
||||
// second parent holds the staged tree. Writing one never touches HEAD, the branch, the user's index
|
||||
// or the stash list.
|
||||
|
||||
import { z } from 'zod'
|
||||
import { lstat } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
type CheckpointGitOptions,
|
||||
type CheckpointRepo,
|
||||
CHECKPOINT_REF_ROOT,
|
||||
gitExitCode,
|
||||
resolveCommit,
|
||||
withTempIndex,
|
||||
WorktreeCheckpointError
|
||||
} from './worktree-checkpoint-git'
|
||||
|
||||
const META_PREFIX = 'orca-checkpoint: '
|
||||
|
||||
export type WorktreeCheckpointKind = 'turn' | 'undo'
|
||||
|
||||
export type WorktreeCheckpointMeta = {
|
||||
v: 1
|
||||
kind: WorktreeCheckpointKind
|
||||
/** HEAD when the checkpoint was taken; null on an unborn branch. */
|
||||
head: string | null
|
||||
/** The staged tree; null while the index had unmerged entries. */
|
||||
indexTree: string | null
|
||||
createdAt: number
|
||||
/** Caller's own labels (session and turn ids), opaque to git. */
|
||||
labels?: Record<string, string>
|
||||
}
|
||||
|
||||
export type WorktreeTree = {
|
||||
tree: string
|
||||
head: string | null
|
||||
indexTree: string | null
|
||||
}
|
||||
|
||||
export type WorktreeCheckpoint = WorktreeCheckpointMeta & {
|
||||
ref: string
|
||||
commit: string
|
||||
tree: string
|
||||
}
|
||||
|
||||
export type CaptureOptions = {
|
||||
signal?: AbortSignal
|
||||
timeoutMs?: number
|
||||
/** Given the working tree's tree, returns ignored paths that must be saved too (restore uses it
|
||||
* to keep a copy of every file it is about to overwrite). */
|
||||
forceInclude?: (tree: string) => Promise<string[]>
|
||||
}
|
||||
|
||||
/** Untracked files above this are left out of snapshots (like ignored files): hashing them every
|
||||
* turn would bloat the object store. A restore that would overwrite one still saves it first. */
|
||||
export const CHECKPOINT_MAX_UNTRACKED_BYTES = 10 * 1024 * 1024
|
||||
|
||||
async function largeUntrackedPaths(
|
||||
repo: CheckpointRepo,
|
||||
options: CheckpointGitOptions,
|
||||
deadline: number | null
|
||||
): Promise<string[]> {
|
||||
const out = await repo.git(['ls-files', '-o', '--exclude-standard', '-z'], options)
|
||||
const large: string[] = []
|
||||
for (const path of out.split('\0')) {
|
||||
if (deadline !== null && Date.now() > deadline) {
|
||||
throw new WorktreeCheckpointError('snapshot-failed', 'The snapshot ran out of time.')
|
||||
}
|
||||
const stat = path ? await lstat(join(repo.root, path)).catch(() => null) : null
|
||||
if (stat?.isFile() && stat.size > CHECKPOINT_MAX_UNTRACKED_BYTES) {
|
||||
large.push(path)
|
||||
}
|
||||
}
|
||||
return large
|
||||
}
|
||||
|
||||
/** `add --ignore-errors` exits 1 after skipping files it couldn't read; everything else is in. */
|
||||
async function addSkippingUnreadable(
|
||||
repo: CheckpointRepo,
|
||||
args: string[],
|
||||
options: CheckpointGitOptions
|
||||
): Promise<void> {
|
||||
try {
|
||||
await repo.git(['add', '--ignore-errors', ...args], options)
|
||||
} catch (error) {
|
||||
if (gitExitCode(error) !== 1) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Writes the working tree's tree into the object store without recording a ref. */
|
||||
export async function captureWorktreeTree(
|
||||
repo: CheckpointRepo,
|
||||
options: CaptureOptions = {}
|
||||
): Promise<WorktreeTree> {
|
||||
// One deadline for the whole capture, not one per git command.
|
||||
const deadline = options.timeoutMs === undefined ? null : Date.now() + options.timeoutMs
|
||||
const run = (env?: Record<string, string>): CheckpointGitOptions => ({
|
||||
...(options.signal ? { signal: options.signal } : {}),
|
||||
...(deadline === null ? {} : { timeoutMs: Math.max(1, deadline - Date.now()) }),
|
||||
...(env ? { env } : {})
|
||||
})
|
||||
const head = await resolveCommit(repo, 'HEAD')
|
||||
const large = await largeUntrackedPaths(repo, run(), deadline)
|
||||
return withTempIndex(repo, true, async (env) => {
|
||||
let indexTree: string | null = null
|
||||
try {
|
||||
// The temp index is a byte copy of the user's, so this is exactly what they had staged.
|
||||
indexTree = (await repo.git(['write-tree'], run(env))).trim() || null
|
||||
} catch {
|
||||
indexTree = null
|
||||
}
|
||||
await addSkippingUnreadable(repo, ['-A', '--pathspec-from-file=-', '--pathspec-file-nul'], {
|
||||
...run(env),
|
||||
stdin: `${[':/', ...large.map((path) => `:(exclude,literal)${path}`)].join('\0')}\0`
|
||||
})
|
||||
let tree = (await repo.git(['write-tree'], run(env))).trim()
|
||||
const forced = options.forceInclude ? await options.forceInclude(tree) : []
|
||||
if (forced.length > 0) {
|
||||
await repo.git(['add', '-f', '--pathspec-from-file=-', '--pathspec-file-nul'], {
|
||||
...run(env),
|
||||
stdin: `${forced.map(literalPathspec).join('\0')}\0`
|
||||
})
|
||||
tree = (await repo.git(['write-tree'], run(env))).trim()
|
||||
}
|
||||
if (!tree) {
|
||||
throw new WorktreeCheckpointError('snapshot-failed', 'git write-tree returned no tree.')
|
||||
}
|
||||
return { tree, head, indexTree }
|
||||
})
|
||||
}
|
||||
|
||||
function literalPathspec(path: string): string {
|
||||
return `:(literal)${path}`
|
||||
}
|
||||
|
||||
export async function writeWorktreeCheckpoint(
|
||||
repo: CheckpointRepo,
|
||||
ref: string,
|
||||
snapshot: WorktreeTree,
|
||||
meta: Omit<WorktreeCheckpointMeta, 'v' | 'head' | 'indexTree'>
|
||||
): Promise<WorktreeCheckpoint> {
|
||||
if (!ref.startsWith(CHECKPOINT_REF_ROOT)) {
|
||||
throw new Error(`checkpoint refs live under ${CHECKPOINT_REF_ROOT}: ${ref}`)
|
||||
}
|
||||
const full: WorktreeCheckpointMeta = {
|
||||
v: 1,
|
||||
kind: meta.kind,
|
||||
head: snapshot.head,
|
||||
indexTree: snapshot.indexTree,
|
||||
createdAt: meta.createdAt,
|
||||
...(meta.labels ? { labels: meta.labels } : {})
|
||||
}
|
||||
const headParent = snapshot.head ? ['-p', snapshot.head] : []
|
||||
// Commit dates follow the caller's clock, so retention and ordering read one timeline.
|
||||
const stamp = `@${Math.floor(meta.createdAt / 1000)} +0000`
|
||||
const env = { GIT_AUTHOR_DATE: stamp, GIT_COMMITTER_DATE: stamp }
|
||||
// The staged tree rides as the second parent so gc keeps it as long as the checkpoint.
|
||||
const indexCommit = snapshot.indexTree
|
||||
? (
|
||||
await repo.git(
|
||||
[
|
||||
'commit-tree',
|
||||
'--no-gpg-sign',
|
||||
snapshot.indexTree,
|
||||
...headParent,
|
||||
'-m',
|
||||
'Orca checkpoint index'
|
||||
],
|
||||
{ env }
|
||||
)
|
||||
).trim()
|
||||
: null
|
||||
const commit = (
|
||||
await repo.git(
|
||||
[
|
||||
'commit-tree',
|
||||
'--no-gpg-sign',
|
||||
snapshot.tree,
|
||||
...headParent,
|
||||
...(indexCommit ? ['-p', indexCommit] : []),
|
||||
'-m',
|
||||
`Orca checkpoint (${meta.kind})\n\n${META_PREFIX}${JSON.stringify(full)}`
|
||||
],
|
||||
{ env }
|
||||
)
|
||||
).trim()
|
||||
await repo.git(['update-ref', ref, commit])
|
||||
return { ...full, ref, commit, tree: snapshot.tree }
|
||||
}
|
||||
|
||||
export async function captureWorktreeCheckpoint(
|
||||
repo: CheckpointRepo,
|
||||
ref: string,
|
||||
meta: Omit<WorktreeCheckpointMeta, 'v' | 'head' | 'indexTree'>,
|
||||
options: CaptureOptions = {}
|
||||
): Promise<WorktreeCheckpoint> {
|
||||
return writeWorktreeCheckpoint(repo, ref, await captureWorktreeTree(repo, options), meta)
|
||||
}
|
||||
|
||||
export async function readWorktreeCheckpoint(
|
||||
repo: CheckpointRepo,
|
||||
ref: string
|
||||
): Promise<WorktreeCheckpoint | null> {
|
||||
if (!ref.startsWith(CHECKPOINT_REF_ROOT)) {
|
||||
return null
|
||||
}
|
||||
const commit = await resolveCommit(repo, ref)
|
||||
if (!commit) {
|
||||
return null
|
||||
}
|
||||
const out = await repo.git(['show', '-s', '--format=%T%x00%B', commit])
|
||||
const [tree, body = ''] = out.split('\0')
|
||||
const line = body.split('\n').find((candidate) => candidate.startsWith(META_PREFIX))
|
||||
const meta = line ? parseMeta(line.slice(META_PREFIX.length)) : null
|
||||
return meta ? { ...meta, ref, commit, tree: tree.trim() } : null
|
||||
}
|
||||
|
||||
const MetaSchema = z.object({
|
||||
v: z.literal(1),
|
||||
kind: z.enum(['turn', 'undo']),
|
||||
head: z.string().nullable(),
|
||||
indexTree: z.string().nullable(),
|
||||
createdAt: z.number(),
|
||||
labels: z.record(z.string(), z.string()).optional()
|
||||
})
|
||||
|
||||
function parseMeta(text: string): WorktreeCheckpointMeta | null {
|
||||
try {
|
||||
const parsed = MetaSchema.safeParse(JSON.parse(text))
|
||||
return parsed.success ? parsed.data : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// Per-worktree ordering shared by every chat's checkpoint work in this process. Process-local by
|
||||
// design: the host that captures a chat's turns is the one that restores them (one main process
|
||||
// or orcad per machine owns a chat).
|
||||
|
||||
// One git writer per worktree at a time across every chat in this process: a capture never
|
||||
// interleaves with a restore, so a send after a rewind snapshots the restored files.
|
||||
const queues = new Map<string, Promise<unknown>>()
|
||||
export function serialize<T>(root: string, task: () => Promise<T>): Promise<T> {
|
||||
const run = (queues.get(root) ?? Promise.resolve()).then(task, task)
|
||||
const tail = run.catch(() => undefined)
|
||||
queues.set(root, tail)
|
||||
void tail.then(() => queues.get(root) === tail && queues.delete(root))
|
||||
return run
|
||||
}
|
||||
|
||||
// Worktrees with a turn between its snapshot and its hand-off to the agent: the chat isn't
|
||||
// "working" yet, but a restore now would rewrite files the agent is about to see.
|
||||
export const startingTurns = new Map<string, number>()
|
||||
export function markTurnStarting(root: string): () => void {
|
||||
startingTurns.set(root, (startingTurns.get(root) ?? 0) + 1)
|
||||
let released = false
|
||||
return () => {
|
||||
if (released) {
|
||||
return
|
||||
}
|
||||
released = true
|
||||
const left = (startingTurns.get(root) ?? 1) - 1
|
||||
if (left > 0) {
|
||||
startingTurns.set(root, left)
|
||||
} else {
|
||||
startingTurns.delete(root)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// Binds checkpoints to the host: the capture a send takes, and the host's preview/restore/undo.
|
||||
|
||||
import type { StructuredAgentSessionDeliveryLoopDeps } from '../delivery/structured-agent-session-delivery-loop-deps'
|
||||
import type { StructuredAgentSessionHostDeps } from '../host/structured-agent-session-host-types'
|
||||
import {
|
||||
type AgentSessionCheckpoints,
|
||||
createAgentSessionCheckpoints
|
||||
} from './agent-session-checkpoints'
|
||||
|
||||
/** The snapshot a send takes before its message opens a turn; never throws. */
|
||||
export function structuredAgentSessionTurnCheckpoint(
|
||||
deps: Pick<
|
||||
StructuredAgentSessionDeliveryLoopDeps,
|
||||
'record' | 'resolveWorkspacePath' | 'logger' | 'now'
|
||||
>,
|
||||
sessionId: string
|
||||
): (itemId: string, signal?: AbortSignal) => Promise<() => void> {
|
||||
const { captureTurn } = createAgentSessionCheckpoints({
|
||||
record: deps.record,
|
||||
...(deps.resolveWorkspacePath ? { resolveWorkspacePath: deps.resolveWorkspacePath } : {}),
|
||||
isBusy: () => false,
|
||||
logger: deps.logger,
|
||||
now: deps.now
|
||||
})
|
||||
return (itemId, signal) => captureTurn(sessionId, itemId, signal)
|
||||
}
|
||||
|
||||
type CheckpointHost = {
|
||||
deps: Pick<StructuredAgentSessionHostDeps, 'store' | 'resolveWorkspacePath' | 'logger' | 'now'>
|
||||
readStatusSummary: (sessionId: string) => { status: string | null } | undefined
|
||||
}
|
||||
const hostCheckpoints = new WeakMap<CheckpointHost, AgentSessionCheckpoints>()
|
||||
|
||||
/** Preview, restore and undo for a host's chats; a restore waits for a running turn to end. */
|
||||
export function hostAgentSessionCheckpoints(host: CheckpointHost): AgentSessionCheckpoints {
|
||||
let checkpoints = hostCheckpoints.get(host)
|
||||
if (!checkpoints) {
|
||||
const { deps } = host
|
||||
checkpoints = createAgentSessionCheckpoints({
|
||||
record: (sessionId) => deps.store.getRecord(sessionId),
|
||||
...(deps.resolveWorkspacePath ? { resolveWorkspacePath: deps.resolveWorkspacePath } : {}),
|
||||
// Any chat working in the same worktree could be writing the files a restore rewrites.
|
||||
isBusy: (sessionId) => {
|
||||
const location = deps.store.getRecord(sessionId)?.location
|
||||
return deps.store
|
||||
.listConversations()
|
||||
.some(
|
||||
(record) =>
|
||||
record.location.workspaceId === location?.workspaceId &&
|
||||
record.location.executionHostId === location.executionHostId &&
|
||||
host.readStatusSummary(record.sessionId)?.status === 'working'
|
||||
)
|
||||
},
|
||||
logger: deps.logger,
|
||||
now: () => deps.now?.() ?? Date.now()
|
||||
})
|
||||
hostCheckpoints.set(host, checkpoints)
|
||||
}
|
||||
return checkpoints
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
// Ties worktree checkpoints to chats: one snapshot before each turn a message opens, keyed by that
|
||||
// message's journal item, so "Rewind to here" can put the files back as they were when it was sent.
|
||||
// Capture failures never block or fail a send; they only mean that message has no checkpoint.
|
||||
|
||||
import { createHash, randomBytes } from 'node:crypto'
|
||||
import type { AgentSessionRecord } from '../../../../shared/agent-session/lifecycle/agent-session-record'
|
||||
import {
|
||||
AGENT_SESSION_CHECKPOINT_UNDO_ID,
|
||||
agentSessionCheckpointPartialMessage,
|
||||
agentSessionCheckpointRefusalMessage,
|
||||
type AgentSessionCheckpointPreview,
|
||||
type AgentSessionCheckpointRestoreResult
|
||||
} from '../../../../shared/agent-session/lifecycle/agent-session-checkpoint'
|
||||
import {
|
||||
CHECKPOINT_REF_ROOT,
|
||||
type CheckpointRepo,
|
||||
openCheckpointRepo,
|
||||
WorktreeCheckpointError
|
||||
} from '../../../git/checkpoints/worktree-checkpoint-git'
|
||||
import {
|
||||
captureWorktreeCheckpoint,
|
||||
readWorktreeCheckpoint,
|
||||
type WorktreeCheckpoint
|
||||
} from '../../../git/checkpoints/worktree-checkpoint-snapshot'
|
||||
import {
|
||||
previewWorktreeRestore,
|
||||
restoreWorktreeCheckpoint
|
||||
} from '../../../git/checkpoints/worktree-checkpoint-restore'
|
||||
import { pruneCheckpointRefs } from '../../../git/checkpoints/worktree-checkpoint-retention'
|
||||
import type { StructuredAgentSessionLogger } from '../host/structured-agent-session-logger'
|
||||
import { markTurnStarting, serialize, startingTurns } from './agent-session-checkpoint-queue'
|
||||
|
||||
export const TURN_CHECKPOINTS_KEPT = 50
|
||||
export const UNDO_CHECKPOINTS_KEPT = 10
|
||||
export const CHECKPOINT_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000
|
||||
/** A worktree too large to snapshot in this long sends without a checkpoint. */
|
||||
export const CHECKPOINT_CAPTURE_TIMEOUT_MS = 15_000
|
||||
|
||||
export type AgentSessionCheckpointsDeps = {
|
||||
record: (sessionId: string) => AgentSessionRecord | null
|
||||
resolveWorkspacePath?: (workspaceId: string) => Promise<string>
|
||||
/** True while any chat in this chat's worktree has a turn running; restores refuse then. */
|
||||
isBusy: (sessionId: string) => boolean
|
||||
logger: StructuredAgentSessionLogger
|
||||
now: () => number
|
||||
openRepo?: (directory: string) => Promise<CheckpointRepo>
|
||||
}
|
||||
|
||||
export type AgentSessionCheckpoints = ReturnType<typeof createAgentSessionCheckpoints>
|
||||
|
||||
/** Ref-safe and stable: readable ids pass through, anything else is hashed. */
|
||||
function refComponent(value: string): string {
|
||||
return /^[A-Za-z0-9_-][A-Za-z0-9._-]{0,127}$/.test(value) && !value.endsWith('.lock')
|
||||
? value
|
||||
: createHash('sha256').update(value).digest('hex').slice(0, 32)
|
||||
}
|
||||
|
||||
function sessionPrefix(sessionId: string): string {
|
||||
return `${CHECKPOINT_REF_ROOT}${refComponent(sessionId)}/`
|
||||
}
|
||||
|
||||
function turnRef(sessionId: string, itemId: string): string {
|
||||
return `${sessionPrefix(sessionId)}turn/${createHash('sha256').update(itemId).digest('hex').slice(0, 32)}`
|
||||
}
|
||||
|
||||
function refuse(reason: Parameters<typeof agentSessionCheckpointRefusalMessage>[0]): never {
|
||||
throw new Error(agentSessionCheckpointRefusalMessage(reason))
|
||||
}
|
||||
|
||||
/** Something else in the workspace that may write files: a terminal agent, say. */
|
||||
/** Gets the worktree root the restore would write. */
|
||||
export type AgentSessionWorkspaceBusy = (root: string) => Promise<boolean>
|
||||
|
||||
export function createAgentSessionCheckpoints(deps: AgentSessionCheckpointsDeps) {
|
||||
const open = deps.openRepo ?? ((directory: string) => openCheckpointRepo(directory))
|
||||
const repoFor = async (sessionId: string): Promise<CheckpointRepo | null> => {
|
||||
const record = deps.record(sessionId)
|
||||
// WSL worktrees route git through another filesystem; their temp index paths don't map.
|
||||
if (!record || record.location.wslDistro) {
|
||||
return null
|
||||
}
|
||||
const directory =
|
||||
record.launchDirectory ?? (await deps.resolveWorkspacePath?.(record.location.workspaceId))
|
||||
return directory ? open(directory) : null
|
||||
}
|
||||
|
||||
const captureTurn = async (
|
||||
sessionId: string,
|
||||
itemId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<() => void> => {
|
||||
const started = deps.now()
|
||||
let release = () => {}
|
||||
try {
|
||||
const repo = await repoFor(sessionId)
|
||||
if (!repo) {
|
||||
return release
|
||||
}
|
||||
release = markTurnStarting(repo.root)
|
||||
await serialize(repo.root, async () => {
|
||||
await captureWorktreeCheckpoint(
|
||||
repo,
|
||||
turnRef(sessionId, itemId),
|
||||
{
|
||||
kind: 'turn',
|
||||
createdAt: deps.now(),
|
||||
labels: { sessionId, itemId }
|
||||
},
|
||||
{ signal, timeoutMs: CHECKPOINT_CAPTURE_TIMEOUT_MS }
|
||||
)
|
||||
await pruneCheckpointRefs(repo, {
|
||||
prefix: `${sessionPrefix(sessionId)}turn/`,
|
||||
keep: TURN_CHECKPOINTS_KEPT,
|
||||
maxAgeMs: CHECKPOINT_MAX_AGE_MS,
|
||||
now: deps.now(),
|
||||
protect: [turnRef(sessionId, itemId)]
|
||||
})
|
||||
})
|
||||
} catch (error) {
|
||||
if (!(error instanceof WorktreeCheckpointError && error.reason === 'not-a-repo')) {
|
||||
deps.logger.warn('[agent-session] turn checkpoint skipped', {
|
||||
scope: 'checkpoint-capture',
|
||||
sessionId,
|
||||
elapsedMs: deps.now() - started,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
}
|
||||
}
|
||||
return release
|
||||
}
|
||||
|
||||
const target = async (
|
||||
sessionId: string,
|
||||
ref: string
|
||||
): Promise<{
|
||||
repo: CheckpointRepo
|
||||
checkpoint: WorktreeCheckpoint
|
||||
} | null> => {
|
||||
const repo = await repoFor(sessionId)
|
||||
const checkpoint = repo ? await readWorktreeCheckpoint(repo, ref) : null
|
||||
return repo && checkpoint ? { repo, checkpoint } : null
|
||||
}
|
||||
|
||||
const preview = async (
|
||||
sessionId: string,
|
||||
itemId: string,
|
||||
workspaceBusy?: AgentSessionWorkspaceBusy
|
||||
): Promise<AgentSessionCheckpointPreview> => {
|
||||
try {
|
||||
const found = await target(sessionId, turnRef(sessionId, itemId))
|
||||
if (!found) {
|
||||
return { available: false, reason: 'none' }
|
||||
}
|
||||
const { changes, headMoved } = await serialize(found.repo.root, () =>
|
||||
previewWorktreeRestore(found.repo, found.checkpoint)
|
||||
)
|
||||
return {
|
||||
available: true,
|
||||
changedFiles: changes.length,
|
||||
headMoved,
|
||||
createdAt: found.checkpoint.createdAt,
|
||||
busy:
|
||||
deps.isBusy(sessionId) ||
|
||||
(startingTurns.get(found.repo.root) ?? 0) > 0 ||
|
||||
((await workspaceBusy?.(found.repo.root).catch(() => false)) ?? false)
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof WorktreeCheckpointError && error.reason === 'not-a-repo') {
|
||||
return { available: false, reason: 'not-a-repo' }
|
||||
}
|
||||
deps.logger.warn('[agent-session] checkpoint preview failed', {
|
||||
scope: 'checkpoint-preview',
|
||||
sessionId,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return { available: false, reason: 'unavailable' }
|
||||
}
|
||||
}
|
||||
|
||||
const restoreRef = async (
|
||||
sessionId: string,
|
||||
ref: string,
|
||||
workspaceBusy?: AgentSessionWorkspaceBusy
|
||||
): Promise<AgentSessionCheckpointRestoreResult> => {
|
||||
const repo = await repoFor(sessionId).catch((error: unknown) => {
|
||||
if (error instanceof WorktreeCheckpointError && error.reason === 'not-a-repo') {
|
||||
refuse('not-a-repo')
|
||||
}
|
||||
throw error
|
||||
})
|
||||
if (!repo) {
|
||||
refuse('none')
|
||||
}
|
||||
const now = deps.now()
|
||||
const undoId = `${now}-${randomBytes(4).toString('hex')}`
|
||||
const undoRef = `${sessionPrefix(sessionId)}undo/${undoId}`
|
||||
try {
|
||||
// Look up and check busy inside the queue: a capture queued first runs first, and the turn
|
||||
// it starts must make this restore refuse rather than rewrite files under a working agent.
|
||||
const result = await serialize(repo.root, async () => {
|
||||
if (
|
||||
deps.isBusy(sessionId) ||
|
||||
(startingTurns.get(repo.root) ?? 0) > 0 ||
|
||||
(await workspaceBusy?.(repo.root))
|
||||
) {
|
||||
refuse('busy')
|
||||
}
|
||||
const checkpoint = await readWorktreeCheckpoint(repo, ref)
|
||||
if (!checkpoint) {
|
||||
refuse('none')
|
||||
}
|
||||
return restoreWorktreeCheckpoint(repo, checkpoint, {
|
||||
ref: undoRef,
|
||||
now,
|
||||
labels: { sessionId }
|
||||
})
|
||||
})
|
||||
void pruneCheckpointRefs(repo, {
|
||||
prefix: `${sessionPrefix(sessionId)}undo/`,
|
||||
keep: UNDO_CHECKPOINTS_KEPT,
|
||||
maxAgeMs: CHECKPOINT_MAX_AGE_MS,
|
||||
now,
|
||||
protect: [undoRef]
|
||||
}).catch(() => undefined)
|
||||
return {
|
||||
changedFiles: result.changes.length,
|
||||
headMoved: result.headMoved,
|
||||
undoId
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof WorktreeCheckpointError && error.reason === 'partial') {
|
||||
throw new Error(agentSessionCheckpointPartialMessage(undoId))
|
||||
}
|
||||
if (
|
||||
error instanceof WorktreeCheckpointError &&
|
||||
(error.reason === 'merge-conflicts' ||
|
||||
error.reason === 'blocked-path' ||
|
||||
error.reason === 'unsafe-path')
|
||||
) {
|
||||
refuse(error.reason)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
captureTurn,
|
||||
preview,
|
||||
restore: (sessionId: string, itemId: string, workspaceBusy?: AgentSessionWorkspaceBusy) =>
|
||||
restoreRef(sessionId, turnRef(sessionId, itemId), workspaceBusy),
|
||||
undo: async (sessionId: string, undoId: string, workspaceBusy?: AgentSessionWorkspaceBusy) => {
|
||||
if (!AGENT_SESSION_CHECKPOINT_UNDO_ID.test(undoId)) {
|
||||
refuse('none')
|
||||
}
|
||||
return restoreRef(sessionId, `${sessionPrefix(sessionId)}undo/${undoId}`, workspaceBusy)
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -69,4 +69,6 @@ export type StructuredAgentSessionDeliveryLoopDeps = {
|
||||
now: () => number
|
||||
acquireAborts: Pick<StructuredAgentSessionAcquireAborts, 'begin'>
|
||||
recordRanOptions?: StructuredAgentSessionRanRecorder
|
||||
/** Where a chat's worktree is, for the file checkpoint taken before each turn. */
|
||||
resolveWorkspacePath?: ((workspaceId: string) => Promise<string>) | undefined
|
||||
}
|
||||
|
||||
@@ -113,9 +113,18 @@ export async function beginSubmissionHandover(
|
||||
}
|
||||
// Preserve handover time so turn-open cannot re-anchor elapsed work.
|
||||
const requestedAt = structuredAgentSessionHandoverOrigin(ctx.journal, submission)
|
||||
let releaseCheckpoint: (() => void) | undefined
|
||||
return {
|
||||
run: async (commit) => {
|
||||
try {
|
||||
// A message that opens a turn gets a file checkpoint first, taken before the agent can
|
||||
// write; one that joins a running turn would snapshot the agent mid-edit, so it gets none.
|
||||
if (turnScope.kind === 'thread' && ctx.captureTurnCheckpoint && !attempt?.signal.aborted) {
|
||||
releaseCheckpoint = await ctx.captureTurnCheckpoint(
|
||||
agentJournalSubmissionKey(clientMessageId),
|
||||
attempt?.signal
|
||||
)
|
||||
}
|
||||
let outcome: AgentSessionDispatchOutcome | null
|
||||
try {
|
||||
for (;;) {
|
||||
@@ -216,6 +225,7 @@ export async function beginSubmissionHandover(
|
||||
}
|
||||
})
|
||||
} finally {
|
||||
releaseCheckpoint?.()
|
||||
release?.()
|
||||
released()
|
||||
}
|
||||
|
||||
+1
@@ -220,6 +220,7 @@ export function createStructuredAgentSessionConversationDelivery(input: {
|
||||
stopping: input.clientDelivery.readStopping,
|
||||
now: () => deps.now?.() ?? Date.now(),
|
||||
acquireAborts: input.acquireAborts,
|
||||
resolveWorkspacePath: deps.resolveWorkspacePath,
|
||||
recordRanOptions: (sessionId, fence) =>
|
||||
createStructuredAgentSessionRanRecorder(
|
||||
{ deps, sessions, now: () => deps.now?.() ?? Date.now() },
|
||||
|
||||
+3
-1
@@ -25,6 +25,7 @@ import {
|
||||
import { structuredAgentSessionEndedChildFailure } from '../termination/structured-agent-session-ended-child-failure'
|
||||
import { sameProviderChild } from '../host/structured-agent-session-provider-child'
|
||||
import { previousExitUnverifiableRefusal } from '../termination/structured-agent-session-child-close'
|
||||
import { structuredAgentSessionTurnCheckpoint } from '../checkpoints/agent-session-checkpoints-host'
|
||||
|
||||
type StartFailure = NonNullable<ReturnType<typeof startThatFailedWhileQueued>>
|
||||
|
||||
@@ -293,7 +294,8 @@ export async function beginStructuredAgentSessionInputHandover(
|
||||
},
|
||||
get recordRanOptions() {
|
||||
return recorder
|
||||
}
|
||||
},
|
||||
captureTurnCheckpoint: structuredAgentSessionTurnCheckpoint(deps, sessionId)
|
||||
},
|
||||
next,
|
||||
// Re-read right before the provider call, which runs outside the serialize.
|
||||
|
||||
@@ -280,6 +280,9 @@ export type AgentSessionHandoverContext = StructuredAgentSessionCommandHandoverC
|
||||
settleEndedInput?: (owner: () => void) => Promise<void>
|
||||
/** A message the agent took: a chat keeps, where it has none saved, the options it ran. */
|
||||
recordRanOptions?: ReturnType<StructuredAgentSessionRanRecorder>
|
||||
/** Snapshots the worktree before this message opens a turn; never throws. */
|
||||
/** Resolves to a release, called once the message has been handed to the agent. */
|
||||
captureTurnCheckpoint?: (itemId: string, signal?: AbortSignal) => Promise<() => void>
|
||||
}
|
||||
|
||||
export type StructuredAgentSessionConversationLifetime = ReturnType<
|
||||
|
||||
+247
@@ -0,0 +1,247 @@
|
||||
// End to end through the host: real sends snapshot a real git worktree before each turn, a real
|
||||
// rewind goes back to a message, and the checkpoint restore (then its undo) puts the files back.
|
||||
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { runProcess } from '@orca/process-host'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { agentJournalSubmissionKey } from '../../../../../shared/agent-session/journal/agent-session-journal-item-key'
|
||||
import { computeAgentSessionPayloadFingerprint } from '../../../../../shared/agent-session/lifecycle/agent-session-mutation-envelope'
|
||||
import type { AgentSessionRecordStore } from '../../../../runtime/agent-session/records/agent-session-record-store'
|
||||
import { openTestAgentSessionRecordStore } from '../../../../runtime/agent-session/records/agent-session-record-store-test-harness'
|
||||
import { StructuredAgentSessionHost } from '../structured-agent-session-host'
|
||||
import type { AgentSessionDispatchOutcome } from '../../providers/structured-agent-session-adapter'
|
||||
import { createAgentSessionCheckpoints } from '../../checkpoints/agent-session-checkpoints'
|
||||
import { hostAgentSessionCheckpoints } from '../../checkpoints/agent-session-checkpoints-host'
|
||||
import type { AgentSessionRecord } from '../../../../../shared/agent-session/lifecycle/agent-session-record'
|
||||
import {
|
||||
HOST_TEST_NOW,
|
||||
HOST_TEST_SESSION,
|
||||
HOST_TEST_THREAD,
|
||||
hostTestAttachParams,
|
||||
hostTestMessage,
|
||||
hostTestOperationId,
|
||||
resetHostTestOperationIds
|
||||
} from './structured-agent-session-host-test-data'
|
||||
import {
|
||||
closeTestJournalHostDatabases,
|
||||
openTestJournalHostDatabase
|
||||
} from '../../../agent-session-journal/store-lifecycle/journal-host-database-test-support'
|
||||
import { createStructuredAgentSessionLogger } from '../structured-agent-session-logger'
|
||||
import { codexProviderHandle } from '../../../../../shared/agent-session/lifecycle/agent-session-provider-handle-encoding'
|
||||
import { claudeAndCodexDeclared } from '../../providers/testing/structured-agent-session-adapter-router-test-support'
|
||||
import { startsWhenPublished } from './structured-agent-session-instant-start.test-support'
|
||||
|
||||
const caller = { callerKey: 'desktop' }
|
||||
let directory: string
|
||||
let worktree: string
|
||||
let store: AgentSessionRecordStore
|
||||
let host: StructuredAgentSessionHost
|
||||
/** What "the agent" does to the worktree when a message reaches it. */
|
||||
let agentEdits: (() => Promise<void>)[]
|
||||
|
||||
async function git(...args: string[]): Promise<string> {
|
||||
const result = await runProcess({ program: 'git', args, cwd: worktree })
|
||||
if (result.code !== 0) {
|
||||
throw new Error(result.stderr)
|
||||
}
|
||||
return result.stdout.trim()
|
||||
}
|
||||
const put = async (path: string, text: string) => {
|
||||
await mkdir(join(worktree, path, '..'), { recursive: true })
|
||||
await writeFile(join(worktree, path), text)
|
||||
}
|
||||
const read = (path: string) => readFile(join(worktree, path), 'utf8')
|
||||
|
||||
beforeEach(async () => {
|
||||
resetHostTestOperationIds()
|
||||
agentEdits = []
|
||||
directory = await mkdtemp(join(tmpdir(), 'orca-rewind-files-'))
|
||||
worktree = await mkdtemp(join(tmpdir(), 'orca-rewind-files-tree-'))
|
||||
await git('init', '-q')
|
||||
await git('config', 'user.name', 'Audit')
|
||||
await git('config', 'user.email', 'audit@example.test')
|
||||
await put('.gitignore', 'node_modules/\n')
|
||||
await put('app.ts', 'v0\n')
|
||||
await git('add', '.')
|
||||
await git('-c', 'commit.gpgsign=false', 'commit', '-qm', 'base')
|
||||
store = await openTestAgentSessionRecordStore(directory)
|
||||
let acquires = 0
|
||||
const adapter = startsWhenPublished(
|
||||
{
|
||||
supportsCreate: (_location, agent) => agent === 'codex',
|
||||
supportsLocation: () => true,
|
||||
acquire: async (input) => {
|
||||
acquires += 1
|
||||
return {
|
||||
process: {
|
||||
hostId: 'local',
|
||||
pid: 4000 + acquires,
|
||||
processStartTimeMs: HOST_TEST_NOW,
|
||||
spawnToken: input.spawnToken
|
||||
},
|
||||
acquisitionGeneration: `generation-${acquires}`,
|
||||
link: {
|
||||
linkId: `link-${acquires}`,
|
||||
mintedAtFence: input.fence,
|
||||
observedAt: HOST_TEST_NOW,
|
||||
origin: acquires === 1 ? 'created' : 'resumed',
|
||||
handle: codexProviderHandle(HOST_TEST_THREAD)
|
||||
}
|
||||
}
|
||||
},
|
||||
dispatch: vi.fn(async (input): Promise<AgentSessionDispatchOutcome> => {
|
||||
await agentEdits.shift()?.()
|
||||
return {
|
||||
state: 'accepted',
|
||||
providerIdentity: {
|
||||
provider: 'codex',
|
||||
threadId: HOST_TEST_THREAD,
|
||||
turnId: input.clientMessageId,
|
||||
ordinal: 0
|
||||
}
|
||||
}
|
||||
}),
|
||||
cancelTurn: async () => ({ cancelled: false }),
|
||||
answerPrompt: async () => {},
|
||||
setOption: async () => {},
|
||||
rewindSupport: () => ({ supported: true }),
|
||||
releaseAcquisition: async () => true,
|
||||
closeSession: async () => true
|
||||
},
|
||||
() => host
|
||||
)
|
||||
host = new StructuredAgentSessionHost({
|
||||
agents: claudeAndCodexDeclared(),
|
||||
logger: createStructuredAgentSessionLogger(),
|
||||
store,
|
||||
adapter,
|
||||
journalDatabase: openTestJournalHostDatabase(directory),
|
||||
claimKeyId: 'key',
|
||||
resolveWorkspacePath: async () => worktree,
|
||||
now: () => HOST_TEST_NOW,
|
||||
probeOwner: async () => ({ outcome: 'exit-observed' })
|
||||
})
|
||||
})
|
||||
afterEach(async () => {
|
||||
await host.flushAllStreamedEvents()
|
||||
await closeTestJournalHostDatabases()
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
await rm(worktree, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
async function send(text: string, edit: () => Promise<void>): Promise<string> {
|
||||
agentEdits.push(edit)
|
||||
const clientOperationId = hostTestOperationId()
|
||||
const body = hostTestMessage(text)
|
||||
expect(
|
||||
await host.send(caller, {
|
||||
body,
|
||||
envelope: {
|
||||
sessionId: HOST_TEST_SESSION,
|
||||
clientOperationId,
|
||||
expectedRuntimeFence: store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence,
|
||||
payloadFingerprint: computeAgentSessionPayloadFingerprint({
|
||||
method: 'agentSession.send',
|
||||
sessionId: HOST_TEST_SESSION,
|
||||
fields: { body }
|
||||
})
|
||||
}
|
||||
})
|
||||
).toMatchObject({ ok: true })
|
||||
await vi.waitFor(async () =>
|
||||
expect(
|
||||
(await host.journalSnapshot(HOST_TEST_SESSION)).submissions.find(
|
||||
(entry) => entry.clientMessageId === clientOperationId
|
||||
)?.dispatchState
|
||||
).toBe('accepted')
|
||||
)
|
||||
return agentJournalSubmissionKey(clientOperationId)
|
||||
}
|
||||
|
||||
describe('rewind with file restore', () => {
|
||||
it('snapshots before each turn and puts files back to the rewound message', async () => {
|
||||
const attached = await host.attach(caller, hostTestAttachParams(null))
|
||||
expect(attached, JSON.stringify(attached)).toMatchObject({ ok: true })
|
||||
const first = await send('add a feature', async () => {
|
||||
await put('app.ts', 'v1\n')
|
||||
await put('feature.ts', 'feature\n')
|
||||
})
|
||||
const second = await send('now refactor', async () => {
|
||||
await put('app.ts', 'v2\n')
|
||||
await rm(join(worktree, 'feature.ts'))
|
||||
await put('refactor/util.ts', 'util\n')
|
||||
await put('node_modules/dep/index.js', 'ignored\n')
|
||||
})
|
||||
const refs = await git('for-each-ref', '--format=%(refname)', 'refs/orca/checkpoints/')
|
||||
expect(refs.split('\n')).toHaveLength(2)
|
||||
expect(await git('stash', 'list')).toBe('')
|
||||
|
||||
const preview = await hostAgentSessionCheckpoints(host).preview(HOST_TEST_SESSION, second)
|
||||
expect(preview).toMatchObject({ available: true, changedFiles: 3, headMoved: false })
|
||||
const restored = await hostAgentSessionCheckpoints(host).restore(HOST_TEST_SESSION, second)
|
||||
expect(restored.changedFiles).toBe(3)
|
||||
expect(await read('app.ts')).toBe('v1\n')
|
||||
expect(await read('feature.ts')).toBe('feature\n')
|
||||
expect(existsSync(join(worktree, 'refactor'))).toBe(false)
|
||||
expect(await read('node_modules/dep/index.js')).toBe('ignored\n')
|
||||
|
||||
await hostAgentSessionCheckpoints(host).undo(HOST_TEST_SESSION, restored.undoId)
|
||||
expect(await read('app.ts')).toBe('v2\n')
|
||||
expect(existsSync(join(worktree, 'feature.ts'))).toBe(false)
|
||||
expect(await read('refactor/util.ts')).toBe('util\n')
|
||||
|
||||
await hostAgentSessionCheckpoints(host).restore(HOST_TEST_SESSION, first)
|
||||
expect(await read('app.ts')).toBe('v0\n')
|
||||
expect(existsSync(join(worktree, 'feature.ts'))).toBe(false)
|
||||
})
|
||||
|
||||
it('has no checkpoint for unknown messages and refuses a bad undo id', async () => {
|
||||
const attached = await host.attach(caller, hostTestAttachParams(null))
|
||||
expect(attached, JSON.stringify(attached)).toMatchObject({ ok: true })
|
||||
expect(await hostAgentSessionCheckpoints(host).preview(HOST_TEST_SESSION, 'orca:nope')).toEqual(
|
||||
{
|
||||
available: false,
|
||||
reason: 'none'
|
||||
}
|
||||
)
|
||||
await expect(
|
||||
hostAgentSessionCheckpoints(host).restore(HOST_TEST_SESSION, 'orca:nope')
|
||||
).rejects.toThrow('agent_session_checkpoint_none')
|
||||
await expect(
|
||||
hostAgentSessionCheckpoints(host).undo(HOST_TEST_SESSION, '../../heads/main')
|
||||
).rejects.toThrow('agent_session_checkpoint_none')
|
||||
})
|
||||
})
|
||||
|
||||
describe('restore admission', () => {
|
||||
it('refuses while a turn is between its snapshot and its hand-off, or the workspace is busy', async () => {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the checkpoints read only location and launchDirectory.
|
||||
const record = {
|
||||
sessionId: 's',
|
||||
location: { workspaceId: 'w', wslDistro: null },
|
||||
launchDirectory: worktree
|
||||
} as unknown as AgentSessionRecord
|
||||
const checkpoints = createAgentSessionCheckpoints({
|
||||
record: () => record,
|
||||
isBusy: () => false,
|
||||
logger: createStructuredAgentSessionLogger(),
|
||||
now: () => HOST_TEST_NOW
|
||||
})
|
||||
const release = await checkpoints.captureTurn('s', 'orca:one')
|
||||
await put('app.ts', 'agent\n')
|
||||
await expect(checkpoints.restore('s', 'orca:one')).rejects.toThrow(
|
||||
'agent_session_checkpoint_busy'
|
||||
)
|
||||
expect(await checkpoints.preview('s', 'orca:one')).toMatchObject({ busy: true })
|
||||
release()
|
||||
await expect(
|
||||
checkpoints.restore('s', 'orca:one', async (root) => root.length > 0)
|
||||
).rejects.toThrow('agent_session_checkpoint_busy')
|
||||
expect(await read('app.ts')).toBe('agent\n')
|
||||
await checkpoints.restore('s', 'orca:one', async () => false)
|
||||
expect(await read('app.ts')).toBe('v0\n')
|
||||
})
|
||||
})
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
// Per-turn file checkpoints: what restoring a message's snapshot would change, the restore itself
|
||||
// (which first saves an undo point), and undoing that restore. Same host code locally and on VMs.
|
||||
|
||||
import { realpath } from 'node:fs/promises'
|
||||
import { defineMethod } from '../../../core'
|
||||
import { requireInstalledStructuredHost } from '../creation/structured-agent-session-gate'
|
||||
import type { AgentSessionWorkspaceBusy } from '../../../../../native-chat/agent-session-wire/checkpoints/agent-session-checkpoints'
|
||||
import { hostAgentSessionCheckpoints } from '../../../../../native-chat/agent-session-wire/checkpoints/agent-session-checkpoints-host'
|
||||
import { getStructuredAgentSessionHost } from '../../../../../native-chat/agent-session-wire/host/structured-agent-session-registry'
|
||||
import type { RpcContext } from '../../../core'
|
||||
import {
|
||||
CheckpointItemParams,
|
||||
CheckpointUndoParams
|
||||
} from '../../../../../../shared/rpc-contract/structured-agent-session-checkpoint-params'
|
||||
|
||||
/** Any agent working in the chat's workspace (terminal agents included) may be writing files.
|
||||
* Rows match by workspace id or by worktree path; a truncated listing can't prove the workspace
|
||||
* idle, so it counts as busy. */
|
||||
function terminalAgentsWorking(ctx: RpcContext, sessionId: string): AgentSessionWorkspaceBusy {
|
||||
return async (root) => {
|
||||
const workspaceId =
|
||||
getStructuredAgentSessionHost()?.deps.store.getRecord(sessionId)?.location.workspaceId
|
||||
const ps = await ctx.runtime.getWorktreePs(10_000)
|
||||
if (ps.truncated) {
|
||||
return true
|
||||
}
|
||||
const rootReal = await realpath(root).catch(() => root)
|
||||
for (const row of ps.worktrees) {
|
||||
const sameRow =
|
||||
row.worktreeId === workspaceId ||
|
||||
(await realpath(row.path).catch(() => row.path)) === rootReal
|
||||
if (sameRow && row.agents.some((agent) => agent.state === 'working')) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export const STRUCTURED_AGENT_SESSION_CHECKPOINT_METHODS = [
|
||||
defineMethod({
|
||||
name: 'agentSession.checkpointPreview',
|
||||
permission: 'workspace',
|
||||
params: CheckpointItemParams,
|
||||
handler: async (params, ctx) =>
|
||||
hostAgentSessionCheckpoints(
|
||||
await requireInstalledStructuredHost(ctx, params.sessionId)
|
||||
).preview(params.sessionId, params.itemId, terminalAgentsWorking(ctx, params.sessionId))
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'agentSession.checkpointRestore',
|
||||
permission: 'workspace',
|
||||
params: CheckpointItemParams,
|
||||
handler: async (params, ctx) =>
|
||||
hostAgentSessionCheckpoints(
|
||||
await requireInstalledStructuredHost(ctx, params.sessionId)
|
||||
).restore(params.sessionId, params.itemId, terminalAgentsWorking(ctx, params.sessionId))
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'agentSession.checkpointUndo',
|
||||
permission: 'workspace',
|
||||
params: CheckpointUndoParams,
|
||||
handler: async (params, ctx) =>
|
||||
hostAgentSessionCheckpoints(await requireInstalledStructuredHost(ctx, params.sessionId)).undo(
|
||||
params.sessionId,
|
||||
params.undoId,
|
||||
terminalAgentsWorking(ctx, params.sessionId)
|
||||
)
|
||||
})
|
||||
]
|
||||
@@ -28,6 +28,7 @@ import { STRUCTURED_AGENT_SESSION_PROMPT_RESPONSE_METHODS } from './conversation
|
||||
import { STRUCTURED_AGENT_SESSION_REVEAL_METHODS } from './conversation/structured-agent-session-reveal'
|
||||
import { STRUCTURED_AGENT_SESSION_REGENERATE_NAME_METHODS } from './conversation/structured-agent-session-regenerate-name'
|
||||
import { STRUCTURED_AGENT_SESSION_RENAME_METHODS } from './conversation/structured-agent-session-rename'
|
||||
import { STRUCTURED_AGENT_SESSION_CHECKPOINT_METHODS } from './conversation/structured-agent-session-checkpoint-methods'
|
||||
import { STRUCTURED_AGENT_SESSION_QUEUED_METHODS } from './conversation/structured-agent-session-queued-methods'
|
||||
import { STRUCTURED_AGENT_SESSION_RESTART_RESUME_METHODS } from './conversation/structured-agent-session-restart-resume'
|
||||
import { STRUCTURED_AGENT_SESSION_STATUS_METHODS } from './subscriptions/structured-agent-session-status-stream'
|
||||
@@ -212,6 +213,7 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [
|
||||
...STRUCTURED_AGENT_SESSION_REVEAL_METHODS,
|
||||
...STRUCTURED_AGENT_SESSION_REGENERATE_NAME_METHODS,
|
||||
...STRUCTURED_AGENT_SESSION_RENAME_METHODS,
|
||||
...STRUCTURED_AGENT_SESSION_CHECKPOINT_METHODS,
|
||||
...STRUCTURED_AGENT_SESSION_RESTART_RESUME_METHODS,
|
||||
...STRUCTURED_AGENT_SESSION_USAGE_LIMIT_CONTINUE_METHODS,
|
||||
...STRUCTURED_AGENT_SESSION_STATUS_METHODS,
|
||||
|
||||
@@ -23,13 +23,24 @@ export type ConfirmationDialogOptions = {
|
||||
cancelVariant?: 'outline' | 'ghost'
|
||||
/** A notice, not a question: only the confirm button shows, and it settles true. */
|
||||
acknowledgeOnly?: boolean
|
||||
initialFocus?: 'confirm'
|
||||
initialFocus?: 'confirm' | 'cancel'
|
||||
/** The short close-prompt look: small title, text and buttons. */
|
||||
compact?: boolean
|
||||
/** Ignore repeated Enter and the opening double-click’s second click; fresh presses and Escape still settle the prompt. */
|
||||
ignoresOpeningGesture?: boolean
|
||||
/** Renders a "Don't ask again" checkbox. `onConfirmed` runs only when the user confirms with it checked. */
|
||||
dontAskAgain?: { label?: string; onConfirmed: () => void }
|
||||
/** Renders a checkbox for an optional part of the action, with an optional line under it.
|
||||
* `onConfirmed` gets the box's state, only on confirm. */
|
||||
option?: {
|
||||
label: string
|
||||
/** Describes the outcome while checked (or always, without `uncheckedDetail`). */
|
||||
detail?: string
|
||||
uncheckedDetail?: string
|
||||
defaultChecked: boolean
|
||||
disabled?: boolean
|
||||
onConfirmed: (checked: boolean) => void
|
||||
}
|
||||
/** Runs when the cancel button settles the prompt, unlike Esc, an outside press or an abort. */
|
||||
onCancelClicked?: () => void
|
||||
/** Renders text inputs; Enter in one confirms. `onConfirmed` gets their values, only on confirm. */
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import React from 'react'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import type { ConfirmationDialogOptions } from '@/components/confirmation-dialog-context'
|
||||
|
||||
/** A confirmation's optional part: sits in the body, right under the description, because it
|
||||
* changes what the confirm button does. The line under it says what the current state means. */
|
||||
export function ConfirmationDialogOption({
|
||||
option,
|
||||
checked,
|
||||
onCheckedChange
|
||||
}: {
|
||||
option: NonNullable<ConfirmationDialogOptions['option']>
|
||||
checked: boolean
|
||||
onCheckedChange: (checked: boolean) => void
|
||||
}): React.JSX.Element {
|
||||
const detail =
|
||||
checked || option.uncheckedDetail === undefined ? option.detail : option.uncheckedDetail
|
||||
return (
|
||||
<div
|
||||
className="group flex items-start gap-2 data-[disabled=true]:cursor-not-allowed"
|
||||
data-disabled={option.disabled === true}
|
||||
>
|
||||
<Checkbox
|
||||
id="confirmation-dialog-option"
|
||||
className="mt-0.5"
|
||||
checked={checked}
|
||||
disabled={option.disabled}
|
||||
onCheckedChange={(value) => onCheckedChange(value === true)}
|
||||
aria-describedby={detail ? 'confirmation-dialog-option-detail' : undefined}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="confirmation-dialog-option">{option.label}</Label>
|
||||
{detail ? (
|
||||
<p
|
||||
id="confirmation-dialog-option-detail"
|
||||
className="text-xs text-muted-foreground"
|
||||
aria-live="polite"
|
||||
>
|
||||
{detail}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -103,6 +103,37 @@ describe('ConfirmationDialogProvider', () => {
|
||||
await waitFor(() => expect(onSettled).toHaveBeenCalledWith(true))
|
||||
})
|
||||
|
||||
it('starts an option at its default and hands its state over only on confirm', async () => {
|
||||
const onConfirmed = vi.fn()
|
||||
const { onSettled } = renderDialog({
|
||||
title: 'Rewind to here?',
|
||||
confirmLabel: 'Rewind',
|
||||
option: {
|
||||
label: 'Also restore 3 changed files',
|
||||
detail: 'Later changes are discarded.',
|
||||
uncheckedDetail: 'Files on disk stay as they are.',
|
||||
defaultChecked: true,
|
||||
onConfirmed
|
||||
},
|
||||
initialFocus: 'cancel'
|
||||
})
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'ask' }))
|
||||
const box = await screen.findByRole('checkbox', { name: 'Also restore 3 changed files' })
|
||||
await waitFor(() =>
|
||||
expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Cancel' }))
|
||||
)
|
||||
expect(box.getAttribute('aria-checked')).toBe('true')
|
||||
expect(box.getAttribute('aria-describedby')).toBe('confirmation-dialog-option-detail')
|
||||
expect(screen.getByText('Later changes are discarded.')).toBeTruthy()
|
||||
await userEvent.click(box)
|
||||
expect(screen.getByText('Files on disk stay as they are.')).toBeTruthy()
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Rewind' }))
|
||||
|
||||
expect(onConfirmed).toHaveBeenCalledWith(false)
|
||||
await waitFor(() => expect(onSettled).toHaveBeenCalledWith(true))
|
||||
})
|
||||
|
||||
it('never saves the preference when the user backs out', async () => {
|
||||
const onConfirmed = vi.fn()
|
||||
const { onSettled } = renderDialog({
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import { useAppStore } from '@/store'
|
||||
import { useTranslate } from '@/i18n/use-translate'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ConfirmationDialogOption } from '@/components/confirmation-dialog-option'
|
||||
|
||||
type ConfirmationDialogRequest = {
|
||||
id: number
|
||||
@@ -35,8 +36,11 @@ export function ConfirmationDialogProvider({
|
||||
const t = useTranslate()
|
||||
const nextIdRef = useRef(0)
|
||||
const confirmButtonRef = useRef<HTMLButtonElement>(null)
|
||||
const cancelButtonRef = useRef<HTMLButtonElement>(null)
|
||||
const [queue, setQueue] = useState<ConfirmationDialogRequest[]>([])
|
||||
const [dontAskAgain, setDontAskAgain] = useState(false)
|
||||
// null until the user touches the option, so each prompt starts from its own default.
|
||||
const [optionChecked, setOptionChecked] = useState<boolean | null>(null)
|
||||
const [fieldValues, setFieldValues] = useState<Record<string, string>>({})
|
||||
const activeRequest = queue[0] ?? null
|
||||
const activeRequestRef = useRef<ConfirmationDialogRequest | null>(activeRequest)
|
||||
@@ -109,6 +113,10 @@ export function ConfirmationDialogProvider({
|
||||
if (confirmed && dontAskAgain) {
|
||||
request.options.dontAskAgain?.onConfirmed()
|
||||
}
|
||||
const option = request.options.option
|
||||
if (confirmed && option) {
|
||||
option.onConfirmed(optionChecked ?? option.defaultChecked)
|
||||
}
|
||||
const fields = request.options.fields
|
||||
if (confirmed && fields) {
|
||||
const defaults = Object.fromEntries(
|
||||
@@ -118,6 +126,7 @@ export function ConfirmationDialogProvider({
|
||||
}
|
||||
// Why: queued prompts must not inherit this request's preference or typed values.
|
||||
setDontAskAgain(false)
|
||||
setOptionChecked(null)
|
||||
setFieldValues({})
|
||||
request.resolve(confirmed)
|
||||
setQueue((currentQueue) => {
|
||||
@@ -127,7 +136,7 @@ export function ConfirmationDialogProvider({
|
||||
return currentQueue.filter((queuedRequest) => queuedRequest.id !== request.id)
|
||||
})
|
||||
},
|
||||
[dontAskAgain, fieldValues]
|
||||
[dontAskAgain, fieldValues, optionChecked]
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -151,6 +160,9 @@ export function ConfirmationDialogProvider({
|
||||
if (activeRequest?.options.initialFocus === 'confirm') {
|
||||
event.preventDefault()
|
||||
confirmButtonRef.current?.focus()
|
||||
} else if (activeRequest?.options.initialFocus === 'cancel') {
|
||||
event.preventDefault()
|
||||
cancelButtonRef.current?.focus()
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -178,6 +190,13 @@ export function ConfirmationDialogProvider({
|
||||
) : null}
|
||||
</DialogHeader>
|
||||
</div>
|
||||
{displayedRequest?.options.option ? (
|
||||
<ConfirmationDialogOption
|
||||
option={displayedRequest.options.option}
|
||||
checked={optionChecked ?? displayedRequest.options.option.defaultChecked}
|
||||
onCheckedChange={setOptionChecked}
|
||||
/>
|
||||
) : null}
|
||||
{displayedRequest?.options.fields ? (
|
||||
<div className="space-y-3">
|
||||
{displayedRequest.options.fields.inputs.map((field) => (
|
||||
@@ -229,6 +248,7 @@ export function ConfirmationDialogProvider({
|
||||
{displayedRequest?.options.acknowledgeOnly ? null : (
|
||||
<Button
|
||||
type="button"
|
||||
ref={cancelButtonRef}
|
||||
variant={displayedRequest?.options.cancelVariant ?? 'outline'}
|
||||
size={buttonSize}
|
||||
onKeyDown={ignoreRepeatedKey}
|
||||
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
import { useEffect } from 'react'
|
||||
import { defineFixture } from '../../../../component-fixtures/component-fixture'
|
||||
import { ConfirmationDialogProvider } from '@/components/confirmation-dialog'
|
||||
import { useConfirmationDialog } from '@/components/confirmation-dialog-context'
|
||||
import type { AgentSessionCheckpointPreview } from '../../../../../../shared/agent-session/lifecycle/agent-session-checkpoint'
|
||||
import { nativeChatRewindPrompt } from './native-chat-rewind-files'
|
||||
|
||||
function OpenRewindPrompt({ preview }: { preview: AgentSessionCheckpointPreview | null }) {
|
||||
const confirm = useConfirmationDialog()
|
||||
useEffect(() => {
|
||||
void confirm({
|
||||
title: 'Rewind to here?',
|
||||
...nativeChatRewindPrompt(preview, () => {}),
|
||||
confirmLabel: 'Rewind',
|
||||
cancelLabel: 'Cancel',
|
||||
confirmVariant: 'destructive',
|
||||
cancelVariant: 'ghost',
|
||||
initialFocus: 'cancel'
|
||||
})
|
||||
}, [confirm, preview])
|
||||
return null
|
||||
}
|
||||
|
||||
function prompt(preview: AgentSessionCheckpointPreview | null) {
|
||||
return defineFixture({
|
||||
width: 560,
|
||||
render: () => (
|
||||
<div className="h-[340px]">
|
||||
<ConfirmationDialogProvider>
|
||||
<OpenRewindPrompt preview={preview} />
|
||||
</ConfirmationDialogProvider>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/** A: a snapshot exists and files differ. */
|
||||
export const restoreFiles = prompt({
|
||||
available: true,
|
||||
changedFiles: 3,
|
||||
headMoved: true,
|
||||
createdAt: 0
|
||||
})
|
||||
/** A, one file: the singular label. */
|
||||
export const restoreOneFile = prompt({
|
||||
available: true,
|
||||
changedFiles: 1,
|
||||
headMoved: false,
|
||||
createdAt: 0
|
||||
})
|
||||
/** A while an agent works in the workspace: the choice is off and says why. */
|
||||
export const restoreBusy = prompt({
|
||||
available: true,
|
||||
changedFiles: 3,
|
||||
headMoved: false,
|
||||
createdAt: 0,
|
||||
busy: true
|
||||
})
|
||||
/** B: files already match the message. */
|
||||
export const filesMatch = prompt({
|
||||
available: true,
|
||||
changedFiles: 0,
|
||||
headMoved: false,
|
||||
createdAt: 0
|
||||
})
|
||||
/** C: no snapshot (older message, older host, or not git). */
|
||||
export const noSnapshot = prompt({ available: false, reason: 'none' })
|
||||
@@ -0,0 +1,201 @@
|
||||
import { toast } from 'sonner'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { ConfirmationDialogOptions } from '@/components/confirmation-dialog-context'
|
||||
import {
|
||||
agentSessionCheckpointPartialUndoIdOf,
|
||||
agentSessionCheckpointRefusalOf,
|
||||
type AgentSessionCheckpointPreview,
|
||||
type AgentSessionCheckpointRestoreResult
|
||||
} from '../../../../../../shared/agent-session/lifecycle/agent-session-checkpoint'
|
||||
|
||||
/** The host's per-turn file checkpoints for one chat. */
|
||||
export type NativeChatCheckpointClient = {
|
||||
preview: (itemId: string) => Promise<AgentSessionCheckpointPreview>
|
||||
restore: (itemId: string) => Promise<AgentSessionCheckpointRestoreResult>
|
||||
undo: (undoId: string) => Promise<AgentSessionCheckpointRestoreResult>
|
||||
}
|
||||
|
||||
/** Past this the prompt opens without a files choice rather than keep the user waiting. */
|
||||
export const NATIVE_CHAT_CHECKPOINT_PREVIEW_TIMEOUT_MS = 4_000
|
||||
|
||||
/** What the files would do on rewind; null when the host can't say in time (or is too old). */
|
||||
export async function previewNativeChatRewindFiles(
|
||||
client: NativeChatCheckpointClient | undefined,
|
||||
itemId: string
|
||||
): Promise<AgentSessionCheckpointPreview | null> {
|
||||
if (!client) {
|
||||
return null
|
||||
}
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
try {
|
||||
return await Promise.race([
|
||||
client.preview(itemId),
|
||||
new Promise<null>((resolve) => {
|
||||
timer = setTimeout(() => resolve(null), NATIVE_CHAT_CHECKPOINT_PREVIEW_TIMEOUT_MS)
|
||||
})
|
||||
])
|
||||
} catch {
|
||||
return null
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
const BASE = () =>
|
||||
translate(
|
||||
'components.native-chat.rewind.removedDescription',
|
||||
'This message and everything after it are removed from the chat. The message goes back to the composer so you can edit and resend it.'
|
||||
)
|
||||
|
||||
function filesLabel(count: number): string {
|
||||
return translate(
|
||||
'components.native-chat.rewind.restoreChangedFiles',
|
||||
'Also restore {{count}} changed files',
|
||||
{ count, defaultValue_one: 'Also restore {{count}} changed file' }
|
||||
)
|
||||
}
|
||||
|
||||
function checkedDetail(headMoved: boolean): string {
|
||||
const detail = translate(
|
||||
'components.native-chat.rewind.restoreFilesOutcome',
|
||||
'Files go back to how they were when this message was sent. Later changes are discarded. You can undo the file restore afterwards.'
|
||||
)
|
||||
return headMoved
|
||||
? `${detail} ${translate(
|
||||
'components.native-chat.rewind.restoreFilesCommitsStay',
|
||||
'Commits made since then stay on the branch.'
|
||||
)}`
|
||||
: detail
|
||||
}
|
||||
|
||||
/** The prompt's wording and files choice; `choose` hears whether to restore files, on confirm. */
|
||||
export function nativeChatRewindPrompt(
|
||||
preview: AgentSessionCheckpointPreview | null,
|
||||
choose: (restoreFiles: boolean) => void
|
||||
): Pick<ConfirmationDialogOptions, 'description' | 'option'> {
|
||||
if (preview?.available && preview.changedFiles > 0) {
|
||||
const busy = preview.busy === true
|
||||
return {
|
||||
description: BASE(),
|
||||
option: {
|
||||
label: filesLabel(preview.changedFiles),
|
||||
detail: busy
|
||||
? translate(
|
||||
'components.native-chat.rewind.restoreFilesBusy',
|
||||
"Files can't be restored while the agent is working."
|
||||
)
|
||||
: checkedDetail(preview.headMoved),
|
||||
uncheckedDetail: busy
|
||||
? undefined
|
||||
: translate(
|
||||
'components.native-chat.rewind.keepFilesOutcome',
|
||||
'Files on disk stay as they are.'
|
||||
),
|
||||
defaultChecked: !busy,
|
||||
disabled: busy,
|
||||
onConfirmed: (checked) => choose(checked && !busy)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (preview?.available) {
|
||||
return {
|
||||
description: `${BASE()} ${translate(
|
||||
'components.native-chat.rewind.filesAlreadyMatch',
|
||||
'Files on disk already match this message.'
|
||||
)}`
|
||||
}
|
||||
}
|
||||
return {
|
||||
description: translate(
|
||||
'components.native-chat.rewind.confirmation',
|
||||
'Discard this message and everything after it? The message returns to the composer so you can edit and resend it. File changes on disk are kept.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function restoredText(count: number): string {
|
||||
return translate('components.native-chat.rewind.restoredFiles', 'Restored {{count}} files', {
|
||||
count,
|
||||
defaultValue_one: 'Restored {{count}} file'
|
||||
})
|
||||
}
|
||||
|
||||
function failureText(error: unknown): string {
|
||||
switch (agentSessionCheckpointRefusalOf(error)) {
|
||||
case 'busy':
|
||||
return translate(
|
||||
'components.native-chat.rewind.chatRewoundBusy',
|
||||
"The chat was rewound, but files weren't restored because the agent is still working. Nothing on disk changed."
|
||||
)
|
||||
case 'merge-conflicts':
|
||||
return translate(
|
||||
'components.native-chat.rewind.chatRewoundConflicts',
|
||||
"The chat was rewound, but files weren't restored because this workspace has unresolved merge conflicts. Nothing on disk changed."
|
||||
)
|
||||
case 'blocked-path':
|
||||
return translate(
|
||||
'components.native-chat.rewind.chatRewoundBlocked',
|
||||
"The chat was rewound, but files weren't restored because a folder now sits where a restored file goes. Nothing on disk changed."
|
||||
)
|
||||
case 'none':
|
||||
return translate(
|
||||
'components.native-chat.rewind.chatRewoundMissing',
|
||||
"The chat was rewound, but files weren't restored because Orca no longer has the files saved for this message. Nothing on disk changed."
|
||||
)
|
||||
case 'partial':
|
||||
case 'not-a-repo':
|
||||
case 'unsafe-path':
|
||||
case null:
|
||||
return translate(
|
||||
'components.native-chat.rewind.chatRewoundFailed',
|
||||
"The chat was rewound, but some files couldn't be restored. Check Source Control before you continue."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Restores after the chat rewound, then offers Undo for the files. */
|
||||
export async function restoreNativeChatRewindFiles(
|
||||
client: NativeChatCheckpointClient,
|
||||
itemId: string
|
||||
): Promise<void> {
|
||||
let result: AgentSessionCheckpointRestoreResult
|
||||
try {
|
||||
result = await client.restore(itemId)
|
||||
} catch (error) {
|
||||
console.warn('[native-chat-rewind] file restore failed', error)
|
||||
const undoId = agentSessionCheckpointPartialUndoIdOf(error)
|
||||
toast.error(
|
||||
failureText(error),
|
||||
undoId ? { action: undoAction(client, undoId), duration: Infinity } : undefined
|
||||
)
|
||||
return
|
||||
}
|
||||
toast.success(restoredText(result.changedFiles), {
|
||||
action: undoAction(client, result.undoId)
|
||||
})
|
||||
}
|
||||
|
||||
function undoAction(client: NativeChatCheckpointClient, undoId: string) {
|
||||
return {
|
||||
label: translate('components.native-chat.rewind.undoRestore', 'Undo'),
|
||||
onClick: () => void undoNativeChatRewindFiles(client, undoId)
|
||||
}
|
||||
}
|
||||
|
||||
async function undoNativeChatRewindFiles(
|
||||
client: NativeChatCheckpointClient,
|
||||
undoId: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
await client.undo(undoId)
|
||||
toast.success(
|
||||
translate(
|
||||
'components.native-chat.rewind.undoneRestore',
|
||||
'Files are back to how they were before the rewind.'
|
||||
)
|
||||
)
|
||||
} catch (error) {
|
||||
console.warn('[native-chat-rewind] file restore undo failed', error)
|
||||
toast.error(failureText(error))
|
||||
}
|
||||
}
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { act, cleanup, renderHook } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const toast = vi.hoisted(() => ({ error: vi.fn(), message: vi.fn(), success: vi.fn() }))
|
||||
vi.mock('sonner', () => ({ toast }))
|
||||
|
||||
import { EMPTY_STRUCTURED_AGENT_SESSION } from '../../../../../../shared/agent-session/structured-agent-session-reducer'
|
||||
import type { AgentJournalRenderItem } from '../../../../../../shared/agent-session/journal/agent-session-journal-types'
|
||||
import type { ConfirmationDialogOptions } from '@/components/confirmation-dialog-context'
|
||||
import { useNativeChatRewind } from './use-native-chat-rewind'
|
||||
import type { NativeChatCheckpointClient } from './native-chat-rewind-files'
|
||||
import type { AgentSessionCheckpointPreview } from '../../../../../../shared/agent-session/lifecycle/agent-session-checkpoint'
|
||||
|
||||
type Input = Parameters<typeof useNativeChatRewind>[0]
|
||||
|
||||
afterEach(cleanup)
|
||||
beforeEach(() => {
|
||||
toast.error.mockReset()
|
||||
toast.success.mockReset()
|
||||
})
|
||||
|
||||
const item = (itemId: string, sequence: number): AgentJournalRenderItem => ({
|
||||
itemId,
|
||||
sequence,
|
||||
revision: 1,
|
||||
observedAt: sequence,
|
||||
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: itemId }] }
|
||||
})
|
||||
const done = { kind: 'done' as const, value: { itemId: 'user', epoch: 'new' } }
|
||||
|
||||
function client(preview: AgentSessionCheckpointPreview) {
|
||||
const restored = { changedFiles: 3, headMoved: false, undoId: '1-abcdef01' }
|
||||
return {
|
||||
preview: vi.fn<NativeChatCheckpointClient['preview']>().mockResolvedValue(preview),
|
||||
restore: vi.fn<NativeChatCheckpointClient['restore']>().mockResolvedValue(restored),
|
||||
undo: vi.fn<NativeChatCheckpointClient['undo']>().mockResolvedValue(restored)
|
||||
}
|
||||
}
|
||||
|
||||
function input(checkpoints?: NativeChatCheckpointClient): Input & {
|
||||
send: ReturnType<typeof vi.fn>
|
||||
} {
|
||||
return {
|
||||
sessionId: 'session',
|
||||
capturePresentation: () => () => true,
|
||||
state: {
|
||||
...EMPTY_STRUCTURED_AGENT_SESSION,
|
||||
epoch: 'old',
|
||||
fence: 1,
|
||||
status: 'ready' as const,
|
||||
cursor: { epoch: 'old', sequence: 11 },
|
||||
items: [item('user', 10), item('later', 11)]
|
||||
},
|
||||
support: { supported: true as const },
|
||||
blocked: false,
|
||||
send: vi.fn().mockResolvedValue(done),
|
||||
...(checkpoints ? { checkpoints } : {})
|
||||
}
|
||||
}
|
||||
|
||||
/** Confirms the prompt, ticking or unticking its files option, and records what it showed. */
|
||||
function answer(restoreFiles: boolean | 'default', shown: ConfirmationDialogOptions[]) {
|
||||
return vi.fn(async (options: ConfirmationDialogOptions) => {
|
||||
shown.push(options)
|
||||
options.option?.onConfirmed(
|
||||
restoreFiles === 'default' ? options.option.defaultChecked : restoreFiles
|
||||
)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
describe('rewind with file restore', () => {
|
||||
it('offers restoring files, on by default, with the count, and restores after the chat rewound', async () => {
|
||||
const checkpoints = client({
|
||||
available: true,
|
||||
changedFiles: 3,
|
||||
headMoved: true,
|
||||
createdAt: 1
|
||||
})
|
||||
const props = input(checkpoints)
|
||||
const view = renderHook(() => useNativeChatRewind(props))
|
||||
const shown: ConfirmationDialogOptions[] = []
|
||||
await act(() => view.result.current.request('user', answer('default', shown)))
|
||||
expect(checkpoints.preview).toHaveBeenCalledWith('user')
|
||||
expect(shown[0]?.option).toMatchObject({
|
||||
label: 'Also restore 3 changed files',
|
||||
defaultChecked: true
|
||||
})
|
||||
expect(shown[0]?.option?.detail).toContain('Later changes are discarded')
|
||||
expect(shown[0]?.option?.uncheckedDetail).toBe('Files on disk stay as they are.')
|
||||
expect(shown[0]?.option?.detail).toContain('Commits made since then stay on the branch')
|
||||
expect(shown[0]?.description).not.toContain('File changes on disk are kept')
|
||||
await vi.waitFor(() => expect(checkpoints.restore).toHaveBeenCalledWith('user'))
|
||||
expect(props.send.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
checkpoints.restore.mock.invocationCallOrder[0]!
|
||||
)
|
||||
await vi.waitFor(() => expect(toast.success).toHaveBeenCalled())
|
||||
expect(toast.success.mock.calls[0]?.[0]).toBe('Restored 3 files')
|
||||
const undo = toast.success.mock.calls[0]?.[1]?.action
|
||||
expect(undo.label).toBe('Undo')
|
||||
undo.onClick()
|
||||
await vi.waitFor(() => expect(checkpoints.undo).toHaveBeenCalledWith('1-abcdef01'))
|
||||
})
|
||||
|
||||
it('disables restoring while an agent works in the workspace, and never restores then', async () => {
|
||||
const checkpoints = client({
|
||||
available: true,
|
||||
changedFiles: 2,
|
||||
headMoved: false,
|
||||
createdAt: 1,
|
||||
busy: true
|
||||
})
|
||||
const shown: ConfirmationDialogOptions[] = []
|
||||
const view = renderHook(() => useNativeChatRewind(input(checkpoints)))
|
||||
await act(() => view.result.current.request('user', answer(true, shown)))
|
||||
expect(shown[0]?.option).toMatchObject({
|
||||
disabled: true,
|
||||
defaultChecked: false,
|
||||
detail: "Files can't be restored while the agent is working."
|
||||
})
|
||||
await act(async () => {})
|
||||
expect(checkpoints.restore).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps files when the box is unticked', async () => {
|
||||
const checkpoints = client({ available: true, changedFiles: 2, headMoved: false, createdAt: 1 })
|
||||
const view = renderHook(() => useNativeChatRewind(input(checkpoints)))
|
||||
await act(() => view.result.current.request('user', answer(false, [])))
|
||||
await act(async () => {})
|
||||
expect(checkpoints.restore).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('never restores files when the chat rewind did not go through', async () => {
|
||||
const checkpoints = client({ available: true, changedFiles: 2, headMoved: false, createdAt: 1 })
|
||||
const props = input(checkpoints)
|
||||
props.send.mockResolvedValue({
|
||||
kind: 'not-done',
|
||||
notice: '',
|
||||
failure: { kind: 'refused', code: 'x', details: { rewindReason: 'busy' } }
|
||||
})
|
||||
const view = renderHook(() => useNativeChatRewind(props))
|
||||
await act(() => view.result.current.request('user', answer(true, [])))
|
||||
await act(async () => {})
|
||||
expect(checkpoints.restore).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the old wording when there is no snapshot or no host support', async () => {
|
||||
for (const checkpoints of [client({ available: false, reason: 'none' }), undefined]) {
|
||||
const shown: ConfirmationDialogOptions[] = []
|
||||
const view = renderHook(() => useNativeChatRewind(input(checkpoints)))
|
||||
await act(() => view.result.current.request('user', answer(true, shown)))
|
||||
expect(shown[0]?.option).toBeUndefined()
|
||||
expect(shown[0]?.description).toContain('File changes on disk are kept')
|
||||
view.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('says files already match when nothing changed since the message', async () => {
|
||||
const shown: ConfirmationDialogOptions[] = []
|
||||
const checkpoints = client({ available: true, changedFiles: 0, headMoved: false, createdAt: 1 })
|
||||
const view = renderHook(() => useNativeChatRewind(input(checkpoints)))
|
||||
await act(() => view.result.current.request('user', answer(true, shown)))
|
||||
expect(shown[0]?.option).toBeUndefined()
|
||||
expect(shown[0]?.description).toContain('Files on disk already match this message.')
|
||||
})
|
||||
|
||||
it('reports a refused restore without claiming files changed', async () => {
|
||||
const checkpoints = client({ available: true, changedFiles: 1, headMoved: false, createdAt: 1 })
|
||||
checkpoints.restore.mockRejectedValue(new Error('agent_session_checkpoint_merge-conflicts'))
|
||||
const view = renderHook(() => useNativeChatRewind(input(checkpoints)))
|
||||
await act(() => view.result.current.request('user', answer(true, [])))
|
||||
await vi.waitFor(() => expect(toast.error).toHaveBeenCalled())
|
||||
expect(toast.error.mock.calls[0]?.[0]).toContain('The chat was rewound, but')
|
||||
expect(toast.error.mock.calls[0]?.[0]).toContain('unresolved merge conflicts')
|
||||
expect(toast.success).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -14,6 +14,12 @@ import type {
|
||||
} from '../../session/structured/writes/use-structured-agent-session-mutate'
|
||||
import { returnMessageToComposer } from '../../session/structured/delivery/structured-agent-session-message-hand-back'
|
||||
import type { NativeChatRewindSurface } from './use-native-chat-rewind-host'
|
||||
import {
|
||||
nativeChatRewindPrompt,
|
||||
previewNativeChatRewindFiles,
|
||||
restoreNativeChatRewindFiles,
|
||||
type NativeChatCheckpointClient
|
||||
} from './native-chat-rewind-files'
|
||||
|
||||
export type RewindInput = Omit<
|
||||
AgentSessionRewindInput,
|
||||
@@ -22,6 +28,8 @@ export type RewindInput = Omit<
|
||||
capturePresentation: CaptureStructuredAgentSessionPresentation
|
||||
/** The composer the discarded message returns to, after whatever is typed there. */
|
||||
composerScopeKey?: string
|
||||
/** The host's per-turn file checkpoints; absent where the host can't restore files. */
|
||||
checkpoints?: NativeChatCheckpointClient
|
||||
send: (fields: {
|
||||
itemId: string
|
||||
expectedEpoch: string
|
||||
@@ -35,11 +43,25 @@ const useAgentSessionRewind = createAgentSessionRewindHook(
|
||||
const notify = { error: toast.error, message: toast.message }
|
||||
|
||||
export function useNativeChatRewind(input: RewindInput) {
|
||||
// The message whose files the user chose to restore, set on confirm and spent by its rewind.
|
||||
const restoreFilesFor = useRef<string | null>(null)
|
||||
const rewind = useAgentSessionRewind(
|
||||
useMemo(() => {
|
||||
const scope = input.composerScopeKey
|
||||
const { checkpoints } = input
|
||||
return {
|
||||
...input,
|
||||
// Files follow the chat only once the chat itself went back; a refused rewind keeps both.
|
||||
send: async (fields: Parameters<RewindInput['send']>[0]) => {
|
||||
const outcome = await input.send(fields)
|
||||
const restore = restoreFilesFor.current === fields.itemId
|
||||
restoreFilesFor.current = null
|
||||
if (restore && checkpoints && outcome.kind === 'done') {
|
||||
// Awaited, so the rewind stays pending (sends wait) until the files are back.
|
||||
await restoreNativeChatRewindFiles(checkpoints, fields.itemId)
|
||||
}
|
||||
return outcome
|
||||
},
|
||||
notify,
|
||||
...(scope
|
||||
? {
|
||||
@@ -55,22 +77,26 @@ export function useNativeChatRewind(input: RewindInput) {
|
||||
}, [input])
|
||||
)
|
||||
const sharedRequest = rewind.request
|
||||
const checkpoints = input.checkpoints
|
||||
const request = useCallback(
|
||||
(itemId: string, confirm: ConfirmationDialogContextValue) =>
|
||||
sharedRequest(itemId, () =>
|
||||
confirm({
|
||||
sharedRequest(itemId, async () => {
|
||||
restoreFilesFor.current = null
|
||||
const preview = await previewNativeChatRewindFiles(checkpoints, itemId)
|
||||
return confirm({
|
||||
title: translate('components.native-chat.rewind.title', 'Rewind to here?'),
|
||||
description: translate(
|
||||
'components.native-chat.rewind.confirmation',
|
||||
'Discard this message and everything after it? The message returns to the composer so you can edit and resend it. File changes on disk are kept.'
|
||||
),
|
||||
...nativeChatRewindPrompt(preview, (restoreFiles) => {
|
||||
restoreFilesFor.current = restoreFiles ? itemId : null
|
||||
}),
|
||||
confirmLabel: translate('components.native-chat.rewind.confirm', 'Rewind'),
|
||||
cancelLabel: translate('components.native-chat.rewind.cancel', 'Cancel'),
|
||||
confirmVariant: 'destructive',
|
||||
cancelVariant: 'ghost'
|
||||
cancelVariant: 'ghost',
|
||||
// Destructive: Enter or Space on open must never rewind or flip the files choice.
|
||||
initialFocus: 'cancel'
|
||||
})
|
||||
),
|
||||
[sharedRequest]
|
||||
}),
|
||||
[checkpoints, sharedRequest]
|
||||
)
|
||||
const shared = rewind.surface
|
||||
const surface = useMemo<NativeChatRewindSurface | undefined>(
|
||||
|
||||
+35
@@ -5,6 +5,15 @@ import { useStructuredAgentSessionHostRecoversRewindOnSend } from '@/runtime/nat
|
||||
import type { StructuredAgentSessionWrite } from '../../session/structured/writes/use-structured-agent-session-mutate'
|
||||
import { useNativeChatRewind, type RewindInput } from './use-native-chat-rewind'
|
||||
import type { NativeChatRewindHost } from './use-native-chat-rewind-host'
|
||||
import { callRuntimeRpc } from '@/runtime/rpc/calls/runtime-rpc-call'
|
||||
import type { NativeChatCheckpointClient } from './native-chat-rewind-files'
|
||||
import type {
|
||||
AgentSessionCheckpointPreview,
|
||||
AgentSessionCheckpointRestoreResult
|
||||
} from '../../../../../../shared/agent-session/lifecycle/agent-session-checkpoint'
|
||||
|
||||
/** A large worktree's restore hashes every file once; don't cut it off mid-write. */
|
||||
const CHECKPOINT_RESTORE_TIMEOUT_MS = 120_000
|
||||
|
||||
/** The rewind a structured session's user rows offer, sent through the session's own writes. */
|
||||
export function useStructuredAgentSessionRewind(
|
||||
@@ -28,8 +37,33 @@ export function useStructuredAgentSessionRewind(
|
||||
const support = useStructuredAgentSessionHostRecoversRewindOnSend(target)
|
||||
? args.support
|
||||
: undefined
|
||||
const checkpoints = useMemo<NativeChatCheckpointClient>(
|
||||
() => ({
|
||||
preview: (itemId) =>
|
||||
callRuntimeRpc<AgentSessionCheckpointPreview>(target, 'agentSession.checkpointPreview', {
|
||||
sessionId,
|
||||
itemId
|
||||
}),
|
||||
restore: (itemId) =>
|
||||
callRuntimeRpc<AgentSessionCheckpointRestoreResult>(
|
||||
target,
|
||||
'agentSession.checkpointRestore',
|
||||
{ sessionId, itemId },
|
||||
{ timeoutMs: CHECKPOINT_RESTORE_TIMEOUT_MS }
|
||||
),
|
||||
undo: (undoId) =>
|
||||
callRuntimeRpc<AgentSessionCheckpointRestoreResult>(
|
||||
target,
|
||||
'agentSession.checkpointUndo',
|
||||
{ sessionId, undoId },
|
||||
{ timeoutMs: CHECKPOINT_RESTORE_TIMEOUT_MS }
|
||||
)
|
||||
}),
|
||||
[sessionId, target]
|
||||
)
|
||||
const input = useMemo<RewindInput>(
|
||||
() => ({
|
||||
checkpoints,
|
||||
sessionId,
|
||||
capturePresentation,
|
||||
composerScopeKey,
|
||||
@@ -45,6 +79,7 @@ export function useStructuredAgentSessionRewind(
|
||||
}),
|
||||
[
|
||||
blocked,
|
||||
checkpoints,
|
||||
capturePresentation,
|
||||
composerScopeKey,
|
||||
contextFloor,
|
||||
|
||||
+6
@@ -2040,6 +2040,12 @@
|
||||
"waitForStart": "Wait for the agent to finish starting.",
|
||||
"waitForTurn": "Wait for the agent to finish responding, or stop it.",
|
||||
"waitMoment": "Wait a moment."
|
||||
},
|
||||
"rewind": {
|
||||
"restoreChangedFiles_one": "Also restore {{count}} changed file",
|
||||
"restoreChangedFiles_other": "Also restore {{count}} changed files",
|
||||
"restoredFiles_one": "Restored {{count}} file",
|
||||
"restoredFiles_other": "Restored {{count}} files"
|
||||
}
|
||||
},
|
||||
"onboarding": {
|
||||
|
||||
@@ -21852,7 +21852,24 @@
|
||||
"confirmation": "Discard this message and everything after it? The message returns to the composer so you can edit and resend it. File changes on disk are kept.",
|
||||
"confirm": "Rewind",
|
||||
"cancel": "Cancel",
|
||||
"action": "Rewind to here"
|
||||
"action": "Rewind to here",
|
||||
"restoreFilesCommitsStay": "Commits made since then stay on the branch.",
|
||||
"filesAlreadyMatch": "Files on disk already match this message.",
|
||||
"undoRestore": "Undo",
|
||||
"undoneRestore": "Files are back to how they were before the rewind.",
|
||||
"removedDescription": "This message and everything after it are removed from the chat. The message goes back to the composer so you can edit and resend it.",
|
||||
"restoreChangedFiles_one": "Also restore {{count}} changed file",
|
||||
"restoreChangedFiles_other": "Also restore {{count}} changed files",
|
||||
"restoreFilesOutcome": "Files go back to how they were when this message was sent. Later changes are discarded. You can undo the file restore afterwards.",
|
||||
"restoreFilesBusy": "Files can't be restored while the agent is working.",
|
||||
"keepFilesOutcome": "Files on disk stay as they are.",
|
||||
"restoredFiles_one": "Restored {{count}} file",
|
||||
"restoredFiles_other": "Restored {{count}} files",
|
||||
"chatRewoundBusy": "The chat was rewound, but files weren't restored because the agent is still working. Nothing on disk changed.",
|
||||
"chatRewoundConflicts": "The chat was rewound, but files weren't restored because this workspace has unresolved merge conflicts. Nothing on disk changed.",
|
||||
"chatRewoundBlocked": "The chat was rewound, but files weren't restored because a folder now sits where a restored file goes. Nothing on disk changed.",
|
||||
"chatRewoundMissing": "The chat was rewound, but files weren't restored because Orca no longer has the files saved for this message. Nothing on disk changed.",
|
||||
"chatRewoundFailed": "The chat was rewound, but some files couldn't be restored. Check Source Control before you continue."
|
||||
},
|
||||
"drop": {
|
||||
"messageTitle": "Drop to attach to this message",
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
// Per-turn file checkpoints: the host snapshots the chat's worktree before each turn starts, and
|
||||
// "Rewind to here" can put the files back. Shared by the host and every client.
|
||||
|
||||
export type AgentSessionCheckpointUnavailableReason =
|
||||
/** No snapshot for this message: sent before checkpoints existed, or pruned. */
|
||||
| 'none'
|
||||
/** The chat's folder is not a git worktree. */
|
||||
| 'not-a-repo'
|
||||
/** The host could not read the worktree (or does not support checkpoints). */
|
||||
| 'unavailable'
|
||||
|
||||
export type AgentSessionCheckpointPreview =
|
||||
| { available: false; reason: AgentSessionCheckpointUnavailableReason }
|
||||
| {
|
||||
available: true
|
||||
/** Files whose content differs between the worktree now and the snapshot. */
|
||||
changedFiles: number
|
||||
/** Commits were made after the snapshot; they stay on the branch. */
|
||||
headMoved: boolean
|
||||
createdAt: number
|
||||
/** An agent is working in this workspace now, so a restore would refuse. */
|
||||
busy?: boolean
|
||||
}
|
||||
|
||||
export type AgentSessionCheckpointRestoreResult = {
|
||||
changedFiles: number
|
||||
headMoved: boolean
|
||||
/** Restores the files as they were right before this restore. */
|
||||
undoId: string
|
||||
}
|
||||
|
||||
/** Why a restore refused before changing any file. */
|
||||
export const AGENT_SESSION_CHECKPOINT_RESTORE_REFUSALS = [
|
||||
'none',
|
||||
'not-a-repo',
|
||||
'busy',
|
||||
'merge-conflicts',
|
||||
'blocked-path',
|
||||
'unsafe-path',
|
||||
/** Not a refusal: some files changed before the restore failed; the error names the undo id. */
|
||||
'partial'
|
||||
] as const
|
||||
export type AgentSessionCheckpointRestoreRefusal =
|
||||
(typeof AGENT_SESSION_CHECKPOINT_RESTORE_REFUSALS)[number]
|
||||
|
||||
const REFUSAL_PREFIX = 'agent_session_checkpoint_'
|
||||
|
||||
export function agentSessionCheckpointRefusalMessage(
|
||||
reason: AgentSessionCheckpointRestoreRefusal
|
||||
): string {
|
||||
return `${REFUSAL_PREFIX}${reason}`
|
||||
}
|
||||
|
||||
/** Reads the refusal out of an RPC error message; null for any other failure. */
|
||||
export function agentSessionCheckpointRefusalOf(
|
||||
error: unknown
|
||||
): AgentSessionCheckpointRestoreRefusal | null {
|
||||
const message = error instanceof Error ? error.message : typeof error === 'string' ? error : ''
|
||||
const match = /agent_session_checkpoint_([a-z-]+)/.exec(message)
|
||||
const reason = match?.[1]
|
||||
return AGENT_SESSION_CHECKPOINT_RESTORE_REFUSALS.find((value) => value === reason) ?? null
|
||||
}
|
||||
|
||||
export function agentSessionCheckpointPartialMessage(undoId: string): string {
|
||||
return `${REFUSAL_PREFIX}partial undo=${undoId}`
|
||||
}
|
||||
|
||||
/** The undo id a partial restore names, so the client can still offer Undo. */
|
||||
export function agentSessionCheckpointPartialUndoIdOf(error: unknown): string | null {
|
||||
const message = error instanceof Error ? error.message : typeof error === 'string' ? error : ''
|
||||
const undoId = /agent_session_checkpoint_partial undo=([0-9]+-[0-9a-f]+)/.exec(message)?.[1]
|
||||
return undoId && AGENT_SESSION_CHECKPOINT_UNDO_ID.test(undoId) ? undoId : null
|
||||
}
|
||||
|
||||
/** Undo ids are the last segment of a host-generated ref name. */
|
||||
export const AGENT_SESSION_CHECKPOINT_UNDO_ID = /^[0-9]{1,16}-[0-9a-f]{8}$/
|
||||
@@ -651,6 +651,10 @@ import {
|
||||
SshTargetUpdate
|
||||
} from './ssh-params'
|
||||
import { StatusGetParams } from './status-params'
|
||||
import {
|
||||
CheckpointItemParams,
|
||||
CheckpointUndoParams
|
||||
} from './structured-agent-session-checkpoint-params'
|
||||
import { ContinueInterruptedParams } from './structured-agent-session-continue-params'
|
||||
import { ContinuationContextParams } from './structured-agent-session-identifiers'
|
||||
import {
|
||||
@@ -848,6 +852,9 @@ export const RPC_PARAMS_BY_METHOD = {
|
||||
'agentSession.agents': AgentsParams,
|
||||
'agentSession.cancel': CancelParams,
|
||||
'agentSession.cancelUsageLimitContinue': CancelUsageLimitContinueParams,
|
||||
'agentSession.checkpointPreview': CheckpointItemParams,
|
||||
'agentSession.checkpointRestore': CheckpointItemParams,
|
||||
'agentSession.checkpointUndo': CheckpointUndoParams,
|
||||
'agentSession.close': OptionsParams,
|
||||
'agentSession.commands': OptionsParams,
|
||||
'agentSession.continuationContext': ContinuationContextParams,
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
// Per-turn file checkpoints for "Rewind to here": preview, restore and undo.
|
||||
|
||||
import { z } from 'zod'
|
||||
import { OptionsParams } from './structured-agent-session-params'
|
||||
import { AGENT_SESSION_CHECKPOINT_UNDO_ID } from '../agent-session/lifecycle/agent-session-checkpoint'
|
||||
|
||||
export const CheckpointItemParams = OptionsParams.extend({
|
||||
itemId: z.string().min(1).max(4096)
|
||||
}).strict()
|
||||
|
||||
export const CheckpointUndoParams = OptionsParams.extend({
|
||||
undoId: z.string().regex(AGENT_SESSION_CHECKPOINT_UNDO_ID)
|
||||
}).strict()
|
||||
@@ -0,0 +1,266 @@
|
||||
// Live: a real Claude Code agent (cheap gateway model) edits a real git worktree through a headless
|
||||
// serve host. Rewind to the second message with "Also restore files" puts the files back to how
|
||||
// they were when it was sent, Undo brings the agent's edits back, and the user's HEAD, index and
|
||||
// stash list are never touched by the per-turn snapshots.
|
||||
// Needs a model: ORCA_E2E_AI_GATEWAY_KEY_FILE names a Vercel AI Gateway key (Anthropic-compatible).
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { expect, test } from './helpers/orca-app'
|
||||
import { launchHeadlessPairedRuntimeHost } from './helpers/headless-paired-runtime-host'
|
||||
import {
|
||||
createStructuredAgentSessionId,
|
||||
structuredAgentSessionCreateParams
|
||||
} from '../../src/shared/agent-session/structured-agent-session-create'
|
||||
import {
|
||||
createStructuredAgentSessionOperationId,
|
||||
structuredAgentSessionPayloadFingerprint
|
||||
} from '../../src/shared/agent-session/lifecycle/structured-agent-session-mutation'
|
||||
import { structuredAgentSessionMessageSendMutation } from '../../src/shared/agent-session/requests/structured-agent-session-send-mutation'
|
||||
import { agentJournalSubmissionKey } from '../../src/shared/agent-session/journal/agent-session-journal-item-key'
|
||||
import type { AgentSessionHistoryResult } from '../../src/shared/agent-session/agent-session-wire'
|
||||
import type {
|
||||
AgentSessionCheckpointPreview,
|
||||
AgentSessionCheckpointRestoreResult
|
||||
} from '../../src/shared/agent-session/lifecycle/agent-session-checkpoint'
|
||||
|
||||
const KEY_FILE = process.env.ORCA_E2E_AI_GATEWAY_KEY_FILE
|
||||
const MODEL = process.env.ORCA_E2E_AI_GATEWAY_MODEL ?? 'moonshotai/kimi-k2'
|
||||
|
||||
function git(cwd: string, ...args: string[]): string {
|
||||
return execFileSync('git', args, { cwd, encoding: 'utf8' }).trim()
|
||||
}
|
||||
|
||||
/** Every non-.git file under `root` with its content: what the user would see on disk. */
|
||||
function files(root: string, dir = root, out: Record<string, string> = {}): Record<string, string> {
|
||||
for (const name of readdirSync(dir)) {
|
||||
if (name === '.git' || name === '.claude') {
|
||||
continue
|
||||
}
|
||||
const full = path.join(dir, name)
|
||||
if (statSync(full).isDirectory()) {
|
||||
files(root, full, out)
|
||||
} else {
|
||||
out[path.relative(root, full)] = readFileSync(full, 'utf8')
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const text = (value: string) => ({
|
||||
kind: 'message' as const,
|
||||
role: 'user' as const,
|
||||
blocks: [{ type: 'text' as const, text: value }]
|
||||
})
|
||||
|
||||
test.describe('rewind with file restore, live agent', () => {
|
||||
test.skip(!KEY_FILE, 'needs a real model: set ORCA_E2E_AI_GATEWAY_KEY_FILE')
|
||||
|
||||
test('restores the files a real agent changed, then undoes the restore', async () => {
|
||||
// A real model runs two tool-using turns, a rewind and two restores: minutes, not seconds.
|
||||
test.setTimeout(900_000)
|
||||
const worktree = mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-rewind-files-'))
|
||||
git(worktree, 'init', '-q')
|
||||
git(worktree, 'config', 'user.name', 'E2E')
|
||||
git(worktree, 'config', 'user.email', 'e2e@example.test')
|
||||
writeFileSync(path.join(worktree, '.gitignore'), 'build/\n')
|
||||
writeFileSync(path.join(worktree, 'app.txt'), 'v0\n')
|
||||
git(worktree, 'add', '.')
|
||||
git(worktree, '-c', 'commit.gpgsign=false', 'commit', '-qm', 'base')
|
||||
writeFileSync(path.join(worktree, 'staged.txt'), 'staged by the user\n')
|
||||
git(worktree, 'add', 'staged.txt')
|
||||
const head = git(worktree, 'rev-parse', 'HEAD')
|
||||
const key = readFileSync(KEY_FILE!, 'utf8').trim()
|
||||
const host = await launchHeadlessPairedRuntimeHost({
|
||||
extraEnv: {
|
||||
ANTHROPIC_BASE_URL: 'https://ai-gateway.vercel.sh',
|
||||
ANTHROPIC_AUTH_TOKEN: key,
|
||||
ANTHROPIC_API_KEY: '',
|
||||
ANTHROPIC_MODEL: MODEL,
|
||||
ANTHROPIC_SMALL_FAST_MODEL: MODEL,
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: MODEL
|
||||
}
|
||||
})
|
||||
const call = async <T>(method: string, params: unknown, timeoutMs = 60_000): Promise<T> =>
|
||||
(await host.client.call<T>(method, params, { timeoutMs })).result
|
||||
try {
|
||||
const added = await call<{ repo: { id: string } }>('repo.add', {
|
||||
path: worktree
|
||||
})
|
||||
let worktreeId = ''
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const listed = await call<{ worktrees: { id: string }[] }>('worktree.list', {
|
||||
repo: `id:${added.repo.id}`
|
||||
})
|
||||
return (worktreeId = listed.worktrees[0]?.id ?? '')
|
||||
})
|
||||
.not.toBe('')
|
||||
const sessionId = createStructuredAgentSessionId('claude', randomUUID)
|
||||
await call(
|
||||
'agentSession.create',
|
||||
structuredAgentSessionCreateParams({
|
||||
sessionId,
|
||||
worktree: `id:${worktreeId}`,
|
||||
agent: 'claude',
|
||||
options: { permissionMode: 'full-access' },
|
||||
randomUuid: randomUUID
|
||||
})
|
||||
)
|
||||
const tail = () =>
|
||||
call<AgentSessionHistoryResult>('agentSession.history', {
|
||||
sessionId,
|
||||
direction: 'tail',
|
||||
limit: 200
|
||||
})
|
||||
let fence = 0
|
||||
await expect
|
||||
.poll(async () => (fence = (await tail()).page.fence ?? 0), {
|
||||
timeout: 120_000
|
||||
})
|
||||
.toBeGreaterThan(0)
|
||||
const send = async (prompt: string): Promise<string> => {
|
||||
const clientOperationId = createStructuredAgentSessionOperationId(randomUUID)
|
||||
const page = (await tail()).page
|
||||
await call(
|
||||
'agentSession.send',
|
||||
{
|
||||
...structuredAgentSessionMessageSendMutation({
|
||||
sessionId,
|
||||
clientOperationId,
|
||||
expectedRuntimeFence: page.fence ?? fence,
|
||||
body: text(prompt),
|
||||
delivery: 'queue-if-active'
|
||||
})
|
||||
},
|
||||
120_000
|
||||
)
|
||||
return clientOperationId
|
||||
}
|
||||
/** The turn the message opened has an agent reply after it and the chat has gone quiet
|
||||
* (no new rows across several polls): models don't always end with the word asked for. */
|
||||
let lastSequence = -1
|
||||
let quietPolls = 0
|
||||
const settled = async (clientOperationId: string): Promise<boolean> => {
|
||||
const page = (await tail()).page
|
||||
const index = page.items.findIndex(
|
||||
(item) => item.itemId === agentJournalSubmissionKey(clientOperationId)
|
||||
)
|
||||
const replied =
|
||||
index !== -1 &&
|
||||
page.items
|
||||
.slice(index + 1)
|
||||
.some((item) => item.body.kind === 'message' && item.body.role === 'assistant')
|
||||
const newest = page.items.at(-1)?.sequence ?? -1
|
||||
quietPolls = newest === lastSequence ? quietPolls + 1 : 0
|
||||
lastSequence = newest
|
||||
if (!replied) {
|
||||
const submission = page.submissions.find(
|
||||
(entry) => entry.clientMessageId === clientOperationId
|
||||
)
|
||||
console.log(
|
||||
`[rewind-files live] waiting: dispatch=${submission?.dispatchState} refs=${git(worktree, 'for-each-ref', '--format=%(refname)', 'refs/orca/').split('\n').length} last=${JSON.stringify(page.items.at(-1)?.body).slice(0, 120)}`
|
||||
)
|
||||
}
|
||||
return replied && quietPolls >= 4
|
||||
}
|
||||
|
||||
const first = await send(
|
||||
'Using your file tools, create a file named feature.txt containing exactly the line: one. Do nothing else, then reply DONE.'
|
||||
)
|
||||
await expect.poll(() => settled(first), { timeout: 300_000, intervals: [3000] }).toBe(true)
|
||||
const beforeSecond = files(worktree)
|
||||
expect(beforeSecond['feature.txt']?.trim()).toBe('one')
|
||||
|
||||
const second = await send(
|
||||
'Using your file tools: overwrite app.txt so it contains exactly the line: v2. Delete feature.txt. Create notes/todo.txt containing exactly the line: later. Do nothing else, then reply DONE.'
|
||||
)
|
||||
await expect.poll(() => settled(second), { timeout: 300_000, intervals: [3000] }).toBe(true)
|
||||
const afterSecond = files(worktree)
|
||||
expect(afterSecond).not.toEqual(beforeSecond)
|
||||
// Snapshots never touch the user's HEAD, staging or stash list.
|
||||
expect(git(worktree, 'rev-parse', 'HEAD')).toBe(head)
|
||||
expect(git(worktree, 'diff', '--cached', '--name-only')).toBe('staged.txt')
|
||||
expect(git(worktree, 'stash', 'list')).toBe('')
|
||||
const refs = git(worktree, 'for-each-ref', '--format=%(refname)', 'refs/orca/checkpoints/')
|
||||
expect(
|
||||
refs.split('\n').filter((ref) => ref.includes('/turn/')).length
|
||||
).toBeGreaterThanOrEqual(2)
|
||||
|
||||
// Rewind the chat to the second message (as the UI does), then restore its files.
|
||||
const itemId = agentJournalSubmissionKey(second)
|
||||
const preview = await call<AgentSessionCheckpointPreview>('agentSession.checkpointPreview', {
|
||||
sessionId,
|
||||
itemId
|
||||
})
|
||||
expect(preview).toMatchObject({ available: true, headMoved: false })
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const page = (await tail()).page
|
||||
const fields = { itemId, expectedEpoch: page.epoch }
|
||||
const response = await host.client.call<unknown>(
|
||||
'agentSession.rewind',
|
||||
{
|
||||
...fields,
|
||||
envelope: {
|
||||
sessionId,
|
||||
clientOperationId: createStructuredAgentSessionOperationId(randomUUID),
|
||||
expectedRuntimeFence: page.fence ?? fence,
|
||||
payloadFingerprint: structuredAgentSessionPayloadFingerprint({
|
||||
method: 'agentSession.rewind',
|
||||
sessionId,
|
||||
fields
|
||||
})
|
||||
}
|
||||
},
|
||||
{ timeoutMs: 200_000 }
|
||||
)
|
||||
return JSON.stringify(response.result)
|
||||
},
|
||||
{ timeout: 240_000, intervals: [5000] }
|
||||
)
|
||||
.toContain('"ok":true')
|
||||
// The turn's tail (naming, final events) may still count as working for a moment.
|
||||
const box: { restored?: AgentSessionCheckpointRestoreResult } = {}
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
try {
|
||||
box.restored = await call<AgentSessionCheckpointRestoreResult>(
|
||||
'agentSession.checkpointRestore',
|
||||
{ sessionId, itemId },
|
||||
120_000
|
||||
)
|
||||
return 'restored'
|
||||
} catch (error) {
|
||||
if (String(error).includes('agent_session_checkpoint_busy')) {
|
||||
return 'busy'
|
||||
}
|
||||
throw error
|
||||
}
|
||||
},
|
||||
{ timeout: 60_000, intervals: [2000] }
|
||||
)
|
||||
.toBe('restored')
|
||||
const restored = box.restored!
|
||||
expect(restored.changedFiles).toBe(
|
||||
(preview.available && preview.changedFiles) || restored.changedFiles
|
||||
)
|
||||
expect(files(worktree)).toEqual(beforeSecond)
|
||||
expect(git(worktree, 'diff', '--cached', '--name-only')).toBe('staged.txt')
|
||||
expect(git(worktree, 'rev-parse', 'HEAD')).toBe(head)
|
||||
|
||||
await call('agentSession.checkpointUndo', { sessionId, undoId: restored.undoId }, 120_000)
|
||||
expect(files(worktree)).toEqual(afterSecond)
|
||||
console.log(
|
||||
`[rewind-files live] model=${MODEL} restored=${restored.changedFiles} files; before=${JSON.stringify(beforeSecond)} after=${JSON.stringify(afterSecond)}`
|
||||
)
|
||||
} finally {
|
||||
await host.dispose()
|
||||
rmSync(worktree, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user