fix(agents): preserve worktree-local ignored state during lossless cleanup (#108401)

* fix(worktrees): preserve provisioned cleanup state

Co-authored-by: yetval <yetvald@gmail.com>

* refactor(worktrees): break provisioned registry cycle

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Yuval Dinodia
2026-07-16 10:06:25 -04:00
committed by GitHub
parent a1ed5e7b3c
commit af05f64f01
13 changed files with 1448 additions and 104 deletions
+2 -2
View File
@@ -30,7 +30,7 @@ Add `.worktreeinclude` at the source repository root to copy selected ignored, u
fixtures/generated/**
```
Only files reported by git as both ignored and untracked are eligible. Tracked files are already present through git and are never copied by this step. OpenClaw does not overwrite destination files or follow symlinked directories, and it preserves copied file modes.
Only files reported by git as both ignored and untracked are eligible. Tracked files are already present through git and are never copied by this step. OpenClaw does not overwrite or change destination files that already exist, does not follow symlinked directories, and preserves copied file modes. It records only paths it actually creates, so later manifest edits cannot make those files disappear from cleanup protection.
## Run repository setup
@@ -59,7 +59,7 @@ The resulting managed worktree is owned by the session, and every agent run in t
## Snapshots, cleanup, and restore
Removal first creates a synthetic commit containing tracked and non-ignored untracked files, and pins it at `refs/openclaw/snapshots/<id>`. Gitignored files are excluded from the repository object database; files selected by `.worktreeinclude` are copied again during restore. If snapshot creation fails, removal stops. An explicit force delete can continue without a snapshot.
Removal first creates a synthetic commit containing tracked and non-ignored untracked files, then pins it at `refs/openclaw/snapshots/<id>`. Ignored files never enter the repository object database. OpenClaw stores only the ignored files it actually provisioned in chunked shared-state database rows; the recorded path set remains authoritative even if `.worktreeinclude` later changes or disappears. Restore reads those bytes from the immutable snapshot and reapplies their complete modes. Automatic cleanup preserves a live worktree when a recorded path can no longer be snapshotted safely. If snapshot creation fails, removal stops. An explicit force delete can continue without a snapshot.
OpenClaw applies these cleanup rules:
+25 -3
View File
@@ -1,7 +1,7 @@
import { existsSync } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import { runCommandWithTimeout } from "../../process/exec.js";
import { runCommandBuffered, runCommandWithTimeout } from "../../process/exec.js";
const GIT_TIMEOUT_MS = 120_000;
@@ -19,7 +19,7 @@ type WorktreeListEntry = {
export async function runGit(
cwd: string,
args: string[],
options: { env?: NodeJS.ProcessEnv; input?: string } = {},
options: { env?: NodeJS.ProcessEnv; input?: string | Uint8Array } = {},
): Promise<GitResult> {
return await runCommandWithTimeout(["git", "-C", cwd, ...args], {
timeoutMs: GIT_TIMEOUT_MS,
@@ -36,7 +36,7 @@ export function commandError(command: string, result: GitResult): Error {
export async function requireGit(
cwd: string,
args: string[],
options: { env?: NodeJS.ProcessEnv; input?: string } = {},
options: { env?: NodeJS.ProcessEnv; input?: string | Uint8Array } = {},
): Promise<string> {
const result = await runGit(cwd, args, options);
if (result.code !== 0) {
@@ -53,6 +53,28 @@ export async function requireGitRaw(cwd: string, args: string[]): Promise<string
return result.stdout;
}
export async function requireGitBuffer(
cwd: string,
args: string[],
options: { env?: NodeJS.ProcessEnv; input?: Uint8Array } = {},
): Promise<Buffer> {
const result = await runCommandBuffered(["git", "-C", cwd, ...args], {
timeoutMs: GIT_TIMEOUT_MS,
env: options.env,
input: options.input,
});
if (result.code !== 0) {
const detail = (result.stderr.length > 0 ? result.stderr : result.stdout)
.toString("utf8")
.trim()
.split("\n")
.slice(-12)
.join("\n");
throw new Error(`git ${args.join(" ")} failed${detail ? `:\n${detail}` : ""}`);
}
return result.stdout;
}
function parseWorktreeList(output: string): WorktreeListEntry[] {
const entries: WorktreeListEntry[] = [];
let current: WorktreeListEntry | undefined;
+387
View File
@@ -0,0 +1,387 @@
import { constants as fsConstants } from "node:fs";
import fs, { type FileHandle } from "node:fs/promises";
import path from "node:path";
import { pathExists, requireGitRaw } from "./git.js";
import {
clearRegistryWorktreeProvisionedChunks,
getRegistryWorktreeProvisionedChunk,
insertRegistryWorktreeProvisionedChunk,
} from "./registry.js";
import type { ProvisionedFileState } from "./types.js";
function normalizeRelativePath(relativePath: string): string | undefined {
if (path.isAbsolute(relativePath)) {
return undefined;
}
const segments = relativePath.split("/");
if (
segments.length === 0 ||
segments.some((segment) => !segment || segment === "." || segment === "..")
) {
return undefined;
}
return segments.join("/");
}
function resolveGitPath(root: string, relativePath: string): string {
return path.join(root, ...relativePath.split("/"));
}
async function hasSafeParentDirectories(root: string, relativePath: string): Promise<boolean> {
const segments = relativePath.split("/");
let current = root;
for (const segment of segments.slice(0, -1)) {
current = path.join(current, segment);
try {
const stat = await fs.lstat(current);
if (stat.isSymbolicLink() || !stat.isDirectory()) {
return false;
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
throw error;
}
}
}
return true;
}
async function lstatIfExists(target: string) {
try {
return await fs.lstat(target);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return undefined;
}
throw error;
}
}
async function copyProvisionedFile(params: {
repoRoot: string;
worktreePath: string;
relativePath: string;
}): Promise<boolean> {
const normalized = normalizeRelativePath(params.relativePath);
if (
!normalized ||
!(await hasSafeParentDirectories(params.repoRoot, normalized)) ||
!(await hasSafeParentDirectories(params.worktreePath, normalized))
) {
return false;
}
const source = resolveGitPath(params.repoRoot, normalized);
const destination = resolveGitPath(params.worktreePath, normalized);
const sourceStat = await fs.lstat(source).catch(() => undefined);
if (!sourceStat?.isFile() || sourceStat.isSymbolicLink()) {
return false;
}
await fs.mkdir(path.dirname(destination), { recursive: true });
try {
await fs.copyFile(source, destination, fsConstants.COPYFILE_EXCL);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "EEXIST") {
// Existing checkout state is user-owned. Never mutate or claim it as provisioned.
return false;
}
throw error;
}
await fs.chmod(destination, sourceStat.mode);
return true;
}
/** Copies the current manifest matches and returns only paths this call actually created. */
export async function provisionIncludedFiles(
repoRoot: string,
worktreePath: string,
): Promise<string[]> {
const includePath = path.join(repoRoot, ".worktreeinclude");
if (!(await pathExists(includePath))) {
return [];
}
const candidatesRaw = await requireGitRaw(repoRoot, [
"ls-files",
"--others",
"--ignored",
"--exclude-standard",
"-z",
]);
const includedRaw = await requireGitRaw(repoRoot, [
"ls-files",
"--others",
"--ignored",
`--exclude-from=${includePath}`,
"-z",
]);
const included = new Set(includedRaw.split("\0").filter(Boolean));
const provisioned: string[] = [];
for (const relativePath of candidatesRaw.split("\0").filter(Boolean)) {
if (!included.has(relativePath)) {
continue;
}
const normalized = normalizeRelativePath(relativePath);
if (
normalized &&
(await copyProvisionedFile({ repoRoot, worktreePath, relativePath: normalized }))
) {
provisioned.push(normalized);
}
}
return provisioned.toSorted();
}
type ProvisionedFile = {
path: string;
target: string;
mode: number | null;
};
type DirectoryIdentity = {
path: string;
dev: number;
ino: number;
};
const SNAPSHOT_CHUNK_BYTES = 1024 * 1024;
async function captureParentDirectoryIdentities(
root: string,
relativePath: string,
): Promise<DirectoryIdentity[]> {
const segments = relativePath.split("/");
const directories = [root];
let current = root;
for (const segment of segments.slice(0, -1)) {
current = path.join(current, segment);
directories.push(current);
}
const identities: DirectoryIdentity[] = [];
for (const directory of directories) {
const stat = await fs.lstat(directory);
if (!stat.isDirectory() || stat.isSymbolicLink()) {
throw new Error(`unsafe provisioned parent directory: ${directory}`);
}
identities.push({ path: directory, dev: stat.dev, ino: stat.ino });
}
return identities;
}
async function validateDirectoryIdentities(identities: readonly DirectoryIdentity[]) {
for (const identity of identities) {
const stat = await fs.lstat(identity.path);
if (
!stat.isDirectory() ||
stat.isSymbolicLink() ||
stat.dev !== identity.dev ||
stat.ino !== identity.ino
) {
throw new Error(`provisioned parent directory changed: ${identity.path}`);
}
}
}
async function inspectProvisionedFiles(
worktreePath: string,
provisionedPaths: readonly string[] | undefined,
): Promise<ProvisionedFile[] | undefined> {
if (provisionedPaths === undefined) {
return undefined;
}
const files: ProvisionedFile[] = [];
for (const relativePath of provisionedPaths) {
const normalized = normalizeRelativePath(relativePath);
if (!normalized || !(await hasSafeParentDirectories(worktreePath, normalized))) {
throw new Error(`unsafe provisioned path: ${relativePath}`);
}
const target = resolveGitPath(worktreePath, normalized);
const stat = await lstatIfExists(target);
if (!stat) {
files.push({ path: normalized, target, mode: null });
continue;
}
if (!stat.isFile() || stat.isSymbolicLink()) {
throw new Error(`provisioned path is no longer a regular file: ${relativePath}`);
}
files.push({ path: normalized, target, mode: stat.mode & 0o7777 });
}
return files.toSorted((a, b) => a.path.localeCompare(b.path));
}
export async function hasUnsnapshotableProvisionedFiles(
worktreePath: string,
provisionedPaths: readonly string[] | undefined,
): Promise<boolean> {
try {
return (await inspectProvisionedFiles(worktreePath, provisionedPaths)) === undefined;
} catch {
return true;
}
}
function sameFileState(left: Awaited<ReturnType<FileHandle["stat"]>>, right: typeof left) {
return (
left.dev === right.dev &&
left.ino === right.ino &&
left.mode === right.mode &&
left.size === right.size &&
left.mtimeMs === right.mtimeMs &&
left.ctimeMs === right.ctimeMs
);
}
/** Stores provisioned bytes outside Git so ignored credentials never enter its object database. */
export async function snapshotProvisionedFiles(
env: NodeJS.ProcessEnv,
worktreeId: string,
worktreePath: string,
provisionedPaths: readonly string[] | undefined,
): Promise<ProvisionedFileState[]> {
const files = await inspectProvisionedFiles(worktreePath, provisionedPaths);
if (files === undefined) {
throw new Error("provisioned path ledger is unavailable");
}
const ignoredUntracked = new Set(
(
await requireGitRaw(worktreePath, [
"ls-files",
"--others",
"--ignored",
"--exclude-standard",
"-z",
])
)
.split("\0")
.filter(Boolean),
);
const currentTracked = new Set(
(await requireGitRaw(worktreePath, ["ls-files", "--cached", "-z"])).split("\0").filter(Boolean),
);
const trackedAtHead = new Set(
(await requireGitRaw(worktreePath, ["ls-tree", "-r", "--name-only", "-z", "HEAD"]))
.split("\0")
.filter(Boolean),
);
clearRegistryWorktreeProvisionedChunks(env, worktreeId);
const states: ProvisionedFileState[] = [];
try {
for (const file of files) {
if (file.mode === null) {
states.push({ path: file.path, mode: null, chunks: 0 });
continue;
}
if (currentTracked.has(file.path)) {
throw new Error(`provisioned path is now tracked: ${file.path}`);
}
if (trackedAtHead.has(file.path)) {
throw new Error(`provisioned path is tracked at HEAD: ${file.path}`);
}
if (!ignoredUntracked.has(file.path)) {
throw new Error(`provisioned path is no longer ignored: ${file.path}`);
}
const parentIdentities = await captureParentDirectoryIdentities(worktreePath, file.path);
const handle = await fs.open(
file.target,
fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0),
);
try {
await validateDirectoryIdentities(parentIdentities);
const before = await handle.stat();
const buffer = Buffer.allocUnsafe(SNAPSHOT_CHUNK_BYTES);
let chunkIndex = 0;
let offset = 0;
while (offset < before.size) {
const { bytesRead } = await handle.read(
buffer,
0,
Math.min(buffer.byteLength, before.size - offset),
offset,
);
if (bytesRead === 0) {
throw new Error(`provisioned file changed while snapshotting: ${file.path}`);
}
insertRegistryWorktreeProvisionedChunk(env, {
worktreeId,
path: file.path,
chunkIndex,
data: buffer.subarray(0, bytesRead),
});
offset += bytesRead;
chunkIndex += 1;
}
const [after, current] = await Promise.all([handle.stat(), fs.lstat(file.target)]);
await validateDirectoryIdentities(parentIdentities);
if (!sameFileState(before, after) || !sameFileState(before, current)) {
throw new Error(`provisioned file changed while snapshotting: ${file.path}`);
}
states.push({ path: file.path, mode: before.mode & 0o7777, chunks: chunkIndex });
} finally {
await handle.close();
}
}
return states;
} catch (error) {
clearRegistryWorktreeProvisionedChunks(env, worktreeId);
throw error;
}
}
async function writeAll(handle: FileHandle, data: Uint8Array): Promise<void> {
let offset = 0;
while (offset < data.byteLength) {
const { bytesWritten } = await handle.write(data, offset, data.byteLength - offset);
if (bytesWritten === 0) {
throw new Error("provisioned snapshot write made no progress");
}
offset += bytesWritten;
}
}
/** Restores provisioned bytes and modes from SQLite, never from the mutable source checkout. */
export async function restoreProvisionedFiles(
env: NodeJS.ProcessEnv,
worktreeId: string,
worktreePath: string,
states: readonly ProvisionedFileState[],
): Promise<void> {
for (const state of states) {
const normalized = normalizeRelativePath(state.path);
if (!normalized || !(await hasSafeParentDirectories(worktreePath, normalized))) {
throw new Error(`unsafe provisioned path: ${state.path}`);
}
const target = resolveGitPath(worktreePath, normalized);
if (state.mode === null) {
if (await lstatIfExists(target)) {
throw new Error(`snapshot expected provisioned path to be absent: ${state.path}`);
}
continue;
}
await fs.mkdir(path.dirname(target), { recursive: true });
const parentIdentities = await captureParentDirectoryIdentities(worktreePath, normalized);
const handle = await fs.open(
target,
fsConstants.O_CREAT |
fsConstants.O_EXCL |
fsConstants.O_WRONLY |
(fsConstants.O_NOFOLLOW ?? 0),
state.mode,
);
try {
await validateDirectoryIdentities(parentIdentities);
for (let chunkIndex = 0; chunkIndex < state.chunks; chunkIndex += 1) {
const chunk = getRegistryWorktreeProvisionedChunk(env, {
worktreeId,
path: state.path,
chunkIndex,
});
if (!chunk) {
throw new Error(`provisioned snapshot chunk missing: ${state.path}:${chunkIndex}`);
}
await writeAll(handle, chunk);
}
await handle.chmod(state.mode);
await validateDirectoryIdentities(parentIdentities);
} finally {
await handle.close();
}
}
}
+72 -2
View File
@@ -2,12 +2,21 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { closeOpenClawStateDatabaseForTest } from "../../state/openclaw-state-db.js";
import { requireNodeSqlite } from "../../infra/node-sqlite.js";
import {
closeOpenClawStateDatabaseForTest,
openOpenClawStateDatabase,
} from "../../state/openclaw-state-db.js";
import {
deleteRegistryWorktree,
getRegistryWorktreeProvisionedChunk,
findRegistryWorktreeByPath,
findLiveRegistryWorktreeByPath,
getRegistryWorktree,
getRegistryWorktreeProvisionedLedger,
getRegistryWorktreeProvisionedPaths,
getRegistryWorktreeProvisionedState,
insertRegistryWorktreeProvisionedChunk,
insertRegistryWorktree,
listRegistryWorktrees,
updateRegistryWorktree,
@@ -43,7 +52,7 @@ describe("managed worktree registry", () => {
createdAt: 10,
lastActiveAt: 10,
};
insertRegistryWorktree(env, record);
insertRegistryWorktree(env, record, { provisionedPaths: [".env.local"] });
insertRegistryWorktree(env, {
...record,
id: "second",
@@ -59,11 +68,15 @@ describe("managed worktree registry", () => {
ownerKind: "workboard",
ownerId: "card-1",
});
expect(getRegistryWorktreeProvisionedPaths(env, "first")).toEqual([".env.local"]);
expect(getRegistryWorktreeProvisionedPaths(env, "second")).toBeUndefined();
expect(getRegistryWorktreeProvisionedLedger(env, "second")).toEqual({ status: "legacy" });
updateRegistryWorktree(env, "first", {
lastActiveAt: 30,
removedAt: 40,
snapshotRef: "refs/openclaw/snapshots/first",
provisionedState: [{ path: ".env.local", mode: 0o600, chunks: 1 }],
});
expect(getRegistryWorktree(env, "first")).toMatchObject({
lastActiveAt: 30,
@@ -72,8 +85,65 @@ describe("managed worktree registry", () => {
});
expect(findLiveRegistryWorktreeByPath(env, record.path)).toBeUndefined();
expect(findRegistryWorktreeByPath(env, record.path)?.id).toBe("first");
expect(getRegistryWorktreeProvisionedPaths(env, "first")).toEqual([".env.local"]);
expect(getRegistryWorktreeProvisionedState(env, "first")).toEqual([
{ path: ".env.local", mode: 0o600, chunks: 1 },
]);
expect(getRegistryWorktreeProvisionedLedger(env, "first")).toEqual({
status: "valid",
paths: [".env.local"],
});
insertRegistryWorktreeProvisionedChunk(env, {
worktreeId: "first",
path: ".env.local",
chunkIndex: 0,
data: Buffer.from("snapshot"),
});
expect(
Buffer.from(
getRegistryWorktreeProvisionedChunk(env, {
worktreeId: "first",
path: ".env.local",
chunkIndex: 0,
})!,
).toString(),
).toBe("snapshot");
deleteRegistryWorktree(env, "first");
expect(getRegistryWorktree(env, "first")).toBeUndefined();
expect(
getRegistryWorktreeProvisionedChunk(env, {
worktreeId: "first",
path: ".env.local",
chunkIndex: 0,
}),
).toBeUndefined();
openOpenClawStateDatabase({ env })
.db.prepare("UPDATE worktrees SET provisioned_paths_json = ? WHERE id = ?")
.run("not-json", "second");
expect(getRegistryWorktreeProvisionedLedger(env, "second")).toEqual({ status: "invalid" });
});
it("adds the provisioned-path ledger to an existing worktree registry", () => {
const databasePath = openOpenClawStateDatabase({ env }).path;
closeOpenClawStateDatabaseForTest();
const { DatabaseSync } = requireNodeSqlite();
const legacy = new DatabaseSync(databasePath);
legacy.exec("ALTER TABLE worktrees DROP COLUMN provisioned_paths_json");
legacy.close();
expect(getRegistryWorktreeProvisionedPaths(env, "missing")).toBeUndefined();
const database = openOpenClawStateDatabase({ env }).db;
const columns = database.prepare("PRAGMA table_info(worktrees)").all() as Array<{
name?: unknown;
}>;
expect(columns.some((column) => column.name === "provisioned_paths_json")).toBe(true);
const chunkTable = database
.prepare(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'worktree_provisioned_file_chunks'",
)
.get();
expect(chunkTable).toBeTruthy();
});
});
+171 -4
View File
@@ -7,11 +7,19 @@ import {
openOpenClawStateDatabase,
runOpenClawStateWriteTransaction,
} from "../../state/openclaw-state-db.js";
import type { ManagedWorktreeOwnerKind, ManagedWorktreeRecord } from "./types.js";
import type {
ManagedWorktreeOwnerKind,
ManagedWorktreeRecord,
ProvisionedFileState,
} from "./types.js";
type WorktreesTable = OpenClawStateKyselyDatabase["worktrees"];
type WorktreeRow = Selectable<WorktreesTable>;
type WorktreeRegistryDatabase = Pick<OpenClawStateKyselyDatabase, "worktrees">;
type WorktreeProvisionedDatabase = Pick<
OpenClawStateKyselyDatabase,
"worktree_provisioned_file_chunks"
>;
type WorktreeLeaseDatabase = Pick<OpenClawStateKyselyDatabase, "worktrees" | "state_leases">;
function dbFor(env: NodeJS.ProcessEnv): DatabaseSync {
@@ -22,6 +30,10 @@ function kyselyFor(db: DatabaseSync) {
return getNodeSqliteKysely<WorktreeRegistryDatabase>(db);
}
function kyselyProvisionedFor(db: DatabaseSync) {
return getNodeSqliteKysely<WorktreeProvisionedDatabase>(db);
}
function kyselyLeaseFor(db: DatabaseSync) {
return getNodeSqliteKysely<WorktreeLeaseDatabase>(db);
}
@@ -44,7 +56,10 @@ function rowToRecord(row: WorktreeRow): ManagedWorktreeRecord {
};
}
function recordToRow(record: ManagedWorktreeRecord): Insertable<WorktreesTable> {
function recordToRow(
record: ManagedWorktreeRecord,
provisionedPaths: readonly string[] | undefined,
): Insertable<WorktreesTable> {
return {
id: record.id,
repo_fingerprint: record.repoFingerprint,
@@ -58,9 +73,42 @@ function recordToRow(record: ManagedWorktreeRecord): Insertable<WorktreesTable>
created_at: record.createdAt,
last_active_at: record.lastActiveAt,
removed_at: record.removedAt ?? null,
provisioned_paths_json:
provisionedPaths === undefined ? null : JSON.stringify(provisionedPaths),
};
}
function parseProvisionedData(
raw: string | null,
): Array<string | ProvisionedFileState> | undefined {
if (raw === null) {
return undefined;
}
try {
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) {
return undefined;
}
return parsed.every(
(entry) =>
typeof entry === "string" ||
(typeof entry === "object" &&
entry !== null &&
typeof (entry as ProvisionedFileState).path === "string" &&
((entry as ProvisionedFileState).mode === null ||
(Number.isInteger((entry as ProvisionedFileState).mode) &&
(entry as ProvisionedFileState).mode! >= 0 &&
(entry as ProvisionedFileState).mode! <= 0o7777)) &&
Number.isInteger((entry as ProvisionedFileState).chunks) &&
(entry as ProvisionedFileState).chunks >= 0),
)
? (parsed as Array<string | ProvisionedFileState>)
: undefined;
} catch {
return undefined;
}
}
export function listRegistryWorktrees(env: NodeJS.ProcessEnv): ManagedWorktreeRecord[] {
const db = dbFor(env);
const query = kyselyFor(db)
@@ -81,6 +129,107 @@ export function getRegistryWorktree(
return row ? rowToRecord(row) : undefined;
}
export function getRegistryWorktreeProvisionedPaths(
env: NodeJS.ProcessEnv,
id: string,
): string[] | undefined {
const ledger = getRegistryWorktreeProvisionedLedger(env, id);
return ledger.status === "valid" ? ledger.paths : undefined;
}
export function getRegistryWorktreeProvisionedLedger(
env: NodeJS.ProcessEnv,
id: string,
): { status: "legacy" } | { status: "invalid" } | { status: "valid"; paths: string[] } {
const db = dbFor(env);
const query = kyselyFor(db)
.selectFrom("worktrees")
.select("provisioned_paths_json")
.where("id", "=", id);
const row = executeSqliteQuerySync(db, query).rows[0];
if (!row) {
return { status: "invalid" };
}
if (row.provisioned_paths_json === null) {
return { status: "legacy" };
}
const data = parseProvisionedData(row.provisioned_paths_json);
return data
? {
status: "valid",
paths: data.map((entry) => (typeof entry === "string" ? entry : entry.path)),
}
: { status: "invalid" };
}
export function getRegistryWorktreeProvisionedState(
env: NodeJS.ProcessEnv,
id: string,
): ProvisionedFileState[] | undefined {
const db = dbFor(env);
const query = kyselyFor(db)
.selectFrom("worktrees")
.select("provisioned_paths_json")
.where("id", "=", id);
const row = executeSqliteQuerySync(db, query).rows[0];
const data = row ? parseProvisionedData(row.provisioned_paths_json) : undefined;
return data?.every((entry): entry is ProvisionedFileState => typeof entry !== "string")
? data
: undefined;
}
export function clearRegistryWorktreeProvisionedChunks(
env: NodeJS.ProcessEnv,
worktreeId: string,
): void {
const db = dbFor(env);
runOpenClawStateWriteTransaction(() => {
executeSqliteQuerySync(
db,
kyselyProvisionedFor(db)
.deleteFrom("worktree_provisioned_file_chunks")
.where("worktree_id", "=", worktreeId),
);
});
}
export function insertRegistryWorktreeProvisionedChunk(
env: NodeJS.ProcessEnv,
params: {
worktreeId: string;
path: string;
chunkIndex: number;
data: Uint8Array;
},
): void {
const db = dbFor(env);
runOpenClawStateWriteTransaction(() => {
executeSqliteQuerySync(
db,
kyselyProvisionedFor(db).insertInto("worktree_provisioned_file_chunks").values({
worktree_id: params.worktreeId,
path: params.path,
chunk_index: params.chunkIndex,
data: params.data,
}),
);
});
}
export function getRegistryWorktreeProvisionedChunk(
env: NodeJS.ProcessEnv,
params: { worktreeId: string; path: string; chunkIndex: number },
): Uint8Array | undefined {
const db = dbFor(env);
const query = kyselyProvisionedFor(db)
.selectFrom("worktree_provisioned_file_chunks")
.select("data")
.where("worktree_id", "=", params.worktreeId)
.where("path", "=", params.path)
.where("chunk_index", "=", params.chunkIndex);
return executeSqliteQuerySync(db, query).rows[0]?.data;
}
export function findLiveRegistryWorktreeByPath(
env: NodeJS.ProcessEnv,
worktreePath: string,
@@ -133,17 +282,24 @@ export function findRegistryWorktreeByPath(
export function insertRegistryWorktree(
env: NodeJS.ProcessEnv,
record: ManagedWorktreeRecord,
options: { provisionedPaths?: readonly string[] } = {},
): void {
const db = dbFor(env);
runOpenClawStateWriteTransaction(() => {
executeSqliteQuerySync(db, kyselyFor(db).insertInto("worktrees").values(recordToRow(record)));
executeSqliteQuerySync(
db,
kyselyFor(db).insertInto("worktrees").values(recordToRow(record, options.provisionedPaths)),
);
});
}
export function updateRegistryWorktree(
env: NodeJS.ProcessEnv,
id: string,
patch: Partial<Pick<ManagedWorktreeRecord, "lastActiveAt" | "removedAt" | "snapshotRef">>,
patch: Partial<Pick<ManagedWorktreeRecord, "lastActiveAt" | "removedAt" | "snapshotRef">> & {
provisionedPaths?: readonly string[];
provisionedState?: readonly ProvisionedFileState[];
},
): void {
const db = dbFor(env);
const values: Partial<WorktreeRow> = {};
@@ -156,6 +312,11 @@ export function updateRegistryWorktree(
if ("snapshotRef" in patch) {
values.snapshot_ref = patch.snapshotRef ?? null;
}
if (patch.provisionedState !== undefined) {
values.provisioned_paths_json = JSON.stringify(patch.provisionedState);
} else if (patch.provisionedPaths !== undefined) {
values.provisioned_paths_json = JSON.stringify(patch.provisionedPaths);
}
runOpenClawStateWriteTransaction(() => {
executeSqliteQuerySync(
db,
@@ -167,6 +328,12 @@ export function updateRegistryWorktree(
export function deleteRegistryWorktree(env: NodeJS.ProcessEnv, id: string): void {
const db = dbFor(env);
runOpenClawStateWriteTransaction(() => {
executeSqliteQuerySync(
db,
kyselyProvisionedFor(db)
.deleteFrom("worktree_provisioned_file_chunks")
.where("worktree_id", "=", id),
);
executeSqliteQuerySync(db, kyselyFor(db).deleteFrom("worktrees").where("id", "=", id));
});
}
@@ -0,0 +1,300 @@
import { execFile } from "node:child_process";
import { constants as fsConstants } from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { closeOpenClawStateDatabaseForTest } from "../../state/openclaw-state-db.js";
import {
deleteRegistryWorktree,
getRegistryWorktree,
getRegistryWorktreeProvisionedPaths,
insertRegistryWorktree,
} from "./registry.js";
import { ManagedWorktreeService } from "./service.js";
const execFileAsync = promisify(execFile);
async function git(cwd: string, ...args: string[]): Promise<string> {
const { stdout } = await execFileAsync("git", ["-C", cwd, ...args], { encoding: "utf8" });
return stdout.trim();
}
async function initializeRepository(root: string, gitTemplate: string): Promise<string> {
const repo = path.join(root, "repo");
await fs.mkdir(repo, { recursive: true });
await git(repo, "init", "-b", "main", `--template=${gitTemplate}`);
await git(repo, "config", "user.name", "OpenClaw Test");
await git(repo, "config", "user.email", "openclaw-test@example.invalid");
await fs.writeFile(path.join(repo, "README.md"), "base\n");
await git(repo, "add", "README.md");
await git(repo, "commit", "-m", "initial");
return await fs.realpath(repo);
}
async function addRemote(root: string, repo: string): Promise<void> {
const remote = path.join(root, "remote.git");
await execFileAsync("git", ["clone", "--bare", repo, remote]);
await git(repo, "remote", "add", "origin", remote);
await git(repo, "push", "-u", "origin", "main");
await git(repo, "remote", "set-head", "origin", "-a");
}
describe("ManagedWorktreeService provisioned state", () => {
let templateRoot: string;
let templateRepo: string;
let gitTemplate: string;
let root: string;
let repo: string;
let env: NodeJS.ProcessEnv;
let now: number;
let service: ManagedWorktreeService;
beforeAll(async () => {
const tempRoot = await fs.realpath(os.tmpdir());
templateRoot = await fs.mkdtemp(path.join(tempRoot, "openclaw-worktree-state-template-"));
gitTemplate = path.join(templateRoot, "git-template");
await fs.mkdir(path.join(gitTemplate, "hooks"), { recursive: true });
templateRepo = await initializeRepository(templateRoot, gitTemplate);
});
afterAll(async () => {
await fs.rm(templateRoot, { recursive: true, force: true });
});
beforeEach(async () => {
const tempRoot = await fs.realpath(os.tmpdir());
root = await fs.mkdtemp(path.join(tempRoot, "openclaw-worktree-state-"));
repo = path.join(root, "repo");
await fs.cp(templateRepo, repo, { mode: fsConstants.COPYFILE_FICLONE, recursive: true });
repo = await fs.realpath(repo);
env = { ...process.env, OPENCLAW_STATE_DIR: path.join(root, "openclaw-state") };
now = 1_700_000_000_000;
service = new ManagedWorktreeService({ env, now: () => now });
});
afterEach(async () => {
closeOpenClawStateDatabaseForTest();
await fs.rm(root, { recursive: true, force: true });
});
it("snapshots large provisioned files without buffering them in the service", async () => {
await fs.writeFile(path.join(repo, ".gitignore"), "large.local\n");
await fs.writeFile(path.join(repo, ".worktreeinclude"), "large.local\n");
await git(repo, "add", ".gitignore", ".worktreeinclude");
await git(repo, "commit", "-m", "configure worktree provisioning");
const source = Buffer.alloc(2 * 1024 * 1024, 0x61);
await fs.writeFile(path.join(repo, "large.local"), source);
await addRemote(root, repo);
const created = await service.create({ repoRoot: repo, name: "large-local" });
await service.acquire(created.id);
const copyPath = path.join(created.path, "large.local");
const copy = Buffer.from(source);
copy[copy.length - 1] = 0x62;
await fs.writeFile(copyPath, copy);
expect(await service.removeIfLossless(created.id)).toBe(true);
await fs.writeFile(path.join(repo, "large.local"), Buffer.from("new source"));
const restored = await service.restore({ id: created.id });
expect((await fs.readFile(path.join(restored.path, "large.local"))).at(-1)).toBe(0x62);
});
it("keeps provisioned files protected after manifest removal or pattern changes", async () => {
await fs.writeFile(path.join(repo, ".gitignore"), ".env.local\nsettings.local\n");
await fs.writeFile(path.join(repo, ".worktreeinclude"), ".env.local\nsettings.local\n");
await git(repo, "add", ".gitignore", ".worktreeinclude");
await git(repo, "commit", "-m", "configure worktree provisioning");
await fs.writeFile(path.join(repo, ".env.local"), "value=source\n");
await fs.writeFile(path.join(repo, "settings.local"), "theme=source\n");
await addRemote(root, repo);
const manifestRemoved = await service.create({ repoRoot: repo, name: "manifest-removed" });
const patternRemoved = await service.create({ repoRoot: repo, name: "pattern-removed" });
const restorable = await service.create({ repoRoot: repo, name: "manifest-restorable" });
await service.acquire(manifestRemoved.id);
await service.acquire(patternRemoved.id);
await service.acquire(restorable.id);
await fs.rm(path.join(repo, ".worktreeinclude"));
await fs.writeFile(path.join(manifestRemoved.path, ".env.local"), "value=rotated\n");
expect(await service.removeIfLossless(manifestRemoved.id)).toBe(true);
const restoredManifest = await service.restore({ id: manifestRemoved.id });
expect(await fs.readFile(path.join(restoredManifest.path, ".env.local"), "utf8")).toBe(
"value=rotated\n",
);
await fs.writeFile(path.join(repo, ".worktreeinclude"), "settings.local\n");
await fs.writeFile(path.join(patternRemoved.path, ".env.local"), "value=pattern-rotated\n");
expect(await service.removeIfLossless(patternRemoved.id)).toBe(true);
const restoredPattern = await service.restore({ id: patternRemoved.id });
expect(await fs.readFile(path.join(restoredPattern.path, ".env.local"), "utf8")).toBe(
"value=pattern-rotated\n",
);
await fs.rm(path.join(repo, ".worktreeinclude"));
expect(await service.removeIfLossless(restorable.id)).toBe(true);
const restored = await service.restore({ id: restorable.id });
expect(await fs.readFile(path.join(restored.path, ".env.local"), "utf8")).toBe(
"value=source\n",
);
expect(await fs.readFile(path.join(restored.path, "settings.local"), "utf8")).toBe(
"theme=source\n",
);
});
it("fails closed for pre-ledger worktrees whose ignored state is unknown", async () => {
await fs.writeFile(path.join(repo, ".gitignore"), ".env.local\n");
await git(repo, "add", ".gitignore");
await git(repo, "commit", "-m", "ignore local environment");
await addRemote(root, repo);
const legacyPath = path.join(root, "legacy-worktree");
await git(repo, "worktree", "add", "-b", "openclaw/legacy", legacyPath, "HEAD");
insertRegistryWorktree(env, {
id: "legacy",
name: "legacy",
repoFingerprint: "legacy-fingerprint",
repoRoot: repo,
path: legacyPath,
branch: "openclaw/legacy",
baseRef: "HEAD",
ownerKind: "session",
createdAt: now,
lastActiveAt: now,
});
await fs.writeFile(path.join(legacyPath, ".env.local"), "unknown-user-state\n");
expect(await git(legacyPath, "status", "--porcelain")).toBe("");
expect(await service.removeIfLossless("legacy")).toBe(false);
await expect(service.remove({ id: "legacy", reason: "manual" })).rejects.toThrow(
"provisioned path ledger is unavailable",
);
expect(await fs.readFile(path.join(legacyPath, ".env.local"), "utf8")).toBe(
"unknown-user-state\n",
);
});
it("fails closed when a provisioned path becomes tracked or unignored", async () => {
await fs.writeFile(path.join(repo, ".gitignore"), ".env.local\n");
await fs.writeFile(path.join(repo, ".worktreeinclude"), ".env.local\n");
await git(repo, "add", ".gitignore", ".worktreeinclude");
await git(repo, "commit", "-m", "configure worktree provisioning");
await fs.writeFile(path.join(repo, ".env.local"), "value=source\n");
const tracked = await service.create({ repoRoot: repo, name: "tracked-provisioned" });
await git(tracked.path, "add", "-f", ".env.local");
await git(tracked.path, "commit", "-m", "track provisioned file");
await expect(service.remove({ id: tracked.id, reason: "manual" })).rejects.toThrow(
"provisioned path is now tracked",
);
await git(tracked.path, "rm", "--cached", ".env.local");
await expect(service.remove({ id: tracked.id, reason: "manual" })).rejects.toThrow(
"provisioned path is tracked at HEAD",
);
const unignored = await service.create({ repoRoot: repo, name: "unignored-provisioned" });
await fs.writeFile(path.join(unignored.path, ".gitignore"), "");
await expect(service.remove({ id: unignored.id, reason: "manual" })).rejects.toThrow(
"provisioned path is no longer ignored",
);
expect(await fs.readFile(path.join(unignored.path, ".env.local"), "utf8")).toBe(
"value=source\n",
);
});
it.skipIf(process.platform === "win32")(
"round trips literal pathspec characters and POSIX backslashes",
async () => {
const wildcardName = "literal*.local";
const backslashName = "foo\\bar.local";
await fs.writeFile(path.join(repo, "literal-one.local"), "tracked\n");
await fs.writeFile(path.join(repo, ".gitignore"), "literal*.local\nfoo\\\\bar.local\n");
await fs.writeFile(path.join(repo, ".worktreeinclude"), "literal*.local\nfoo\\\\bar.local\n");
await git(repo, "add", ".gitignore", ".worktreeinclude");
await git(repo, "add", "-f", "literal-one.local");
await git(repo, "commit", "-m", "configure literal worktree provisioning");
await fs.writeFile(path.join(repo, wildcardName), "wildcard source\n");
await fs.writeFile(path.join(repo, backslashName), "backslash source\n");
const created = await service.create({ repoRoot: repo, name: "literal-paths" });
await fs.writeFile(path.join(created.path, wildcardName), "wildcard local\n");
await fs.writeFile(path.join(created.path, backslashName), "backslash local\n");
await service.remove({ id: created.id, reason: "test" });
const restored = await service.restore({ id: created.id });
expect(await fs.readFile(path.join(restored.path, wildcardName), "utf8")).toBe(
"wildcard local\n",
);
expect(await fs.readFile(path.join(restored.path, backslashName), "utf8")).toBe(
"backslash local\n",
);
},
);
it("upgrades the provisioned ledger when restoring a pre-ledger snapshot", async () => {
await fs.writeFile(path.join(repo, ".gitignore"), ".env.local\n");
await fs.writeFile(path.join(repo, ".worktreeinclude"), ".env.local\n");
await git(repo, "add", ".gitignore", ".worktreeinclude");
await git(repo, "commit", "-m", "configure worktree provisioning");
await fs.writeFile(path.join(repo, ".env.local"), "value=source\n");
await addRemote(root, repo);
const created = await service.create({ repoRoot: repo, name: "legacy-restore" });
await service.remove({ id: created.id, reason: "test" });
const removed = getRegistryWorktree(env, created.id)!;
deleteRegistryWorktree(env, created.id);
insertRegistryWorktree(env, removed);
const restored = await service.restore({ id: created.id });
expect(getRegistryWorktreeProvisionedPaths(env, created.id)).toEqual([".env.local"]);
await fs.writeFile(path.join(restored.path, ".env.local"), "value=restored-local\n");
expect(await service.removeIfLossless(created.id)).toBe(true);
const roundTripped = await service.restore({ id: created.id });
expect(await fs.readFile(path.join(roundTripped.path, ".env.local"), "utf8")).toBe(
"value=restored-local\n",
);
});
it("snapshots deleted skip-worktree files still included by sparse rules", async () => {
const created = await service.create({ repoRoot: repo, name: "stale-sparse-bit" });
await git(created.path, "sparse-checkout", "set", "--no-cone", "/*");
await git(created.path, "update-index", "--skip-worktree", "README.md");
await fs.rm(path.join(created.path, "README.md"));
await service.remove({ id: created.id, reason: "test" });
const restored = await service.restore({ id: created.id });
await expect(fs.stat(path.join(restored.path, "README.md"))).rejects.toMatchObject({
code: "ENOENT",
});
});
it.skipIf(process.platform !== "linux")(
"snapshots non-UTF-8 Git paths byte-for-byte",
async () => {
const rawName = Buffer.from([0x72, 0x61, 0x77, 0xff]);
const sourcePath = Buffer.concat([Buffer.from(repo), Buffer.from(path.sep), rawName]);
await fs.writeFile(sourcePath, "source\n");
await git(repo, "add", "-A");
await git(repo, "commit", "-m", "add raw path");
const created = await service.create({ repoRoot: repo, name: "raw-path" });
const worktreePath = Buffer.concat([
Buffer.from(created.path),
Buffer.from(path.sep),
rawName,
]);
await fs.writeFile(worktreePath, "local bytes\n");
await service.remove({ id: created.id, reason: "test" });
const restored = await service.restore({ id: created.id });
const restoredPath = Buffer.concat([
Buffer.from(restored.path),
Buffer.from(path.sep),
rawName,
]);
expect(await fs.readFile(restoredPath, "utf8")).toBe("local bytes\n");
},
);
});
+228 -11
View File
@@ -6,7 +6,12 @@ import path from "node:path";
import { promisify } from "node:util";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { closeOpenClawStateDatabaseForTest } from "../../state/openclaw-state-db.js";
import { getRegistryWorktree } from "./registry.js";
import {
getRegistryWorktree,
getRegistryWorktreeProvisionedChunk,
getRegistryWorktreeProvisionedPaths,
getRegistryWorktreeProvisionedState,
} from "./registry.js";
import {
IDLE_GC_MS,
ManagedWorktreeService,
@@ -409,17 +414,18 @@ describe("ManagedWorktreeService", () => {
await fs.writeFile(path.join(repo, "cache", "keep.txt"), "keep\n", { mode: 0o744 });
await fs.writeFile(path.join(repo, "cache", "skip.bin"), "skip\n");
const outside = path.join(root, "outside.txt");
await fs.writeFile(outside, "secret\n");
await fs.writeFile(outside, "outside\n");
await fs.symlink(outside, path.join(repo, "linked"));
const outsideDir = path.join(root, "outside-dir");
await fs.mkdir(outsideDir);
await fs.writeFile(path.join(outsideDir, "escape.txt"), "secret\n");
await fs.writeFile(path.join(outsideDir, "escape.txt"), "outside\n");
await fs.symlink(outsideDir, path.join(repo, "linked-dir"));
const created = await service.create({ repoRoot: repo, name: "includes" });
const copied = path.join(created.path, "cache", "keep.txt");
expect(await fs.readFile(copied, "utf8")).toBe("keep\n");
expect((await fs.stat(copied)).mode & 0o777).toBe(0o744);
expect(getRegistryWorktreeProvisionedPaths(env, created.id)).toEqual(["cache/keep.txt"]);
await expect(fs.stat(path.join(created.path, "cache", "skip.bin"))).rejects.toMatchObject({
code: "ENOENT",
});
@@ -432,7 +438,7 @@ describe("ManagedWorktreeService", () => {
});
it("never overwrites a base-ref file with an ignored source candidate", async () => {
await fs.writeFile(path.join(repo, "collision.txt"), "from base\n");
await fs.writeFile(path.join(repo, "collision.txt"), "from base\n", { mode: 0o644 });
await git(repo, "add", "collision.txt");
await git(repo, "commit", "-m", "base collision");
await git(repo, "checkout", "-b", "source");
@@ -440,7 +446,7 @@ describe("ManagedWorktreeService", () => {
await fs.writeFile(path.join(repo, ".gitignore"), "collision.txt\n");
await git(repo, "add", ".gitignore");
await git(repo, "commit", "-m", "ignore local collision");
await fs.writeFile(path.join(repo, "collision.txt"), "from source\n");
await fs.writeFile(path.join(repo, "collision.txt"), "from source\n", { mode: 0o755 });
await fs.writeFile(path.join(repo, ".worktreeinclude"), "collision.txt\n");
const created = await service.create({
@@ -450,6 +456,10 @@ describe("ManagedWorktreeService", () => {
});
expect(await fs.readFile(path.join(created.path, "collision.txt"), "utf8")).toBe("from base\n");
if (process.platform !== "win32") {
expect((await fs.stat(path.join(created.path, "collision.txt"))).mode & 0o777).toBe(0o644);
}
expect(getRegistryWorktreeProvisionedPaths(env, created.id)).toEqual([]);
});
it("runs an executable setup script with source and worktree paths", async () => {
@@ -498,12 +508,12 @@ describe("ManagedWorktreeService", () => {
expect(await git(repo, "branch", "--list", "openclaw/broken-setup")).toBe("");
});
it("restores tracked and untracked state while reprovisioning ignored files", async () => {
it("restores tracked, untracked, and provisioned ignored files from the snapshot", async () => {
await fs.writeFile(path.join(repo, ".gitignore"), "ignored.txt\nprovisioned.env\n");
await fs.writeFile(path.join(repo, ".worktreeinclude"), "provisioned.env\n");
await git(repo, "add", ".gitignore", ".worktreeinclude");
await git(repo, "commit", "-m", "configure worktree provisioning");
await fs.writeFile(path.join(repo, "provisioned.env"), "source secret\n");
await fs.writeFile(path.join(repo, "provisioned.env"), "source value\n");
const created = await service.create({ repoRoot: repo, name: "roundtrip" });
const originalHead = await git(created.path, "rev-parse", "HEAD");
await fs.writeFile(path.join(created.path, "README.md"), "changed\n");
@@ -514,9 +524,12 @@ describe("ManagedWorktreeService", () => {
expect(removed).toMatchObject({ removed: true, snapshotRef: expect.any(String) });
await expect(fs.stat(created.path)).rejects.toMatchObject({ code: "ENOENT" });
expect(await git(repo, "show-ref", "--verify", removed.snapshotRef!)).not.toBe("");
const provisionedState = getRegistryWorktreeProvisionedState(env, created.id)!;
expect(provisionedState).toEqual([{ path: "provisioned.env", mode: 0o644, chunks: 1 }]);
const snapshotFiles = await git(repo, "ls-tree", "-r", "--name-only", removed.snapshotRef!);
expect(snapshotFiles).not.toContain("ignored.txt");
expect(snapshotFiles).not.toContain("provisioned.env");
await fs.writeFile(path.join(repo, "provisioned.env"), "new source value\n");
now += IDLE_GC_MS + 1;
const restored = await service.restore({ id: created.id });
@@ -533,8 +546,15 @@ describe("ManagedWorktreeService", () => {
"untracked\n",
);
expect(await fs.readFile(path.join(restored.path, "provisioned.env"), "utf8")).toBe(
"source secret\n",
"source value\n",
);
expect(
getRegistryWorktreeProvisionedChunk(env, {
worktreeId: created.id,
path: "provisioned.env",
chunkIndex: 0,
}),
).toBeDefined();
await expect(fs.stat(path.join(restored.path, "ignored.txt"))).rejects.toMatchObject({
code: "ENOENT",
});
@@ -546,6 +566,64 @@ describe("ManagedWorktreeService", () => {
expect(await git(restored.path, "diff", "--name-only")).toBe("README.md");
});
it("captures tracked executable-bit changes when core.filemode is disabled", async () => {
const script = path.join(repo, "tool.sh");
await fs.writeFile(script, "#!/bin/sh\n", { mode: 0o755 });
await git(repo, "add", "tool.sh");
await git(repo, "update-index", "--chmod=+x", "tool.sh");
await git(repo, "commit", "-m", "add executable");
await git(repo, "config", "core.filemode", "false");
const created = await service.create({ repoRoot: repo, name: "filemode" });
await fs.chmod(path.join(created.path, "tool.sh"), 0o644);
await fs.writeFile(path.join(created.path, "README.md"), "changed\n");
const removed = await service.remove({ id: created.id, reason: "test" });
expect(await git(repo, "ls-tree", removed.snapshotRef!, "tool.sh")).toMatch(/^100644 /);
});
it("snapshots modified tracked files marked assume-unchanged", async () => {
const created = await service.create({ repoRoot: repo, name: "assume-unchanged" });
await git(created.path, "update-index", "--assume-unchanged", "README.md");
await fs.writeFile(path.join(created.path, "README.md"), "hidden local change\n");
expect(await git(created.path, "status", "--porcelain")).toBe("");
await service.remove({ id: created.id, reason: "test" });
const restored = await service.restore({ id: created.id });
expect(await fs.readFile(path.join(restored.path, "README.md"), "utf8")).toBe(
"hidden local change\n",
);
});
it("snapshots materialized tracked files marked skip-worktree", async () => {
const created = await service.create({ repoRoot: repo, name: "skip-worktree" });
await git(created.path, "update-index", "--skip-worktree", "README.md");
await fs.writeFile(path.join(created.path, "README.md"), "hidden sparse change\n");
expect(await git(created.path, "status", "--porcelain")).toBe("");
await service.remove({ id: created.id, reason: "test" });
const restored = await service.restore({ id: created.id });
expect(await fs.readFile(path.join(restored.path, "README.md"), "utf8")).toBe(
"hidden sparse change\n",
);
});
it("snapshots deletions hidden by skip-worktree outside sparse checkout", async () => {
const created = await service.create({ repoRoot: repo, name: "skip-worktree-deleted" });
await git(created.path, "update-index", "--skip-worktree", "README.md");
await fs.rm(path.join(created.path, "README.md"));
expect(await git(created.path, "status", "--porcelain")).toBe("");
await service.remove({ id: created.id, reason: "test" });
const restored = await service.restore({ id: created.id });
await expect(fs.stat(path.join(restored.path, "README.md"))).rejects.toMatchObject({
code: "ENOENT",
});
});
it("refuses to overwrite a branch recreated before restore", async () => {
const created = await service.create({ repoRoot: repo, name: "restore-collision" });
await service.remove({ id: created.id, reason: "test" });
@@ -559,18 +637,38 @@ describe("ManagedWorktreeService", () => {
});
it("fails closed when a nested repository cannot be captured in full", async () => {
await fs.writeFile(path.join(repo, ".gitignore"), ".env.local\n");
await fs.writeFile(path.join(repo, ".worktreeinclude"), ".env.local\n");
await git(repo, "add", ".gitignore", ".worktreeinclude");
await git(repo, "commit", "-m", "configure worktree provisioning");
await fs.writeFile(path.join(repo, ".env.local"), "value=source\n");
await fs.mkdir(path.join(repo, "tracked"));
await fs.writeFile(path.join(repo, "tracked", "outer.txt"), "tracked\n");
await git(repo, "add", "tracked/outer.txt");
await git(repo, "commit", "-m", "add tracked parent");
const created = await service.create({ repoRoot: repo, name: "nested-repository" });
const nested = await initializeRepository(created.path, gitTemplate, "nested");
await fs.writeFile(path.join(nested, "untracked-secret.txt"), "do not lose\n");
const nested = await initializeRepository(
path.join(created.path, "tracked"),
gitTemplate,
"nested",
);
await fs.writeFile(path.join(nested, "untracked-local.txt"), "do not lose\n");
await expect(service.remove({ id: created.id, reason: "test" })).rejects.toThrow(
"nested git repositories cannot be snapshotted losslessly",
);
expect(await fs.readFile(path.join(nested, "untracked-secret.txt"), "utf8")).toBe(
expect(await fs.readFile(path.join(nested, "untracked-local.txt"), "utf8")).toBe(
"do not lose\n",
);
expect(getRegistryWorktree(env, created.id)?.removedAt).toBeUndefined();
expect(
getRegistryWorktreeProvisionedChunk(env, {
worktreeId: created.id,
path: ".env.local",
chunkIndex: 0,
}),
).toBeUndefined();
});
it("rematerializes a named workboard checkout from its retained snapshot", async () => {
@@ -619,6 +717,101 @@ describe("ManagedWorktreeService", () => {
expect(await service.removeIfLossless(committed.id)).toBe(false);
});
it("snapshots provisioned ignored file state independently of the source", async () => {
await fs.writeFile(path.join(repo, ".gitignore"), ".env.local\nnode_modules/\n");
await fs.writeFile(path.join(repo, ".worktreeinclude"), ".env.local\n");
await git(repo, "add", ".gitignore", ".worktreeinclude");
await git(repo, "commit", "-m", "configure worktree provisioning");
await fs.writeFile(path.join(repo, ".env.local"), "value=old-source\n");
await addRemote(root, repo);
const rotated = await service.create({ repoRoot: repo, name: "rotated-local" });
await service.acquire(rotated.id);
expect(await fs.readFile(path.join(rotated.path, ".env.local"), "utf8")).toBe(
"value=old-source\n",
);
await fs.writeFile(path.join(rotated.path, ".env.local"), "value=rotated-only-copy\n");
expect(await service.removeIfLossless(rotated.id)).toBe(true);
await fs.writeFile(path.join(repo, ".env.local"), "value=newer-source\n");
const restoredRotated = await service.restore({ id: rotated.id });
expect(await fs.readFile(path.join(restoredRotated.path, ".env.local"), "utf8")).toBe(
"value=rotated-only-copy\n",
);
const rebuildable = await service.create({ repoRoot: repo, name: "rebuildable" });
await service.acquire(rebuildable.id);
await fs.mkdir(path.join(rebuildable.path, "node_modules"), { recursive: true });
await fs.writeFile(path.join(rebuildable.path, "node_modules", "cache.js"), "cache\n");
expect(await service.removeIfLossless(rebuildable.id)).toBe(true);
const deleted = await service.create({ repoRoot: repo, name: "deleted-local" });
await service.acquire(deleted.id);
const deletedCopy = path.join(deleted.path, ".env.local");
await fs.rm(deletedCopy);
expect(await service.removeIfLossless(deleted.id)).toBe(true);
const restoredDeleted = await service.restore({ id: deleted.id });
await expect(fs.stat(path.join(restoredDeleted.path, ".env.local"))).rejects.toMatchObject({
code: "ENOENT",
});
});
it.skipIf(process.platform === "win32")(
"snapshots regular provisioned file modes and protects changed file types",
async () => {
await fs.writeFile(path.join(repo, ".gitignore"), ".env.local\n");
await fs.writeFile(path.join(repo, ".worktreeinclude"), ".env.local\n");
await git(repo, "add", ".gitignore", ".worktreeinclude");
await git(repo, "commit", "-m", "configure worktree provisioning");
const sourcePath = path.join(repo, ".env.local");
await fs.writeFile(sourcePath, "value=source\n", { mode: 0o644 });
await addRemote(root, repo);
const executable = await service.create({ repoRoot: repo, name: "executable-local" });
await service.acquire(executable.id);
const executableCopy = path.join(executable.path, ".env.local");
await fs.chmod(executableCopy, 0o755);
expect(await git(executable.path, "status", "--porcelain")).toBe("");
expect(await service.removeIfLossless(executable.id)).toBe(true);
const restoredExecutable = await service.restore({ id: executable.id });
expect((await fs.lstat(path.join(restoredExecutable.path, ".env.local"))).mode & 0o777).toBe(
0o755,
);
const specialMode = await service.create({ repoRoot: repo, name: "special-mode-local" });
await service.acquire(specialMode.id);
const specialModeCopy = path.join(specialMode.path, ".env.local");
await fs.chmod(specialModeCopy, 0o1644);
expect(await service.removeIfLossless(specialMode.id)).toBe(true);
const restoredSpecialMode = await service.restore({ id: specialMode.id });
expect(
(await fs.lstat(path.join(restoredSpecialMode.path, ".env.local"))).mode & 0o7777,
).toBe(0o1644);
const linked = await service.create({ repoRoot: repo, name: "linked-local" });
await service.acquire(linked.id);
const linkedCopy = path.join(linked.path, ".env.local");
await fs.rm(linkedCopy);
await fs.symlink(sourcePath, linkedCopy);
expect(await git(linked.path, "status", "--porcelain")).toBe("");
expect(await service.removeIfLossless(linked.id)).toBe(false);
expect((await fs.lstat(linkedCopy)).isSymbolicLink()).toBe(true);
const sourceLinked = await service.create({ repoRoot: repo, name: "source-linked-local" });
await service.acquire(sourceLinked.id);
const outside = path.join(root, "same-local-value");
await fs.writeFile(outside, "value=source\n");
await fs.rm(sourcePath);
await fs.symlink(outside, sourcePath);
expect(await git(sourceLinked.path, "status", "--porcelain")).toBe("");
expect(await service.removeIfLossless(sourceLinked.id)).toBe(true);
const restoredSourceLinked = await service.restore({ id: sourceLinked.id });
expect((await fs.lstat(path.join(restoredSourceLinked.path, ".env.local"))).isFile()).toBe(
true,
);
},
);
it("exempts manual worktrees and garbage collects idle run-owned worktrees", async () => {
const manual = await service.create({ repoRoot: repo, name: "manual-idle" });
const created = await service.create({
@@ -636,6 +829,30 @@ describe("ManagedWorktreeService", () => {
expect(await fs.stat(manual.path)).toBeTruthy();
});
it("garbage collects modified provisioned files into the immutable snapshot", async () => {
await fs.writeFile(path.join(repo, ".gitignore"), ".env.local\n");
await fs.writeFile(path.join(repo, ".worktreeinclude"), ".env.local\n");
await git(repo, "add", ".gitignore", ".worktreeinclude");
await git(repo, "commit", "-m", "configure worktree provisioning");
await fs.writeFile(path.join(repo, ".env.local"), "value=old-source\n");
const created = await service.create({
repoRoot: repo,
name: "idle-rotated",
ownerKind: "workboard",
});
await fs.rm(path.join(repo, ".worktreeinclude"));
await fs.writeFile(path.join(created.path, ".env.local"), "value=rotated-only-copy\n");
now += IDLE_GC_MS + 1;
expect((await service.gc()).removed).toEqual([created.id]);
await fs.writeFile(path.join(repo, ".env.local"), "value=newer-source\n");
const restored = await service.restore({ id: created.id });
expect(await fs.readFile(path.join(restored.path, ".env.local"), "utf8")).toBe(
"value=rotated-only-copy\n",
);
});
it("uses owner activity to protect only active idle session worktrees", async () => {
const active = await service.create({
repoRoot: repo,
+229 -82
View File
@@ -1,5 +1,5 @@
import { createHash, randomBytes, randomUUID } from "node:crypto";
import { constants as fsConstants, type Dirent } from "node:fs";
import type { Dirent } from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
@@ -14,17 +14,27 @@ import {
pathExists,
removeEmptyParents,
requireGit,
requireGitRaw,
requireGitBuffer,
runGit,
type GitResult,
} from "./git.js";
import { worktreeOwnerMatches } from "./owner.js";
import {
hasUnsnapshotableProvisionedFiles,
provisionIncludedFiles,
restoreProvisionedFiles,
snapshotProvisionedFiles,
} from "./provisioned-files.js";
import {
clearRegistryWorktreeProvisionedChunks,
deleteRegistryWorktree,
findRegistryWorktreeByPath,
findLiveRegistryWorktreeByOwner,
findLiveRegistryWorktreeByPath,
getRegistryWorktree,
getRegistryWorktreeProvisionedLedger,
getRegistryWorktreeProvisionedPaths,
getRegistryWorktreeProvisionedState,
insertRegistryWorktree,
listRegistryWorktrees,
updateRegistryWorktree,
@@ -139,76 +149,6 @@ async function resolveRepository(repoRoot: string): Promise<{
return { repoRoot: canonicalRoot, sourceRoot, commonDir, originUrl, fingerprint };
}
async function ensureNoSymlinkDirectory(root: string, relativePath: string): Promise<boolean> {
const segments = relativePath.split(/[\\/]/).filter(Boolean);
let current = root;
for (const segment of segments.slice(0, -1)) {
current = path.join(current, segment);
try {
const stat = await fs.lstat(current);
if (stat.isSymbolicLink() || !stat.isDirectory()) {
return false;
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
continue;
}
throw error;
}
}
return true;
}
async function copyIncludedFiles(repoRoot: string, worktreePath: string): Promise<void> {
const includePath = path.join(repoRoot, ".worktreeinclude");
if (!(await pathExists(includePath))) {
return;
}
const candidatesRaw = await requireGitRaw(repoRoot, [
"ls-files",
"--others",
"--ignored",
"--exclude-standard",
"-z",
]);
const includedRaw = await requireGitRaw(repoRoot, [
"ls-files",
"--others",
"--ignored",
`--exclude-from=${includePath}`,
"-z",
]);
const included = new Set(includedRaw.split("\0").filter(Boolean));
for (const relativePath of candidatesRaw.split("\0").filter(Boolean)) {
if (!included.has(relativePath) || path.isAbsolute(relativePath)) {
continue;
}
const normalized = path.normalize(relativePath);
if (normalized === ".." || normalized.startsWith(`..${path.sep}`)) {
continue;
}
if (
!(await ensureNoSymlinkDirectory(repoRoot, normalized)) ||
!(await ensureNoSymlinkDirectory(worktreePath, normalized))
) {
continue;
}
const source = path.join(repoRoot, normalized);
const destination = path.join(worktreePath, normalized);
const sourceStat = await fs.lstat(source).catch(() => undefined);
if (!sourceStat?.isFile() || sourceStat.isSymbolicLink()) {
continue;
}
await fs.mkdir(path.dirname(destination), { recursive: true });
await fs.copyFile(source, destination, fsConstants.COPYFILE_EXCL).catch((error: unknown) => {
if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
throw error;
}
});
await fs.chmod(destination, sourceStat.mode);
}
}
async function cleanupFailedCreate(repoRoot: string, worktreePath: string, branch: string) {
const removed = await runGit(repoRoot, ["worktree", "remove", "--force", worktreePath]);
const deletedBranch = await runGit(repoRoot, ["branch", "-D", branch]);
@@ -333,7 +273,76 @@ async function directorySizeBytes(root: string): Promise<number> {
return total;
}
async function snapshotWorktree(record: ManagedWorktreeRecord, reason: string): Promise<string> {
async function containsGitMarker(root: string, checkoutRoot = false): Promise<boolean> {
let entries: Dirent[];
try {
entries = await fs.readdir(root, { withFileTypes: true });
} catch (error) {
if (isMissingFileError(error)) {
return false;
}
throw error;
}
for (const entry of entries) {
if (entry.name === ".git") {
if (!checkoutRoot) {
return true;
}
continue;
}
if (entry.isDirectory() && (await containsGitMarker(path.join(root, entry.name), false))) {
return true;
}
}
return false;
}
function splitNullBuffer(input: Buffer): Buffer[] {
const fields: Buffer[] = [];
let start = 0;
for (let index = 0; index < input.length; index += 1) {
if (input[index] !== 0) {
continue;
}
if (index > start) {
fields.push(input.subarray(start, index));
}
start = index + 1;
}
if (start < input.length) {
fields.push(input.subarray(start));
}
return fields;
}
function gitPathKey(gitPath: Buffer): string {
return gitPath.toString("hex");
}
function checkoutPathFromGitBytes(checkoutRoot: string, gitPath: Buffer): string | Buffer {
if (process.platform === "win32") {
return path.join(checkoutRoot, ...gitPath.toString("utf8").split("/"));
}
return Buffer.concat([Buffer.from(checkoutRoot), Buffer.from(path.sep), gitPath]);
}
async function rawPathExists(target: string | Buffer): Promise<boolean> {
try {
await fs.lstat(target);
return true;
} catch (error) {
if (isMissingFileError(error)) {
return false;
}
throw error;
}
}
async function snapshotWorktree(
record: ManagedWorktreeRecord,
reason: string,
provisionedPaths: readonly string[],
): Promise<string> {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-worktree-index-"));
const indexPath = path.join(tempDir, "index");
const snapshotRef = `${SNAPSHOT_REF_PREFIX}/${record.id}`;
@@ -343,12 +352,97 @@ async function snapshotWorktree(record: ManagedWorktreeRecord, reason: string):
GIT_AUTHOR_EMAIL: "openclaw@localhost",
GIT_COMMITTER_NAME: "OpenClaw",
GIT_COMMITTER_EMAIL: "openclaw@localhost",
...(process.platform === "win32"
? {}
: {
GIT_CONFIG_COUNT: "1",
GIT_CONFIG_KEY_0: "core.filemode",
GIT_CONFIG_VALUE_0: "true",
}),
};
try {
if (await containsGitMarker(record.path, true)) {
throw new Error("nested git repositories cannot be snapshotted losslessly");
}
const provisioned = new Set(provisionedPaths.map((entry) => gitPathKey(Buffer.from(entry))));
const snapshotPaths = new Map<string, Buffer>();
const addSnapshotPath = (entry: Buffer) => {
const key = gitPathKey(entry);
if (!provisioned.has(key)) {
snapshotPaths.set(key, entry);
}
};
const sparseConfig = await runGit(record.path, ["config", "--bool", "core.sparseCheckout"]);
if (sparseConfig.code !== 0 && sparseConfig.code !== 1) {
throw commandError("git config --bool core.sparseCheckout", sparseConfig);
}
const sparseCheckout = sparseConfig.code === 0 && sparseConfig.stdout.trim() === "true";
const sparseCandidates: Buffer[] = [];
for (const entry of splitNullBuffer(
await requireGitBuffer(record.path, ["ls-files", "-v", "-z"]),
)) {
if (entry.length < 3) {
continue;
}
const tag = String.fromCharCode(entry[0] ?? 0).toUpperCase();
const trackedPath = entry.subarray(2);
if (
tag !== "S" ||
(await rawPathExists(checkoutPathFromGitBytes(record.path, trackedPath))) ||
!sparseCheckout
) {
addSnapshotPath(trackedPath);
} else {
sparseCandidates.push(trackedPath);
}
}
if (sparseCandidates.length > 0) {
const included = await requireGitBuffer(
record.path,
["sparse-checkout", "check-rules", "-z"],
{
input: Buffer.concat(sparseCandidates.flatMap((entry) => [entry, Buffer.from([0])])),
},
);
for (const entry of splitNullBuffer(included)) {
// Missing paths included by the active rules are deletions, even if their
// index skip-worktree bit is stale. Truly sparse omissions stay at HEAD.
addSnapshotPath(entry);
}
}
for (const args of [
["diff-index", "--cached", "--name-only", "-z", "HEAD", "--"],
["ls-files", "-z", "--others", "--exclude-standard"],
]) {
for (const entry of splitNullBuffer(await requireGitBuffer(record.path, args))) {
addSnapshotPath(entry);
}
}
await requireGit(record.path, ["read-tree", "HEAD"], { env });
// Ignored files stay outside the repository object database; provisioning recreates them.
await requireGit(record.path, ["add", "-A"], { env });
// This index came from a tree, so it has no checkout-local skip-worktree
// bits and update-index is independent of the source worktree's sparse cone.
await requireGit(record.path, ["update-index", "--add", "--remove", "-z", "--stdin"], {
env,
input:
snapshotPaths.size > 0
? Buffer.concat([...snapshotPaths.values()].flatMap((entry) => [entry, Buffer.from([0])]))
: Buffer.alloc(0),
});
const tree = await requireGit(record.path, ["write-tree"], { env });
for (const provisionedPath of provisionedPaths) {
const overlap = await requireGit(record.path, [
"--literal-pathspecs",
"ls-tree",
"-r",
"--name-only",
tree,
"--",
provisionedPath,
]);
if (overlap) {
throw new Error(`provisioned path entered Git snapshot: ${provisionedPath}`);
}
}
const treeEntries = await requireGit(record.path, ["ls-tree", "-r", tree]);
// Gitlinks omit nested worktree files, so accepting one would violate the full-tree snapshot.
if (treeEntries.split("\n").some((entry) => entry.startsWith("160000 "))) {
@@ -445,8 +539,9 @@ export class ManagedWorktreeService {
if (added.code !== 0) {
throw commandError("git worktree add", added);
}
let provisionedPaths: string[];
try {
await copyIncludedFiles(repository.sourceRoot, worktreePath);
provisionedPaths = await provisionIncludedFiles(repository.sourceRoot, worktreePath);
if (runRepositorySetup) {
await runSetupScript(repository.sourceRoot, worktreePath);
}
@@ -472,7 +567,7 @@ export class ManagedWorktreeService {
createdAt,
lastActiveAt: createdAt,
};
insertRegistryWorktree(this.env, record);
insertRegistryWorktree(this.env, record, { provisionedPaths });
return record;
}
@@ -639,10 +734,31 @@ export class ManagedWorktreeService {
let snapshotRef = record.snapshotRef;
let snapshotError: string | undefined;
try {
snapshotRef = await snapshotWorktree(record, params.reason);
updateRegistryWorktree(this.env, record.id, { snapshotRef });
const provisionedState = await snapshotProvisionedFiles(
this.env,
record.id,
record.path,
getRegistryWorktreeProvisionedPaths(this.env, record.id),
);
snapshotRef = await snapshotWorktree(
record,
params.reason,
provisionedState.map((entry) => entry.path),
);
updateRegistryWorktree(this.env, record.id, {
snapshotRef,
provisionedState,
});
} catch (error) {
snapshotError = error instanceof Error ? error.message : String(error);
try {
clearRegistryWorktreeProvisionedChunks(this.env, record.id);
} catch (cleanupError) {
throw new WorktreeSnapshotError(
`${snapshotError}; provisioned snapshot cleanup failed: ${String(cleanupError)}`,
{ cause: cleanupError },
);
}
if (!force) {
throw new WorktreeSnapshotError(snapshotError, { cause: error });
}
@@ -692,13 +808,28 @@ export class ManagedWorktreeService {
record.snapshotRef,
]);
let branchCreated = false;
let restoredProvisionedPaths: string[];
try {
// Branch history stays at the original commit; the snapshot is restored as working state.
await requireGit(record.repoRoot, ["branch", record.branch, parent]);
branchCreated = true;
await requireGit(record.path, ["symbolic-ref", "HEAD", `refs/heads/${record.branch}`]);
await requireGit(record.path, ["reset"]);
await copyIncludedFiles(record.repoRoot, record.path);
const provisionedLedger = getRegistryWorktreeProvisionedLedger(this.env, record.id);
if (provisionedLedger.status === "legacy") {
// Explicitly removed pre-ledger worktrees retain their historical restore behavior.
restoredProvisionedPaths = await provisionIncludedFiles(record.repoRoot, record.path);
} else {
if (provisionedLedger.status === "invalid") {
throw new Error(`worktree ${record.id} has invalid provisioned file metadata`);
}
const provisionedState = getRegistryWorktreeProvisionedState(this.env, record.id);
if (provisionedState === undefined) {
throw new Error(`worktree ${record.id} snapshot lacks provisioned file metadata`);
}
await restoreProvisionedFiles(this.env, record.id, record.path, provisionedState);
restoredProvisionedPaths = provisionedState.map((state) => state.path);
}
} catch (error) {
const removed = await runGit(record.repoRoot, ["worktree", "remove", "--force", record.path]);
const branchDeleted = branchCreated
@@ -713,7 +844,11 @@ export class ManagedWorktreeService {
throw error;
}
const lastActiveAt = this.now();
updateRegistryWorktree(this.env, params.id, { removedAt: undefined, lastActiveAt });
updateRegistryWorktree(this.env, params.id, {
removedAt: undefined,
lastActiveAt,
provisionedPaths: restoredProvisionedPaths,
});
// Clear any lease rows or removal marker stranded by a crash between git removal
// and finalize so the restored worktree admits runs again.
finalizeWorktreeRemoval(this.env, params.id);
@@ -741,7 +876,11 @@ export class ManagedWorktreeService {
"--remotes",
"--oneline",
]);
if (status || unpushed) {
const ignoredDrift = await hasUnsnapshotableProvisionedFiles(
record.path,
getRegistryWorktreeProvisionedPaths(this.env, record.id),
);
if (status || unpushed || ignoredDrift) {
abortWorktreeRemoval(this.env, id, claimToken);
return false;
}
@@ -836,6 +975,14 @@ export class ManagedWorktreeService {
if (hasLiveWorktreeRunLease(this.env, record.id)) {
return true;
}
if (
await hasUnsnapshotableProvisionedFiles(
record.path,
getRegistryWorktreeProvisionedPaths(this.env, record.id),
)
) {
return true;
}
const state = await lockState(record);
if (state.kind === "live" || state.kind === "foreign") {
return true;
+6
View File
@@ -1,5 +1,11 @@
export type ManagedWorktreeOwnerKind = "manual" | "workboard" | "session";
export type ProvisionedFileState = {
path: string;
mode: number | null;
chunks: number;
};
export type ManagedWorktreeRecord = {
id: string;
name: string;
+9
View File
@@ -1233,6 +1233,13 @@ export interface WorkspaceSetupState {
workspace_path: string;
}
export interface WorktreeProvisionedFileChunks {
chunk_index: number;
data: Uint8Array;
path: string;
worktree_id: string;
}
export interface Worktrees {
base_ref: string;
branch: string;
@@ -1242,6 +1249,7 @@ export interface Worktrees {
owner_id: string | null;
owner_kind: string;
path: string;
provisioned_paths_json: string | null;
removed_at: number | null;
repo_fingerprint: string;
repo_root: string;
@@ -1336,5 +1344,6 @@ export interface DB {
worker_workspace_pending_results: WorkerWorkspacePendingResults;
worker_workspace_reconciliations: WorkerWorkspaceReconciliations;
workspace_setup_state: WorkspaceSetupState;
worktree_provisioned_file_chunks: WorktreeProvisionedFileChunks;
worktrees: Worktrees;
}
+1
View File
@@ -1350,6 +1350,7 @@ function backfillDeliveryQueueEntriesFromEntryJson(db: DatabaseSync): void {
// The caller owns the state.schema.ensure transaction so every probe, DDL
// change, and backfill observes one authoritative schema across processes.
function ensureAdditiveStateColumns(db: DatabaseSync): void {
ensureColumn(db, "worktrees", "provisioned_paths_json TEXT");
ensureColumn(db, "node_host_config", "gateway_context_path TEXT");
ensureColumn(db, "apns_registrations", "relay_origin TEXT");
ensureColumn(db, "device_pairing_pending", "refreshed_at_ms INTEGER");
@@ -1541,6 +1541,7 @@ CREATE TABLE IF NOT EXISTS worktrees (
owner_kind TEXT NOT NULL CHECK (owner_kind IN ('manual', 'workboard', 'session')),
owner_id TEXT,
snapshot_ref TEXT,
provisioned_paths_json TEXT,
created_at INTEGER NOT NULL,
last_active_at INTEGER NOT NULL,
removed_at INTEGER
@@ -1552,6 +1553,14 @@ CREATE INDEX IF NOT EXISTS idx_worktrees_repo_fingerprint
CREATE INDEX IF NOT EXISTS idx_worktrees_removed_at
ON worktrees(removed_at);
CREATE TABLE IF NOT EXISTS worktree_provisioned_file_chunks (
worktree_id TEXT NOT NULL,
path TEXT NOT NULL,
chunk_index INTEGER NOT NULL CHECK (chunk_index >= 0),
data BLOB NOT NULL,
PRIMARY KEY (worktree_id, path, chunk_index)
) STRICT;
-- Gateway-owned custom session group catalog (names + display order).
-- Membership stays on each session entry's category field; this table only
-- owns which groups exist and how operator UIs order them.
+9
View File
@@ -1536,6 +1536,7 @@ CREATE TABLE IF NOT EXISTS worktrees (
owner_kind TEXT NOT NULL CHECK (owner_kind IN ('manual', 'workboard', 'session')),
owner_id TEXT,
snapshot_ref TEXT,
provisioned_paths_json TEXT,
created_at INTEGER NOT NULL,
last_active_at INTEGER NOT NULL,
removed_at INTEGER
@@ -1547,6 +1548,14 @@ CREATE INDEX IF NOT EXISTS idx_worktrees_repo_fingerprint
CREATE INDEX IF NOT EXISTS idx_worktrees_removed_at
ON worktrees(removed_at);
CREATE TABLE IF NOT EXISTS worktree_provisioned_file_chunks (
worktree_id TEXT NOT NULL,
path TEXT NOT NULL,
chunk_index INTEGER NOT NULL CHECK (chunk_index >= 0),
data BLOB NOT NULL,
PRIMARY KEY (worktree_id, path, chunk_index)
) STRICT;
-- Gateway-owned custom session group catalog (names + display order).
-- Membership stays on each session entry's category field; this table only
-- owns which groups exist and how operator UIs order them.