Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion apps/server/src/git/isolatedRunWorkspaceFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
},
);
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>) =>
Effect.flatMap(VcsProcess.VcsProcess, (process) =>
Expand Down Expand Up @@ -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)),
);
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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;
},
);
2 changes: 2 additions & 0 deletions apps/server/src/serverRuntimeStartup.autoPull.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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({
Expand Down
18 changes: 17 additions & 1 deletion apps/server/src/serverRuntimeStartup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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(
Expand Down