feat(FN-3841): add worktree database hydration to executor
Implements worktree database hydration for the executor (`worktree-db-hydrate.ts`), enabling the task executor to restore its database state from a worktree on resume, with integration in `executor.ts` and comprehensive test coverage across the new module and worktree scenarios. Documentation and a Fusion-Task-Id: FN-3841
This commit is contained in:
@@ -187,6 +187,13 @@ vi.mock("../step-session-executor.js", () => ({
|
||||
vi.mock("../rate-limit-retry.js", () => ({
|
||||
withRateLimitRetry: vi.fn((fn: () => Promise<unknown>) => fn()),
|
||||
}));
|
||||
vi.mock("../worktree-db-hydrate.js", () => ({
|
||||
hydrateWorktreeDb: vi.fn().mockResolvedValue({
|
||||
tasksCopied: 0,
|
||||
documentsCopied: 0,
|
||||
degraded: false,
|
||||
}),
|
||||
}));
|
||||
vi.mock("../verification-utils.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../verification-utils.js")>("../verification-utils.js");
|
||||
return {
|
||||
@@ -221,6 +228,7 @@ import { StepSessionExecutor } from "../step-session-executor.js";
|
||||
import { withRateLimitRetry } from "../rate-limit-retry.js";
|
||||
import { execSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { hydrateWorktreeDb } from "../worktree-db-hydrate.js";
|
||||
|
||||
export const mockedCreateFnAgent = vi.mocked(createFnAgent);
|
||||
export const mockedSessionManager = vi.mocked(SessionManager);
|
||||
@@ -230,6 +238,7 @@ export const mockedStepSessionExecutor = vi.mocked(StepSessionExecutor);
|
||||
export const mockedWithRateLimitRetry = vi.mocked(withRateLimitRetry);
|
||||
export const mockedExecSync = vi.mocked(execSync);
|
||||
export const mockedExistsSync = vi.mocked(existsSync);
|
||||
export const mockedHydrateWorktreeDb = vi.mocked(hydrateWorktreeDb);
|
||||
|
||||
export type EventListener = (...args: unknown[]) => void;
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
mockedWithRateLimitRetry,
|
||||
mockedExecSync,
|
||||
mockedExistsSync,
|
||||
mockedHydrateWorktreeDb,
|
||||
mockExecuteAll,
|
||||
mockTerminateAllSessions,
|
||||
mockCleanup,
|
||||
@@ -2094,3 +2095,71 @@ function createMockTaskDetail(overrides: Partial<TaskDetail> = {}): TaskDetail {
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
describe("worktree DB hydration", () => {
|
||||
const makeTask = (overrides: Partial<Task> = {}): Task => ({
|
||||
id: "FN-HYD",
|
||||
title: "Hydrate",
|
||||
description: "Hydrate",
|
||||
column: "in-progress",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetExecutorMocks();
|
||||
mockedHydrateWorktreeDb.mockReset();
|
||||
mockedHydrateWorktreeDb.mockResolvedValue({
|
||||
tasksCopied: 1,
|
||||
documentsCopied: 2,
|
||||
degraded: false,
|
||||
});
|
||||
mockedCreateFnAgent.mockResolvedValue({
|
||||
session: { prompt: vi.fn().mockResolvedValue(undefined), dispose: vi.fn() },
|
||||
} as any);
|
||||
});
|
||||
|
||||
it("runs once for fresh worktree", async () => {
|
||||
mockedExistsSync.mockReturnValue(false);
|
||||
const store = createMockStore();
|
||||
const executor = new TaskExecutor(store, "/tmp/test");
|
||||
await executor.execute(makeTask());
|
||||
expect(mockedHydrateWorktreeDb).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("runs once for pool acquire", async () => {
|
||||
mockedExistsSync.mockReturnValue(false);
|
||||
const store = createMockStore();
|
||||
const pool = {
|
||||
acquire: vi.fn(() => "/tmp/test/.worktrees/pooled"),
|
||||
prepareForTask: vi.fn(async () => "fusion/fn-hyd"),
|
||||
release: vi.fn(),
|
||||
} as any;
|
||||
store.getSettings.mockResolvedValue({ ...(await store.getSettings()), recycleWorktrees: true });
|
||||
const executor = new TaskExecutor(store, "/tmp/test", { pool });
|
||||
await executor.execute(makeTask());
|
||||
expect(mockedHydrateWorktreeDb).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("runs hydration path when executor reassigns unusable root worktree", async () => {
|
||||
mockedExistsSync.mockReturnValue(true);
|
||||
const store = createMockStore();
|
||||
const executor = new TaskExecutor(store, "/tmp/test");
|
||||
await executor.execute(makeTask({ worktree: "/tmp/test" }));
|
||||
expect(mockedHydrateWorktreeDb).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("hydration failure does not abort execute", async () => {
|
||||
mockedHydrateWorktreeDb.mockRejectedValueOnce(new Error("boom"));
|
||||
mockedExistsSync.mockReturnValue(false);
|
||||
const store = createMockStore();
|
||||
const executor = new TaskExecutor(store, "/tmp/test");
|
||||
await executor.execute(makeTask());
|
||||
expect(mockedCreateFnAgent).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
146
packages/engine/src/__tests__/worktree-db-hydrate.test.ts
Normal file
146
packages/engine/src/__tests__/worktree-db-hydrate.test.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { chmodSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
import { Database, DatabaseSync } from "@fusion/core";
|
||||
import { hydrateWorktreeDb } from "../worktree-db-hydrate.js";
|
||||
|
||||
function makeProject(prefix: string): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), prefix));
|
||||
mkdirSync(join(dir, ".fusion"), { recursive: true });
|
||||
const db = new Database(join(dir, ".fusion"));
|
||||
db.init();
|
||||
db.close();
|
||||
return dir;
|
||||
}
|
||||
|
||||
function insertTask(projectDir: string, id: string): void {
|
||||
const db = new DatabaseSync(join(projectDir, ".fusion", "fusion.db"));
|
||||
const now = new Date().toISOString();
|
||||
db.prepare("INSERT OR REPLACE INTO tasks (id, description, \"column\", createdAt, updatedAt, dependencies) VALUES (?, ?, 'todo', ?, ?, '[]')")
|
||||
.run(id, id, now, now);
|
||||
db.close();
|
||||
}
|
||||
|
||||
function insertDoc(projectDir: string, taskId: string): void {
|
||||
const db = new DatabaseSync(join(projectDir, ".fusion", "fusion.db"));
|
||||
const now = new Date().toISOString();
|
||||
db.prepare("INSERT OR REPLACE INTO task_documents (id, taskId, key, content, revision, author, metadata, createdAt, updatedAt) VALUES (?, ?, 'notes', 'hello', 1, 'test', NULL, ?, ?)")
|
||||
.run(`doc-${taskId}`, taskId, now, now);
|
||||
db.close();
|
||||
}
|
||||
|
||||
function sha(file: string): string {
|
||||
if (!existsSync(file)) return "";
|
||||
return createHash("sha256").update(readFileSync(file)).digest("hex");
|
||||
}
|
||||
|
||||
describe("hydrateWorktreeDb", () => {
|
||||
const cleanup: string[] = [];
|
||||
afterEach(() => {
|
||||
for (const dir of cleanup) rmSync(dir, { recursive: true, force: true });
|
||||
cleanup.length = 0;
|
||||
});
|
||||
|
||||
it("hydrates transitive dependencies and is idempotent", async () => {
|
||||
const root = makeProject("h-root-");
|
||||
const worktree = makeProject("h-dst-");
|
||||
cleanup.push(root, worktree);
|
||||
|
||||
insertTask(root, "FN-A");
|
||||
insertTask(root, "FN-B");
|
||||
insertTask(root, "FN-C");
|
||||
insertDoc(root, "FN-B");
|
||||
|
||||
const depMap: Record<string, string[]> = { "FN-A": ["FN-B"], "FN-B": ["FN-C"], "FN-C": [] };
|
||||
const store = { getTask: vi.fn(async (id: string) => ({ id, dependencies: depMap[id] ?? [] })) };
|
||||
|
||||
const first = await hydrateWorktreeDb({ rootDir: root, worktreePath: worktree, taskId: "FN-A", store: store as any, logger: { warn: vi.fn() } });
|
||||
const second = await hydrateWorktreeDb({ rootDir: root, worktreePath: worktree, taskId: "FN-A", store: store as any, logger: { warn: vi.fn() } });
|
||||
expect(first.degraded).toBe(false);
|
||||
expect(second.degraded).toBe(false);
|
||||
|
||||
const db = new DatabaseSync(join(worktree, ".fusion", "fusion.db"));
|
||||
const tasks = (db.prepare("SELECT COUNT(*) as c FROM tasks WHERE id IN ('FN-A','FN-B','FN-C')").get() as any).c;
|
||||
const docs = (db.prepare("SELECT COUNT(*) as c FROM task_documents WHERE taskId='FN-B'").get() as any).c;
|
||||
db.close();
|
||||
expect(tasks).toBe(3);
|
||||
expect(docs).toBe(1);
|
||||
});
|
||||
|
||||
it("no-op when rootDir === worktreePath", async () => {
|
||||
const root = makeProject("h-same-");
|
||||
cleanup.push(root);
|
||||
const result = await hydrateWorktreeDb({ rootDir: root, worktreePath: root, taskId: "FN-A", store: { getTask: vi.fn() } as any, logger: { warn: vi.fn() } });
|
||||
expect(result.reason).toBe("root_worktree");
|
||||
});
|
||||
|
||||
it("handles cycle and 50-id cap", async () => {
|
||||
const root = makeProject("h-cycle-");
|
||||
const worktree = makeProject("h-cycle-dst-");
|
||||
cleanup.push(root, worktree);
|
||||
for (let i = 0; i < 60; i++) insertTask(root, `FN-${i}`);
|
||||
|
||||
const map: Record<string, string[]> = { "FN-A": ["FN-B"], "FN-B": ["FN-A"] };
|
||||
for (let i = 0; i < 60; i++) map[`FN-${i}`] = i < 59 ? [`FN-${i + 1}`] : [];
|
||||
const store = { getTask: vi.fn(async (id: string) => ({ id, dependencies: map[id] ?? [] })) };
|
||||
|
||||
insertTask(root, "FN-A");
|
||||
insertTask(root, "FN-B");
|
||||
const cyc = await hydrateWorktreeDb({ rootDir: root, worktreePath: worktree, taskId: "FN-A", store: store as any, logger: { warn: vi.fn() } });
|
||||
const capped = await hydrateWorktreeDb({ rootDir: root, worktreePath: worktree, taskId: "FN-0", store: store as any, logger: { warn: vi.fn() } });
|
||||
expect(cyc.degraded).toBe(false);
|
||||
expect(capped.tasksCopied).toBeLessThanOrEqual(50);
|
||||
});
|
||||
|
||||
it("handles schema drift by dropping missing destination columns", async () => {
|
||||
const root = makeProject("h-drift-");
|
||||
const worktree = makeProject("h-drift-dst-");
|
||||
cleanup.push(root, worktree);
|
||||
insertTask(root, "FN-1");
|
||||
const driftDb = new DatabaseSync(join(worktree, ".fusion", "fusion.db"));
|
||||
driftDb.exec("DROP TRIGGER IF EXISTS tasks_fts_ai");
|
||||
driftDb.exec("DROP TRIGGER IF EXISTS tasks_fts_au");
|
||||
driftDb.exec("DROP TRIGGER IF EXISTS tasks_fts_ad");
|
||||
driftDb.exec("ALTER TABLE tasks DROP COLUMN title");
|
||||
driftDb.close();
|
||||
|
||||
const warn = vi.fn();
|
||||
const store = { getTask: vi.fn(async () => ({ id: "FN-1", dependencies: [] })) };
|
||||
const result = await hydrateWorktreeDb({ rootDir: root, worktreePath: worktree, taskId: "FN-1", store: store as any, logger: { warn } });
|
||||
|
||||
expect(result.degraded).toBe(false);
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining("tasks.title"));
|
||||
});
|
||||
|
||||
it("does not mutate source db file bytes", async () => {
|
||||
const root = makeProject("h-src-");
|
||||
const worktree = makeProject("h-dst2-");
|
||||
cleanup.push(root, worktree);
|
||||
insertTask(root, "FN-1");
|
||||
const store = { getTask: vi.fn(async () => ({ id: "FN-1", dependencies: [] })) };
|
||||
|
||||
const dbPath = join(root, ".fusion", "fusion.db");
|
||||
const before = [sha(dbPath), sha(`${dbPath}-wal`), sha(`${dbPath}-shm`)];
|
||||
await hydrateWorktreeDb({ rootDir: root, worktreePath: worktree, taskId: "FN-1", store: store as any, logger: { warn: vi.fn() } });
|
||||
const after = [sha(dbPath), sha(`${dbPath}-wal`), sha(`${dbPath}-shm`)];
|
||||
expect(after).toEqual(before);
|
||||
});
|
||||
|
||||
it("degrades on write failure", async () => {
|
||||
const root = makeProject("h-denied-");
|
||||
const worktree = makeProject("h-denied-dst-");
|
||||
cleanup.push(root, worktree);
|
||||
insertTask(root, "FN-1");
|
||||
const store = { getTask: vi.fn(async () => ({ id: "FN-1", dependencies: [] })) };
|
||||
|
||||
chmodSync(join(worktree, ".fusion"), 0o500);
|
||||
const warn = vi.fn();
|
||||
const result = await hydrateWorktreeDb({ rootDir: root, worktreePath: worktree, taskId: "FN-1", store: store as any, logger: { warn } });
|
||||
chmodSync(join(worktree, ".fusion"), 0o700);
|
||||
expect(result.degraded).toBe(true);
|
||||
expect(warn).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -48,6 +48,7 @@ import type { StuckTaskDetector, StuckTaskEvent } from "./stuck-task-detector.js
|
||||
import type { PluginRunner } from "./plugin-runner.js";
|
||||
import { isContextLimitError } from "./context-limit-detector.js";
|
||||
import { StepSessionExecutor } from "./step-session-executor.js";
|
||||
import { hydrateWorktreeDb } from "./worktree-db-hydrate.js";
|
||||
import {
|
||||
resolveAgentInstructions,
|
||||
buildSystemPromptWithInstructions,
|
||||
@@ -2371,6 +2372,35 @@ export class TaskExecutor {
|
||||
} else {
|
||||
await this.store.logEntry(task.id, `Acquired worktree from pool: ${worktreePath}`, undefined, this.currentRunContext);
|
||||
}
|
||||
|
||||
if (this.rootDir !== worktreePath) {
|
||||
try {
|
||||
const hydration = await hydrateWorktreeDb({
|
||||
rootDir: this.rootDir,
|
||||
worktreePath,
|
||||
taskId: task.id,
|
||||
store: this.store,
|
||||
logger: executorLog,
|
||||
});
|
||||
if (hydration.degraded) {
|
||||
await this.store.logEntry(
|
||||
task.id,
|
||||
`Worktree DB hydration degraded: ${hydration.reason ?? "unknown"}`,
|
||||
undefined,
|
||||
this.currentRunContext,
|
||||
);
|
||||
} else {
|
||||
await this.store.logEntry(
|
||||
task.id,
|
||||
`Hydrated worktree DB: ${hydration.tasksCopied} tasks, ${hydration.documentsCopied} task_documents`,
|
||||
undefined,
|
||||
this.currentRunContext,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
executorLog.warn(`${task.id}: worktree DB hydration failed: ${formatError(error)}`);
|
||||
}
|
||||
}
|
||||
} catch (poolErr: unknown) {
|
||||
// Pool preparation failed — release the worktree back and fall through
|
||||
// to fresh worktree creation
|
||||
@@ -2462,9 +2492,68 @@ export class TaskExecutor {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!acquiredFromPool) {
|
||||
if (this.rootDir !== worktreePath) {
|
||||
try {
|
||||
const hydration = await hydrateWorktreeDb({
|
||||
rootDir: this.rootDir,
|
||||
worktreePath,
|
||||
taskId: task.id,
|
||||
store: this.store,
|
||||
logger: executorLog,
|
||||
});
|
||||
if (hydration.degraded) {
|
||||
await this.store.logEntry(
|
||||
task.id,
|
||||
`Worktree DB hydration degraded: ${hydration.reason ?? "unknown"}`,
|
||||
undefined,
|
||||
this.currentRunContext,
|
||||
);
|
||||
} else {
|
||||
await this.store.logEntry(
|
||||
task.id,
|
||||
`Hydrated worktree DB: ${hydration.tasksCopied} tasks, ${hydration.documentsCopied} task_documents`,
|
||||
undefined,
|
||||
this.currentRunContext,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
executorLog.warn(`${task.id}: worktree DB hydration failed: ${formatError(error)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (task.worktree) {
|
||||
// Task already had a worktree assigned and it exists on disk — reuse it
|
||||
executorLog.log(`Reusing existing worktree: ${worktreePath}`);
|
||||
if (this.rootDir !== worktreePath) {
|
||||
try {
|
||||
const hydration = await hydrateWorktreeDb({
|
||||
rootDir: this.rootDir,
|
||||
worktreePath,
|
||||
taskId: task.id,
|
||||
store: this.store,
|
||||
logger: executorLog,
|
||||
});
|
||||
if (hydration.degraded) {
|
||||
await this.store.logEntry(
|
||||
task.id,
|
||||
`Worktree DB hydration degraded: ${hydration.reason ?? "unknown"}`,
|
||||
undefined,
|
||||
this.currentRunContext,
|
||||
);
|
||||
} else {
|
||||
await this.store.logEntry(
|
||||
task.id,
|
||||
`Hydrated worktree DB: ${hydration.tasksCopied} tasks, ${hydration.documentsCopied} task_documents`,
|
||||
undefined,
|
||||
this.currentRunContext,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
executorLog.warn(`${task.id}: worktree DB hydration failed: ${formatError(error)}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Directory exists at generated path but task has no worktree — create via normal flow
|
||||
const created = await this.createWorktree(branchName, worktreePath, task.id);
|
||||
|
||||
172
packages/engine/src/worktree-db-hydrate.ts
Normal file
172
packages/engine/src/worktree-db-hydrate.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { DatabaseSync, TaskStore } from "@fusion/core";
|
||||
|
||||
export interface HydrateWorktreeDbParams {
|
||||
rootDir: string;
|
||||
worktreePath: string;
|
||||
taskId: string;
|
||||
store: Pick<TaskStore, "getTask">;
|
||||
logger: { warn: (message: string) => void };
|
||||
}
|
||||
|
||||
export interface HydrateWorktreeDbResult {
|
||||
tasksCopied: number;
|
||||
documentsCopied: number;
|
||||
degraded: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
const MAX_DEPTH = 5;
|
||||
const MAX_IDS = 50;
|
||||
|
||||
function getDbPath(projectDir: string): string {
|
||||
return join(projectDir, ".fusion", "fusion.db");
|
||||
}
|
||||
|
||||
function getColumns(db: DatabaseSync, table: "tasks" | "task_documents"): string[] {
|
||||
const rows = db.prepare(`PRAGMA table_info('${table}')`).all() as Array<{ name?: string }>;
|
||||
return rows.map((row) => row.name).filter((name): name is string => typeof name === "string" && name.length > 0);
|
||||
}
|
||||
|
||||
function intersectColumns(src: string[], dst: string[]) {
|
||||
const dstSet = new Set(dst);
|
||||
const shared = src.filter((column) => dstSet.has(column));
|
||||
const dropped = src.filter((column) => !dstSet.has(column));
|
||||
return { shared, dropped };
|
||||
}
|
||||
|
||||
async function resolveDependencyIds(taskId: string, store: Pick<TaskStore, "getTask">): Promise<string[]> {
|
||||
const visited = new Set<string>();
|
||||
const queue: Array<{ id: string; depth: number }> = [{ id: taskId, depth: 0 }];
|
||||
|
||||
while (queue.length > 0 && visited.size < MAX_IDS) {
|
||||
const current = queue.shift();
|
||||
if (!current || visited.has(current.id)) continue;
|
||||
visited.add(current.id);
|
||||
if (current.depth >= MAX_DEPTH) continue;
|
||||
|
||||
const task = await store.getTask(current.id);
|
||||
const deps = Array.isArray(task?.dependencies) ? task.dependencies : [];
|
||||
for (const depId of deps) {
|
||||
if (!visited.has(depId) && queue.length + visited.size < MAX_IDS) {
|
||||
queue.push({ id: depId, depth: current.depth + 1 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(visited);
|
||||
}
|
||||
|
||||
function ensureWorktreeSchema(worktreePath: string): void {
|
||||
const schemaStore = new TaskStore(worktreePath);
|
||||
schemaStore.close();
|
||||
}
|
||||
|
||||
export async function hydrateWorktreeDb({
|
||||
rootDir,
|
||||
worktreePath,
|
||||
taskId,
|
||||
store,
|
||||
logger,
|
||||
}: HydrateWorktreeDbParams): Promise<HydrateWorktreeDbResult> {
|
||||
if (rootDir === worktreePath) {
|
||||
return { tasksCopied: 0, documentsCopied: 0, degraded: false, reason: "root_worktree" };
|
||||
}
|
||||
|
||||
let srcDb: DatabaseSync | undefined;
|
||||
let dstDb: DatabaseSync | undefined;
|
||||
|
||||
try {
|
||||
const ids = await resolveDependencyIds(taskId, store);
|
||||
if (ids.length === 0) {
|
||||
return { tasksCopied: 0, documentsCopied: 0, degraded: false, reason: "no_ids" };
|
||||
}
|
||||
|
||||
const srcDbPath = getDbPath(rootDir);
|
||||
const dstDbPath = getDbPath(worktreePath);
|
||||
|
||||
if (!existsSync(srcDbPath)) {
|
||||
return { tasksCopied: 0, documentsCopied: 0, degraded: true, reason: "source_db_missing" };
|
||||
}
|
||||
|
||||
if (!existsSync(dstDbPath)) {
|
||||
ensureWorktreeSchema(worktreePath);
|
||||
}
|
||||
|
||||
srcDb = new DatabaseSync(srcDbPath);
|
||||
dstDb = new DatabaseSync(dstDbPath);
|
||||
|
||||
dstDb.exec("PRAGMA journal_mode = WAL");
|
||||
|
||||
const srcTaskCols = getColumns(srcDb, "tasks");
|
||||
const dstTaskCols = getColumns(dstDb, "tasks");
|
||||
const srcDocCols = getColumns(srcDb, "task_documents");
|
||||
const dstDocCols = getColumns(dstDb, "task_documents");
|
||||
|
||||
const { shared: taskColumns, dropped: droppedTaskColumns } = intersectColumns(srcTaskCols, dstTaskCols);
|
||||
const { shared: docColumns, dropped: droppedDocColumns } = intersectColumns(srcDocCols, dstDocCols);
|
||||
|
||||
if (taskColumns.length === 0 || docColumns.length === 0) {
|
||||
throw new Error("schema intersection empty");
|
||||
}
|
||||
|
||||
const dropped = [...droppedTaskColumns.map((c) => `tasks.${c}`), ...droppedDocColumns.map((c) => `task_documents.${c}`)];
|
||||
if (dropped.length > 0) {
|
||||
logger.warn(`Worktree DB hydration dropped columns for ${taskId}: ${dropped.join(", ")}`);
|
||||
}
|
||||
|
||||
const placeholders = ids.map(() => "?").join(", ");
|
||||
const taskColumnList = taskColumns.join(", ");
|
||||
const docColumnList = docColumns.join(", ");
|
||||
const taskValuePlaceholders = taskColumns.map(() => "?").join(", ");
|
||||
const docValuePlaceholders = docColumns.map(() => "?").join(", ");
|
||||
|
||||
const taskRows = srcDb
|
||||
.prepare(`SELECT ${taskColumnList} FROM tasks WHERE id IN (${placeholders})`)
|
||||
.all(...ids) as Array<Record<string, unknown>>;
|
||||
|
||||
const documentRows = srcDb
|
||||
.prepare(`SELECT ${docColumnList} FROM task_documents WHERE taskId IN (${placeholders})`)
|
||||
.all(...ids) as Array<Record<string, unknown>>;
|
||||
|
||||
const insertTask = dstDb.prepare(
|
||||
`INSERT OR REPLACE INTO tasks (${taskColumnList}) VALUES (${taskValuePlaceholders})`,
|
||||
);
|
||||
const insertDocument = dstDb.prepare(
|
||||
`INSERT OR REPLACE INTO task_documents (${docColumnList}) VALUES (${docValuePlaceholders})`,
|
||||
);
|
||||
|
||||
dstDb.exec("BEGIN IMMEDIATE");
|
||||
try {
|
||||
for (const row of taskRows) {
|
||||
insertTask.run(...taskColumns.map((column) => row[column]));
|
||||
}
|
||||
for (const row of documentRows) {
|
||||
insertDocument.run(...docColumns.map((column) => row[column]));
|
||||
}
|
||||
dstDb.exec("COMMIT");
|
||||
} catch (error) {
|
||||
dstDb.exec("ROLLBACK");
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
tasksCopied: taskRows.length,
|
||||
documentsCopied: documentRows.length,
|
||||
degraded: false,
|
||||
};
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
logger.warn(`Worktree DB hydration failed for ${taskId}: ${reason} (${worktreePath})`);
|
||||
return {
|
||||
tasksCopied: 0,
|
||||
documentsCopied: 0,
|
||||
degraded: true,
|
||||
reason,
|
||||
};
|
||||
} finally {
|
||||
srcDb?.close();
|
||||
dstDb?.close();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user