From 9d28a278e9a787564e3022ca2ff42722dec871ee Mon Sep 17 00:00:00 2001 From: KinfeMichael Tariku <65047246+Kinfe123@users.noreply.github.com> Date: Thu, 8 Oct 2026 01:40:50 -0700 Subject: [PATCH 1/2] feat(server): integrate Riftri worktree storage Use native copy-on-write worktrees with fail-closed lifecycle handling, workspace-scoped Linux recovery, published Riftri packaging, and real-filesystem and artifact verification. --- .github/workflows/ci.yml | 144 +++- apps/server/package.json | 1 + apps/server/src/git/GitManager.test.ts | 8 + .../src/orchestration-v2/ProjectionStore.ts | 36 + .../ProjectionStore.worktreeRoots.test.ts | 71 ++ .../ProviderSessionManager.test.ts | 45 + .../ProviderSessionManager.ts | 20 +- .../ProviderTurnControlService.test.ts | 1 + apps/server/src/server.ts | 6 +- .../src/serverRuntimeStartup.autoPull.test.ts | 8 + apps/server/src/serverRuntimeStartup.test.ts | 121 +++ apps/server/src/serverRuntimeStartup.ts | 36 +- apps/server/src/terminal/Manager.test.ts | 4 +- apps/server/src/terminal/Manager.ts | 10 +- apps/server/src/vcs/GitVcsDriver.ts | 3 +- ...itVcsDriverCore.riftri.performance.test.ts | 205 +++++ .../src/vcs/GitVcsDriverCore.riftri.test.ts | 361 ++++++++ apps/server/src/vcs/GitVcsDriverCore.test.ts | 42 +- apps/server/src/vcs/GitVcsDriverCore.ts | 152 +++- apps/server/src/vcs/RiftriWorktrees.test.ts | 785 ++++++++++++++++++ apps/server/src/vcs/RiftriWorktrees.ts | 600 +++++++++++++ .../src/workspace/WorkspaceFileSystem.ts | 9 +- .../src/workspace/WorkspaceStorage.test.ts | 122 +++ apps/server/src/workspace/WorkspaceStorage.ts | 131 +++ apps/server/src/ws.ts | 5 + docs/user/project-settings.md | 35 + knip.jsonc | 3 +- packages/contracts/src/project.test.ts | 16 + packages/contracts/src/project.ts | 5 +- pnpm-lock.yaml | 87 ++ pnpm-workspace.yaml | 10 + scripts/build-desktop-artifact.test.ts | 6 +- scripts/build-desktop-artifact.ts | 3 +- scripts/lib/cli-external-packages.test.ts | 29 +- scripts/lib/cli-external-packages.ts | 5 + scripts/lib/riftri-packaging.test.ts | 185 +++++ 36 files changed, 3248 insertions(+), 62 deletions(-) create mode 100644 apps/server/src/orchestration-v2/ProjectionStore.worktreeRoots.test.ts create mode 100644 apps/server/src/vcs/GitVcsDriverCore.riftri.performance.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 apps/server/src/workspace/WorkspaceStorage.test.ts create mode 100644 apps/server/src/workspace/WorkspaceStorage.ts create mode 100644 scripts/lib/riftri-packaging.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dbdc1ec40e64..c0fe56bc1b14 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,7 +84,7 @@ jobs: build: name: Build runs-on: blacksmith-8vcpu-ubuntu-2404 - timeout-minutes: 10 + timeout-minutes: 30 steps: - name: Checkout uses: actions/checkout@v6 @@ -129,6 +129,38 @@ 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' + + - 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 @@ -478,6 +510,115 @@ 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: 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: | + 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/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: | + 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,6 +640,7 @@ jobs: mobile_native_changes, mobile_native_static_analysis, release_smoke, + riftri_storage, ] runs-on: blacksmith-2vcpu-ubuntu-2404 timeout-minutes: 5 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/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 1ce25b4106be..ec16cc5ec21e 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -68,6 +68,8 @@ 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"; +import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; const encodeCliJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); const decodeForgejoPullRequest = Schema.decodeEffect(ForgejoPullRequestSchema); @@ -732,6 +734,7 @@ function makeManager(input?: { ), ), ).pipe( + Layer.provide(WorkspaceStorage.layer), Layer.provideMerge(VcsProcess.layer), Layer.provideMerge(NodeServices.layer), Layer.provideMerge(layerServerConfig), @@ -794,6 +797,11 @@ const layerGitManagerTest = GitVcsDriver.layer.pipe( Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-git-manager-test-" })), Layer.provideMerge(VcsProcess.layer), Layer.provideMerge(NodeServices.layer), + // These fixtures remove their temporary directories directly. Native bases + // need journaled cleanup, covered by GitVcsDriverCore.riftri.test.ts instead. + Layer.provideMerge( + Layer.succeed(HostProcessEnvironment, { ...process.env, T3CODE_WORKTREE_STORAGE: "git" }), + ), ); it.layer(layerGitManagerTest)("GitManager", (it) => { diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 11f98f2330b5..14ac6f012b5d 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, @@ -78,6 +79,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", { @@ -342,6 +345,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; @@ -5348,6 +5356,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, ) => @@ -5687,6 +5708,7 @@ export const layer: Layer.Layer = return { apply, getShellSnapshot, + getWorktreeRoots, getThreadShell, getThread, getSettlementCandidates, @@ -5729,6 +5751,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/ProviderSessionManager.test.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts index 2f2b0402f620..2ea6bc8ed0ff 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( @@ -524,6 +525,7 @@ function layerTest(input: { ), ); return Layer.mergeAll( + WorkspaceStorage.layer, layerTestStores, layerConfiguredEventSink, IdAllocator.layer, @@ -4476,6 +4478,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 d181497ec275..9656dcba0233 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"; @@ -146,6 +147,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 @@ -2023,6 +2014,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")), @@ -2450,6 +2442,6 @@ export const layerWithOptions = ( ), } satisfies ProviderSessionManagerV2Shape); }), - ); + ).pipe(Layer.provide(WorkspaceStorage.layer)); export const layer = layerWithOptions(); 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/server.ts b/apps/server/src/server.ts index 192b17204aea..a79c63f03876 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 8313d33dc220..965efadc896b 100644 --- a/apps/server/src/serverRuntimeStartup.autoPull.test.ts +++ b/apps/server/src/serverRuntimeStartup.autoPull.test.ts @@ -1,10 +1,12 @@ 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"; 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"; @@ -19,6 +21,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 +140,11 @@ 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)({}), + 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 9aa6ebfaa45f..3e9b7cc8b453 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -10,8 +10,16 @@ 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 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 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"; @@ -23,6 +31,95 @@ 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; + 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, + }); + }, + }), + ), + 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)), + [ + ["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 +127,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 +138,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 +151,28 @@ it.effect("starts without scanning or rebuilding projection history", () => }), ); +it.effect("failure to load startup metadata 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..89604ce631f0 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -39,7 +39,9 @@ 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 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"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as ServerSettings from "./serverSettings.ts"; @@ -381,22 +383,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* WorkspaceStorage.WorkspaceStorage; + 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 +432,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 +543,12 @@ 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 : Effect.void, + ), recover: runStartupPhase("orchestration-v2.recovery", providerRuntimeRecovery.recover), recoverDelegatedTasks: runStartupPhase( "orchestration-v2.delegated-tasks.recover", @@ -689,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.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/vcs/GitVcsDriverCore.riftri.test.ts b/apps/server/src/vcs/GitVcsDriverCore.riftri.test.ts new file mode 100644 index 000000000000..e785b9516e86 --- /dev/null +++ b/apps/server/src/vcs/GitVcsDriverCore.riftri.test.ts @@ -0,0 +1,361 @@ +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 WorkspaceStorage from "../workspace/WorkspaceStorage.ts"; +import * as Layer from "effect/Layer"; +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(Layer.merge(WorkspaceStorage.layer, 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(Layer.merge(WorkspaceStorage.layer, 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(Layer.merge(WorkspaceStorage.layer, 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* git(["config", "status.showUntrackedFiles", "no"]); + const hiddenNote = path.join(destination, "notes.txt"); + yield* fs.writeFileString(hiddenNote, "keep this untracked note\n"); + assert.equal((yield* git(["status", "--porcelain"], destination)).stdout, ""); + assert.isTrue( + Result.isFailure( + yield* restarted.removeWorktree({ cwd, path: destination }).pipe(Effect.result), + ), + ); + assert.equal(yield* fs.readFileString(hiddenNote), "keep this untracked note\n"); + yield* fs.remove(hiddenNote); + 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( + Layer.merge(WorkspaceStorage.layer, 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 a71226c06aee..749ca4de1034 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"; @@ -38,6 +38,7 @@ import { windowsLongPathConfigEnv, } from "./GitVcsDriverCore.ts"; import * as GitVcsDriver from "./GitVcsDriver.ts"; +import * as WorkspaceStorage from "../workspace/WorkspaceStorage.ts"; const encodeGitCommandError = Schema.encodeEffect(Schema.fromJsonString(GitCommandError)); @@ -47,6 +48,21 @@ 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", + // 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", + }), + ), ); const makeNonRepositoryHandle = () => @@ -172,6 +188,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), ), ), @@ -231,6 +248,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 @@ -422,6 +440,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(); @@ -534,6 +553,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(); @@ -573,6 +593,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(); @@ -605,6 +626,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(); @@ -643,6 +665,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(); @@ -679,6 +702,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(); @@ -740,6 +764,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[] = []; @@ -915,6 +940,7 @@ it.effect.each([ }), ); const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provide(WorkspaceStorage.layer), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ); const cwd = yield* makeTmpDir(); @@ -1424,8 +1450,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, @@ -1463,8 +1489,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)); @@ -1667,8 +1693,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( @@ -2334,8 +2360,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]) { @@ -3263,8 +3289,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); @@ -3572,8 +3598,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" }) @@ -3752,8 +3778,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 002311fda2cf..ddb75f2070c3 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -43,6 +43,9 @@ import { parseRemoteRefWithRemoteNames, } from "../git/remoteRefs.ts"; 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); @@ -945,9 +948,22 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const { worktreesDir } = yield* ServerConfig.ServerConfig; const crypto = yield* Crypto.Crypto; const hostPlatform = yield* HostProcessPlatform; + 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], @@ -3417,32 +3433,73 @@ 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; - 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); @@ -3784,6 +3841,40 @@ 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, + 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; // Git refuses to remove a worktree with untracked files unless forced, but // its check honors `status.showUntrackedFiles=no` and would delete them. const args = ["-c", "status.showUntrackedFiles=normal", "worktree", "remove"]; @@ -3800,6 +3891,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, }, ); @@ -3835,10 +3927,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..c5761b921743 --- /dev/null +++ b/apps/server/src/vcs/RiftriWorktrees.test.ts @@ -0,0 +1,785 @@ +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 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), + 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); + 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, ""); + 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"); + 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), + ), + ); + 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/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/apps/server/src/ws.ts b/apps/server/src/ws.ts index 53bf27800583..32fb2daaa00e 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -513,6 +513,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/docs/user/project-settings.md b/docs/user/project-settings.md index 598ef5cb5e2e..3a82fd56804d 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -130,6 +130,41 @@ 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. +T3 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. No agent prompt or shell setup is +needed for worktrees created through T3. Git commands run independently in a terminal +are unchanged. 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. + +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 +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, +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 +`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/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/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=18.18'} + hasBin: true + rimraf@2.6.3: resolution: {integrity: sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==} deprecated: Rimraf versions prior to v4 are no longer supported @@ -21532,6 +21584,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 70b610959865..9381c21840d8 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 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..02ca32d8950f 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", () => { @@ -83,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", + ], ); }); }); 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..d08341df0416 --- /dev/null +++ b/scripts/lib/riftri-packaging.test.ts @@ -0,0 +1,185 @@ +// @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, +); + +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}`)); + // 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 = { + ...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 06d16d94dd91cee4802efc52358a0420d1ceab57 Mon Sep 17 00:00:00 2001 From: Kinfe123 Date: Thu, 8 Oct 2026 02:34:48 -0700 Subject: [PATCH 2/2] ci: verify Riftri compatibility on fork runners --- .github/workflows/ci.yml | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c0fe56bc1b14..3c9da244a859 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: 30 steps: - name: Checkout @@ -169,7 +170,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 @@ -233,7 +234,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 @@ -262,7 +263,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 @@ -334,7 +335,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 @@ -347,7 +348,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 @@ -381,7 +382,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 @@ -459,7 +460,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 @@ -487,7 +488,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 @@ -578,6 +579,7 @@ jobs: 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/git/GitManager.test.ts apps/server/src/orchestration-v2/ProjectionStore.worktreeRoots.test.ts apps/server/src/serverRuntimeStartup.test.ts apps/server/src/serverRuntimeStartup.autoPull.test.ts @@ -642,7 +644,7 @@ jobs: 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