mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-08-28 02:44:59 +00:00
feat: capture immutable workspace checkpoints (#1113)
This commit is contained in:
parent
0338570cde
commit
d8f1e0abf3
8 changed files with 1395 additions and 0 deletions
35
docs/architecture/WORKSPACE-CHECKPOINTS-V1.md
Normal file
35
docs/architecture/WORKSPACE-CHECKPOINTS-V1.md
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
# Workspace Checkpoints v1
|
||||
|
||||
`workspace-checkpoint/v1` is the immutable storage contract for a run-owned worktree at a safe execution boundary. It is not the existing task-resume checkpoint and it is not permission to mutate or rewind a workspace.
|
||||
|
||||
## Capture foundation
|
||||
|
||||
The first slice captures:
|
||||
|
||||
- exact workspace, task, attempt, boundary, parent, turn, and conversation-cursor identity;
|
||||
- Git HEAD, branch, porcelain status digest, and a content-addressed copy of the exact Git index;
|
||||
- tracked worktree files, untracked non-ignored files, and explicit tracked-file absence;
|
||||
- content-addressed regular text blobs with mode, size, and SHA-256 evidence;
|
||||
- deterministic exclusion evidence for sensitive files, binary files, symlinks, unsupported entries, file-size limits, aggregate byte limits, and inventory limits; and
|
||||
- a digest over the complete immutable metadata document.
|
||||
|
||||
Ignored files are excluded by Git policy. Sensitive files, binary files, symlinks, `.git`, and `.veritas-kanban` content are excluded before blob persistence. Defaults cap one checkpoint at 10,000 files, 64 MiB total content, 8 MiB per file, and 2,000 retained exclusion records.
|
||||
|
||||
## Consistency and atomicity
|
||||
|
||||
Capture resolves the canonical Git worktree root and refuses a subdirectory or different repository. It records Git state before scanning, validates each file did not change across no-follow open/read/file-handle-stat checks, and compares HEAD, branch, index, and status again before publishing. Concurrent workspace mutation therefore aborts the capture instead of exposing a mixed-time snapshot.
|
||||
|
||||
Blobs are written under a SHA-256 content address and verified on reuse. Metadata is written into a private temporary directory and the directory is renamed into the exact run scope only after every blob and integrity check succeeds. Interrupted captures may leave unreachable deduplicated blobs, but list/get cannot expose a partial checkpoint as valid.
|
||||
|
||||
Caller operation IDs are persisted only as digests. Repeating the same exact capture operation returns the original checkpoint. Reusing that operation identity with changed boundary, scope, cursor, parent, worktree, or policy evidence returns conflict.
|
||||
|
||||
## Deliberate next boundaries
|
||||
|
||||
This foundation does not yet claim turn hooks, hunk attribution, preview, restore, or retention cleanup. Those layers must consume this immutable repository and remain preview-first. Rewind cannot write until current HEAD, index, file hashes, worktree ownership, external changes, and approval evidence all match the checkpoint descendant it intends to replace.
|
||||
|
||||
## Code
|
||||
|
||||
- Shared contract: `shared/src/types/workspace-checkpoint.types.ts`
|
||||
- Validation: `server/src/schemas/workspace-checkpoint-schemas.ts`
|
||||
- File repository: `server/src/storage/workspace-checkpoint-repository.ts`
|
||||
- Focused verification: `server/src/__tests__/workspace-checkpoint-repository.test.ts`
|
||||
249
server/src/__tests__/workspace-checkpoint-repository.test.ts
Normal file
249
server/src/__tests__/workspace-checkpoint-repository.test.ts
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
import { execFile } from 'node:child_process';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { promisify } from 'node:util';
|
||||
import { FileWorkspaceCheckpointRepository } from '../storage/workspace-checkpoint-repository.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const roots: string[] = [];
|
||||
|
||||
async function fixture() {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'vk-workspace-checkpoint-'));
|
||||
roots.push(root);
|
||||
const worktreePath = path.join(root, 'worktree');
|
||||
const storePath = path.join(root, 'store');
|
||||
await fs.mkdir(worktreePath);
|
||||
await execFileAsync('git', ['init'], { cwd: worktreePath });
|
||||
await execFileAsync('git', ['config', 'user.name', 'Veritas Test'], { cwd: worktreePath });
|
||||
await execFileAsync('git', ['config', 'user.email', 'veritas@example.test'], {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
await fs.writeFile(path.join(worktreePath, '.gitignore'), '.env\nignored.txt\n');
|
||||
await fs.writeFile(path.join(worktreePath, 'tracked.txt'), 'tracked baseline\n');
|
||||
await fs.writeFile(path.join(worktreePath, 'deleted.txt'), 'delete me\n');
|
||||
await execFileAsync('git', ['add', '.gitignore', 'tracked.txt', 'deleted.txt'], {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
await execFileAsync('git', ['commit', '-m', 'fixture'], { cwd: worktreePath });
|
||||
await fs.rm(path.join(worktreePath, 'deleted.txt'));
|
||||
await fs.writeFile(path.join(worktreePath, 'tracked.txt'), 'tracked staged\n');
|
||||
await execFileAsync('git', ['add', 'tracked.txt'], { cwd: worktreePath });
|
||||
await fs.writeFile(path.join(worktreePath, 'tracked.txt'), 'tracked worktree\n');
|
||||
await fs.writeFile(path.join(worktreePath, 'untracked.txt'), 'untracked content\n');
|
||||
await fs.writeFile(path.join(worktreePath, '.env'), 'SECRET=value\n');
|
||||
await fs.writeFile(path.join(worktreePath, 'ignored.txt'), 'ignored\n');
|
||||
await fs.writeFile(path.join(worktreePath, 'binary.bin'), Buffer.from([1, 0, 2]));
|
||||
await fs.symlink('tracked.txt', path.join(worktreePath, 'linked.txt'));
|
||||
return { root, worktreePath, storePath };
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe('FileWorkspaceCheckpointRepository', () => {
|
||||
it('captures a bounded content-addressed worktree and exact Git index posture', async () => {
|
||||
const { worktreePath, storePath } = await fixture();
|
||||
const repository = new FileWorkspaceCheckpointRepository({
|
||||
baseDir: storePath,
|
||||
now: () => new Date('2026-07-26T06:00:00.000Z'),
|
||||
});
|
||||
const request = {
|
||||
workspaceId: 'workspace-872',
|
||||
taskId: 'task-872',
|
||||
attemptId: 'attempt-872',
|
||||
operationId: 'before-turn-1',
|
||||
boundary: 'before-user-turn' as const,
|
||||
worktreePath,
|
||||
worktreeManifestId: 'worktree-872',
|
||||
turnId: 'turn-1',
|
||||
conversationCursor: 'cursor-1',
|
||||
};
|
||||
|
||||
const checkpoint = await repository.capture(request);
|
||||
|
||||
expect(checkpoint).toMatchObject({
|
||||
schemaVersion: 'workspace-checkpoint/v1',
|
||||
workspaceId: request.workspaceId,
|
||||
taskId: request.taskId,
|
||||
attemptId: request.attemptId,
|
||||
boundary: request.boundary,
|
||||
worktreeManifestId: request.worktreeManifestId,
|
||||
turnId: request.turnId,
|
||||
conversationCursor: request.conversationCursor,
|
||||
git: {
|
||||
dirty: true,
|
||||
head: expect.stringMatching(/^[a-f0-9]{40}$/),
|
||||
branch: expect.any(String),
|
||||
indexDigest: expect.stringMatching(/^sha256:[a-f0-9]{64}$/),
|
||||
indexBlobDigest: expect.stringMatching(/^sha256:[a-f0-9]{64}$/),
|
||||
},
|
||||
digest: expect.stringMatching(/^sha256:[a-f0-9]{64}$/),
|
||||
});
|
||||
expect(checkpoint.files).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
path: 'tracked.txt',
|
||||
source: 'tracked',
|
||||
state: 'present',
|
||||
size: Buffer.byteLength('tracked worktree\n'),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
path: 'untracked.txt',
|
||||
source: 'untracked',
|
||||
state: 'present',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
path: 'deleted.txt',
|
||||
source: 'tracked',
|
||||
state: 'absent',
|
||||
size: 0,
|
||||
}),
|
||||
])
|
||||
);
|
||||
expect(checkpoint.exclusions).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ path: 'binary.bin', reason: 'binary' }),
|
||||
expect.objectContaining({ path: 'linked.txt', reason: 'symlink' }),
|
||||
])
|
||||
);
|
||||
expect(checkpoint.files.some((file) => file.path === '.env')).toBe(false);
|
||||
expect(checkpoint.files.some((file) => file.path === 'ignored.txt')).toBe(false);
|
||||
const tracked = checkpoint.files.find((file) => file.path === 'tracked.txt');
|
||||
await expect(repository.readBlob(tracked?.blobDigest ?? '')).resolves.toEqual(
|
||||
Buffer.from('tracked worktree\n')
|
||||
);
|
||||
await expect(repository.readBlob(checkpoint.git.indexBlobDigest)).resolves.toHaveLength(
|
||||
checkpoint.git.indexBytes
|
||||
);
|
||||
await expect(
|
||||
repository.get({
|
||||
workspaceId: request.workspaceId,
|
||||
taskId: request.taskId,
|
||||
attemptId: request.attemptId,
|
||||
checkpointId: checkpoint.id,
|
||||
})
|
||||
).resolves.toEqual(checkpoint);
|
||||
await expect(repository.list(request)).resolves.toEqual([checkpoint]);
|
||||
|
||||
await fs.writeFile(path.join(worktreePath, 'tracked.txt'), 'changed after checkpoint\n');
|
||||
await expect(repository.capture(request)).resolves.toEqual(checkpoint);
|
||||
await expect(repository.capture({ ...request, boundary: 'before-compaction' })).rejects.toThrow(
|
||||
'operation identity was reused'
|
||||
);
|
||||
});
|
||||
|
||||
it('publishes metadata last and leaves failed captures undiscoverable', async () => {
|
||||
const { worktreePath, storePath } = await fixture();
|
||||
const beforePublish = vi.fn(async () => {
|
||||
throw new Error('injected publish failure');
|
||||
});
|
||||
const repository = new FileWorkspaceCheckpointRepository({
|
||||
baseDir: storePath,
|
||||
beforePublish,
|
||||
});
|
||||
const request = {
|
||||
workspaceId: 'workspace-atomic',
|
||||
taskId: 'task-atomic',
|
||||
attemptId: 'attempt-atomic',
|
||||
operationId: 'capture-atomic',
|
||||
boundary: 'manual' as const,
|
||||
worktreePath,
|
||||
};
|
||||
|
||||
await expect(repository.capture(request)).rejects.toThrow('injected publish failure');
|
||||
expect(beforePublish).toHaveBeenCalledOnce();
|
||||
await expect(repository.list(request)).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('returns one canonical checkpoint for concurrent retries of the same operation', async () => {
|
||||
const { worktreePath, storePath } = await fixture();
|
||||
let arrivals = 0;
|
||||
let release!: () => void;
|
||||
const bothArrived = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const repository = new FileWorkspaceCheckpointRepository({
|
||||
baseDir: storePath,
|
||||
beforePublish: async () => {
|
||||
arrivals += 1;
|
||||
if (arrivals === 2) release();
|
||||
await bothArrived;
|
||||
},
|
||||
});
|
||||
const request = {
|
||||
workspaceId: 'workspace-concurrent',
|
||||
taskId: 'task-concurrent',
|
||||
attemptId: 'attempt-concurrent',
|
||||
operationId: 'capture-concurrent',
|
||||
boundary: 'manual' as const,
|
||||
worktreePath,
|
||||
};
|
||||
|
||||
const [first, second] = await Promise.all([
|
||||
repository.capture(request),
|
||||
repository.capture(request),
|
||||
]);
|
||||
|
||||
expect(first).toEqual(second);
|
||||
await expect(repository.list(request)).resolves.toEqual([first]);
|
||||
});
|
||||
|
||||
it('enforces file and byte limits without exposing sensitive or oversized content', async () => {
|
||||
const { worktreePath, storePath } = await fixture();
|
||||
await fs.writeFile(path.join(worktreePath, 'credentials.json'), '{"token":"private"}\n');
|
||||
await fs.writeFile(path.join(worktreePath, 'large.txt'), 'x'.repeat(64));
|
||||
const repository = new FileWorkspaceCheckpointRepository({
|
||||
baseDir: storePath,
|
||||
policy: {
|
||||
maxFiles: 100,
|
||||
maxBytes: 1_024,
|
||||
maxFileBytes: 32,
|
||||
maxExclusions: 100,
|
||||
},
|
||||
});
|
||||
|
||||
const checkpoint = await repository.capture({
|
||||
workspaceId: 'workspace-limits',
|
||||
taskId: 'task-limits',
|
||||
attemptId: 'attempt-limits',
|
||||
operationId: 'capture-limits',
|
||||
boundary: 'manual',
|
||||
worktreePath,
|
||||
});
|
||||
|
||||
expect(checkpoint.exclusions).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ path: 'credentials.json', reason: 'sensitive' }),
|
||||
expect.objectContaining({ path: 'large.txt', reason: 'too-large', size: 64 }),
|
||||
])
|
||||
);
|
||||
expect(checkpoint.files.some((file) => file.path === 'credentials.json')).toBe(false);
|
||||
expect(checkpoint.files.some((file) => file.path === 'large.txt')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a checkpoint blob replaced by a symlink', async () => {
|
||||
const { root, worktreePath, storePath } = await fixture();
|
||||
const repository = new FileWorkspaceCheckpointRepository({ baseDir: storePath });
|
||||
const checkpoint = await repository.capture({
|
||||
workspaceId: 'workspace-symlink',
|
||||
taskId: 'task-symlink',
|
||||
attemptId: 'attempt-symlink',
|
||||
operationId: 'capture-symlink',
|
||||
boundary: 'manual',
|
||||
worktreePath,
|
||||
});
|
||||
const tracked = checkpoint.files.find((file) => file.path === 'tracked.txt');
|
||||
const digest = tracked?.blobDigest ?? '';
|
||||
const hex = digest.slice('sha256:'.length);
|
||||
const blobPath = path.join(storePath, 'blobs', hex.slice(0, 2), hex);
|
||||
const replacement = path.join(root, 'replacement.txt');
|
||||
await fs.writeFile(replacement, 'tracked worktree\n');
|
||||
await fs.rm(blobPath);
|
||||
await fs.symlink(replacement, blobPath);
|
||||
|
||||
await expect(repository.readBlob(digest)).rejects.toThrow('blob is not a bounded regular file');
|
||||
});
|
||||
});
|
||||
195
server/src/schemas/workspace-checkpoint-schemas.ts
Normal file
195
server/src/schemas/workspace-checkpoint-schemas.ts
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
import { z } from 'zod';
|
||||
import {
|
||||
WORKSPACE_CHECKPOINT_BOUNDARIES,
|
||||
WORKSPACE_CHECKPOINT_EXCLUSION_REASONS,
|
||||
WORKSPACE_CHECKPOINT_SCHEMA_VERSION,
|
||||
type WorkspaceCheckpoint,
|
||||
} from '@veritas-kanban/shared';
|
||||
|
||||
const identifierSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(240)
|
||||
.regex(/^[A-Za-z0-9._:-]+$/);
|
||||
const digestSchema = z.string().regex(/^sha256:[a-f0-9]{64}$/);
|
||||
const opaqueReferenceSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(2_048)
|
||||
.refine((value) => value.trim().length > 0 && !value.includes('\0'), {
|
||||
message: 'Checkpoint references must contain non-whitespace text and no NUL bytes.',
|
||||
});
|
||||
const relativePathSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(4_096)
|
||||
.refine(
|
||||
(value) =>
|
||||
!value.includes('\0') &&
|
||||
!value.includes('\\') &&
|
||||
!value.startsWith('/') &&
|
||||
!/^[A-Za-z]:/.test(value) &&
|
||||
!value.split('/').includes('..'),
|
||||
{
|
||||
message: 'Checkpoint file paths must be safe relative paths with no NUL bytes.',
|
||||
}
|
||||
);
|
||||
|
||||
export const WorkspaceCheckpointSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(WORKSPACE_CHECKPOINT_SCHEMA_VERSION),
|
||||
id: identifierSchema,
|
||||
workspaceId: identifierSchema,
|
||||
taskId: identifierSchema,
|
||||
attemptId: identifierSchema,
|
||||
boundary: z.enum(WORKSPACE_CHECKPOINT_BOUNDARIES),
|
||||
operationIdDigest: digestSchema,
|
||||
captureRequestDigest: digestSchema,
|
||||
worktreeRootDigest: digestSchema,
|
||||
worktreeManifestId: opaqueReferenceSchema.optional(),
|
||||
parentCheckpointId: opaqueReferenceSchema.optional(),
|
||||
turnId: opaqueReferenceSchema.optional(),
|
||||
conversationCursor: opaqueReferenceSchema.optional(),
|
||||
git: z
|
||||
.object({
|
||||
head: z
|
||||
.string()
|
||||
.regex(/^[a-f0-9]{40,64}$/)
|
||||
.nullable(),
|
||||
branch: z.string().trim().min(1).max(500).nullable(),
|
||||
indexDigest: digestSchema,
|
||||
indexBlobDigest: digestSchema,
|
||||
indexBytes: z.number().int().nonnegative(),
|
||||
statusDigest: digestSchema,
|
||||
dirty: z.boolean(),
|
||||
})
|
||||
.strict(),
|
||||
policy: z
|
||||
.object({
|
||||
ignoredFiles: z.literal('excluded'),
|
||||
sensitiveFiles: z.literal('excluded'),
|
||||
binaryFiles: z.literal('excluded'),
|
||||
symlinks: z.literal('excluded'),
|
||||
maxFiles: z.number().int().min(1).max(100_000),
|
||||
maxBytes: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(4 * 1_024 * 1_024 * 1_024),
|
||||
maxFileBytes: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(512 * 1_024 * 1_024),
|
||||
maxExclusions: z.number().int().min(1).max(100_000),
|
||||
})
|
||||
.strict(),
|
||||
files: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
path: relativePathSchema,
|
||||
source: z.enum(['tracked', 'untracked']),
|
||||
state: z.enum(['present', 'absent']),
|
||||
mode: z.number().int().nonnegative().max(0o7777).optional(),
|
||||
size: z.number().int().nonnegative(),
|
||||
contentDigest: digestSchema.optional(),
|
||||
blobDigest: digestSchema.optional(),
|
||||
})
|
||||
.strict()
|
||||
)
|
||||
.max(100_000),
|
||||
exclusions: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
path: relativePathSchema,
|
||||
source: z.enum(['tracked', 'untracked']),
|
||||
reason: z.enum(WORKSPACE_CHECKPOINT_EXCLUSION_REASONS),
|
||||
size: z.number().int().nonnegative().optional(),
|
||||
})
|
||||
.strict()
|
||||
)
|
||||
.max(100_000),
|
||||
excludedCount: z.number().int().nonnegative(),
|
||||
exclusionsTruncated: z.boolean(),
|
||||
fileCount: z.number().int().nonnegative(),
|
||||
contentBytes: z.number().int().nonnegative(),
|
||||
storedBytes: z.number().int().nonnegative(),
|
||||
createdAt: z.iso.datetime(),
|
||||
digest: digestSchema,
|
||||
})
|
||||
.strict()
|
||||
.superRefine((checkpoint, context) => {
|
||||
if (checkpoint.fileCount !== checkpoint.files.length) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['fileCount'],
|
||||
message: 'Checkpoint fileCount must match the file inventory.',
|
||||
});
|
||||
}
|
||||
if (checkpoint.excludedCount < checkpoint.exclusions.length) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['excludedCount'],
|
||||
message: 'Checkpoint excludedCount cannot be below the retained exclusions.',
|
||||
});
|
||||
}
|
||||
if (
|
||||
checkpoint.exclusionsTruncated !==
|
||||
checkpoint.excludedCount > checkpoint.exclusions.length
|
||||
) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['exclusionsTruncated'],
|
||||
message: 'Checkpoint truncation evidence must match the exclusion counts.',
|
||||
});
|
||||
}
|
||||
if (
|
||||
checkpoint.fileCount > checkpoint.policy.maxFiles ||
|
||||
checkpoint.contentBytes > checkpoint.policy.maxBytes
|
||||
) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['policy'],
|
||||
message: 'Checkpoint inventory must remain within its recorded policy bounds.',
|
||||
});
|
||||
}
|
||||
for (const [index, file] of checkpoint.files.entries()) {
|
||||
if (
|
||||
file.state === 'present' &&
|
||||
(!file.contentDigest || !file.blobDigest || file.mode === undefined)
|
||||
) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['files', index],
|
||||
message: 'Present checkpoint files require mode and content-addressed blob evidence.',
|
||||
});
|
||||
}
|
||||
if (
|
||||
file.state === 'present' &&
|
||||
(file.contentDigest !== file.blobDigest || file.size > checkpoint.policy.maxFileBytes)
|
||||
) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['files', index],
|
||||
message: 'Checkpoint content evidence must match and remain within its file bound.',
|
||||
});
|
||||
}
|
||||
if (
|
||||
file.state === 'absent' &&
|
||||
(file.contentDigest || file.blobDigest || file.mode !== undefined || file.size !== 0)
|
||||
) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['files', index],
|
||||
message: 'Absent checkpoint files cannot include content evidence.',
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export function parseWorkspaceCheckpoint(value: unknown): WorkspaceCheckpoint {
|
||||
return WorkspaceCheckpointSchema.parse(value) as WorkspaceCheckpoint;
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ import type { FSWatcher } from 'node:fs';
|
|||
import { EventEmitter } from 'node:events';
|
||||
import {
|
||||
access,
|
||||
lstat as lstatAsync,
|
||||
mkdir as mkdirAsync,
|
||||
readFile as readFileAsync,
|
||||
readdir as readdirAsync,
|
||||
|
|
@ -69,6 +70,7 @@ export const createWriteStream = fs.createWriteStream;
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const mkdir = mkdirAsync;
|
||||
export const lstat = lstatAsync;
|
||||
export const readFile = readFileAsync;
|
||||
export const readdir = readdirAsync;
|
||||
export const realpath = fs.promises.realpath;
|
||||
|
|
|
|||
|
|
@ -48,6 +48,17 @@ export {
|
|||
InMemoryWorktreeManifestRepository,
|
||||
type WorktreeManifestRepository,
|
||||
} from './worktree-manifest-repository.js';
|
||||
export {
|
||||
FileWorkspaceCheckpointRepository,
|
||||
getWorkspaceCheckpointsDir,
|
||||
type FileWorkspaceCheckpointRepositoryOptions,
|
||||
type WorkspaceCheckpointCaptureInput,
|
||||
type WorkspaceCheckpointCommandResult,
|
||||
type WorkspaceCheckpointCommandRunner,
|
||||
type WorkspaceCheckpointListQuery,
|
||||
type WorkspaceCheckpointLookup,
|
||||
type WorkspaceCheckpointRepository,
|
||||
} from './workspace-checkpoint-repository.js';
|
||||
export {
|
||||
FileDependencyCircuitStateRepository,
|
||||
InMemoryDependencyCircuitStateRepository,
|
||||
|
|
|
|||
811
server/src/storage/workspace-checkpoint-repository.ts
Normal file
811
server/src/storage/workspace-checkpoint-repository.ts
Normal file
|
|
@ -0,0 +1,811 @@
|
|||
import { createHash } from 'node:crypto';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { constants } from 'node:fs';
|
||||
import { open } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { nanoid } from 'nanoid';
|
||||
import type {
|
||||
WorkspaceCheckpoint,
|
||||
WorkspaceCheckpointBoundary,
|
||||
WorkspaceCheckpointExclusion,
|
||||
WorkspaceCheckpointFile,
|
||||
WorkspaceCheckpointFileSource,
|
||||
WorkspaceCheckpointPolicy,
|
||||
} from '@veritas-kanban/shared';
|
||||
import { parseWorkspaceCheckpoint } from '../schemas/workspace-checkpoint-schemas.js';
|
||||
import { ConflictError } from '../middleware/error-handler.js';
|
||||
import { digestRunLaunchValue } from '../utils/run-launch-manifest-digest.js';
|
||||
import { getRuntimeDir } from '../utils/paths.js';
|
||||
import { ensureWithinBase } from '../utils/sanitize.js';
|
||||
import { atomicWriteFile, lstat, mkdir, readdir, realpath, rename, rm } from './fs-helpers.js';
|
||||
|
||||
const DEFAULT_POLICY: WorkspaceCheckpointPolicy = {
|
||||
ignoredFiles: 'excluded',
|
||||
sensitiveFiles: 'excluded',
|
||||
binaryFiles: 'excluded',
|
||||
symlinks: 'excluded',
|
||||
maxFiles: 10_000,
|
||||
maxBytes: 64 * 1_024 * 1_024,
|
||||
maxFileBytes: 8 * 1_024 * 1_024,
|
||||
maxExclusions: 2_000,
|
||||
};
|
||||
const MAX_METADATA_BYTES = 32 * 1_024 * 1_024;
|
||||
const MAX_GIT_OUTPUT_BYTES = 32 * 1_024 * 1_024;
|
||||
const MAX_POLICY: Record<'maxFiles' | 'maxBytes' | 'maxFileBytes' | 'maxExclusions', number> = {
|
||||
maxFiles: 100_000,
|
||||
maxBytes: 4 * 1_024 * 1_024 * 1_024,
|
||||
maxFileBytes: 512 * 1_024 * 1_024,
|
||||
maxExclusions: 100_000,
|
||||
};
|
||||
|
||||
export interface WorkspaceCheckpointCaptureInput {
|
||||
workspaceId: string;
|
||||
taskId: string;
|
||||
attemptId: string;
|
||||
operationId: string;
|
||||
boundary: WorkspaceCheckpointBoundary;
|
||||
worktreePath: string;
|
||||
worktreeManifestId?: string;
|
||||
parentCheckpointId?: string;
|
||||
turnId?: string;
|
||||
conversationCursor?: string;
|
||||
}
|
||||
|
||||
export interface WorkspaceCheckpointLookup {
|
||||
workspaceId: string;
|
||||
taskId: string;
|
||||
attemptId: string;
|
||||
checkpointId: string;
|
||||
}
|
||||
|
||||
export interface WorkspaceCheckpointListQuery {
|
||||
workspaceId: string;
|
||||
taskId: string;
|
||||
attemptId: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface WorkspaceCheckpointRepository {
|
||||
capture(input: WorkspaceCheckpointCaptureInput): Promise<WorkspaceCheckpoint>;
|
||||
get(lookup: WorkspaceCheckpointLookup): Promise<WorkspaceCheckpoint | null>;
|
||||
list(query: WorkspaceCheckpointListQuery): Promise<WorkspaceCheckpoint[]>;
|
||||
readBlob(digest: string): Promise<Buffer>;
|
||||
}
|
||||
|
||||
export interface WorkspaceCheckpointCommandResult {
|
||||
stdout: Buffer;
|
||||
stderr: Buffer;
|
||||
}
|
||||
|
||||
export type WorkspaceCheckpointCommandRunner = (
|
||||
command: string,
|
||||
args: string[],
|
||||
options: { cwd: string; maxBuffer: number }
|
||||
) => Promise<WorkspaceCheckpointCommandResult>;
|
||||
|
||||
export interface FileWorkspaceCheckpointRepositoryOptions {
|
||||
baseDir?: string;
|
||||
policy?: Partial<WorkspaceCheckpointPolicy>;
|
||||
now?: () => Date;
|
||||
runCommand?: WorkspaceCheckpointCommandRunner;
|
||||
beforePublish?: (checkpoint: WorkspaceCheckpoint) => void | Promise<void>;
|
||||
}
|
||||
|
||||
interface GitCaptureState {
|
||||
head: string | null;
|
||||
branch: string | null;
|
||||
index: Buffer;
|
||||
indexDigest: string;
|
||||
status: Buffer;
|
||||
statusDigest: string;
|
||||
tracked: string[];
|
||||
untracked: string[];
|
||||
}
|
||||
|
||||
export function getWorkspaceCheckpointsDir(): string {
|
||||
return path.join(getRuntimeDir(), 'workspace-checkpoints');
|
||||
}
|
||||
|
||||
export class FileWorkspaceCheckpointRepository implements WorkspaceCheckpointRepository {
|
||||
private readonly baseDir: string;
|
||||
private readonly policy: WorkspaceCheckpointPolicy;
|
||||
private readonly now: () => Date;
|
||||
private readonly runCommand: WorkspaceCheckpointCommandRunner;
|
||||
private readonly beforePublish?: (checkpoint: WorkspaceCheckpoint) => void | Promise<void>;
|
||||
|
||||
constructor(options: FileWorkspaceCheckpointRepositoryOptions = {}) {
|
||||
this.baseDir = path.resolve(options.baseDir ?? getWorkspaceCheckpointsDir());
|
||||
this.policy = normalizePolicy(options.policy);
|
||||
this.now = options.now ?? (() => new Date());
|
||||
this.runCommand = options.runCommand ?? defaultCommandRunner;
|
||||
this.beforePublish = options.beforePublish;
|
||||
}
|
||||
|
||||
async capture(input: WorkspaceCheckpointCaptureInput): Promise<WorkspaceCheckpoint> {
|
||||
validateCaptureInput(input);
|
||||
const canonicalRoot = await realpath(path.resolve(input.worktreePath));
|
||||
const gitRoot = (await this.git(canonicalRoot, ['rev-parse', '--show-toplevel']))
|
||||
.toString('utf8')
|
||||
.trim();
|
||||
const canonicalGitRoot = await realpath(gitRoot);
|
||||
if (canonicalGitRoot !== canonicalRoot) {
|
||||
throw new ConflictError('Workspace checkpoint root is not the exact Git worktree root.', {
|
||||
requestedRootDigest: digestRunLaunchValue(canonicalRoot),
|
||||
gitRootDigest: digestRunLaunchValue(canonicalGitRoot),
|
||||
});
|
||||
}
|
||||
const worktreeRootDigest = digestRunLaunchValue(canonicalRoot);
|
||||
const operationIdDigest = digestRunLaunchValue(input.operationId);
|
||||
const captureRequestDigest = digestRunLaunchValue({
|
||||
workspaceId: input.workspaceId,
|
||||
taskId: input.taskId,
|
||||
attemptId: input.attemptId,
|
||||
boundary: input.boundary,
|
||||
operationIdDigest,
|
||||
worktreeRootDigest,
|
||||
worktreeManifestId: input.worktreeManifestId,
|
||||
parentCheckpointId: input.parentCheckpointId,
|
||||
turnId: input.turnId,
|
||||
conversationCursor: input.conversationCursor,
|
||||
policy: this.policy,
|
||||
});
|
||||
const checkpointId = checkpointIdFor(
|
||||
input.workspaceId,
|
||||
input.taskId,
|
||||
input.attemptId,
|
||||
operationIdDigest
|
||||
);
|
||||
const lookup = {
|
||||
workspaceId: input.workspaceId,
|
||||
taskId: input.taskId,
|
||||
attemptId: input.attemptId,
|
||||
checkpointId,
|
||||
};
|
||||
const existing = await this.get(lookup);
|
||||
if (existing) {
|
||||
if (existing.captureRequestDigest !== captureRequestDigest) {
|
||||
throw new ConflictError(
|
||||
'Workspace checkpoint operation identity was reused for a changed capture request.',
|
||||
{ checkpointId }
|
||||
);
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
const before = await this.captureGitState(canonicalRoot);
|
||||
const candidates = mergeCandidates(before.tracked, before.untracked);
|
||||
const files: WorkspaceCheckpointFile[] = [];
|
||||
const exclusions: WorkspaceCheckpointExclusion[] = [];
|
||||
let excludedCount = 0;
|
||||
let contentBytes = 0;
|
||||
for (const [candidateIndex, candidate] of candidates.entries()) {
|
||||
const exclude = (reason: WorkspaceCheckpointExclusion['reason'], size?: number) => {
|
||||
excludedCount += 1;
|
||||
if (exclusions.length < this.policy.maxExclusions) {
|
||||
exclusions.push({
|
||||
path: candidate.path,
|
||||
source: candidate.source,
|
||||
reason,
|
||||
...(size === undefined ? {} : { size }),
|
||||
});
|
||||
}
|
||||
};
|
||||
if (candidateIndex >= this.policy.maxFiles) {
|
||||
for (const remainder of candidates.slice(candidateIndex)) {
|
||||
excludedCount += 1;
|
||||
if (exclusions.length < this.policy.maxExclusions) {
|
||||
exclusions.push({
|
||||
path: remainder.path,
|
||||
source: remainder.source,
|
||||
reason: 'file-limit',
|
||||
});
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (sensitivePath(candidate.path)) {
|
||||
exclude('sensitive');
|
||||
continue;
|
||||
}
|
||||
if (candidate.path.includes('\\')) {
|
||||
exclude('unsupported-file');
|
||||
continue;
|
||||
}
|
||||
const resolved = ensureWithinBase(canonicalRoot, path.resolve(canonicalRoot, candidate.path));
|
||||
let first: Awaited<ReturnType<typeof lstat>>;
|
||||
try {
|
||||
first = await lstat(resolved);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT' && candidate.source === 'tracked') {
|
||||
files.push({
|
||||
path: candidate.path,
|
||||
source: candidate.source,
|
||||
state: 'absent',
|
||||
size: 0,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
exclude('read-failed');
|
||||
continue;
|
||||
}
|
||||
if (first.isSymbolicLink()) {
|
||||
exclude('symlink');
|
||||
continue;
|
||||
}
|
||||
if (!first.isFile()) {
|
||||
exclude('unsupported-file');
|
||||
continue;
|
||||
}
|
||||
if (first.size > this.policy.maxFileBytes) {
|
||||
exclude('too-large', first.size);
|
||||
continue;
|
||||
}
|
||||
if (contentBytes + first.size > this.policy.maxBytes) {
|
||||
exclude('byte-limit', first.size);
|
||||
continue;
|
||||
}
|
||||
let content: Buffer;
|
||||
let handle: Awaited<ReturnType<typeof open>> | undefined;
|
||||
try {
|
||||
handle = await open(resolved, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
||||
const opened = await handle.stat();
|
||||
if (
|
||||
!opened.isFile() ||
|
||||
first.dev !== opened.dev ||
|
||||
first.ino !== opened.ino ||
|
||||
first.size !== opened.size ||
|
||||
first.mtimeMs !== opened.mtimeMs
|
||||
) {
|
||||
throw new ConflictError('Workspace file changed while its checkpoint was captured.', {
|
||||
path: candidate.path,
|
||||
});
|
||||
}
|
||||
content = await handle.readFile();
|
||||
const second = await handle.stat();
|
||||
if (
|
||||
!second.isFile() ||
|
||||
opened.dev !== second.dev ||
|
||||
opened.ino !== second.ino ||
|
||||
opened.size !== second.size ||
|
||||
opened.mtimeMs !== second.mtimeMs ||
|
||||
content.byteLength !== second.size
|
||||
) {
|
||||
throw new ConflictError('Workspace file changed while its checkpoint was captured.', {
|
||||
path: candidate.path,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof ConflictError) throw error;
|
||||
if ((error as NodeJS.ErrnoException).code === 'ELOOP') {
|
||||
throw new ConflictError('Workspace file became a symlink during checkpoint capture.', {
|
||||
path: candidate.path,
|
||||
});
|
||||
}
|
||||
exclude('read-failed', first.size);
|
||||
continue;
|
||||
} finally {
|
||||
await handle?.close();
|
||||
}
|
||||
if (binaryContent(content)) {
|
||||
exclude('binary', content.byteLength);
|
||||
continue;
|
||||
}
|
||||
const blobDigest = await this.storeBlob(content);
|
||||
files.push({
|
||||
path: candidate.path,
|
||||
source: candidate.source,
|
||||
state: 'present',
|
||||
mode: first.mode & 0o7777,
|
||||
size: content.byteLength,
|
||||
contentDigest: blobDigest,
|
||||
blobDigest,
|
||||
});
|
||||
contentBytes += content.byteLength;
|
||||
}
|
||||
const after = await this.captureGitState(canonicalRoot);
|
||||
if (
|
||||
before.head !== after.head ||
|
||||
before.branch !== after.branch ||
|
||||
before.indexDigest !== after.indexDigest ||
|
||||
before.statusDigest !== after.statusDigest
|
||||
) {
|
||||
throw new ConflictError('Workspace Git state changed while its checkpoint was captured.', {
|
||||
checkpointId,
|
||||
});
|
||||
}
|
||||
const indexBlobDigest = await this.storeBlob(before.index);
|
||||
const createdAt = this.now().toISOString();
|
||||
const payload = {
|
||||
schemaVersion: 'workspace-checkpoint/v1' as const,
|
||||
id: checkpointId,
|
||||
workspaceId: input.workspaceId,
|
||||
taskId: input.taskId,
|
||||
attemptId: input.attemptId,
|
||||
boundary: input.boundary,
|
||||
operationIdDigest,
|
||||
captureRequestDigest,
|
||||
worktreeRootDigest,
|
||||
...(input.worktreeManifestId ? { worktreeManifestId: input.worktreeManifestId } : {}),
|
||||
...(input.parentCheckpointId ? { parentCheckpointId: input.parentCheckpointId } : {}),
|
||||
...(input.turnId ? { turnId: input.turnId } : {}),
|
||||
...(input.conversationCursor ? { conversationCursor: input.conversationCursor } : {}),
|
||||
git: {
|
||||
head: before.head,
|
||||
branch: before.branch,
|
||||
indexDigest: before.indexDigest,
|
||||
indexBlobDigest,
|
||||
indexBytes: before.index.byteLength,
|
||||
statusDigest: before.statusDigest,
|
||||
dirty: before.status.byteLength > 0,
|
||||
},
|
||||
policy: this.policy,
|
||||
files,
|
||||
exclusions,
|
||||
excludedCount,
|
||||
exclusionsTruncated: excludedCount > exclusions.length,
|
||||
fileCount: files.length,
|
||||
contentBytes,
|
||||
storedBytes: contentBytes + before.index.byteLength,
|
||||
createdAt,
|
||||
};
|
||||
const checkpoint = parseWorkspaceCheckpoint({
|
||||
...payload,
|
||||
digest: digestRunLaunchValue(payload),
|
||||
});
|
||||
return this.publish(checkpoint);
|
||||
}
|
||||
|
||||
async get(lookup: WorkspaceCheckpointLookup): Promise<WorkspaceCheckpoint | null> {
|
||||
const metadataPath = this.metadataPath(lookup);
|
||||
if (!(await this.assertPrivateDirectoryPath(path.dirname(metadataPath), true))) return null;
|
||||
const content = await this.readBoundedRegularFile(
|
||||
metadataPath,
|
||||
MAX_METADATA_BYTES,
|
||||
'Workspace checkpoint metadata failed its integrity bound.',
|
||||
true
|
||||
);
|
||||
if (!content) return null;
|
||||
const checkpoint = parseWorkspaceCheckpoint(JSON.parse(content.toString('utf8')));
|
||||
if (
|
||||
checkpoint.workspaceId !== lookup.workspaceId ||
|
||||
checkpoint.taskId !== lookup.taskId ||
|
||||
checkpoint.attemptId !== lookup.attemptId ||
|
||||
checkpoint.id !== lookup.checkpointId
|
||||
) {
|
||||
throw new ConflictError('Workspace checkpoint metadata scope does not match its path.', {
|
||||
checkpointId: lookup.checkpointId,
|
||||
});
|
||||
}
|
||||
const { digest: _digest, ...payload } = checkpoint;
|
||||
if (checkpoint.digest !== digestRunLaunchValue(payload)) {
|
||||
throw new ConflictError('Workspace checkpoint metadata digest is invalid.', {
|
||||
checkpointId: lookup.checkpointId,
|
||||
});
|
||||
}
|
||||
return checkpoint;
|
||||
}
|
||||
|
||||
async list(query: WorkspaceCheckpointListQuery): Promise<WorkspaceCheckpoint[]> {
|
||||
validateScope(query);
|
||||
const limit = Math.min(Math.max(query.limit ?? 100, 1), 2_000);
|
||||
const parent = this.attemptPath(query);
|
||||
if (!(await this.assertPrivateDirectoryPath(parent, true))) return [];
|
||||
const entries = (await readdir(parent, { withFileTypes: true }))
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.isDirectory() && !entry.isSymbolicLink() && entry.name.startsWith('checkpoint_')
|
||||
)
|
||||
.sort((left, right) => left.name.localeCompare(right.name));
|
||||
const checkpoints: WorkspaceCheckpoint[] = [];
|
||||
for (const entry of entries) {
|
||||
if (checkpoints.length >= limit) break;
|
||||
const checkpoint = await this.get({
|
||||
...query,
|
||||
checkpointId: entry.name,
|
||||
});
|
||||
if (checkpoint) checkpoints.push(checkpoint);
|
||||
}
|
||||
return checkpoints.sort(
|
||||
(left, right) =>
|
||||
Date.parse(right.createdAt) - Date.parse(left.createdAt) || left.id.localeCompare(right.id)
|
||||
);
|
||||
}
|
||||
|
||||
async readBlob(digest: string): Promise<Buffer> {
|
||||
const blobPath = this.blobPath(digest);
|
||||
await this.assertPrivateDirectoryPath(path.dirname(blobPath));
|
||||
const content = await this.readBoundedRegularFile(
|
||||
blobPath,
|
||||
Math.max(this.policy.maxBytes, MAX_GIT_OUTPUT_BYTES),
|
||||
'Workspace checkpoint blob is not a bounded regular file.',
|
||||
true
|
||||
);
|
||||
if (!content) {
|
||||
throw new ConflictError('Workspace checkpoint blob is missing.', { digest });
|
||||
}
|
||||
if (sha256(content) !== digest) {
|
||||
throw new ConflictError('Workspace checkpoint blob digest is invalid.', { digest });
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
private async captureGitState(worktreeRoot: string): Promise<GitCaptureState> {
|
||||
const [headResult, branchResult, indexPathResult, status, tracked, untracked] =
|
||||
await Promise.all([
|
||||
this.git(worktreeRoot, ['rev-parse', '--verify', 'HEAD'], true),
|
||||
this.git(worktreeRoot, ['symbolic-ref', '--short', '-q', 'HEAD'], true),
|
||||
this.git(worktreeRoot, ['rev-parse', '--git-path', 'index']),
|
||||
this.git(worktreeRoot, ['status', '--porcelain=v2', '-z', '--untracked-files=all']),
|
||||
this.git(worktreeRoot, ['ls-files', '-z', '--cached']),
|
||||
this.git(worktreeRoot, ['ls-files', '-z', '--others', '--exclude-standard']),
|
||||
]);
|
||||
const indexPathValue = indexPathResult.toString('utf8').trim();
|
||||
const indexPath = path.isAbsolute(indexPathValue)
|
||||
? indexPathValue
|
||||
: path.resolve(worktreeRoot, indexPathValue);
|
||||
const index =
|
||||
(await this.readBoundedRegularFile(
|
||||
indexPath,
|
||||
MAX_GIT_OUTPUT_BYTES,
|
||||
'Workspace checkpoint Git index is not a bounded regular file.',
|
||||
true
|
||||
)) ?? Buffer.alloc(0);
|
||||
return {
|
||||
head: optionalOutput(headResult),
|
||||
branch: optionalOutput(branchResult),
|
||||
index,
|
||||
indexDigest: sha256(index),
|
||||
status,
|
||||
statusDigest: sha256(status),
|
||||
tracked: parseNullList(tracked),
|
||||
untracked: parseNullList(untracked),
|
||||
};
|
||||
}
|
||||
|
||||
private async git(worktreeRoot: string, args: string[], allowFailure = false): Promise<Buffer> {
|
||||
try {
|
||||
return (
|
||||
await this.runCommand('git', args, {
|
||||
cwd: worktreeRoot,
|
||||
maxBuffer: MAX_GIT_OUTPUT_BYTES,
|
||||
})
|
||||
).stdout;
|
||||
} catch (error) {
|
||||
if (allowFailure) return Buffer.alloc(0);
|
||||
throw new ConflictError('Workspace checkpoint Git inspection failed.', {
|
||||
command: args[0],
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async storeBlob(content: Buffer): Promise<string> {
|
||||
const digest = sha256(content);
|
||||
const blobPath = this.blobPath(digest);
|
||||
await this.preparePrivateDirectory(['blobs', digest.slice('sha256:'.length, 9)]);
|
||||
const existing = await this.readBoundedRegularFile(
|
||||
blobPath,
|
||||
Math.max(this.policy.maxBytes, MAX_GIT_OUTPUT_BYTES),
|
||||
'Workspace checkpoint blob path is not a bounded regular file.',
|
||||
true
|
||||
);
|
||||
if (existing) {
|
||||
if (sha256(existing) !== digest) {
|
||||
throw new ConflictError('Workspace checkpoint blob path contains mismatched content.', {
|
||||
digest,
|
||||
});
|
||||
}
|
||||
return digest;
|
||||
}
|
||||
await atomicWriteFile(blobPath, content);
|
||||
return digest;
|
||||
}
|
||||
|
||||
private async publish(checkpoint: WorkspaceCheckpoint): Promise<WorkspaceCheckpoint> {
|
||||
const parent = await this.preparePrivateDirectory([
|
||||
checkpoint.workspaceId,
|
||||
checkpoint.taskId,
|
||||
checkpoint.attemptId,
|
||||
]);
|
||||
const destination = this.checkpointPath({ ...checkpoint, checkpointId: checkpoint.id });
|
||||
const temporary = ensureWithinBase(
|
||||
parent,
|
||||
path.join(parent, `.tmp-${checkpoint.id}-${nanoid(8)}`)
|
||||
);
|
||||
await mkdir(temporary, { mode: 0o700 });
|
||||
try {
|
||||
const metadata = `${JSON.stringify(checkpoint, null, 2)}\n`;
|
||||
if (Buffer.byteLength(metadata) > MAX_METADATA_BYTES) {
|
||||
throw new ConflictError('Workspace checkpoint metadata exceeds its integrity bound.', {
|
||||
checkpointId: checkpoint.id,
|
||||
});
|
||||
}
|
||||
await atomicWriteFile(path.join(temporary, 'metadata.json'), metadata);
|
||||
await this.beforePublish?.(checkpoint);
|
||||
await rename(temporary, destination);
|
||||
return checkpoint;
|
||||
} catch (error) {
|
||||
const raced = await this.get({
|
||||
workspaceId: checkpoint.workspaceId,
|
||||
taskId: checkpoint.taskId,
|
||||
attemptId: checkpoint.attemptId,
|
||||
checkpointId: checkpoint.id,
|
||||
});
|
||||
if (raced?.captureRequestDigest === checkpoint.captureRequestDigest) return raced;
|
||||
throw error;
|
||||
} finally {
|
||||
await rm(temporary, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
private metadataPath(lookup: WorkspaceCheckpointLookup): string {
|
||||
return path.join(this.checkpointPath(lookup), 'metadata.json');
|
||||
}
|
||||
|
||||
private checkpointPath(lookup: WorkspaceCheckpointLookup): string {
|
||||
validateIdentifier(lookup.checkpointId, 'checkpointId');
|
||||
return ensureWithinBase(this.baseDir, path.join(this.attemptPath(lookup), lookup.checkpointId));
|
||||
}
|
||||
|
||||
private attemptPath(
|
||||
scope: Pick<WorkspaceCheckpointLookup, 'workspaceId' | 'taskId' | 'attemptId'>
|
||||
): string {
|
||||
validateScope(scope);
|
||||
return ensureWithinBase(
|
||||
this.baseDir,
|
||||
path.join(this.baseDir, scope.workspaceId, scope.taskId, scope.attemptId)
|
||||
);
|
||||
}
|
||||
|
||||
private blobPath(digest: string): string {
|
||||
const match = /^sha256:([a-f0-9]{64})$/.exec(digest);
|
||||
if (!match) throw new Error('Workspace checkpoint blob digest is invalid.');
|
||||
return ensureWithinBase(
|
||||
this.baseDir,
|
||||
path.join(this.baseDir, 'blobs', match[1].slice(0, 2), match[1])
|
||||
);
|
||||
}
|
||||
|
||||
private async preparePrivateDirectory(segments: string[]): Promise<string> {
|
||||
await mkdir(this.baseDir, { recursive: true, mode: 0o700 });
|
||||
await this.assertPrivateDirectory(this.baseDir);
|
||||
let current = this.baseDir;
|
||||
for (const segment of segments) {
|
||||
validateIdentifier(segment, 'storage path');
|
||||
const next = ensureWithinBase(this.baseDir, path.join(current, segment));
|
||||
await mkdir(next, { mode: 0o700 }).catch((error) => {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
|
||||
});
|
||||
await this.assertPrivateDirectory(next);
|
||||
current = next;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
private async assertPrivateDirectoryPath(
|
||||
directory: string,
|
||||
allowMissing = false
|
||||
): Promise<boolean> {
|
||||
const bounded = ensureWithinBase(this.baseDir, directory);
|
||||
const relative = path.relative(this.baseDir, bounded);
|
||||
let current = this.baseDir;
|
||||
try {
|
||||
await this.assertPrivateDirectory(current);
|
||||
} catch (error) {
|
||||
if (allowMissing && (error as NodeJS.ErrnoException).code === 'ENOENT') return false;
|
||||
throw error;
|
||||
}
|
||||
for (const segment of relative ? relative.split(path.sep) : []) {
|
||||
validateIdentifier(segment, 'storage path');
|
||||
current = ensureWithinBase(this.baseDir, path.join(current, segment));
|
||||
try {
|
||||
await this.assertPrivateDirectory(current);
|
||||
} catch (error) {
|
||||
if (allowMissing && (error as NodeJS.ErrnoException).code === 'ENOENT') return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private async assertPrivateDirectory(directory: string): Promise<void> {
|
||||
const stat = await lstat(directory);
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
||||
throw new ConflictError('Workspace checkpoint path is not a private regular directory.');
|
||||
}
|
||||
}
|
||||
|
||||
private async readBoundedRegularFile(
|
||||
filePath: string,
|
||||
maxBytes: number,
|
||||
message: string,
|
||||
allowMissing = false
|
||||
): Promise<Buffer | null> {
|
||||
let handle: Awaited<ReturnType<typeof open>> | undefined;
|
||||
try {
|
||||
handle = await open(filePath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
||||
const stat = await handle.stat();
|
||||
if (!stat.isFile() || stat.size > maxBytes) {
|
||||
throw new ConflictError(message);
|
||||
}
|
||||
return await handle.readFile();
|
||||
} catch (error) {
|
||||
if (allowMissing && (error as NodeJS.ErrnoException).code === 'ENOENT') return null;
|
||||
if ((error as NodeJS.ErrnoException).code === 'ELOOP') {
|
||||
throw new ConflictError(message, {
|
||||
cause: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
await handle?.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateCaptureInput(input: WorkspaceCheckpointCaptureInput): void {
|
||||
validateScope(input);
|
||||
validateOpaqueReference(input.operationId, 'operationId');
|
||||
for (const optional of [
|
||||
input.worktreeManifestId,
|
||||
input.parentCheckpointId,
|
||||
input.turnId,
|
||||
input.conversationCursor,
|
||||
]) {
|
||||
if (optional) validateOpaqueReference(optional, 'checkpoint reference');
|
||||
}
|
||||
if (
|
||||
![
|
||||
'before-user-turn',
|
||||
'before-compaction',
|
||||
'before-retry',
|
||||
'before-provider-handoff',
|
||||
'manual',
|
||||
].includes(input.boundary)
|
||||
) {
|
||||
throw new Error('Workspace checkpoint boundary is invalid.');
|
||||
}
|
||||
}
|
||||
|
||||
function validateScope(
|
||||
scope: Pick<WorkspaceCheckpointLookup, 'workspaceId' | 'taskId' | 'attemptId'>
|
||||
): void {
|
||||
validateIdentifier(scope.workspaceId, 'workspaceId');
|
||||
validateIdentifier(scope.taskId, 'taskId');
|
||||
validateIdentifier(scope.attemptId, 'attemptId');
|
||||
}
|
||||
|
||||
function validateIdentifier(value: string, label: string): void {
|
||||
if (!/^[A-Za-z0-9._:-]{1,240}$/.test(value)) {
|
||||
throw new Error(`Workspace checkpoint ${label} is invalid.`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateOpaqueReference(value: string, label: string): void {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.length > 2_048 ||
|
||||
value.trim().length === 0 ||
|
||||
value.includes('\0')
|
||||
) {
|
||||
throw new Error(`Workspace checkpoint ${label} is invalid.`);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePolicy(
|
||||
overrides: Partial<WorkspaceCheckpointPolicy> | undefined
|
||||
): WorkspaceCheckpointPolicy {
|
||||
const policy = { ...DEFAULT_POLICY, ...overrides };
|
||||
for (const key of ['maxFiles', 'maxBytes', 'maxFileBytes', 'maxExclusions'] as const) {
|
||||
if (!Number.isInteger(policy[key]) || policy[key] < 1 || policy[key] > MAX_POLICY[key]) {
|
||||
throw new Error(
|
||||
`Workspace checkpoint ${key} must be an integer between 1 and ${MAX_POLICY[key]}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
if (policy.maxFileBytes > policy.maxBytes) {
|
||||
throw new Error('Workspace checkpoint maxFileBytes cannot exceed maxBytes.');
|
||||
}
|
||||
return policy;
|
||||
}
|
||||
|
||||
function checkpointIdFor(
|
||||
workspaceId: string,
|
||||
taskId: string,
|
||||
attemptId: string,
|
||||
operationIdDigest: string
|
||||
): string {
|
||||
return `checkpoint_${digestRunLaunchValue({
|
||||
workspaceId,
|
||||
taskId,
|
||||
attemptId,
|
||||
operationIdDigest,
|
||||
})
|
||||
.slice('sha256:'.length)
|
||||
.slice(0, 24)}`;
|
||||
}
|
||||
|
||||
function mergeCandidates(
|
||||
tracked: string[],
|
||||
untracked: string[]
|
||||
): Array<{ path: string; source: WorkspaceCheckpointFileSource }> {
|
||||
const candidates = new Map<string, WorkspaceCheckpointFileSource>();
|
||||
for (const file of tracked) candidates.set(file, 'tracked');
|
||||
for (const file of untracked) {
|
||||
if (!candidates.has(file)) candidates.set(file, 'untracked');
|
||||
}
|
||||
return [...candidates.entries()]
|
||||
.map(([candidatePath, source]) => ({ path: candidatePath, source }))
|
||||
.sort((left, right) => left.path.localeCompare(right.path));
|
||||
}
|
||||
|
||||
function parseNullList(value: Buffer): string[] {
|
||||
return value.toString('utf8').split('\0').filter(Boolean);
|
||||
}
|
||||
|
||||
function optionalOutput(value: Buffer): string | null {
|
||||
const output = value.toString('utf8').trim();
|
||||
return output || null;
|
||||
}
|
||||
|
||||
function sensitivePath(relativePath: string): boolean {
|
||||
const normalized = relativePath.replaceAll('\\', '/').toLowerCase();
|
||||
const segments = normalized.split('/');
|
||||
const name = segments.at(-1) ?? '';
|
||||
if (segments.includes('.git') || segments.includes('.veritas-kanban')) return true;
|
||||
if (name === '.env' || (name.startsWith('.env.') && !/\.(?:example|sample)$/.test(name))) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
name === '.npmrc' ||
|
||||
name === '.netrc' ||
|
||||
name === 'credentials.json' ||
|
||||
name === 'secrets.json' ||
|
||||
/\.(?:pem|key|p12|pfx)$/.test(name)
|
||||
);
|
||||
}
|
||||
|
||||
function binaryContent(content: Buffer): boolean {
|
||||
if (content.subarray(0, 8_192).includes(0)) return true;
|
||||
try {
|
||||
new TextDecoder('utf-8', { fatal: true }).decode(content);
|
||||
return false;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function sha256(content: Uint8Array): string {
|
||||
return `sha256:${createHash('sha256').update(content).digest('hex')}`;
|
||||
}
|
||||
|
||||
async function defaultCommandRunner(
|
||||
command: string,
|
||||
args: string[],
|
||||
options: { cwd: string; maxBuffer: number }
|
||||
): Promise<WorkspaceCheckpointCommandResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(
|
||||
command,
|
||||
args,
|
||||
{
|
||||
cwd: options.cwd,
|
||||
encoding: 'buffer',
|
||||
maxBuffer: options.maxBuffer,
|
||||
windowsHide: true,
|
||||
},
|
||||
(error, stdout, stderr) => {
|
||||
if (error) {
|
||||
reject(
|
||||
new Error(
|
||||
`${command} ${args[0] ?? ''} failed: ${Buffer.from(stderr).toString('utf8').trim()}`,
|
||||
{ cause: error }
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
resolve({ stdout: Buffer.from(stdout), stderr: Buffer.from(stderr) });
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -69,6 +69,7 @@ export * from './runtime-hook.types.js';
|
|||
export * from './auth.types.js';
|
||||
export * from './credential-broker.types.js';
|
||||
export * from './worktree-manifest.types.js';
|
||||
export * from './workspace-checkpoint.types.js';
|
||||
export * from './run-approval.types.js';
|
||||
export * from './conversation-lifecycle.types.js';
|
||||
export * from './tool-control-plane.types.js';
|
||||
|
|
|
|||
91
shared/src/types/workspace-checkpoint.types.ts
Normal file
91
shared/src/types/workspace-checkpoint.types.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
export const WORKSPACE_CHECKPOINT_SCHEMA_VERSION = 'workspace-checkpoint/v1' as const;
|
||||
|
||||
export const WORKSPACE_CHECKPOINT_BOUNDARIES = [
|
||||
'before-user-turn',
|
||||
'before-compaction',
|
||||
'before-retry',
|
||||
'before-provider-handoff',
|
||||
'manual',
|
||||
] as const;
|
||||
|
||||
export const WORKSPACE_CHECKPOINT_EXCLUSION_REASONS = [
|
||||
'sensitive',
|
||||
'binary',
|
||||
'too-large',
|
||||
'symlink',
|
||||
'unsupported-file',
|
||||
'file-limit',
|
||||
'byte-limit',
|
||||
'read-failed',
|
||||
] as const;
|
||||
|
||||
export type WorkspaceCheckpointBoundary = (typeof WORKSPACE_CHECKPOINT_BOUNDARIES)[number];
|
||||
export type WorkspaceCheckpointExclusionReason =
|
||||
(typeof WORKSPACE_CHECKPOINT_EXCLUSION_REASONS)[number];
|
||||
export type WorkspaceCheckpointFileSource = 'tracked' | 'untracked';
|
||||
export type WorkspaceCheckpointFileState = 'present' | 'absent';
|
||||
|
||||
export interface WorkspaceCheckpointPolicy {
|
||||
ignoredFiles: 'excluded';
|
||||
sensitiveFiles: 'excluded';
|
||||
binaryFiles: 'excluded';
|
||||
symlinks: 'excluded';
|
||||
maxFiles: number;
|
||||
maxBytes: number;
|
||||
maxFileBytes: number;
|
||||
maxExclusions: number;
|
||||
}
|
||||
|
||||
export interface WorkspaceCheckpointFile {
|
||||
path: string;
|
||||
source: WorkspaceCheckpointFileSource;
|
||||
state: WorkspaceCheckpointFileState;
|
||||
mode?: number;
|
||||
size: number;
|
||||
contentDigest?: string;
|
||||
blobDigest?: string;
|
||||
}
|
||||
|
||||
export interface WorkspaceCheckpointExclusion {
|
||||
path: string;
|
||||
source: WorkspaceCheckpointFileSource;
|
||||
reason: WorkspaceCheckpointExclusionReason;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export interface WorkspaceCheckpointGitState {
|
||||
head: string | null;
|
||||
branch: string | null;
|
||||
indexDigest: string;
|
||||
indexBlobDigest: string;
|
||||
indexBytes: number;
|
||||
statusDigest: string;
|
||||
dirty: boolean;
|
||||
}
|
||||
|
||||
export interface WorkspaceCheckpoint {
|
||||
schemaVersion: typeof WORKSPACE_CHECKPOINT_SCHEMA_VERSION;
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
taskId: string;
|
||||
attemptId: string;
|
||||
boundary: WorkspaceCheckpointBoundary;
|
||||
operationIdDigest: string;
|
||||
captureRequestDigest: string;
|
||||
worktreeRootDigest: string;
|
||||
worktreeManifestId?: string;
|
||||
parentCheckpointId?: string;
|
||||
turnId?: string;
|
||||
conversationCursor?: string;
|
||||
git: WorkspaceCheckpointGitState;
|
||||
policy: WorkspaceCheckpointPolicy;
|
||||
files: WorkspaceCheckpointFile[];
|
||||
exclusions: WorkspaceCheckpointExclusion[];
|
||||
excludedCount: number;
|
||||
exclusionsTruncated: boolean;
|
||||
fileCount: number;
|
||||
contentBytes: number;
|
||||
storedBytes: number;
|
||||
createdAt: string;
|
||||
digest: string;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue