From 3e46d177829b62dad003b7a86260f855d38e85a1 Mon Sep 17 00:00:00 2001 From: Kinfe123 Date: Wed, 7 Oct 2026 22:40:47 -0700 Subject: [PATCH 1/7] feat(server): integrate Riftri worktree storage --- .../src/orchestration-v2/ProjectionStore.ts | 36 + .../ProjectionStore.worktreeRoots.test.ts | 71 ++ .../ProviderTurnControlService.test.ts | 1 + .../src/serverRuntimeStartup.autoPull.test.ts | 4 + apps/server/src/serverRuntimeStartup.test.ts | 85 +++ apps/server/src/serverRuntimeStartup.ts | 37 + .../src/vcs/GitVcsDriverCore.riftri.test.ts | 346 ++++++++++ apps/server/src/vcs/GitVcsDriverCore.test.ts | 11 +- apps/server/src/vcs/GitVcsDriverCore.ts | 109 ++- apps/server/src/vcs/RiftriWorktrees.test.ts | 649 ++++++++++++++++++ apps/server/src/vcs/RiftriWorktrees.ts | 600 ++++++++++++++++ docs/user/project-settings.md | 26 + scripts/build-desktop-artifact.test.ts | 6 +- scripts/build-desktop-artifact.ts | 3 +- scripts/lib/cli-external-packages.test.ts | 20 + scripts/lib/cli-external-packages.ts | 5 + scripts/lib/riftri-packaging.test.ts | 112 +++ 17 files changed, 2090 insertions(+), 31 deletions(-) create mode 100644 apps/server/src/orchestration-v2/ProjectionStore.worktreeRoots.test.ts create mode 100644 apps/server/src/vcs/GitVcsDriverCore.riftri.test.ts create mode 100644 apps/server/src/vcs/RiftriWorktrees.test.ts create mode 100644 apps/server/src/vcs/RiftriWorktrees.ts create mode 100644 scripts/lib/riftri-packaging.test.ts diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index ec75937a9a62..7e6ec2cebc8d 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -54,6 +54,7 @@ import { ThreadId, TurnItemId, NodeId, + ProjectId, } from "@t3tools/contracts"; import { createOrchestrationV2TurnItemVisibility, @@ -77,6 +78,8 @@ import { THREAD_HISTORY_MAX_RAW_TURNS, } from "./threadHistoryPaging.ts"; +const WorktreeRoot = Schema.Struct({ projectId: ProjectId, worktreePath: Schema.String }); + export class ProjectionStoreApplyEventError extends Schema.TaggedError()( "ProjectionStoreApplyEventError", { @@ -341,6 +344,11 @@ export interface ProjectionStoreV2Shape { readonly location?: "active" | "archive"; readonly unsettledOnly?: boolean; }) => Effect.Effect; + /** Storage startup needs roots, not run/session/message enrichment. Includes archives. */ + readonly getWorktreeRoots: () => Effect.Effect< + ReadonlyArray, + ProjectionStoreV2Error + >; readonly getThreadShell: ( threadId: ThreadId, ) => Effect.Effect; @@ -5332,6 +5340,19 @@ export const layer: Layer.Layer = ) .pipe(Effect.mapError((cause) => new ProjectionStoreSetupError({ cause }))); + const getWorktreeRoots: ProjectionStoreV2Shape["getWorktreeRoots"] = () => + sql` + SELECT DISTINCT + json_extract(payload_json, '$.projectId') AS projectId, + json_extract(payload_json, '$.worktreePath') AS worktreePath + FROM orchestration_v2_projection_threads + WHERE deleted_at IS NULL + AND json_extract(payload_json, '$.worktreePath') IS NOT NULL + `.pipe( + Effect.flatMap(Schema.decodeUnknownEffect(Schema.Array(WorktreeRoot))), + Effect.mapError((cause) => new ProjectionStoreSetupError({ cause })), + ); + const getThreadsWithPullRequests: ProjectionStoreV2Shape["getThreadsWithPullRequests"] = ( threadId, ) => @@ -5671,6 +5692,7 @@ export const layer: Layer.Layer = return { apply, getShellSnapshot, + getWorktreeRoots, getThreadShell, getThread, getSettlementCandidates, @@ -5713,6 +5735,20 @@ export const layerMemory: Layer.Layer = Layer.effect( const sequence = yield* Ref.make(0); const service: ProjectionStoreV2Shape = { + getWorktreeRoots: () => + Ref.get(replayState).pipe( + Effect.map(({ projections }) => { + const roots = new Map(); + for (const { thread } of projections.values()) { + if (thread.deletedAt !== null || thread.worktreePath === null) continue; + roots.set(JSON.stringify([thread.projectId, thread.worktreePath]), { + projectId: thread.projectId, + worktreePath: thread.worktreePath, + }); + } + return [...roots.values()]; + }), + ), apply: (event) => Effect.gen(function* () { const result = yield* Ref.modify(replayState, (existing) => { diff --git a/apps/server/src/orchestration-v2/ProjectionStore.worktreeRoots.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.worktreeRoots.test.ts new file mode 100644 index 000000000000..d3e3272a2966 --- /dev/null +++ b/apps/server/src/orchestration-v2/ProjectionStore.worktreeRoots.test.ts @@ -0,0 +1,71 @@ +import { assert, it } from "@effect/vitest"; +import { EventId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as ProjectionStore from "./ProjectionStore.ts"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; + +it.effect.each([ + { + name: "sqlite", + layer: ProjectionStore.layer.pipe(Layer.provide(SqlitePersistence.layerMemory)), + }, + { name: "memory", layer: ProjectionStore.layerMemory }, +])("$name: startup reads distinct live worktree roots, including archived threads", ({ layer }) => + Effect.gen(function* () { + const store = yield* ProjectionStore.ProjectionStoreV2; + const now = yield* DateTime.now; + const providerInstanceId = ProviderInstanceId.make("codex"); + for (const [suffix, worktreePath, archived, deleted, project] of [ + ["main", null, false, false, "one"], + ["active", "/views/shared", false, false, "one"], + ["duplicate", "/views/shared", false, false, "one"], + ["archived", "/views/archived", true, false, "one"], + ["deleted", "/views/deleted", false, true, "one"], + ["other-project", "/views/shared", false, false, "two"], + ] as const) { + const threadId = ThreadId.make(`thread:${suffix}`); + yield* store.apply({ + id: EventId.make(`event:${suffix}`), + type: "thread.created", + threadId, + occurredAt: now, + payload: { + id: threadId, + projectId: ProjectId.make(project), + title: suffix, + createdBy: "user", + creationSource: "web", + providerInstanceId, + modelSelection: { instanceId: providerInstanceId, model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath, + activeProviderThreadId: null, + lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, + forkedFrom: null, + createdAt: now, + updatedAt: now, + archivedAt: archived ? now : null, + deletedAt: deleted ? now : null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + }, + }); + } + const roots = yield* store.getWorktreeRoots(); + assert.deepEqual( + roots.toSorted((a, b) => + `${a.projectId}:${a.worktreePath}`.localeCompare(`${b.projectId}:${b.worktreePath}`), + ), + [ + { projectId: "one", worktreePath: "/views/archived" }, + { projectId: "one", worktreePath: "/views/shared" }, + { projectId: "two", worktreePath: "/views/shared" }, + ], + ); + }).pipe(Effect.provide(layer)), +); diff --git a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts index 7918f819eee2..50809b5d9cff 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts @@ -213,6 +213,7 @@ it.effect( apply: () => Effect.void, getLimitRecoveryCandidates: () => Effect.die("unused getLimitRecoveryCandidates"), getShellSnapshot: () => Effect.die("unused getShellSnapshot"), + getWorktreeRoots: () => Effect.die("unused getWorktreeRoots"), getThreadShell: () => Effect.die("unused getThreadShell"), getThread: () => Ref.get(projection).pipe(Effect.map((state) => state.thread)), getSettlementCandidates: () => Effect.die("unused getSettlementCandidates"), diff --git a/apps/server/src/serverRuntimeStartup.autoPull.test.ts b/apps/server/src/serverRuntimeStartup.autoPull.test.ts index 8313d33dc220..d2a73ccd788f 100644 --- a/apps/server/src/serverRuntimeStartup.autoPull.test.ts +++ b/apps/server/src/serverRuntimeStartup.autoPull.test.ts @@ -1,4 +1,5 @@ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import { expect, it } from "@effect/vitest"; import { DEFAULT_SERVER_SETTINGS, EnvironmentId, ProjectId } from "@t3tools/contracts"; import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability"; @@ -19,6 +20,7 @@ import * as EffectWorker from "./orchestration-v2/EffectWorker.ts"; import * as LegacyV1ThreadImporter from "./orchestration-v2/legacy/LegacyV1ThreadImporter.ts"; import * as Orchestrator from "./orchestration-v2/Orchestrator.ts"; import * as ProjectStore from "./orchestration-v2/ProjectStore.ts"; +import * as ProjectionStore from "./orchestration-v2/ProjectionStore.ts"; import * as ProviderRuntimeRecovery from "./orchestration-v2/ProviderRuntimeRecoveryService.ts"; import * as ProviderSessionManager from "./orchestration-v2/ProviderSessionManager.ts"; import * as ThreadLaunch from "./orchestration-v2/ThreadLaunchService.ts"; @@ -137,6 +139,8 @@ it.effect("parks automatic pull until activation without delaying command readin Layer.mock(ProjectStore.ProjectStoreV2)({ listShells: () => Effect.succeed(snapshot.projects), }), + Layer.mock(ProjectionStore.ProjectionStoreV2)({ getWorktreeRoots: () => Effect.succeed([]) }), + Layer.mock(ChildProcessSpawner.ChildProcessSpawner)({}), Layer.mock(ProjectService.ProjectService)({ snapshot: Effect.succeed(snapshot) }), Layer.mock(ThreadManagement.ThreadManagementService)({ getShellSnapshot: () => diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index 9aa6ebfaa45f..fca3645e0f4b 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -10,8 +10,15 @@ import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as Ref from "effect/Ref"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; +import * as ProjectStore from "./orchestration-v2/ProjectStore.ts"; +import * as ProjectionStore from "./orchestration-v2/ProjectionStore.ts"; +import * as ProcessRunner from "./processRunner.ts"; import * as ServerConfig from "./config.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; @@ -23,6 +30,62 @@ it("uses the canonical Codex model for auto-bootstrap", () => { }); }); +it.effect("restores persisted worktrees by repository before their providers can resume", () => + Effect.gen(function* () { + const calls: { cwd: string | undefined; args: ReadonlyArray }[] = []; + let repaired = false; + yield* ServerRuntimeStartup.restorePersistedWorktrees.pipe( + Effect.provide(Layer.mock(ProjectStore.ProjectStoreV2)({ + listShells: () => Effect.succeed(["one", "two", "plain"].map((id) => ({ + id: ProjectId.make(id), title: id, + workspaceRoot: id === "plain" ? "/not-a-repository" : "/repository", + defaultModelSelection: null, scripts: [], + createdAt: "2026-10-07T00:00:00.000Z", updatedAt: "2026-10-07T00:00:00.000Z", deletedAt: null, + }))), + })), + Effect.provide(Layer.mock(ProjectionStore.ProjectionStoreV2)({ + getWorktreeRoots: () => Effect.succeed([ + { projectId: ProjectId.make("one"), worktreePath: "/views/a" }, + { projectId: ProjectId.make("two"), worktreePath: "/views/a" }, + { projectId: ProjectId.make("two"), worktreePath: "/views/b" }, + { projectId: ProjectId.make("deleted-project"), worktreePath: "/views/deleted-project" }, + ]), + })), + Effect.provideService(ProcessRunner.ProcessRunner, { + run: (input) => { + calls.push({ cwd: input.cwd, args: input.args }); + let report: unknown; + if (input.args[0] === "repair") { + repaired = true; + report = { schema_version: 1, errors: [] }; + } else { + report = { + schema_version: 1, native_path_encoding: "unix-bytes-hex", + worktrees: input.args.slice(input.args.indexOf("--") + 1).map((path) => ({ + path, path_native_hex: Buffer.from(path).toString("hex"), + state_directory: "/state", state_directory_native_hex: Buffer.from("/state").toString("hex"), + backend: "overlay-fs", mount_status: repaired ? "active" : "recovery-required", + })), + }; + } + return Effect.succeed({ + code: ChildProcessSpawner.ExitCode(0), stdout: JSON.stringify(report), stderr: "", + timedOut: false, stdoutTruncated: false, stderrTruncated: false, + stdoutInvalidUtf8: false, stderrInvalidUtf8: false, + }); + }, + }), + Effect.provideService(HostProcessEnvironment, { RIFTRI_BINARY: "/test/riftri" }), + Effect.provide(Path.layer), + ); + assert.deepEqual(calls.map(({ args }) => args.slice(0, 2)), [ + ["worktree", "inspect"], ["repair", "--state-dir=/state"], ["worktree", "inspect"], + ]); + assert.isTrue(calls.every(({ cwd }) => cwd === "/repository")); + assert.deepEqual(calls[0]!.args.slice(calls[0]!.args.indexOf("--") + 1), ["/views/a", "/views/b"]); + }), +); + it.effect("starts without scanning or rebuilding projection history", () => Effect.gen(function* () { const calls = yield* Ref.make>([]); @@ -30,6 +93,7 @@ it.effect("starts without scanning or rebuilding projection history", () => const result = yield* ServerRuntimeStartup.runOrderedV2StartupPhases({ importLegacyShells: record("import"), + restoreWorktreeStorage: record("storage"), recover: record("recover").pipe(Effect.as({ closedRequests: 2 })), recoverDelegatedTasks: record("delegated"), startEffectWorker: record("worker"), @@ -40,6 +104,7 @@ it.effect("starts without scanning or rebuilding projection history", () => // before the worker runs restart continuations that would otherwise race it. assert.deepEqual(yield* Ref.get(calls), [ "import", + "storage", "recover", "delegated", "worker", @@ -52,6 +117,26 @@ it.effect("starts without scanning or rebuilding projection history", () => }), ); +it.effect("an unsafe persisted worktree prevents provider recovery and background work", () => + Effect.gen(function* () { + const calls: string[] = []; + const record = (label: string) => + Effect.sync(() => { + calls.push(label); + }); + const result = yield* ServerRuntimeStartup.runOrderedV2StartupPhases({ + importLegacyShells: record("import"), + restoreWorktreeStorage: record("storage").pipe(Effect.andThen(Effect.fail("mount unavailable"))), + recover: record("recover"), + recoverDelegatedTasks: record("delegated"), + startEffectWorker: record("worker"), + autoBootstrap: record("bootstrap"), + }).pipe(Effect.exit); + assert.isTrue(Exit.isFailure(result)); + assert.deepEqual(calls, ["import", "storage"]); + }), +); + it.effect("interrupts the effect worker when awareness relay startup fails", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 88aa4298c596..e23c444897b1 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -39,7 +39,10 @@ import * as ThreadLaunch from "./orchestration-v2/ThreadLaunchService.ts"; import * as ThreadManagement from "./orchestration-v2/ThreadManagementService.ts"; import * as ProjectService from "./project/ProjectService.ts"; import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; +import * as RiftriWorktrees from "./vcs/RiftriWorktrees.ts"; +import * as ProcessRunner from "./processRunner.ts"; import * as ProjectStore from "./orchestration-v2/ProjectStore.ts"; +import * as ProjectionStore from "./orchestration-v2/ProjectionStore.ts"; import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as ServerSettings from "./serverSettings.ts"; @@ -381,22 +384,47 @@ export const startEffectWorkerWithRelay = Effect.fn( ); }); +export const restorePersistedWorktrees = Effect.gen(function* () { + const projects = yield* (yield* ProjectStore.ProjectStoreV2).listShells(); + const roots = yield* (yield* ProjectionStore.ProjectionStoreV2).getWorktreeRoots(); + const repositories = new Map(projects.map((project) => [project.id, project.workspaceRoot])); + const pathsByRepository = new Map>(); + for (const root of roots) { + const repository = repositories.get(root.projectId); + if (repository === undefined) continue; + let paths = pathsByRepository.get(repository); + if (paths === undefined) { + paths = new Set(); + pathsByRepository.set(repository, paths); + } + paths.add(root.worktreePath); + } + if (pathsByRepository.size === 0) return; + const storage = yield* RiftriWorktrees.make(); + for (const [cwd, destinations] of pathsByRepository) { + yield* storage.restore({ cwd, destinations: [...destinations] }); + } +}); + export function runOrderedV2StartupPhases< Import, Recovery, Bootstrap, ImportError, + StorageError, RecoveryError, DelegationError, WorkerError, BootstrapError, ImportContext, + StorageContext, RecoveryContext, DelegationContext, WorkerContext, BootstrapContext, >(input: { readonly importLegacyShells: Effect.Effect; + readonly restoreWorktreeStorage: Effect.Effect; readonly recover: Effect.Effect; /** Settles delegated tasks whose runs recovery just terminalized. */ readonly recoverDelegatedTasks: Effect.Effect; @@ -405,6 +433,7 @@ export function runOrderedV2StartupPhases< }) { return Effect.gen(function* () { yield* input.importLegacyShells; + yield* input.restoreWorktreeStorage; const recovery = yield* input.recover; yield* input.recoverDelegatedTasks; yield* input.startEffectWorker; @@ -515,6 +544,14 @@ const make = (options?: StartupOptions) => ), ), ), + restoreWorktreeStorage: runStartupPhase( + "worktrees.storage.restore", + // Native clones survive a reboot without mounts. Only Linux can + // have journaled views whose files are hidden until remounting. + environment.platform.os === "linux" + ? restorePersistedWorktrees.pipe(Effect.provide(ProcessRunner.layer)) + : Effect.void, + ), recover: runStartupPhase("orchestration-v2.recovery", providerRuntimeRecovery.recover), recoverDelegatedTasks: runStartupPhase( "orchestration-v2.delegated-tasks.recover", diff --git a/apps/server/src/vcs/GitVcsDriverCore.riftri.test.ts b/apps/server/src/vcs/GitVcsDriverCore.riftri.test.ts new file mode 100644 index 000000000000..193125a841c6 --- /dev/null +++ b/apps/server/src/vcs/GitVcsDriverCore.riftri.test.ts @@ -0,0 +1,346 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Result from "effect/Result"; +import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as ProcessRunner from "../processRunner.ts"; +import * as ServerConfig from "../config.ts"; +import { makeGitVcsDriverCore } from "./GitVcsDriverCore.ts"; + +const nativeBinary = process.env.T3CODE_TEST_RIFTRI_BINARY; +// The packaged pass must resolve the installed dependency through the driver, +// while fixture cleanup can still address that package's executable directly. +const packaged = process.env.T3CODE_TEST_RIFTRI_PACKAGE === "1"; + +it.effect.skipIf(!process.env.T3CODE_TEST_RIFTRI_BINARY)( + "native preflight conflicts preserve Git driver error reasons and existing content", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const platform = yield* HostProcessPlatform; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-riftri-conflicts-" }); + const cwd = path.join(root, "repository"); + yield* fs.makeDirectory(cwd); + const environment = { + ...process.env, + RIFTRI_BINARY: packaged ? undefined : nativeBinary, + RIFTRI_BYPASS: "1", + GIT_CONFIG_GLOBAL: platform === "win32" ? "NUL" : "/dev/null", + GIT_CONFIG_NOSYSTEM: "1", + }; + const runner = yield* ProcessRunner.make(); + const git = (args: ReadonlyArray) => + runner.run({ command: "git", args, cwd, env: environment }).pipe( + Effect.tap((result) => Effect.sync(() => assert.equal(result.code, 0, result.stderr))), + Effect.map((result) => result.stdout), + ); + yield* git(["init", "-b", "main"]); + yield* git(["config", "user.name", "T3 test"]); + yield* git(["config", "user.email", "t3@example.invalid"]); + yield* git(["-c", "commit.gpgsign=false", "commit", "--allow-empty", "-m", "fixture"]); + yield* git(["branch", "taken"]); + const occupiedFile = path.join(root, "file"); + const occupiedDirectory = path.join(root, "directory"); + yield* fs.writeFileString(occupiedFile, "preserve file"); + yield* fs.makeDirectory(occupiedDirectory); + yield* fs.writeFileString(path.join(occupiedDirectory, "keep.txt"), "preserve directory"); + const worktreesBefore = yield* git(["worktree", "list", "--porcelain"]); + const config = ServerConfig.layerTest(cwd, path.join(root, "t3")); + let claimed = 0; + for (const mode of ["git", "auto", "riftri"] as const) { + const branchesBefore = yield* git(["show-ref"]); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provide(config), + Effect.provideService(HostProcessEnvironment, { + ...environment, + T3CODE_WORKTREE_STORAGE: mode, + }), + ); + for (const { input, reason } of [ + { + input: { cwd, path: path.join(root, "branch"), refName: "main", newRefName: "taken" }, + reason: "branch_already_exists", + }, + { + input: { cwd, path: path.join(root, "busy"), refName: "main" }, + reason: "branch_checked_out_in_worktree", + }, + { + input: { cwd, path: occupiedFile, refName: "main", newRefName: `${mode}-unused-file` }, + reason: "path_already_exists", + }, + { + input: { + cwd, + path: occupiedDirectory, + refName: "main", + newRefName: `${mode}-unused-dir`, + }, + reason: "path_already_exists", + }, + ]) { + const result = yield* driver + .createWorktree(input, { + progress: { + onWorktreeClaimed: () => + Effect.sync(() => { + claimed++; + }), + }, + }) + .pipe(Effect.result); + assert.isTrue(Result.isFailure(result)); + if (Result.isFailure(result)) { + assert.equal(result.failure.reason, reason, `${mode}: ${result.failure.message}`); + } + } + // Plain Git may create the requested branch before rejecting an + // occupied path. Native preflight must not introduce that side effect. + if (mode !== "git") assert.equal(yield* git(["show-ref"]), branchesBefore); + } + assert.equal(claimed, 0); + assert.equal(yield* git(["worktree", "list", "--porcelain"]), worktreesBefore); + assert.equal(yield* fs.readFileString(occupiedFile), "preserve file"); + assert.equal( + yield* fs.readFileString(path.join(occupiedDirectory, "keep.txt")), + "preserve directory", + ); + assert.isFalse(yield* fs.exists(path.join(root, ".t3-riftri"))); + assert.isFalse(yield* fs.exists(path.join(root, "branch"))); + assert.isFalse(yield* fs.exists(path.join(root, "busy"))); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + { timeout: 30_000 }, +); + +it.effect.skipIf(!process.env.T3CODE_TEST_RIFTRI_BINARY)( + "the shared Git driver creates managed worktrees and keeps managed cleanup in git mode", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const platform = yield* HostProcessPlatform; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-core-riftri-" }); + const cwd = path.join(root, "repository"); + yield* fs.makeDirectory(cwd); + const environment = { + ...process.env, + RIFTRI_BINARY: packaged ? undefined : nativeBinary, + RIFTRI_BYPASS: "1", + GIT_CONFIG_GLOBAL: platform === "win32" ? "NUL" : "/dev/null", + GIT_CONFIG_NOSYSTEM: "1", + T3CODE_WORKTREE_STORAGE: "riftri", + }; + const runner = yield* ProcessRunner.make(); + const git = (args: ReadonlyArray, directory = cwd) => + runner + .run({ command: "git", args, cwd: directory, env: environment }) + .pipe( + Effect.tap((result) => Effect.sync(() => assert.equal(result.code, 0, result.stderr))), + ); + yield* git(["init", "-b", "main"]); + yield* git(["config", "user.name", "T3 test"]); + yield* git(["config", "user.email", "t3@example.invalid"]); + yield* fs.writeFileString(path.join(cwd, "tracked.txt"), "original\n"); + yield* git(["add", "."]); + yield* git(["-c", "commit.gpgsign=false", "commit", "-m", "fixture"]); + const config = ServerConfig.layerTest(cwd, path.join(root, "t3")); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provide(config), + Effect.provideService(HostProcessEnvironment, environment), + ); + const destination = path.join(root, "view"); + // Bases are intentionally immutable. Always retire the fixture through + // the lifecycle before the temporary-directory finalizer removes it. + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + const repaired = yield* runner.run({ + command: nativeBinary!, + args: ["repair", `--state-dir=${path.join(root, ".t3-riftri")}`], + cwd, + env: environment, + }); + assert.equal(repaired.code, 0, repaired.stderr); + if (yield* fs.exists(destination)) { + yield* driver.removeWorktree({ cwd, path: destination, force: true }); + } + const collected = yield* runner.run({ + command: nativeBinary!, + args: ["gc", "--apply", "--yes", `--state-dir=${path.join(root, ".t3-riftri")}`], + cwd, + env: environment, + }); + assert.equal(collected.code, 0, collected.stderr); + }).pipe(Effect.orDie), + ); + const claimed: string[] = []; + const created = yield* driver.createWorktree( + { cwd, path: destination, refName: "main", newRefName: "topic" }, + { + progress: { + onWorktreeClaimed: (view) => + Effect.sync(() => { + claimed.push(view); + }), + }, + }, + ); + assert.equal(created.worktree.path, destination); + assert.deepEqual(claimed, [destination]); + const owner = yield* runner.run({ + command: nativeBinary!, + args: ["worktree", "owner", "--json", "--", destination], + cwd, + env: environment, + }); + assert.equal(owner.code, 0, owner.stderr); + assert.include(owner.stdout, ".t3-riftri"); + assert.equal((yield* git(["status", "--porcelain"], destination)).stdout, ""); + const restarted = yield* makeGitVcsDriverCore().pipe( + Effect.provide(config), + Effect.provideService(HostProcessEnvironment, { + ...environment, + T3CODE_WORKTREE_STORAGE: "git", + }), + ); + yield* fs.writeFileString(path.join(destination, "tracked.txt"), "private edit\n"); + assert.isTrue( + Result.isFailure( + yield* restarted.removeWorktree({ cwd, path: destination }).pipe(Effect.result), + ), + ); + assert.equal( + yield* fs.readFileString(path.join(destination, "tracked.txt")), + "private edit\n", + ); + yield* fs.writeFileString(path.join(destination, "tracked.txt"), "original\n"); + yield* restarted.removeWorktree({ cwd, path: destination }); + yield* restarted.pruneWorktrees({ cwd }); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + { timeout: 30_000 }, +); + +it.effect.each([ + "HEAD", + "commit", + "tag", + "topic", + "refs/heads/topic", + "origin/topic", + "remote-only", + "ambiguous", + "@{-1}", + "submodule-tree", +])( + "matches ordinary Git's worktree reference semantics for %s", + (reference) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const platform = yield* HostProcessPlatform; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-riftri-refs-" }); + const cwd = path.join(root, "repository"); + yield* fs.makeDirectory(cwd); + const environment = { + ...process.env, + RIFTRI_BINARY: packaged ? undefined : nativeBinary, + RIFTRI_BYPASS: "1", + GIT_CONFIG_GLOBAL: platform === "win32" ? "NUL" : "/dev/null", + GIT_CONFIG_NOSYSTEM: "1", + T3CODE_WORKTREE_STORAGE: "auto", + }; + const runner = yield* ProcessRunner.make(); + const git = (args: ReadonlyArray, directory = cwd) => + runner.run({ command: "git", args, cwd: directory, env: environment }).pipe( + Effect.tap((result) => Effect.sync(() => assert.equal(result.code, 0, result.stderr))), + Effect.map((result) => result.stdout.trim()), + ); + yield* git(["init", "-b", "main"]); + yield* git(["config", "user.name", "T3 test"]); + yield* git(["config", "user.email", "t3@example.invalid"]); + yield* fs.writeFileString(path.join(cwd, "tracked.txt"), "original\n"); + yield* git(["add", "."]); + yield* git(["-c", "commit.gpgsign=false", "commit", "-m", "fixture"]); + const commit = yield* git(["rev-parse", "HEAD"]); + yield* git(["branch", "topic"]); + yield* git(["tag", "tag"]); + yield* git(["remote", "add", "origin", cwd]); + yield* git(["update-ref", "refs/remotes/origin/topic", commit]); + yield* git(["update-ref", "refs/remotes/origin/remote-only", commit]); + if (reference === "ambiguous") { + yield* git(["tag", "ambiguous"]); + yield* fs.writeFileString(path.join(cwd, "tracked.txt"), "branch differs from tag\n"); + yield* git(["-c", "commit.gpgsign=false", "commit", "-am", "branch change"]); + yield* git(["branch", "ambiguous"]); + } + if (reference === "@{-1}") { + yield* git(["switch", "topic"]); + yield* git(["switch", "main"]); + } + if (reference === "submodule-tree") { + yield* fs.writeFileString( + path.join(cwd, ".gitmodules"), + '[submodule "child"]\n\tpath = child\n\turl = ./child.git\n', + ); + yield* git(["add", ".gitmodules"]); + yield* git(["update-index", "--add", "--cacheinfo", `160000,${commit},child`]); + yield* git(["-c", "commit.gpgsign=false", "commit", "-m", "submodule"]); + yield* git(["branch", "submodule-tree"]); + } + const refName = reference === "commit" ? commit : reference; + const destination = path.join(root, "view"); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provide(ServerConfig.layerTest(cwd, path.join(root, "t3"))), + Effect.provideService(HostProcessEnvironment, environment), + ); + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + if (yield* fs.exists(destination)) { + yield* driver.removeWorktree({ cwd, path: destination, force: true }); + } + if (!(yield* fs.exists(path.join(root, ".t3-riftri")))) return; + const collected = yield* runner.run({ + command: nativeBinary!, + args: ["gc", "--apply", "--yes", `--state-dir=${path.join(root, ".t3-riftri")}`], + cwd, + env: environment, + }); + assert.equal(collected.code, 0, collected.stderr); + }).pipe(Effect.orDie), + ); + yield* git(["worktree", "add", destination, refName]); + const expectedCommit = yield* git(["rev-parse", "HEAD"], destination); + const expectedBranch = yield* git(["branch", "--show-current"], destination); + const expectedTracking = yield* git([ + "for-each-ref", + "--format=%(upstream)", + `refs/heads/${expectedBranch}`, + ]); + yield* git(["worktree", "remove", destination]); + if (reference === "remote-only") yield* git(["branch", "-D", "remote-only"]); + + yield* driver.createWorktree({ cwd, path: destination, refName }, { submodules: "none" }); + assert.equal(yield* git(["rev-parse", "HEAD"], destination), expectedCommit); + assert.equal(yield* git(["branch", "--show-current"], destination), expectedBranch); + assert.equal( + yield* git(["for-each-ref", "--format=%(upstream)", `refs/heads/${expectedBranch}`]), + expectedTracking, + ); + assert.equal(yield* git(["status", "--porcelain"], destination), ""); + const owner = yield* runner.run({ + command: nativeBinary!, + args: ["worktree", "owner", "--json", "--", destination], + cwd, + env: environment, + }); + assert.equal(owner.code, 0, owner.stderr); + if (reference === "remote-only" || reference === "submodule-tree") { + assert.include(owner.stdout, '"state_directory": null'); + } else { + assert.include(owner.stdout, ".t3-riftri"); + } + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + { timeout: 30_000, skip: !process.env.T3CODE_TEST_RIFTRI_BINARY }, +); diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index c6e24caa6250..15a6b3b9b6c8 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -1,7 +1,7 @@ // @effect-diagnostics nodeBuiltinImport:off - realpathSync.native resolves Windows 8.3 short names, which the Effect realPath does not. import * as NodeFS from "node:fs"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { assert, it, describe } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -46,6 +46,15 @@ const layerServerConfig = ServerConfig.layerTest(process.cwd(), { const layerTest = GitVcsDriver.layer.pipe( Layer.provide(layerServerConfig), Layer.provideMerge(NodeServices.layer), + // These fixtures assert ordinary Git checkout progress and prune behavior. + // Native ownership/recovery and retained-base cleanup have real filesystem + // coverage in GitVcsDriverCore.riftri.test.ts. + Layer.provideMerge( + Layer.succeed(HostProcessEnvironment, { + ...process.env, + T3CODE_WORKTREE_STORAGE: "git", + }), + ), ); const makeNonRepositoryHandle = () => diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 140786cce0a4..a70b526b5c31 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -43,6 +43,8 @@ import { parseRemoteRefWithRemoteNames, } from "../git/remoteRefs.ts"; import * as ServerConfig from "../config.ts"; +import * as ProcessRunner from "../processRunner.ts"; +import * as RiftriWorktrees from "./RiftriWorktrees.ts"; const DEFAULT_TIMEOUT_MS = 30_000; const gitProcesses = Semaphore.makeUnsafe(8); @@ -905,6 +907,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const { worktreesDir } = yield* ServerConfig.ServerConfig; const crypto = yield* Crypto.Crypto; + const worktreeStorage = yield* RiftriWorktrees.make().pipe(Effect.provide(ProcessRunner.layer)); const executeRaw: GitVcsDriver.GitVcsDriver["Service"]["execute"] = Effect.fnUntraced( function* (input) { @@ -3379,29 +3382,55 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const progress = options?.progress; const onCheckoutProgress = progress?.onCheckoutProgress; - const checkoutWorkers = (yield* readConfigValue(input.cwd, "checkout.workers")) ?? "0"; - yield* executeGit( - "GitVcsDriver.createWorktree", - input.cwd, - ["-c", `checkout.workers=${checkoutWorkers}`, ...args], - { - fallbackErrorDetail: "git worktree add failed", - timeoutMs: WORKTREE_ADD_TIMEOUT_MS, - ...(onCheckoutProgress - ? { - // Git only prints checkout progress when stderr is a tty or the - // delay elapsed. GIT_PROGRESS_DELAY=0 forces it through the pipe. - env: { GIT_PROGRESS_DELAY: "0", LC_ALL: "C" }, - progress: { - onStderrLine: (line) => { - const parsed = parseGitCheckoutProgressLine(line); - return parsed ? onCheckoutProgress(parsed) : Effect.void; + const optimized = yield* worktreeStorage + .create({ + cwd: input.cwd, + destination: worktreePath, + revision: input.refName, + ...(input.newRefName ? { branch: input.newRefName } : {}), + }) + .pipe( + Effect.mapError( + (cause) => + new GitCommandError({ + operation: "GitVcsDriver.createWorktree", + command: "riftri worktree add", + cwd: input.cwd, + ...cause.gitErrorFields, + cause, + }), + ), + ); + if (optimized === null) { + const checkoutWorkers = (yield* readConfigValue(input.cwd, "checkout.workers")) ?? "0"; + yield* executeGit( + "GitVcsDriver.createWorktree", + input.cwd, + ["-c", `checkout.workers=${checkoutWorkers}`, ...args], + { + fallbackErrorDetail: "git worktree add failed", + timeoutMs: WORKTREE_ADD_TIMEOUT_MS, + env: { RIFTRI_BYPASS: "1" }, + ...(onCheckoutProgress + ? { + // Git only prints checkout progress when stderr is a tty or the + // delay elapsed. GIT_PROGRESS_DELAY=0 forces it through the pipe. + env: { + RIFTRI_BYPASS: "1", + GIT_PROGRESS_DELAY: "0", + LC_ALL: "C", }, - }, - } - : {}), - }, - ); + progress: { + onStderrLine: (line) => { + const parsed = parseGitCheckoutProgressLine(line); + return parsed ? onCheckoutProgress(parsed) : Effect.void; + }, + }, + } + : {}), + }, + ); + } if (progress?.onWorktreeClaimed) { yield* progress.onWorktreeClaimed(worktreePath); @@ -3743,6 +3772,25 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const removeWorktree: GitVcsDriver.GitVcsDriver["Service"]["removeWorktree"] = Effect.fn( "removeWorktree", )(function* (input) { + const removed = yield* worktreeStorage + .remove({ + cwd: input.cwd, + destination: input.path, + ...(input.force ? { force: true } : {}), + }) + .pipe( + Effect.mapError( + (cause) => + new GitCommandError({ + operation: "GitVcsDriver.removeWorktree", + command: "riftri worktree remove", + cwd: input.cwd, + ...cause.gitErrorFields, + cause, + }), + ), + ); + if (removed) return; const args = ["worktree", "remove"]; if (input.force) { args.push("--force"); @@ -3757,6 +3805,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* // minutes, especially on Windows. Keep it bounded without interrupting // git midway through cleanup. timeoutMs: WORKTREE_REMOVE_TIMEOUT_MS, + env: { RIFTRI_BYPASS: "1" }, allowNonZeroExit: true, }, ); @@ -3792,10 +3841,18 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const pruneWorktrees: GitVcsDriver.GitVcsDriver["Service"]["pruneWorktrees"] = Effect.fn( "pruneWorktrees", )(function* (input) { - yield* executeGit("GitVcsDriver.pruneWorktrees", input.cwd, ["worktree", "prune"], { - timeoutMs: 15_000, - fallbackErrorDetail: "git worktree prune failed", - }); + yield* worktreeStorage.prune(input.cwd).pipe( + Effect.mapError( + (cause) => + new GitCommandError({ + operation: "GitVcsDriver.pruneWorktrees", + command: "riftri worktree prune", + cwd: input.cwd, + ...cause.gitErrorFields, + cause, + }), + ), + ); }); const deleteLocalBranch: GitVcsDriver.GitVcsDriver["Service"]["deleteLocalBranch"] = Effect.fn( diff --git a/apps/server/src/vcs/RiftriWorktrees.test.ts b/apps/server/src/vcs/RiftriWorktrees.test.ts new file mode 100644 index 000000000000..20076f8be069 --- /dev/null +++ b/apps/server/src/vcs/RiftriWorktrees.test.ts @@ -0,0 +1,649 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Deferred from "effect/Deferred"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Result from "effect/Result"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as ProcessRunner from "../processRunner.ts"; +import * as RiftriWorktrees from "./RiftriWorktrees.ts"; + +const output = (code: number, stdout: unknown, stderr = ""): ProcessRunner.ProcessRunOutput => ({ + code: ChildProcessSpawner.ExitCode(code), + stdout: stdout === undefined ? "" : JSON.stringify(stdout), + stderr, + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, +}); + +const inspectionView = (destination: string, mount: string | null = null) => ({ + path: destination, + path_native_hex: Buffer.from(destination).toString("hex"), + state_directory: "/storage/state", + state_directory_native_hex: Buffer.from("/storage/state").toString("hex"), + backend: mount === null ? "reflink" : "overlay-fs", + mount_status: mount, +}); +const inspection = (views: ReadonlyArray) => + output(0, { schema_version: 1, native_path_encoding: "unix-bytes-hex", worktrees: views }); + +it.effect("large restart batches stay bounded and validate every batch before repair", () => + Effect.gen(function* () { + const destinations = Array.from( + { length: 100 }, + (_, index) => `/views/${"x".repeat(100)}/${index}`, + ); + const calls: ReadonlyArray[] = []; + const storage = yield* RiftriWorktrees.make().pipe( + Effect.provideService(ProcessRunner.ProcessRunner, { + run: ({ args }) => { + calls.push(args); + assert.equal(args[1], "inspect", "a later foreign mount must prevent every repair"); + assert.isBelow(Buffer.byteLength(args.join(" ")), 8192); + const paths = args.slice(args.indexOf("--") + 1); + return Effect.succeed( + inspection( + paths.map((destination) => + inspectionView( + destination, + destination === destinations.at(-1) ? "foreign" : "recovery-required", + ), + ), + ), + ); + }, + }), + Effect.provideService(HostProcessEnvironment, { RIFTRI_BINARY: "/test/riftri" }), + ); + assert.isTrue( + Result.isFailure(yield* storage.restore({ cwd: "/repo", destinations }).pipe(Effect.result)), + ); + assert.isAbove(calls.length, 1); + assert.deepEqual( + calls.flatMap((args) => args.slice(args.indexOf("--") + 1)), + destinations, + ); + }).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect( + "restart restores missing mounts once per state even in git mode and rechecks readiness", + () => + Effect.gen(function* () { + const calls: ReadonlyArray[] = []; + let checks = 0; + const storage = yield* RiftriWorktrees.make("git").pipe( + Effect.provideService(ProcessRunner.ProcessRunner, { + run: ({ args }) => { + calls.push(args); + if (args[0] === "repair") + return Effect.succeed(output(0, { schema_version: 1, errors: [] })); + checks++; + return Effect.succeed( + inspection([ + inspectionView("/view-a", checks === 1 ? "recovery-required" : "active"), + inspectionView("/view-b", checks === 1 ? "recovery-required" : "active"), + ]), + ); + }, + }), + Effect.provideService(HostProcessEnvironment, { RIFTRI_BINARY: "/test/riftri" }), + ); + yield* storage.restore({ cwd: "/repo", destinations: ["/view-a", "/view-b", "/view-a"] }); + assert.deepEqual( + calls.map((args) => args.slice(0, 2)), + [ + ["worktree", "inspect"], + ["repair", "--state-dir=/storage/state"], + ["worktree", "inspect"], + ], + ); + assert.deepEqual(calls[0]!.slice(calls[0]!.indexOf("--") + 1), ["/view-a", "/view-b"]); + }).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect("restart does not repair native clones, active mounts, or unmanaged directories", () => + Effect.gen(function* () { + const calls: ReadonlyArray[] = []; + const storage = yield* RiftriWorktrees.make().pipe( + Effect.provideService(ProcessRunner.ProcessRunner, { + run: ({ args }) => { + calls.push(args); + return Effect.succeed( + inspection([ + inspectionView("/native"), + inspectionView("/mounted", "active"), + { + ...inspectionView("/ordinary"), + state_directory: null, + state_directory_native_hex: null, + backend: null, + }, + ]), + ); + }, + }), + Effect.provideService(HostProcessEnvironment, { RIFTRI_BINARY: "/test/riftri" }), + ); + yield* storage.restore({ cwd: "/repo", destinations: [] }); + assert.equal(calls.length, 0); + yield* storage.restore({ cwd: "/repo", destinations: ["/native", "/mounted", "/ordinary"] }); + assert.equal(calls.length, 1); + assert.equal(calls[0]![1], "inspect"); + }).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect( + "restart refuses unsafe, incomplete, mismatched, or lossy inspection without repair", + () => + Effect.gen(function* () { + const view = inspectionView("/view", "recovery-required"); + const reports = [ + output(1, undefined), + output(0, {}), + inspection([]), + inspection([view, view]), + ...["foreign", "different-namespace", "unavailable"].map((status) => + inspection([inspectionView("/view", status)]), + ), + inspection([{ ...view, path: "/other" }]), + inspection([{ ...view, path_native_hex: "ff" }]), + inspection([{ ...view, state_directory_native_hex: "ff" }]), + inspection([{ ...view, mount_status: null }]), + inspection([{ ...view, backend: null }]), + inspection([{ ...view, state_directory: null, state_directory_native_hex: null }]), + inspection([{ ...view, backend: "reflink" }]), + ]; + for (const report of reports) { + let calls = 0; + const storage = yield* RiftriWorktrees.make().pipe( + Effect.provideService(ProcessRunner.ProcessRunner, { + run: () => { + calls++; + return Effect.succeed(report); + }, + }), + Effect.provideService(HostProcessEnvironment, { RIFTRI_BINARY: "/test/riftri" }), + ); + assert.isTrue( + Result.isFailure( + yield* storage.restore({ cwd: "/repo", destinations: ["/view"] }).pipe(Effect.result), + ), + ); + assert.equal(calls, 1, "unsafe inspection must not initiate repair"); + } + }).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect("a successful repair is insufficient when readiness or ownership changed", () => + Effect.gen(function* () { + for (const after of [ + inspectionView("/view", "recovery-required"), + inspectionView("/view", "foreign"), + { + ...inspectionView("/view", "active"), + state_directory: "/other", + state_directory_native_hex: Buffer.from("/other").toString("hex"), + }, + { + ...inspectionView("/view"), + state_directory: null, + state_directory_native_hex: null, + backend: null, + }, + inspectionView("/view"), + ]) { + let checks = 0; + const storage = yield* RiftriWorktrees.make().pipe( + Effect.provideService(ProcessRunner.ProcessRunner, { + run: ({ args }) => { + if (args[0] === "repair") + return Effect.succeed(output(0, { schema_version: 1, errors: [] })); + return Effect.succeed( + inspection([++checks === 1 ? inspectionView("/view", "recovery-required") : after]), + ); + }, + }), + Effect.provideService(HostProcessEnvironment, { RIFTRI_BINARY: "/test/riftri" }), + ); + assert.isTrue( + Result.isFailure( + yield* storage.restore({ cwd: "/repo", destinations: ["/view"] }).pipe(Effect.result), + ), + ); + assert.equal(checks, 2); + } + }).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect("git mode still routes managed removal through its exact owning state", () => + Effect.gen(function* () { + const calls: ReadonlyArray[] = []; + const stateDirectory = "/storage/other-repository-state"; + const storage = yield* RiftriWorktrees.make("git").pipe( + Effect.provideService(ProcessRunner.ProcessRunner, { + run: (input) => { + calls.push(input.args); + return Effect.succeed( + input.args[1] === "owner" + ? output(0, { + schema_version: 1, + state_directory: stateDirectory, + state_directory_native_hex: Buffer.from(stateDirectory).toString("hex"), + native_path_encoding: "unix-bytes-hex", + }) + : output(0, {}), + ); + }, + }), + Effect.provideService(HostProcessEnvironment, { RIFTRI_BINARY: "/test/riftri" }), + ); + assert.isNull(yield* storage.create({ cwd: "/repo", destination: "/view", revision: "main" })); + assert.equal(calls.length, 0); + assert.isTrue(yield* storage.remove({ cwd: "/repo", destination: "/view", force: true })); + assert.deepEqual( + calls.map((args) => args[1]), + ["owner", "remove"], + ); + assert.include(calls[1]!, `--state-dir=${stateDirectory}`); + assert.include(calls[1]!, "--force"); + assert.include(calls[1]!, "--yes"); + }).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect("failed, malformed, or lossy ownership never permits ordinary removal", () => + Effect.gen(function* () { + for (const report of [ + output(1, undefined), + output(0, {}), + output(0, { + schema_version: 1, + state_directory: "/state/�", + state_directory_native_hex: "ff", + native_path_encoding: "unix-bytes-hex", + }), + output(0, { + schema_version: 1, + state_directory: null, + state_directory_native_hex: "00", + native_path_encoding: "unix-bytes-hex", + }), + ]) { + let calls = 0; + const storage = yield* RiftriWorktrees.make("git").pipe( + Effect.provideService(ProcessRunner.ProcessRunner, { + run: () => { + calls++; + return Effect.succeed(report); + }, + }), + Effect.provideService(HostProcessEnvironment, { RIFTRI_BINARY: "/test/riftri" }), + ); + assert.isTrue( + Result.isFailure( + yield* storage.remove({ cwd: "/repo", destination: "/view" }).pipe(Effect.result), + ), + ); + assert.equal(calls, 1); + } + }).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect("interruption repairs the journal without force-removing a possibly completed view", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* fs.makeTempDirectoryScoped(); + const started = yield* Deferred.make(); + const calls: ReadonlyArray[] = []; + const storage = yield* RiftriWorktrees.make("riftri").pipe( + Effect.provideService(ProcessRunner.ProcessRunner, { + run: (input) => { + calls.push(input.args); + if (input.args[0] === "repair") + return Effect.succeed(output(0, { schema_version: 1, errors: [] })); + return Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)); + }, + }), + Effect.provideService(HostProcessEnvironment, { RIFTRI_BINARY: "/test/riftri" }), + ); + const fiber = yield* storage + .create({ cwd, destination: path.join(cwd, "view"), revision: "main", branch: "topic" }) + .pipe(Effect.forkChild); + yield* Deferred.await(started); + yield* Fiber.interrupt(fiber); + assert.deepEqual( + calls.map((args) => args.slice(0, 2)), + [ + ["worktree", "add"], + ["repair", `--state-dir=${path.join(cwd, ".t3-riftri")}`], + ], + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("a failed checkout hook never selects ordinary Git fallback", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* fs.makeTempDirectoryScoped(); + const destination = path.join(cwd, "view"); + const commands: ReadonlyArray[] = []; + const storage = yield* RiftriWorktrees.make("auto").pipe( + Effect.provideService(ProcessRunner.ProcessRunner, { + run: (input) => { + commands.push(input.args); + return Effect.succeed( + output(3, { + schema_version: 1, + destination, + backend: "apfs-clone", + reused_base: false, + post_checkout: { exit_code: 3 }, + }), + ); + }, + }), + Effect.provideService(HostProcessEnvironment, { RIFTRI_BINARY: "/test/riftri" }), + ); + const result = yield* storage + .create({ cwd, destination, revision: "main", branch: "topic" }) + .pipe(Effect.result); + assert.isTrue(Result.isFailure(result)); + if (Result.isFailure(result)) assert.equal(result.failure.reason, "hook-failed"); + assert.equal(commands.filter((args) => args[0] === "worktree" && args[1] === "add").length, 1); + assert.equal(commands.length, 1, "the native add already preflights its exact target tree"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect.each(["branch-already-exists", "branch-checked-out", "destination-exists"] as const)( + "preserves the %s conflict without retrying or parsing human diagnostics", + (code) => + Effect.gen(function* () { + let calls = 0; + const receipt = { + schemaVersion: 1, + outcome: "failed", + operation: "worktree-add", + category: "policy", + code, + cleanup: "not-needed", + recovery: "not-required", + message: "arbitrary native diagnostic that must not reach the public error", + }; + for (const mode of ["auto", "riftri"] as const) { + const storage = yield* RiftriWorktrees.make(mode).pipe( + Effect.provideService(ProcessRunner.ProcessRunner, { + run: () => { + calls++; + return Effect.succeed(output(3, undefined, JSON.stringify(receipt))); + }, + }), + Effect.provideService(HostProcessEnvironment, { RIFTRI_BINARY: "/test/riftri" }), + ); + const result = yield* storage + .create({ cwd: "/repo", destination: "/view", revision: "main", branch: "topic" }) + .pipe(Effect.result); + assert.isTrue(Result.isFailure(result)); + if (Result.isFailure(result)) { + assert.equal(result.failure.reason, code); + assert.notInclude(result.failure.message, receipt.message); + } + } + assert.equal(calls, 2, "one native add per request; no preflight or retry"); + }).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect("uncertain conflict receipts remain operational failures", () => + Effect.gen(function* () { + const receipt = { + schemaVersion: 1, + outcome: "failed", + operation: "worktree-add", + category: "policy", + code: "branch-already-exists", + cleanup: "not-needed", + recovery: "not-required", + }; + for (const failure of [ + output(1, undefined, JSON.stringify(receipt)), + { ...output(3, undefined, JSON.stringify(receipt)), timedOut: true }, + output(3, {}, JSON.stringify(receipt)), + output(3, undefined, JSON.stringify({ ...receipt, category: "operational" })), + output(3, undefined, JSON.stringify({ ...receipt, cleanup: "unknown" })), + output(3, undefined, JSON.stringify({ ...receipt, recovery: "required" })), + output(3, undefined, JSON.stringify({ ...receipt, operation: "worktree-remove" })), + output(3, undefined, "branch-already-exists"), + ]) { + let calls = 0; + const storage = yield* RiftriWorktrees.make("auto").pipe( + Effect.provideService(ProcessRunner.ProcessRunner, { + run: () => { + calls++; + return Effect.succeed(failure); + }, + }), + Effect.provideService(HostProcessEnvironment, { RIFTRI_BINARY: "/test/riftri" }), + ); + const result = yield* storage + .create({ cwd: "/repo", destination: "/view", revision: "main", branch: "topic" }) + .pipe(Effect.result); + assert.isTrue(Result.isFailure(result)); + if (Result.isFailure(result)) assert.equal(result.failure.reason, "failed"); + assert.equal(calls, 1); + } + }).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect("only an unsupported checkout receipt permits automatic fallback", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* fs.makeTempDirectoryScoped(); + const receipt = { + schemaVersion: 1, + outcome: "failed", + operation: "worktree-add", + category: "policy", + code: "unsupported-checkout", + cleanup: "not-needed", + recovery: "not-required", + }; + for (const failure of [ + output(3, undefined, JSON.stringify(receipt)), + output(3, undefined), + output(1, undefined, JSON.stringify({ ...receipt, category: "operational" })), + output(3, undefined, JSON.stringify({ ...receipt, code: "stale-state-registration" })), + ]) { + const storage = yield* RiftriWorktrees.make("auto").pipe( + Effect.provideService(ProcessRunner.ProcessRunner, { run: () => Effect.succeed(failure) }), + Effect.provideService(HostProcessEnvironment, { RIFTRI_BINARY: "/test/riftri" }), + ); + const result = yield* storage + .create({ cwd, destination: path.join(cwd, "view"), revision: "main", branch: "topic" }) + .pipe(Effect.result); + if (failure.stderr === JSON.stringify(receipt)) { + assert.isTrue(Result.isSuccess(result)); + if (Result.isSuccess(result)) assert.equal(result.success, null); + } else { + assert.isTrue(Result.isFailure(result)); + } + } + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect( + "cached creation does not scan and repair the entire state before native validation", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* fs.makeTempDirectoryScoped(); + yield* fs.makeDirectory(path.join(cwd, ".t3-riftri")); + const destination = path.join(cwd, "view"); + const calls: ReadonlyArray[] = []; + const storage = yield* RiftriWorktrees.make("auto").pipe( + Effect.provideService(ProcessRunner.ProcessRunner, { + run: (input) => { + calls.push(input.args); + return Effect.succeed( + output( + 0, + input.args[0] === "repair" + ? { schema_version: 1, errors: [] } + : { + schema_version: 1, + destination, + backend: "apfs-clone", + reused_base: true, + post_checkout: null, + }, + ), + ); + }, + }), + Effect.provideService(HostProcessEnvironment, { RIFTRI_BINARY: "/test/riftri" }), + ); + yield* storage.create({ cwd, destination, revision: "main", branch: "topic" }); + assert.deepEqual( + calls.map((args) => args.slice(0, 2)), + [["worktree", "add"]], + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect( + "remote guessing never bypasses a managed destination or failed ownership discovery", + () => + Effect.gen(function* () { + const stateDirectory = "/state"; + for (const owner of [ + output(1, undefined), + output(0, {}), + output(0, { + schema_version: 1, + state_directory: stateDirectory, + state_directory_native_hex: Buffer.from(stateDirectory).toString("hex"), + native_path_encoding: "unix-bytes-hex", + }), + ]) { + const calls: string[] = []; + const storage = yield* RiftriWorktrees.make("auto").pipe( + Effect.provideService(ProcessRunner.ProcessRunner, { + run: (input) => { + calls.push(input.command === "git" ? input.args[0]! : input.args[1]!); + return Effect.succeed(input.command === "git" ? output(1, undefined) : owner); + }, + }), + Effect.provideService(HostProcessEnvironment, { RIFTRI_BINARY: "/test/riftri" }), + ); + assert.isTrue( + Result.isFailure( + yield* storage + .create({ cwd: "/repo", destination: "/view", revision: "remote-only" }) + .pipe(Effect.result), + ), + ); + assert.deepEqual(calls, ["show-ref", "check-ref-format", "rev-parse", "owner"]); + } + }).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect.skipIf(!process.env.T3CODE_TEST_RIFTRI_BINARY)( + "real native worktrees reuse a base, isolate writes, and preserve dirty work", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const platform = yield* HostProcessPlatform; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-riftri-native-" }); + const cwd = path.join(root, "repo"); + yield* fs.makeDirectory(cwd); + const environment = { + ...process.env, + RIFTRI_BINARY: process.env.T3CODE_TEST_RIFTRI_BINARY, + RIFTRI_BYPASS: "1", + GIT_CONFIG_GLOBAL: platform === "win32" ? "NUL" : "/dev/null", + GIT_CONFIG_NOSYSTEM: "1", + }; + const runner = yield* ProcessRunner.make(); + const git = (args: ReadonlyArray, directory = cwd) => + runner + .run({ command: "git", args, cwd: directory, env: environment }) + .pipe( + Effect.tap((result) => Effect.sync(() => assert.equal(result.code, 0, result.stderr))), + ); + yield* git(["init", "-b", "main"]); + yield* git(["config", "user.name", "T3 test"]); + yield* git(["config", "user.email", "t3@example.invalid"]); + yield* fs.writeFileString(path.join(cwd, "tracked.txt"), "original\n"); + yield* git(["add", "."]); + yield* git(["-c", "commit.gpgsign=false", "commit", "-m", "fixture"]); + const storage = yield* RiftriWorktrees.make("riftri").pipe( + Effect.provideService(ProcessRunner.ProcessRunner, runner), + Effect.provideService(HostProcessEnvironment, environment), + ); + const firstPath = path.join(root, "first"); + const secondPath = path.join(root, "second"); + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + for (const destination of [firstPath, secondPath]) { + if (yield* fs.exists(destination)) + yield* storage.remove({ cwd, destination, force: true }); + } + const collected = yield* runner.run({ + command: environment.RIFTRI_BINARY!, + args: ["gc", "--apply", "--yes", `--state-dir=${path.join(root, ".t3-riftri")}`], + cwd, + env: environment, + }); + assert.equal(collected.code, 0, collected.stderr); + }).pipe(Effect.orDie), + ); + const first = yield* storage.create({ + cwd, + destination: firstPath, + branch: "first", + revision: "main", + }); + const second = yield* storage.create({ + cwd, + destination: secondPath, + branch: "second", + revision: "main", + }); + assert.isNotNull(first); + assert.isNotNull(second); + assert.equal(first?.reused_base, false); + assert.equal(second?.reused_base, true); + yield* storage.restore({ cwd, destinations: [firstPath, secondPath] }); + assert.equal((yield* git(["status", "--porcelain"], firstPath)).stdout, ""); + assert.equal((yield* git(["status", "--porcelain"], secondPath)).stdout, ""); + assert.equal( + (yield* git(["rev-parse", "HEAD"], secondPath)).stdout, + (yield* git(["rev-parse", "HEAD"])).stdout, + ); + yield* fs.writeFileString(path.join(firstPath, "tracked.txt"), "private edit\n"); + assert.equal(yield* fs.readFileString(path.join(secondPath, "tracked.txt")), "original\n"); + assert.equal(yield* fs.readFileString(path.join(cwd, "tracked.txt")), "original\n"); + assert.isTrue( + Result.isFailure( + yield* storage.remove({ cwd, destination: firstPath }).pipe(Effect.result), + ), + ); + assert.equal(yield* fs.readFileString(path.join(firstPath, "tracked.txt")), "private edit\n"); + yield* fs.writeFileString(path.join(firstPath, "tracked.txt"), "original\n"); + assert.isTrue(yield* storage.remove({ cwd, destination: firstPath })); + assert.isTrue(yield* storage.remove({ cwd, destination: secondPath })); + assert.isFalse(yield* fs.exists(firstPath)); + yield* storage.prune(cwd); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + // Real Git processes, journal fsyncs, and APFS operations share a busy host. + { timeout: 30_000 }, +); diff --git a/apps/server/src/vcs/RiftriWorktrees.ts b/apps/server/src/vcs/RiftriWorktrees.ts new file mode 100644 index 000000000000..bbf8eb2de52d --- /dev/null +++ b/apps/server/src/vcs/RiftriWorktrees.ts @@ -0,0 +1,600 @@ +// @effect-diagnostics nodeBuiltinImport:off -- createRequire loads the disk-backed native package in Node SEA builds. +import * as NodeModule from "node:module"; +import * as Effect from "effect/Effect"; +import * as Path from "effect/Path"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import type { GitCommandFailureReason } from "@t3tools/contracts"; +import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import * as ProcessRunner from "../processRunner.ts"; + +// The native package must remain on disk, including in desktop/SEA bundles. +const requireRiftri = NodeModule.createRequire(import.meta.url); + +const Operation = Schema.Literals([ + "configuration", + "resolve-reference", + "add", + "owner", + "inspect", + "remove", + "prune", + "repair", +]); +const Reason = Schema.Literals([ + "invalid-configuration", + "unavailable", + "failed", + "invalid-output", + "repair-required", + "hook-failed", + "unsupported-reference", + "branch-already-exists", + "branch-checked-out", + "destination-exists", +]); + +class RiftriWorktreeError extends Schema.TaggedError()("RiftriWorktreeError", { + operation: Operation, + reason: Reason, + cause: Schema.optional(Schema.Defect()), +}) { + override get message(): string { + return this.gitErrorFields.detail; + } + + get gitErrorFields(): { detail: string; reason?: GitCommandFailureReason } { + switch (this.reason) { + case "invalid-configuration": + return { detail: "T3CODE_WORKTREE_STORAGE must be auto, git, or riftri." }; + case "unavailable": + return { + detail: + "Riftri is unavailable. Reinstall the server with its native optional dependencies.", + }; + case "hook-failed": + return { + detail: + "The checkout hook failed. Riftri kept the created worktree; inspect it before retrying.", + }; + case "unsupported-reference": + return { + detail: + "Riftri requires a local branch or a resolvable commit. Select an explicit base reference, or use auto storage for Git's remote-branch guessing.", + }; + case "repair-required": + return { + detail: "Riftri state needs attention. Inspect it with riftri status before retrying.", + }; + case "invalid-output": + return { + detail: "Riftri returned an invalid report. No ordinary Git fallback was attempted.", + }; + case "branch-already-exists": + return { + detail: "That branch already exists. Choose another name or use the existing branch.", + reason: "branch_already_exists", + }; + case "branch-checked-out": + return { + detail: + "That branch is already checked out in a registered worktree. Use that worktree or choose another branch.", + reason: "branch_checked_out_in_worktree", + }; + case "destination-exists": + return { + detail: + "The worktree destination is occupied. Choose another location; existing files were preserved.", + reason: "path_already_exists", + }; + case "failed": + return { + detail: `Riftri ${this.operation} failed. Inspect riftri status before retrying.`, + }; + } + } +} + +const AddReport = Schema.Struct({ + schema_version: Schema.Literal(1), + destination: Schema.String, + backend: Schema.Literals(["apfs-clone", "reflink", "overlay-fs", "refs-block-clone"]), + reused_base: Schema.Boolean, + post_checkout: Schema.NullOr(Schema.Struct({ exit_code: Schema.NullOr(Schema.Number) })), +}); +const PreflightRefusal = Schema.Struct({ + schemaVersion: Schema.Literal(1), + outcome: Schema.Literal("failed"), + operation: Schema.Literal("worktree-add"), + category: Schema.Literal("policy"), + code: Schema.Literals([ + "unsupported-checkout", + "branch-already-exists", + "branch-checked-out", + "destination-exists", + ]), + cleanup: Schema.Literal("not-needed"), + recovery: Schema.Literal("not-required"), +}); +const Owner = Schema.Struct({ + schema_version: Schema.Literal(1), + state_directory: Schema.NullOr(Schema.String), + state_directory_native_hex: Schema.NullOr(Schema.String), + native_path_encoding: Schema.Literals(["unix-bytes-hex", "windows-utf16le-hex"]), +}); +const RepairReport = Schema.Struct({ + schema_version: Schema.Literal(1), + errors: Schema.Array(Schema.Unknown), +}); +const InspectionView = Schema.Struct({ + path: Schema.String, + path_native_hex: Schema.String, + state_directory: Schema.NullOr(Schema.String), + state_directory_native_hex: Schema.NullOr(Schema.String), + backend: Schema.NullOr( + Schema.Literals(["apfs-clone", "reflink", "overlay-fs", "refs-block-clone"]), + ), + mount_status: Schema.NullOr( + Schema.Literals([ + "active", + "recovery-required", + "different-namespace", + "foreign", + "unavailable", + ]), + ), +}); +const Inspection = Schema.Struct({ + schema_version: Schema.Literal(1), + native_path_encoding: Schema.Literals(["unix-bytes-hex", "windows-utf16le-hex"]), + worktrees: Schema.Array(InspectionView), +}); + +const decodeAddReport = Schema.decodeUnknownEffect(Schema.fromJsonString(AddReport)); +const decodeOwner = Schema.decodeUnknownEffect(Schema.fromJsonString(Owner)); +const decodeRepairReport = Schema.decodeUnknownEffect(Schema.fromJsonString(RepairReport)); +const decodeInspection = Schema.decodeUnknownEffect(Schema.fromJsonString(Inspection)); +const decodeRefusal = Schema.decodeUnknownResult(Schema.fromJsonString(PreflightRefusal)); +const tryDecodeAddReport = Schema.decodeUnknownResult(Schema.fromJsonString(AddReport)); +const decodeStorageMode = Schema.decodeUnknownEffect(Schema.Literals(["auto", "git", "riftri"])); + +// This is the storage implementation of GitVcsDriver, not another workspace +// authority. Git still owns refs and every ordinary read/write operation. +export const make = Effect.fn("RiftriWorktrees.make")(function* ( + selectedMode?: "auto" | "git" | "riftri", +) { + const runner = yield* ProcessRunner.ProcessRunner; + const path = yield* Path.Path; + const environment = yield* HostProcessEnvironment; + + const run = Effect.fn("RiftriWorktrees.run")(function* ( + operation: typeof Operation.Type, + cwd: string, + args: ReadonlyArray, + ) { + const executable = yield* Effect.try({ + try: () => + environment.RIFTRI_BINARY ?? + (requireRiftri("riftri") as { resolveBinary: () => string }).resolveBinary(), + catch: (cause) => new RiftriWorktreeError({ operation, reason: "unavailable", cause }), + }); + const boundary = args.indexOf("--"); + const flags = ["--json", "--json-errors"]; + const argv = + boundary < 0 + ? [...args, ...flags] + : [...args.slice(0, boundary), ...flags, ...args.slice(boundary)]; + const output = yield* runner + .run({ + command: executable, + args: argv, + cwd, + env: { ...environment, GIT_OPTIONAL_LOCKS: "0" }, + timeout: "5 minutes", + maxOutputBytes: 1024 * 1024, + }) + .pipe( + Effect.mapError((cause) => new RiftriWorktreeError({ operation, reason: "failed", cause })), + ); + if ( + output.stdoutTruncated || + output.stderrTruncated || + output.stdoutInvalidUtf8 || + output.stderrInvalidUtf8 + ) { + return yield* new RiftriWorktreeError({ operation, reason: "invalid-output", cause: output }); + } + return output; + }); + + const decode = ( + decoder: (input: unknown) => Effect.Effect, + operation: typeof Operation.Type, + output: ProcessRunner.ProcessRunOutput, + ) => + decoder(output.stdout).pipe( + Effect.mapError( + (cause) => new RiftriWorktreeError({ operation, reason: "invalid-output", cause }), + ), + ); + + const requireSuccess = ( + operation: typeof Operation.Type, + output: ProcessRunner.ProcessRunOutput, + ) => + output.code === 0 && !output.timedOut + ? Effect.void + : Effect.fail(new RiftriWorktreeError({ operation, reason: "failed", cause: output })); + + const repair = Effect.fn("RiftriWorktrees.repair")(function* ( + cwd: string, + stateDirectory: string, + ) { + const output = yield* run("repair", cwd, ["repair", `--state-dir=${stateDirectory}`]); + yield* requireSuccess("repair", output); + const report = yield* decode(decodeRepairReport, "repair", output); + if (report.errors.length > 0) { + return yield* new RiftriWorktreeError({ + operation: "repair", + reason: "repair-required", + cause: report, + }); + } + }); + + const resolveReference = Effect.fn("RiftriWorktrees.resolveReference")(function* ( + cwd: string, + revision: string, + ) { + const probe = Effect.fn("RiftriWorktrees.probeReference")(function* ( + args: ReadonlyArray, + invalidBranchAllowed = false, + ) { + const output = yield* runner + .run({ + command: "git", + args, + cwd, + env: { ...environment, RIFTRI_BYPASS: "1", GIT_OPTIONAL_LOCKS: "0" }, + timeout: "5 seconds", + maxOutputBytes: 64 * 1024, + }) + .pipe( + Effect.mapError( + (cause) => + new RiftriWorktreeError({ operation: "resolve-reference", reason: "failed", cause }), + ), + ); + if ( + output.timedOut || + (output.code !== 0 && output.code !== 1 && !(invalidBranchAllowed && output.code === 128)) + ) { + return yield* new RiftriWorktreeError({ + operation: "resolve-reference", + reason: "failed", + cause: output, + }); + } + if ( + output.stdoutTruncated || + output.stderrTruncated || + output.stdoutInvalidUtf8 || + output.stderrInvalidUtf8 + ) { + return yield* new RiftriWorktreeError({ + operation: "resolve-reference", + reason: "invalid-output", + cause: output, + }); + } + return output; + }); + // Git prefers an existing short local branch, even when a tag shares its + // name. HEAD, tags, hashes and fully-qualified refs are detached instead. + if ((yield* probe(["show-ref", "--verify", "--quiet", `refs/heads/${revision}`])).code === 0) { + return { mode: "branch" as const, revision }; + } + // Let Git expand previous-checkout expressions such as @{-1}; resolving + // only their commit would silently detach a branch that Git checks out. + const expanded = yield* probe(["check-ref-format", "--branch", revision], true); + const branch = expanded.stdout.trim(); + if ( + expanded.code === 0 && + branch !== revision && + (yield* probe(["show-ref", "--verify", "--quiet", `refs/heads/${branch}`])).code === 0 + ) { + return { mode: "branch" as const, revision: branch }; + } + if ( + (yield* probe([ + "rev-parse", + "--verify", + "--quiet", + "--end-of-options", + `${revision}^{commit}`, + ])).code === 0 + ) { + return { mode: "detached" as const, revision }; + } + // An unresolved name may trigger Git's remote-branch guessing, including + // checkout.defaultRemote and tracking configuration. Leave that to Git. + return { mode: "git" as const, revision }; + }); + + const create = Effect.fn("RiftriWorktrees.create")(function* (input: { + readonly cwd: string; + readonly destination: string; + readonly revision: string; + readonly branch?: string; + }) { + const mode = yield* decodeStorageMode( + selectedMode ?? environment.T3CODE_WORKTREE_STORAGE ?? "auto", + ).pipe( + Effect.mapError( + (cause) => + new RiftriWorktreeError({ + operation: "configuration", + reason: "invalid-configuration", + cause, + }), + ), + ); + if (mode === "git") return null; + const head = input.branch + ? { mode: "branch", revision: input.revision } + : yield* resolveReference(input.cwd, input.revision); + if (head.mode === "git") { + if (mode === "riftri") { + return yield* new RiftriWorktreeError({ + operation: "add", + reason: "unsupported-reference", + }); + } + // This is a pre-mutation delegation, not a retry after native failure. + // Incomplete journals or a managed destination must still block it. + if ((yield* findOwner(input.cwd, input.destination)) !== null) { + return yield* new RiftriWorktreeError({ operation: "add", reason: "repair-required" }); + } + yield* Effect.logWarning( + "Worktree uses ordinary Git: remote-branch guessing requires Git's reference handling.", + ); + return null; + } + // Add validates the exact requested tree and destination itself. A separate + // doctor would duplicate that work and inspect the caller's HEAD instead. + // Place immutable data beside the destination so cross-volume repositories + // can still clone. The native journal registers this location with Git. + const stateDirectory = path.join(path.dirname(input.destination), ".t3-riftri"); + // Native add rejects pending recovery under its own locks. Do not run a + // state-wide repair on every cached add or mutate other views implicitly. + const output = yield* run("add", input.cwd, [ + "worktree", + "add", + `--state-dir=${stateDirectory}`, + ...(input.branch ? [`--branch=${input.branch}`] : []), + ...(head.mode === "detached" ? ["--detach"] : []), + "--", + input.destination, + head.revision, + ]).pipe( + Effect.onInterrupt(() => + repair(input.cwd, stateDirectory).pipe( + Effect.catchTags({ + RiftriWorktreeError: () => + Effect.logWarning( + "Interrupted Riftri worktree creation needs inspection with riftri status.", + ), + }), + Effect.andThen( + Effect.logWarning( + "Creation was interrupted. Inspect riftri worktree list before retrying; a completed worktree may have been retained.", + ), + ), + ), + ), + ); + if (output.code !== 0) { + const receipt = decodeRefusal(output.stderr.trim().split("\n").at(-1)); + if ( + !output.timedOut && + output.code === 3 && + output.stdout.trim() === "" && + Result.isSuccess(receipt) + ) { + if (receipt.success.code !== "unsupported-checkout") { + return yield* new RiftriWorktreeError({ + operation: "add", + reason: receipt.success.code, + cause: output, + }); + } + if (mode === "auto") { + yield* Effect.logWarning( + "Worktree uses ordinary Git: this checkout is unsupported by Riftri.", + ); + return null; + } + } + const report = tryDecodeAddReport(output.stdout); + if ( + Result.isSuccess(report) && + report.success.post_checkout !== null && + report.success.post_checkout.exit_code !== 0 + ) { + return yield* new RiftriWorktreeError({ + operation: "add", + reason: "hook-failed", + cause: output, + }); + } + yield* requireSuccess("add", output); + } + yield* requireSuccess("add", output); + const report = yield* decode(decodeAddReport, "add", output); + yield* Effect.logInfo("Created a Riftri worktree.", { + backend: report.backend, + reusedBase: report.reused_base, + }); + return report; + }); + + const findOwner = Effect.fn("RiftriWorktrees.findOwner")(function* ( + cwd: string, + destination: string, + ) { + // Ownership must still be checked in git mode and after a server restart. + // Never infer "unmanaged" from a failed discovery or a missing directory. + const output = yield* run("owner", cwd, ["worktree", "owner", "--", destination]); + yield* requireSuccess("owner", output); + const owner = yield* decode(decodeOwner, "owner", output); + if (owner.state_directory === null && owner.state_directory_native_hex === null) return null; + // The CLI's display paths can be lossy. Do not route a mutation to a + // different directory when Node cannot represent the native path exactly. + if ( + owner.state_directory === null || + owner.state_directory_native_hex !== + Buffer.from( + owner.state_directory, + owner.native_path_encoding === "unix-bytes-hex" ? "utf8" : "utf16le", + ).toString("hex") + ) { + return yield* new RiftriWorktreeError({ + operation: "owner", + reason: "invalid-output", + cause: owner, + }); + } + return owner.state_directory; + }); + + const remove = Effect.fn("RiftriWorktrees.remove")(function* (input: { + readonly cwd: string; + readonly destination: string; + readonly force?: boolean; + }) { + const stateDirectory = yield* findOwner(input.cwd, input.destination); + if (stateDirectory === null) return false; + const removed = yield* run("remove", input.cwd, [ + "worktree", + "remove", + `--state-dir=${stateDirectory}`, + ...(input.force ? ["--force", "--yes"] : []), + "--", + input.destination, + ]); + yield* requireSuccess("remove", removed); + return true; + }); + + const prune = Effect.fn("RiftriWorktrees.prune")(function* (cwd: string) { + // Native prune validates every registered state before touching Git's + // registrations, including incomplete operations in another state folder. + const output = yield* run("prune", cwd, ["worktree", "prune"]); + yield* requireSuccess("prune", output); + }); + + const inspect = Effect.fn("RiftriWorktrees.inspect")(function* ( + cwd: string, + destinations: ReadonlyArray, + ) { + const views: Array = []; + // Keep argv bounded on Windows as well as Unix. Batched metadata reads + // avoid one process and one state discovery pass per persisted thread. + const batches: string[][] = []; + let batch: string[] = []; + let bytes = 0; + for (const destination of destinations) { + const size = Buffer.byteLength(destination, "utf8") * 2 + 4; + if (batch.length > 0 && bytes + size > 8192) { + batches.push(batch); + batch = []; + bytes = 0; + } + batch.push(destination); + bytes += size; + } + if (batch.length > 0) batches.push(batch); + for (const paths of batches) { + const output = yield* run("inspect", cwd, ["worktree", "inspect", "--", ...paths]); + yield* requireSuccess("inspect", output); + const report = yield* decode(decodeInspection, "inspect", output); + const encoding = report.native_path_encoding === "unix-bytes-hex" ? "utf8" : "utf16le"; + if ( + report.worktrees.length !== paths.length || + report.worktrees.some((view, index) => { + if ( + view.path !== paths[index] || + view.path_native_hex !== Buffer.from(view.path, encoding).toString("hex") + ) + return true; + if (view.state_directory === null) { + return ( + view.state_directory_native_hex !== null || + view.backend !== null || + view.mount_status !== null + ); + } + return ( + !path.isAbsolute(view.state_directory) || + view.state_directory_native_hex !== + Buffer.from(view.state_directory, encoding).toString("hex") || + view.backend === null || + (view.backend === "overlay-fs" + ? view.mount_status === null + : view.mount_status !== null) + ); + }) + ) { + return yield* new RiftriWorktreeError({ + operation: "inspect", + reason: "invalid-output", + cause: report, + }); + } + views.push(...report.worktrees); + } + return views; + }); + + const restore = Effect.fn("RiftriWorktrees.restore")(function* (input: { + readonly cwd: string; + readonly destinations: ReadonlyArray; + }) { + const destinations = [...new Set(input.destinations)]; + const views = yield* inspect(input.cwd, destinations); + const states = new Set(); + for (const view of views) { + if (view.mount_status === null || view.mount_status === "active") continue; + if (view.mount_status !== "recovery-required" || view.state_directory === null) { + return yield* new RiftriWorktreeError({ + operation: "inspect", + reason: "repair-required", + cause: view, + }); + } + states.add(view.state_directory); + } + if (states.size === 0) return; + for (const state of states) yield* repair(input.cwd, state); + // Repair skips busy journals. Success alone must not let a provider write + // into the empty directory hidden beneath an absent OverlayFS mount. + const restored = yield* inspect(input.cwd, destinations); + for (const [index, before] of views.entries()) { + if (before.backend !== "overlay-fs") continue; + const after = restored[index]!; + if ( + after.state_directory !== before.state_directory || + after.backend !== before.backend || + after.mount_status !== "active" + ) { + return yield* new RiftriWorktreeError({ + operation: "inspect", + reason: "repair-required", + cause: after, + }); + } + } + }); + + return { create, remove, prune, restore }; +}); diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md index e1e8aaaf2fbe..a4d14440ef2e 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -130,6 +130,32 @@ such as another drive, set **Settings → Storage → Worktree location** to an `D:\worktrees` or `~/worktrees`. The setting is per machine. Existing worktrees stay where they are, and cleanup covers both the default folder and the custom one. +This fork uses Riftri for new worktrees when the repository and destination support +native copy-on-write storage. Git still owns branches, commits, and worktree metadata; +editors and agents use the same ordinary directories. Unsupported checkouts, such as +repositories with submodules, use ordinary Git and log the fallback. Git also handles +remote-branch guessing when a name does not resolve locally; explicit branches, tags, +commit IDs, and `HEAD` can use optimized storage. Failed or interrupted mutations are +never retried as an ordinary checkout. + +Set `T3CODE_WORKTREE_STORAGE=git` in the server environment to use ordinary Git for new +worktrees, or `riftri` to require optimized creation without fallback. The default is `auto`. +Changing this setting does not change existing worktrees: Riftri-managed removal and prune +continue through Riftri, including after restarting the server. Native optional dependencies +must be installed; a missing or broken Riftri executable is an error, not an implicit fallback. + +On Linux, startup checks saved worktrees before resuming providers or background work. +Missing OverlayFS mounts are repaired and checked again. If storage cannot be verified, +startup stops for inspection instead of letting an agent write into an unmounted directory. +This protection still applies when new worktrees are configured to use ordinary Git. + +Riftri retains reusable immutable bases in a `.t3-riftri` directory beside its worktrees. +Removing a worktree does not delete its retained base. Inspect that directory with +`riftri status --state-dir `; use `riftri gc --state-dir ` to preview +collection before explicitly applying it. Do not delete the directory manually while views +still depend on it. Copy-on-write reduces duplicated tracked-file storage; it does not +share writable dependency folders or guarantee faster creation. + ## Storage cleanup Open **Settings → Storage** to enable automatic cleanup on one machine or all connected diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index 4b9294e16887..f5b1dbbaf4d2 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -629,11 +629,11 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { ); // Windows unpacks native files explicitly so their JavaScript and metadata - // stay archived. Other platforms retain electron-builder's defaults. + // stay archived. POSIX executables without an extension are explicit too. assert.notProperty(mac, "asar"); assert.notProperty(linux, "asar"); - assert.notProperty(mac, "asarUnpack"); - assert.notProperty(linux, "asarUnpack"); + assert.deepStrictEqual(mac.asarUnpack, ["**/node_modules/riftri-*/bin/*"]); + assert.deepStrictEqual(linux.asarUnpack, ["**/node_modules/riftri-*/bin/*"]); assert.deepStrictEqual(win.asar, { smartUnpack: false }); assert.deepStrictEqual(win.asarUnpack, [WINDOWS_NATIVE_ASAR_UNPACK_GLOB]); assert.deepStrictEqual(winWithoutWslRuntime.asar, win.asar); diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index 301b42613d25..f756d3a6e1d3 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -31,6 +31,7 @@ import { import { getDefaultBuildArch } from "./lib/build-target-arch.ts"; import { findInlinedExternalPackages, + RIFTRI_ASAR_UNPACK_GLOB, selectCliRuntimeExternalDependencies, } from "./lib/cli-external-packages.ts"; import { loadRepoEnv } from "./lib/public-config.ts"; @@ -2689,7 +2690,7 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( // not inflate the loose-file count and slow NSIS installation. ...(platform === "win" ? { asar: { smartUnpack: false }, asarUnpack: [WINDOWS_NATIVE_ASAR_UNPACK_GLOB] } - : {}), + : { asarUnpack: [RIFTRI_ASAR_UNPACK_GLOB] }), extraResources: [ ...DESKTOP_EXTRA_RESOURCES, ...(platform === "linux" ? LINUX_CAPTURE_EXTRA_RESOURCES : []), diff --git a/scripts/lib/cli-external-packages.test.ts b/scripts/lib/cli-external-packages.test.ts index 33c2e897b047..73499faeb24b 100644 --- a/scripts/lib/cli-external-packages.test.ts +++ b/scripts/lib/cli-external-packages.test.ts @@ -63,6 +63,26 @@ describe("shouldBundleCliDependency", () => { it("treats prefix-matched siblings as external", () => { assert.strictEqual(shouldBundleCliDependency("node-gyp-build-optional-packages"), false); }); + + it("keeps Riftri's resolver and every native executable on disk", () => { + for (const id of [ + "riftri", + "riftri-darwin-arm64", + "riftri-darwin-x64", + "riftri-linux-arm64-gnu", + "riftri-linux-arm64-musl", + "riftri-linux-x64-gnu", + "riftri-linux-x64-musl", + "riftri-win32-arm64", + "riftri-win32-x64", + ]) { + assert.strictEqual(shouldBundleCliDependency(id), false, id); + } + assert.deepStrictEqual( + selectCliRuntimeExternalDependencies({ riftri: "0.6.3", effect: "4.0.1" }), + { riftri: "0.6.3" }, + ); + }); }); describe("selectCliRuntimeExternalDependencies", () => { diff --git a/scripts/lib/cli-external-packages.ts b/scripts/lib/cli-external-packages.ts index a1846e1175c2..b1113ff6a44b 100644 --- a/scripts/lib/cli-external-packages.ts +++ b/scripts/lib/cli-external-packages.ts @@ -30,6 +30,8 @@ export const CLI_RUNTIME_EXTERNAL_PREFIXES = [ "@cursor/sdk", // Playwright reads package.json and browsers.json beside its runtime modules. "playwright-core", + // Riftri resolves an optional platform executable relative to its package. + "riftri", "node-pty", "ffi-rs", "@yuuang/", @@ -50,6 +52,9 @@ export const CLI_RUNTIME_EXTERNAL_PREFIXES = [ "utf-8-validate", ] as const; +// POSIX native executables have no extension for Electron's unpack heuristics. +export const RIFTRI_ASAR_UNPACK_GLOB = "**/node_modules/riftri-*/bin/*"; + // These are Cursor's disk-backed dependency closure. Match package boundaries // so "zod" does not also externalize unrelated packages such as zod-to-json-schema. const CURSOR_RUNTIME_DEPENDENCIES = [ diff --git a/scripts/lib/riftri-packaging.test.ts b/scripts/lib/riftri-packaging.test.ts new file mode 100644 index 000000000000..61ec67ce42ed --- /dev/null +++ b/scripts/lib/riftri-packaging.test.ts @@ -0,0 +1,112 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeModule from "node:module"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; +import { createPackageWithOptions } from "@electron/asar"; +import { assert, it } from "@effect/vitest"; +import { build } from "vite-plus/pack"; +import { + isExternalCliDependency, + RIFTRI_ASAR_UNPACK_GLOB, + shouldBundleCliDependency, +} from "./cli-external-packages.ts"; +import { findEsmImportsOfExternalPackages } from "./cli-executable-imports.ts"; + +const repoRoot = NodeURL.fileURLToPath(new URL("../..", import.meta.url)); +const require = NodeModule.createRequire(NodePath.join(repoRoot, "apps/server/package.json")); +const electron = process.env.T3CODE_TEST_ELECTRON_BINARY; + +async function probe(runtime?: string): Promise { + const scratch = await NodeFSP.realpath( + await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-riftri-package-")), + ); + try { + for (let parent = NodePath.dirname(scratch); ; parent = NodePath.dirname(parent)) { + assert.isFalse(NodeFS.existsSync(NodePath.join(parent, "node_modules"))); + if (parent === NodePath.dirname(parent)) break; + } + // This proof must use the installed package, not an executable override. + assert.isNotOk(process.env.RIFTRI_BINARY); + const native = (require("riftri") as { resolveBinary: () => string }).resolveBinary(); + const platformRoot = NodePath.dirname(NodePath.dirname(native)); + const launcherRoot = NodePath.dirname(NodePath.dirname(require.resolve("riftri"))); + const output = NodePath.join(scratch, "package"); + const entry = NodePath.join(scratch, "probe.mjs"); + const repository = NodePath.join(scratch, "repository"); + await NodeFSP.mkdir(repository); + const gitConfig = NodePath.join(scratch, "empty.gitconfig"); + await NodeFSP.writeFile(gitConfig, ""); + const env = { + ...process.env, + NODE_PATH: "", + NODE_OPTIONS: "", + RIFTRI_BYPASS: "1", + GIT_CONFIG_GLOBAL: gitConfig, + GIT_CONFIG_NOSYSTEM: "1", + ...(runtime ? { ELECTRON_RUN_AS_NODE: "1" } : {}), + }; + NodeChildProcess.execFileSync("git", ["init", "--quiet", repository], { env }); + await NodeFSP.writeFile( + entry, + ` + import * as Effect from ${JSON.stringify(require.resolve("effect/Effect"))}; + import * as NodeServices from ${JSON.stringify(require.resolve("@effect/platform-node/NodeServices"))}; + import * as RiftriWorktrees from ${JSON.stringify(NodePath.join(repoRoot, "apps/server/src/vcs/RiftriWorktrees.ts"))}; + import * as ProcessRunner from ${JSON.stringify(NodePath.join(repoRoot, "apps/server/src/processRunner.ts"))}; + await Effect.runPromise(Effect.gen(function* () { + const storage = yield* RiftriWorktrees.make(); + yield* storage.restore({cwd: process.argv[2], destinations: [process.argv[2]]}); + }).pipe(Effect.provide(ProcessRunner.layer), Effect.provide(NodeServices.layer))); + console.log('Packaged storage adapter inspected the real repository.'); + `, + ); + await build({ + config: false, + entry: [entry], + outDir: output, + platform: "node", + format: "esm", + dts: false, + logLevel: "error", + deps: { + alwaysBundle: shouldBundleCliDependency, + neverBundle: isExternalCliDependency, + onlyBundle: false, + }, + }); + for (const source of [launcherRoot, platformRoot]) { + await NodeFSP.cp(source, NodePath.join(output, "node_modules", NodePath.basename(source)), { + recursive: true, + filter: (file) => file === source || NodePath.basename(file) !== "node_modules", + }); + } + const bundle = NodePath.join(output, "probe.mjs"); + assert.deepEqual(findEsmImportsOfExternalPackages(await NodeFSP.readFile(bundle, "utf8")), []); + let executableEntry = bundle; + if (runtime) { + const archive = NodePath.join(scratch, "server.asar"); + await createPackageWithOptions(output, archive, { unpack: RIFTRI_ASAR_UNPACK_GLOB }); + executableEntry = NodePath.join(archive, "probe.mjs"); + } + const stdout = NodeChildProcess.execFileSync( + runtime ?? process.execPath, + ["--no-global-search-paths", executableEntry, repository], + { cwd: scratch, env, encoding: "utf8", timeout: 30_000 }, + ); + assert.include(stdout, "Packaged storage adapter inspected the real repository."); + } finally { + await NodeFSP.rm(scratch, { recursive: true, force: true }); + } +} + +it("runs the bundled storage adapter using only its staged native package", () => probe(), 60_000); + +it.skipIf(!electron)( + "runs the bundled storage adapter from an actual Electron ASAR archive", + () => probe(electron), + 60_000, +); From 137b28a3ec41ee85c5f19a324bc0b6b2382fc548 Mon Sep 17 00:00:00 2001 From: Kinfe123 Date: Thu, 8 Oct 2026 00:13:25 -0700 Subject: [PATCH 2/7] build(server): pin released Riftri workspace runtime --- apps/server/package.json | 1 + pnpm-lock.yaml | 87 ++++++++++++++++++++++++++++++++++++++++ pnpm-workspace.yaml | 10 +++++ 3 files changed, 98 insertions(+) diff --git a/apps/server/package.json b/apps/server/package.json index 2aa6a961d6eb..8587b95bb896 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -45,6 +45,7 @@ "node-pty": "^1.2.0-beta.15", "playwright-core": "1.60.0", "proper-lockfile": "4.1.2", + "riftri": "0.6.3", "stream-chain": "^4.2.5", "stream-json": "3.6.0", "yaml": "catalog:", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 17a19d1ffbeb..b4d3965ff733 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -573,6 +573,9 @@ importers: proper-lockfile: specifier: 4.1.2 version: 4.1.2 + riftri: + specifier: 0.6.3 + version: 0.6.3 stream-chain: specifier: ^4.2.5 version: 4.2.5 @@ -10277,6 +10280,55 @@ packages: rettime@0.10.1: resolution: {integrity: sha512-uyDrIlUEH37cinabq0AX4QbgV4HbFZ/gqoiunWQ1UqBtRvTTytwhNYjE++pO/MjPTZL5KQCf2bEoJ/BJNVQ5Kw==} + riftri-darwin-arm64@0.6.3: + resolution: {integrity: sha512-uGDdjg9Svg3c6SrBgs5qlA039QPEiAjUFBzmvN249ma88B7h4BbKzHR2ssBA81ygn4qh3TBAYWSEfIyekSbkZg==} + cpu: [arm64] + os: [darwin] + + riftri-darwin-x64@0.6.3: + resolution: {integrity: sha512-MghBLRCKVHUXj9tiBXKJdK35FJrkAvc5+nh4kXYa1FTqEIGMQQcHBO36nK3Hzuw/ruwMy6tL11p1kLMbjO2bvw==} + cpu: [x64] + os: [darwin] + + riftri-linux-arm64-gnu@0.6.3: + resolution: {integrity: sha512-s/FqkwJPcI7Pys/RMh/BZy5hufFc+oMhly93zggiOQftpMrR4t1CTOFibpmqbtTBK1iHzk057pP1gkJGfIjTHQ==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + riftri-linux-arm64-musl@0.6.3: + resolution: {integrity: sha512-5hh4i1WBrN8YST98W5SVsAVtyVQ3xcV+/jZ9ay8xgQEpvu4cZ7SXWQX2TPHfjme5BBVIjQaG+0t6peqGo8F9ig==} + cpu: [arm64] + os: [linux] + libc: [musl] + + riftri-linux-x64-gnu@0.6.3: + resolution: {integrity: sha512-x1RDlTSjNdB7M6o1i86t3HLrWStFZM/z+2P4i7FD7YnHT0KvSBWNv8A2VB7V7TBaBG8HwDnAUUPiCtsUvDPrpw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + riftri-linux-x64-musl@0.6.3: + resolution: {integrity: sha512-RLc4fb8dsIBpD2FCsPf3AL0+wLlFCcbZgKiXyyVVvZgzWh/Hk6Az/BxQXaB1FmQAUgAosJJVSTX94VvP4AfHzQ==} + cpu: [x64] + os: [linux] + libc: [musl] + + riftri-win32-arm64@0.6.3: + resolution: {integrity: sha512-gFc7A4kfcLctsaZlXCBe+CIWQHb9tZ8uCfQAWK+8jbhifitcZfdvlfJM/3OjfZXvlDR5z/upGAfk+kxYZCyyJA==} + cpu: [arm64] + os: [win32] + + riftri-win32-x64@0.6.3: + resolution: {integrity: sha512-jIKh6i9iVVZybXvD1l3ycn20uq5pVOfpnvjjKNDRHPpM0cU/TqObdi/r3NyL/vf2QS5y8a//EMI384YLFlYAxw==} + cpu: [x64] + os: [win32] + + riftri@0.6.3: + resolution: {integrity: sha512-REidtwKOb17c+RiqC2KMpssfpp5J5/Ir/eIP5apNx0Hb0qYPqIciukfS6Yl0t9KYyT/6ZpQwT/mHRoyWkEZWNQ==} + engines: {node: '>=18.18'} + hasBin: true + rimraf@2.6.3: resolution: {integrity: sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==} deprecated: Rimraf versions prior to v4 are no longer supported @@ -21514,6 +21566,41 @@ snapshots: rettime@0.10.1: optional: true + riftri-darwin-arm64@0.6.3: + optional: true + + riftri-darwin-x64@0.6.3: + optional: true + + riftri-linux-arm64-gnu@0.6.3: + optional: true + + riftri-linux-arm64-musl@0.6.3: + optional: true + + riftri-linux-x64-gnu@0.6.3: + optional: true + + riftri-linux-x64-musl@0.6.3: + optional: true + + riftri-win32-arm64@0.6.3: + optional: true + + riftri-win32-x64@0.6.3: + optional: true + + riftri@0.6.3: + optionalDependencies: + riftri-darwin-arm64: 0.6.3 + riftri-darwin-x64: 0.6.3 + riftri-linux-arm64-gnu: 0.6.3 + riftri-linux-arm64-musl: 0.6.3 + riftri-linux-x64-gnu: 0.6.3 + riftri-linux-x64-musl: 0.6.3 + riftri-win32-arm64: 0.6.3 + riftri-win32-x64: 0.6.3 + rimraf@2.6.3: dependencies: glob: 7.2.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 92373445f5ed..cfd67c17709b 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -58,6 +58,16 @@ catalog: yaml: ^2.9.0 minimumReleaseAgeExclude: + # v0.6.3's signed release and all nine npm provenance receipts were verified. + - riftri@0.6.3 + - riftri-darwin-arm64@0.6.3 + - riftri-darwin-x64@0.6.3 + - riftri-linux-arm64-gnu@0.6.3 + - riftri-linux-arm64-musl@0.6.3 + - riftri-linux-x64-gnu@0.6.3 + - riftri-linux-x64-musl@0.6.3 + - riftri-win32-arm64@0.6.3 + - riftri-win32-x64@0.6.3 - expo-glass-effect@58.0.3 - expo-blur@58.0.3 - expo-audio@58.0.4 From 4ab6b7045779e722ed8902abab8421f1e1835992 Mon Sep 17 00:00:00 2001 From: Kinfe123 Date: Thu, 8 Oct 2026 00:37:48 -0700 Subject: [PATCH 3/7] ci: verify Riftri storage on hosted macOS and Linux runners --- .github/workflows/ci.yml | 103 ++++++++++++++++++++++++++++++++++----- 1 file changed, 91 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dbdc1ec40e64..e3e2c38be3dd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,9 +17,10 @@ jobs: # Lint, typecheck, and the desktop build each get a runner so they run side by # side instead of one after another. The `Check` job at the end of this file # passes only when every other job does. + # Forks use GitHub-hosted runners without requiring the upstream runner app. lint: name: Lint - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ${{ github.event.repository.fork && 'ubuntu-24.04' || 'blacksmith-8vcpu-ubuntu-2404' }} timeout-minutes: 10 steps: - name: Checkout @@ -57,7 +58,7 @@ jobs: typecheck: name: Typecheck - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ${{ github.event.repository.fork && 'ubuntu-24.04' || 'blacksmith-8vcpu-ubuntu-2404' }} timeout-minutes: 10 steps: - name: Checkout @@ -83,7 +84,7 @@ jobs: build: name: Build - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ${{ github.event.repository.fork && 'ubuntu-24.04' || 'blacksmith-8vcpu-ubuntu-2404' }} timeout-minutes: 10 steps: - name: Checkout @@ -137,7 +138,7 @@ jobs: # per runner is unchanged. test: name: Test - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ${{ github.event.repository.fork && 'ubuntu-24.04' || 'blacksmith-8vcpu-ubuntu-2404' }} timeout-minutes: 10 steps: - name: Checkout @@ -201,7 +202,7 @@ jobs: # started last and ran alone for about 40s after everything else finished. test_web: name: Test Web - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ${{ github.event.repository.fork && 'ubuntu-24.04' || 'blacksmith-8vcpu-ubuntu-2404' }} timeout-minutes: 10 steps: - name: Checkout @@ -230,7 +231,7 @@ jobs: # split files by recorded duration (apps/server/src/testUtils/weightedShardSequencer.ts). test_server: name: Test Server ${{ matrix.shard }} - runs-on: blacksmith-4vcpu-ubuntu-2404 + runs-on: ${{ github.event.repository.fork && 'ubuntu-24.04' || 'blacksmith-4vcpu-ubuntu-2404' }} timeout-minutes: 10 strategy: fail-fast: false @@ -302,7 +303,7 @@ jobs: # On Blacksmith like every other job here: the GitHub-hosted queue is shared # with the PR bots and can leave this job waiting until it is abandoned, # which fails `Check` on an otherwise green run. - runs-on: blacksmith-2vcpu-ubuntu-2404 + runs-on: ${{ github.event.repository.fork && 'ubuntu-24.04' || 'blacksmith-2vcpu-ubuntu-2404' }} timeout-minutes: 5 steps: - name: Require transfer measurements @@ -315,7 +316,7 @@ jobs: # for checks that take under 3s, on the critical path of every PR. rust: name: Rust - runs-on: blacksmith-4vcpu-ubuntu-2404 + runs-on: ${{ github.event.repository.fork && 'ubuntu-24.04' || 'blacksmith-4vcpu-ubuntu-2404' }} timeout-minutes: 10 steps: - name: Checkout @@ -349,7 +350,7 @@ jobs: # the diff cannot be resolved, the lint runs. mobile_native_changes: name: Mobile Native Changes - runs-on: blacksmith-2vcpu-ubuntu-2404 + runs-on: ${{ github.event.repository.fork && 'ubuntu-24.04' || 'blacksmith-2vcpu-ubuntu-2404' }} timeout-minutes: 5 permissions: contents: read @@ -427,7 +428,7 @@ jobs: # Skip only on an explicit "no": a gate job that failed or errored leaves the # output empty, and that must run the lint rather than silently skip it. if: ${{ !cancelled() && needs.mobile_native_changes.outputs.changed != 'false' }} - runs-on: blacksmith-6vcpu-macos-26 + runs-on: ${{ github.event.repository.fork && 'macos-15' || 'blacksmith-6vcpu-macos-26' }} timeout-minutes: 10 steps: - name: Checkout @@ -455,7 +456,7 @@ jobs: release_smoke: name: Release Smoke - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ${{ github.event.repository.fork && 'ubuntu-24.04' || 'blacksmith-8vcpu-ubuntu-2404' }} timeout-minutes: 10 steps: - name: Checkout @@ -478,6 +479,83 @@ jobs: - name: Exercise release-only workflow steps run: node scripts/release-smoke.ts + riftri_storage: + name: Riftri storage (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + os: [macos-15, ubuntu-24.04] + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: true + run-install: true + + - name: Ensure Electron runtime is installed + run: vp run --filter @t3tools/desktop ensure:electron + + - name: Resolve published native runtimes + run: | + node --input-type=module <<'NODE' >> "$GITHUB_ENV" + import { createRequire } from 'node:module'; + const server = createRequire(`${process.cwd()}/apps/server/package.json`); + const desktop = createRequire(`${process.cwd()}/apps/desktop/package.json`); + console.log(`T3CODE_TEST_RIFTRI_BINARY=${server('riftri').resolveBinary()}`); + console.log(`T3CODE_TEST_ELECTRON_BINARY=${desktop('electron')}`); + NODE + echo 'T3CODE_TEST_RIFTRI_PACKAGE=1' >> "$GITHUB_ENV" + echo 'RIFTRI_BYPASS=1' >> "$GITHUB_ENV" + + - name: Prepare disposable Linux OverlayFS volume + if: runner.os == 'Linux' + run: | + # Only this disposable runner's mount namespace is used by the tests. + if test -e /proc/sys/kernel/apparmor_restrict_unprivileged_userns; then + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 + fi + truncate -s 2G "$RUNNER_TEMP/t3-riftri-overlay.img" + mkdir "$RUNNER_TEMP/t3-riftri-overlay" + mkfs.ext4 -F "$RUNNER_TEMP/t3-riftri-overlay.img" + sudo mount -o loop "$RUNNER_TEMP/t3-riftri-overlay.img" "$RUNNER_TEMP/t3-riftri-overlay" + sudo chown "$(id -u):$(id -g)" "$RUNNER_TEMP/t3-riftri-overlay" + + - name: Test native Git lifecycle and packaged runtime + shell: bash + run: | + unset RIFTRI_BINARY + command=(vp test run --fileParallelism=false + apps/server/src/vcs/RiftriWorktrees.test.ts + apps/server/src/vcs/GitVcsDriverCore.riftri.test.ts + apps/server/src/orchestration-v2/ProjectionStore.worktreeRoots.test.ts + apps/server/src/serverRuntimeStartup.test.ts + apps/server/src/serverRuntimeStartup.autoPull.test.ts + scripts/lib/riftri-packaging.test.ts) + if test "$RUNNER_OS" = Linux; then + export TMPDIR="$RUNNER_TEMP/t3-riftri-overlay" + unshare --user --map-root-user --mount --propagation private -- "${command[@]}" + else + "${command[@]}" + fi + + - name: Unmount disposable Linux volume + if: always() && runner.os == 'Linux' + run: | + if mountpoint -q "$RUNNER_TEMP/t3-riftri-overlay"; then + sudo umount "$RUNNER_TEMP/t3-riftri-overlay" + fi + # Branch protection requires this job by its name, `Check`. It fails when any # other job fails, is cancelled, or is skipped without a reason, so splitting # or resharding jobs needs no ruleset change. Add every new job to `needs`. @@ -499,8 +577,9 @@ jobs: mobile_native_changes, mobile_native_static_analysis, release_smoke, + riftri_storage, ] - runs-on: blacksmith-2vcpu-ubuntu-2404 + runs-on: ${{ github.event.repository.fork && 'ubuntu-24.04' || 'blacksmith-2vcpu-ubuntu-2404' }} timeout-minutes: 5 steps: - name: Require every job to pass From 97cdffc023550455dd8162a8cbaef66fadb360de Mon Sep 17 00:00:00 2001 From: Kinfe123 Date: Thu, 8 Oct 2026 00:46:50 -0700 Subject: [PATCH 4/7] fix(server): wire storage recovery into the production runtime --- apps/server/src/server.ts | 6 +- .../src/serverRuntimeStartup.autoPull.test.ts | 4 +- apps/server/src/serverRuntimeStartup.test.ts | 90 +++++++++++++------ apps/server/src/vcs/GitVcsDriverCore.test.ts | 6 ++ knip.jsonc | 3 +- scripts/lib/cli-external-packages.test.ts | 9 +- 6 files changed, 86 insertions(+), 32 deletions(-) diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index ebbc73758a43..187473cf7243 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -1034,7 +1034,11 @@ const layerMakeServer = Layer.unwrap( ], { concurrency: "unbounded" }, ).pipe(Effect.asVoid), - }).pipe(Layer.provideMerge(layerRuntimeDependencies), Layer.provide(layerLauncher)); + }).pipe( + Layer.provide(ProjectionStoreV2.layer), + Layer.provideMerge(layerRuntimeDependencies), + Layer.provide(layerLauncher), + ); const layerRoutes = HttpRouter.serve(layerMakeRoutes.pipe(Layer.provide(layerLauncher)), { disableLogger: !config.logWebSocketEvents, diff --git a/apps/server/src/serverRuntimeStartup.autoPull.test.ts b/apps/server/src/serverRuntimeStartup.autoPull.test.ts index d2a73ccd788f..c38495e4c6d2 100644 --- a/apps/server/src/serverRuntimeStartup.autoPull.test.ts +++ b/apps/server/src/serverRuntimeStartup.autoPull.test.ts @@ -139,7 +139,9 @@ it.effect("parks automatic pull until activation without delaying command readin Layer.mock(ProjectStore.ProjectStoreV2)({ listShells: () => Effect.succeed(snapshot.projects), }), - Layer.mock(ProjectionStore.ProjectionStoreV2)({ getWorktreeRoots: () => Effect.succeed([]) }), + Layer.mock(ProjectionStore.ProjectionStoreV2)({ + getWorktreeRoots: () => Effect.succeed([]), + }), Layer.mock(ChildProcessSpawner.ChildProcessSpawner)({}), Layer.mock(ProjectService.ProjectService)({ snapshot: Effect.succeed(snapshot) }), Layer.mock(ThreadManagement.ThreadManagementService)({ diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index fca3645e0f4b..1715960d6b74 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -35,22 +35,37 @@ it.effect("restores persisted worktrees by repository before their providers can const calls: { cwd: string | undefined; args: ReadonlyArray }[] = []; let repaired = false; yield* ServerRuntimeStartup.restorePersistedWorktrees.pipe( - Effect.provide(Layer.mock(ProjectStore.ProjectStoreV2)({ - listShells: () => Effect.succeed(["one", "two", "plain"].map((id) => ({ - id: ProjectId.make(id), title: id, - workspaceRoot: id === "plain" ? "/not-a-repository" : "/repository", - defaultModelSelection: null, scripts: [], - createdAt: "2026-10-07T00:00:00.000Z", updatedAt: "2026-10-07T00:00:00.000Z", deletedAt: null, - }))), - })), - Effect.provide(Layer.mock(ProjectionStore.ProjectionStoreV2)({ - getWorktreeRoots: () => Effect.succeed([ - { projectId: ProjectId.make("one"), worktreePath: "/views/a" }, - { projectId: ProjectId.make("two"), worktreePath: "/views/a" }, - { projectId: ProjectId.make("two"), worktreePath: "/views/b" }, - { projectId: ProjectId.make("deleted-project"), worktreePath: "/views/deleted-project" }, - ]), - })), + Effect.provide( + Layer.mock(ProjectStore.ProjectStoreV2)({ + listShells: () => + Effect.succeed( + ["one", "two", "plain"].map((id) => ({ + id: ProjectId.make(id), + title: id, + workspaceRoot: id === "plain" ? "/not-a-repository" : "/repository", + defaultModelSelection: null, + scripts: [], + createdAt: "2026-10-07T00:00:00.000Z", + updatedAt: "2026-10-07T00:00:00.000Z", + deletedAt: null, + })), + ), + }), + ), + Effect.provide( + Layer.mock(ProjectionStore.ProjectionStoreV2)({ + getWorktreeRoots: () => + Effect.succeed([ + { projectId: ProjectId.make("one"), worktreePath: "/views/a" }, + { projectId: ProjectId.make("two"), worktreePath: "/views/a" }, + { projectId: ProjectId.make("two"), worktreePath: "/views/b" }, + { + projectId: ProjectId.make("deleted-project"), + worktreePath: "/views/deleted-project", + }, + ]), + }), + ), Effect.provideService(ProcessRunner.ProcessRunner, { run: (input) => { calls.push({ cwd: input.cwd, args: input.args }); @@ -60,29 +75,46 @@ it.effect("restores persisted worktrees by repository before their providers can report = { schema_version: 1, errors: [] }; } else { report = { - schema_version: 1, native_path_encoding: "unix-bytes-hex", + schema_version: 1, + native_path_encoding: "unix-bytes-hex", worktrees: input.args.slice(input.args.indexOf("--") + 1).map((path) => ({ - path, path_native_hex: Buffer.from(path).toString("hex"), - state_directory: "/state", state_directory_native_hex: Buffer.from("/state").toString("hex"), - backend: "overlay-fs", mount_status: repaired ? "active" : "recovery-required", + path, + path_native_hex: Buffer.from(path).toString("hex"), + state_directory: "/state", + state_directory_native_hex: Buffer.from("/state").toString("hex"), + backend: "overlay-fs", + mount_status: repaired ? "active" : "recovery-required", })), }; } return Effect.succeed({ - code: ChildProcessSpawner.ExitCode(0), stdout: JSON.stringify(report), stderr: "", - timedOut: false, stdoutTruncated: false, stderrTruncated: false, - stdoutInvalidUtf8: false, stderrInvalidUtf8: false, + code: ChildProcessSpawner.ExitCode(0), + stdout: JSON.stringify(report), + stderr: "", + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, }); }, }), Effect.provideService(HostProcessEnvironment, { RIFTRI_BINARY: "/test/riftri" }), Effect.provide(Path.layer), ); - assert.deepEqual(calls.map(({ args }) => args.slice(0, 2)), [ - ["worktree", "inspect"], ["repair", "--state-dir=/state"], ["worktree", "inspect"], - ]); + assert.deepEqual( + calls.map(({ args }) => args.slice(0, 2)), + [ + ["worktree", "inspect"], + ["repair", "--state-dir=/state"], + ["worktree", "inspect"], + ], + ); assert.isTrue(calls.every(({ cwd }) => cwd === "/repository")); - assert.deepEqual(calls[0]!.args.slice(calls[0]!.args.indexOf("--") + 1), ["/views/a", "/views/b"]); + assert.deepEqual(calls[0]!.args.slice(calls[0]!.args.indexOf("--") + 1), [ + "/views/a", + "/views/b", + ]); }), ); @@ -126,7 +158,9 @@ it.effect("an unsafe persisted worktree prevents provider recovery and backgroun }); const result = yield* ServerRuntimeStartup.runOrderedV2StartupPhases({ importLegacyShells: record("import"), - restoreWorktreeStorage: record("storage").pipe(Effect.andThen(Effect.fail("mount unavailable"))), + restoreWorktreeStorage: record("storage").pipe( + Effect.andThen(Effect.fail("mount unavailable")), + ), recover: record("recover"), recoverDelegatedTasks: record("delegated"), startEffectWorker: record("worker"), diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 15a6b3b9b6c8..0fca6a86d75f 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -53,6 +53,12 @@ const layerTest = GitVcsDriver.layer.pipe( Layer.succeed(HostProcessEnvironment, { ...process.env, T3CODE_WORKTREE_STORAGE: "git", + // The suite's injected config is for ordinary Git. Native lifecycle + // commands deliberately refuse it; these disposable repos use no host config. + GIT_CONFIG_COUNT: undefined, + GIT_CONFIG_PARAMETERS: undefined, + GIT_CONFIG_GLOBAL: HostProcessPlatform.defaultValue() === "win32" ? "NUL" : "/dev/null", + GIT_CONFIG_NOSYSTEM: "1", }), ), ); diff --git a/knip.jsonc b/knip.jsonc index 24d0f85db881..7c60c690ea40 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -36,7 +36,8 @@ ], // Keep the transitive Effect runtime pinned for standalone npm installs. // The Vite+ web build prerequisite. - "ignoreDependencies": ["@effect/platform-node-shared", "@t3tools/web"], + // RiftriWorktrees loads the disk-backed native package with createRequire. + "ignoreDependencies": ["@effect/platform-node-shared", "@t3tools/web", "riftri"], }, "apps/desktop": { // Electron loads these bundles by filename rather than importing them. diff --git a/scripts/lib/cli-external-packages.test.ts b/scripts/lib/cli-external-packages.test.ts index 73499faeb24b..02ca32d8950f 100644 --- a/scripts/lib/cli-external-packages.test.ts +++ b/scripts/lib/cli-external-packages.test.ts @@ -103,7 +103,14 @@ describe("selectCliRuntimeExternalDependencies", () => { it("selects every external root declared by the server", () => { assert.deepStrictEqual( Object.keys(selectCliRuntimeExternalDependencies(serverPackageJson.dependencies)).sort(), - ["@cursor/sdk", "@ff-labs/fff-node", "@napi-rs/keyring", "node-pty", "playwright-core"], + [ + "@cursor/sdk", + "@ff-labs/fff-node", + "@napi-rs/keyring", + "node-pty", + "playwright-core", + "riftri", + ], ); }); }); From 9acbf826d79e74cc112845fc77e02f5424d940dc Mon Sep 17 00:00:00 2001 From: Kinfe123 Date: Thu, 8 Oct 2026 01:10:02 -0700 Subject: [PATCH 5/7] fix(server): isolate unavailable workspaces during storage recovery --- .github/workflows/ci.yml | 3 + .../ProviderSessionManager.test.ts | 45 ++++++ .../ProviderSessionManager.ts | 20 +-- .../src/serverRuntimeStartup.autoPull.test.ts | 2 + apps/server/src/serverRuntimeStartup.test.ts | 136 +++++++++--------- apps/server/src/serverRuntimeStartup.ts | 11 +- apps/server/src/terminal/Manager.test.ts | 4 +- apps/server/src/terminal/Manager.ts | 10 +- apps/server/src/vcs/GitVcsDriver.ts | 3 +- .../src/vcs/GitVcsDriverCore.riftri.test.ts | 12 +- apps/server/src/vcs/GitVcsDriverCore.test.ts | 25 +++- apps/server/src/vcs/GitVcsDriverCore.ts | 43 ++++++ apps/server/src/vcs/RiftriWorktrees.test.ts | 136 ++++++++++++++++++ .../src/workspace/WorkspaceFileSystem.ts | 9 +- .../src/workspace/WorkspaceStorage.test.ts | 122 ++++++++++++++++ apps/server/src/workspace/WorkspaceStorage.ts | 131 +++++++++++++++++ docs/user/project-settings.md | 7 +- 17 files changed, 614 insertions(+), 105 deletions(-) create mode 100644 apps/server/src/workspace/WorkspaceStorage.test.ts create mode 100644 apps/server/src/workspace/WorkspaceStorage.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e3e2c38be3dd..bf8a121e55e0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -541,11 +541,14 @@ jobs: apps/server/src/orchestration-v2/ProjectionStore.worktreeRoots.test.ts apps/server/src/serverRuntimeStartup.test.ts apps/server/src/serverRuntimeStartup.autoPull.test.ts + apps/server/src/workspace/WorkspaceStorage.test.ts scripts/lib/riftri-packaging.test.ts) if test "$RUNNER_OS" = Linux; then + export T3CODE_TEST_RIFTRI_BACKEND=overlay-fs export TMPDIR="$RUNNER_TEMP/t3-riftri-overlay" unshare --user --map-root-user --mount --propagation private -- "${command[@]}" else + export T3CODE_TEST_RIFTRI_BACKEND=apfs-clone "${command[@]}" fi diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts index bfb076ee3bf3..d39bd13dbe39 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts @@ -56,6 +56,7 @@ import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; import * as ProviderEventIngestor from "./ProviderEventIngestor.ts"; import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; import * as ProviderSessionManager from "./ProviderSessionManager.ts"; +import * as WorkspaceStorage from "../workspace/WorkspaceStorage.ts"; const layerTestDatabase = SqlitePersistence.layerMemory; const layerTestStores = Layer.merge(EventStore.layer, ProjectionStore.layer).pipe( @@ -514,6 +515,7 @@ function layerTest(input: { ), ); return Layer.mergeAll( + WorkspaceStorage.layer, layerTestStores, layerConfiguredEventSink, IdAllocator.layer, @@ -4269,6 +4271,49 @@ it.effect( }), ); +it.effect("blocks provider launch only for an unverifiable persisted workspace", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const root = yield* fileSystem.makeTempDirectoryScoped(); + const cwd = `${root}/workspace`; + const healthy = `${root}/healthy`; + yield* fileSystem.makeDirectory(cwd); + yield* fileSystem.makeDirectory(healthy); + const state = yield* Ref.make(emptyState); + yield* Effect.gen(function* () { + const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; + const storage = yield* WorkspaceStorage.WorkspaceStorage; + const eventSink = yield* EventSink.EventSinkV2; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const threadId = ThreadId.make("thread-storage-blocked"); + const providerSessionId = yield* idAllocator.allocate.providerSession({ + providerInstanceId: modelSelection.instanceId, + threadId, + }); + yield* eventSink.write({ + events: [ + yield* makeThreadCreatedEvent({ idAllocator, threadId, now: yield* DateTime.now }), + ], + }); + // A disappeared repository is not proof that its persisted worktree is + // unmanaged. Startup must preserve the path and block its providers. + yield* storage.restore({ cwd: `${root}/missing-repository`, destinations: [cwd] }); + const input = { + threadId, + providerSessionId, + modelSelection, + runtimePolicy: { ...runtimePolicy, cwd }, + }; + const error = yield* manager.open(input).pipe(Effect.flip); + assert.instanceOf(error, WorkspaceStorage.WorkspaceStorageUnavailableError); + assert.equal((yield* Ref.get(state)).openCount, 0); + assert.isTrue(yield* fileSystem.exists(cwd)); + yield* manager.open({ ...input, runtimePolicy: { ...runtimePolicy, cwd: healthy } }); + assert.equal((yield* Ref.get(state)).openCount, 1); + }).pipe(Effect.provide(layerTest({ state, idleTimeoutMs: 60_000 }))); + }).pipe(Effect.provide(NodeServices.layer)), +); + it.effect.each(["missing", "file"] as const)( "rejects a %s workspace before opening a provider session", (workspaceState) => diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.ts index f84441129cc3..69d15d7cdb9f 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.ts @@ -21,6 +21,7 @@ import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as FiberSet from "effect/FiberSet"; import * as FileSystem from "effect/FileSystem"; +import * as WorkspaceStorage from "../workspace/WorkspaceStorage.ts"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; @@ -145,6 +146,7 @@ export class ProviderSessionActivityError extends Schema.TaggedError => +export const layerWithOptions = (options: ProviderSessionManagerV2LayerOptions = {}) => Layer.effect( ProviderSessionManagerV2, Effect.gen(function* () { const registry = yield* ProviderAdapterRegistry.ProviderAdapterRegistryV2; const fileSystem = yield* FileSystem.FileSystem; + const workspaceStorage = yield* WorkspaceStorage.WorkspaceStorage; const mcpSessionRegistry = yield* McpSessionRegistry.McpSessionRegistry; /** * Optional so the many focused tests that assemble this layer by hand do @@ -1822,6 +1813,7 @@ export const layerWithOptions = ( Effect.gen(function* () { const cwd = input.runtimePolicy.cwd; if (cwd !== null) { + yield* workspaceStorage.ensureReady(cwd); const workspaceIsDirectory = yield* fileSystem.stat(cwd).pipe( Effect.map((stat) => stat.type === "Directory"), Effect.catch((error) => Effect.succeed(error.reason._tag !== "NotFound")), @@ -2228,6 +2220,6 @@ export const layerWithOptions = ( ), } satisfies ProviderSessionManagerV2Shape); }), - ); + ).pipe(Layer.provide(WorkspaceStorage.layer)); export const layer = layerWithOptions(); diff --git a/apps/server/src/serverRuntimeStartup.autoPull.test.ts b/apps/server/src/serverRuntimeStartup.autoPull.test.ts index c38495e4c6d2..965efadc896b 100644 --- a/apps/server/src/serverRuntimeStartup.autoPull.test.ts +++ b/apps/server/src/serverRuntimeStartup.autoPull.test.ts @@ -6,6 +6,7 @@ import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability"; import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import { HttpServer } from "effect/http"; @@ -143,6 +144,7 @@ it.effect("parks automatic pull until activation without delaying command readin getWorktreeRoots: () => Effect.succeed([]), }), Layer.mock(ChildProcessSpawner.ChildProcessSpawner)({}), + FileSystem.layerNoop({}), Layer.mock(ProjectService.ProjectService)({ snapshot: Effect.succeed(snapshot) }), Layer.mock(ThreadManagement.ThreadManagementService)({ getShellSnapshot: () => diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index 1715960d6b74..3e9b7cc8b453 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -11,7 +11,6 @@ import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as Ref from "effect/Ref"; import * as Layer from "effect/Layer"; -import * as Path from "effect/Path"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; @@ -19,6 +18,8 @@ import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; import * as ProjectStore from "./orchestration-v2/ProjectStore.ts"; import * as ProjectionStore from "./orchestration-v2/ProjectionStore.ts"; import * as ProcessRunner from "./processRunner.ts"; +import * as WorkspaceStorage from "./workspace/WorkspaceStorage.ts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; import * as ServerConfig from "./config.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; @@ -34,74 +35,75 @@ it.effect("restores persisted worktrees by repository before their providers can Effect.gen(function* () { const calls: { cwd: string | undefined; args: ReadonlyArray }[] = []; let repaired = false; - yield* ServerRuntimeStartup.restorePersistedWorktrees.pipe( - Effect.provide( - Layer.mock(ProjectStore.ProjectStoreV2)({ - listShells: () => - Effect.succeed( - ["one", "two", "plain"].map((id) => ({ - id: ProjectId.make(id), - title: id, - workspaceRoot: id === "plain" ? "/not-a-repository" : "/repository", - defaultModelSelection: null, - scripts: [], - createdAt: "2026-10-07T00:00:00.000Z", - updatedAt: "2026-10-07T00:00:00.000Z", - deletedAt: null, - })), - ), - }), - ), - Effect.provide( - Layer.mock(ProjectionStore.ProjectionStoreV2)({ - getWorktreeRoots: () => - Effect.succeed([ - { projectId: ProjectId.make("one"), worktreePath: "/views/a" }, - { projectId: ProjectId.make("two"), worktreePath: "/views/a" }, - { projectId: ProjectId.make("two"), worktreePath: "/views/b" }, - { - projectId: ProjectId.make("deleted-project"), - worktreePath: "/views/deleted-project", - }, - ]), + const testLayer = Layer.mergeAll( + Layer.effect(WorkspaceStorage.WorkspaceStorage, WorkspaceStorage.make), + Layer.mock(ProjectStore.ProjectStoreV2)({ + listShells: () => + Effect.succeed( + ["one", "two", "plain"].map((id) => ({ + id: ProjectId.make(id), + title: id, + workspaceRoot: id === "plain" ? "/not-a-repository" : "/repository", + defaultModelSelection: null, + scripts: [], + createdAt: "2026-10-07T00:00:00.000Z", + updatedAt: "2026-10-07T00:00:00.000Z", + deletedAt: null, + })), + ), + }), + Layer.mock(ProjectionStore.ProjectionStoreV2)({ + getWorktreeRoots: () => + Effect.succeed([ + { projectId: ProjectId.make("one"), worktreePath: "/views/a" }, + { projectId: ProjectId.make("two"), worktreePath: "/views/a" }, + { projectId: ProjectId.make("two"), worktreePath: "/views/b" }, + { + projectId: ProjectId.make("deleted-project"), + worktreePath: "/views/deleted-project", + }, + ]), + }), + ).pipe( + Layer.provide( + Layer.succeed(ProcessRunner.ProcessRunner, { + run: (input) => { + calls.push({ cwd: input.cwd, args: input.args }); + let report: unknown; + if (input.args[0] === "repair") { + repaired = true; + report = { schema_version: 1, errors: [] }; + } else { + report = { + schema_version: 1, + native_path_encoding: "unix-bytes-hex", + worktrees: input.args.slice(input.args.indexOf("--") + 1).map((path) => ({ + path, + path_native_hex: Buffer.from(path).toString("hex"), + state_directory: "/state", + state_directory_native_hex: Buffer.from("/state").toString("hex"), + backend: "overlay-fs", + mount_status: repaired ? "active" : "recovery-required", + })), + }; + } + return Effect.succeed({ + code: ChildProcessSpawner.ExitCode(0), + stdout: JSON.stringify(report), + stderr: "", + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, + }); + }, }), ), - Effect.provideService(ProcessRunner.ProcessRunner, { - run: (input) => { - calls.push({ cwd: input.cwd, args: input.args }); - let report: unknown; - if (input.args[0] === "repair") { - repaired = true; - report = { schema_version: 1, errors: [] }; - } else { - report = { - schema_version: 1, - native_path_encoding: "unix-bytes-hex", - worktrees: input.args.slice(input.args.indexOf("--") + 1).map((path) => ({ - path, - path_native_hex: Buffer.from(path).toString("hex"), - state_directory: "/state", - state_directory_native_hex: Buffer.from("/state").toString("hex"), - backend: "overlay-fs", - mount_status: repaired ? "active" : "recovery-required", - })), - }; - } - return Effect.succeed({ - code: ChildProcessSpawner.ExitCode(0), - stdout: JSON.stringify(report), - stderr: "", - timedOut: false, - stdoutTruncated: false, - stderrTruncated: false, - stdoutInvalidUtf8: false, - stderrInvalidUtf8: false, - }); - }, - }), - Effect.provideService(HostProcessEnvironment, { RIFTRI_BINARY: "/test/riftri" }), - Effect.provide(Path.layer), + Layer.provide(Layer.succeed(HostProcessEnvironment, { RIFTRI_BINARY: "/test/riftri" })), + Layer.provide(NodeServices.layer), ); + yield* ServerRuntimeStartup.restorePersistedWorktrees.pipe(Effect.provide(testLayer)); assert.deepEqual( calls.map(({ args }) => args.slice(0, 2)), [ @@ -149,7 +151,7 @@ it.effect("starts without scanning or rebuilding projection history", () => }), ); -it.effect("an unsafe persisted worktree prevents provider recovery and background work", () => +it.effect("failure to load startup metadata prevents provider recovery and background work", () => Effect.gen(function* () { const calls: string[] = []; const record = (label: string) => diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index e23c444897b1..89604ce631f0 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -39,8 +39,7 @@ import * as ThreadLaunch from "./orchestration-v2/ThreadLaunchService.ts"; import * as ThreadManagement from "./orchestration-v2/ThreadManagementService.ts"; import * as ProjectService from "./project/ProjectService.ts"; import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; -import * as RiftriWorktrees from "./vcs/RiftriWorktrees.ts"; -import * as ProcessRunner from "./processRunner.ts"; +import * as WorkspaceStorage from "./workspace/WorkspaceStorage.ts"; import * as ProjectStore from "./orchestration-v2/ProjectStore.ts"; import * as ProjectionStore from "./orchestration-v2/ProjectionStore.ts"; import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts"; @@ -400,7 +399,7 @@ export const restorePersistedWorktrees = Effect.gen(function* () { paths.add(root.worktreePath); } if (pathsByRepository.size === 0) return; - const storage = yield* RiftriWorktrees.make(); + const storage = yield* WorkspaceStorage.WorkspaceStorage; for (const [cwd, destinations] of pathsByRepository) { yield* storage.restore({ cwd, destinations: [...destinations] }); } @@ -548,9 +547,7 @@ const make = (options?: StartupOptions) => "worktrees.storage.restore", // Native clones survive a reboot without mounts. Only Linux can // have journaled views whose files are hidden until remounting. - environment.platform.os === "linux" - ? restorePersistedWorktrees.pipe(Effect.provide(ProcessRunner.layer)) - : Effect.void, + environment.platform.os === "linux" ? restorePersistedWorktrees : Effect.void, ), recover: runStartupPhase("orchestration-v2.recovery", providerRuntimeRecovery.recover), recoverDelegatedTasks: runStartupPhase( @@ -726,6 +723,6 @@ const make = (options?: StartupOptions) => }); export const layerWithOptions = (options?: StartupOptions) => - Layer.effect(ServerRuntimeStartup, make(options)); + Layer.effect(ServerRuntimeStartup, make(options)).pipe(Layer.provide(WorkspaceStorage.layer)); const layer = layerWithOptions(); diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index 5a493cc28053..3f0b7daa31b8 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -39,6 +39,7 @@ import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as ServerConfig from "../config.ts"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as ProcessRunner from "../processRunner.ts"; +import * as WorkspaceStorage from "../workspace/WorkspaceStorage.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as TerminalManager from "./Manager.ts"; import * as PtyAdapter from "./PtyAdapter.ts"; @@ -267,6 +268,7 @@ const createManager = ( const logsDir = join(baseDir, "userdata", "logs", "terminals"); const ptyAdapter = options.ptyAdapter ?? new FakePtyAdapter(); + const workspaceStorage = yield* WorkspaceStorage.make; const manager = yield* TerminalManager.makeWithOptions({ logsDir, historyLineLimit, @@ -296,7 +298,7 @@ const createManager = ( managedBinaryCacheDir: options.managedBinaryCacheDir, managedBinaryToolsDir: options.managedBinaryToolsDir, }), - }); + }).pipe(Effect.provideService(WorkspaceStorage.WorkspaceStorage, workspaceStorage)); const eventsRef = yield* Ref.make>([]); const unsubscribe = yield* manager.subscribe((event) => Ref.update(eventsRef, (events) => [...events, event]), diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 3c71a8849475..6024dc17ab33 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -7,6 +7,7 @@ * @module TerminalManager */ import { withWorkspaceLease } from "../workspace/workspaceLease.ts"; +import * as WorkspaceStorage from "../workspace/WorkspaceStorage.ts"; import { DEFAULT_TERMINAL_ID, TerminalCwdError, @@ -1470,6 +1471,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func options: TerminalManagerOptions, ) { const fileSystem = yield* FileSystem.FileSystem; + const workspaceStorage = yield* WorkspaceStorage.WorkspaceStorage; const path = yield* Path.Path; const context = yield* Effect.context(); const runFork = Effect.runForkWith(context); @@ -1937,6 +1939,9 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func }); const assertValidCwd = Effect.fn("terminal.assertValidCwd")(function* (cwd: string) { + yield* workspaceStorage + .ensureReady(cwd) + .pipe(Effect.mapError((cause) => new TerminalCwdStatError({ cwd, cause }))); const stats = yield* fileSystem.stat(cwd).pipe( Effect.catchTags({ PlatformError: (cause) => @@ -3189,4 +3194,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func }); }); -export const layer = Layer.effect(TerminalManager, make()).pipe(Layer.provide(ProcessRunner.layer)); +export const layer = Layer.effect(TerminalManager, make()).pipe( + Layer.provide(WorkspaceStorage.layer), + Layer.provide(ProcessRunner.layer), +); diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 6513d037c8dc..e9d4b9f1c2d4 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -39,6 +39,7 @@ import { } from "./GitVcsDriverCore.ts"; import * as VcsDriver from "./VcsDriver.ts"; import * as VcsProcess from "./VcsProcess.ts"; +import * as WorkspaceStorage from "../workspace/WorkspaceStorage.ts"; export interface ExecuteGitInput { readonly operation: string; @@ -1262,4 +1263,4 @@ export const make = Effect.gen(function* () { }); export const layerVcs = Layer.effect(VcsDriver.VcsDriver, makeVcsDriver); -export const layer = Layer.effect(GitVcsDriver, make); +export const layer = Layer.effect(GitVcsDriver, make).pipe(Layer.provide(WorkspaceStorage.layer)); diff --git a/apps/server/src/vcs/GitVcsDriverCore.riftri.test.ts b/apps/server/src/vcs/GitVcsDriverCore.riftri.test.ts index 193125a841c6..9bb8416ad3a3 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.riftri.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.riftri.test.ts @@ -6,6 +6,8 @@ import * as Path from "effect/Path"; import * as Result from "effect/Result"; import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as ProcessRunner from "../processRunner.ts"; +import * as WorkspaceStorage from "../workspace/WorkspaceStorage.ts"; +import * as Layer from "effect/Layer"; import * as ServerConfig from "../config.ts"; import { makeGitVcsDriverCore } from "./GitVcsDriverCore.ts"; @@ -53,7 +55,7 @@ it.effect.skipIf(!process.env.T3CODE_TEST_RIFTRI_BINARY)( for (const mode of ["git", "auto", "riftri"] as const) { const branchesBefore = yield* git(["show-ref"]); const driver = yield* makeGitVcsDriverCore().pipe( - Effect.provide(config), + Effect.provide(Layer.merge(WorkspaceStorage.layer, config)), Effect.provideService(HostProcessEnvironment, { ...environment, T3CODE_WORKTREE_STORAGE: mode, @@ -148,7 +150,7 @@ it.effect.skipIf(!process.env.T3CODE_TEST_RIFTRI_BINARY)( yield* git(["-c", "commit.gpgsign=false", "commit", "-m", "fixture"]); const config = ServerConfig.layerTest(cwd, path.join(root, "t3")); const driver = yield* makeGitVcsDriverCore().pipe( - Effect.provide(config), + Effect.provide(Layer.merge(WorkspaceStorage.layer, config)), Effect.provideService(HostProcessEnvironment, environment), ); const destination = path.join(root, "view"); @@ -199,7 +201,7 @@ it.effect.skipIf(!process.env.T3CODE_TEST_RIFTRI_BINARY)( assert.include(owner.stdout, ".t3-riftri"); assert.equal((yield* git(["status", "--porcelain"], destination)).stdout, ""); const restarted = yield* makeGitVcsDriverCore().pipe( - Effect.provide(config), + Effect.provide(Layer.merge(WorkspaceStorage.layer, config)), Effect.provideService(HostProcessEnvironment, { ...environment, T3CODE_WORKTREE_STORAGE: "git", @@ -292,7 +294,9 @@ it.effect.each([ const refName = reference === "commit" ? commit : reference; const destination = path.join(root, "view"); const driver = yield* makeGitVcsDriverCore().pipe( - Effect.provide(ServerConfig.layerTest(cwd, path.join(root, "t3"))), + Effect.provide( + Layer.merge(WorkspaceStorage.layer, ServerConfig.layerTest(cwd, path.join(root, "t3"))), + ), Effect.provideService(HostProcessEnvironment, environment), ); yield* Effect.addFinalizer(() => diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 0fca6a86d75f..de5593a3b820 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -37,6 +37,7 @@ import { splitNullSeparatedGitStdoutPaths, } from "./GitVcsDriverCore.ts"; import * as GitVcsDriver from "./GitVcsDriver.ts"; +import * as WorkspaceStorage from "../workspace/WorkspaceStorage.ts"; const encodeGitCommandError = Schema.encodeEffect(Schema.fromJsonString(GitCommandError)); @@ -186,6 +187,7 @@ it.effect("bounds Git bursts across drivers without timing out queued commands", const drivers = yield* Effect.all( Array.from({ length: 16 }, () => makeGitVcsDriverCore().pipe( + Effect.provide(WorkspaceStorage.layer), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ), ), @@ -245,6 +247,7 @@ it.effect.each([{ timeoutMs: null }, { timeoutMs: 30_001 }])( ), ); const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provide(WorkspaceStorage.layer), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ); const slow = yield* driver @@ -436,6 +439,7 @@ it.effect("coalesces concurrent ref pages into one repository snapshot", () => }), ); const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provide(WorkspaceStorage.layer), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, countingSpawner), ); const cwd = yield* makeTmpDir(); @@ -548,6 +552,7 @@ it.effect("retries an in-flight ref snapshot invalidated by a mutation", () => }), ); const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provide(WorkspaceStorage.layer), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, coordinatingSpawner), ); const cwd = yield* makeTmpDir(); @@ -587,6 +592,7 @@ it.effect("invalidates a ref snapshot when a mutation fails after changing Git", }), ); const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provide(WorkspaceStorage.layer), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, partiallyFailingSpawner), ); const cwd = yield* makeTmpDir(); @@ -619,6 +625,7 @@ it.effect("fails a ref snapshot when for-each-ref exits unsuccessfully", () => }), ); const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provide(WorkspaceStorage.layer), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, failingSnapshotSpawner), ); const cwd = yield* makeTmpDir(); @@ -657,6 +664,7 @@ it.effect("marks the current branch when worktree metadata is unavailable", () = }), ); const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provide(WorkspaceStorage.layer), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, incompleteMetadataSpawner), ); const cwd = yield* makeTmpDir(); @@ -693,6 +701,7 @@ it.effect("ignores worktree metadata for directories that no longer exist", () = }), ); const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provide(WorkspaceStorage.layer), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, staleWorktreeSpawner), ); const cwd = yield* makeTmpDir(); @@ -754,6 +763,7 @@ it.effect("backs off and logs failed fetch attempts across linked worktrees", () }), ); const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provide(WorkspaceStorage.layer), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, failingFetchSpawner), ); const warnings: string[] = []; @@ -929,6 +939,7 @@ it.effect.each([ }), ); const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provide(WorkspaceStorage.layer), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ); const cwd = yield* makeTmpDir(); @@ -1438,8 +1449,8 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { return delegate.spawn(command); }); const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provide(Layer.merge(WorkspaceStorage.layer, layerServerConfig)), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.provide(layerServerConfig), ); const branch = yield* driver.getReviewDiffPreview({ cwd, @@ -1477,8 +1488,8 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { : delegate.spawn(command), ); const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provide(Layer.merge(WorkspaceStorage.layer, layerServerConfig)), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.provide(layerServerConfig), ); const result = yield* driver.getReviewDiffPreview({ cwd }).pipe(Effect.result); assert.isTrue(Result.isFailure(result)); @@ -1681,8 +1692,8 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { : delegate.spawn(command); }); const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provide(Layer.merge(WorkspaceStorage.layer, layerServerConfig)), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, failingLsFilesSpawner), - Effect.provide(layerServerConfig), ); const cwd = yield* makeTmpDir(); yield* initRepoWithCommit(cwd).pipe( @@ -2348,8 +2359,8 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provide(Layer.merge(WorkspaceStorage.layer, layerServerConfig)), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.provide(layerServerConfig), ); for (const hasUpstream of [false, true]) { @@ -3250,8 +3261,8 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provide(Layer.merge(WorkspaceStorage.layer, layerServerConfig)), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, delayedRemovalSpawner), - Effect.provide(layerServerConfig), ); const cwd = yield* makeTmpDir(); const { initialBranch } = yield* initRepoWithCommit(cwd); @@ -3559,8 +3570,8 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provide(Layer.merge(WorkspaceStorage.layer, layerServerConfig)), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.provide(layerServerConfig), ); const fetching = yield* driver .fetchRemote({ cwd, remoteName: "origin", refName: "main" }) @@ -3739,8 +3750,8 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provide(Layer.merge(WorkspaceStorage.layer, layerServerConfig)), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, delayedPushSpawner), - Effect.provide(layerServerConfig), ); const cwd = yield* makeTmpDir(); const remote = yield* makeTmpDir("git-remote-"); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index a70b526b5c31..c9266a39095a 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -45,6 +45,7 @@ import { import * as ServerConfig from "../config.ts"; import * as ProcessRunner from "../processRunner.ts"; import * as RiftriWorktrees from "./RiftriWorktrees.ts"; +import * as WorkspaceStorage from "../workspace/WorkspaceStorage.ts"; const DEFAULT_TIMEOUT_MS = 30_000; const gitProcesses = Semaphore.makeUnsafe(8); @@ -907,10 +908,22 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const { worktreesDir } = yield* ServerConfig.ServerConfig; const crypto = yield* Crypto.Crypto; + const workspaceStorage = yield* WorkspaceStorage.WorkspaceStorage; const worktreeStorage = yield* RiftriWorktrees.make().pipe(Effect.provide(ProcessRunner.layer)); const executeRaw: GitVcsDriver.GitVcsDriver["Service"]["execute"] = Effect.fnUntraced( function* (input) { + yield* workspaceStorage.ensureReady(input.cwd).pipe( + Effect.mapError( + (cause) => + new GitCommandError({ + ...gitCommandContext(input), + detail: + "Workspace storage is not ready. Inspect and repair its Riftri state, then retry.", + cause, + }), + ), + ); const commandInput = { ...input, args: [...input.args], @@ -3379,6 +3392,21 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const args = input.newRefName ? ["worktree", "add", "-b", input.newRefName, worktreePath, input.refName] : ["worktree", "add", worktreePath, input.refName]; + for (const cwd of [input.cwd, worktreePath]) { + yield* workspaceStorage.ensureReady(cwd).pipe( + Effect.mapError( + (cause) => + new GitCommandError({ + operation: "GitVcsDriver.createWorktree", + command: "git worktree add", + cwd: input.cwd, + detail: + "Workspace storage is not ready. Inspect and repair its Riftri state, then retry.", + cause, + }), + ), + ); + } const progress = options?.progress; const onCheckoutProgress = progress?.onCheckoutProgress; @@ -3772,6 +3800,21 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const removeWorktree: GitVcsDriver.GitVcsDriver["Service"]["removeWorktree"] = Effect.fn( "removeWorktree", )(function* (input) { + for (const cwd of [input.cwd, input.path]) { + yield* workspaceStorage.ensureReady(cwd).pipe( + Effect.mapError( + (cause) => + new GitCommandError({ + operation: "GitVcsDriver.removeWorktree", + command: "git worktree remove", + cwd: input.cwd, + detail: + "Workspace storage is not ready. Inspect and repair its Riftri state, then retry.", + cause, + }), + ), + ); + } const removed = yield* worktreeStorage .remove({ cwd: input.cwd, diff --git a/apps/server/src/vcs/RiftriWorktrees.test.ts b/apps/server/src/vcs/RiftriWorktrees.test.ts index 20076f8be069..c5761b921743 100644 --- a/apps/server/src/vcs/RiftriWorktrees.test.ts +++ b/apps/server/src/vcs/RiftriWorktrees.test.ts @@ -6,10 +6,17 @@ import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import * as Layer from "effect/Layer"; +import { ProjectId } from "@t3tools/contracts"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as ProcessRunner from "../processRunner.ts"; import * as RiftriWorktrees from "./RiftriWorktrees.ts"; +import * as WorkspaceStorage from "../workspace/WorkspaceStorage.ts"; +import * as ServerRuntimeStartup from "../serverRuntimeStartup.ts"; +import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; +import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; const output = (code: number, stdout: unknown, stderr = ""): ProcessRunner.ProcessRunOutput => ({ code: ChildProcessSpawner.ExitCode(code), @@ -622,6 +629,9 @@ it.effect.skipIf(!process.env.T3CODE_TEST_RIFTRI_BINARY)( assert.isNotNull(second); assert.equal(first?.reused_base, false); assert.equal(second?.reused_base, true); + if (process.env.T3CODE_TEST_RIFTRI_BACKEND) { + assert.equal(first?.backend, process.env.T3CODE_TEST_RIFTRI_BACKEND); + } yield* storage.restore({ cwd, destinations: [firstPath, secondPath] }); assert.equal((yield* git(["status", "--porcelain"], firstPath)).stdout, ""); assert.equal((yield* git(["status", "--porcelain"], secondPath)).stdout, ""); @@ -632,6 +642,132 @@ it.effect.skipIf(!process.env.T3CODE_TEST_RIFTRI_BINARY)( yield* fs.writeFileString(path.join(firstPath, "tracked.txt"), "private edit\n"); assert.equal(yield* fs.readFileString(path.join(secondPath, "tracked.txt")), "original\n"); assert.equal(yield* fs.readFileString(path.join(cwd, "tracked.txt")), "original\n"); + if (first?.backend === "overlay-fs") { + const checkedCommand = (command: string, args: ReadonlyArray) => + runner + .run({ command, args, cwd, env: environment }) + .pipe( + Effect.tap((result) => + Effect.sync(() => assert.equal(result.code, 0, result.stderr)), + ), + ); + const remainingFields = [Schema.Record(Schema.String, Schema.Unknown)] as const; + const BootContext = Schema.StructWithRest( + Schema.Struct({ boot_id: Schema.String }), + remainingFields, + ); + const decodeJournal = Schema.decodeUnknownEffect( + Schema.fromJsonString( + Schema.StructWithRest( + Schema.Struct({ + destination: Schema.Struct({ + encoding: Schema.Literal("unix-bytes"), + units: Schema.Array(Schema.Number), + }), + overlayfs: Schema.StructWithRest( + Schema.Struct({ + mount_context: BootContext, + mount_identity: BootContext, + }), + remainingFields, + ), + }), + remainingFields, + ), + ), + ); + const operations = path.join(root, ".t3-riftri", "operations"); + let journalPath: string | undefined; + for (const entry of yield* fs.readDirectory(operations)) { + if (!entry.endsWith(".json")) continue; + const candidate = path.join(operations, entry); + const journal = yield* decodeJournal(yield* fs.readFileString(candidate)); + if (Buffer.from(journal.destination.units).toString("utf8") !== firstPath) continue; + journalPath = candidate; + // Only this disposable fixture's journal is changed. Mount loss and + // an old boot identity reproduce the post-reboot recovery boundary. + yield* checkedCommand("umount", ["--", firstPath]); + assert.isFalse(yield* fs.exists(path.join(firstPath, "tracked.txt"))); + const previousBoot = "00000000-0000-0000-0000-000000000000"; + yield* fs.writeFileString( + candidate, + JSON.stringify({ + ...journal, + overlayfs: { + ...journal.overlayfs, + mount_context: { ...journal.overlayfs.mount_context, boot_id: previousBoot }, + mount_identity: { ...journal.overlayfs.mount_identity, boot_id: previousBoot }, + }, + }), + ); + break; + } + assert.isDefined(journalPath); + const readiness = yield* WorkspaceStorage.make.pipe( + Effect.provideService(ProcessRunner.ProcessRunner, runner), + Effect.provideService(HostProcessEnvironment, environment), + ); + const projectId = ProjectId.make("overlay-recovery"); + const startupLayer = Layer.mergeAll( + Layer.succeed(WorkspaceStorage.WorkspaceStorage, readiness), + Layer.mock(ProjectStore.ProjectStoreV2)({ + listShells: () => + Effect.succeed([ + { + id: projectId, + title: "Recovery fixture", + workspaceRoot: cwd, + defaultModelSelection: null, + scripts: [], + deletedAt: null, + createdAt: "2026-10-08T00:00:00.000Z", + updatedAt: "2026-10-08T00:00:00.000Z", + }, + ]), + }), + Layer.mock(ProjectionStore.ProjectionStoreV2)({ + getWorktreeRoots: () => + Effect.succeed([ + { projectId, worktreePath: firstPath }, + { projectId, worktreePath: secondPath }, + ]), + }), + ); + yield* ServerRuntimeStartup.restorePersistedWorktrees.pipe(Effect.provide(startupLayer)); + yield* readiness.ensureReady(firstPath); + assert.equal( + yield* fs.readFileString(path.join(firstPath, "tracked.txt")), + "private edit\n", + ); + assert.equal(yield* fs.readFileString(path.join(secondPath, "tracked.txt")), "original\n"); + + const foreign = path.join(root, "foreign"); + yield* fs.makeDirectory(foreign); + yield* fs.writeFileString(path.join(foreign, "sentinel"), "preserve foreign content\n"); + yield* Effect.acquireUseRelease( + checkedCommand("mount", ["--bind", foreign, firstPath]), + () => + Effect.gen(function* () { + yield* ServerRuntimeStartup.restorePersistedWorktrees.pipe( + Effect.provide(startupLayer), + ); + yield* readiness.ensureReady(secondPath); + assert.isTrue( + Result.isFailure(yield* readiness.ensureReady(firstPath).pipe(Effect.result)), + ); + assert.equal( + yield* fs.readFileString(path.join(firstPath, "sentinel")), + "preserve foreign content\n", + ); + }), + () => checkedCommand("umount", ["--", firstPath]).pipe(Effect.orDie), + ); + yield* readiness.ensureReady(firstPath); + assert.equal( + yield* fs.readFileString(path.join(firstPath, "tracked.txt")), + "private edit\n", + ); + } assert.isTrue( Result.isFailure( yield* storage.remove({ cwd, destination: firstPath }).pipe(Effect.result), diff --git a/apps/server/src/workspace/WorkspaceFileSystem.ts b/apps/server/src/workspace/WorkspaceFileSystem.ts index 9ef3d4546d1d..aec6ad8d71da 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.ts @@ -27,6 +27,7 @@ import * as Schema from "effect/Schema"; import * as WorkspaceEntries from "./WorkspaceEntries.ts"; import * as WorkspacePaths from "./WorkspacePaths.ts"; +import * as WorkspaceStorage from "./WorkspaceStorage.ts"; const PROJECT_READ_FILE_MAX_BYTES = 1024 * 1024; @@ -96,6 +97,7 @@ export class WorkspaceBinaryFileError extends Schema.TaggedError @@ -343,4 +348,6 @@ export const make = Effect.gen(function* () { return WorkspaceFileSystem.of({ readFile, writeFile }); }); -export const layer = Layer.effect(WorkspaceFileSystem, make); +export const layer = Layer.effect(WorkspaceFileSystem, make).pipe( + Layer.provide(WorkspaceStorage.layer), +); diff --git a/apps/server/src/workspace/WorkspaceStorage.test.ts b/apps/server/src/workspace/WorkspaceStorage.test.ts new file mode 100644 index 000000000000..ca4b57e841a8 --- /dev/null +++ b/apps/server/src/workspace/WorkspaceStorage.test.ts @@ -0,0 +1,122 @@ +import { assert, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Result from "effect/Result"; +import * as Layer from "effect/Layer"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import * as ProcessRunner from "../processRunner.ts"; +import * as WorkspaceStorage from "./WorkspaceStorage.ts"; +import * as WorkspacePaths from "./WorkspacePaths.ts"; +import * as WorkspaceEntries from "./WorkspaceEntries.ts"; +import * as WorkspaceFileSystem from "./WorkspaceFileSystem.ts"; +import * as TerminalManager from "../terminal/Manager.ts"; +import * as ServerConfig from "../config.ts"; +import { makeGitVcsDriverCore } from "../vcs/GitVcsDriverCore.ts"; + +it.effect("isolates an unverifiable workspace and allows retry after its mount is repaired", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-storage-readiness-" }); + const broken = path.join(root, "broken"); + const healthy = path.join(root, "healthy"); + const neighbor = path.join(root, "broken-neighbor"); + const alias = path.join(root, "alias"); + for (const directory of [broken, healthy, neighbor]) yield* fs.makeDirectory(directory); + yield* fs.symlink(broken, alias); + let ready = false; + const calls: ReadonlyArray[] = []; + const service = yield* WorkspaceStorage.make.pipe( + Effect.provideService(HostProcessEnvironment, { RIFTRI_BINARY: "/test/riftri" }), + Effect.provideService(ProcessRunner.ProcessRunner, { + run: (input) => { + calls.push(input.args); + assert.equal(input.args[1], "inspect"); + return Effect.succeed({ + code: ChildProcessSpawner.ExitCode(0), + stderr: "", + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, + stdout: JSON.stringify({ + schema_version: 1, + native_path_encoding: "unix-bytes-hex", + worktrees: input.args.slice(input.args.indexOf("--") + 1).map((destination) => ({ + path: destination, + path_native_hex: Buffer.from(destination).toString("hex"), + state_directory: root, + state_directory_native_hex: Buffer.from(root).toString("hex"), + backend: "overlay-fs", + mount_status: destination === broken && !ready ? "foreign" : "active", + })), + }), + }); + }, + }), + ); + yield* service.restore({ cwd: root, destinations: [broken, healthy, broken] }); + assert.equal(calls.length, 3, "one failed batch, then one inspection per distinct workspace"); + yield* service.ensureReady(healthy); + yield* service.ensureReady(neighbor); + assert.equal(calls.length, 3, "unrelated workspaces do not trigger native checks"); + for (const destination of [broken, path.join(alias, "new", "file.txt")]) { + const result = yield* service.ensureReady(destination).pipe(Effect.result); + assert.isTrue(Result.isFailure(result)); + if (Result.isFailure(result)) assert.equal(result.failure.workspaceRoot, broken); + } + const guards = Layer.succeed(WorkspaceStorage.WorkspaceStorage, service); + const files = yield* WorkspaceFileSystem.make.pipe( + Effect.provide( + Layer.mergeAll( + guards, + WorkspacePaths.layer, + Layer.mock(WorkspaceEntries.WorkspaceEntries)({ refresh: () => Effect.void }), + ), + ), + ); + const write = yield* files + .writeFile({ cwd: alias, relativePath: "new/file.txt", contents: "must not be written" }) + .pipe(Effect.result); + assert.isTrue(Result.isFailure(write)); + assert.isFalse(yield* fs.exists(path.join(broken, "new"))); + yield* files.writeFile({ cwd: healthy, relativePath: "ok.txt", contents: "healthy workspace" }); + assert.equal(yield* fs.readFileString(path.join(healthy, "ok.txt")), "healthy workspace"); + + const git = yield* makeGitVcsDriverCore().pipe( + Effect.provide(Layer.merge(guards, ServerConfig.layerTest(root, path.join(root, "t3")))), + ); + const command = yield* git + .execute({ cwd: broken, args: ["init"], operation: "test.blockedInit" }) + .pipe(Effect.result); + assert.isTrue(Result.isFailure(command)); + assert.isFalse(yield* fs.exists(path.join(broken, ".git"))); + + let spawned = false; + const terminal = yield* TerminalManager.makeWithOptions({ + logsDir: path.join(root, "logs"), + shellResolver: () => "/bin/sh", + ptyAdapter: { + spawn: () => + Effect.sync(() => { + spawned = true; + throw new Error("must not spawn"); + }), + }, + }).pipe(Effect.provide(Layer.merge(guards, ProcessRunner.layer))); + const opened = yield* terminal + .open({ threadId: "blocked", terminalId: "default", cwd: broken }) + .pipe(Effect.result); + assert.isTrue(Result.isFailure(opened)); + assert.isFalse(spawned); + ready = true; + yield* service.ensureReady(broken); + const verifiedCalls = calls.length; + yield* service.ensureReady(path.join(alias, "new", "file.txt")); + assert.equal(calls.length, verifiedCalls, "verified workspaces stay off the native hot path"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/src/workspace/WorkspaceStorage.ts b/apps/server/src/workspace/WorkspaceStorage.ts new file mode 100644 index 000000000000..2423eb534f9a --- /dev/null +++ b/apps/server/src/workspace/WorkspaceStorage.ts @@ -0,0 +1,131 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import * as KeyedLock from "@t3tools/shared/KeyedLock"; +import * as ProcessRunner from "../processRunner.ts"; +import * as RiftriWorktrees from "../vcs/RiftriWorktrees.ts"; + +export class WorkspaceStorageUnavailableError extends Schema.TaggedError()( + "WorkspaceStorageUnavailableError", + { workspaceRoot: Schema.String, cause: Schema.Defect() }, +) { + override get message(): string { + return `Workspace storage is not ready: ${this.workspaceRoot}. Inspect and repair its Riftri state, then retry. Existing files have been preserved.`; + } +} + +export class WorkspaceStorage extends Context.Service< + WorkspaceStorage, + { + readonly restore: (input: { + readonly cwd: string; + readonly destinations: ReadonlyArray; + }) => Effect.Effect; + readonly ensureReady: (cwd: string) => Effect.Effect; + } +>()("t3/workspace/WorkspaceStorage") {} + +export const make = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const storage = yield* RiftriWorktrees.make(); + const locks = yield* KeyedLock.make(); + const blocked = new Map< + string, + { readonly cwd: string; readonly aliases: ReadonlyArray } + >(); + + const contains = (root: string, candidate: string) => { + const relative = path.relative(root, candidate); + return ( + relative === "" || + (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative)) + ); + }; + + const canonical = Effect.fnUntraced(function* (candidate: string): Effect.fn.Return { + const resolved = path.resolve(candidate); + const result = yield* fileSystem.realPath(resolved).pipe(Effect.result); + if (Result.isSuccess(result)) return result.success; + const parent = path.dirname(resolved); + if (parent === resolved) return resolved; + // New files have no realpath yet. Resolve their closest existing parent so + // a symlink to an unavailable worktree cannot bypass the write guard. + return path.join(yield* canonical(parent), path.basename(resolved)); + }); + + const restore: WorkspaceStorage["Service"]["restore"] = (input) => + locks.withLock( + input.cwd, + Effect.gen(function* () { + const destinations = [ + ...new Set(input.destinations.map((destination) => path.resolve(destination))), + ]; + for (const destination of destinations) { + blocked.set(destination, { + cwd: input.cwd, + aliases: [destination, yield* canonical(destination)], + }); + } + const batch = yield* storage.restore({ ...input, destinations }).pipe(Effect.result); + for (const destination of destinations) { + // The successful path stays batched. Only a failed batch needs to + // distinguish a broken workspace from its healthy neighbors. + const result = + Result.isSuccess(batch) || destinations.length === 1 + ? batch + : yield* storage + .restore({ cwd: input.cwd, destinations: [destination] }) + .pipe(Effect.result); + if (Result.isSuccess(result)) { + blocked.delete(destination); + } else { + yield* Effect.logWarning( + "Workspace storage could not be restored; only this workspace is blocked", + { + workspaceRoot: destination, + }, + ); + } + } + }), + ); + + const ensureReady = Effect.fnUntraced(function* (cwd: string) { + // No filesystem calls or native processes on the normal, healthy path. + if (blocked.size === 0) return; + const candidate = path.resolve(cwd); + const realCandidate = yield* canonical(candidate); + for (const [destination, entry] of blocked) { + if ( + !entry.aliases.some((alias) => contains(alias, candidate) || contains(alias, realCandidate)) + ) + continue; + yield* locks.withLock( + entry.cwd, + Effect.gen(function* () { + if (!blocked.has(destination)) return; + const result = yield* storage + .restore({ cwd: entry.cwd, destinations: [destination] }) + .pipe(Effect.result); + if (Result.isSuccess(result)) { + blocked.delete(destination); + return; + } + return yield* new WorkspaceStorageUnavailableError({ + workspaceRoot: destination, + cause: result.failure, + }); + }), + ); + } + }); + + return WorkspaceStorage.of({ restore, ensureReady }); +}); + +export const layer = Layer.effect(WorkspaceStorage, make).pipe(Layer.provide(ProcessRunner.layer)); diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md index a4d14440ef2e..13a84f1bf4ae 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -146,8 +146,11 @@ must be installed; a missing or broken Riftri executable is an error, not an imp On Linux, startup checks saved worktrees before resuming providers or background work. Missing OverlayFS mounts are repaired and checked again. If storage cannot be verified, -startup stops for inspection instead of letting an agent write into an unmounted directory. -This protection still applies when new worktrees are configured to use ordinary Git. +that workspace's providers, terminals, Git operations, and file writes stay blocked; +other workspaces remain usable. Inspect its state with `riftri status --state-dir ` +and use `riftri repair --state-dir ` when appropriate, then retry the operation. +T3 verifies readiness before unblocking it. This protection still applies when new worktrees +are configured to use ordinary Git. Riftri retains reusable immutable bases in a `.t3-riftri` directory beside its worktrees. Removing a worktree does not delete its retained base. Inspect that directory with From b8c3ad06b066cde1558a5277a7ab5130cb7c57e5 Mon Sep 17 00:00:00 2001 From: Kinfe123 Date: Thu, 8 Oct 2026 01:22:05 -0700 Subject: [PATCH 6/7] test(storage): verify release allocation and packaged desktop runtime --- .github/workflows/ci.yml | 49 ++++- apps/server/src/git/GitManager.test.ts | 2 + ...itVcsDriverCore.riftri.performance.test.ts | 205 ++++++++++++++++++ apps/server/src/ws.ts | 5 + packages/contracts/src/project.test.ts | 16 ++ packages/contracts/src/project.ts | 5 +- scripts/lib/riftri-packaging.test.ts | 67 ++++++ 7 files changed, 347 insertions(+), 2 deletions(-) create mode 100644 apps/server/src/vcs/GitVcsDriverCore.riftri.performance.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bf8a121e55e0..65c24047ef6a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,7 +85,7 @@ jobs: build: name: Build runs-on: ${{ github.event.repository.fork && 'ubuntu-24.04' || 'blacksmith-8vcpu-ubuntu-2404' }} - timeout-minutes: 10 + timeout-minutes: 30 steps: - name: Checkout uses: actions/checkout@v6 @@ -130,6 +130,24 @@ jobs: - name: Verify preload bundle output run: node apps/desktop/scripts/verify-preload-bundle.mjs + - name: Build unsigned desktop archive without publishing + env: + T3CODE_DESKTOP_SIGNED: "false" + run: | + sudo apt-get install -y imagemagick + node scripts/build-desktop-artifact.ts --platform linux --target tar.gz --arch x64 --skip-build --output-dir "$RUNNER_TEMP/t3-desktop-archive" + + - name: Verify Riftri in the complete desktop archive + run: | + mkdir "$RUNNER_TEMP/t3-desktop-extracted" + tar -xzf "$RUNNER_TEMP"/t3-desktop-archive/*.tar.gz -C "$RUNNER_TEMP/t3-desktop-extracted" + T3CODE_TEST_DESKTOP_ASAR=$(find "$RUNNER_TEMP/t3-desktop-extracted" -name app.asar -type f) + test -f "$T3CODE_TEST_DESKTOP_ASAR" + export T3CODE_TEST_DESKTOP_ASAR + export T3CODE_TEST_ELECTRON_BINARY=$(node -e "console.log(require(require.resolve('electron', { paths: ['./apps/desktop'] })))") + unset RIFTRI_BINARY + vp test run scripts/lib/riftri-packaging.test.ts -t 'complete desktop archive' + # Everything except `t3` (apps/server) and the web app, which has its own job # below. `--parallel` drops the package dependency ordering that `vp run` # applies by default: these `test` tasks declare no `dependsOn` and resolve @@ -531,6 +549,14 @@ jobs: sudo mount -o loop "$RUNNER_TEMP/t3-riftri-overlay.img" "$RUNNER_TEMP/t3-riftri-overlay" sudo chown "$(id -u):$(id -g)" "$RUNNER_TEMP/t3-riftri-overlay" + - name: Prepare disposable macOS APFS volume + if: runner.os == 'macOS' + run: | + hdiutil create -size 1g -fs APFS -volname T3RiftriProof -type SPARSEBUNDLE "$RUNNER_TEMP/t3-riftri-apfs.sparsebundle" + mkdir "$RUNNER_TEMP/t3-riftri-apfs" + hdiutil attach -nobrowse -mountpoint "$RUNNER_TEMP/t3-riftri-apfs" "$RUNNER_TEMP/t3-riftri-apfs.sparsebundle" + touch "$RUNNER_TEMP/t3-riftri-apfs/.metadata_never_index" + - name: Test native Git lifecycle and packaged runtime shell: bash run: | @@ -538,20 +564,41 @@ jobs: command=(vp test run --fileParallelism=false apps/server/src/vcs/RiftriWorktrees.test.ts apps/server/src/vcs/GitVcsDriverCore.riftri.test.ts + apps/server/src/vcs/GitVcsDriverCore.riftri.performance.test.ts apps/server/src/orchestration-v2/ProjectionStore.worktreeRoots.test.ts apps/server/src/serverRuntimeStartup.test.ts apps/server/src/serverRuntimeStartup.autoPull.test.ts apps/server/src/workspace/WorkspaceStorage.test.ts scripts/lib/riftri-packaging.test.ts) + export T3CODE_TEST_RIFTRI_REPORT="$RUNNER_TEMP/t3-riftri-performance.json" if test "$RUNNER_OS" = Linux; then export T3CODE_TEST_RIFTRI_BACKEND=overlay-fs export TMPDIR="$RUNNER_TEMP/t3-riftri-overlay" + export T3CODE_TEST_RIFTRI_VOLUME="$TMPDIR" unshare --user --map-root-user --mount --propagation private -- "${command[@]}" else export T3CODE_TEST_RIFTRI_BACKEND=apfs-clone + export TMPDIR="$RUNNER_TEMP/t3-riftri-apfs" + export T3CODE_TEST_RIFTRI_VOLUME="$TMPDIR" "${command[@]}" fi + - name: Upload physical storage and latency measurements + if: always() + uses: actions/upload-artifact@v7 + with: + name: riftri-performance-${{ matrix.os }} + path: ${{ runner.temp }}/t3-riftri-performance.json + if-no-files-found: error + retention-days: 30 + + - name: Unmount disposable macOS volume + if: always() && runner.os == 'macOS' + run: | + if mount | grep -Fq " on $RUNNER_TEMP/t3-riftri-apfs "; then + hdiutil detach "$RUNNER_TEMP/t3-riftri-apfs" + fi + - name: Unmount disposable Linux volume if: always() && runner.os == 'Linux' run: | diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index adef6ab42afe..8aa7ce2de8af 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -69,6 +69,7 @@ import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.t import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as GitManager from "./GitManager.ts"; +import * as WorkspaceStorage from "../workspace/WorkspaceStorage.ts"; const encodeCliJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); const decodeForgejoPullRequest = Schema.decodeEffect(ForgejoPullRequestSchema); @@ -714,6 +715,7 @@ function makeManager(input?: { ), ), ).pipe( + Layer.provide(WorkspaceStorage.layer), Layer.provideMerge(VcsProcess.layer), Layer.provideMerge(NodeServices.layer), Layer.provideMerge(layerServerConfig), diff --git a/apps/server/src/vcs/GitVcsDriverCore.riftri.performance.test.ts b/apps/server/src/vcs/GitVcsDriverCore.riftri.performance.test.ts new file mode 100644 index 000000000000..98b878eca988 --- /dev/null +++ b/apps/server/src/vcs/GitVcsDriverCore.riftri.performance.test.ts @@ -0,0 +1,205 @@ +// @effect-diagnostics nodeBuiltinImport:off -- statfs measures shared physical allocation on an isolated test volume. +import * as NodeFS from "node:fs"; +import * as NodeCrypto from "node:crypto"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import * as ProcessRunner from "../processRunner.ts"; +import * as ServerConfig from "../config.ts"; +import * as WorkspaceStorage from "../workspace/WorkspaceStorage.ts"; +import { makeGitVcsDriverCore } from "./GitVcsDriverCore.ts"; + +const volume = process.env.T3CODE_TEST_RIFTRI_VOLUME; +const nativeBinary = process.env.T3CODE_TEST_RIFTRI_BINARY; +const decodeStatus = Schema.decodeUnknownEffect( + Schema.fromJsonString( + Schema.Struct({ + bases: Schema.Array(Schema.Struct({ path: Schema.String, reference_count: Schema.Number })), + operations: Schema.Struct({ active_views: Schema.Number }), + diagnostic_issues: Schema.Array(Schema.Unknown), + }), + ), +); + +// Only run on the dedicated CI disk image, with no other writers. Summing du +// for cloned files double-counts shared extents and cannot prove a saving. +it.effect.skipIf(!volume || !nativeBinary)( + "measures released worktree storage and latency through the real T3 driver", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + assert.isNotOk(process.env.RIFTRI_BINARY); + assert.notEqual(NodeFS.statSync(volume!).dev, NodeFS.statSync(process.cwd()).dev); + const root = yield* fs.makeTempDirectoryScoped({ + directory: volume!, + prefix: "t3-riftri-benchmark-", + }); + const cwd = path.join(root, "repository"); + yield* fs.makeDirectory(cwd); + const environment = { + ...process.env, + RIFTRI_BINARY: undefined, + RIFTRI_BYPASS: "1", + GIT_CONFIG_COUNT: undefined, + GIT_CONFIG_PARAMETERS: undefined, + GIT_CONFIG_GLOBAL: "/dev/null", + GIT_CONFIG_NOSYSTEM: "1", + }; + const runner = yield* ProcessRunner.make(); + const run = (command: string, args: ReadonlyArray, directory = cwd) => + runner.run({ command, args, cwd: directory, env: environment }).pipe( + Effect.tap((result) => Effect.sync(() => assert.equal(result.code, 0, result.stderr))), + Effect.map((result) => result.stdout), + ); + const git = (args: ReadonlyArray, directory = cwd) => run("git", args, directory); + const used = () => { + const stat = NodeFS.statfsSync(volume!, { bigint: true }); + return Number((stat.blocks - stat.bfree) * stat.bsize); + }; + yield* git(["init", "-b", "main"]); + yield* git(["config", "user.name", "T3 benchmark"]); + yield* git(["config", "user.email", "t3@example.invalid"]); + const original = "private writes must not change the shared base\n"; + yield* fs.writeFileString(path.join(cwd, "README.md"), original); + // An incompressible payload plus many small files exercises both data + // sharing and per-file metadata. This is a fixture, not a user-repo claim. + yield* fs.writeFile(path.join(cwd, "payload.bin"), NodeCrypto.randomBytes(32 * 1024 * 1024)); + for (let index = 0; index < 256; index++) { + yield* fs.writeFileString( + path.join(cwd, `file-${index}.txt`), + `${index}\n${"source\n".repeat(680)}`, + ); + } + yield* git(["add", "."]); + yield* git(["-c", "commit.gpgsign=false", "commit", "-m", "fixture"]); + const head = (yield* git(["rev-parse", "HEAD"])).trim(); + const version = (yield* run(nativeBinary!, ["--version"])).trim(); + const measurements: { + mode: string; + physicalBytes: number; + incrementalBytes: number[]; + creationMilliseconds: number[]; + }[] = []; + for (const mode of ["git", "riftri"] as const) { + const directory = path.join(root, mode); + yield* fs.makeDirectory(directory); + const stateDir = path.join(directory, ".t3-riftri"); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provide( + Layer.merge(WorkspaceStorage.layer, ServerConfig.layerTest(cwd, path.join(root, "t3"))), + ), + Effect.provideService(HostProcessEnvironment, { + ...environment, + T3CODE_WORKTREE_STORAGE: mode, + }), + ); + const destinations = Array.from({ length: 3 }, (_, index) => + path.join(directory, `view-${index}`), + ); + yield* Effect.scoped( + Effect.gen(function* () { + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + if (mode === "riftri" && (yield* fs.exists(stateDir))) { + yield* run(nativeBinary!, ["repair", `--state-dir=${stateDir}`]); + } + for (const destination of destinations) { + if (yield* fs.exists(destination)) { + yield* driver.removeWorktree({ cwd, path: destination, force: true }); + } + } + if (mode === "riftri" && (yield* fs.exists(stateDir))) { + yield* run(nativeBinary!, ["gc", "--apply", "--yes", `--state-dir=${stateDir}`]); + const empty = yield* decodeStatus( + yield* run(nativeBinary!, ["status", "--json", `--state-dir=${stateDir}`]), + ); + assert.equal(empty.operations.active_views, 0); + assert.equal(empty.bases.length, 0); + } + }).pipe(Effect.orDie), + ); + yield* run("sync", []); + const baseline = used(); + let previous = baseline; + const incrementalBytes: number[] = []; + const creationMilliseconds: number[] = []; + for (const [index, destination] of destinations.entries()) { + // Use wall time, not Effect's virtual test clock. Report latency + // without a flaky threshold or an assumed speedup. + const start = performance.now(); + yield* driver.createWorktree({ + cwd, + path: destination, + refName: "main", + newRefName: `benchmark-${mode}-${index}`, + }); + creationMilliseconds.push(performance.now() - start); + assert.equal(yield* git(["status", "--porcelain"], destination), ""); + assert.equal((yield* git(["rev-parse", "HEAD"], destination)).trim(), head); + yield* run("sync", []); + const current = used(); + incrementalBytes.push(current - previous); + previous = current; + } + measurements.push({ + mode, + physicalBytes: previous - baseline, + incrementalBytes, + creationMilliseconds, + }); + if (mode === "riftri") { + const status = yield* decodeStatus( + yield* run(nativeBinary!, ["status", "--json", `--state-dir=${stateDir}`]), + ); + assert.equal(status.bases.length, 1); + assert.equal(status.bases[0]!.reference_count, 3); + assert.equal(status.operations.active_views, 3); + assert.equal(status.diagnostic_issues.length, 0); + yield* fs.writeFileString(path.join(destinations[0]!, "README.md"), "private edit\n"); + assert.equal( + yield* fs.readFileString(path.join(destinations[1]!, "README.md")), + original, + ); + assert.equal( + yield* fs.readFileString(path.join(status.bases[0]!.path, "README.md")), + original, + ); + yield* fs.writeFileString(path.join(destinations[0]!, "README.md"), original); + } + }), + ); + } + const savingPercent = + 100 * (1 - measurements[1]!.physicalBytes / measurements[0]!.physicalBytes); + const report = JSON.stringify( + { + version, + backend: process.env.T3CODE_TEST_RIFTRI_BACKEND, + views: 3, + fixture: "32 MiB incompressible payload and 257 text files", + driver: "GitVcsDriverCore", + includesColdBase: true, + measurements, + savingPercent, + timingNote: + "Single-run cold-base then two cached-base adds; no assumed latency improvement.", + }, + null, + 2, + ); + yield* Effect.log(report); + if (process.env.T3CODE_TEST_RIFTRI_REPORT) { + yield* fs.writeFileString(process.env.T3CODE_TEST_RIFTRI_REPORT, report); + } + assert.isAbove(measurements[0]!.physicalBytes, 0); + assert.isAbove(measurements[1]!.physicalBytes, 0); + assert.isAbove(savingPercent, 40); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + { timeout: 120_000 }, +); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index fab4edb44406..1642a1dc2a0c 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -512,6 +512,11 @@ function projectFileFailureContext( readonly operationPath?: string; } { switch (error._tag) { + case "WorkspaceStorageUnavailableError": + return { + failure: "workspace_storage_unavailable", + resolvedWorkspaceRoot: error.workspaceRoot, + }; case "WorkspacePathOutsideRootError": return { failure: "workspace_path_outside_root" }; case "WorkspaceFileSystemOperationError": diff --git a/packages/contracts/src/project.test.ts b/packages/contracts/src/project.test.ts index 81c260aa2725..27859850c179 100644 --- a/packages/contracts/src/project.test.ts +++ b/packages/contracts/src/project.test.ts @@ -26,6 +26,8 @@ const decodeProjectUpdatePayload = Schema.decodeUnknownSync(ProjectUpdatePayload const decodeProjectMutation = Schema.decodeUnknownSync(ProjectMutation); const decodeSearchEntriesInput = Schema.decodeUnknownSync(ProjectSearchEntriesInput); const decodeSearchContentsInput = Schema.decodeUnknownSync(ProjectSearchContentsInput); +const encodeWriteErrorJson = Schema.encodeSync(Schema.fromJsonString(ProjectWriteFileError)); +const decodeWriteErrorJson = Schema.decodeUnknownSync(Schema.fromJsonString(ProjectWriteFileError)); describe("project search inputs", () => { it("allows an empty entries query for bounded frecency browsing", () => { @@ -52,6 +54,20 @@ describe("project search inputs", () => { }); describe("project RPC errors", () => { + it("keeps storage repair guidance through a write-error wire round trip", () => { + const error = new ProjectWriteFileError({ + cwd: "/workspace", + relativePath: "new.txt", + failure: "workspace_storage_unavailable", + resolvedWorkspaceRoot: "/workspace", + cause: new Error("private native output"), + }); + const decoded = decodeWriteErrorJson(encodeWriteErrorJson(error)); + expect(decoded.failure).toBe("workspace_storage_unavailable"); + expect(decoded.message).toContain("Inspect and repair its Riftri state, then retry"); + expect(decoded.message).not.toContain("private native output"); + }); + it("derives stable messages from structured request context while retaining causes", () => { const cause = new Error("sensitive platform detail"); const searchError = new ProjectSearchEntriesError({ diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index d1d13e82ac37..981f0e204173 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -450,6 +450,7 @@ export const ProjectReadFileResult = Schema.Struct({ export type ProjectReadFileResult = typeof ProjectReadFileResult.Type; export const ProjectFileFailure = Schema.Literals([ + "workspace_storage_unavailable", "workspace_path_outside_root", "resolved_path_outside_root", "path_not_file", @@ -558,7 +559,9 @@ export class ProjectWriteFileError extends Schema.TaggedError probe(electron), 60_000, ); + +it.skipIf(!process.env.T3CODE_TEST_DESKTOP_ASAR || !electron)( + "loads the server and native dependency from the complete desktop archive", + async () => { + const archive = NodePath.resolve(process.env.T3CODE_TEST_DESKTOP_ASAR!); + const scratch = await NodeFSP.realpath( + await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-desktop-riftri-")), + ); + try { + assert.isNotOk(process.env.RIFTRI_BINARY); + assert.isTrue(NodeFS.existsSync(archive)); + assert.isFalse(archive.startsWith(`${repoRoot}${NodePath.sep}`)); + for (let parent = NodePath.dirname(archive); ; parent = NodePath.dirname(parent)) { + assert.isFalse(NodeFS.existsSync(NodePath.join(parent, "node_modules"))); + if (parent === NodePath.dirname(parent)) break; + } + const env = { + ...process.env, + NODE_PATH: "", + NODE_OPTIONS: "", + ELECTRON_RUN_AS_NODE: "1", + RIFTRI_BYPASS: "1", + GIT_CONFIG_GLOBAL: NodePath.join(scratch, "empty.gitconfig"), + GIT_CONFIG_NOSYSTEM: "1", + GIT_CONFIG_COUNT: undefined, + GIT_CONFIG_PARAMETERS: undefined, + }; + await NodeFSP.writeFile(env.GIT_CONFIG_GLOBAL, ""); + NodeChildProcess.execFileSync("git", ["init", "--quiet", scratch], { env }); + const entry = NodePath.join(archive, "apps/server/dist/bin.mjs"); + const version = NodeChildProcess.execFileSync( + electron!, + ["--no-global-search-paths", entry, "--version"], + { cwd: scratch, env, encoding: "utf8", timeout: 30_000 }, + ); + assert.match(version, /\d+\.\d+\.\d+/); + const output = NodeChildProcess.execFileSync( + electron!, + [ + "--no-global-search-paths", + "-e", + ` + const assert = require('node:assert/strict'); + const { createRequire } = require('node:module'); + const path = require('node:path'); + const server = createRequire(${JSON.stringify(entry)}); + assert(server.resolve('riftri').startsWith(${JSON.stringify(`${archive}/`)})); + const { Riftri, resolveBinary } = server('riftri'); + const binary = resolveBinary(); + assert(binary.startsWith(${JSON.stringify(`${archive}.unpacked/`)})); + const client = new Riftri({ repository: process.cwd() }); + client.worktree.inspect([process.cwd()]).then(report => { + assert.equal(report.worktrees.length, 1); + assert.equal(report.worktrees[0].state_directory, null); + console.log('Complete desktop archive loaded the server and executed its own Riftri package.'); + }).catch(error => { console.error(error); process.exitCode = 1; }); + `, + ], + { cwd: scratch, env, encoding: "utf8", timeout: 30_000 }, + ); + assert.include(output, "executed its own Riftri package"); + } finally { + await NodeFSP.rm(scratch, { recursive: true, force: true }); + } + }, + 60_000, +); From 4daf351d59112728e2cdf4410e6593c2aab31715 Mon Sep 17 00:00:00 2001 From: Kinfe123 Date: Thu, 8 Oct 2026 01:29:35 -0700 Subject: [PATCH 7/7] test(release): exercise standalone CLI and complete desktop archives --- .github/workflows/ci.yml | 14 ++++++++++++++ docs/user/project-settings.md | 4 ++++ scripts/lib/riftri-packaging.test.ts | 10 ++++++++-- 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 65c24047ef6a..f045c7fdb6d5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -148,6 +148,20 @@ jobs: unset RIFTRI_BINARY vp test run scripts/lib/riftri-packaging.test.ts -t 'complete desktop archive' + - name: Build CLI single-executable + env: + # Match the release build and SEA_NODE_VERSION in the server config. + VP_NODE_VERSION: "26.8.2" + run: node apps/server/scripts/cli.ts build-exe --verbose + + - name: Package and start the standalone CLI in a temporary home + run: | + t3_artifact_version=$(node -p "require('./apps/server/package.json').version") + mkdir -p "$RUNNER_TEMP/t3-cli-resource-monitor/linux-x64" + cp native/resource-monitor/target/x86_64-unknown-linux-gnu/release/t3-resource-monitor "$RUNNER_TEMP/t3-cli-resource-monitor/linux-x64/" + node scripts/build-cli-archive.ts --platform linux --arch x64 --version "$t3_artifact_version" --resource-monitor-dir "$RUNNER_TEMP/t3-cli-resource-monitor" --output-dir "$RUNNER_TEMP/t3-cli-archive" + node scripts/smoke-cli-archive.ts --archive "$RUNNER_TEMP"/t3-cli-archive/*.tar.gz --expect-version "$t3_artifact_version" + # Everything except `t3` (apps/server) and the web app, which has its own job # below. `--parallel` drops the package dependency ordering that `vp run` # applies by default: these `test` tasks declare no `dependsOn` and resolve diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md index 13a84f1bf4ae..5d9ce1dc1b9c 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -138,6 +138,10 @@ remote-branch guessing when a name does not resolve locally; explicit branches, commit IDs, and `HEAD` can use optimized storage. Failed or interrupted mutations are never retried as an ordinary checkout. +The benefit is lower disk use across worktrees, not guaranteed faster creation. +Preparing and validating the shared base can take longer than an ordinary checkout. +Dependency installs and build outputs are still private to each worktree. + Set `T3CODE_WORKTREE_STORAGE=git` in the server environment to use ordinary Git for new worktrees, or `riftri` to require optimized creation without fallback. The default is `auto`. Changing this setting does not change existing worktrees: Riftri-managed removal and prune diff --git a/scripts/lib/riftri-packaging.test.ts b/scripts/lib/riftri-packaging.test.ts index 38dd0fd4463e..d08341df0416 100644 --- a/scripts/lib/riftri-packaging.test.ts +++ b/scripts/lib/riftri-packaging.test.ts @@ -122,8 +122,14 @@ it.skipIf(!process.env.T3CODE_TEST_DESKTOP_ASAR || !electron)( assert.isNotOk(process.env.RIFTRI_BINARY); assert.isTrue(NodeFS.existsSync(archive)); assert.isFalse(archive.startsWith(`${repoRoot}${NodePath.sep}`)); - for (let parent = NodePath.dirname(archive); ; parent = NodePath.dirname(parent)) { - assert.isFalse(NodeFS.existsSync(NodePath.join(parent, "node_modules"))); + // resources/node_modules is part of the shipped artifact (Cursor's + // native helpers). Only ancestors outside resources must be empty. + for ( + let parent = NodePath.dirname(NodePath.dirname(archive)); + ; + parent = NodePath.dirname(parent) + ) { + assert.isFalse(NodeFS.existsSync(NodePath.join(parent, "node_modules")), parent); if (parent === NodePath.dirname(parent)) break; } const env = {