diff --git a/apps/server/src/git/isolatedRunWorkspaceFile.ts b/apps/server/src/git/isolatedRunWorkspaceFile.ts index 807fb13d0b9a..3a82a6c28e1c 100644 --- a/apps/server/src/git/isolatedRunWorkspaceFile.ts +++ b/apps/server/src/git/isolatedRunWorkspaceFile.ts @@ -9,6 +9,7 @@ * * @module isolatedRunWorkspaceFile */ +import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJson"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -62,7 +63,8 @@ export const writeIsolatedRunWorkspaceFile = Effect.fn("writeIsolatedRunWorkspac ); const target = WorkspaceRepositories.isolatedRunWorkspaceFilePath(input.container); const contents = yield* encodeDocument({ ...document, folders }); - yield* fileSystem.writeFileString(target, `${contents}\n`); + // Atomic, so a file that exists is a complete one and callers can skip it. + yield* writeFileStringAtomically({ filePath: target, contents: `${contents}\n` }); return target; }, ); diff --git a/apps/server/src/orchestration-v2/legacy/repairMultiRepoForkWorktrees.test.ts b/apps/server/src/orchestration-v2/legacy/repairMultiRepoForkWorktrees.test.ts index 3e6e3b906ef3..045f04ca9f72 100644 --- a/apps/server/src/orchestration-v2/legacy/repairMultiRepoForkWorktrees.test.ts +++ b/apps/server/src/orchestration-v2/legacy/repairMultiRepoForkWorktrees.test.ts @@ -14,7 +14,10 @@ import * as GitVcsDriver from "../../vcs/GitVcsDriver.ts"; import * as VcsProcess from "../../vcs/VcsProcess.ts"; import * as WorkspaceRepositories from "../../workspace/WorkspaceRepositories.ts"; import * as ThreadManagement from "../ThreadManagementService.ts"; -import { repairMultiRepoForkWorktrees } from "./repairMultiRepoForkWorktrees.ts"; +import { + repairMultiRepoForkWorktrees, + writeMultiRepoForkWorkspaceFiles, +} from "./repairMultiRepoForkWorktrees.ts"; const git = (cwd: string, args: ReadonlyArray) => Effect.flatMap(VcsProcess.VcsProcess, (process) => @@ -187,3 +190,164 @@ it.effect("does nothing on databases that never ran the fork", () => ); }).pipe(Effect.provide(NodeServices.layer)), ); + +const workspaceFileLayer = NodeSqliteClient.layer({ filename: ":memory:" }).pipe( + Layer.merge(WorkspaceRepositories.layer), + Layer.provideMerge(NodeServices.layer), +); + +const forkWorkspace = JSON.stringify({ + folders: [ + { path: "./api", name: "api" }, + { path: "./web", name: "web" }, + ], +}); + +/** A project with `api` and `web`, and a thread's container recorded in V2 at `container`. */ +const makeForkThread = Effect.fn("makeForkThread")(function* (input: { + readonly threadId: string; + readonly projectRoot: string; + readonly container: string; + /** How many worktrees the fork recorded for the thread; 2 unless set. */ + readonly forkWorktrees?: number; +}) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sql = yield* SqlClient.SqlClient; + for (const name of ["api", "web"]) { + yield* fileSystem.makeDirectory(path.join(input.projectRoot, name, ".git"), { + recursive: true, + }); + yield* fileSystem.makeDirectory(path.join(input.container, name), { recursive: true }); + } + yield* fileSystem.writeFileString( + path.join(input.projectRoot, "team.code-workspace"), + forkWorkspace, + ); + yield* fileSystem.writeFileString( + WorkspaceRepositories.isolatedRunLayoutPath(input.container), + JSON.stringify({ + repositories: ["api", "web"].map((name) => ({ + relativePath: name, + name, + source: path.join(input.projectRoot, name), + })), + }), + ); + yield* sql` + INSERT INTO orchestration_v2_projection_threads (thread_id, project_id, deleted_at, payload_json) + VALUES ( + ${input.threadId}, + ${input.threadId}, + NULL, + ${JSON.stringify({ worktreePath: input.container })} + ) + `; + yield* sql` + INSERT INTO projection_projects (project_id, workspace_root) + VALUES (${input.threadId}, ${input.projectRoot}) + `; + const forkWorktrees = ["api", "web"].slice(0, input.forkWorktrees ?? 2).map((name) => ({ + repoRoot: path.join(input.projectRoot, name), + worktreePath: path.join(input.container, name), + })); + yield* sql` + INSERT INTO projection_threads (thread_id, worktrees_json) + VALUES (${input.threadId}, ${JSON.stringify(forkWorktrees)}) + `; +}); + +const createWorkspaceFileTables = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql` + CREATE TABLE orchestration_v2_projection_threads ( + thread_id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + deleted_at TEXT, + payload_json TEXT NOT NULL + ) + `; + yield* sql`CREATE TABLE projection_projects (project_id TEXT PRIMARY KEY, workspace_root TEXT)`; + yield* sql` + CREATE TABLE projection_threads ( + thread_id TEXT PRIMARY KEY, + worktrees_json TEXT NOT NULL DEFAULT '[]' + ) + `; +}); + +it.effect("writes V2's workspace file for fork threads and keeps the fork's copy", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-fork-workspace-" }); + const projectRoot = path.join(baseDir, "team"); + const container = path.join(baseDir, "worktrees", "project-1", "thread-1"); + yield* createWorkspaceFileTables; + yield* makeForkThread({ threadId: "thread-1", projectRoot, container }); + // The fork's copy inside the container, pointed at the worktrees. + const forkCopy = path.join(container, "team.code-workspace"); + yield* fileSystem.writeFileString(forkCopy, forkWorkspace); + + assert.strictEqual(yield* writeMultiRepoForkWorkspaceFiles(), 1); + + const written = JSON.parse( + yield* fileSystem.readFileString( + WorkspaceRepositories.isolatedRunWorkspaceFilePath(container), + ), + ); + assert.deepStrictEqual(written.folders, [ + { path: path.join(container, "api"), name: "api" }, + { path: path.join(container, "web"), name: "web" }, + ]); + // The pre-V2 build opens the fork's copy, so it stays as it was. + assert.strictEqual(yield* fileSystem.readFileString(forkCopy), forkWorkspace); + const service = yield* WorkspaceRepositories.WorkspaceRepositories; + assert.strictEqual( + yield* service.workspaceFile(container), + WorkspaceRepositories.isolatedRunWorkspaceFilePath(container), + ); + + assert.strictEqual(yield* writeMultiRepoForkWorkspaceFiles(), 0); + }).pipe(Effect.provide(workspaceFileLayer)), +); + +it.effect("leaves V2 isolated runs that link the project's workspace file alone", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-fork-workspace-" }); + const projectRoot = path.join(baseDir, "team"); + const container = path.join(baseDir, "worktrees", "project-1", "thread-1"); + yield* createWorkspaceFileTables; + yield* makeForkThread({ threadId: "thread-1", projectRoot, container }); + yield* fileSystem.symlink( + path.join(projectRoot, "team.code-workspace"), + path.join(container, "team.code-workspace"), + ); + + assert.strictEqual(yield* writeMultiRepoForkWorkspaceFiles(), 0); + assert.isFalse( + yield* fileSystem.exists(WorkspaceRepositories.isolatedRunWorkspaceFilePath(container)), + ); + }).pipe(Effect.provide(workspaceFileLayer)), +); + +it.effect("leaves threads the fork gave a single worktree alone", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-fork-workspace-" }); + const projectRoot = path.join(baseDir, "team"); + const container = path.join(baseDir, "worktrees", "project-1", "thread-1"); + yield* createWorkspaceFileTables; + yield* makeForkThread({ threadId: "thread-1", projectRoot, container, forkWorktrees: 1 }); + // A real file in the container, as a root repository that commits one would leave. + yield* fileSystem.writeFileString(path.join(container, "team.code-workspace"), forkWorkspace); + + assert.strictEqual(yield* writeMultiRepoForkWorkspaceFiles(), 0); + assert.isFalse( + yield* fileSystem.exists(WorkspaceRepositories.isolatedRunWorkspaceFilePath(container)), + ); + }).pipe(Effect.provide(workspaceFileLayer)), +); diff --git a/apps/server/src/orchestration-v2/legacy/repairMultiRepoForkWorktrees.ts b/apps/server/src/orchestration-v2/legacy/repairMultiRepoForkWorktrees.ts index 0a9f15b88042..e661e8c571db 100644 --- a/apps/server/src/orchestration-v2/legacy/repairMultiRepoForkWorktrees.ts +++ b/apps/server/src/orchestration-v2/legacy/repairMultiRepoForkWorktrees.ts @@ -19,6 +19,7 @@ import * as Schema from "effect/Schema"; import * as SqlClient from "effect/sql/SqlClient"; import * as ServerConfig from "../../config.ts"; +import { writeIsolatedRunWorkspaceFile } from "../../git/isolatedRunWorkspaceFile.ts"; import * as WorkspaceRepositories from "../../workspace/WorkspaceRepositories.ts"; import * as ThreadManagement from "../ThreadManagementService.ts"; @@ -108,3 +109,92 @@ export const repairMultiRepoForkWorktrees = Effect.fn("repairMultiRepoForkWorktr } return repaired; }); + +/** + * Gives repaired fork threads the workspace file V2 writes beside a new isolated run. + * + * The fork kept its copy inside the container, so V2's "Open in" fell back to the bare folder. + * This writes V2's own file from the project's workspace file. The fork's copy stays, because + * the pre-V2 build opens it and a database can still go back to that build. Threads that + * already have the file are skipped, so this runs once per thread. + */ +export const writeMultiRepoForkWorkspaceFiles = Effect.fn("writeMultiRepoForkWorkspaceFiles")( + function* () { + const sql = yield* SqlClient.SqlClient; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspaceRepositories = yield* WorkspaceRepositories.WorkspaceRepositories; + + const columns = yield* sql<{ readonly name: string }>`PRAGMA table_info(projection_threads)`; + if (!columns.some((column) => column.name === "worktrees_json")) return 0; + + // Only threads the fork gave several worktrees can hold its copy, so a database that + // never ran the fork touches no files. + const rows = yield* sql<{ + readonly thread_id: string; + readonly worktree_path: string; + readonly workspace_root: string; + }>` + SELECT + thread.thread_id, + json_extract(thread.payload_json, '$.worktreePath') AS worktree_path, + project.workspace_root + FROM orchestration_v2_projection_threads AS thread + INNER JOIN projection_projects AS project ON project.project_id = thread.project_id + INNER JOIN projection_threads AS fork ON fork.thread_id = thread.thread_id + WHERE thread.deleted_at IS NULL + AND json_extract(thread.payload_json, '$.worktreePath') IS NOT NULL + AND CASE WHEN json_valid(fork.worktrees_json) + THEN json_array_length(fork.worktrees_json) ELSE 0 END > 1 + `; + + // Only the fork left a real workspace file in a container; V2 links the project's in. + const holdsForkCopy = (container: string) => + fileSystem.readDirectory(container).pipe( + Effect.flatMap((names) => { + const held = names.filter((name) => + name.endsWith(WorkspaceRepositories.CODE_WORKSPACE_EXTENSION), + ); + if (held.length !== 1) return Effect.succeed(false); + return fileSystem.readLink(path.join(container, held[0]!)).pipe( + Effect.as(false), + Effect.orElseSucceed(() => true), + ); + }), + Effect.orElseSucceed(() => false), + ); + + let written = 0; + for (const row of rows) { + const container = row.worktree_path; + const target = WorkspaceRepositories.isolatedRunWorkspaceFilePath(container); + if (yield* fileSystem.exists(target).pipe(Effect.orElseSucceed(() => true))) continue; + const layout = yield* workspaceRepositories.readIsolatedRun(container); + if (layout === null || !(yield* holdsForkCopy(container))) continue; + const projectWorkspaceFile = yield* workspaceRepositories.workspaceFile(row.workspace_root); + if (projectWorkspaceFile === null) continue; + + yield* writeIsolatedRunWorkspaceFile({ + projectWorkspaceFile, + container, + repositories: layout.repositories.map((repository) => ({ + source: repository.source, + worktree: path.resolve(container, repository.relativePath), + })), + }).pipe( + Effect.tap(() => + Effect.sync(() => { + written += 1; + }), + ), + Effect.catch((cause) => + Effect.logWarning("Could not write a multi-repo fork thread's workspace file", { + threadId: row.thread_id, + cause, + }), + ), + ); + } + return written; + }, +); diff --git a/apps/server/src/serverRuntimeStartup.autoPull.test.ts b/apps/server/src/serverRuntimeStartup.autoPull.test.ts index 67bff5023436..3ec169a7a15f 100644 --- a/apps/server/src/serverRuntimeStartup.autoPull.test.ts +++ b/apps/server/src/serverRuntimeStartup.autoPull.test.ts @@ -34,6 +34,7 @@ import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; import * as ServerSettings from "./serverSettings.ts"; import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; +import * as WorkspaceRepositories from "./workspace/WorkspaceRepositories.ts"; it.effect("parks automatic pull until activation without delaying command readiness", () => Effect.scoped( @@ -140,6 +141,7 @@ it.effect("parks automatic pull until activation without delaying command readin listShells: () => Effect.succeed(snapshot.projects), }), Layer.mock(ProjectService.ProjectService)({ snapshot: Effect.succeed(snapshot) }), + Layer.mock(WorkspaceRepositories.WorkspaceRepositories)({}), Layer.mock(ThreadManagement.ThreadManagementService)({ getShellSnapshot: () => Effect.succeed({ diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 5ae83d8cdfdd..cd34a1234181 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -32,7 +32,10 @@ import * as Keybindings from "./keybindings.ts"; import * as ExternalLauncher from "./process/externalLauncher.ts"; import * as EffectWorker from "./orchestration-v2/EffectWorker.ts"; import * as LegacyV1ThreadImporter from "./orchestration-v2/legacy/LegacyV1ThreadImporter.ts"; -import { repairMultiRepoForkWorktrees } from "./orchestration-v2/legacy/repairMultiRepoForkWorktrees.ts"; +import { + repairMultiRepoForkWorktrees, + writeMultiRepoForkWorkspaceFiles, +} from "./orchestration-v2/legacy/repairMultiRepoForkWorktrees.ts"; import * as Orchestrator from "./orchestration-v2/Orchestrator.ts"; import * as ProviderRuntimeRecovery from "./orchestration-v2/ProviderRuntimeRecoveryService.ts"; import * as ProviderSessionManager from "./orchestration-v2/ProviderSessionManager.ts"; @@ -553,6 +556,19 @@ const make = (options?: StartupOptions) => ), ), ); + yield* runStartupPhase( + "orchestration-v2.legacy-v1.multi-repo-fork-workspace-files", + writeMultiRepoForkWorkspaceFiles().pipe( + Effect.tap((written) => + written === 0 + ? Effect.void + : Effect.logInfo("Wrote workspace files for multi-repo fork threads", { written }), + ), + Effect.catch((cause) => + Effect.logWarning("Failed to write multi-repo fork thread workspace files", { cause }), + ), + ), + ); // Runs after activation: the status check fetches every enabled project's // remote, and awaiting it here held command readiness for that long. yield* runStartupPhase(