diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7fee5f57a83c..710d81b494e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ concurrency: jobs: check: name: Check - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ${{ github.repository == 'maxibotstef/t3code' && 'ubuntu-24.04' || 'blacksmith-8vcpu-ubuntu-2404' }} timeout-minutes: 10 steps: - name: Checkout @@ -67,7 +67,7 @@ jobs: # limit stays at the default 4 so peak load per runner is unchanged. test: name: Test - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ${{ github.repository == 'maxibotstef/t3code' && 'ubuntu-24.04' || 'blacksmith-8vcpu-ubuntu-2404' }} timeout-minutes: 10 steps: - name: Checkout @@ -100,7 +100,7 @@ jobs: # isolation that flag buys is preserved exactly. test_server: name: Test Server ${{ matrix.shard }} - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ${{ github.repository == 'maxibotstef/t3code' && 'ubuntu-24.04' || 'blacksmith-8vcpu-ubuntu-2404' }} timeout-minutes: 10 strategy: fail-fast: false @@ -167,7 +167,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.repository == 'maxibotstef/t3code' && 'ubuntu-24.04' || 'blacksmith-4vcpu-ubuntu-2404' }} timeout-minutes: 10 steps: - name: Checkout @@ -195,7 +195,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.repository == 'maxibotstef/t3code' && 'ubuntu-24.04' || 'blacksmith-2vcpu-ubuntu-2404' }} timeout-minutes: 5 permissions: contents: read @@ -273,7 +273,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.repository == 'maxibotstef/t3code' && 'macos-26' || 'blacksmith-6vcpu-macos-26' }} timeout-minutes: 10 steps: - name: Checkout @@ -301,7 +301,7 @@ jobs: release_smoke: name: Release Smoke - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ${{ github.repository == 'maxibotstef/t3code' && 'ubuntu-24.04' || 'blacksmith-8vcpu-ubuntu-2404' }} timeout-minutes: 10 steps: - name: Checkout diff --git a/.github/workflows/mobile-fingerprint-check.yml b/.github/workflows/mobile-fingerprint-check.yml index fd98817cd105..cb8eb156037d 100644 --- a/.github/workflows/mobile-fingerprint-check.yml +++ b/.github/workflows/mobile-fingerprint-check.yml @@ -32,7 +32,7 @@ concurrency: jobs: fingerprint: name: Native fingerprint diff - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ${{ github.repository == 'maxibotstef/t3code' && 'ubuntu-24.04' || 'blacksmith-8vcpu-ubuntu-2404' }} permissions: contents: read issues: write diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index b4e04fd44f60..36b260ac87b6 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -5,6 +5,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { ApprovalRequestId, CodexSettings, + FULL_WORKTREE_MATERIALIZATION_STATE, ProviderDriverKind, type OrchestrationEvent, type OrchestrationThread, @@ -332,6 +333,7 @@ export const makeOrchestrationIntegrationHarness = ( readonly oldBranch: string; readonly newBranch: string; }) => Effect.succeed({ branch: input.newBranch }), + verifyWorktreeMaterialization: () => Effect.succeed(FULL_WORKTREE_MATERIALIZATION_STATE), }); const textGenerationLayer = Layer.succeed(TextGeneration, { generateBranchName: () => Effect.succeed({ branch: "update" }), diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 50adf83bfc53..bf16cc7ab792 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -111,6 +111,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.gitPreparePullRequestThread]: AuthOrchestrationOperateScope, [WS_METHODS.vcsListRefs]: AuthOrchestrationReadScope, [WS_METHODS.vcsCreateWorktree]: AuthOrchestrationOperateScope, + [WS_METHODS.vcsExpandWorktreeMaterialization]: AuthOrchestrationOperateScope, [WS_METHODS.vcsRemoveWorktree]: AuthOrchestrationOperateScope, [WS_METHODS.vcsCreateRef]: AuthOrchestrationOperateScope, [WS_METHODS.vcsSwitchRef]: AuthOrchestrationOperateScope, diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index c9b4a4cca365..ab95c91de651 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -11,6 +11,7 @@ import { type VcsCreateRefResult, type VcsCreateWorktreeInput, type VcsCreateWorktreeResult, + type VcsWorktreeMaterializationState, type VcsListRefsInput, type VcsListRefsResult, type GitManagerServiceError, @@ -65,6 +66,13 @@ export class GitWorkflowService extends Context.Service< readonly createWorktree: ( input: VcsCreateWorktreeInput, ) => Effect.Effect; + readonly verifyWorktreeMaterialization: ( + cwd: string, + ) => Effect.Effect; + readonly expandWorktreeMaterializationFull: ( + cwd: string, + reason: string, + ) => Effect.Effect; readonly fetchRemote: (input: { readonly cwd: string; readonly remoteName: string; @@ -311,6 +319,14 @@ export const make = Effect.gen(function* () { ensureGitCommand("GitWorkflowService.createWorktree", input.cwd).pipe( Effect.andThen(git.createWorktree(input)), ), + verifyWorktreeMaterialization: (cwd) => + ensureGitCommand("GitWorkflowService.verifyWorktreeMaterialization", cwd).pipe( + Effect.andThen(git.verifyWorktreeMaterialization(cwd)), + ), + expandWorktreeMaterializationFull: (cwd, reason) => + ensureGitCommand("GitWorkflowService.expandWorktreeMaterializationFull", cwd).pipe( + Effect.andThen(git.expandWorktreeMaterializationFull(cwd, reason)), + ), fetchRemote: (input) => ensureGitCommand("GitWorkflowService.fetchRemote", input.cwd).pipe( Effect.andThen(git.fetchRemote(input)), diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 1b8a451175f3..762535ad77b4 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -12,6 +12,7 @@ import { } from "@t3tools/contracts"; import * as Option from "effect/Option"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as NodeCrypto from "node:crypto"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -39,6 +40,7 @@ import * as ThreadPlanProgress from "../ThreadPlanProgress.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { OrchestrationProjectionPipeline } from "../Services/ProjectionPipeline.ts"; import { ServerConfig } from "../../config.ts"; +import { makeGitVcsDriverCore } from "../../vcs/GitVcsDriverCore.ts"; const makeProjectionPipelinePrefixedTestLayer = (prefix: string) => OrchestrationProjectionPipelineLive.pipe( @@ -476,6 +478,379 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta }, ); +it.layer(BaseTestLayer)("OrchestrationProjectionPipeline materialization", (it) => { + it.effect("persists server-authored requested and effective identities", () => + Effect.gen(function* () { + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + const now = "2026-01-01T00:00:00.000Z"; + const events: Array[0]> = [ + { + type: "project.created", + eventId: EventId.make("evt-materialization-project"), + aggregateKind: "project", + aggregateId: ProjectId.make("project-materialization"), + occurredAt: now, + commandId: CommandId.make("cmd-materialization-project"), + causationEventId: null, + correlationId: CommandId.make("cmd-materialization-project"), + metadata: {}, + payload: { + projectId: ProjectId.make("project-materialization"), + title: "Project", + workspaceRoot: "/tmp/project-materialization", + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }, + { + type: "thread.created", + eventId: EventId.make("evt-materialization-thread"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-materialization"), + occurredAt: now, + commandId: CommandId.make("cmd-materialization-thread"), + causationEventId: null, + correlationId: CommandId.make("cmd-materialization-thread"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-materialization"), + projectId: ProjectId.make("project-materialization"), + title: "Thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }, + { + type: "thread.materialization-set", + eventId: EventId.make("evt-materialization-set"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-materialization"), + occurredAt: now, + commandId: CommandId.make("cmd-materialization-set"), + causationEventId: null, + correlationId: CommandId.make("cmd-materialization-set"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-materialization"), + materialization: { + requestedProfileId: "governance-review", + effectiveProfileId: "full", + mode: "full", + reason: "required-paths-missing", + expectedContractSha256: "a".repeat(64), + contractSha256: "a".repeat(64), + manifestSha256: "b".repeat(64), + conePaths: ["docs"], + requiredPaths: ["docs/spec.md"], + taskId: "OC-1", + taskSlug: "task", + }, + updatedAt: now, + }, + }, + ]; + yield* Effect.forEach(events, eventStore.append, { concurrency: 1 }); + yield* projectionPipeline.bootstrap; + const rows = yield* sql<{ + readonly requested: string; + readonly effective: string; + readonly reason: string; + }>` + SELECT + materialization_requested_profile_id AS requested, + materialization_effective_profile_id AS effective, + materialization_reason AS reason + FROM projection_threads + WHERE thread_id = 'thread-materialization' + `; + assert.deepEqual(rows, [ + { + requested: "governance-review", + effective: "full", + reason: "required-paths-missing", + }, + ]); + }), + ); + + it.effect.skipIf(process.env.T3_MATERIALIZATION_CANARY_REPO === undefined)( + "persists source-built creator identity through query-only readback and clean expansion", + () => + Effect.scoped( + Effect.gen(function* () { + const sourceRepo = process.env.T3_MATERIALIZATION_CANARY_REPO!; + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const cloneRoot = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-materialization-readback-", + }); + const repo = pathService.join(cloneRoot, "repo"); + const sparsePath = pathService.join(cloneRoot, "explicit-sparse"); + const omittedPath = pathService.join(cloneRoot, "omitted-full"); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provide( + ServerConfig.layerTest(cloneRoot, { prefix: "t3-materialization-readback-server-" }), + ), + ); + const git = (cwd: string, args: ReadonlyArray) => + driver.execute({ + operation: "GitVcsDriver.test.materializationReadback", + cwd, + args, + maxOutputBytes: 4 * 1024 * 1024, + }); + yield* git(cloneRoot, [ + "clone", + "--quiet", + "--shared", + "--no-checkout", + sourceRepo, + repo, + ]); + const pinnedCommit = (yield* git(repo, ["rev-parse", "HEAD^{commit}"])).stdout.trim(); + yield* git(repo, [ + "checkout", + pinnedCommit, + "--", + "config/worktree-materialization-profiles.json", + ]); + yield* git(repo, ["read-tree", pinnedCommit]); + const contractRaw = (yield* git(repo, [ + "show", + `${pinnedCommit}:config/worktree-materialization-profiles.json`, + ])).stdout; + const expectedContractSha256 = NodeCrypto.createHash("sha256") + .update(contractRaw) + .digest("hex"); + const taskCardPath = (yield* git(repo, [ + "ls-tree", + "-r", + "--name-only", + pinnedCommit, + "ops/stef-task", + ])).stdout + .split(/\r?\n/) + .find((candidate) => candidate.endsWith("/stef-task.json")); + if (!taskCardPath) return assert.fail("source-built readback needs a tracked task card"); + + const created = yield* driver.createWorktree({ + cwd: repo, + path: sparsePath, + refName: pinnedCommit, + newRefName: "readback/explicit-sparse", + materialization: { + requestedProfileId: "governance-review", + expectedContractSha256, + taskId: "OC-L3-READBACK", + taskSlug: "source-built-readback", + taskCardPath, + scopePaths: ["builds/task-queue/lib/build-state.js"], + taskClasses: ["source-task"], + }, + }); + if (!created.materialization) + return assert.fail("explicit profile needs persisted identity"); + const verifiedSparse = yield* driver.verifyWorktreeMaterialization(sparsePath); + assert.deepStrictEqual(verifiedSparse, created.materialization); + + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + const now = "2026-09-05T00:00:00.000Z"; + const projectId = ProjectId.make("project-source-built-materialization"); + const threadId = ThreadId.make("thread-source-built-materialization"); + const initialEvents: Array[0]> = [ + { + type: "project.created", + eventId: EventId.make("evt-source-built-project"), + aggregateKind: "project", + aggregateId: projectId, + occurredAt: now, + commandId: CommandId.make("cmd-source-built-project"), + causationEventId: null, + correlationId: CommandId.make("cmd-source-built-project"), + metadata: {}, + payload: { + projectId, + title: "Source-built materialization", + workspaceRoot: repo, + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }, + { + type: "thread.created", + eventId: EventId.make("evt-source-built-thread"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.make("cmd-source-built-thread"), + causationEventId: null, + correlationId: CommandId.make("cmd-source-built-thread"), + metadata: {}, + payload: { + threadId, + projectId, + title: "Source-built thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: created.worktree.refName, + worktreePath: created.worktree.path, + createdAt: now, + updatedAt: now, + }, + }, + { + type: "thread.materialization-set", + eventId: EventId.make("evt-source-built-materialization"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.make("cmd-source-built-materialization"), + causationEventId: null, + correlationId: CommandId.make("cmd-source-built-materialization"), + metadata: {}, + payload: { + threadId, + materialization: created.materialization, + updatedAt: now, + }, + }, + ]; + yield* Effect.forEach(initialEvents, eventStore.append, { concurrency: 1 }); + yield* projectionPipeline.bootstrap; + const readback = () => + sql<{ + readonly requested: string; + readonly effective: string; + readonly mode: string; + readonly expectedHash: string | null; + readonly contractHash: string | null; + readonly manifestHash: string | null; + readonly reason: string | null; + }>` + SELECT + materialization_requested_profile_id AS requested, + materialization_effective_profile_id AS effective, + materialization_mode AS mode, + materialization_expected_contract_sha256 AS "expectedHash", + materialization_contract_sha256 AS "contractHash", + materialization_manifest_sha256 AS "manifestHash", + materialization_reason AS reason + FROM projection_threads + WHERE thread_id = ${threadId} + `; + const sparseRows = yield* readback(); + assert.deepStrictEqual(sparseRows, [ + { + requested: created.materialization.requestedProfileId, + effective: "governance-review", + mode: "sparse", + expectedHash: created.materialization.expectedContractSha256, + contractHash: created.materialization.contractSha256, + manifestHash: created.materialization.manifestSha256, + reason: created.materialization.reason, + }, + ]); + + const expanded = yield* driver.expandWorktreeMaterializationFull( + sparsePath, + "source-built-readback-expand-full", + ); + const expandedEvent = yield* eventStore.append({ + type: "thread.materialization-set", + eventId: EventId.make("evt-source-built-expanded"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: "2026-09-05T00:00:01.000Z", + commandId: CommandId.make("cmd-source-built-expanded"), + causationEventId: null, + correlationId: CommandId.make("cmd-source-built-expanded"), + metadata: {}, + payload: { + threadId, + materialization: expanded, + updatedAt: "2026-09-05T00:00:01.000Z", + }, + }); + yield* projectionPipeline.projectEvent(expandedEvent); + const expandedRows = yield* readback(); + assert.equal(expandedRows[0]?.effective, "full"); + assert.equal(expandedRows[0]?.mode, "full"); + assert.equal(expandedRows[0]?.reason, "source-built-readback-expand-full"); + assert.equal((yield* driver.verifyWorktreeMaterialization(sparsePath)).mode, "full"); + assert.equal((yield* git(sparsePath, ["status", "--porcelain=v1"])).stdout.trim(), ""); + + const omitted = yield* driver.createWorktree({ + cwd: repo, + path: omittedPath, + refName: pinnedCommit, + newRefName: "readback/omitted-full", + }); + assert.equal(omitted.materialization, undefined); + const omittedFull = yield* driver.verifyWorktreeMaterialization(omittedPath); + assert.equal(omittedFull.effectiveProfileId, "full"); + assert.equal(omittedFull.mode, "full"); + + const readbackOutput = process.env.T3_MATERIALIZATION_READBACK_OUTPUT; + if (readbackOutput) { + const requiredPathsPresent = (yield* Effect.forEach( + created.materialization.requiredPaths, + (relativePath) => fileSystem.exists(pathService.join(sparsePath, relativePath)), + )).every(Boolean); + const t3SourceHead = (yield* git(process.cwd(), [ + "rev-parse", + "HEAD^{commit}", + ])).stdout.trim(); + // @effect-diagnostics-next-line preferSchemaOverJson:off + const readbackJson = JSON.stringify( + { + schemaVersion: "t3.source-built-materialization-readback.v1", + t3SourceHead, + clawdSourceHead: pinnedCommit, + explicitProfile: sparseRows[0], + expandedProfile: expandedRows[0], + omittedProfile: { + effective: omittedFull.effectiveProfileId, + mode: omittedFull.mode, + }, + requiredPathsPresent, + cleanAfterExpansion: true, + queryOnlyDatabaseReadback: true, + isolatedTestState: cloneRoot, + installedBundleEdited: false, + liveDatabaseEdited: false, + serviceRestarted: false, + }, + null, + 2, + ); + yield* fileSystem.writeFileString(readbackOutput, `${readbackJson}\n`); + } + }), + ), + 300_000, + ); +}); + it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { it.effect( "passes explicit empty attachment arrays through the projection pipeline to clear attachments", diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 1ba1b6afa4f3..888131dc04a2 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -1,5 +1,6 @@ import { ApprovalRequestId, + FULL_WORKTREE_MATERIALIZATION_STATE, type ChatAttachment, type OrchestrationEvent, type OrchestrationSessionStatus, @@ -636,6 +637,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti interactionMode: event.payload.interactionMode, branch: event.payload.branch, worktreePath: event.payload.worktreePath, + materialization: FULL_WORKTREE_MATERIALIZATION_STATE, linkedPullRequest: null, latestTurnId: null, createdAt: event.payload.createdAt, @@ -842,6 +844,19 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti return; } + case "thread.materialization-set": { + const existingRow = yield* projectionThreadRepository.getById({ + threadId: event.payload.threadId, + }); + if (Option.isNone(existingRow)) return; + yield* projectionThreadRepository.upsert({ + ...existingRow.value, + materialization: event.payload.materialization, + updatedAt: event.payload.updatedAt, + }); + return; + } + case "thread.runtime-mode-set": { const existingRow = yield* projectionThreadRepository.getById({ threadId: event.payload.threadId, diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 682b280bf4d0..fc8363f88629 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -6,6 +6,7 @@ import { ThreadId, TurnId, ProviderInstanceId, + FULL_WORKTREE_MATERIALIZATION_STATE, } from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -309,6 +310,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { runtimeMode: "full-access", branch: null, worktreePath: null, + materialization: FULL_WORKTREE_MATERIALIZATION_STATE, linkedPullRequest: { projectId: asProjectId("project-1"), repository: "pingdotgg/t3code", @@ -437,6 +439,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { runtimeMode: "full-access", branch: null, worktreePath: null, + materialization: FULL_WORKTREE_MATERIALIZATION_STATE, linkedPullRequest: { projectId: asProjectId("project-1"), repository: "pingdotgg/t3code", diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 0d064a1d6e9c..f667f50c63b8 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -28,6 +28,7 @@ import { ProjectId, ThreadLinkedPullRequest, ThreadId, + VcsWorktreeMaterializationState, } from "@t3tools/contracts"; import * as Arr from "effect/Array"; import * as Effect from "effect/Effect"; @@ -101,6 +102,7 @@ const ProjectionThreadDbRowSchema = ProjectionThread.mapFields( Struct.assign({ modelSelection: Schema.fromJsonString(ModelSelection), linkedPullRequest: Schema.NullOr(Schema.fromJsonString(ThreadLinkedPullRequest)), + materialization: Schema.fromJsonString(VcsWorktreeMaterializationState), }), ); const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( @@ -471,6 +473,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interaction_mode AS "interactionMode", branch, worktree_path AS "worktreePath", + materialization_json AS "materialization", linked_pull_request_json AS "linkedPullRequest", latest_turn_id AS "latestTurnId", created_at AS "createdAt", @@ -509,6 +512,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interaction_mode AS "interactionMode", branch, worktree_path AS "worktreePath", + materialization_json AS "materialization", linked_pull_request_json AS "linkedPullRequest", latest_turn_id AS "latestTurnId", created_at AS "createdAt", @@ -549,6 +553,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interaction_mode AS "interactionMode", branch, worktree_path AS "worktreePath", + materialization_json AS "materialization", linked_pull_request_json AS "linkedPullRequest", latest_turn_id AS "latestTurnId", created_at AS "createdAt", @@ -1011,6 +1016,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interaction_mode AS "interactionMode", branch, worktree_path AS "worktreePath", + materialization_json AS "materialization", linked_pull_request_json AS "linkedPullRequest", latest_turn_id AS "latestTurnId", created_at AS "createdAt", @@ -1942,6 +1948,7 @@ pending_approval_requests AS ( interactionMode: row.interactionMode, branch: row.branch, worktreePath: row.worktreePath, + materialization: row.materialization, ...(row.linkedPullRequest === null ? {} : { linkedPullRequest: row.linkedPullRequest }), @@ -2155,6 +2162,7 @@ pending_approval_requests AS ( interactionMode: row.interactionMode, branch: row.branch, worktreePath: row.worktreePath, + materialization: row.materialization, ...(row.linkedPullRequest === null ? {} : { linkedPullRequest: row.linkedPullRequest }), @@ -2295,6 +2303,7 @@ pending_approval_requests AS ( interactionMode: row.interactionMode, branch: row.branch, worktreePath: row.worktreePath, + materialization: row.materialization, ...(row.linkedPullRequest === null ? {} : { linkedPullRequest: row.linkedPullRequest }), @@ -2443,6 +2452,7 @@ pending_approval_requests AS ( interactionMode: row.interactionMode, branch: row.branch, worktreePath: row.worktreePath, + materialization: row.materialization, ...(row.linkedPullRequest === null ? {} : { linkedPullRequest: row.linkedPullRequest }), @@ -2737,6 +2747,7 @@ pending_approval_requests AS ( interactionMode: threadRow.value.interactionMode, branch: threadRow.value.branch, worktreePath: threadRow.value.worktreePath, + materialization: threadRow.value.materialization, ...(threadRow.value.linkedPullRequest === null ? {} : { linkedPullRequest: threadRow.value.linkedPullRequest }), @@ -2975,6 +2986,7 @@ pending_approval_requests AS ( interactionMode: threadRow.value.interactionMode, branch: threadRow.value.branch, worktreePath: threadRow.value.worktreePath, + materialization: threadRow.value.materialization, ...(threadRow.value.linkedPullRequest === null ? {} : { linkedPullRequest: threadRow.value.linkedPullRequest }), diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 1c5e834ec2d7..08d7f02f7f34 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -9,7 +9,10 @@ import { ProviderSession, ProviderDriverKind, ProviderInstanceId, + FULL_WORKTREE_MATERIALIZATION_STATE, + GitCommandError, ProviderSetupError, + type VcsWorktreeMaterializationState, } from "@t3tools/contracts"; import { createModelSelection } from "@t3tools/shared/model"; import { @@ -177,10 +180,22 @@ describe("ProviderCommandReactor", () => { readonly compactThreadEffect?: () => Effect.Effect; readonly interruptTurnEffect?: () => Effect.Effect; readonly stopSessionEffect?: () => Effect.Effect; + readonly createWorktreeEffect?: ( + worktreeInput: Parameters< + GitWorkflowService.GitWorkflowService["Service"]["createWorktree"] + >[0], + ) => ReturnType; + readonly expandWorktreeMaterializationEffect?: ( + cwd: string, + reason: string, + ) => Effect.Effect; readonly startSessionEffect?: ( session: ProviderSession, ) => Effect.Effect; readonly tryHandlePromptCommandEffect?: ProviderAuthService["Service"]["tryHandlePromptCommand"]; + readonly verifyWorktreeMaterializationEffect?: ( + cwd: string, + ) => Effect.Effect; }) { const now = "2026-01-01T00:00:00.000Z"; const baseDir = @@ -302,8 +317,28 @@ describe("ProviderCommandReactor", () => { ); const pruneWorktrees = vi.fn((_: { readonly cwd: string }) => Effect.void); const createWorktree = vi.fn( - (input: { readonly refName: string; readonly path: string | null }) => - Effect.succeed({ worktree: { path: input.path ?? "", refName: input.refName } }), + ( + worktreeInput: Parameters< + GitWorkflowService.GitWorkflowService["Service"]["createWorktree"] + >[0], + ) => + input?.createWorktreeEffect?.(worktreeInput) ?? + Effect.succeed({ + worktree: { path: worktreeInput.path ?? "", refName: worktreeInput.refName }, + }), + ); + const verifyWorktreeMaterialization = vi.fn( + (cwd: string) => + input?.verifyWorktreeMaterializationEffect?.(cwd) ?? + Effect.succeed(FULL_WORKTREE_MATERIALIZATION_STATE), + ); + const expandWorktreeMaterializationFull = vi.fn( + (cwd: string, reason: string) => + input?.expandWorktreeMaterializationEffect?.(cwd, reason) ?? + Effect.succeed({ + ...FULL_WORKTREE_MATERIALIZATION_STATE, + reason, + }), ); const refreshStatus = vi.fn((_: string) => Effect.succeed({ @@ -453,6 +488,8 @@ describe("ProviderCommandReactor", () => { renameBranch, pruneWorktrees, createWorktree, + verifyWorktreeMaterialization, + expandWorktreeMaterializationFull, } satisfies Partial), ), Layer.provideMerge( @@ -582,6 +619,8 @@ describe("ProviderCommandReactor", () => { renameBranch, pruneWorktrees, createWorktree, + verifyWorktreeMaterialization, + expandWorktreeMaterializationFull, refreshStatus, generateBranchName, generateThreadTitle, @@ -2217,6 +2256,556 @@ describe("ProviderCommandReactor", () => { ); }); + it("round-trips persisted sparse identity when recreating a missing worktree", async () => { + const sparseMaterialization: VcsWorktreeMaterializationState = { + ...FULL_WORKTREE_MATERIALIZATION_STATE, + requestedProfileId: "governance-review", + effectiveProfileId: "governance-review", + mode: "sparse", + reason: null, + expectedContractSha256: "a".repeat(64), + contractSha256: "a".repeat(64), + manifestSha256: "b".repeat(64), + conePaths: ["docs"], + requiredPaths: ["docs/spec.md"], + taskId: "OC-1", + taskSlug: "sparse-rehydrate", + taskCardPath: "ops/stef-task/sparse-rehydrate/stef-task.json", + scopePaths: ["docs/spec.md"], + taskClasses: ["source-task"], + includeResearchTask: true, + }; + const recreatedMaterialization = { + ...sparseMaterialization, + baseSha: "c".repeat(40), + manifestSha256: "d".repeat(64), + reason: "rehydrated-effective-profile", + }; + let createdMaterialization: VcsWorktreeMaterializationState | null = null; + const harness = await createHarness({ + createWorktreeEffect: (worktreeInput) => { + createdMaterialization = recreatedMaterialization; + return Effect.succeed({ + worktree: { path: worktreeInput.path ?? "", refName: worktreeInput.refName }, + materialization: recreatedMaterialization, + }); + }, + verifyWorktreeMaterializationEffect: () => + createdMaterialization + ? Effect.succeed(createdMaterialization) + : Effect.fail( + new GitCommandError({ + operation: "GitVcsDriver.verifyWorktreeMaterialization", + command: "git", + cwd: worktreePath, + detail: "worktree was not recreated", + }), + ), + }); + const now = "2026-01-01T00:00:00.000Z"; + const worktreePath = NodePath.join(harness.stateDir, "missing-sparse-worktree"); + + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-missing-sparse-worktree"), + threadId: ThreadId.make("thread-1"), + branch: "feature/sparse-restore", + worktreePath, + }), + ); + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.materialization.set", + commandId: CommandId.make("cmd-thread-sparse-materialization"), + threadId: ThreadId.make("thread-1"), + materialization: sparseMaterialization, + createdAt: now, + }), + ); + expect( + (await harness.readModel()).threads.find((entry) => entry.id === ThreadId.make("thread-1")) + ?.materialization, + ).toEqual(sparseMaterialization); + + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-missing-sparse-worktree"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-missing-sparse-worktree"), + role: "user", + text: "continue", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.startSession.mock.calls.length === 1); + expect(harness.createWorktree).toHaveBeenCalledWith({ + cwd: "/tmp/provider-project", + refName: "feature/sparse-restore", + path: worktreePath, + materialization: { + requestedProfileId: "governance-review", + expectedContractSha256: "a".repeat(64), + taskId: "OC-1", + taskSlug: "sparse-rehydrate", + taskCardPath: "ops/stef-task/sparse-rehydrate/stef-task.json", + scopePaths: ["docs/spec.md"], + taskClasses: ["source-task"], + includeResearchTask: true, + }, + }); + expect(harness.createWorktree.mock.invocationCallOrder[0]).toBeLessThan( + harness.verifyWorktreeMaterialization.mock.invocationCallOrder[0]!, + ); + expect(harness.verifyWorktreeMaterialization.mock.invocationCallOrder[0]).toBeLessThan( + harness.startSession.mock.invocationCallOrder[0]!, + ); + expect( + (await harness.readModel()).threads.find((entry) => entry.id === ThreadId.make("thread-1")) + ?.materialization, + ).toEqual(recreatedMaterialization); + }); + + it("rehydrates a prior sparse fallback as full and persists the recreated identity", async () => { + const fallbackMaterialization: VcsWorktreeMaterializationState = { + ...FULL_WORKTREE_MATERIALIZATION_STATE, + requestedProfileId: "governance-review", + reason: "sparse-setup-failed", + expectedContractSha256: "a".repeat(64), + contractSha256: "a".repeat(64), + taskId: "OC-FALLBACK", + taskSlug: "fallback-rehydrate", + taskCardPath: "ops/stef-task/fallback-rehydrate/stef-task.json", + scopePaths: ["docs/spec.md"], + taskClasses: ["source-task"], + baseSha: "b".repeat(40), + }; + const recreatedMaterialization: VcsWorktreeMaterializationState = { + ...fallbackMaterialization, + requestedProfileId: "full", + reason: "explicit-full", + baseSha: "c".repeat(40), + }; + let createdMaterialization: VcsWorktreeMaterializationState | null = null; + const harness = await createHarness({ + createWorktreeEffect: (worktreeInput) => { + createdMaterialization = recreatedMaterialization; + return Effect.succeed({ + worktree: { path: worktreeInput.path ?? "", refName: worktreeInput.refName }, + materialization: recreatedMaterialization, + }); + }, + verifyWorktreeMaterializationEffect: () => + createdMaterialization + ? Effect.succeed(createdMaterialization) + : Effect.fail( + new GitCommandError({ + operation: "GitVcsDriver.verifyWorktreeMaterialization", + command: "git", + cwd: "/missing", + detail: "worktree was not recreated", + }), + ), + }); + const now = "2026-01-01T00:00:00.000Z"; + const worktreePath = NodePath.join(harness.stateDir, "missing-fallback-worktree"); + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-missing-fallback-worktree"), + threadId: ThreadId.make("thread-1"), + branch: "feature/fallback-restore", + worktreePath, + }), + ); + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.materialization.set", + commandId: CommandId.make("cmd-thread-fallback-materialization"), + threadId: ThreadId.make("thread-1"), + materialization: fallbackMaterialization, + createdAt: now, + }), + ); + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-missing-fallback-worktree"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-missing-fallback-worktree"), + role: "user", + text: "continue", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.startSession.mock.calls.length === 1); + expect(harness.createWorktree).toHaveBeenCalledWith({ + cwd: "/tmp/provider-project", + refName: "feature/fallback-restore", + path: worktreePath, + materialization: { + requestedProfileId: "full", + expectedContractSha256: "a".repeat(64), + taskId: "OC-FALLBACK", + taskSlug: "fallback-rehydrate", + taskCardPath: "ops/stef-task/fallback-rehydrate/stef-task.json", + scopePaths: ["docs/spec.md"], + taskClasses: ["source-task"], + }, + }); + expect( + (await harness.readModel()).threads.find((entry) => entry.id === ThreadId.make("thread-1")) + ?.materialization, + ).toEqual(recreatedMaterialization); + }); + + it("rehydrates incomplete effective-full identity through verified legacy full", async () => { + const incompleteFallback: VcsWorktreeMaterializationState = { + ...FULL_WORKTREE_MATERIALIZATION_STATE, + requestedProfileId: "governance-review", + reason: "task-card-materialization-failed", + }; + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + const worktreePath = NodePath.join(harness.stateDir, "missing-incomplete-full-worktree"); + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-missing-incomplete-full-worktree"), + threadId: ThreadId.make("thread-1"), + branch: "feature/incomplete-full-restore", + worktreePath, + }), + ); + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.materialization.set", + commandId: CommandId.make("cmd-thread-incomplete-full-materialization"), + threadId: ThreadId.make("thread-1"), + materialization: incompleteFallback, + createdAt: now, + }), + ); + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-missing-incomplete-full-worktree"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-missing-incomplete-full-worktree"), + role: "user", + text: "continue", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.startSession.mock.calls.length === 1); + expect(harness.createWorktree).toHaveBeenCalledWith({ + cwd: "/tmp/provider-project", + refName: "feature/incomplete-full-restore", + path: worktreePath, + }); + expect(harness.verifyWorktreeMaterialization).toHaveBeenCalledWith(worktreePath); + expect( + (await harness.readModel()).threads.find((entry) => entry.id === ThreadId.make("thread-1")) + ?.materialization, + ).toEqual(FULL_WORKTREE_MATERIALIZATION_STATE); + }); + + it("surfaces a hard legacy-full verification failure before provider start", async () => { + const worktreePath = "/tmp/incomplete-full-verification-failure"; + const verificationError = new GitCommandError({ + operation: "GitVcsDriver.verifyWorktreeMaterialization", + command: "git", + cwd: worktreePath, + detail: "injected legacy full verification failure", + }); + const harness = await createHarness({ + verifyWorktreeMaterializationEffect: () => Effect.fail(verificationError), + expandWorktreeMaterializationEffect: () => Effect.fail(verificationError), + }); + const now = "2026-01-01T00:00:00.000Z"; + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-incomplete-full-verification-failure"), + threadId: ThreadId.make("thread-1"), + branch: "feature/incomplete-full-verification-failure", + worktreePath, + }), + ); + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.materialization.set", + commandId: CommandId.make("cmd-thread-incomplete-full-verification-state"), + threadId: ThreadId.make("thread-1"), + materialization: { + ...FULL_WORKTREE_MATERIALIZATION_STATE, + requestedProfileId: "governance-review", + reason: "task-card-materialization-failed", + }, + createdAt: now, + }), + ); + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-incomplete-full-verification-failure"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-incomplete-full-verification-failure"), + role: "user", + text: "continue", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(async () => { + const thread = (await harness.readModel()).threads.find( + (entry) => entry.id === ThreadId.make("thread-1"), + ); + return thread?.session?.status === "error"; + }); + expect(harness.startSession).not.toHaveBeenCalled(); + expect(harness.sendTurn).not.toHaveBeenCalled(); + expect( + (await harness.readModel()).threads.find((entry) => entry.id === ThreadId.make("thread-1")) + ?.session?.lastError, + ).toContain("injected legacy full verification failure"); + }); + + it("accepts legacy full materialization before a worktree turn", async () => { + const harness = await createHarness(); + const worktreePath = NodePath.join(harness.stateDir, "legacy-full-worktree"); + NodeFS.mkdirSync(worktreePath, { recursive: true }); + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-legacy-full"), + threadId: ThreadId.make("thread-1"), + branch: "feature/legacy-full", + worktreePath, + }), + ); + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-legacy-full"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-legacy-full"), + role: "user", + text: "continue", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: "2026-01-01T00:00:00.000Z", + }), + ); + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + expect(harness.verifyWorktreeMaterialization).toHaveBeenCalledWith(worktreePath); + }); + + it("expands and persists a materialization mismatch before the pristine first turn", async () => { + const expandedMaterialization = { + ...FULL_WORKTREE_MATERIALIZATION_STATE, + requestedProfileId: "governance-review", + reason: "pre-first-turn:mismatch", + expectedContractSha256: "a".repeat(64), + contractSha256: "a".repeat(64), + taskId: "OC-PRISTINE", + taskSlug: "pristine-expand", + taskCardPath: "ops/stef-task/pristine-expand/stef-task.json", + scopePaths: ["docs/spec.md"], + } satisfies VcsWorktreeMaterializationState; + const harness = await createHarness({ + verifyWorktreeMaterializationEffect: () => + Effect.succeed({ + ...FULL_WORKTREE_MATERIALIZATION_STATE, + requestedProfileId: "governance-review", + effectiveProfileId: "governance-review", + mode: "sparse", + reason: null, + }), + expandWorktreeMaterializationEffect: () => Effect.succeed(expandedMaterialization), + }); + const worktreePath = NodePath.join(harness.stateDir, "mismatched-worktree"); + NodeFS.mkdirSync(worktreePath, { recursive: true }); + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-mismatched-materialization"), + threadId: ThreadId.make("thread-1"), + branch: "feature/mismatch", + worktreePath, + }), + ); + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-mismatched-materialization"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-mismatched-materialization"), + role: "user", + text: "continue", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: "2026-01-01T00:00:00.000Z", + }), + ); + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + const thread = (await harness.readModel()).threads.find( + (entry) => entry.id === ThreadId.make("thread-1"), + ); + expect(harness.expandWorktreeMaterializationFull).toHaveBeenCalledWith( + worktreePath, + "pre-first-turn:materialization-mismatch", + ); + expect(thread?.materialization).toEqual(expandedMaterialization); + expect(thread?.session?.lastError).toBeNull(); + }); + + it("expands and persists a verifier failure before the pristine first turn", async () => { + const worktreePath = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3-verifier-error-worktree-"), + ); + const harness = await createHarness({ + verifyWorktreeMaterializationEffect: () => + Effect.fail( + new GitCommandError({ + operation: "GitVcsDriver.verifyWorktreeMaterialization", + command: "git", + cwd: worktreePath, + detail: "required paths missing; run expand-full, then reverify", + }), + ), + expandWorktreeMaterializationEffect: (_cwd, reason) => + Effect.succeed({ ...FULL_WORKTREE_MATERIALIZATION_STATE, reason }), + }); + createdStateDirs.add(worktreePath); + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-verifier-error"), + threadId: ThreadId.make("thread-1"), + branch: "feature/verifier-error", + worktreePath, + }), + ); + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-verifier-error"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-verifier-error"), + role: "user", + text: "continue", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: "2026-01-01T00:00:00.000Z", + }), + ); + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + expect(harness.expandWorktreeMaterializationFull).toHaveBeenCalledWith( + worktreePath, + "pre-first-turn:verification-failed", + ); + expect( + (await harness.readModel()).threads.find((entry) => entry.id === ThreadId.make("thread-1")) + ?.materialization, + ).toEqual({ + ...FULL_WORKTREE_MATERIALIZATION_STATE, + reason: "pre-first-turn:verification-failed", + }); + }); + + it("blocks a verifier failure after work has begun without expanding", async () => { + const worktreePath = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3-post-work-verifier-error-"), + ); + let verificationCount = 0; + const harness = await createHarness({ + verifyWorktreeMaterializationEffect: () => { + verificationCount += 1; + return verificationCount === 1 + ? Effect.succeed(FULL_WORKTREE_MATERIALIZATION_STATE) + : Effect.fail( + new GitCommandError({ + operation: "GitVcsDriver.verifyWorktreeMaterialization", + command: "git", + cwd: worktreePath, + detail: "required paths missing; run expand-full, then reverify", + }), + ); + }, + }); + createdStateDirs.add(worktreePath); + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-post-work-verifier-error"), + threadId: ThreadId.make("thread-1"), + branch: "feature/post-work-verifier-error", + worktreePath, + }), + ); + for (const index of [1, 2]) { + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make(`cmd-turn-start-post-work-verifier-error-${index}`), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId(`user-message-post-work-verifier-error-${index}`), + role: "user", + text: "continue", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: `2026-01-01T00:00:0${index}.000Z`, + }), + ); + if (index === 1) await waitFor(() => harness.sendTurn.mock.calls.length === 1); + } + await waitFor(async () => { + const thread = (await harness.readModel()).threads.find( + (entry) => entry.id === ThreadId.make("thread-1"), + ); + return thread?.session?.status === "error"; + }); + expect(harness.sendTurn).toHaveBeenCalledTimes(1); + expect(harness.expandWorktreeMaterializationFull).not.toHaveBeenCalled(); + }); + it("forwards codex model options through session start and turn send", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 1beab8ed22bb..9d72ef7b1691 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -9,6 +9,9 @@ import { type OrchestrationSession, ThreadId, type ProviderSession, + type OrchestrationThread, + FULL_WORKTREE_MATERIALIZATION_STATE, + type VcsWorktreeMaterializationRequest, type RuntimeMode, type TurnId, } from "@t3tools/contracts"; @@ -483,14 +486,19 @@ const make = Effect.gen(function* () { * Recreates a thread's worktree from its branch when the directory has * disappeared. Provider sessions resume into the persisted cwd, so a missing * worktree makes every later turn fail as a bogus "session not found". - * Best-effort: on failure the turn proceeds and reports the real error. + * Recreation is best-effort; the mandatory verifier blocks the turn if the + * restored path is absent or does not match the persisted materialization. */ - const ensureThreadWorktree = Effect.fnUntraced(function* (thread: { - readonly id: ThreadId; - readonly projectId: ProjectId; - readonly branch: string | null; - readonly worktreePath: string | null; - }) { + const ensureThreadWorktree = Effect.fnUntraced(function* ( + thread: { + readonly id: ThreadId; + readonly projectId: ProjectId; + readonly branch: string | null; + readonly worktreePath: string | null; + readonly materialization?: OrchestrationThread["materialization"]; + }, + createdAt: string, + ) { const { worktreePath, branch } = thread; if (!worktreePath || !branch) { return; @@ -511,8 +519,58 @@ const make = Effect.gen(function* () { }); // A directory deleted without `git worktree remove` leaves an admin entry // that makes `git worktree add` refuse the path; prune clears it. - yield* gitWorkflow.pruneWorktrees({ cwd }).pipe( - Effect.andThen(gitWorkflow.createWorktree({ cwd, refName: branch, path: worktreePath })), + const persistedMaterialization = thread.materialization ?? FULL_WORKTREE_MATERIALIZATION_STATE; + const taskCardPath = persistedMaterialization.taskCardPath ?? null; + const hasNonDefaultIdentity = !Equal.equals( + persistedMaterialization, + FULL_WORKTREE_MATERIALIZATION_STATE, + ); + const rehydrationMaterialization = + hasNonDefaultIdentity && + persistedMaterialization.expectedContractSha256 && + persistedMaterialization.taskId && + persistedMaterialization.taskSlug && + taskCardPath && + persistedMaterialization.scopePaths?.length + ? ({ + requestedProfileId: persistedMaterialization.effectiveProfileId, + expectedContractSha256: persistedMaterialization.expectedContractSha256, + taskId: persistedMaterialization.taskId, + taskSlug: persistedMaterialization.taskSlug, + taskCardPath, + scopePaths: persistedMaterialization.scopePaths ?? [], + ...(persistedMaterialization.taskClasses + ? { taskClasses: persistedMaterialization.taskClasses } + : {}), + ...(persistedMaterialization.includeResearchTask === true + ? { includeResearchTask: true } + : {}), + } satisfies VcsWorktreeMaterializationRequest) + : undefined; + const rehydrateAsLegacyFull = + hasNonDefaultIdentity && + persistedMaterialization.effectiveProfileId === "full" && + !rehydrationMaterialization; + if (hasNonDefaultIdentity && !rehydrationMaterialization && !rehydrateAsLegacyFull) { + yield* Effect.logWarning( + "provider command reactor cannot recreate worktree with incomplete materialization identity", + { + threadId: thread.id, + worktreePath, + effectiveProfileId: persistedMaterialization.effectiveProfileId, + }, + ); + return; + } + const recreated = yield* gitWorkflow.pruneWorktrees({ cwd }).pipe( + Effect.andThen( + gitWorkflow.createWorktree({ + cwd, + refName: branch, + path: worktreePath, + ...(rehydrationMaterialization ? { materialization: rehydrationMaterialization } : {}), + }), + ), Effect.catchCause((cause) => Cause.hasInterruptsOnly(cause) ? Effect.failCause(cause) @@ -520,9 +578,80 @@ const make = Effect.gen(function* () { threadId: thread.id, worktreePath, cause: Cause.pretty(cause), - }), + }).pipe(Effect.as(null)), ), ); + const recreatedMaterialization = recreated?.materialization; + if (rehydrateAsLegacyFull && recreated) { + const verifiedLegacyFull = yield* gitWorkflow + .verifyWorktreeMaterialization(worktreePath) + .pipe( + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.failCause(cause) + : Effect.logWarning( + "provider command reactor could not verify legacy full recreation", + { + threadId: thread.id, + worktreePath, + cause: Cause.pretty(cause), + }, + ).pipe(Effect.as(null)), + ), + ); + if (!verifiedLegacyFull) return; + if (!Equal.equals(verifiedLegacyFull, FULL_WORKTREE_MATERIALIZATION_STATE)) { + yield* Effect.logWarning( + "provider command reactor legacy full recreation did not verify as default full", + { + threadId: thread.id, + worktreePath, + recreatedEffectiveProfileId: verifiedLegacyFull.effectiveProfileId, + }, + ); + return; + } + yield* Effect.logInfo( + "provider command reactor recovered incomplete materialization as legacy full", + { + threadId: thread.id, + worktreePath, + priorRequestedProfileId: persistedMaterialization.requestedProfileId, + priorReason: persistedMaterialization.reason, + }, + ); + yield* orchestrationEngine.dispatch({ + type: "thread.materialization.set", + commandId: yield* serverCommandId("rehydrate-legacy-full-materialization-set"), + threadId: thread.id, + materialization: verifiedLegacyFull, + createdAt, + }); + return verifiedLegacyFull; + } + if (!rehydrationMaterialization || !recreatedMaterialization) return; + if ( + recreatedMaterialization.effectiveProfileId !== persistedMaterialization.effectiveProfileId + ) { + yield* Effect.logWarning( + "provider command reactor recreated worktree with a different effective materialization", + { + threadId: thread.id, + worktreePath, + persistedEffectiveProfileId: persistedMaterialization.effectiveProfileId, + recreatedEffectiveProfileId: recreatedMaterialization.effectiveProfileId, + }, + ); + return; + } + yield* orchestrationEngine.dispatch({ + type: "thread.materialization.set", + commandId: yield* serverCommandId("rehydrate-thread-materialization-set"), + threadId: thread.id, + materialization: recreatedMaterialization, + createdAt, + }); + return recreatedMaterialization; }); const resolveThread = Effect.fnUntraced(function* (threadId: ThreadId) { @@ -1232,6 +1361,12 @@ const make = Effect.gen(function* () { ), ); + const isCompactCommand = isCompactCommandMessage(message); + const nonCompactUserMessageCount = thread.messages.filter( + (entry) => entry.role === "user" && !isCompactCommandMessage(entry), + ).length; + const isPristineWorktreeTurn = nonCompactUserMessageCount === 1 && !isCompactCommand; + const authCommandHandled = yield* Effect.gen(function* () { // Native account commands belong to the thread's existing provider session. const instanceId = @@ -1283,12 +1418,60 @@ const make = Effect.gen(function* () { return; } - yield* ensureThreadWorktree(thread); + const recreatedMaterialization = yield* ensureThreadWorktree(thread, event.payload.createdAt); + + const materializationReady = yield* Effect.gen(function* () { + if (!thread.worktreePath) return true; + const expectedMaterialization = + recreatedMaterialization ?? thread.materialization ?? FULL_WORKTREE_MATERIALIZATION_STATE; + const verification = yield* gitWorkflow + .verifyWorktreeMaterialization(thread.worktreePath) + .pipe( + Effect.map((materialization) => ({ _tag: "Success", materialization }) as const), + Effect.catchCause((cause) => Effect.succeed({ _tag: "Failure" as const, cause })), + ); + if ( + verification._tag === "Success" && + Equal.equals(verification.materialization, expectedMaterialization) + ) { + return true; + } + if (!isPristineWorktreeTurn) { + if (verification._tag === "Failure") { + return yield* Effect.failCause(verification.cause); + } + return yield* new ProviderAdapterRequestError({ + provider: providerErrorLabelFromInstanceHint({ + instanceId: String(thread.modelSelection.instanceId), + }), + method: "thread.turn.start", + detail: + "Persisted thread materialization does not match the worktree. Preserve changes in an ordinary named commit or operator-approved external copy, reach a clean state without automated stash/reset/clean/removal, run expand-full, then reverify.", + }); + } + const expanded = yield* gitWorkflow.expandWorktreeMaterializationFull( + thread.worktreePath, + verification._tag === "Failure" + ? "pre-first-turn:verification-failed" + : "pre-first-turn:materialization-mismatch", + ); + yield* orchestrationEngine.dispatch({ + type: "thread.materialization.set", + commandId: yield* serverCommandId("pre-first-turn-materialization-expand"), + threadId: thread.id, + materialization: expanded, + createdAt: event.payload.createdAt, + }); + return true; + }).pipe( + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.interrupt + : recoverTurnStartFailure(cause).pipe(Effect.as(false)), + ), + ); + if (!materializationReady) return; - const isCompactCommand = isCompactCommandMessage(message); - const nonCompactUserMessageCount = thread.messages.filter( - (entry) => entry.role === "user" && !isCompactCommandMessage(entry), - ).length; if (nonCompactUserMessageCount === 1 && !isCompactCommand) { const project = yield* resolveProject(thread.projectId); const generationCwd = diff --git a/apps/server/src/orchestration/Schemas.ts b/apps/server/src/orchestration/Schemas.ts index 7e866cf89592..9b6a83c7db66 100644 --- a/apps/server/src/orchestration/Schemas.ts +++ b/apps/server/src/orchestration/Schemas.ts @@ -3,6 +3,7 @@ import { ProjectMetaUpdatedPayload as ContractsProjectMetaUpdatedPayloadSchema, ProjectDeletedPayload as ContractsProjectDeletedPayloadSchema, ThreadCreatedPayload as ContractsThreadCreatedPayloadSchema, + ThreadMaterializationSetPayload as ContractsThreadMaterializationSetPayloadSchema, ThreadArchivedPayload as ContractsThreadArchivedPayloadSchema, ThreadSettledPayload as ContractsThreadSettledPayloadSchema, ThreadMetaUpdatedPayload as ContractsThreadMetaUpdatedPayloadSchema, @@ -35,6 +36,7 @@ export const ProjectMetaUpdatedPayload = ContractsProjectMetaUpdatedPayloadSchem export const ProjectDeletedPayload = ContractsProjectDeletedPayloadSchema; export const ThreadCreatedPayload = ContractsThreadCreatedPayloadSchema; +export const ThreadMaterializationSetPayload = ContractsThreadMaterializationSetPayloadSchema; export const ThreadArchivedPayload = ContractsThreadArchivedPayloadSchema; export const ThreadSettledPayload = ContractsThreadSettledPayloadSchema; export const ThreadMetaUpdatedPayload = ContractsThreadMetaUpdatedPayloadSchema; diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index b336053ac9e1..1caa4a1d2172 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -833,6 +833,28 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.materialization.set": { + yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "thread.materialization-set", + payload: { + threadId: command.threadId, + materialization: command.materialization, + updatedAt: command.createdAt, + }, + }; + } + case "thread.title.regeneration.complete": { const thread = yield* requireThread({ readModel, diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index dad3d07370f9..deed4c842e3c 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -1,3 +1,4 @@ +import { it as effectIt } from "@effect/vitest"; import { CommandId, EventId, @@ -5,6 +6,7 @@ import { ProviderDriverKind, ThreadId, type OrchestrationEvent, + FULL_WORKTREE_MATERIALIZATION_STATE, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import { describe, expect, it } from "vite-plus/test"; @@ -85,6 +87,7 @@ describe("orchestration projector", () => { interactionMode: "default", branch: null, worktreePath: null, + materialization: FULL_WORKTREE_MATERIALIZATION_STATE, latestTurn: null, createdAt: now, updatedAt: now, @@ -104,6 +107,65 @@ describe("orchestration projector", () => { ]); }); + effectIt.effect("applies server-owned thread materialization events", () => + Effect.gen(function* () { + const now = "2026-01-01T00:00:00.000Z"; + const created = yield* projectEvent( + createEmptyReadModel(now), + makeEvent({ + sequence: 1, + type: "thread.created", + aggregateKind: "thread", + aggregateId: "thread-materialized", + occurredAt: now, + commandId: "cmd-create", + payload: { + threadId: "thread-materialized", + projectId: "project-1", + title: "demo", + modelSelection: { provider: "codex", model: "gpt-5-codex" }, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }), + ); + const sparse = { + ...FULL_WORKTREE_MATERIALIZATION_STATE, + requestedProfileId: "governance-review", + effectiveProfileId: "governance-review", + mode: "sparse" as const, + reason: null, + expectedContractSha256: "a".repeat(64), + contractSha256: "a".repeat(64), + manifestSha256: "b".repeat(64), + conePaths: ["docs"], + requiredPaths: ["docs/spec.md"], + taskId: "OC-1", + taskSlug: "demo", + }; + const next = yield* projectEvent( + created, + makeEvent({ + sequence: 2, + type: "thread.materialization-set", + aggregateKind: "thread", + aggregateId: "thread-materialized", + occurredAt: now, + commandId: "cmd-materialization", + payload: { + threadId: "thread-materialized", + materialization: sparse, + updatedAt: now, + }, + }), + ); + expect(next.threads[0]?.materialization).toEqual(sparse); + }), + ); + it("fails when event payload cannot be decoded by runtime schema", async () => { const now = "2026-01-01T00:00:00.000Z"; const model = createEmptyReadModel(now); diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 3cea194bbb44..330953eb348a 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -4,6 +4,7 @@ import { OrchestrationMessage, OrchestrationSession, OrchestrationThread, + FULL_WORKTREE_MATERIALIZATION_STATE, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; @@ -18,6 +19,7 @@ import { ThreadActivityAppendedPayload, ThreadArchivedPayload, ThreadCreatedPayload, + ThreadMaterializationSetPayload, ThreadDeletedPayload, ThreadInteractionModeSetPayload, ThreadMetaUpdatedPayload, @@ -326,6 +328,7 @@ export function projectEvent( interactionMode: payload.interactionMode, branch: payload.branch, worktreePath: payload.worktreePath, + materialization: FULL_WORKTREE_MATERIALIZATION_STATE, latestTurn: null, createdAt: payload.createdAt, updatedAt: payload.updatedAt, @@ -353,6 +356,22 @@ export function projectEvent( }; }); + case "thread.materialization-set": + return decodeForEvent( + ThreadMaterializationSetPayload, + event.payload, + event.type, + "payload", + ).pipe( + Effect.map((payload) => ({ + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + materialization: payload.materialization, + updatedAt: payload.updatedAt, + }), + })), + ); + case "thread.deleted": return decodeForEvent(ThreadDeletedPayload, event.payload, event.type, "payload").pipe( Effect.map((payload) => ({ diff --git a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts index adc3ca40cbb5..312a8190f6b4 100644 --- a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts @@ -1,4 +1,9 @@ -import { ProjectId, ThreadId, ProviderInstanceId } from "@t3tools/contracts"; +import { + FULL_WORKTREE_MATERIALIZATION_STATE, + ProjectId, + ThreadId, + ProviderInstanceId, +} from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -89,6 +94,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { interactionMode: "default", branch: null, worktreePath: null, + materialization: FULL_WORKTREE_MATERIALIZATION_STATE, latestTurnId: null, createdAt: "2026-03-24T00:00:00.000Z", updatedAt: "2026-03-24T00:00:00.000Z", @@ -153,6 +159,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { interactionMode: "default", branch: null, worktreePath: null, + materialization: FULL_WORKTREE_MATERIALIZATION_STATE, latestTurnId: null, createdAt: "2026-03-24T00:00:00.000Z", updatedAt: "2026-03-25T00:00:00.000Z", @@ -229,6 +236,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { interactionMode: "default", branch: null, worktreePath: null, + materialization: FULL_WORKTREE_MATERIALIZATION_STATE, linkedPullRequest, latestTurnId: null, createdAt: "2026-03-24T00:00:00.000Z", diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index d5653a2c8b42..3ad26e5aecd0 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -14,12 +14,18 @@ import { ProjectionThreadRepository, type ProjectionThreadRepositoryShape, } from "../Services/ProjectionThreads.ts"; -import { ModelSelection, ThreadLinkedPullRequest } from "@t3tools/contracts"; +import { + ModelSelection, + FULL_WORKTREE_MATERIALIZATION_STATE, + ThreadLinkedPullRequest, + VcsWorktreeMaterializationState, +} from "@t3tools/contracts"; const ProjectionThreadDbRow = ProjectionThread.mapFields( Struct.assign({ modelSelection: Schema.fromJsonString(ModelSelection), linkedPullRequest: Schema.NullOr(Schema.fromJsonString(ThreadLinkedPullRequest)), + materialization: Schema.fromJsonString(VcsWorktreeMaterializationState), }), ); type ProjectionThreadDbRow = typeof ProjectionThreadDbRow.Type; @@ -29,8 +35,9 @@ const makeProjectionThreadRepository = Effect.gen(function* () { const upsertProjectionThreadRow = SqlSchema.void({ Request: ProjectionThread, - execute: (row) => - sql` + execute: (row) => { + const materialization = row.materialization ?? FULL_WORKTREE_MATERIALIZATION_STATE; + return sql` INSERT INTO projection_threads ( thread_id, project_id, @@ -40,6 +47,14 @@ const makeProjectionThreadRepository = Effect.gen(function* () { interaction_mode, branch, worktree_path, + materialization_json, + materialization_requested_profile_id, + materialization_effective_profile_id, + materialization_mode, + materialization_expected_contract_sha256, + materialization_contract_sha256, + materialization_manifest_sha256, + materialization_reason, linked_pull_request_json, latest_turn_id, created_at, @@ -69,6 +84,14 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.interactionMode}, ${row.branch}, ${row.worktreePath}, + ${JSON.stringify(materialization)}, + ${materialization.requestedProfileId}, + ${materialization.effectiveProfileId}, + ${materialization.mode}, + ${materialization.expectedContractSha256}, + ${materialization.contractSha256}, + ${materialization.manifestSha256}, + ${materialization.reason}, ${row.linkedPullRequest === undefined || row.linkedPullRequest === null ? null : JSON.stringify(row.linkedPullRequest)}, ${row.latestTurnId}, ${row.createdAt}, @@ -98,6 +121,14 @@ const makeProjectionThreadRepository = Effect.gen(function* () { interaction_mode = excluded.interaction_mode, branch = excluded.branch, worktree_path = excluded.worktree_path, + materialization_json = excluded.materialization_json, + materialization_requested_profile_id = excluded.materialization_requested_profile_id, + materialization_effective_profile_id = excluded.materialization_effective_profile_id, + materialization_mode = excluded.materialization_mode, + materialization_expected_contract_sha256 = excluded.materialization_expected_contract_sha256, + materialization_contract_sha256 = excluded.materialization_contract_sha256, + materialization_manifest_sha256 = excluded.materialization_manifest_sha256, + materialization_reason = excluded.materialization_reason, linked_pull_request_json = excluded.linked_pull_request_json, latest_turn_id = excluded.latest_turn_id, created_at = excluded.created_at, @@ -117,7 +148,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { pending_user_input_count = excluded.pending_user_input_count, has_actionable_proposed_plan = excluded.has_actionable_proposed_plan, deleted_at = excluded.deleted_at - `, + `; + }, }); const getProjectionThreadRow = SqlSchema.findOneOption({ @@ -134,6 +166,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { interaction_mode AS "interactionMode", branch, worktree_path AS "worktreePath", + materialization_json AS "materialization", linked_pull_request_json AS "linkedPullRequest", latest_turn_id AS "latestTurnId", created_at AS "createdAt", @@ -172,6 +205,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { interaction_mode AS "interactionMode", branch, worktree_path AS "worktreePath", + materialization_json AS "materialization", linked_pull_request_json AS "linkedPullRequest", latest_turn_id AS "latestTurnId", created_at AS "createdAt", diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 92dc18291057..106527076a09 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -59,6 +59,7 @@ import Migration0044 from "./Migrations/044_ClearAutomaticProjectModelDefaults.t import Migration0045 from "./Migrations/045_ProjectionProjectsAutoPull.ts"; import Migration0046 from "./Migrations/046_RepairAutomaticSettlementTimestamps.ts"; import Migration0047 from "./Migrations/047_ProjectionProjectIcon.ts"; +import Migration0048 from "./Migrations/048_ProjectionThreadMaterialization.ts"; /** * Migration loader with all migrations defined inline. @@ -118,6 +119,7 @@ export const migrationEntries = [ [45, "ProjectionProjectsAutoPull", Migration0045], [46, "RepairAutomaticSettlementTimestamps", Migration0046], [47, "ProjectionProjectIcon", Migration0047], + [48, "ProjectionThreadMaterialization", Migration0048], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/048_ProjectionThreadMaterialization.test.ts b/apps/server/src/persistence/Migrations/048_ProjectionThreadMaterialization.test.ts new file mode 100644 index 000000000000..0908e583b3ce --- /dev/null +++ b/apps/server/src/persistence/Migrations/048_ProjectionThreadMaterialization.test.ts @@ -0,0 +1,151 @@ +import { assert, it } from "@effect/vitest"; +import { + FULL_WORKTREE_MATERIALIZATION_STATE, + ThreadId, + VcsWorktreeMaterializationState, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import { ProjectionThreadRepositoryLive } from "../Layers/ProjectionThreads.ts"; +import { ProjectionThreadRepository } from "../Services/ProjectionThreads.ts"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; + +const layer = it.layer( + ProjectionThreadRepositoryLive.pipe(Layer.provideMerge(NodeSqliteClient.layerMemory())), +); + +layer("048_ProjectionThreadMaterialization", (it) => { + it.effect("adds explicit identity columns and a full-state backfill default", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations({ toMigrationInclusive: 47 }); + yield* sql` + INSERT INTO projection_threads ( + thread_id, + project_id, + title, + model_selection_json, + runtime_mode, + interaction_mode, + branch, + worktree_path, + linked_pull_request_json, + latest_turn_id, + latest_user_message_at, + pending_approval_count, + pending_user_input_count, + has_actionable_proposed_plan, + pinned_at, + pin_order_key, + created_at, + updated_at, + deleted_at + ) VALUES ( + 'thread-pre-48', + 'project-pre-48', + 'Pre-48 thread', + '{"instanceId":"codex","model":"gpt-5-codex"}', + 'full-access', + 'default', + NULL, + NULL, + NULL, + NULL, + NULL, + 0, + 0, + 0, + NULL, + NULL, + '2026-09-01T00:00:00.000Z', + '2026-09-01T00:00:00.000Z', + NULL + ) + `; + yield* runMigrations({ toMigrationInclusive: 48 }); + + const columns = yield* sql<{ + readonly name: string; + readonly dfltValue: string | null; + readonly notNull: number; + }>` + SELECT name, dflt_value AS "dfltValue", "notnull" AS "notNull" + FROM pragma_table_info('projection_threads') + `; + for (const name of [ + "materialization_json", + "materialization_requested_profile_id", + "materialization_effective_profile_id", + "materialization_mode", + "materialization_expected_contract_sha256", + "materialization_contract_sha256", + "materialization_manifest_sha256", + "materialization_reason", + ]) { + assert.ok( + columns.some((column) => column.name === name), + name, + ); + } + const defaultSql = columns.find( + (column) => column.name === "materialization_json", + )?.dfltValue; + assert.ok(defaultSql); + const defaultJson = + defaultSql.startsWith("'") && defaultSql.endsWith("'") + ? defaultSql.slice(1, -1).replaceAll("''", "'") + : defaultSql; + const decodedDefault = yield* Schema.decodeUnknownEffect( + Schema.fromJsonString(VcsWorktreeMaterializationState), + )(defaultJson); + assert.deepStrictEqual(decodedDefault, FULL_WORKTREE_MATERIALIZATION_STATE); + + const repository = yield* ProjectionThreadRepository; + const row = yield* repository.getById({ threadId: ThreadId.make("thread-pre-48") }); + assert.ok(Option.isSome(row)); + assert.deepStrictEqual(row.value.materialization, FULL_WORKTREE_MATERIALIZATION_STATE); + + const materializationReason = columns.find( + (column) => column.name === "materialization_reason", + ); + assert.equal(materializationReason?.notNull, 0); + const sparse = { + ...FULL_WORKTREE_MATERIALIZATION_STATE, + requestedProfileId: "governance-review", + effectiveProfileId: "governance-review", + mode: "sparse" as const, + reason: null, + expectedContractSha256: "a".repeat(64), + contractSha256: "a".repeat(64), + manifestSha256: "b".repeat(64), + conePaths: ["docs"], + requiredPaths: ["docs/spec.md"], + taskId: "OC-1", + taskSlug: "sparse-persistence", + taskCardPath: "ops/stef-task/sparse-persistence/stef-task.json", + scopePaths: ["docs/spec.md"], + }; + yield* repository.upsert({ + ...row.value, + materialization: sparse, + updatedAt: "2026-09-01T00:00:01.000Z", + }); + const sparseRow = yield* repository.getById({ + threadId: ThreadId.make("thread-pre-48"), + }); + assert.ok(Option.isSome(sparseRow)); + assert.deepStrictEqual(sparseRow.value.materialization, sparse); + const rawReason = yield* sql<{ readonly reason: string | null }>` + SELECT materialization_reason AS reason + FROM projection_threads + WHERE thread_id = 'thread-pre-48' + `; + assert.deepStrictEqual(rawReason, [{ reason: null }]); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/048_ProjectionThreadMaterialization.ts b/apps/server/src/persistence/Migrations/048_ProjectionThreadMaterialization.ts new file mode 100644 index 000000000000..484aea920595 --- /dev/null +++ b/apps/server/src/persistence/Migrations/048_ProjectionThreadMaterialization.ts @@ -0,0 +1,59 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_threads) + `; + const has = (name: string) => columns.some((column) => column.name === name); + + if (!has("materialization_json")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN materialization_json TEXT NOT NULL DEFAULT '{"status":"ready","requestedProfileId":"full","effectiveProfileId":"full","mode":"full","reason":"default-full","expectedContractSha256":null,"contractSha256":null,"manifestSha256":null,"conePaths":[],"requiredPaths":[],"taskId":null,"taskSlug":null,"taskCardPath":null,"baseSha":null}' + `; + } + if (!has("materialization_requested_profile_id")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN materialization_requested_profile_id TEXT NOT NULL DEFAULT 'full' + `; + } + if (!has("materialization_effective_profile_id")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN materialization_effective_profile_id TEXT NOT NULL DEFAULT 'full' + `; + } + if (!has("materialization_mode")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN materialization_mode TEXT NOT NULL DEFAULT 'full' + `; + } + if (!has("materialization_expected_contract_sha256")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN materialization_expected_contract_sha256 TEXT + `; + } + if (!has("materialization_contract_sha256")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN materialization_contract_sha256 TEXT + `; + } + if (!has("materialization_manifest_sha256")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN materialization_manifest_sha256 TEXT + `; + } + if (!has("materialization_reason")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN materialization_reason TEXT DEFAULT 'default-full' + `; + } +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index a70548bc110c..69b2527dfddc 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -17,11 +17,13 @@ import { ThreadLinkedPullRequest, ThreadId, TurnId, + VcsWorktreeMaterializationState, + FULL_WORKTREE_MATERIALIZATION_STATE, } from "@t3tools/contracts"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Context from "effect/Context"; -import type * as Effect from "effect/Effect"; +import * as Effect from "effect/Effect"; import type { ProjectionRepositoryError } from "../Errors.ts"; @@ -34,6 +36,9 @@ export const ProjectionThread = Schema.Struct({ interactionMode: ProviderInteractionMode, branch: Schema.NullOr(Schema.String), worktreePath: Schema.NullOr(Schema.String), + materialization: VcsWorktreeMaterializationState.pipe( + Schema.withDecodingDefault(Effect.succeed(FULL_WORKTREE_MATERIALIZATION_STATE)), + ), linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)), latestTurnId: Schema.NullOr(TurnId), createdAt: IsoDateTime, diff --git a/apps/server/src/review/ReviewService.test.ts b/apps/server/src/review/ReviewService.test.ts index 01a4692264e0..0f6d84defe3a 100644 --- a/apps/server/src/review/ReviewService.test.ts +++ b/apps/server/src/review/ReviewService.test.ts @@ -1,10 +1,12 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as DateTime from "effect/DateTime"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as PlatformError from "effect/PlatformError"; +import { FULL_WORKTREE_MATERIALIZATION_STATE, GitCommandError } from "@t3tools/contracts"; import { ServerConfig } from "../config.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; @@ -14,6 +16,8 @@ function makeLayer(input: { readonly workspaceRoot: string; readonly baseDir: string; readonly detectCalls?: Array<{ readonly cwd: string }>; + readonly detectResult?: VcsDriverRegistry.VcsDriverHandle | null; + readonly git?: Partial; }) { return ReviewService.layer.pipe( Layer.provide( @@ -23,11 +27,11 @@ function makeLayer(input: { detect: (request) => Effect.sync(() => { input.detectCalls?.push({ cwd: request.cwd }); - return null; + return input.detectResult ?? null; }), }), ), - Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})), + Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({ ...input.git })), Layer.provide(ServerConfig.layerTest(input.workspaceRoot, input.baseDir)), Layer.provideMerge(NodeServices.layer), ); @@ -108,6 +112,81 @@ describe("ReviewService", () => { }).pipe(Effect.provide(NodeServices.layer)), ); + it.effect("verifies a legacy full Git checkout before review and preserves normal output", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const workspaceRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-workspace-" }); + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-base-" }); + const verifyCalls: Array = []; + const result = yield* Effect.gen(function* () { + const review = yield* ReviewService.ReviewService; + return yield* review.getDiffPreview({ cwd: workspaceRoot }); + }).pipe( + Effect.provide( + makeLayer({ + workspaceRoot, + baseDir, + detectResult: { + kind: "git", + repository: {} as never, + driver: {} as never, + }, + git: { + verifyWorktreeMaterialization: (cwd) => + Effect.sync(() => { + verifyCalls.push(cwd); + return FULL_WORKTREE_MATERIALIZATION_STATE; + }), + getReviewDiffPreview: (input) => + Effect.gen(function* () { + return { cwd: input.cwd, generatedAt: yield* DateTime.now, sources: [] }; + }), + }, + }), + ), + ); + assert.deepStrictEqual(verifyCalls, [workspaceRoot]); + assert.strictEqual(result.cwd, workspaceRoot); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("blocks review when Git materialization verification fails", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const workspaceRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-workspace-" }); + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-base-" }); + const error = yield* Effect.gen(function* () { + const review = yield* ReviewService.ReviewService; + return yield* review.getDiffPreview({ cwd: workspaceRoot }).pipe(Effect.flip); + }).pipe( + Effect.provide( + makeLayer({ + workspaceRoot, + baseDir, + detectResult: { + kind: "git", + repository: {} as never, + driver: {} as never, + }, + git: { + verifyWorktreeMaterialization: () => + Effect.fail( + new GitCommandError({ + operation: "GitVcsDriver.verifyWorktreeMaterialization", + command: "git", + cwd: workspaceRoot, + detail: "required paths missing", + }), + ), + }, + }), + ), + ); + assert.strictEqual(error._tag, "GitCommandError"); + assert.match(error.message, /required paths missing/); + }).pipe(Effect.provide(NodeServices.layer)), + ); + it.effect("preserves unexpected path-resolution failures", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/review/ReviewService.ts b/apps/server/src/review/ReviewService.ts index 275dcb416610..14fd711c2f82 100644 --- a/apps/server/src/review/ReviewService.ts +++ b/apps/server/src/review/ReviewService.ts @@ -99,6 +99,9 @@ export const make = Effect.gen(function* () { sources: [], }; } + if (handle.kind === "git") { + yield* git.verifyWorktreeMaterialization(input.cwd); + } const getDriverDiffPreview = handle.driver.getDiffPreview; if (!getDriverDiffPreview) { @@ -129,6 +132,8 @@ export const make = Effect.gen(function* () { }); } + yield* git.verifyWorktreeMaterialization(input.cwd); + return yield* git.getReviewDiffFileContents(input); }); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 85afe00cb52a..529250360ebf 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -41,6 +41,7 @@ import { WS_METHODS, WsRpcGroup, EditorId, + FULL_WORKTREE_MATERIALIZATION_STATE, } from "@t3tools/contracts"; import { computeDpopAccessTokenHash, @@ -8872,6 +8873,154 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("binds expand-full to an inactive thread path and persists the result", () => + Effect.gen(function* () { + const threadId = ThreadId.make("thread-expand-materialization"); + const worktreePath = "/tmp/thread-expand-materialization"; + const dispatchedCommands: Array = []; + const expandedMaterialization = { + ...FULL_WORKTREE_MATERIALIZATION_STATE, + reason: "operator-recovery", + }; + const expandWorktreeMaterializationFull = vi.fn((cwd: string, reason: string) => + Effect.succeed({ + ...expandedMaterialization, + reason: `${reason}:${cwd}`, + }), + ); + const refreshStatus = vi.fn((_: string) => + Effect.succeed({ + isRepo: true, + hasPrimaryRemote: true, + isDefaultRef: false, + refName: "feature/expand", + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: null, + }), + ); + let threadShell: OrchestrationThreadShell = makeDefaultOrchestrationThreadShell({ + id: threadId, + worktreePath, + session: null, + }); + + yield* buildAppUnderTest({ + layers: { + gitVcsDriver: { expandWorktreeMaterializationFull }, + vcsStatusBroadcaster: { refreshStatus }, + orchestrationEngine: { + dispatch: (command) => + Effect.sync(() => { + dispatchedCommands.push(command); + return { sequence: dispatchedCommands.length }; + }), + }, + projectionSnapshotQuery: { + getThreadShellById: () => Effect.succeed(Option.some(threadShell)), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const pathMismatch = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.vcsExpandWorktreeMaterialization]({ + cwd: "/tmp/not-the-thread-worktree", + threadId, + }), + ).pipe(Effect.result), + ); + assertTrue(pathMismatch._tag === "Failure"); + assertTrue(pathMismatch.failure._tag === "OrchestrationDispatchCommandError"); + assert.include(pathMismatch.failure.message, "exact persisted worktree path"); + + threadShell = makeDefaultOrchestrationThreadShell({ + id: threadId, + worktreePath, + session: { + threadId, + status: "running", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: "2026-01-01T00:00:00.000Z", + }, + }); + const activeSession = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.vcsExpandWorktreeMaterialization]({ cwd: worktreePath, threadId }), + ).pipe(Effect.result), + ); + assertTrue(activeSession._tag === "Failure"); + assertTrue(activeSession.failure._tag === "OrchestrationDispatchCommandError"); + assert.include(activeSession.failure.message, "thread session is active"); + assert.equal(expandWorktreeMaterializationFull.mock.calls.length, 0); + assert.equal(dispatchedCommands.length, 0); + + threadShell = makeDefaultOrchestrationThreadShell({ + id: threadId, + worktreePath, + session: { + threadId, + status: "ready", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: "2026-01-01T00:00:00.000Z", + }, + }); + const readySession = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.vcsExpandWorktreeMaterialization]({ cwd: worktreePath, threadId }), + ).pipe(Effect.result), + ); + assertTrue(readySession._tag === "Failure"); + assertTrue(readySession.failure._tag === "OrchestrationDispatchCommandError"); + assert.include(readySession.failure.message, "thread session is active"); + assert.equal(expandWorktreeMaterializationFull.mock.calls.length, 0); + assert.equal(dispatchedCommands.length, 0); + + threadShell = makeDefaultOrchestrationThreadShell({ + id: threadId, + worktreePath, + session: null, + }); + const response = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.vcsExpandWorktreeMaterialization]({ + cwd: worktreePath, + threadId, + reason: "operator-recovery", + }), + ), + ); + + assert.deepEqual(expandWorktreeMaterializationFull.mock.calls[0]?.[0], worktreePath); + assert.deepEqual(expandWorktreeMaterializationFull.mock.calls[0]?.[1], "operator-recovery"); + assert.deepEqual(response.materialization, { + ...expandedMaterialization, + reason: `operator-recovery:${worktreePath}`, + }); + const materializationCommand = dispatchedCommands[0]; + assertTrue(materializationCommand?.type === "thread.materialization.set"); + if (materializationCommand?.type === "thread.materialization.set") { + assert.equal(materializationCommand.threadId, threadId); + assert.deepEqual(materializationCommand.materialization, response.materialization); + } + assert.deepEqual( + dispatchedCommands.map((command) => command.type), + ["thread.materialization.set"], + ); + assert.deepEqual(refreshStatus.mock.calls[0]?.[0], worktreePath); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect( "bootstraps first-send worktree turns on the server before dispatching turn start", () => @@ -8917,6 +9066,26 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }), ); const fetchedOriginCommit = "0123456789abcdef0123456789abcdef01234567"; + const materializationRequest = { + requestedProfileId: "governance-review", + expectedContractSha256: "a".repeat(64), + taskId: "OC-1", + taskSlug: "task", + taskCardPath: "ops/stef-task/task/stef-task.json", + scopePaths: ["docs/spec.md"], + taskClasses: ["source-task"], + } as const; + const materializationState = { + ...FULL_WORKTREE_MATERIALIZATION_STATE, + ...materializationRequest, + effectiveProfileId: "governance-review", + mode: "sparse" as const, + reason: null, + contractSha256: "a".repeat(64), + manifestSha256: "b".repeat(64), + conePaths: ["docs"], + requiredPaths: ["docs/spec.md"], + }; const resolveRemoteTrackingCommit = vi.fn( (_: Parameters[0]) => Effect.sync(() => { @@ -8936,9 +9105,17 @@ it.layer(NodeServices.layer)("server router seam", (it) => { refName: "t3code/bootstrap-refName", path: "/tmp/bootstrap-worktree", }, + materialization: materializationState, }; }), ); + const verifyWorktreeMaterialization = vi.fn((cwd: string) => + Effect.sync(() => { + bootstrapGitOperations.push("verify-materialization"); + assert.equal(cwd, "/tmp/bootstrap-worktree"); + return materializationState; + }), + ); const runForThread = vi.fn( ( _: Parameters< @@ -8962,6 +9139,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { remoteBranchExists, resolveRemoteTrackingCommit, createWorktree, + verifyWorktreeMaterialization, }, vcsStatusBroadcaster: { refreshStatus, @@ -8970,6 +9148,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { dispatch: (command) => Effect.sync(() => { dispatchedCommands.push(command); + if (command.type === "thread.materialization.set") { + bootstrapGitOperations.push("persist-materialization"); + } return { sequence: dispatchedCommands.length }; }), readEvents: () => Stream.empty, @@ -9013,6 +9194,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { baseBranch: "main", branch: "t3code/bootstrap-refName", startFromOrigin: true, + materialization: materializationRequest, }, runSetupScript: true, }, @@ -9021,12 +9203,13 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ), ); - assert.equal(response.sequence, 5); + assert.equal(response.sequence, 6); assert.deepEqual( dispatchedCommands.map((command) => command.type), [ "thread.create", "thread.meta.update", + "thread.materialization.set", "thread.activity.append", "thread.activity.append", "thread.turn.start", @@ -9038,6 +9221,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { newRefName: "t3code/bootstrap-refName", baseRefName: "main", path: null, + materialization: materializationRequest, }); assert.deepEqual(fetchRemote.mock.calls[0]?.[0], { cwd: "/tmp/project", @@ -9059,6 +9243,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { "remote-branch-exists", "resolve-remote-commit", "create-worktree", + "verify-materialization", + "persist-materialization", ]); assert.deepEqual(runForThread.mock.calls[0]?.[0], { threadId: ThreadId.make("thread-bootstrap"), @@ -9076,7 +9262,12 @@ it.layer(NodeServices.layer)("server router seam", (it) => { setupActivities.map((command) => command.activity.kind), ["setup-script.requested", "setup-script.started"], ); - const finalCommand = dispatchedCommands[4]; + const materializationCommand = dispatchedCommands[2]; + assertTrue(materializationCommand?.type === "thread.materialization.set"); + if (materializationCommand?.type === "thread.materialization.set") { + assert.deepEqual(materializationCommand.materialization, materializationState); + } + const finalCommand = dispatchedCommands[5]; assertTrue(finalCommand?.type === "thread.turn.start"); if (finalCommand?.type === "thread.turn.start") { assert.equal(finalCommand.bootstrap, undefined); @@ -9084,6 +9275,206 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("fails bootstrap closed when verified materialization differs from creation", () => + Effect.gen(function* () { + const dispatchedCommands: Array = []; + const materializationRequest = { + requestedProfileId: "governance-review", + expectedContractSha256: "a".repeat(64), + taskId: "OC-1", + taskSlug: "task", + taskCardPath: "ops/stef-task/task/stef-task.json", + scopePaths: ["docs/spec.md"], + } as const; + const createdMaterialization = { + ...FULL_WORKTREE_MATERIALIZATION_STATE, + ...materializationRequest, + effectiveProfileId: "governance-review", + mode: "sparse" as const, + reason: null, + contractSha256: "a".repeat(64), + manifestSha256: "b".repeat(64), + conePaths: ["docs"], + requiredPaths: ["docs/spec.md"], + }; + const verifiedMaterialization = { + ...createdMaterialization, + manifestSha256: "c".repeat(64), + }; + const createWorktree = vi.fn( + (_: Parameters[0]) => + Effect.succeed({ + worktree: { + refName: "t3code/bootstrap-mismatch", + path: "/tmp/bootstrap-mismatch-worktree", + }, + materialization: createdMaterialization, + }), + ); + const verifyWorktreeMaterialization = vi.fn((_: string) => + Effect.succeed(verifiedMaterialization), + ); + + yield* buildAppUnderTest({ + layers: { + gitVcsDriver: { createWorktree, verifyWorktreeMaterialization }, + orchestrationEngine: { + dispatch: (command) => + Effect.sync(() => { + dispatchedCommands.push(command); + return { sequence: dispatchedCommands.length }; + }), + readEvents: () => Stream.empty, + }, + }, + }); + + const createdAt = "2026-01-01T00:00:00.000Z"; + const wsUrl = yield* getWsServerUrl("/ws"); + const result = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-bootstrap-materialization-mismatch"), + threadId: ThreadId.make("thread-bootstrap-materialization-mismatch"), + message: { + messageId: MessageId.make("msg-bootstrap-materialization-mismatch"), + role: "user", + text: "hello", + attachments: [], + }, + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + bootstrap: { + createThread: { + projectId: defaultProjectId, + title: "Bootstrap Thread", + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: null, + createdAt, + }, + prepareWorktree: { + projectCwd: "/tmp/project", + baseBranch: "main", + branch: "t3code/bootstrap-mismatch", + materialization: materializationRequest, + }, + runSetupScript: false, + }, + createdAt, + }), + ).pipe(Effect.result), + ); + + assertTrue(result._tag === "Failure"); + assertTrue(result.failure._tag === "OrchestrationDispatchCommandError"); + assert.include(result.failure.message, "does not match its persisted identity"); + assert.strictEqual(result.failure.bootstrapThreadDisposition, "deleted"); + assert.deepEqual( + dispatchedCommands.map((command) => command.type), + ["thread.create", "thread.meta.update", "thread.delete"], + ); + assert.equal(verifyWorktreeMaterialization.mock.calls.length, 1); + assert.isFalse( + dispatchedCommands.some( + (command) => + command.type === "thread.materialization.set" || command.type === "thread.turn.start", + ), + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("fails bootstrap closed when requested materialization returns no identity", () => + Effect.gen(function* () { + const dispatchedCommands: Array = []; + const materializationRequest = { + requestedProfileId: "governance-review", + expectedContractSha256: "a".repeat(64), + taskId: "OC-1", + taskSlug: "task", + taskCardPath: "ops/stef-task/task/stef-task.json", + scopePaths: ["docs/spec.md"], + } as const; + const createWorktree = vi.fn( + (_: Parameters[0]) => + Effect.succeed({ + worktree: { + refName: "t3code/bootstrap-missing-materialization", + path: "/tmp/bootstrap-missing-materialization-worktree", + }, + }), + ); + + yield* buildAppUnderTest({ + layers: { + gitVcsDriver: { createWorktree }, + orchestrationEngine: { + dispatch: (command) => + Effect.sync(() => { + dispatchedCommands.push(command); + return { sequence: dispatchedCommands.length }; + }), + readEvents: () => Stream.empty, + }, + }, + }); + + const createdAt = "2026-01-01T00:00:00.000Z"; + const wsUrl = yield* getWsServerUrl("/ws"); + const result = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-bootstrap-missing-materialization"), + threadId: ThreadId.make("thread-bootstrap-missing-materialization"), + message: { + messageId: MessageId.make("msg-bootstrap-missing-materialization"), + role: "user", + text: "hello", + attachments: [], + }, + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + bootstrap: { + createThread: { + projectId: defaultProjectId, + title: "Bootstrap Thread", + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: null, + createdAt, + }, + prepareWorktree: { + projectCwd: "/tmp/project", + baseBranch: "main", + branch: "t3code/bootstrap-missing-materialization", + materialization: materializationRequest, + }, + runSetupScript: false, + }, + createdAt, + }), + ).pipe(Effect.result), + ); + + assertTrue(result._tag === "Failure"); + assertTrue(result.failure._tag === "OrchestrationDispatchCommandError"); + assert.include(result.failure.message, "returned no persisted identity"); + assert.strictEqual(result.failure.bootstrapThreadDisposition, "deleted"); + assert.deepEqual( + dispatchedCommands.map((command) => command.type), + ["thread.create", "thread.delete"], + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect.each([ { caseName: "the origin remote is missing", hasOrigin: false }, { caseName: "the base branch exists only locally", hasOrigin: true }, diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 08b474cf42da..7c22189378a3 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -18,6 +18,7 @@ import { type VcsCreateRefResult, type VcsCreateWorktreeInput, type VcsCreateWorktreeResult, + type VcsWorktreeMaterializationState, type ReviewDiffPreviewInput, type ReviewDiffPreviewResult, type ReviewDiffFileContentsInput, @@ -281,6 +282,13 @@ export class GitVcsDriver extends Context.Service< readonly createWorktree: ( input: VcsCreateWorktreeInput, ) => Effect.Effect; + readonly verifyWorktreeMaterialization: ( + cwd: string, + ) => Effect.Effect; + readonly expandWorktreeMaterializationFull: ( + cwd: string, + reason: string, + ) => Effect.Effect; readonly fetchPullRequestBranch: ( input: GitFetchPullRequestBranchInput, ) => Effect.Effect; diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 75f69de952c1..c75f08b4f787 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -1,10 +1,16 @@ +// @effect-diagnostics nodeBuiltinImport:off import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as NodeCrypto from "node:crypto"; +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; import { assert, it, describe } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import * as Ref from "effect/Ref"; @@ -14,7 +20,11 @@ import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { GitCommandError, type ReviewDiffFileContentsInput } from "@t3tools/contracts"; +import { + FULL_WORKTREE_MATERIALIZATION_STATE, + GitCommandError, + type ReviewDiffFileContentsInput, +} from "@t3tools/contracts"; import { ServerConfig } from "../config.ts"; import { makeGitVcsDriverCore, splitNullSeparatedGitStdoutPaths } from "./GitVcsDriverCore.ts"; import * as GitVcsDriver from "./GitVcsDriver.ts"; @@ -26,6 +36,8 @@ const TestLayer = GitVcsDriver.layer.pipe( Layer.provide(ServerConfigLayer), Layer.provideMerge(NodeServices.layer), ); +const worktreeMaterializationSha256ForTest = (value: string) => + NodeCrypto.createHash("sha256").update(value).digest("hex"); const makeNonRepositoryHandle = () => ChildProcessSpawner.makeHandle({ @@ -105,6 +117,7 @@ const git = ( args, ...(env ? { env } : {}), timeoutMs: 10_000, + maxOutputBytes: 32 * 1024 * 1024, }); return result.stdout.trim(); }); @@ -128,6 +141,173 @@ const initRepoWithCommit = ( return { initialBranch }; }); +const writeMaterializationFixture = Effect.fn("writeMaterializationFixture")(function* ( + cwd: string, + options: { + readonly requiredOutsideCone?: boolean; + readonly requiredMissingEverywhere?: boolean; + readonly invalidSharedPath?: boolean; + readonly invalidSharedValue?: unknown; + readonly omitTaskCardCone?: boolean; + readonly utf8Bom?: boolean; + } = {}, +) { + const contract = { + schemaVersion: "clawd.worktree-materialization-profiles.v1", + taskContext: { + taskCardRoot: "ops/stef-task", + buildStateRoot: "ops/build-state", + researchRoot: "ops/research", + }, + sharedConePaths: + options.invalidSharedValue !== undefined + ? ["config", "ops/stef-task", "ops/build-state", options.invalidSharedValue] + : options.invalidSharedPath + ? ["config", "ops/stef-task", "ops/build-state", "/absolute-cone"] + : options.omitTaskCardCone + ? ["config", "ops/build-state"] + : ["config", "ops/stef-task", "ops/build-state"], + sharedRequiredPaths: ["config/worktree-materialization-profiles.json"], + unsupportedTaskClasses: ["unclassified", "multi-domain", "live-runtime"], + profiles: [ + { id: "full", mode: "full", conePaths: [], requiredPaths: [] }, + { + id: "governance-review", + mode: "sparse", + conePaths: ["docs"], + requiredPaths: options.requiredMissingEverywhere + ? ["missing/never.txt"] + : options.requiredOutsideCone + ? ["outside/needed.txt"] + : [], + }, + { + id: "brandt-source", + mode: "sparse", + conePaths: ["brandt-pattern-recognition"], + requiredPaths: ["brandt-pattern-recognition/source.ts"], + }, + { + id: "trading-strategy-source", + mode: "sparse", + conePaths: ["strategies"], + requiredPaths: ["strategies/source.ts"], + }, + ], + } as const; + // @effect-diagnostics-next-line preferSchemaOverJson:off + const raw = `${options.utf8Bom ? "\uFEFF" : ""}${JSON.stringify(contract, null, 2)}\n`; + yield* writeTextFile(cwd, "config/worktree-materialization-profiles.json", raw); + yield* writeTextFile(cwd, "docs/spec.md", "# sparse\n"); + yield* writeTextFile(cwd, "ops/stef-task/task/stef-task.json", "{}\n"); + yield* writeTextFile(cwd, "ops/build-state/OC-1/proof.json", "{}\n"); + yield* writeTextFile(cwd, "outside/needed.txt", "needed\n"); + yield* writeTextFile(cwd, "brandt-pattern-recognition/source.ts", "export {};\n"); + yield* writeTextFile(cwd, "strategies/source.ts", "export {};\n"); + yield* writeTextFile(cwd, "excluded/large.txt", `${"x".repeat(1_000_000)}\n`); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "materialization fixture"]); + return { + expectedContractSha256: NodeCrypto.createHash("sha256").update(raw).digest("hex"), + expectedTaskCardSha256: NodeCrypto.createHash("sha256").update("{}\n").digest("hex"), + }; +}); + +const logicalWorkingTreeBytes = ( + root: string, +): Effect.Effect => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const visit = (candidate: string): Effect.Effect => + Effect.gen(function* () { + if (Option.isSome(yield* fileSystem.readLink(candidate).pipe(Effect.option))) return 0; + const infoOption = yield* fileSystem.stat(candidate).pipe(Effect.option); + if (Option.isNone(infoOption)) return 0; + const info = infoOption.value; + if (info.type === "File") return Number(info.size); + if (info.type !== "Directory") return 0; + const names = yield* fileSystem.readDirectory(candidate); + let total = 0; + for (const name of names) { + if (candidate === root && name === ".git") continue; + total += yield* visit(pathService.join(candidate, name)); + } + return total; + }); + return yield* visit(root); + }); + +const realCanaryVerifierPaths = (profileId: string): ReadonlyArray => + ({ + "governance-review": ["scripts/test/worktree-materialization.test.js"], + "brandt-source": ["brandt-pattern-recognition/tests/runtime-config-parser.test.js"], + "trading-strategy-source": ["trading-hub/lib/futures-symbol-normalizer.test.js"], + })[profileId] ?? []; + +const runRealCanaryVerifier = (cwd: string, profileId: string) => { + const result = NodeChildProcess.spawnSync( + process.execPath, + ["--test", "--test-reporter=dot", "--", ...realCanaryVerifierPaths(profileId)], + { + cwd, + encoding: "utf8", + timeout: 300_000, + maxBuffer: 8 * 1024 * 1024, + env: Object.fromEntries( + Object.entries(process.env).filter(([key]) => key !== "NODE_TEST_CONTEXT"), + ), + }, + ); + return { + status: result.status, + signal: result.signal ?? null, + error: result.error ? String(result.error.message || result.error) : null, + stdout: String(result.stdout || ""), + stderr: String(result.stderr || ""), + }; +}; + +const realCanaryReviewPackManifest = (cwd: string, scopePath: string) => { + const absolutePath = NodePath.join(cwd, scopePath); + const diff = NodeChildProcess.spawnSync( + "git", + ["diff", "--no-index", "--", "/dev/null", absolutePath], + { + cwd, + encoding: "utf8", + timeout: 60_000, + maxBuffer: 8 * 1024 * 1024, + }, + ); + if (![0, 1].includes(diff.status ?? -1) || diff.error || diff.signal) { + throw new Error(String(diff.stderr || diff.stdout || "review-pack diff failed").trim()); + } + const bytes = NodeFS.readFileSync(absolutePath); + const indexEntry = NodeChildProcess.execFileSync( + "git", + ["ls-files", "--stage", "--", scopePath], + { cwd, encoding: "utf8" }, + ).trim(); + const normalizedDiff = String(diff.stdout || "") + .split(NodePath.resolve(cwd)) + .join("$ROOT"); + const digest = (value: string | Uint8Array) => + NodeCrypto.createHash("sha256").update(value).digest("hex"); + const manifest = { + sources: [ + { + path: scopePath, + bytes: bytes.length, + sha256: digest(bytes), + indexEntry, + }, + ], + diffSha256: digest(normalizedDiff), + }; + return digest(JSON.stringify(manifest)); +}; + it.effect("uses stable diagnostics for every parsed non-repository command", () => { const commands: Array<{ readonly args: ReadonlyArray; readonly lcAll?: string }> = []; const spawner = ChildProcessSpawner.make((command) => @@ -1376,6 +1556,2147 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }); describe("worktree operations", () => { + it.effect( + "materializes an explicit hash-bound sparse worktree and expands only while clean", + () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const { expectedContractSha256 } = yield* writeMaterializationFixture(cwd); + const pathService = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const worktreePath = pathService.join( + yield* makeTmpDir("git-worktrees-"), + "sparse-materialized", + ); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const created = yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/sparse-materialized", + materialization: { + requestedProfileId: "governance-review", + expectedContractSha256, + taskId: "OC-1", + taskSlug: "task", + taskCardPath: "ops/stef-task/task/stef-task.json", + scopePaths: ["docs/spec.md"], + taskClasses: ["source-task"], + }, + }); + + if (!created.materialization) return assert.fail("expected materialization state"); + assert.equal(created.materialization.effectiveProfileId, "governance-review"); + assert.equal(created.materialization.mode, "sparse"); + assert.equal( + yield* fileSystem.exists(pathService.join(worktreePath, "docs/spec.md")), + true, + ); + assert.equal( + yield* fileSystem.exists(pathService.join(worktreePath, "excluded/large.txt")), + false, + ); + assert.equal(yield* git(worktreePath, ["config", "--bool", "index.sparse"]), "false"); + assert.deepStrictEqual( + yield* driver.verifyWorktreeMaterialization(worktreePath), + created.materialization, + ); + + yield* writeTextFile(worktreePath, "dirty.txt", "dirty\n"); + const dirtyExpansion = yield* Effect.result( + driver.expandWorktreeMaterializationFull(worktreePath, "must-refuse"), + ); + assert.equal(dirtyExpansion._tag, "Failure"); + assert.equal( + (yield* driver.verifyWorktreeMaterialization(worktreePath)).effectiveProfileId, + "governance-review", + ); + assert.equal( + yield* fileSystem.exists(pathService.join(worktreePath, "excluded/large.txt")), + false, + ); + yield* fileSystem.remove(pathService.join(worktreePath, "dirty.txt")); + yield* writeTextFile(worktreePath, "docs/spec.md", "# tracked dirty\n"); + assert.equal( + (yield* Effect.result( + driver.expandWorktreeMaterializationFull(worktreePath, "must-refuse-tracked"), + ))._tag, + "Failure", + ); + assert.equal( + yield* fileSystem.exists(pathService.join(worktreePath, "excluded/large.txt")), + false, + ); + yield* git(worktreePath, ["checkout", "--", "docs/spec.md"]); + + const expanded = yield* driver.expandWorktreeMaterializationFull( + worktreePath, + "test-expand", + ); + assert.equal(expanded.effectiveProfileId, "full"); + assert.equal( + yield* fileSystem.exists(pathService.join(worktreePath, "excluded/large.txt")), + true, + ); + }), + ); + + it.effect("preserves remote-base tracking while pinning the created branch commit", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-worktree-remote-"); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const { expectedContractSha256 } = yield* writeMaterializationFixture(cwd); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remote]); + yield* git(cwd, ["push", "origin", initialBranch]); + yield* git(cwd, ["fetch", "origin"]); + yield* git(cwd, ["config", "branch.autoSetupMerge", "true"]); + yield* writeTextFile(cwd, "local-only.txt", "local divergence\n"); + yield* git(cwd, ["add", "local-only.txt"]); + yield* git(cwd, ["commit", "-m", "local divergence"]); + const pathService = yield* Path.Path; + const worktreePath = pathService.join( + yield* makeTmpDir("git-worktrees-"), + "remote-tracking", + ); + const driver = yield* GitVcsDriver.GitVcsDriver; + + yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: `origin/${initialBranch}`, + newRefName: "feature/remote-tracking", + baseRefName: initialBranch, + materialization: { + requestedProfileId: "full", + expectedContractSha256, + taskId: "OC-TRACK", + taskSlug: "remote-tracking", + taskCardPath: "ops/stef-task/task/stef-task.json", + scopePaths: ["docs/spec.md"], + taskClasses: ["source-task"], + }, + }); + + assert.equal( + yield* git(worktreePath, ["rev-parse", "HEAD^{commit}"]), + yield* git(cwd, ["rev-parse", `origin/${initialBranch}^{commit}`]), + ); + assert.notEqual( + yield* git(worktreePath, ["rev-parse", "HEAD^{commit}"]), + yield* git(cwd, ["rev-parse", `${initialBranch}^{commit}`]), + ); + assert.equal( + yield* git(worktreePath, ["rev-parse", "--abbrev-ref", "@{upstream}"]), + `origin/${initialBranch}`, + ); + }), + ); + + it.effect("pins the one matching remote branch before legacy DWIM worktree creation", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-worktree-dwim-remote-"); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const { expectedContractSha256 } = yield* writeMaterializationFixture(cwd); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remote]); + yield* git(cwd, ["checkout", "-b", "remote-only"]); + yield* git(cwd, ["push", "origin", "remote-only"]); + yield* git(cwd, ["checkout", initialBranch]); + yield* git(cwd, ["branch", "-D", "remote-only"]); + yield* git(cwd, ["fetch", "origin"]); + yield* git(cwd, ["config", "branch.autoSetupMerge", "true"]); + const pathService = yield* Path.Path; + const worktreePath = pathService.join( + yield* makeTmpDir("git-worktrees-"), + "remote-only-dwim", + ); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const created = yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: "remote-only", + newRefName: "feature/remote-only-materialized", + materialization: { + requestedProfileId: "full", + expectedContractSha256, + taskId: "OC-DWIM", + taskSlug: "remote-only", + taskCardPath: "ops/stef-task/task/stef-task.json", + scopePaths: ["docs/spec.md"], + taskClasses: ["source-task"], + }, + }); + + assert.equal(created.worktree.refName, "feature/remote-only-materialized"); + assert.equal( + yield* git(worktreePath, ["branch", "--show-current"]), + "feature/remote-only-materialized", + ); + assert.equal( + yield* git(worktreePath, ["rev-parse", "HEAD^{commit}"]), + yield* git(cwd, ["rev-parse", "origin/remote-only^{commit}"]), + ); + assert.equal( + yield* git(worktreePath, ["rev-parse", "--abbrev-ref", "@{upstream}"]), + "origin/remote-only", + ); + }), + ); + + it.effect("refuses ambiguous bare branch names shared by two remotes", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remoteA = yield* makeTmpDir("git-worktree-ambiguous-a-"); + const remoteB = yield* makeTmpDir("git-worktree-ambiguous-b-"); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const { expectedContractSha256 } = yield* writeMaterializationFixture(cwd); + yield* git(remoteA, ["init", "--bare"]); + yield* git(remoteB, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remoteA]); + yield* git(cwd, ["remote", "add", "upstream", remoteB]); + yield* git(cwd, ["checkout", "-b", "ambiguous"]); + yield* git(cwd, ["push", "origin", "ambiguous"]); + yield* git(cwd, ["push", "upstream", "ambiguous"]); + yield* git(cwd, ["checkout", initialBranch]); + yield* git(cwd, ["branch", "-D", "ambiguous"]); + yield* git(cwd, ["fetch", "origin"]); + yield* git(cwd, ["fetch", "upstream"]); + const pathService = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const worktreePath = pathService.join( + yield* makeTmpDir("git-worktrees-"), + "ambiguous-remote-branch", + ); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const result = yield* Effect.result( + driver.createWorktree({ + cwd, + path: worktreePath, + refName: "ambiguous", + newRefName: "feature/ambiguous-remote-branch", + materialization: { + requestedProfileId: "full", + expectedContractSha256, + taskId: "OC-AMBIGUOUS", + taskSlug: "ambiguous-remote-branch", + taskCardPath: "ops/stef-task/task/stef-task.json", + scopePaths: ["docs/spec.md"], + taskClasses: ["source-task"], + }, + }), + ); + + assert.equal(result._tag, "Failure"); + if (result._tag === "Failure") { + assert.equal(result.failure.operation, "GitVcsDriver.createWorktree.pinCommit"); + } + assert.equal(yield* fileSystem.exists(worktreePath), false); + }), + ); + + it.effect("respects simple auto-setup when the local and remote branch names differ", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-worktree-simple-remote-"); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const { expectedContractSha256 } = yield* writeMaterializationFixture(cwd); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remote]); + yield* git(cwd, ["push", "origin", initialBranch]); + yield* git(cwd, ["fetch", "origin"]); + yield* git(cwd, ["config", "branch.autoSetupMerge", "simple"]); + const pathService = yield* Path.Path; + const worktreePath = pathService.join( + yield* makeTmpDir("git-worktrees-"), + "simple-auto-setup", + ); + const driver = yield* GitVcsDriver.GitVcsDriver; + + yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: `origin/${initialBranch}`, + newRefName: "feature/simple-auto-setup", + materialization: { + requestedProfileId: "full", + expectedContractSha256, + taskId: "OC-SIMPLE", + taskSlug: "simple-auto-setup", + taskCardPath: "ops/stef-task/task/stef-task.json", + scopePaths: ["docs/spec.md"], + taskClasses: ["source-task"], + }, + }); + + const upstream = yield* driver.execute({ + operation: "test.simpleAutoSetup", + args: ["rev-parse", "--abbrev-ref", "@{upstream}"], + cwd: worktreePath, + allowNonZeroExit: true, + }); + assert.notEqual(upstream.exitCode, 0); + + yield* git(cwd, ["config", "branch.autoSetupMerge", ""]); + const emptyModePath = pathService.join( + yield* makeTmpDir("git-worktrees-"), + "empty-auto-setup", + ); + yield* driver.createWorktree({ + cwd, + path: emptyModePath, + refName: `origin/${initialBranch}`, + newRefName: "feature/empty-auto-setup", + materialization: { + requestedProfileId: "full", + expectedContractSha256, + taskId: "OC-EMPTY", + taskSlug: "empty-auto-setup", + taskCardPath: "ops/stef-task/task/stef-task.json", + scopePaths: ["docs/spec.md"], + taskClasses: ["source-task"], + }, + }); + const emptyModeUpstream = yield* driver.execute({ + operation: "test.emptyAutoSetup", + args: ["rev-parse", "--abbrev-ref", "@{upstream}"], + cwd: emptyModePath, + allowNonZeroExit: true, + }); + assert.notEqual(emptyModeUpstream.exitCode, 0); + + yield* git(cwd, ["config", "branch.autoSetupMerge", "off"]); + const offModePath = pathService.join(yield* makeTmpDir("git-worktrees-"), "off-auto-setup"); + yield* driver.createWorktree({ + cwd, + path: offModePath, + refName: `origin/${initialBranch}`, + newRefName: "feature/off-auto-setup", + materialization: { + requestedProfileId: "full", + expectedContractSha256, + taskId: "OC-OFF", + taskSlug: "off-auto-setup", + taskCardPath: "ops/stef-task/task/stef-task.json", + scopePaths: ["docs/spec.md"], + taskClasses: ["source-task"], + }, + }); + const offModeUpstream = yield* driver.execute({ + operation: "test.offAutoSetup", + args: ["rev-parse", "--abbrev-ref", "@{upstream}"], + cwd: offModePath, + allowNonZeroExit: true, + }); + assert.notEqual(offModeUpstream.exitCode, 0); + }), + ); + + it.effect("inherits the start branch upstream when auto-setup is inherit", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-worktree-inherit-remote-"); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const { expectedContractSha256 } = yield* writeMaterializationFixture(cwd); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remote]); + yield* git(cwd, ["push", "-u", "origin", initialBranch]); + yield* git(cwd, ["config", "branch.autoSetupMerge", "inherit"]); + const pathService = yield* Path.Path; + const worktreePath = pathService.join( + yield* makeTmpDir("git-worktrees-"), + "inherit-auto-setup", + ); + const driver = yield* GitVcsDriver.GitVcsDriver; + + yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/inherit-auto-setup", + materialization: { + requestedProfileId: "full", + expectedContractSha256, + taskId: "OC-INHERIT", + taskSlug: "inherit-auto-setup", + taskCardPath: "ops/stef-task/task/stef-task.json", + scopePaths: ["docs/spec.md"], + taskClasses: ["source-task"], + }, + }); + + assert.equal( + yield* git(worktreePath, ["rev-parse", "--abbrev-ref", "@{upstream}"]), + `origin/${initialBranch}`, + ); + + yield* git(cwd, ["config", "branch.autoSetupMerge", "always"]); + const alwaysPath = pathService.join( + yield* makeTmpDir("git-worktrees-"), + "always-auto-setup", + ); + yield* driver.createWorktree({ + cwd, + path: alwaysPath, + refName: initialBranch, + newRefName: "feature/always-auto-setup", + materialization: { + requestedProfileId: "full", + expectedContractSha256, + taskId: "OC-ALWAYS", + taskSlug: "always-auto-setup", + taskCardPath: "ops/stef-task/task/stef-task.json", + scopePaths: ["docs/spec.md"], + taskClasses: ["source-task"], + }, + }); + assert.equal( + yield* git(alwaysPath, ["rev-parse", "--abbrev-ref", "@{upstream}"]), + initialBranch, + ); + }), + ); + + it.effect("keeps default worktree placement named after a project subdirectory", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const pathService = yield* Path.Path; + const projectCwd = pathService.join(cwd, "nested-project"); + const fileSystem = yield* FileSystem.FileSystem; + yield* fileSystem.makeDirectory(projectCwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const created = yield* driver.createWorktree({ + cwd: projectCwd, + path: null, + refName: initialBranch, + newRefName: "feature/nested-project-placement", + }); + + assert.equal( + pathService.basename(pathService.dirname(created.worktree.path)), + "nested-project", + ); + }), + ); + + it.effect("keeps omitted materialization on the legacy stateless create path", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const pathService = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const worktreePath = pathService.join( + yield* makeTmpDir("git-worktrees-"), + "legacy-stateless", + ); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const created = yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/legacy-stateless", + }); + + const gitDir = yield* git(worktreePath, ["rev-parse", "--git-dir"]); + assert.equal(created.materialization, undefined); + assert.equal( + yield* fileSystem.exists( + pathService.join( + pathService.resolve(worktreePath, gitDir), + "worktree-materialization.json", + ), + ), + false, + ); + assert.deepStrictEqual( + yield* driver.verifyWorktreeMaterialization(worktreePath), + FULL_WORKTREE_MATERIALIZATION_STATE, + ); + }), + ); + + it.effect("disables inherited sparse configuration for an ordinary full worktree", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const { expectedContractSha256 } = yield* writeMaterializationFixture(cwd); + yield* git(cwd, ["config", "core.sparseCheckout", "true"]); + const pathService = yield* Path.Path; + const worktreePath = pathService.join( + yield* makeTmpDir("git-worktrees-"), + "inherited-sparse-full", + ); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const created = yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/inherited-sparse-full", + materialization: { + requestedProfileId: "full", + expectedContractSha256, + taskId: "OC-INHERITED", + taskSlug: "inherited-sparse", + taskCardPath: "ops/stef-task/task/stef-task.json", + scopePaths: ["docs/spec.md"], + taskClasses: ["source-task"], + }, + }); + + assert.equal(created.materialization?.effectiveProfileId, "full"); + assert.equal( + yield* git(worktreePath, ["config", "--bool", "core.sparseCheckout"]), + "false", + ); + assert.equal((yield* driver.verifyWorktreeMaterialization(worktreePath)).mode, "full"); + }), + ); + + it.effect("falls back to full before release when sparse required paths are absent", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const { expectedContractSha256 } = yield* writeMaterializationFixture(cwd, { + requiredOutsideCone: true, + }); + const pathService = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const worktreePath = pathService.join(yield* makeTmpDir("git-worktrees-"), "full-fallback"); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const created = yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/full-fallback", + materialization: { + requestedProfileId: "governance-review", + expectedContractSha256, + taskId: "OC-1", + taskSlug: "task", + taskCardPath: "ops/stef-task/task/stef-task.json", + scopePaths: ["docs/spec.md"], + taskClasses: ["source-task"], + }, + }); + + if (!created.materialization) return assert.fail("expected fallback materialization state"); + assert.equal(created.materialization.requestedProfileId, "governance-review"); + assert.equal(created.materialization.effectiveProfileId, "full"); + assert.equal(created.materialization.reason, "required-paths-missing"); + assert.equal( + yield* fileSystem.exists(pathService.join(worktreePath, "outside/needed.txt")), + true, + ); + }), + ); + + it.effect("expand-full repairs missing and failed sparse identities", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const { expectedContractSha256 } = yield* writeMaterializationFixture(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(cwd, [ + "sparse-checkout", + "set", + "--cone", + "--no-sparse-index", + "config", + "docs", + ]); + assert.equal( + (yield* Effect.result(driver.verifyWorktreeMaterialization(cwd)))._tag, + "Failure", + ); + const repairedMissing = yield* driver.expandWorktreeMaterializationFull( + cwd, + "repair-missing-state", + ); + assert.equal(repairedMissing.status, "ready"); + assert.equal(repairedMissing.effectiveProfileId, "full"); + assert.equal((yield* driver.verifyWorktreeMaterialization(cwd)).effectiveProfileId, "full"); + const pathService = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + assert.equal(yield* fileSystem.exists(pathService.join(cwd, "excluded/large.txt")), true); + + const sparsePath = pathService.join( + yield* makeTmpDir("git-worktrees-"), + "failed-state-repair", + ); + const created = yield* driver.createWorktree({ + cwd, + path: sparsePath, + refName: initialBranch, + newRefName: "feature/failed-state-repair", + materialization: { + requestedProfileId: "governance-review", + expectedContractSha256, + taskId: "OC-1", + taskSlug: "task", + taskCardPath: "ops/stef-task/task/stef-task.json", + scopePaths: ["docs/spec.md"], + taskClasses: ["source-task"], + }, + }); + if (!created.materialization) return assert.fail("expected materialization state"); + const gitDir = yield* git(sparsePath, ["rev-parse", "--git-dir"]); + const statePath = pathService.join( + pathService.resolve(sparsePath, gitDir), + "worktree-materialization.json", + ); + // @effect-diagnostics-next-line preferSchemaOverJson:off + const failedState = JSON.parse(yield* fileSystem.readFileString(statePath)); + failedState.status = "failed"; + failedState.reason = "injected-terminal-failure"; + // @effect-diagnostics-next-line preferSchemaOverJson:off + yield* fileSystem.writeFileString(statePath, `${JSON.stringify(failedState, null, 2)}\n`); + assert.equal( + (yield* Effect.result(driver.verifyWorktreeMaterialization(sparsePath)))._tag, + "Failure", + ); + const repairedFailed = yield* driver.expandWorktreeMaterializationFull( + sparsePath, + "repair-failed-state", + ); + assert.equal(repairedFailed.status, "ready"); + assert.equal(repairedFailed.effectiveProfileId, "full"); + assert.equal( + yield* fileSystem.exists(pathService.join(sparsePath, "excluded/large.txt")), + true, + ); + }), + ); + + it.effect("expand-full preserves unreadable state and refuses to certify the sparse tree", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const { expectedContractSha256 } = yield* writeMaterializationFixture(cwd); + const pathService = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const worktreePath = pathService.join( + yield* makeTmpDir("git-worktrees-"), + "unreadable-state-recovery", + ); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/unreadable-state-recovery", + materialization: { + requestedProfileId: "governance-review", + expectedContractSha256, + taskId: "OC-STATE", + taskSlug: "unreadable-state", + taskCardPath: "ops/stef-task/task/stef-task.json", + scopePaths: ["docs/spec.md"], + taskClasses: ["source-task"], + }, + }); + const gitDir = yield* git(worktreePath, ["rev-parse", "--git-dir"]); + const statePath = pathService.join( + pathService.resolve(worktreePath, gitDir), + "worktree-materialization.json", + ); + const before = "{not-json\n"; + yield* fileSystem.writeFileString(statePath, before); + const cone = yield* git(worktreePath, ["sparse-checkout", "list"]); + const index = yield* git(worktreePath, ["ls-files", "--stage"]); + + const expanded = yield* Effect.result( + driver.expandWorktreeMaterializationFull(worktreePath, "recover-unreadable-state"), + ); + + assert.equal(expanded._tag, "Failure"); + assert.equal(yield* fileSystem.readFileString(statePath), before); + assert.equal(yield* git(worktreePath, ["config", "--bool", "core.sparseCheckout"]), "true"); + assert.equal(yield* git(worktreePath, ["sparse-checkout", "list"]), cone); + assert.equal(yield* git(worktreePath, ["ls-files", "--stage"]), index); + assert.equal( + yield* fileSystem.exists(pathService.join(worktreePath, "excluded/large.txt")), + false, + ); + }), + ); + + it.effect("expand-full converges after interruption before the atomic state rename", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const { expectedContractSha256 } = yield* writeMaterializationFixture(cwd); + const pathService = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const worktreePath = pathService.join( + yield* makeTmpDir("git-worktrees-"), + "interrupted-state-rename", + ); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/interrupted-state-rename", + materialization: { + requestedProfileId: "governance-review", + expectedContractSha256, + taskId: "OC-INTERRUPT-RENAME", + taskSlug: "interrupted-state-rename", + taskCardPath: "ops/stef-task/task/stef-task.json", + scopePaths: ["docs/spec.md"], + taskClasses: ["source-task"], + }, + }); + const gitDir = yield* git(worktreePath, ["rev-parse", "--git-dir"]); + const statePath = pathService.join( + pathService.resolve(worktreePath, gitDir), + "worktree-materialization.json", + ); + const before = yield* fileSystem.readFileString(statePath); + let failRename = true; + const interruptedFileSystem = FileSystem.make({ + ...fileSystem, + rename: (fromPath, toPath) => { + if (failRename && toPath === statePath) { + failRename = false; + return Effect.fail( + PlatformError.systemError({ + _tag: "Unknown", + module: "FileSystem", + method: "rename", + description: "injected interruption before state rename", + }), + ); + } + return fileSystem.rename(fromPath, toPath); + }, + }); + const interruptedDriver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(FileSystem.FileSystem, interruptedFileSystem), + Effect.provide(ServerConfigLayer), + ); + + assert.equal( + (yield* Effect.result( + interruptedDriver.expandWorktreeMaterializationFull( + worktreePath, + "interrupted-before-state-rename", + ), + ))._tag, + "Failure", + ); + assert.equal(yield* fileSystem.readFileString(statePath), before); + assert.equal( + yield* git(worktreePath, ["config", "--bool", "core.sparseCheckout"]), + "false", + ); + assert.equal( + (yield* Effect.result(driver.verifyWorktreeMaterialization(worktreePath)))._tag, + "Failure", + ); + + const retried = yield* driver.expandWorktreeMaterializationFull( + worktreePath, + "retry-after-state-rename-interruption", + ); + assert.equal(retried.effectiveProfileId, "full"); + assert.equal((yield* driver.verifyWorktreeMaterialization(worktreePath)).mode, "full"); + }), + ); + + it.effect("expand-full leaves a recoverable full state when verification is interrupted", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const { expectedContractSha256 } = yield* writeMaterializationFixture(cwd); + const pathService = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const worktreePath = pathService.join( + yield* makeTmpDir("git-worktrees-"), + "interrupted-verification", + ); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/interrupted-verification", + materialization: { + requestedProfileId: "governance-review", + expectedContractSha256, + taskId: "OC-INTERRUPT-VERIFY", + taskSlug: "interrupted-verification", + taskCardPath: "ops/stef-task/task/stef-task.json", + scopePaths: ["docs/spec.md"], + taskClasses: ["source-task"], + }, + }); + const gitDir = yield* git(worktreePath, ["rev-parse", "--git-dir"]); + const statePath = pathService.join( + pathService.resolve(worktreePath, gitDir), + "worktree-materialization.json", + ); + let stateRenamed = false; + let failRead = true; + const interruptedFileSystem = FileSystem.make({ + ...fileSystem, + rename: (fromPath, toPath) => + fileSystem.rename(fromPath, toPath).pipe( + Effect.tap(() => + Effect.sync(() => { + if (toPath === statePath) stateRenamed = true; + }), + ), + ), + readFile: (filePath: string) => { + if (stateRenamed && failRead && filePath === statePath) { + failRead = false; + return Effect.fail( + PlatformError.systemError({ + _tag: "Unknown", + module: "FileSystem", + method: "readFile", + description: "injected interruption before verification", + }), + ); + } + return fileSystem.readFile(filePath); + }, + }); + const interruptedDriver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(FileSystem.FileSystem, interruptedFileSystem), + Effect.provide(ServerConfigLayer), + ); + + assert.equal( + (yield* Effect.result( + interruptedDriver.expandWorktreeMaterializationFull( + worktreePath, + "interrupted-before-verification", + ), + ))._tag, + "Failure", + ); + const persisted = yield* driver.verifyWorktreeMaterialization(worktreePath); + assert.equal(persisted.effectiveProfileId, "full"); + const retried = yield* driver.expandWorktreeMaterializationFull( + worktreePath, + "retry-after-verification-interruption", + ); + assert.equal(retried.effectiveProfileId, "full"); + }), + ); + + it.effect("expand-full refuses failed source identity and branch setup states", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const { expectedContractSha256 } = yield* writeMaterializationFixture(cwd); + const pathService = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const worktreePath = pathService.join( + yield* makeTmpDir("git-worktrees-"), + "failed-source-identity", + ); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/failed-source-identity", + materialization: { + requestedProfileId: "governance-review", + expectedContractSha256, + taskId: "OC-IDENTITY", + taskSlug: "failed-source-identity", + taskCardPath: "ops/stef-task/task/stef-task.json", + scopePaths: ["docs/spec.md"], + taskClasses: ["source-task"], + }, + }); + const gitDir = yield* git(worktreePath, ["rev-parse", "--git-dir"]); + const statePath = pathService.join( + pathService.resolve(worktreePath, gitDir), + "worktree-materialization.json", + ); + // @effect-diagnostics-next-line preferSchemaOverJson:off + const state = JSON.parse(yield* fileSystem.readFileString(statePath)); + yield* fileSystem.writeFileString( + statePath, + // @effect-diagnostics-next-line preferSchemaOverJson:off + `${JSON.stringify({ ...state, status: "failed", reason: "materialized-head-mismatch" })}\n`, + ); + + const expansion = yield* Effect.result( + driver.expandWorktreeMaterializationFull(worktreePath, "must-refuse"), + ); + + assert.equal(expansion._tag, "Failure"); + // @effect-diagnostics-next-line preferSchemaOverJson:off + const preserved = JSON.parse(yield* fileSystem.readFileString(statePath)); + assert.equal(preserved.status, "failed"); + assert.equal(preserved.reason, "materialized-head-mismatch"); + }), + ); + + it.effect("expand-full repopulates files when sparse config was already disabled", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const { expectedContractSha256 } = yield* writeMaterializationFixture(cwd); + const pathService = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const worktreePath = pathService.join( + yield* makeTmpDir("git-worktrees-"), + "disabled-sparse-config", + ); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/disabled-sparse-config", + materialization: { + requestedProfileId: "governance-review", + expectedContractSha256, + taskId: "OC-1", + taskSlug: "task", + taskCardPath: "ops/stef-task/task/stef-task.json", + scopePaths: ["docs/spec.md"], + taskClasses: ["source-task"], + }, + }); + assert.equal( + yield* fileSystem.exists(pathService.join(worktreePath, "excluded/large.txt")), + false, + ); + yield* git(worktreePath, ["config", "--worktree", "core.sparseCheckout", "false"]); + assert.equal( + (yield* Effect.result(driver.verifyWorktreeMaterialization(worktreePath)))._tag, + "Failure", + ); + const expanded = yield* driver.expandWorktreeMaterializationFull( + worktreePath, + "recover-disabled-sparse-config", + ); + assert.equal(expanded.effectiveProfileId, "full"); + assert.equal( + yield* fileSystem.exists(pathService.join(worktreePath, "excluded/large.txt")), + true, + ); + }), + ); + + it.effect("falls back to full when an explicit sparse request has no task class", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const { expectedContractSha256 } = yield* writeMaterializationFixture(cwd); + const pathService = yield* Path.Path; + const worktreePath = pathService.join( + yield* makeTmpDir("git-worktrees-"), + "unclassified-fallback", + ); + const driver = yield* GitVcsDriver.GitVcsDriver; + const created = yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/unclassified-fallback", + materialization: { + requestedProfileId: "governance-review", + expectedContractSha256, + taskId: "OC-1", + taskSlug: "task", + taskCardPath: "ops/stef-task/task/stef-task.json", + scopePaths: ["docs/new-file.md"], + }, + }); + assert.equal(created.materialization?.effectiveProfileId, "full"); + assert.equal(created.materialization?.reason, "unsupported:unclassified"); + }), + ); + + it.effect("falls back to full for an unknown task-class token", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const { expectedContractSha256 } = yield* writeMaterializationFixture(cwd); + const pathService = yield* Path.Path; + const driver = yield* GitVcsDriver.GitVcsDriver; + const created = yield* driver.createWorktree({ + cwd, + path: pathService.join(yield* makeTmpDir("git-worktrees-"), "unknown-class"), + refName: initialBranch, + newRefName: "feature/unknown-class", + materialization: { + requestedProfileId: "governance-review", + expectedContractSha256, + taskId: "OC-1", + taskSlug: "task", + taskCardPath: "ops/stef-task/task/stef-task.json", + scopePaths: ["docs/spec.md"], + taskClasses: ["srouce-task"], + }, + }); + assert.equal(created.materialization?.effectiveProfileId, "full"); + assert.equal(created.materialization?.reason, "unsupported:srouce-task"); + }), + ); + + it.effect("falls back to full when the exact task card is absent at the pinned commit", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const { expectedContractSha256 } = yield* writeMaterializationFixture(cwd); + const pathService = yield* Path.Path; + const driver = yield* GitVcsDriver.GitVcsDriver; + const created = yield* driver.createWorktree({ + cwd, + path: pathService.join(yield* makeTmpDir("git-worktrees-"), "missing-task-card"), + refName: initialBranch, + newRefName: "feature/missing-task-card", + materialization: { + requestedProfileId: "governance-review", + expectedContractSha256, + taskId: "OC-1", + taskSlug: "task", + taskCardPath: "ops/stef-task/not-at-base/stef-task.json", + scopePaths: ["docs/spec.md"], + taskClasses: ["source-task"], + }, + }); + assert.equal(created.materialization?.effectiveProfileId, "full"); + assert.equal(created.materialization?.reason, "task-card-missing-at-base"); + }), + ); + + it.effect("carries a hash-bound generated task card into the sparse worktree", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const { expectedContractSha256 } = yield* writeMaterializationFixture(cwd); + const taskCardPath = "ops/stef-task/generated/stef-task.json"; + const taskCardBytes = '{"issue":{"id":"OC-GENERATED"}}\n'; + yield* writeTextFile(cwd, taskCardPath, taskCardBytes); + const pathService = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const inheritedExcludePath = pathService.join( + yield* makeTmpDir("git-global-excludes-"), + "global-excludes", + ); + yield* fileSystem.writeFileString(inheritedExcludePath, "*.local-only\n"); + yield* git(cwd, ["config", "core.excludesFile", inheritedExcludePath]); + const worktreePath = pathService.join( + yield* makeTmpDir("git-worktrees-"), + "generated-task-card", + ); + const driver = yield* GitVcsDriver.GitVcsDriver; + const created = yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/generated-task-card", + materialization: { + requestedProfileId: "governance-review", + expectedContractSha256, + taskId: "OC-GENERATED", + taskSlug: "generated", + taskCardPath, + scopePaths: ["docs/spec.md"], + taskClasses: ["source-task"], + }, + }); + assert.equal(created.materialization?.effectiveProfileId, "governance-review"); + assert.match(created.materialization?.taskCardSha256 ?? "", /^[a-f0-9]{64}$/); + assert.equal( + yield* fileSystem.readFileString(pathService.join(worktreePath, taskCardPath)), + taskCardBytes, + ); + assert.equal( + (yield* driver.verifyWorktreeMaterialization(worktreePath)).effectiveProfileId, + "governance-review", + ); + assert.equal(yield* git(worktreePath, ["status", "--porcelain=v1"]), ""); + assert.equal( + yield* git(cwd, ["config", "--get", "core.excludesFile"]), + inheritedExcludePath, + ); + assert.equal(yield* git(cwd, ["config", "--get", "extensions.worktreeConfig"]), "true"); + const worktreeExcludePath = yield* git(worktreePath, [ + "config", + "--worktree", + "--get", + "core.excludesFile", + ]); + assert.notEqual(worktreeExcludePath, inheritedExcludePath); + assert.equal( + yield* fileSystem.readFileString(worktreeExcludePath), + `*.local-only\n${taskCardPath}\n`, + ); + yield* fileSystem.writeFileString( + pathService.join(worktreePath, taskCardPath), + '{"tampered":true}\n', + ); + assert.equal( + (yield* Effect.result(driver.verifyWorktreeMaterialization(worktreePath)))._tag, + "Failure", + ); + assert.equal(yield* git(worktreePath, ["status", "--porcelain=v1"]), ""); + const tamperedExpansion = yield* Effect.result( + driver.expandWorktreeMaterializationFull(worktreePath, "must-not-rebind-generated-card"), + ); + assert.equal(tamperedExpansion._tag, "Failure"); + assert.equal( + yield* fileSystem.exists(pathService.join(worktreePath, "excluded/large.txt")), + false, + ); + yield* fileSystem.writeFileString( + pathService.join(worktreePath, taskCardPath), + taskCardBytes, + ); + const expanded = yield* driver.expandWorktreeMaterializationFull( + worktreePath, + "restored-generated-card", + ); + assert.equal(expanded.effectiveProfileId, "full"); + assert.equal((yield* driver.verifyWorktreeMaterialization(worktreePath)).mode, "full"); + }), + ); + + it.effect("completes full fallback when generated task-card bytes change during checkout", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const { expectedContractSha256 } = yield* writeMaterializationFixture(cwd); + const taskCardPath = "ops/stef-task/generated-race/stef-task.json"; + const pathService = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const sourcePath = pathService.join(cwd, taskCardPath); + yield* writeTextFile(cwd, taskCardPath, '{"issue":{"id":"OC-RACE"}}\n'); + const gitDir = yield* git(cwd, ["rev-parse", "--git-dir"]); + const hookPath = pathService.join(cwd, gitDir, "hooks", "post-checkout"); + yield* fileSystem.writeFileString( + hookPath, + `#!/bin/sh\nprintf '%s\\n' '{"issue":{"id":"OC-CHANGED"}}' > '${sourcePath}'\n`, + ); + yield* fileSystem.chmod(hookPath, 0o755); + const worktreePath = pathService.join( + yield* makeTmpDir("git-worktrees-"), + "generated-task-card-race", + ); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const created = yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/generated-task-card-race", + materialization: { + requestedProfileId: "governance-review", + expectedContractSha256, + taskId: "OC-RACE", + taskSlug: "generated-race", + taskCardPath, + scopePaths: ["docs/spec.md"], + taskClasses: ["source-task"], + }, + }); + + assert.equal(created.materialization?.effectiveProfileId, "full"); + assert.equal(created.materialization?.reason, "task-card-materialization-failed"); + assert.equal(created.materialization?.taskCardPath, null); + assert.equal(created.materialization?.taskCardSha256, null); + assert.equal(created.materialization?.requiredPaths.includes(taskCardPath), false); + assert.equal(yield* fileSystem.exists(pathService.join(worktreePath, taskCardPath)), false); + assert.equal((yield* driver.verifyWorktreeMaterialization(worktreePath)).mode, "full"); + }), + ); + + it.effect("preserves inherited ignores when full fallback revisits a generated task card", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const { expectedContractSha256 } = yield* writeMaterializationFixture(cwd, { + requiredOutsideCone: true, + }); + const taskCardPath = "ops/stef-task/generated-fallback/stef-task.json"; + yield* writeTextFile(cwd, taskCardPath, '{"issue":{"id":"OC-FALLBACK"}}\n'); + const pathService = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const inheritedExcludePath = pathService.join( + yield* makeTmpDir("git-global-excludes-"), + "global-excludes", + ); + yield* fileSystem.writeFileString(inheritedExcludePath, "*.local-only\n"); + yield* git(cwd, ["config", "core.excludesFile", inheritedExcludePath]); + const worktreePath = pathService.join( + yield* makeTmpDir("git-worktrees-"), + "generated-task-card-fallback", + ); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const created = yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/generated-task-card-fallback", + materialization: { + requestedProfileId: "governance-review", + expectedContractSha256, + taskId: "OC-FALLBACK", + taskSlug: "generated-fallback", + taskCardPath, + scopePaths: ["docs/spec.md"], + taskClasses: ["source-task"], + }, + }); + + assert.equal(created.materialization?.effectiveProfileId, "full"); + assert.equal(created.materialization?.reason, "required-paths-missing"); + const worktreeExcludePath = yield* git(worktreePath, [ + "config", + "--worktree", + "--get", + "core.excludesFile", + ]); + assert.equal( + yield* fileSystem.readFileString(worktreeExcludePath), + `*.local-only\n${taskCardPath}\n`, + ); + }), + ); + + it.effect("preserves the default XDG Git ignore when adding a generated task card", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const { expectedContractSha256 } = yield* writeMaterializationFixture(cwd); + const taskCardPath = "ops/stef-task/generated-xdg/stef-task.json"; + yield* writeTextFile(cwd, taskCardPath, '{"issue":{"id":"OC-XDG"}}\n'); + const pathService = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const xdgRoot = yield* makeTmpDir("git-xdg-config-"); + const defaultExcludePath = pathService.join(xdgRoot, "git", "ignore"); + yield* fileSystem.makeDirectory(pathService.dirname(defaultExcludePath), { + recursive: true, + }); + yield* fileSystem.writeFileString(defaultExcludePath, "*.xdg-only\n"); + const emptyGlobalConfig = pathService.join(xdgRoot, "empty-global-config"); + yield* fileSystem.writeFileString(emptyGlobalConfig, ""); + const previousXdg = process.env.XDG_CONFIG_HOME; + const previousGlobalConfig = process.env.GIT_CONFIG_GLOBAL; + const previousNoSystem = process.env.GIT_CONFIG_NOSYSTEM; + process.env.XDG_CONFIG_HOME = xdgRoot; + process.env.GIT_CONFIG_GLOBAL = emptyGlobalConfig; + process.env.GIT_CONFIG_NOSYSTEM = "1"; + yield* Effect.addFinalizer(() => + Effect.sync(() => { + if (previousXdg === undefined) delete process.env.XDG_CONFIG_HOME; + else process.env.XDG_CONFIG_HOME = previousXdg; + if (previousGlobalConfig === undefined) delete process.env.GIT_CONFIG_GLOBAL; + else process.env.GIT_CONFIG_GLOBAL = previousGlobalConfig; + if (previousNoSystem === undefined) delete process.env.GIT_CONFIG_NOSYSTEM; + else process.env.GIT_CONFIG_NOSYSTEM = previousNoSystem; + }), + ); + const worktreePath = pathService.join( + yield* makeTmpDir("git-worktrees-"), + "generated-task-card-xdg", + ); + const driver = yield* GitVcsDriver.GitVcsDriver; + + yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/generated-task-card-xdg", + materialization: { + requestedProfileId: "governance-review", + expectedContractSha256, + taskId: "OC-XDG", + taskSlug: "generated-xdg", + taskCardPath, + scopePaths: ["docs/spec.md"], + taskClasses: ["source-task"], + }, + }); + + const worktreeExcludePath = yield* git(worktreePath, [ + "config", + "--worktree", + "--get", + "core.excludesFile", + ]); + assert.notEqual(worktreeExcludePath, defaultExcludePath); + assert.equal(yield* fileSystem.readFileString(defaultExcludePath), "*.xdg-only\n"); + assert.equal( + yield* fileSystem.readFileString(worktreeExcludePath), + `*.xdg-only\n${taskCardPath}\n`, + ); + }), + ); + + it.effect("falls back to full when tracked task-card working bytes differ from the base", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const { expectedContractSha256 } = yield* writeMaterializationFixture(cwd); + yield* writeTextFile( + cwd, + "ops/stef-task/task/stef-task.json", + '{"issue":{"id":"OC-DIRTY"}}\n', + ); + const pathService = yield* Path.Path; + const driver = yield* GitVcsDriver.GitVcsDriver; + const created = yield* driver.createWorktree({ + cwd, + path: pathService.join(yield* makeTmpDir("git-worktrees-"), "dirty-task-card"), + refName: initialBranch, + newRefName: "feature/dirty-task-card", + materialization: { + requestedProfileId: "governance-review", + expectedContractSha256, + taskId: "OC-DIRTY", + taskSlug: "dirty", + taskCardPath: "ops/stef-task/task/stef-task.json", + scopePaths: ["docs/spec.md"], + taskClasses: ["source-task"], + }, + }); + assert.equal(created.materialization?.effectiveProfileId, "full"); + assert.equal(created.materialization?.reason, "task-card-source-mismatch"); + }), + ); + + it.effect("hashes contract bytes from the pinned commit instead of working-tree drift", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const { expectedContractSha256 } = yield* writeMaterializationFixture(cwd); + const pathService = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const contractPath = pathService.join(cwd, "config/worktree-materialization-profiles.json"); + const contractRaw = yield* fileSystem.readFileString(contractPath); + yield* fileSystem.writeFileString(contractPath, `${contractRaw} \n`); + const driver = yield* GitVcsDriver.GitVcsDriver; + const created = yield* driver.createWorktree({ + cwd, + path: pathService.join(yield* makeTmpDir("git-worktrees-"), "pinned-contract"), + refName: initialBranch, + newRefName: "feature/pinned-contract", + materialization: { + requestedProfileId: "governance-review", + expectedContractSha256, + taskId: "OC-1", + taskSlug: "task", + taskCardPath: "ops/stef-task/task/stef-task.json", + scopePaths: ["docs/spec.md"], + taskClasses: ["source-task"], + }, + }); + assert.equal(created.materialization?.effectiveProfileId, "governance-review"); + assert.equal(created.materialization?.contractSha256, expectedContractSha256); + }), + ); + + it.effect("verifies committed materialization bytes with checkout EOL conversion", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const { expectedContractSha256, expectedTaskCardSha256 } = + yield* writeMaterializationFixture(cwd); + yield* git(cwd, ["config", "core.autocrlf", "true"]); + const pathService = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const worktreePath = pathService.join( + yield* makeTmpDir("git-worktrees-"), + "autocrlf-materialization", + ); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const created = yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/autocrlf-materialization", + materialization: { + requestedProfileId: "governance-review", + expectedContractSha256, + taskId: "OC-EOL", + taskSlug: "autocrlf", + taskCardPath: "ops/stef-task/task/stef-task.json", + scopePaths: ["docs/spec.md"], + taskClasses: ["source-task"], + }, + }); + + assert.equal(created.materialization?.effectiveProfileId, "governance-review"); + assert.equal(created.materialization?.taskCardSha256, expectedTaskCardSha256); + assert.notEqual( + NodeCrypto.createHash("sha256") + .update( + yield* fileSystem.readFile( + pathService.join(worktreePath, "ops/stef-task/task/stef-task.json"), + ), + ) + .digest("hex"), + expectedTaskCardSha256, + ); + assert.equal((yield* driver.verifyWorktreeMaterialization(worktreePath)).mode, "sparse"); + const expanded = yield* driver.expandWorktreeMaterializationFull( + worktreePath, + "autocrlf-expand", + ); + assert.equal(expanded.taskCardSha256, created.materialization?.taskCardSha256); + assert.equal((yield* driver.verifyWorktreeMaterialization(worktreePath)).mode, "full"); + }), + ); + + it.effect("hashes the exact committed UTF-8 BOM bytes", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const { expectedContractSha256 } = yield* writeMaterializationFixture(cwd, { + utf8Bom: true, + }); + const pathService = yield* Path.Path; + const worktreePath = pathService.join( + yield* makeTmpDir("git-worktrees-"), + "bom-materialization", + ); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const created = yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/bom-materialization", + materialization: { + requestedProfileId: "governance-review", + expectedContractSha256, + taskId: "OC-BOM", + taskSlug: "bom", + taskCardPath: "ops/stef-task/task/stef-task.json", + scopePaths: ["docs/spec.md"], + taskClasses: ["source-task"], + }, + }); + + assert.equal(created.materialization?.effectiveProfileId, "governance-review"); + assert.equal(created.materialization?.contractSha256, expectedContractSha256); + }), + ); + + it.effect("drops unsafe task-card paths from ordinary full state", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const pathService = yield* Path.Path; + const driver = yield* GitVcsDriver.GitVcsDriver; + const created = yield* driver.createWorktree({ + cwd, + path: pathService.join(yield* makeTmpDir("git-worktrees-"), "unsafe-full-card"), + refName: initialBranch, + newRefName: "feature/unsafe-full-card", + materialization: { + requestedProfileId: "full", + expectedContractSha256: "a".repeat(64), + taskId: "OC-FULL", + taskSlug: "unsafe-full", + taskCardPath: "../../outside.json", + scopePaths: ["../../outside.ts"], + taskClasses: ["source-task"], + }, + }); + + assert.equal(created.materialization?.effectiveProfileId, "full"); + assert.equal(created.materialization?.taskCardPath, null); + assert.deepStrictEqual(created.materialization?.scopePaths, []); + }), + ); + + it.effect("carries a valid generated task card through explicit full materialization", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const { expectedContractSha256 } = yield* writeMaterializationFixture(cwd); + const taskCardPath = "ops/stef-task/generated-full/stef-task.json"; + const taskCardBytes = '{"issue":{"id":"OC-FULL-CARD"}}\n'; + yield* writeTextFile(cwd, taskCardPath, taskCardBytes); + const pathService = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const worktreePath = pathService.join( + yield* makeTmpDir("git-worktrees-"), + "generated-full-card", + ); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const created = yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/generated-full-card", + materialization: { + requestedProfileId: "full", + expectedContractSha256, + taskId: "OC-FULL-CARD", + taskSlug: "generated-full", + taskCardPath, + scopePaths: ["docs/spec.md"], + taskClasses: ["source-task"], + }, + }); + + assert.equal(created.materialization?.effectiveProfileId, "full"); + assert.equal(created.materialization?.taskCardGenerated, true); + assert.equal( + yield* fileSystem.readFileString(pathService.join(worktreePath, taskCardPath)), + taskCardBytes, + ); + assert.equal(yield* git(worktreePath, ["status", "--porcelain=v1"]), ""); + }), + ); + + it.effect( + "falls back to full before a generated card can escape through a symlinked parent", + () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const { expectedContractSha256 } = yield* writeMaterializationFixture(cwd); + const pathService = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const outside = yield* makeTmpDir("generated-card-outside-"); + const outsideCard = pathService.join(outside, "stef-task.json"); + const taskCardBytes = '{"issue":{"id":"OC-SYMLINK"}}\n'; + yield* fileSystem.writeFileString(outsideCard, taskCardBytes); + yield* fileSystem.symlink(outside, pathService.join(cwd, "ops", "stef-task", "escape")); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "symlink task card parent"]); + const worktreePath = pathService.join( + yield* makeTmpDir("git-worktrees-"), + "generated-card-parent-symlink", + ); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const created = yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/generated-card-parent-symlink", + materialization: { + requestedProfileId: "governance-review", + expectedContractSha256, + taskId: "OC-SYMLINK", + taskSlug: "generated-card-parent-symlink", + taskCardPath: "ops/stef-task/escape/stef-task.json", + scopePaths: ["docs/spec.md"], + taskClasses: ["source-task"], + }, + }); + + assert.equal(created.materialization?.effectiveProfileId, "full"); + assert.equal(created.materialization?.reason, "task-card-materialization-failed"); + assert.equal(created.materialization?.taskCardPath, null); + assert.equal(created.materialization?.taskCardSha256, null); + assert.equal(yield* fileSystem.readFileString(outsideCard), taskCardBytes); + }), + ); + + it.effect("falls back to full when the contract does not cone-cover task cards", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const { expectedContractSha256 } = yield* writeMaterializationFixture(cwd, { + omitTaskCardCone: true, + }); + const pathService = yield* Path.Path; + const driver = yield* GitVcsDriver.GitVcsDriver; + const created = yield* driver.createWorktree({ + cwd, + path: pathService.join(yield* makeTmpDir("git-worktrees-"), "uncovered-card-root"), + refName: initialBranch, + newRefName: "feature/uncovered-card-root", + materialization: { + requestedProfileId: "governance-review", + expectedContractSha256, + taskId: "OC-CONE", + taskSlug: "uncovered-card-root", + taskCardPath: "ops/stef-task/task/stef-task.json", + scopePaths: ["docs/spec.md"], + taskClasses: ["source-task"], + }, + }); + + assert.equal(created.materialization?.effectiveProfileId, "full"); + assert.equal(created.materialization?.reason, "contract-unavailable"); + }), + ); + + it.effect("forces the schema-valid UI sentinel to full", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const { expectedContractSha256 } = yield* writeMaterializationFixture(cwd); + const pathService = yield* Path.Path; + const worktreePath = pathService.join( + yield* makeTmpDir("git-worktrees-"), + "ui-sentinel-fallback", + ); + const driver = yield* GitVcsDriver.GitVcsDriver; + const created = yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/ui-sentinel-fallback", + materialization: { + requestedProfileId: "governance-review", + expectedContractSha256, + taskId: "invalid-context", + taskSlug: "invalid-context", + taskCardPath: "invalid", + scopePaths: ["invalid"], + taskClasses: ["unclassified"], + }, + }); + assert.equal(created.materialization?.effectiveProfileId, "full"); + assert.equal(created.materialization?.reason, "unsupported:unclassified"); + }), + ); + + it.effect("falls back to full when requested work is outside the selected profile cone", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const { expectedContractSha256 } = yield* writeMaterializationFixture(cwd); + const pathService = yield* Path.Path; + const driver = yield* GitVcsDriver.GitVcsDriver; + + const created = yield* driver.createWorktree({ + cwd, + path: pathService.join(yield* makeTmpDir("git-worktrees-"), "outside-profile-cone"), + refName: initialBranch, + newRefName: "feature/outside-profile-cone", + materialization: { + requestedProfileId: "governance-review", + expectedContractSha256, + taskId: "OC-CONE", + taskSlug: "outside-profile-cone", + taskCardPath: "ops/stef-task/task/stef-task.json", + scopePaths: ["strategies/source.ts"], + taskClasses: ["source-task"], + }, + }); + + assert.equal(created.materialization?.effectiveProfileId, "full"); + assert.equal(created.materialization?.reason, "multi-domain"); + }), + ); + + it.effect("falls back to full on a mismatched expected contract hash", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* writeMaterializationFixture(cwd); + const pathService = yield* Path.Path; + const worktreePath = pathService.join( + yield* makeTmpDir("git-worktrees-"), + "hash-mismatch-fallback", + ); + const driver = yield* GitVcsDriver.GitVcsDriver; + const created = yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/hash-mismatch-fallback", + materialization: { + requestedProfileId: "governance-review", + expectedContractSha256: "0".repeat(64), + taskId: "OC-1", + taskSlug: "task", + taskCardPath: "ops/stef-task/task/stef-task.json", + scopePaths: ["docs/spec.md"], + taskClasses: ["source-task"], + }, + }); + assert.equal(created.materialization?.effectiveProfileId, "full"); + assert.equal(created.materialization?.reason, "hash-mismatch"); + }), + ); + + it.effect("persists a blocking marker when full fallback cannot satisfy required paths", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const { expectedContractSha256 } = yield* writeMaterializationFixture(cwd, { + requiredMissingEverywhere: true, + }); + const pathService = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const worktreePath = pathService.join( + yield* makeTmpDir("git-worktrees-"), + "terminal-fallback-failure", + ); + const driver = yield* GitVcsDriver.GitVcsDriver; + const result = yield* Effect.result( + driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/terminal-fallback-failure", + materialization: { + requestedProfileId: "governance-review", + expectedContractSha256, + taskId: "OC-1", + taskSlug: "task", + taskCardPath: "ops/stef-task/task/stef-task.json", + scopePaths: ["docs/spec.md"], + taskClasses: ["source-task"], + }, + }), + ); + assert.equal(result._tag, "Failure"); + const gitDir = yield* git(worktreePath, ["rev-parse", "--git-dir"]); + const statePath = pathService.join( + pathService.resolve(worktreePath, gitDir), + "worktree-materialization.json", + ); + // @effect-diagnostics-next-line preferSchemaOverJson:off + const failedState = JSON.parse(yield* fileSystem.readFileString(statePath)); + assert.equal(failedState.status, "failed"); + assert.equal( + failedState.reason, + "required-paths-missing:full-fallback-required-paths-missing", + ); + const verification = yield* Effect.result( + driver.verifyWorktreeMaterialization(worktreePath), + ); + assert.equal(verification._tag, "Failure"); + }), + ); + + it.effect("falls back to full when the repository contract has an invalid shared path", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const { expectedContractSha256 } = yield* writeMaterializationFixture(cwd, { + invalidSharedPath: true, + }); + const pathService = yield* Path.Path; + const worktreePath = pathService.join( + yield* makeTmpDir("git-worktrees-"), + "malformed-contract-fallback", + ); + const driver = yield* GitVcsDriver.GitVcsDriver; + const created = yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/malformed-contract-fallback", + materialization: { + requestedProfileId: "governance-review", + expectedContractSha256, + taskId: "OC-1", + taskSlug: "task", + taskCardPath: "ops/stef-task/task/stef-task.json", + scopePaths: ["docs/spec.md"], + taskClasses: ["source-task"], + }, + }); + assert.equal(created.materialization?.effectiveProfileId, "full"); + assert.equal(created.materialization?.reason, "contract-unavailable"); + }), + ); + + it.effect("falls back to full for noncanonical or non-string contract paths", () => + Effect.gen(function* () { + const pathService = yield* Path.Path; + const driver = yield* GitVcsDriver.GitVcsDriver; + for (const [index, invalidSharedValue] of ["docs/../x", 123].entries()) { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const { expectedContractSha256 } = yield* writeMaterializationFixture(cwd, { + invalidSharedValue, + }); + const created = yield* driver.createWorktree({ + cwd, + path: pathService.join( + yield* makeTmpDir("git-worktrees-"), + `invalid-contract-path-${index}`, + ), + refName: initialBranch, + newRefName: `feature/invalid-contract-path-${index}`, + materialization: { + requestedProfileId: "governance-review", + expectedContractSha256, + taskId: "OC-1", + taskSlug: "task", + taskCardPath: "ops/stef-task/task/stef-task.json", + scopePaths: ["docs/spec.md"], + taskClasses: ["source-task"], + }, + }); + assert.equal(created.materialization?.effectiveProfileId, "full"); + assert.equal(created.materialization?.reason, "contract-unavailable"); + } + }), + ); + + it.effect("keeps future scope paths in the cone when the exact task card is pinned", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const { expectedContractSha256 } = yield* writeMaterializationFixture(cwd); + const pathService = yield* Path.Path; + const worktreePath = pathService.join(yield* makeTmpDir("git-worktrees-"), "future-paths"); + const driver = yield* GitVcsDriver.GitVcsDriver; + const created = yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/future-paths", + materialization: { + requestedProfileId: "governance-review", + expectedContractSha256, + taskId: "OC-FUTURE", + taskSlug: "future-task", + taskCardPath: "ops/stef-task/task/stef-task.json", + scopePaths: ["docs/future-file.md"], + taskClasses: ["source-task"], + }, + }); + assert.equal(created.materialization?.effectiveProfileId, "governance-review"); + assert.equal(created.materialization?.requiredPaths.includes("docs/future-file.md"), false); + assert.equal( + created.materialization?.declaredDynamicPaths?.includes("docs/future-file.md"), + true, + ); + }), + ); + + it.effect("materialization canary preserves full-index identity for every sparse profile", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const { expectedContractSha256 } = yield* writeMaterializationFixture(cwd); + const pathService = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const driver = yield* GitVcsDriver.GitVcsDriver; + const profiles = [ + ["governance-review", "docs/spec.md"], + ["brandt-source", "brandt-pattern-recognition/source.ts"], + ["trading-strategy-source", "strategies/source.ts"], + ] as const; + + for (const [index, [profileId, scopePath]] of profiles.entries()) { + const worktreesRoot = yield* makeTmpDir(`git-canary-${index}-`); + const fullPath = pathService.join(worktreesRoot, "full"); + const sparsePath = pathService.join(worktreesRoot, "sparse"); + const full = yield* driver.createWorktree({ + cwd, + path: fullPath, + refName: initialBranch, + newRefName: `canary/${index}/full`, + }); + const sparse = yield* driver.createWorktree({ + cwd, + path: sparsePath, + refName: initialBranch, + newRefName: `canary/${index}/sparse`, + materialization: { + requestedProfileId: profileId, + expectedContractSha256, + taskId: "OC-1", + taskSlug: "task", + taskCardPath: "ops/stef-task/task/stef-task.json", + scopePaths: [scopePath], + taskClasses: ["source-task"], + }, + }); + if (!sparse.materialization) return assert.fail("expected sparse canary identity"); + assert.equal(sparse.materialization.effectiveProfileId, profileId); + assert.equal( + yield* git(fullPath, ["rev-parse", "HEAD^{tree}"]), + yield* git(sparsePath, ["rev-parse", "HEAD^{tree}"]), + ); + assert.equal( + worktreeMaterializationSha256ForTest(yield* git(fullPath, ["ls-files", "--stage"])), + worktreeMaterializationSha256ForTest(yield* git(sparsePath, ["ls-files", "--stage"])), + ); + assert.equal( + yield* git(fullPath, ["ls-tree", "-r", "HEAD"]), + yield* git(sparsePath, ["ls-tree", "-r", "HEAD"]), + ); + assert.equal(yield* git(sparsePath, ["status", "--porcelain=v1"]), ""); + const fullBytes = yield* logicalWorkingTreeBytes(fullPath); + const sparseBytes = yield* logicalWorkingTreeBytes(sparsePath); + assert.ok(sparseBytes <= fullBytes * 0.5, `${profileId}: ${sparseBytes}/${fullBytes}`); + + if (index === 0) { + for (const hiddenPath of ["ops/stef-task/task/stef-task.json", scopePath]) { + yield* fileSystem.remove(pathService.join(sparsePath, hiddenPath)); + assert.equal( + (yield* Effect.result(driver.verifyWorktreeMaterialization(sparsePath)))._tag, + "Failure", + ); + yield* git(sparsePath, ["checkout", "--", hiddenPath]); + assert.equal( + (yield* driver.verifyWorktreeMaterialization(sparsePath)).effectiveProfileId, + profileId, + ); + } + } + + const expanded = yield* driver.expandWorktreeMaterializationFull( + sparsePath, + "canary-expand-full", + ); + assert.equal(expanded.effectiveProfileId, "full"); + assert.equal( + yield* fileSystem.exists(pathService.join(sparsePath, "excluded/large.txt")), + true, + ); + assert.equal(full.materialization, undefined); + assert.equal( + (yield* driver.verifyWorktreeMaterialization(fullPath)).effectiveProfileId, + "full", + ); + } + }), + ); + + it.effect.skipIf(process.env.T3_MATERIALIZATION_CANARY_REPO === undefined)( + "materialization real-repository canary preserves every frozen profile", + () => + Effect.gen(function* () { + const sourceRepo = process.env.T3_MATERIALIZATION_CANARY_REPO!; + const cloneRoot = yield* makeTmpDir("git-real-canary-"); + const pathService = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const repo = pathService.join(cloneRoot, "repo"); + yield* git(cloneRoot, [ + "clone", + "--quiet", + "--shared", + "--no-checkout", + sourceRepo, + repo, + ]); + const pinnedCommit = yield* git(repo, ["rev-parse", "HEAD^{commit}"]); + yield* git(repo, [ + "checkout", + pinnedCommit, + "--", + "config/worktree-materialization-profiles.json", + ]); + yield* git(repo, ["read-tree", pinnedCommit]); + const driver = yield* GitVcsDriver.GitVcsDriver; + const contractResult = yield* driver.execute({ + operation: "GitVcsDriver.test.realCanaryContract", + cwd: repo, + args: ["show", `${pinnedCommit}:config/worktree-materialization-profiles.json`], + maxOutputBytes: 1024 * 1024, + }); + const contractRaw = Buffer.from(contractResult.stdout, "utf8"); + const expectedContractSha256 = NodeCrypto.createHash("sha256") + .update(contractRaw) + .digest("hex"); + const taskCardPath = (yield* git(repo, [ + "ls-tree", + "-r", + "--name-only", + pinnedCommit, + "ops/stef-task", + ])) + .split(/\r?\n/) + .find((candidate) => candidate.endsWith("/stef-task.json")); + if (!taskCardPath) return assert.fail("real repository needs a tracked task card"); + const fullTree = yield* git(repo, ["rev-parse", `${pinnedCommit}^{tree}`]); + const fullIndexHash = worktreeMaterializationSha256ForTest( + yield* git(repo, ["ls-files", "--stage"]), + ); + const fullModesHash = worktreeMaterializationSha256ForTest( + yield* git(repo, ["ls-tree", "-r", pinnedCommit]), + ); + const fullBytes = (yield* git(repo, ["ls-tree", "-r", "-l", pinnedCommit])) + .split(/\r?\n/) + .reduce((total, line) => { + const match = line.match(/^\d+\s+\w+\s+[a-f0-9]+\s+(\d+)\t/); + return total + Number(match?.[1] ?? 0); + }, 0); + const allProfiles = [ + ["governance-review", "builds/task-queue/lib/build-state.js"], + ["brandt-source", "brandt-pattern-recognition/runtime-config.js"], + ["trading-strategy-source", "cot-data/build-latest-from-index.js"], + ] as const; + const requestedProfile = process.env.T3_MATERIALIZATION_CANARY_PROFILE; + const profiles = requestedProfile + ? allProfiles.filter(([profileId]) => profileId === requestedProfile) + : allProfiles; + if (requestedProfile && profiles.length !== 1) { + return assert.fail(`unknown real canary profile: ${requestedProfile}`); + } + + for (const [index, [profileId, scopePath]] of profiles.entries()) { + const fullPath = pathService.join(cloneRoot, `full-${index}`); + const sparsePath = pathService.join(cloneRoot, `sparse-${index}`); + const full = yield* driver.createWorktree({ + cwd: repo, + path: fullPath, + refName: pinnedCommit, + newRefName: `real-canary/${index}/full`, + }); + const created = yield* driver.createWorktree({ + cwd: repo, + path: sparsePath, + refName: pinnedCommit, + newRefName: `real-canary/${index}/sparse`, + materialization: { + requestedProfileId: profileId, + expectedContractSha256, + taskId: `OC-REAL-CANARY-${index + 1}`, + taskSlug: `${profileId}-canary`, + taskCardPath, + scopePaths: [scopePath], + taskClasses: ["source-task"], + }, + }); + assert.equal(full.worktree.path, fullPath); + assert.equal(created.materialization?.effectiveProfileId, profileId); + const fullTwinTree = yield* git(fullPath, ["rev-parse", "HEAD^{tree}"]); + const sparseTree = yield* git(sparsePath, ["rev-parse", "HEAD^{tree}"]); + assert.equal(fullTwinTree, fullTree); + assert.equal(sparseTree, fullTwinTree); + const fullTwinIndexHash = worktreeMaterializationSha256ForTest( + yield* git(fullPath, ["ls-files", "--stage"]), + ); + const sparseIndexHash = worktreeMaterializationSha256ForTest( + yield* git(sparsePath, ["ls-files", "--stage"]), + ); + assert.equal(sparseIndexHash, fullTwinIndexHash); + assert.equal(fullTwinIndexHash, fullIndexHash); + const fullTwinModesHash = worktreeMaterializationSha256ForTest( + yield* git(fullPath, ["ls-tree", "-r", "HEAD"]), + ); + const sparseModesHash = worktreeMaterializationSha256ForTest( + yield* git(sparsePath, ["ls-tree", "-r", "HEAD"]), + ); + assert.equal(sparseModesHash, fullTwinModesHash); + assert.equal(fullTwinModesHash, fullModesHash); + assert.equal(yield* git(sparsePath, ["status", "--porcelain=v1"]), ""); + assert.equal(yield* git(fullPath, ["status", "--porcelain=v1"]), ""); + const fullTwinBytes = yield* logicalWorkingTreeBytes(fullPath); + const sparseBytes = yield* logicalWorkingTreeBytes(sparsePath); + const reductionPct = Number( + (((fullTwinBytes - sparseBytes) / fullTwinBytes) * 100).toFixed(2), + ); + assert.ok(reductionPct >= 50, `${profileId}: ${sparseBytes}/${fullTwinBytes}`); + const fullVerifier = runRealCanaryVerifier(fullPath, profileId); + const sparseVerifier = runRealCanaryVerifier(sparsePath, profileId); + assert.equal( + fullVerifier.status, + 0, + fullVerifier.error || fullVerifier.stderr || fullVerifier.stdout, + ); + assert.deepStrictEqual(sparseVerifier, fullVerifier); + const verifierOutputSha256 = worktreeMaterializationSha256ForTest( + [ + fullVerifier.status, + fullVerifier.signal, + fullVerifier.error, + fullVerifier.stdout, + fullVerifier.stderr, + ].join("\0"), + ); + const sparseReviewPackManifestSha256 = realCanaryReviewPackManifest( + sparsePath, + scopePath, + ); + const fullReviewPackManifestSha256 = realCanaryReviewPackManifest(fullPath, scopePath); + assert.equal(sparseReviewPackManifestSha256, fullReviewPackManifestSha256); + const negativeControls: Array<{ + readonly path: string; + readonly rejected: boolean; + readonly restored: boolean; + }> = []; + for (const hiddenPath of [taskCardPath, scopePath]) { + yield* fileSystem.remove(pathService.join(sparsePath, hiddenPath)); + const rejected = + (yield* Effect.result(driver.verifyWorktreeMaterialization(sparsePath)))._tag === + "Failure"; + assert.equal(rejected, true); + yield* git(sparsePath, ["checkout", "--", hiddenPath]); + const restored = + (yield* driver.verifyWorktreeMaterialization(sparsePath)).effectiveProfileId === + profileId; + assert.equal(restored, true); + negativeControls.push({ path: hiddenPath, rejected, restored }); + } + const expanded = yield* driver.expandWorktreeMaterializationFull( + sparsePath, + "real-canary-expand-full", + ); + assert.equal(expanded.effectiveProfileId, "full"); + assert.equal(yield* logicalWorkingTreeBytes(sparsePath), fullTwinBytes); + assert.equal(yield* git(sparsePath, ["status", "--porcelain=v1"]), ""); + + const resultPath = process.env.T3_MATERIALIZATION_CANARY_RESULT_PATH; + if (resultPath) { + const t3SourceHead = yield* git(process.cwd(), ["rev-parse", "HEAD^{commit}"]); + // @effect-diagnostics-next-line preferSchemaOverJson:off + const resultJson = JSON.stringify( + { + schemaVersion: "t3.materialization-real-canary-result.v1", + ok: true, + t3SourceHead, + clawdSourceHead: pinnedCommit, + profileId, + pinnedCommit, + fullTree, + fullTwinTree, + sparseTree, + fullIndexHash, + fullTwinIndexHash, + sparseIndexHash, + fullModesHash, + fullTwinModesHash, + sparseModesHash, + fullTwinBytes, + sparseBytes, + reductionPct, + requiredPaths: created.materialization?.requiredPaths ?? [], + verifierPaths: realCanaryVerifierPaths(profileId), + verifierOutputSha256, + verifierParity: true, + reviewPackManifestSha256: fullReviewPackManifestSha256, + reviewPackParity: true, + negativeControls, + expansionStatus: expanded.mode, + expansionReason: expanded.reason, + cleanAfterExpansion: true, + }, + null, + 2, + ); + yield* fileSystem.writeFileString(resultPath, `${resultJson}\n`); + } + } + }), + 300_000, + ); + it.effect("preserves newline characters in worktree paths when listing refs", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 23f8e6f2a995..b53cfaa8351e 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -1,4 +1,6 @@ import * as Arr from "effect/Array"; +import * as NodeCrypto from "node:crypto"; +import * as NodeOS from "node:os"; import * as Cache from "effect/Cache"; import * as Data from "effect/Data"; import * as Crypto from "effect/Crypto"; @@ -25,6 +27,10 @@ import { type ReviewDiffPreviewInput, type ReviewDiffPreviewSource, type VcsRef, + FULL_WORKTREE_MATERIALIZATION_STATE, + type VcsWorktreeMaterializationRequest, + VcsWorktreeMaterializationState as VcsWorktreeMaterializationStateSchema, + type VcsWorktreeMaterializationState, } from "@t3tools/contracts"; import { dedupeRemoteBranchesWithLocalMatches, normalizeGitRemoteUrl } from "@t3tools/shared/git"; import { compactTraceAttributes } from "@t3tools/shared/observability"; @@ -74,6 +80,17 @@ const LIST_REFS_REFRESH_COALESCE_TTL = Duration.seconds(5); const LIST_REFS_REFRESH_FAILURE_COOLDOWN = Duration.seconds(30); const STATUS_DEFAULT_BRANCH_CACHE_TTL = Duration.minutes(5); const STATUS_ORIGIN_EXISTS_CACHE_TTL = Duration.minutes(5); +const WORKTREE_MATERIALIZATION_CONTRACT_PATH = "config/worktree-materialization-profiles.json"; +const WORKTREE_MATERIALIZATION_CONTRACT_SCHEMA = "clawd.worktree-materialization-profiles.v1"; +const WORKTREE_MATERIALIZATION_TASK_CARD_ROOT = "ops/stef-task"; +const SUPPORTED_SPARSE_TASK_CLASSES = new Set(["source-task", "task-evidence"]); +const WORKTREE_MATERIALIZATION_STATE_SCHEMA = "clawd.worktree-materialization-state.v1"; +const WORKTREE_MATERIALIZATION_STATE_FILE = "worktree-materialization.json"; +const WORKTREE_MATERIALIZATION_RECOVERY = + "Preserve changes in an ordinary named commit or operator-approved external copy, reach a clean state without automated stash/reset/clean/removal, run expand-full, then reverify."; +const decodeWorktreeMaterializationState = Schema.decodeUnknownSync( + VcsWorktreeMaterializationStateSchema, +); const STATUS_UPSTREAM_REFRESH_ENV = Object.freeze({ GCM_INTERACTIVE: "never", GIT_ASKPASS: "", @@ -108,6 +125,234 @@ const NON_REPOSITORY_REMOTE_STATUS_DETAILS = Object.freeze; + readonly sharedRequiredPaths: ReadonlyArray; + readonly unsupportedTaskClasses: ReadonlyArray; + readonly profiles: ReadonlyArray<{ + readonly id: string; + readonly mode: "full" | "sparse"; + readonly conePaths: ReadonlyArray; + readonly requiredPaths: ReadonlyArray; + }>; +} + +function worktreeMaterializationSha256(bytes: Uint8Array | string): string { + return NodeCrypto.createHash("sha256").update(bytes).digest("hex"); +} + +function normalizeMaterializationRepoPath(value: unknown): string | null { + const raw = String(value ?? "") + .trim() + .replaceAll("\\", "/"); + if (raw.length === 0 || raw.startsWith("/") || raw.includes("\0")) return null; + const parts = raw.split("/"); + if ( + parts.some((part) => part.length === 0 || part === "." || part === ".." || part.startsWith("-")) + ) + return null; + return parts.join("/"); +} + +function validMaterializationSegment(value: unknown): string | null { + const text = String(value ?? "").trim(); + return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(text) ? text : null; +} + +function validMaterializationContractPath(value: unknown): value is string { + return typeof value === "string" && normalizeMaterializationRepoPath(value) === value; +} + +function uniqueMaterializationPaths(values: ReadonlyArray): Array { + return [...new Set(values)].toSorted((left, right) => (left < right ? -1 : left > right ? 1 : 0)); +} + +function minimalMaterializationConePaths(values: ReadonlyArray): Array { + const sorted = uniqueMaterializationPaths(values); + return sorted.filter( + (candidate) => + !sorted.some((other) => other !== candidate && candidate.startsWith(`${other}/`)), + ); +} + +function fullMaterializationState( + request: VcsWorktreeMaterializationRequest | undefined, + reason: string, + contractSha256: string | null = null, +): VcsWorktreeMaterializationState { + const taskCardPath = normalizeMaterializationRepoPath(request?.taskCardPath); + const normalizedScopePaths = request?.scopePaths.map(normalizeMaterializationRepoPath) ?? []; + return { + ...FULL_WORKTREE_MATERIALIZATION_STATE, + conePaths: [], + requiredPaths: [], + status: "ready", + requestedProfileId: request?.requestedProfileId ?? "full", + reason, + expectedContractSha256: request?.expectedContractSha256 ?? null, + contractSha256, + taskId: validMaterializationSegment(request?.taskId), + taskSlug: validMaterializationSegment(request?.taskSlug), + taskCardPath, + scopePaths: normalizedScopePaths.every((candidate) => candidate !== null) + ? (normalizedScopePaths as Array) + : [], + taskClasses: request?.taskClasses ? [...request.taskClasses] : [], + includeResearchTask: request?.includeResearchTask === true, + }; +} + +function parseWorktreeMaterializationContract(raw: Uint8Array): WorktreeMaterializationContract { + const parsed = JSON.parse( + new TextDecoder().decode(raw), + ) as Partial; + if ( + parsed.schemaVersion !== WORKTREE_MATERIALIZATION_CONTRACT_SCHEMA || + !parsed.taskContext || + !Array.isArray(parsed.sharedConePaths) || + !Array.isArray(parsed.sharedRequiredPaths) || + !Array.isArray(parsed.unsupportedTaskClasses) || + !Array.isArray(parsed.profiles) + ) { + throw new Error("invalid worktree materialization contract"); + } + const contract = parsed as WorktreeMaterializationContract; + const profileIds = contract.profiles.map((profile) => profile.id); + const taskCardRootCovered = contract.sharedConePaths.some( + (conePath) => + contract.taskContext.taskCardRoot === conePath || + contract.taskContext.taskCardRoot.startsWith(`${conePath}/`), + ); + const declaredPaths = [ + contract.taskContext.taskCardRoot, + contract.taskContext.buildStateRoot, + contract.taskContext.researchRoot, + ...contract.sharedConePaths, + ...contract.sharedRequiredPaths, + ...contract.profiles.flatMap((profile) => [...profile.conePaths, ...profile.requiredPaths]), + ]; + if ( + JSON.stringify(profileIds) !== + JSON.stringify(["full", "governance-review", "brandt-source", "trading-strategy-source"]) || + contract.profiles[0]?.id !== "full" || + contract.profiles[0]?.mode !== "full" || + !taskCardRootCovered || + contract.profiles.some( + (profile) => + !validMaterializationSegment(profile.id) || + !["full", "sparse"].includes(profile.mode) || + !Array.isArray(profile.conePaths) || + !Array.isArray(profile.requiredPaths), + ) || + declaredPaths.some((candidate) => !validMaterializationContractPath(candidate)) + ) { + throw new Error("invalid worktree materialization profile declaration"); + } + return contract; +} + +function resolveWorktreeMaterialization( + request: VcsWorktreeMaterializationRequest | undefined, + contract: WorktreeMaterializationContract | null, + contractSha256: string | null, +): VcsWorktreeMaterializationState { + if (!request) return fullMaterializationState(undefined, "default-full"); + if (!contract || !contractSha256) { + return fullMaterializationState(request, "contract-unavailable"); + } + if (request.requestedProfileId === "full") { + return fullMaterializationState(request, "explicit-full", contractSha256); + } + if (request.expectedContractSha256 !== contractSha256) { + return fullMaterializationState(request, "hash-mismatch", contractSha256); + } + const profile = contract.profiles.find( + (candidate) => candidate.id === request.requestedProfileId, + ); + if (!profile || profile.mode !== "sparse") { + return fullMaterializationState(request, "unknown-profile", contractSha256); + } + const taskId = validMaterializationSegment(request.taskId); + const taskSlug = validMaterializationSegment(request.taskSlug); + if (!taskId || !taskSlug) { + return fullMaterializationState(request, "missing-task-context", contractSha256); + } + const taskClasses = (request.taskClasses ?? []) + .map((value) => value.trim()) + .filter((value) => value.length > 0); + if (taskClasses.length === 0) { + return fullMaterializationState(request, "unsupported:unclassified", contractSha256); + } + const blockedClass = taskClasses.find((value) => contract.unsupportedTaskClasses.includes(value)); + if (blockedClass) { + return fullMaterializationState(request, `unsupported:${blockedClass}`, contractSha256); + } + const unknownClass = taskClasses.find((value) => !SUPPORTED_SPARSE_TASK_CLASSES.has(value)); + if (unknownClass) { + return fullMaterializationState(request, `unsupported:${unknownClass}`, contractSha256); + } + const dynamicConePaths = [ + `${contract.taskContext.buildStateRoot}/${taskId}`, + ...(request.includeResearchTask ? [`${contract.taskContext.researchRoot}/${taskSlug}`] : []), + ]; + const conePaths = minimalMaterializationConePaths([ + ...contract.sharedConePaths, + ...profile.conePaths, + ...dynamicConePaths, + ]); + const normalizedScopePaths = request.scopePaths.map(normalizeMaterializationRepoPath); + if (normalizedScopePaths.some((scopePath) => scopePath === null)) { + return fullMaterializationState(request, "ambiguous", contractSha256); + } + const scopePaths = normalizedScopePaths as Array; + const scopeIsCovered = (scopePath: string) => + conePaths.some((conePath) => scopePath === conePath || scopePath.startsWith(`${conePath}/`)); + if (scopePaths.length === 0 || scopePaths.some((scopePath) => !scopeIsCovered(scopePath))) { + return fullMaterializationState(request, "multi-domain", contractSha256); + } + const taskCardPath = normalizeMaterializationRepoPath(request.taskCardPath); + if (!taskCardPath || !taskCardPath.startsWith(`${contract.taskContext.taskCardRoot}/`)) { + return fullMaterializationState(request, "task-card-outside-repository", contractSha256); + } + const declaredDynamicPaths = uniqueMaterializationPaths([ + ...scopePaths, + taskCardPath, + `${contract.taskContext.buildStateRoot}/${taskId}`, + ]); + const requiredPaths = uniqueMaterializationPaths([ + ...contract.sharedRequiredPaths, + ...profile.requiredPaths, + ]); + const manifestSha256 = worktreeMaterializationSha256( + JSON.stringify({ profileId: profile.id, conePaths, requiredPaths }), + ); + return { + status: "ready", + requestedProfileId: request.requestedProfileId, + effectiveProfileId: profile.id, + mode: "sparse", + reason: null, + expectedContractSha256: request.expectedContractSha256, + contractSha256, + manifestSha256, + conePaths, + requiredPaths, + taskId, + taskSlug, + taskCardPath, + scopePaths: scopePaths as Array, + taskClasses, + includeResearchTask: request.includeResearchTask === true, + declaredDynamicPaths, + }; +} + type TraceTailState = { processedChars: number; remainder: string; @@ -957,6 +1202,864 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ), ); + const materializationError = (operation: string, cwd: string, detail: string, cause?: unknown) => + new GitCommandError({ + ...gitCommandContext({ operation, cwd, args: [] }), + detail, + ...(cause === undefined ? {} : { cause }), + }); + + const readMaterializationBlobAtCommit = Effect.fn("readMaterializationBlobAtCommit")(function* ( + repoRoot: string, + pinnedCommit: string, + relativePath: string, + operation: string, + ) { + const commandInput = { + operation, + cwd: repoRoot, + args: ["cat-file", "blob", `${pinnedCommit}:${relativePath}`], + } as const; + const timed = yield* Effect.gen(function* () { + const child = yield* commandSpawner + .spawn(ChildProcess.make("git", commandInput.args, { cwd: repoRoot })) + .pipe( + Effect.mapError((cause) => + materializationError( + operation, + repoRoot, + "Failed to read committed blob bytes.", + cause, + ), + ), + ); + const chunks: Array = []; + let byteLength = 0; + const [, , exitCode] = yield* Effect.all( + [ + Stream.runForEach(child.stdout, (chunk) => { + byteLength += chunk.byteLength; + if (byteLength > DEFAULT_MAX_OUTPUT_BYTES) { + return Effect.fail( + materializationError( + operation, + repoRoot, + "Committed blob exceeded the materialization byte limit.", + ), + ); + } + chunks.push(chunk); + return Effect.void; + }).pipe( + Effect.mapError((cause) => + Schema.is(GitCommandError)(cause) + ? cause + : materializationError( + operation, + repoRoot, + "Failed to collect committed blob bytes.", + cause, + ), + ), + ), + collectOutput(commandInput, child.stderr, DEFAULT_MAX_OUTPUT_BYTES, false, undefined), + child.exitCode.pipe( + Effect.mapError((cause) => + materializationError( + operation, + repoRoot, + "Failed to read committed blob exit code.", + cause, + ), + ), + ), + ], + { concurrency: "unbounded" }, + ); + if (exitCode !== 0) return null; + const bytes = new Uint8Array(byteLength); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return bytes; + }).pipe(Effect.timeoutOption(DEFAULT_TIMEOUT_MS), Effect.scoped); + if (Option.isNone(timed)) { + return yield* materializationError( + operation, + repoRoot, + "Timed out while reading committed blob bytes.", + ); + } + return timed.value; + }); + + const readMaterializationContract = Effect.fn("readMaterializationContract")(function* ( + repoRoot: string, + pinnedCommit: string, + ) { + const shownRead = yield* Effect.exit( + readMaterializationBlobAtCommit( + repoRoot, + pinnedCommit, + WORKTREE_MATERIALIZATION_CONTRACT_PATH, + "GitVcsDriver.materialization.readContractAtBase", + ), + ); + if (Exit.isFailure(shownRead) || !shownRead.value) return null; + const raw = shownRead.value; + const loaded = yield* Effect.exit( + Effect.try({ + try: () => ({ + contract: parseWorktreeMaterializationContract(raw), + sha256: worktreeMaterializationSha256(raw), + }), + catch: (cause) => + materializationError( + "GitVcsDriver.materialization.readContract", + repoRoot, + "Worktree materialization contract is unreadable.", + cause, + ), + }), + ); + return Exit.isSuccess(loaded) ? loaded.value : null; + }); + + const pinMaterializationRequiredPaths = Effect.fn("pinMaterializationRequiredPaths")(function* ( + repoRoot: string, + pinnedCommit: string, + state: VcsWorktreeMaterializationState, + ) { + const taskCardPath = state.taskCardPath; + const fullWithoutTaskCard = (reason: string | null) => ({ + ...state, + effectiveProfileId: "full", + mode: "full" as const, + reason, + manifestSha256: null, + conePaths: [], + requiredPaths: [], + taskCardPath: null, + taskCardSha256: null, + taskCardGenerated: false, + baseSha: pinnedCommit, + }); + if (!taskCardPath) { + return fullWithoutTaskCard(state.reason); + } + if ( + taskCardPath !== WORKTREE_MATERIALIZATION_TASK_CARD_ROOT && + !taskCardPath.startsWith(`${WORKTREE_MATERIALIZATION_TASK_CARD_ROOT}/`) + ) { + return fullWithoutTaskCard(state.mode === "sparse" ? "task-card-outside-root" : state.reason); + } + const taskCardAtBase = yield* executeGit( + "GitVcsDriver.materialization.taskCardAtBase", + repoRoot, + ["cat-file", "-e", `${pinnedCommit}:${taskCardPath}`], + { allowNonZeroExit: true }, + ); + let taskCardBytes: Uint8Array | null = null; + let sourceTaskCardBytes: Uint8Array | null = null; + const sourcePath = path.join(repoRoot, taskCardPath); + const sourceLink = yield* Effect.exit(fileSystem.readLink(sourcePath)); + if (Exit.isFailure(sourceLink)) { + const source = yield* Effect.exit(fileSystem.readFile(sourcePath)); + if (Exit.isSuccess(source)) sourceTaskCardBytes = source.value; + } + if (taskCardAtBase.exitCode === 0) { + const taskCardRead = yield* Effect.exit( + readMaterializationBlobAtCommit( + repoRoot, + pinnedCommit, + taskCardPath, + "GitVcsDriver.materialization.taskCardBytesAtBase", + ), + ); + if (Exit.isFailure(taskCardRead) || !taskCardRead.value) { + return fullWithoutTaskCard( + state.mode === "sparse" ? "task-card-unreadable-at-base" : state.reason, + ); + } + taskCardBytes = taskCardRead.value; + if ( + taskCardBytes && + sourceTaskCardBytes && + worktreeMaterializationSha256(taskCardBytes) !== + worktreeMaterializationSha256(sourceTaskCardBytes) + ) { + const requiredPaths = uniqueMaterializationPaths([taskCardPath]); + if (state.mode !== "sparse") { + return { + ...state, + requiredPaths: uniqueMaterializationPaths([...state.requiredPaths, taskCardPath]), + taskCardSha256: worktreeMaterializationSha256(taskCardBytes), + taskCardGenerated: false, + baseSha: pinnedCommit, + }; + } + return { + ...state, + effectiveProfileId: "full", + mode: "full" as const, + reason: "task-card-source-mismatch", + manifestSha256: null, + conePaths: [], + requiredPaths, + taskCardSha256: worktreeMaterializationSha256(taskCardBytes), + taskCardGenerated: false, + baseSha: pinnedCommit, + }; + } + } else { + taskCardBytes = sourceTaskCardBytes; + } + if (!taskCardBytes) { + return fullWithoutTaskCard( + state.mode === "sparse" ? "task-card-missing-at-base" : state.reason, + ); + } + const presentDynamicPaths: Array = []; + if (state.mode === "sparse") { + for (const candidate of state.declaredDynamicPaths ?? []) { + const result = yield* executeGit( + "GitVcsDriver.materialization.pathAtBase", + repoRoot, + ["cat-file", "-e", `${pinnedCommit}:${candidate}`], + { allowNonZeroExit: true }, + ); + if (result.exitCode === 0) presentDynamicPaths.push(candidate); + } + } + const requiredPaths = uniqueMaterializationPaths([ + ...state.requiredPaths, + taskCardPath, + ...presentDynamicPaths, + ]); + return { + ...state, + baseSha: pinnedCommit, + taskCardSha256: worktreeMaterializationSha256(taskCardBytes), + taskCardGenerated: taskCardAtBase.exitCode !== 0, + requiredPaths, + manifestSha256: + state.mode === "sparse" + ? worktreeMaterializationSha256( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + profileId: state.effectiveProfileId, + conePaths: state.conePaths, + requiredPaths, + }), + ) + : state.manifestSha256, + }; + }); + + const ensureMaterializedTaskCard = Effect.fn("ensureMaterializedTaskCard")(function* ( + repoRoot: string, + worktreePath: string, + state: VcsWorktreeMaterializationState, + ) { + if (!state.taskCardPath || !state.taskCardSha256) return; + const taskCardPath = state.taskCardPath; + const targetPath = path.join(worktreePath, taskCardPath); + const configureIgnore = Effect.fn("configureGeneratedTaskCardIgnore")(function* () { + if (!state.taskCardGenerated) return; + const gitDirRaw = (yield* runGitStdout( + "GitVcsDriver.materialization.taskCardGitDir", + worktreePath, + ["rev-parse", "--git-dir"], + )).trim(); + const gitDir = path.isAbsolute(gitDirRaw) ? gitDirRaw : path.resolve(worktreePath, gitDirRaw); + const excludePath = path.join(gitDir, "worktree-materialization-excludes"); + const inheritedExclude = yield* executeGit( + "GitVcsDriver.materialization.inheritedTaskCardExclude", + worktreePath, + ["config", "--path", "--get", "core.excludesFile"], + { allowNonZeroExit: true }, + ); + const configuredExcludePath = + inheritedExclude.exitCode === 0 ? inheritedExclude.stdout.trim() : ""; + const defaultExcludePath = path.join( + process.env.XDG_CONFIG_HOME || path.join(NodeOS.homedir(), ".config"), + "git", + "ignore", + ); + const inheritedExcludePath = configuredExcludePath || defaultExcludePath; + const inheritedExcludeBytes = inheritedExcludePath + ? yield* Effect.exit(fileSystem.readFileString(inheritedExcludePath)) + : null; + const inheritedPatterns = + inheritedExcludeBytes && Exit.isSuccess(inheritedExcludeBytes) + ? inheritedExcludeBytes.value + : ""; + const inheritedLines = inheritedPatterns.split(/\r?\n/); + const nextPatterns = inheritedLines.includes(taskCardPath) + ? inheritedPatterns + : `${inheritedPatterns}${inheritedPatterns && !inheritedPatterns.endsWith("\n") ? "\n" : ""}${taskCardPath}\n`; + yield* fileSystem.writeFileString(excludePath, nextPatterns); + const worktreeConfig = yield* executeGit( + "GitVcsDriver.materialization.worktreeConfigEnabled", + repoRoot, + ["config", "--bool", "--get", "extensions.worktreeConfig"], + { allowNonZeroExit: true }, + ); + if (worktreeConfig.exitCode !== 0 || worktreeConfig.stdout.trim() !== "true") { + const unsafeSharedKeys = yield* Effect.forEach( + ["core.worktree", "core.sparseCheckout"], + (key) => + executeGit( + "GitVcsDriver.materialization.worktreeConfigPreflight", + repoRoot, + ["config", "--local", "--get", key], + { allowNonZeroExit: true }, + ).pipe(Effect.map((result) => result.exitCode === 0)), + ); + const sharedBare = yield* executeGit( + "GitVcsDriver.materialization.worktreeConfigBarePreflight", + repoRoot, + ["config", "--local", "--bool", "--get", "core.bare"], + { allowNonZeroExit: true }, + ); + if ( + unsafeSharedKeys.includes(true) || + (sharedBare.exitCode === 0 && sharedBare.stdout.trim() === "true") + ) { + return yield* materializationError( + "GitVcsDriver.materialization.enableWorktreeConfig", + repoRoot, + "Cannot safely enable per-worktree configuration while shared worktree-only core settings are present.", + ); + } + yield* runGit("GitVcsDriver.materialization.enableWorktreeConfig", repoRoot, [ + "config", + "extensions.worktreeConfig", + "true", + ]); + } + yield* runGit("GitVcsDriver.materialization.taskCardExclude", worktreePath, [ + "config", + "--worktree", + "core.excludesFile", + excludePath, + ]); + }); + const ensureTargetParentContained = Effect.fn("ensureGeneratedTaskCardParentContained")( + function* () { + if (!state.taskCardGenerated) return; + const worktreeRealPath = yield* fileSystem.realPath(worktreePath); + const targetParent = path.dirname(targetPath); + const isInsideWorktree = (candidate: string) => { + const relative = path.relative(worktreeRealPath, candidate); + return ( + relative === "" || (!relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) + ); + }; + let existingParent = worktreePath; + for (const component of taskCardPath.split("/").slice(0, -1)) { + existingParent = path.join(existingParent, component); + if (!(yield* fileSystem.exists(existingParent))) break; + const realParent = yield* fileSystem.realPath(existingParent); + if (!isInsideWorktree(realParent)) { + return yield* materializationError( + "GitVcsDriver.materialization.taskCard", + worktreePath, + "Generated task card parent escapes the worktree through a symbolic link.", + ); + } + } + yield* fileSystem.makeDirectory(targetParent, { recursive: true }); + if (!isInsideWorktree(yield* fileSystem.realPath(targetParent))) { + return yield* materializationError( + "GitVcsDriver.materialization.taskCard", + worktreePath, + "Generated task card parent escapes the worktree.", + ); + } + }, + ); + yield* ensureTargetParentContained(); + const target = yield* Effect.exit(fileSystem.readFile(targetPath)); + if (Exit.isSuccess(target)) { + const identityMatches = state.taskCardGenerated + ? worktreeMaterializationSha256(target.value) === state.taskCardSha256 + : yield* taskCardIdentityMatches(worktreePath, state); + if (!identityMatches) { + return yield* materializationError( + "GitVcsDriver.materialization.taskCard", + worktreePath, + "Materialized task card bytes do not match the persisted identity.", + ); + } + yield* configureIgnore(); + return; + } + const sourcePath = path.join(repoRoot, state.taskCardPath); + const sourceLink = yield* Effect.exit(fileSystem.readLink(sourcePath)); + if (Exit.isSuccess(sourceLink)) { + return yield* materializationError( + "GitVcsDriver.materialization.taskCard", + repoRoot, + "Hash-bound task card source cannot be a symbolic link.", + ); + } + const source = yield* fileSystem + .readFile(sourcePath) + .pipe( + Effect.mapError((cause) => + materializationError( + "GitVcsDriver.materialization.taskCard", + repoRoot, + "Hash-bound task card source is unavailable.", + cause, + ), + ), + ); + if (worktreeMaterializationSha256(source) !== state.taskCardSha256) { + return yield* materializationError( + "GitVcsDriver.materialization.taskCard", + repoRoot, + "Hash-bound task card source changed before materialization.", + ); + } + yield* configureIgnore(); + yield* fileSystem.writeFile(targetPath, source); + }); + + const materializationStatePath = Effect.fn("materializationStatePath")(function* (cwd: string) { + const gitDirRaw = (yield* runGitStdout("GitVcsDriver.materialization.gitDir", cwd, [ + "rev-parse", + "--git-dir", + ])).trim(); + const gitDir = path.isAbsolute(gitDirRaw) ? gitDirRaw : path.resolve(cwd, gitDirRaw); + return path.join(gitDir, WORKTREE_MATERIALIZATION_STATE_FILE); + }); + + const writeMaterializationState = Effect.fn("writeMaterializationState")(function* ( + cwd: string, + state: VcsWorktreeMaterializationState, + ) { + const statePath = yield* materializationStatePath(cwd); + const temporaryPath = `${statePath}.${process.pid}.${NodeCrypto.randomUUID()}.tmp`; + // @effect-diagnostics-next-line preferSchemaOverJson:off + const payload = `${JSON.stringify( + { schemaVersion: WORKTREE_MATERIALIZATION_STATE_SCHEMA, ...state }, + null, + 2, + )}\n`; + yield* fileSystem.writeFileString(temporaryPath, payload).pipe( + Effect.andThen(fileSystem.rename(temporaryPath, statePath)), + Effect.mapError((cause) => + materializationError( + "GitVcsDriver.materialization.writeState", + cwd, + "Failed to atomically persist worktree materialization state.", + cause, + ), + ), + ); + }); + + const readMaterializationState = Effect.fn("readMaterializationState")(function* (cwd: string) { + const statePath = yield* materializationStatePath(cwd); + const exists = yield* fileSystem + .exists(statePath) + .pipe( + Effect.mapError((cause) => + materializationError( + "GitVcsDriver.materialization.readState", + cwd, + "Failed to inspect worktree materialization state.", + cause, + ), + ), + ); + if (!exists) return null; + const raw = yield* fileSystem + .readFileString(statePath) + .pipe( + Effect.mapError((cause) => + materializationError( + "GitVcsDriver.materialization.readState", + cwd, + "Failed to read worktree materialization state.", + cause, + ), + ), + ); + return yield* Effect.try({ + try: () => { + // @effect-diagnostics-next-line preferSchemaOverJson:off + const parsed = JSON.parse(raw) as Record; + if (parsed.schemaVersion !== WORKTREE_MATERIALIZATION_STATE_SCHEMA) { + throw new Error("invalid materialization state schema"); + } + return decodeWorktreeMaterializationState(parsed); + }, + catch: (cause) => + materializationError( + "GitVcsDriver.materialization.readState", + cwd, + "Worktree materialization state is unreadable.", + cause, + ), + }); + }); + + const requiredMaterializationPathsMissing = Effect.fn("requiredMaterializationPathsMissing")( + function* (cwd: string, requiredPaths: ReadonlyArray) { + const existence = yield* Effect.forEach( + requiredPaths, + (relativePath) => + fileSystem + .exists(path.join(cwd, relativePath)) + .pipe( + Effect.mapError((cause) => + materializationError( + "GitVcsDriver.materialization.requiredPaths", + cwd, + `Failed to inspect required path '${relativePath}'.`, + cause, + ), + ), + ), + { concurrency: "unbounded" }, + ); + return requiredPaths.filter((_, index) => existence[index] !== true); + }, + ); + + const taskCardIdentityMatches = Effect.fn("taskCardIdentityMatches")(function* ( + cwd: string, + state: VcsWorktreeMaterializationState, + ) { + if (!state.taskCardPath || !state.taskCardSha256) return true; + if (!state.taskCardGenerated && state.baseSha) { + const repoRoot = (yield* runGitStdout("GitVcsDriver.materialization.taskCardRepoRoot", cwd, [ + "rev-parse", + "--show-toplevel", + ])).trim(); + const baseBytes = yield* readMaterializationBlobAtCommit( + repoRoot, + state.baseSha, + state.taskCardPath, + "GitVcsDriver.materialization.verifyTaskCardAtBase", + ); + if (!baseBytes || worktreeMaterializationSha256(baseBytes) !== state.taskCardSha256) { + return false; + } + const diff = yield* executeGit( + "GitVcsDriver.materialization.verifyTaskCardWorktree", + cwd, + ["diff", "--quiet", state.baseSha, "--", state.taskCardPath], + { allowNonZeroExit: true }, + ); + return diff.exitCode === 0; + } + const loaded = yield* Effect.exit(fileSystem.readFile(path.join(cwd, state.taskCardPath))); + return ( + Exit.isSuccess(loaded) && worktreeMaterializationSha256(loaded.value) === state.taskCardSha256 + ); + }); + + const generatedTaskCardIsIgnored = Effect.fn("generatedTaskCardIsIgnored")(function* ( + cwd: string, + state: VcsWorktreeMaterializationState, + ) { + if (!state.taskCardGenerated || !state.taskCardPath) return true; + const result = yield* executeGit( + "GitVcsDriver.materialization.taskCardIgnored", + cwd, + ["check-ignore", "--quiet", "--", state.taskCardPath], + { allowNonZeroExit: true }, + ); + return result.exitCode === 0; + }); + + const sparseCheckoutEnabled = Effect.fn("sparseCheckoutEnabled")(function* (cwd: string) { + const result = yield* executeGit( + "GitVcsDriver.materialization.sparseEnabled", + cwd, + ["config", "--bool", "core.sparseCheckout"], + { allowNonZeroExit: true }, + ); + return result.exitCode === 0 && result.stdout.trim() === "true"; + }); + + const verifyWorktreeMaterialization = Effect.fn("verifyWorktreeMaterialization")(function* ( + cwd: string, + ) { + const persisted = yield* readMaterializationState(cwd); + if (!persisted) { + if (yield* sparseCheckoutEnabled(cwd)) { + return yield* materializationError( + "GitVcsDriver.verifyWorktreeMaterialization", + cwd, + "Sparse checkout has no persisted materialization identity. Preserve changes in an ordinary named commit or operator-approved external copy, reach a clean state without automated stash/reset/clean/removal, run expand-full, then reverify.", + ); + } + return { + ...FULL_WORKTREE_MATERIALIZATION_STATE, + conePaths: [], + requiredPaths: [], + }; + } + if (persisted.status === "failed") { + return yield* materializationError( + "GitVcsDriver.verifyWorktreeMaterialization", + cwd, + `Worktree materialization failed before release: ${persisted.reason ?? "unknown failure"}. ${WORKTREE_MATERIALIZATION_RECOVERY}`, + ); + } + const sparseEnabled = yield* sparseCheckoutEnabled(cwd); + if (persisted.effectiveProfileId === "full") { + if (sparseEnabled) { + return yield* materializationError( + "GitVcsDriver.verifyWorktreeMaterialization", + cwd, + "Effective full materialization still has sparse checkout enabled.", + ); + } + const missing = yield* requiredMaterializationPathsMissing(cwd, persisted.requiredPaths); + if (missing.length > 0) { + return yield* materializationError( + "GitVcsDriver.verifyWorktreeMaterialization", + cwd, + `Full materialization is missing required path(s): ${missing.join(", ")}. ${WORKTREE_MATERIALIZATION_RECOVERY}`, + ); + } + if (!(yield* taskCardIdentityMatches(cwd, persisted))) { + return yield* materializationError( + "GitVcsDriver.verifyWorktreeMaterialization", + cwd, + persisted.taskCardGenerated + ? "Generated task card bytes do not match the persisted identity. Restore the exact task card bytes, then run expand-full and reverify." + : "Tracked task card changed from the materialization base. Preserve the work in a named commit and create a new worktree with a new explicit task-card identity; expand-full cannot rebind it.", + ); + } + if (!(yield* generatedTaskCardIsIgnored(cwd, persisted))) { + return yield* materializationError( + "GitVcsDriver.verifyWorktreeMaterialization", + cwd, + "Generated task card is visible to Git status.", + ); + } + return persisted; + } + if (!sparseEnabled || persisted.mode !== "sparse") { + return yield* materializationError( + "GitVcsDriver.verifyWorktreeMaterialization", + cwd, + "Persisted sparse materialization does not match Git sparse-checkout state.", + ); + } + const repoRoot = (yield* runGitStdout("GitVcsDriver.materialization.repoRoot", cwd, [ + "rev-parse", + "--show-toplevel", + ])).trim(); + const contract = persisted.baseSha + ? yield* readMaterializationContract(repoRoot, persisted.baseSha) + : null; + const contractDiff = persisted.baseSha + ? yield* executeGit( + "GitVcsDriver.materialization.verifyContractWorktree", + cwd, + ["diff", "--quiet", persisted.baseSha, "--", WORKTREE_MATERIALIZATION_CONTRACT_PATH], + { allowNonZeroExit: true }, + ) + : null; + if (!contract || contract.sha256 !== persisted.contractSha256 || contractDiff?.exitCode !== 0) { + return yield* materializationError( + "GitVcsDriver.verifyWorktreeMaterialization", + cwd, + `Materialization contract bytes do not match the persisted identity. ${WORKTREE_MATERIALIZATION_RECOVERY}`, + ); + } + const conePaths = minimalMaterializationConePaths([...persisted.conePaths]); + const requiredPaths = uniqueMaterializationPaths([...persisted.requiredPaths]); + const manifestSha256 = worktreeMaterializationSha256( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + profileId: persisted.effectiveProfileId, + conePaths, + requiredPaths, + }), + ); + if (manifestSha256 !== persisted.manifestSha256) { + return yield* materializationError( + "GitVcsDriver.verifyWorktreeMaterialization", + cwd, + "Persisted materialization manifest does not match its hash.", + ); + } + const sparseIndex = yield* executeGit( + "GitVcsDriver.materialization.sparseIndex", + cwd, + ["config", "--bool", "index.sparse"], + { allowNonZeroExit: true }, + ); + if (sparseIndex.exitCode === 0 && sparseIndex.stdout.trim() === "true") { + return yield* materializationError( + "GitVcsDriver.verifyWorktreeMaterialization", + cwd, + "Sparse index is forbidden for materialized worktrees.", + ); + } + const observedConePaths = (yield* runGitStdout("GitVcsDriver.materialization.sparseList", cwd, [ + "sparse-checkout", + "list", + ])) + .split(/\r?\n/) + .filter((candidate) => candidate.length > 0) + .map(normalizeMaterializationRepoPath); + if (observedConePaths.some((candidate) => candidate === null)) { + return yield* materializationError( + "GitVcsDriver.verifyWorktreeMaterialization", + cwd, + "Git sparse cone contains an invalid path.", + ); + } + const observedCone = uniqueMaterializationPaths(observedConePaths as Array); + // @effect-diagnostics-next-line preferSchemaOverJson:off + if (JSON.stringify(observedCone) !== JSON.stringify(conePaths)) { + return yield* materializationError( + "GitVcsDriver.verifyWorktreeMaterialization", + cwd, + "Git sparse cone does not match persisted materialization state.", + ); + } + const missing = yield* requiredMaterializationPathsMissing(cwd, requiredPaths); + if (missing.length > 0) { + return yield* materializationError( + "GitVcsDriver.verifyWorktreeMaterialization", + cwd, + `Sparse materialization is missing required path(s): ${missing.join(", ")}. Preserve changes in an ordinary named commit or operator-approved external copy, reach a clean state without automated stash/reset/clean/removal, run expand-full, then reverify.`, + ); + } + if (!(yield* taskCardIdentityMatches(cwd, persisted))) { + return yield* materializationError( + "GitVcsDriver.verifyWorktreeMaterialization", + cwd, + persisted.taskCardGenerated + ? "Generated task card bytes do not match the persisted identity. Restore the exact task card bytes, then run expand-full and reverify." + : "Tracked task card changed from the materialization base. Preserve the work in a named commit and create a new worktree with a new explicit task-card identity; expand-full cannot rebind it.", + ); + } + if (!(yield* generatedTaskCardIsIgnored(cwd, persisted))) { + return yield* materializationError( + "GitVcsDriver.verifyWorktreeMaterialization", + cwd, + "Generated task card is visible to Git status.", + ); + } + return persisted; + }); + + const expandWorktreeMaterializationFull = Effect.fn("expandWorktreeMaterializationFull")( + function* (cwd: string, reason: string) { + const status = (yield* runGitStdout("GitVcsDriver.materialization.expandStatus", cwd, [ + "status", + "--porcelain=v1", + "--untracked-files=all", + ])).trim(); + if (status.length > 0) { + return yield* materializationError( + "GitVcsDriver.expandWorktreeMaterializationFull", + cwd, + "Worktree must be clean before expand-full.", + ); + } + const persisted = yield* readMaterializationState(cwd).pipe( + Effect.mapError((cause) => + materializationError( + "GitVcsDriver.expandWorktreeMaterializationFull", + cwd, + "Persisted materialization identity is unreadable. Preserve changes in a named commit or operator-approved external copy. Restore the original state from trusted evidence or create a new worktree; expand-full cannot certify unknown identity.", + cause, + ), + ), + ); + if ( + persisted?.status === "failed" && + [ + "materialized-head-mismatch", + "materialized-head-unreadable", + "materialized-branch-mismatch", + "materialized-branch-unreadable", + "upstream-tracking-failed", + "tracking-inspection-failed", + ].includes(persisted.reason ?? "") + ) { + return yield* materializationError( + "GitVcsDriver.expandWorktreeMaterializationFull", + cwd, + "This worktree failed source identity or branch setup before release. Preserve diagnostic evidence and create a new worktree; expand-full cannot certify it.", + ); + } + if (persisted && !(yield* taskCardIdentityMatches(cwd, persisted))) { + return yield* materializationError( + "GitVcsDriver.expandWorktreeMaterializationFull", + cwd, + persisted.taskCardGenerated + ? "Generated task card bytes do not match the persisted identity. Restore the exact task card bytes before expand-full." + : "Tracked task card changed from the materialization base. Preserve the work in a named commit and create a new worktree with a new explicit task-card identity; expand-full cannot rebind it.", + ); + } + const wasSparse = yield* sparseCheckoutEnabled(cwd); + const needsSparseDisable = + wasSparse || persisted?.mode === "sparse" || persisted?.status === "failed"; + if (needsSparseDisable) { + yield* runGit( + "GitVcsDriver.materialization.expandFull", + cwd, + ["sparse-checkout", "disable"], + { timeoutMs: WORKTREE_ADD_TIMEOUT_MS }, + ); + if (yield* sparseCheckoutEnabled(cwd)) { + return yield* materializationError( + "GitVcsDriver.expandWorktreeMaterializationFull", + cwd, + "Sparse checkout remained enabled after expand-full.", + ); + } + } + const baseSha = (yield* runGitStdout("GitVcsDriver.materialization.expandFullHead", cwd, [ + "rev-parse", + "HEAD^{commit}", + ])).trim(); + const baseState = persisted ?? { + ...FULL_WORKTREE_MATERIALIZATION_STATE, + conePaths: [], + requiredPaths: [], + baseSha, + }; + const missing = yield* requiredMaterializationPathsMissing(cwd, baseState.requiredPaths); + if (missing.length > 0) { + return yield* materializationError( + "GitVcsDriver.expandWorktreeMaterializationFull", + cwd, + `Full expansion is missing required path(s): ${missing.join(", ")}`, + ); + } + const nextState: VcsWorktreeMaterializationState = { + ...baseState, + status: "ready", + effectiveProfileId: "full", + mode: "full", + reason: reason.trim() || "expand-full", + taskCardSha256: baseState.taskCardSha256 ?? null, + baseSha: baseState.baseSha ?? baseSha, + }; + // Preserve the requested sparse manifest as provenance. Only the + // effective profile and mode transition after creation. + yield* writeMaterializationState(cwd, nextState); + return yield* verifyWorktreeMaterialization(cwd); + }, + ); + const branchExists = (cwd: string, refName: string): Effect.Effect => executeGit( "GitVcsDriver.branchExists", @@ -2831,27 +3934,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }, ); - const createWorktree: GitVcsDriver.GitVcsDriver["Service"]["createWorktree"] = Effect.fn( - "createWorktree", - )(function* (input) { - const targetBranch = input.newRefName ?? input.refName; - const sanitizedBranch = targetBranch.replace(/\//g, "-"); - const repoName = path.basename(input.cwd); - const worktreePath = input.path ?? path.join(worktreesDir, repoName, sanitizedBranch); - const args = input.newRefName - ? ["worktree", "add", "-b", input.newRefName, worktreePath, input.refName] - : ["worktree", "add", worktreePath, input.refName]; - - yield* executeGit("GitVcsDriver.createWorktree", input.cwd, args, { - fallbackErrorDetail: "git worktree add failed", - timeoutMs: WORKTREE_ADD_TIMEOUT_MS, - }); - - // `git worktree add` leaves submodules empty, so a repo that keeps agent - // skills, tooling or source in one gets a worktree that is quietly missing - // them. Best-effort: the objects are usually already in the parent's - // `.git/modules`, but a first-ever clone needs the network, and failing to - // populate a submodule must not roll back the caller's thread. + const completeWorktreeCreation = Effect.fn("completeWorktreeCreation")(function* ( + input: Parameters[0], + worktreePath: string, + targetBranch: string, + materialization?: VcsWorktreeMaterializationState, + ) { + // `git worktree add` leaves submodules empty. Populate them best-effort; + // an unreachable first-time submodule must not roll back the thread. const hasSubmodules = yield* fileSystem .exists(path.join(worktreePath, ".gitmodules")) .pipe(Effect.orElseSucceed(() => false)); @@ -2886,13 +3976,492 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } return { - worktree: { - path: worktreePath, - refName: targetBranch, - }, + worktree: { path: worktreePath, refName: targetBranch }, + ...(materialization ? { materialization } : {}), }; }); + const resolvePinnedWorktreeCommit = Effect.fn("resolvePinnedWorktreeCommit")(function* ( + repoRoot: string, + refName: string, + ) { + const exact = yield* executeGit( + "GitVcsDriver.createWorktree.pinCommit", + repoRoot, + ["rev-parse", "--verify", `${refName}^{commit}`], + { allowNonZeroExit: true }, + ); + if (exact.exitCode === 0) return { commit: exact.stdout.trim(), remoteRef: null }; + const remoteMatches = yield* executeGit( + "GitVcsDriver.createWorktree.pinRemoteCommit", + repoRoot, + ["for-each-ref", "--format=%(objectname) %(refname)", `refs/remotes/*/${refName}`], + { allowNonZeroExit: true }, + ); + const matches = remoteMatches.stdout.split(/\r?\n/).filter(Boolean); + if (remoteMatches.exitCode === 0 && matches.length === 1) { + const [commit, fullRef] = matches[0]!.split(" ", 2); + return { + commit: commit!, + remoteRef: fullRef!.replace(/^refs\/remotes\//, ""), + }; + } + return yield* materializationError( + "GitVcsDriver.createWorktree.pinCommit", + repoRoot, + `Cannot resolve '${refName}' to one source commit.`, + ); + }); + + const createWorktree: GitVcsDriver.GitVcsDriver["Service"]["createWorktree"] = Effect.fn( + "createWorktree", + )(function* (input) { + const targetBranch = input.newRefName ?? input.refName; + const sanitizedBranch = targetBranch.replace(/\//g, "-"); + const repoName = path.basename(input.cwd); + const worktreePath = input.path ?? path.join(worktreesDir, repoName, sanitizedBranch); + if (!input.materialization) { + const legacyArgs = input.newRefName + ? ["worktree", "add", "-b", input.newRefName, worktreePath, input.refName] + : ["worktree", "add", worktreePath, input.refName]; + yield* executeGit("GitVcsDriver.createWorktree", input.cwd, legacyArgs, { + fallbackErrorDetail: "git worktree add failed", + timeoutMs: WORKTREE_ADD_TIMEOUT_MS, + }); + return yield* completeWorktreeCreation(input, worktreePath, targetBranch); + } + const repoRoot = (yield* runGitStdout("GitVcsDriver.createWorktree.repoRoot", input.cwd, [ + "rev-parse", + "--show-toplevel", + ])).trim(); + const pinned = yield* resolvePinnedWorktreeCommit(repoRoot, input.refName); + const pinnedCommit = pinned.commit; + const contract = yield* readMaterializationContract(repoRoot, pinnedCommit); + const requestedMaterialization = yield* pinMaterializationRequiredPaths( + repoRoot, + pinnedCommit, + resolveWorktreeMaterialization( + input.materialization, + contract?.contract ?? null, + contract?.sha256 ?? null, + ), + ); + const args = input.newRefName + ? ["worktree", "add", "--no-checkout", "-b", input.newRefName, worktreePath, pinnedCommit] + : ["worktree", "add", "--no-checkout", worktreePath, input.refName]; + + yield* executeGit("GitVcsDriver.createWorktree", repoRoot, args, { + fallbackErrorDetail: "git worktree add failed", + timeoutMs: WORKTREE_ADD_TIMEOUT_MS, + }); + + const sparseStateAfterAdd = Effect.fn("sparseStateAfterAdd")(function* (reason: string) { + const observed = yield* Effect.exit(sparseCheckoutEnabled(worktreePath)); + if (Exit.isSuccess(observed)) return observed.value; + yield* writeMaterializationState(worktreePath, { + ...requestedMaterialization, + status: "failed", + reason, + }); + return yield* materializationError( + "GitVcsDriver.createWorktree", + worktreePath, + "Worktree sparse-checkout state could not be read. The never-released worktree was preserved for diagnosis.", + ); + }); + + if (input.newRefName) { + const trackingDecision = yield* Effect.exit( + Effect.gen(function* () { + let trackingRef = pinned.remoteRef; + if (!trackingRef) { + const remoteStart = yield* executeGit( + "GitVcsDriver.createWorktree.remoteStart", + repoRoot, + ["show-ref", "--verify", "--quiet", `refs/remotes/${input.refName}`], + { allowNonZeroExit: true }, + ); + if (remoteStart.exitCode === 0) trackingRef = input.refName; + } + const autoSetupMerge = yield* executeGit( + "GitVcsDriver.createWorktree.autoSetupMerge", + repoRoot, + ["config", "--get", "branch.autoSetupMerge"], + { allowNonZeroExit: true }, + ); + let autoSetupMode = autoSetupMerge.stdout.trim().toLowerCase(); + if (autoSetupMerge.exitCode !== 0) { + autoSetupMode = "true"; + } else if (!autoSetupMode) { + const booleanMode = yield* executeGit( + "GitVcsDriver.createWorktree.autoSetupMergeBoolean", + repoRoot, + ["config", "--type=bool", "--get", "branch.autoSetupMerge"], + { allowNonZeroExit: true }, + ); + autoSetupMode = booleanMode.exitCode === 0 ? booleanMode.stdout.trim() : "true"; + } + if (["false", "no", "off", "0"].includes(autoSetupMode)) return null; + if (autoSetupMode === "inherit") { + const inheritedUpstream = yield* executeGit( + "GitVcsDriver.createWorktree.inheritedUpstream", + repoRoot, + ["rev-parse", "--abbrev-ref", `${input.refName}@{upstream}`], + { allowNonZeroExit: true }, + ); + return inheritedUpstream.exitCode === 0 ? inheritedUpstream.stdout.trim() : null; + } + if (trackingRef) { + if (autoSetupMode !== "simple") return trackingRef; + const remoteNames = yield* listRemoteNames(repoRoot); + const remote = parseRemoteRefWithRemoteNames( + trackingRef, + remoteNames.toSorted((left, right) => right.length - left.length), + ); + return remote?.branchName === input.newRefName ? trackingRef : null; + } + if (autoSetupMode !== "always") return null; + const localStart = yield* executeGit( + "GitVcsDriver.createWorktree.localTrackingStart", + repoRoot, + ["show-ref", "--verify", "--quiet", `refs/heads/${input.refName}`], + { allowNonZeroExit: true }, + ); + return localStart.exitCode === 0 ? input.refName : null; + }), + ); + if (Exit.isFailure(trackingDecision)) { + yield* writeMaterializationState(worktreePath, { + ...requestedMaterialization, + status: "failed", + reason: "tracking-inspection-failed", + }); + return yield* materializationError( + "GitVcsDriver.createWorktree", + worktreePath, + "Worktree branch tracking could not be inspected. The never-released worktree was preserved for diagnosis.", + ); + } + const upstreamToSet = trackingDecision.value; + if (upstreamToSet) { + const preserved = yield* Effect.exit( + runGit("GitVcsDriver.createWorktree.preserveUpstream", repoRoot, [ + "branch", + "--set-upstream-to", + upstreamToSet, + input.newRefName, + ]), + ); + if (Exit.isFailure(preserved)) { + yield* writeMaterializationState(worktreePath, { + ...requestedMaterialization, + status: "failed", + reason: "upstream-tracking-failed", + }); + return yield* materializationError( + "GitVcsDriver.createWorktree", + worktreePath, + "Worktree branch upstream tracking failed after creation. The never-released worktree was preserved for diagnosis.", + ); + } + } + } + + let materialization: VcsWorktreeMaterializationState = requestedMaterialization; + if (requestedMaterialization.mode === "sparse") { + let fallbackReason: string | null = null; + const sparseSet = yield* Effect.exit( + runGit( + "GitVcsDriver.createWorktree.sparseSet", + worktreePath, + [ + "sparse-checkout", + "set", + "--cone", + "--no-sparse-index", + ...requestedMaterialization.conePaths, + ], + { timeoutMs: WORKTREE_ADD_TIMEOUT_MS }, + ), + ); + if (Exit.isFailure(sparseSet)) { + fallbackReason = "sparse-setup-failed"; + } else { + const checkout = yield* Effect.exit( + runGit( + "GitVcsDriver.createWorktree.checkoutSparse", + worktreePath, + ["checkout", "--force", targetBranch], + { timeoutMs: WORKTREE_ADD_TIMEOUT_MS }, + ), + ); + if (Exit.isFailure(checkout)) { + fallbackReason = "sparse-checkout-failed"; + } else { + const taskCard = yield* Effect.exit( + ensureMaterializedTaskCard(repoRoot, worktreePath, requestedMaterialization), + ); + if (Exit.isFailure(taskCard)) { + fallbackReason = "task-card-materialization-failed"; + } else { + const missingRead = yield* Effect.exit( + requiredMaterializationPathsMissing( + worktreePath, + requestedMaterialization.requiredPaths, + ), + ); + if (Exit.isFailure(missingRead)) fallbackReason = "required-path-inspection-failed"; + else if (missingRead.value.length > 0) fallbackReason = "required-paths-missing"; + } + } + } + + if (fallbackReason) { + if (yield* sparseStateAfterAdd(`${fallbackReason}:sparse-state-unreadable`)) { + const disabled = yield* Effect.exit( + runGit( + "GitVcsDriver.createWorktree.disableSparseFallback", + worktreePath, + ["sparse-checkout", "disable"], + { timeoutMs: WORKTREE_ADD_TIMEOUT_MS }, + ), + ); + if (Exit.isFailure(disabled)) { + const failedState: VcsWorktreeMaterializationState = { + ...requestedMaterialization, + status: "failed", + reason: `${fallbackReason}:full-fallback-disable-failed`, + }; + yield* writeMaterializationState(worktreePath, failedState); + return yield* materializationError( + "GitVcsDriver.createWorktree", + worktreePath, + `${fallbackReason}; full fallback could not disable sparse checkout. The never-released worktree was preserved for diagnosis.`, + ); + } + } + const fullCheckout = yield* Effect.exit( + runGit( + "GitVcsDriver.createWorktree.checkoutFullFallback", + worktreePath, + ["checkout", "--force", targetBranch], + { timeoutMs: WORKTREE_ADD_TIMEOUT_MS }, + ), + ); + if (Exit.isFailure(fullCheckout)) { + const failedState: VcsWorktreeMaterializationState = { + ...requestedMaterialization, + status: "failed", + effectiveProfileId: "full", + mode: "full", + reason: `${fallbackReason}:full-fallback-checkout-failed`, + }; + yield* writeMaterializationState(worktreePath, failedState); + return yield* materializationError( + "GitVcsDriver.createWorktree", + worktreePath, + `${fallbackReason}; full fallback checkout failed. The never-released worktree was preserved for diagnosis.`, + ); + } + const withoutTaskCardIdentity = (state: VcsWorktreeMaterializationState) => ({ + taskCardPath: null, + taskCardSha256: null, + taskCardGenerated: false, + requiredPaths: state.requiredPaths.filter( + (candidate) => candidate !== state.taskCardPath, + ), + }); + const dropTaskCardIdentity = fallbackReason === "task-card-materialization-failed"; + let fullMaterialization: VcsWorktreeMaterializationState = { + ...requestedMaterialization, + effectiveProfileId: "full", + mode: "full", + reason: fallbackReason, + ...(dropTaskCardIdentity ? withoutTaskCardIdentity(requestedMaterialization) : {}), + }; + if (!dropTaskCardIdentity) { + const fullTaskCard = yield* Effect.exit( + ensureMaterializedTaskCard(repoRoot, worktreePath, fullMaterialization), + ); + if (Exit.isFailure(fullTaskCard)) { + fullMaterialization = { + ...fullMaterialization, + ...withoutTaskCardIdentity(fullMaterialization), + }; + } + } + const missingRead = yield* Effect.exit( + requiredMaterializationPathsMissing(worktreePath, fullMaterialization.requiredPaths), + ); + if (Exit.isFailure(missingRead)) { + const failedState: VcsWorktreeMaterializationState = { + ...fullMaterialization, + status: "failed", + reason: `${fallbackReason}:full-fallback-required-paths-unreadable`, + }; + yield* writeMaterializationState(worktreePath, failedState); + return yield* materializationError( + "GitVcsDriver.createWorktree", + worktreePath, + `${fallbackReason}; full fallback required paths could not be inspected. The never-released worktree was preserved for diagnosis.`, + ); + } + if (missingRead.value.length > 0) { + const failedState: VcsWorktreeMaterializationState = { + ...fullMaterialization, + status: "failed", + reason: `${fallbackReason}:full-fallback-required-paths-missing`, + }; + yield* writeMaterializationState(worktreePath, failedState); + return yield* materializationError( + "GitVcsDriver.createWorktree", + worktreePath, + `${fallbackReason}; full fallback is missing required path(s): ${missingRead.value.join(", ")}. The never-released worktree was preserved for diagnosis.`, + ); + } + materialization = fullMaterialization; + } + } else { + if (yield* sparseStateAfterAdd("full-sparse-state-unreadable")) { + const disabled = yield* Effect.exit( + runGit( + "GitVcsDriver.createWorktree.disableInheritedSparse", + worktreePath, + ["sparse-checkout", "disable"], + { timeoutMs: WORKTREE_ADD_TIMEOUT_MS }, + ), + ); + if (Exit.isFailure(disabled)) { + yield* writeMaterializationState(worktreePath, { + ...requestedMaterialization, + status: "failed", + reason: "inherited-sparse-disable-failed", + }); + return yield* materializationError( + "GitVcsDriver.createWorktree", + worktreePath, + "Inherited sparse checkout could not be disabled. The never-released worktree was preserved for diagnosis.", + ); + } + } + const fullCheckout = yield* Effect.exit( + runGit( + "GitVcsDriver.createWorktree.checkoutFull", + worktreePath, + ["checkout", "--force", targetBranch], + { timeoutMs: WORKTREE_ADD_TIMEOUT_MS }, + ), + ); + if (Exit.isFailure(fullCheckout)) { + const failedState: VcsWorktreeMaterializationState = { + ...requestedMaterialization, + status: "failed", + reason: "full-checkout-failed", + }; + yield* writeMaterializationState(worktreePath, failedState); + return yield* materializationError( + "GitVcsDriver.createWorktree", + worktreePath, + "Full worktree checkout failed. The never-released worktree was preserved for diagnosis.", + ); + } + const fullTaskCard = yield* Effect.exit( + ensureMaterializedTaskCard(repoRoot, worktreePath, requestedMaterialization), + ); + if (Exit.isFailure(fullTaskCard)) { + const failedState: VcsWorktreeMaterializationState = { + ...requestedMaterialization, + status: "failed", + reason: "full-task-card-materialization-failed", + }; + yield* writeMaterializationState(worktreePath, failedState); + return yield* materializationError( + "GitVcsDriver.createWorktree", + worktreePath, + "Full worktree could not materialize the exact task card. The never-released worktree was preserved for diagnosis.", + ); + } + } + + const observedHeadRead = yield* Effect.exit( + runGitStdout("GitVcsDriver.createWorktree.verifyHead", worktreePath, [ + "rev-parse", + "--verify", + "HEAD^{commit}", + ]), + ); + if (Exit.isFailure(observedHeadRead)) { + yield* writeMaterializationState(worktreePath, { + ...materialization, + status: "failed", + reason: "materialized-head-unreadable", + }); + return yield* materializationError( + "GitVcsDriver.createWorktree", + worktreePath, + "Materialized worktree HEAD could not be read. The never-released worktree was preserved for diagnosis.", + ); + } + const observedHead = observedHeadRead.value.trim(); + if (observedHead !== pinnedCommit) { + yield* writeMaterializationState(worktreePath, { + ...materialization, + status: "failed", + reason: "materialized-head-mismatch", + }); + return yield* materializationError( + "GitVcsDriver.createWorktree", + worktreePath, + "Materialized worktree HEAD does not match the pinned source commit. The never-released worktree was preserved for diagnosis.", + ); + } + if (input.newRefName) { + const observedBranchRead = yield* Effect.exit( + runGitStdout("GitVcsDriver.createWorktree.verifyBranch", worktreePath, [ + "branch", + "--show-current", + ]), + ); + if (Exit.isFailure(observedBranchRead)) { + yield* writeMaterializationState(worktreePath, { + ...materialization, + status: "failed", + reason: "materialized-branch-unreadable", + }); + return yield* materializationError( + "GitVcsDriver.createWorktree", + worktreePath, + "Materialized worktree branch could not be read. The never-released worktree was preserved for diagnosis.", + ); + } + const observedBranch = observedBranchRead.value.trim(); + if (observedBranch !== input.newRefName) { + yield* writeMaterializationState(worktreePath, { + ...materialization, + status: "failed", + reason: "materialized-branch-mismatch", + }); + return yield* materializationError( + "GitVcsDriver.createWorktree", + worktreePath, + "Materialized worktree branch does not match the requested branch. The never-released worktree was preserved for diagnosis.", + ); + } + } + yield* writeMaterializationState(worktreePath, materialization); + materialization = yield* verifyWorktreeMaterialization(worktreePath).pipe( + Effect.tapError(() => + writeMaterializationState(worktreePath, { + ...materialization, + status: "failed", + reason: "post-create-verification-failed", + }), + ), + ); + + return yield* completeWorktreeCreation(input, worktreePath, targetBranch, materialization); + }); + const fetchPullRequestBranch: GitVcsDriver.GitVcsDriver["Service"]["fetchPullRequestBranch"] = Effect.fn("fetchPullRequestBranch")(function* (input) { const remoteName = yield* resolvePrimaryRemoteName(input.cwd); @@ -3320,6 +4889,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* readConfigValue, listRefs, createWorktree: (input) => withListRefsInvalidation(input.cwd, createWorktree(input)), + verifyWorktreeMaterialization, + expandWorktreeMaterializationFull, fetchPullRequestBranch: (input) => withListRefsInvalidation(input.cwd, fetchPullRequestBranch(input)), fetchPullRequestHeadCommit, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 0a9b5b64389d..e94baf0257bc 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -3,6 +3,7 @@ import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as Equal from "effect/Equal"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; @@ -1134,7 +1135,15 @@ const makeWsRpcLayer = ( newRefName: bootstrap.prepareWorktree.branch, baseRefName: bootstrap.prepareWorktree.baseBranch, path: null, + ...(bootstrap.prepareWorktree.materialization + ? { materialization: bootstrap.prepareWorktree.materialization } + : {}), }); + if (bootstrap.prepareWorktree.materialization && !worktree.materialization) { + return yield* new OrchestrationDispatchCommandError({ + message: "Requested worktree materialization returned no persisted identity.", + }); + } targetWorktreePath = worktree.worktree.path; yield* dispatchFromClient({ type: "thread.meta.update", @@ -1143,6 +1152,24 @@ const makeWsRpcLayer = ( branch: worktree.worktree.refName, worktreePath: targetWorktreePath, }); + const materialization = worktree.materialization; + if (materialization) { + const verifiedMaterialization = + yield* gitWorkflow.verifyWorktreeMaterialization(targetWorktreePath); + if (!Equal.equals(verifiedMaterialization, materialization)) { + return yield* new OrchestrationDispatchCommandError({ + message: + "Created worktree materialization does not match its persisted identity.", + }); + } + yield* dispatchFromClient({ + type: "thread.materialization.set", + commandId: yield* serverCommandId("bootstrap-thread-materialization-set"), + threadId: command.threadId, + materialization, + createdAt: finalTurnStartCommand.createdAt, + }); + } yield* refreshGitStatus(targetWorktreePath); } @@ -2405,6 +2432,59 @@ const makeWsRpcLayer = ( gitWorkflow.createWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), { "rpc.aggregate": "vcs" }, ), + [WS_METHODS.vcsExpandWorktreeMaterialization]: (input) => + observeRpcEffect( + WS_METHODS.vcsExpandWorktreeMaterialization, + Effect.gen(function* () { + const thread = Option.getOrUndefined( + yield* projectionSnapshotQuery.getThreadShellById(input.threadId).pipe( + Effect.mapError( + (cause) => + new OrchestrationDispatchCommandError({ + message: "Failed to read thread state for expand-full.", + cause, + }), + ), + ), + ); + if (!thread || thread.worktreePath !== input.cwd) { + return yield* new OrchestrationDispatchCommandError({ + message: + "expand-full requires the exact persisted worktree path for the named thread.", + }); + } + if ( + thread.session && + thread.session.status !== "stopped" && + thread.session.status !== "error" + ) { + return yield* new OrchestrationDispatchCommandError({ + message: "expand-full is unavailable while the thread session is active.", + }); + } + const materialization = yield* gitWorkflow.expandWorktreeMaterializationFull( + input.cwd, + input.reason ?? "operator-expand-full", + ); + yield* dispatchFromClient({ + type: "thread.materialization.set", + commandId: yield* serverCommandId("thread-materialization-expand-full"), + threadId: input.threadId, + materialization, + createdAt: yield* nowIso, + }).pipe( + Effect.mapError((cause) => + toDispatchCommandError( + cause, + "Failed to persist expanded worktree materialization", + ), + ), + ); + yield* refreshGitStatus(input.cwd); + return { materialization }; + }), + { "rpc.aggregate": "vcs" }, + ), [WS_METHODS.vcsRemoveWorktree]: (input) => observeRpcEffect( WS_METHODS.vcsRemoveWorktree, diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 8e9c80641f2b..83625c52eea8 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -42,6 +42,11 @@ import { resolveDraftPromotionNavigationTarget, resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, + buildUiWorktreeMaterializationRequest, + resolveMaterializationTaskCardRead, + parseWorktreeMaterializationUiContract, + resolveUiWorktreeMaterializationRequest, + worktreeMaterializationPresentation, resolveDraftHeroState, scheduleEnvironmentReconnectWarning, startNewThreadForProject, @@ -1169,6 +1174,337 @@ describe("resolveSendEnvMode", () => { }); }); +describe("worktree materialization selection", () => { + it("discovers profile IDs from the repository contract rather than UI constants", () => { + const parsed = parseWorktreeMaterializationUiContract( + JSON.stringify({ + schemaVersion: "clawd.worktree-materialization-profiles.v1", + profiles: [ + { id: "full", mode: "full" }, + { id: "repository-profile", mode: "sparse" }, + ], + }), + ); + expect(parsed?.profiles.map((profile) => profile.id)).toEqual(["full", "repository-profile"]); + }); + + it("uses only matching structured task-card metadata and never title text", () => { + const sha = "a".repeat(64); + const request = resolveUiWorktreeMaterializationRequest({ + requestedProfileId: "governance-review", + contractSha256: sha, + taskCardPath: "ops/stef-task/task/stef-task.json", + taskCardContents: JSON.stringify({ + title: "brandt trading governance words are irrelevant", + issue: { id: "OC-1" }, + materialization: { + requestedProfileId: "governance-review", + expectedContractSha256: sha, + taskId: "OC-1", + taskSlug: "task", + taskCardPath: "ops/stef-task/task/stef-task.json", + scopePaths: ["docs/spec.md"], + taskClasses: ["source-task"], + }, + verification: { + status: "declared", + args: { paths: ["scripts/test/materialization.test.js"] }, + }, + }), + }); + expect(request?.requestedProfileId).toBe("governance-review"); + expect(request?.taskCardPath).toBe("ops/stef-task/task/stef-task.json"); + expect(request?.scopePaths).toEqual(["docs/spec.md", "scripts/test/materialization.test.js"]); + expect( + resolveUiWorktreeMaterializationRequest({ + requestedProfileId: "governance-review", + contractSha256: sha, + taskCardPath: "ops/stef-task/task/stef-task.json", + taskCardContents: JSON.stringify({ + issue: { id: "OC-OTHER" }, + materialization: { + requestedProfileId: "governance-review", + expectedContractSha256: sha, + taskId: "OC-1", + taskSlug: "task", + taskCardPath: "ops/stef-task/task/stef-task.json", + scopePaths: ["docs/spec.md"], + taskClasses: ["source-task"], + }, + }), + }), + ).toBeUndefined(); + expect( + resolveUiWorktreeMaterializationRequest({ + requestedProfileId: "brandt-source", + contractSha256: sha, + taskCardPath: "ops/stef-task/other/stef-task.json", + taskCardContents: JSON.stringify({ title: "Brandt source task" }), + }), + ).toBeUndefined(); + }); + + it("rejects card fields that would fail the wire schema", () => { + const sha = "a".repeat(64); + expect( + resolveUiWorktreeMaterializationRequest({ + requestedProfileId: "governance-review", + contractSha256: sha, + taskCardPath: "ops/stef-task/task/stef-task.json", + taskCardContents: JSON.stringify({ + materialization: { + requestedProfileId: "governance-review", + expectedContractSha256: sha, + taskId: "", + taskSlug: "task", + scopePaths: [123], + }, + }), + }), + ).toBeUndefined(); + expect( + resolveUiWorktreeMaterializationRequest({ + requestedProfileId: "governance-review", + contractSha256: sha, + taskCardPath: "ops/stef-task/task/stef-task.json", + taskCardContents: JSON.stringify({ + materialization: { + requestedProfileId: "governance-review", + expectedContractSha256: sha, + taskId: "OC-1", + taskSlug: "task", + scopePaths: ["docs/spec.md"], + taskClasses: ["source-task"], + }, + verification: { status: "declared", args: { paths: [123] } }, + }), + }), + ).toBeUndefined(); + }); + + it("reads only declared verifier paths and accepts other typed verifier arguments", () => { + const sha = "a".repeat(64); + const materialization = { + requestedProfileId: "governance-review", + expectedContractSha256: sha, + taskId: "OC-1", + taskSlug: "task", + taskCardPath: "ops/stef-task/task/stef-task.json", + scopePaths: ["docs/spec.md"], + taskClasses: ["source-task"], + }; + const withArgs = resolveUiWorktreeMaterializationRequest({ + requestedProfileId: "governance-review", + contractSha256: sha, + taskCardPath: "ops/stef-task/task/stef-task.json", + taskCardContents: JSON.stringify({ + issue: { id: "OC-1" }, + materialization, + verification: { + status: "declared", + args: { + paths: ["scripts/test/materialization.test.js"], + tags: ["not-a-path"], + timeout: 300, + }, + }, + }), + }); + expect(withArgs?.scopePaths).toEqual(["docs/spec.md", "scripts/test/materialization.test.js"]); + + const withoutArgs = resolveUiWorktreeMaterializationRequest({ + requestedProfileId: "governance-review", + contractSha256: sha, + taskCardPath: "ops/stef-task/task/stef-task.json", + taskCardContents: JSON.stringify({ + issue: { id: "OC-1" }, + materialization, + verification: { status: "declared" }, + }), + }); + expect(withoutArgs?.scopePaths).toEqual(["docs/spec.md"]); + + const trimmed = resolveUiWorktreeMaterializationRequest({ + requestedProfileId: "governance-review", + contractSha256: sha, + taskCardPath: "ops/stef-task/task/stef-task.json", + taskCardContents: JSON.stringify({ + issue: { id: "OC-1" }, + materialization: { + ...materialization, + taskId: " OC-1 ", + taskSlug: " task ", + scopePaths: [" docs/spec.md "], + taskClasses: [" source-task "], + }, + verification: { + status: "declared", + args: { paths: [" scripts/test/materialization.test.js "] }, + }, + }), + }); + expect(trimmed).toMatchObject({ + taskId: "OC-1", + taskSlug: "task", + scopePaths: ["docs/spec.md", "scripts/test/materialization.test.js"], + taskClasses: ["source-task"], + }); + }); + + it("waits for a selected card without reusing stale or truncated content", () => { + const ready = { + enabled: true, + requestedProfileId: "governance-review", + taskCardPath: "ops/stef-task/new/stef-task.json", + debouncedTaskCardPath: "ops/stef-task/new/stef-task.json", + isPending: false, + isError: false, + data: { contents: "card", truncated: false }, + }; + expect(resolveMaterializationTaskCardRead(ready)).toEqual({ pending: false, contents: "card" }); + expect(resolveMaterializationTaskCardRead({ ...ready, isPending: true })).toEqual({ + pending: true, + contents: null, + }); + expect( + resolveMaterializationTaskCardRead({ + ...ready, + debouncedTaskCardPath: "ops/stef-task/old/stef-task.json", + }), + ).toEqual({ pending: true, contents: null }); + for (const data of [undefined, { contents: "card", truncated: true }]) { + expect(resolveMaterializationTaskCardRead({ ...ready, data })).toEqual({ + pending: false, + contents: null, + }); + } + const failed = resolveMaterializationTaskCardRead({ ...ready, isError: true }); + expect(failed).toEqual({ pending: false, contents: null }); + expect( + buildUiWorktreeMaterializationRequest({ + requestedProfileId: ready.requestedProfileId, + contractSha256: "a".repeat(64), + taskCardPath: ready.taskCardPath, + taskCardContents: failed.contents, + })?.taskClasses, + ).toEqual(["unclassified"]); + }); + + it("does not block sending when card reading is disabled, cleared, or full is selected", () => { + const pending = { + enabled: true, + requestedProfileId: "governance-review", + taskCardPath: "ops/stef-task/new/stef-task.json", + debouncedTaskCardPath: "ops/stef-task/old/stef-task.json", + isPending: true, + isError: false, + data: undefined, + }; + for (const changed of [ + { enabled: false }, + { requestedProfileId: "full" }, + { taskCardPath: " " }, + ]) { + expect(resolveMaterializationTaskCardRead({ ...pending, ...changed })).toEqual({ + pending: false, + contents: null, + }); + } + }); + + it("builds a schema-valid unclassified sentinel that must fall back full", () => { + const sha = "a".repeat(64); + expect( + buildUiWorktreeMaterializationRequest({ + requestedProfileId: "governance-review", + contractSha256: sha, + taskCardPath: "", + taskCardContents: null, + }), + ).toEqual({ + requestedProfileId: "governance-review", + expectedContractSha256: sha, + taskId: "invalid-context", + taskSlug: "invalid-context", + taskCardPath: "invalid", + scopePaths: ["invalid"], + taskClasses: ["unclassified"], + }); + expect( + buildUiWorktreeMaterializationRequest({ + requestedProfileId: "governance-review", + contractSha256: sha, + taskCardPath: "ops/stef-task/task/stef-task.json", + taskCardContents: JSON.stringify({ + materialization: { + requestedProfileId: "governance-review", + expectedContractSha256: "b".repeat(64), + taskId: "OC-1", + taskSlug: "task", + scopePaths: ["docs/spec.md"], + taskClasses: ["source-task"], + }, + }), + })?.taskClasses, + ).toEqual(["unclassified"]); + }); + + it("surfaces sparse and requested-to-full effective states", () => { + const fullFallback = worktreeMaterializationPresentation({ + requestedProfileId: "governance-review", + effectiveProfileId: "full", + mode: "full", + reason: "hash-mismatch", + expectedContractSha256: null, + contractSha256: null, + manifestSha256: null, + conePaths: [], + requiredPaths: [], + taskId: null, + taskSlug: null, + }); + expect(fullFallback).toEqual({ + label: "Requested governance-review → full (hash-mismatch)", + canExpand: false, + fellBack: true, + }); + expect( + worktreeMaterializationPresentation({ + requestedProfileId: "governance-review", + effectiveProfileId: "full", + mode: "full", + reason: "user-expand-full", + expectedContractSha256: null, + contractSha256: null, + manifestSha256: null, + conePaths: [], + requiredPaths: [], + taskId: null, + taskSlug: null, + }), + ).toEqual({ label: "Expanded to full", canExpand: false, fellBack: false }); + expect( + worktreeMaterializationPresentation({ + requestedProfileId: "governance-review", + effectiveProfileId: "governance-review", + mode: "sparse", + reason: null, + expectedContractSha256: null, + contractSha256: null, + manifestSha256: null, + conePaths: [], + requiredPaths: [], + taskId: null, + taskSlug: null, + }), + ).toEqual({ + label: "Sparse profile: governance-review", + canExpand: true, + fellBack: false, + }); + }); +}); + describe("resolveBackgroundDraftWorkspaceOptions", () => { it("keeps New worktree selected without reusing the launched worktree", () => { expect( diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 1a6b1b775f41..10e31549b7a8 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -16,6 +16,8 @@ import { type ScopedThreadRef, type ThreadId, type TurnId, + type VcsWorktreeMaterializationRequest, + type VcsWorktreeMaterializationState, } from "@t3tools/contracts"; import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; import { @@ -61,6 +63,187 @@ export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10; export const MAX_HIDDEN_MOUNTED_PREVIEW_THREADS = 3; export const ENVIRONMENT_RECONNECT_WARNING_GRACE_MS = 2_000; +export interface WorktreeMaterializationUiContract { + readonly schemaVersion: string; + readonly profiles: ReadonlyArray<{ readonly id: string; readonly mode: "full" | "sparse" }>; +} + +export function parseWorktreeMaterializationUiContract( + contents: string, +): WorktreeMaterializationUiContract | null { + try { + const parsed = JSON.parse(contents) as Partial; + if ( + parsed.schemaVersion !== "clawd.worktree-materialization-profiles.v1" || + !Array.isArray(parsed.profiles) || + parsed.profiles.length === 0 || + parsed.profiles[0]?.id !== "full" || + parsed.profiles.some( + (profile) => + typeof profile?.id !== "string" || !["full", "sparse"].includes(profile.mode ?? ""), + ) + ) { + return null; + } + return parsed as WorktreeMaterializationUiContract; + } catch { + return null; + } +} + +export function resolveUiWorktreeMaterializationRequest(input: { + readonly requestedProfileId: string; + readonly contractSha256: string | null; + readonly taskCardContents: string | null; + readonly taskCardPath: string; +}): VcsWorktreeMaterializationRequest | undefined { + if (input.requestedProfileId === "full") return undefined; + try { + const card = JSON.parse(input.taskCardContents ?? "") as { + readonly issue?: { readonly id?: string }; + readonly issueId?: string; + readonly materialization?: Partial; + readonly verification?: { + readonly status?: string; + readonly args?: Readonly>; + }; + }; + const declared = card.materialization; + const verifierPathsValue = + card.verification?.status === "declared" ? card.verification.args?.paths : undefined; + const verificationArgsValid = + verifierPathsValue === undefined || + (Array.isArray(verifierPathsValue) && + verifierPathsValue.every( + (candidate) => typeof candidate === "string" && candidate.trim().length > 0, + )); + const verifierPaths = verificationArgsValid + ? ((verifierPathsValue ?? []) as ReadonlyArray).map((candidate) => candidate.trim()) + : []; + const normalizeIssueId = (value: unknown) => + String(value ?? "") + .trim() + .toLowerCase() + .replaceAll(/[^a-z0-9-]+/g, "-") + .replaceAll(/^-+|-+$/g, ""); + const selectedCardPath = input.taskCardPath.trim().replaceAll("\\", "/"); + const cardPathParts = selectedCardPath.split("/"); + const expectedTaskSlug = cardPathParts.length >= 2 ? cardPathParts.at(-2) : undefined; + const taskId = typeof declared?.taskId === "string" ? declared.taskId.trim() : ""; + const taskSlug = typeof declared?.taskSlug === "string" ? declared.taskSlug.trim() : ""; + const scopePaths = Array.isArray(declared?.scopePaths) + ? declared.scopePaths.map((scopePath) => + typeof scopePath === "string" ? scopePath.trim() : scopePath, + ) + : []; + const taskClasses = Array.isArray(declared?.taskClasses) + ? declared.taskClasses.map((taskClass) => + typeof taskClass === "string" ? taskClass.trim() : taskClass, + ) + : declared?.taskClasses; + if ( + !declared || + !verificationArgsValid || + declared.requestedProfileId !== input.requestedProfileId || + input.contractSha256 === null || + typeof declared.expectedContractSha256 !== "string" || + !/^[a-f0-9]{64}$/.test(declared.expectedContractSha256) || + declared.expectedContractSha256 !== input.contractSha256 || + !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(taskId) || + !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(taskSlug) || + input.taskCardPath.trim().length === 0 || + normalizeIssueId(card.issue?.id ?? card.issueId) !== normalizeIssueId(taskId) || + taskSlug !== expectedTaskSlug || + declared.taskCardPath !== selectedCardPath || + scopePaths.length === 0 || + scopePaths.some( + (scopePath) => typeof scopePath !== "string" || scopePath.trim().length === 0, + ) || + (declared.taskClasses !== undefined && + (!Array.isArray(taskClasses) || + taskClasses.some( + (taskClass) => typeof taskClass !== "string" || taskClass.trim().length === 0, + ))) + ) { + return undefined; + } + return { + requestedProfileId: declared.requestedProfileId, + expectedContractSha256: declared.expectedContractSha256, + taskId, + taskSlug, + taskCardPath: selectedCardPath, + scopePaths: [...new Set([...(scopePaths as Array), ...verifierPaths])], + ...(Array.isArray(taskClasses) ? { taskClasses: taskClasses as Array } : {}), + ...(declared.includeResearchTask === true ? { includeResearchTask: true } : {}), + }; + } catch { + return undefined; + } +} + +export function resolveMaterializationTaskCardRead(input: { + readonly enabled: boolean; + readonly requestedProfileId: string; + readonly taskCardPath: string; + readonly debouncedTaskCardPath: string; + readonly isPending: boolean; + readonly isError: boolean; + readonly data: { readonly contents: string; readonly truncated: boolean } | null | undefined; +}): { readonly pending: boolean; readonly contents: string | null } { + if (!input.enabled || input.requestedProfileId === "full" || !input.taskCardPath.trim()) { + return { pending: false, contents: null }; + } + const pathMatches = input.taskCardPath.trim() === input.debouncedTaskCardPath.trim(); + const pending = !pathMatches || (input.isPending && !input.isError); + return { + pending, + contents: + !pending && !input.isError && input.data?.truncated === false ? input.data.contents : null, + }; +} + +export function buildUiWorktreeMaterializationRequest(input: { + readonly requestedProfileId: string; + readonly contractSha256: string | null; + readonly taskCardContents: string | null; + readonly taskCardPath: string; +}): VcsWorktreeMaterializationRequest | undefined { + const fromCard = resolveUiWorktreeMaterializationRequest(input); + if (fromCard || input.requestedProfileId === "full") return fromCard; + if (!input.contractSha256) return undefined; + return { + requestedProfileId: input.requestedProfileId, + expectedContractSha256: input.contractSha256, + taskId: "invalid-context", + taskSlug: "invalid-context", + taskCardPath: input.taskCardPath.trim() || "invalid", + scopePaths: ["invalid"], + taskClasses: ["unclassified"], + }; +} + +export function worktreeMaterializationPresentation( + state: VcsWorktreeMaterializationState, +): { readonly label: string; readonly canExpand: boolean; readonly fellBack: boolean } | null { + if (state.requestedProfileId === "full" && state.effectiveProfileId === "full") return null; + if (state.mode === "full") { + if (state.reason === "user-expand-full") { + return { label: "Expanded to full", canExpand: false, fellBack: false }; + } + return { + label: `Requested ${state.requestedProfileId} → full${state.reason ? ` (${state.reason})` : ""}`, + canExpand: false, + fellBack: true, + }; + } + return { + label: `Sparse profile: ${state.effectiveProfileId}`, + canExpand: true, + fellBack: false, + }; +} + export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String); export function agentControlledBrowserCloseConfirmation( diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 1b9c106fe523..dc77f73032ab 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -24,7 +24,9 @@ import { resolveEnvironmentMachineKind, RuntimeMode, TerminalOpenInput, + FULL_WORKTREE_MATERIALIZATION_STATE, } from "@t3tools/contracts"; +import { sha256 as sha256Bytes } from "@noble/hashes/sha2"; import { type EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection"; import { wasBootstrapThreadDeleted } from "@t3tools/client-runtime/errors"; import { type CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates"; @@ -266,6 +268,7 @@ import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../termina import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions"; import { projectEnvironment } from "../state/projects"; import { useEnvironmentQuery } from "../state/query"; +import { useDebouncedValue } from "../state/queries"; import { environmentServerConfigsAtom, primaryServerAvailableEditorsAtom, @@ -385,6 +388,10 @@ import { resolveDraftHeroState, resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, + buildUiWorktreeMaterializationRequest, + resolveMaterializationTaskCardRead, + parseWorktreeMaterializationUiContract, + worktreeMaterializationPresentation, revokeBlobPreviewUrl, revokeUserMessagePreviewUrls, shouldWriteThreadErrorToCurrentServerThread, @@ -1383,6 +1390,10 @@ function ChatViewContent(props: ChatViewProps) { reportFailure: false, }); const switchGitRef = useAtomCommand(vcsEnvironment.switchRef, { reportFailure: false }); + const expandWorktreeMaterialization = useAtomCommand( + vcsEnvironment.expandWorktreeMaterialization, + { reportFailure: false }, + ); const setThreadRuntimeMode = useAtomCommand(threadEnvironment.setRuntimeMode, { reportFailure: false, }); @@ -4935,6 +4946,136 @@ function ChatViewContent(props: ChatViewProps) { requestedEnvMode: envMode, isGitRepo, }); + const [requestedMaterializationProfileId, setRequestedMaterializationProfileId] = + useState("full"); + const [materializationTaskCardPath, setMaterializationTaskCardPath] = useState(""); + const debouncedMaterializationTaskCardPath = useDebouncedValue(materializationTaskCardPath, 300); + const [materializationExpandPending, setMaterializationExpandPending] = useState(false); + useEffect(() => { + setRequestedMaterializationProfileId("full"); + setMaterializationTaskCardPath(""); + setMaterializationExpandPending(false); + }, [activeProject?.id, activeThread?.id]); + const materializationContractQuery = useEnvironmentQuery( + activeProject && + envMode === "worktree" && + (isLocalDraftThread || canOverrideServerThreadEnvMode) + ? projectEnvironment.readFile({ + environmentId, + input: { + cwd: activeProject.workspaceRoot, + relativePath: "config/worktree-materialization-profiles.json", + }, + }) + : null, + ); + const materializationSelectionEnabled = Boolean( + activeProject && + envMode === "worktree" && + (isLocalDraftThread || canOverrideServerThreadEnvMode), + ); + const materializationTaskCardQuery = useEnvironmentQuery( + activeProject && + materializationSelectionEnabled && + debouncedMaterializationTaskCardPath.trim().length > 0 + ? projectEnvironment.readFile({ + environmentId, + input: { + cwd: activeProject.workspaceRoot, + relativePath: debouncedMaterializationTaskCardPath.trim(), + }, + }) + : null, + ); + const materializationContract = useMemo( + () => + materializationContractQuery.data?.truncated === false + ? parseWorktreeMaterializationUiContract(materializationContractQuery.data.contents) + : null, + [materializationContractQuery.data], + ); + const materializationContractSha256 = useMemo(() => { + const source = materializationContractQuery.data; + if (!source || source.truncated || materializationContract === null) { + return null; + } + const bytes = new TextEncoder().encode(source.contents); + if (bytes.byteLength !== source.byteLength) { + return null; + } + return [...sha256Bytes(bytes)].map((byte) => byte.toString(16).padStart(2, "0")).join(""); + }, [materializationContract, materializationContractQuery.data]); + useEffect(() => { + if ( + requestedMaterializationProfileId !== "full" && + !materializationContract?.profiles.some( + (profile) => profile.id === requestedMaterializationProfileId, + ) + ) { + setRequestedMaterializationProfileId("full"); + } + }, [materializationContract, requestedMaterializationProfileId]); + const materializationTaskCardRead = resolveMaterializationTaskCardRead({ + enabled: materializationSelectionEnabled, + requestedProfileId: requestedMaterializationProfileId, + taskCardPath: materializationTaskCardPath, + debouncedTaskCardPath: debouncedMaterializationTaskCardPath, + isPending: materializationTaskCardQuery.isPending, + isError: materializationTaskCardQuery.error !== null, + data: materializationTaskCardQuery.data, + }); + const requestedWorktreeMaterialization = useMemo(() => { + return buildUiWorktreeMaterializationRequest({ + requestedProfileId: requestedMaterializationProfileId, + contractSha256: materializationContractSha256, + taskCardContents: materializationTaskCardRead.contents, + taskCardPath: materializationTaskCardPath, + }); + }, [ + materializationContractSha256, + materializationTaskCardPath, + materializationTaskCardRead.contents, + requestedMaterializationProfileId, + ]); + const materializationTaskCardReadPending = materializationTaskCardRead.pending; + const activeMaterializationPresentation = useMemo( + () => + activeThread + ? worktreeMaterializationPresentation( + activeThread.materialization ?? FULL_WORKTREE_MATERIALIZATION_STATE, + ) + : null, + [activeThread], + ); + const handleExpandWorktreeMaterialization = useCallback(async () => { + if (!activeThread?.worktreePath || materializationExpandPending) return; + setMaterializationExpandPending(true); + try { + const result = await expandWorktreeMaterialization({ + environmentId, + input: { + cwd: activeThread.worktreePath, + threadId: activeThread.id, + reason: "user-expand-full", + }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not expand worktree", + description: chatActionErrorMessage(squashAtomCommandFailure(result)), + }), + ); + } else if (result._tag === "Success") { + toastManager.add( + stackedThreadToast({ type: "success", title: "Worktree expanded to full" }), + ); + } + } finally { + setMaterializationExpandPending(false); + } + }, [activeThread, environmentId, expandWorktreeMaterialization, materializationExpandPending]); const localCheckoutBranchMismatch = useMemo( () => isServerThread @@ -6069,6 +6210,7 @@ function ChatViewContent(props: ChatViewProps) { isSendBusy || isConnecting || threadDetailLoading || + materializationTaskCardReadPending || sendInFlightRef.current || feedbackUploadsInFlightRef.current.has(routeThreadKey) ) { @@ -6658,6 +6800,9 @@ function ChatViewContent(props: ChatViewProps) { baseBranch: baseBranchForWorktree, branch: buildTemporaryWorktreeBranchName(randomHex), ...(startFromOrigin ? { startFromOrigin: true } : {}), + ...(requestedWorktreeMaterialization + ? { materialization: requestedWorktreeMaterialization } + : {}), }, runSetupScript: true, } @@ -7909,7 +8054,9 @@ function ChatViewContent(props: ChatViewProps) { ? "Sending feedback" : threadDetailLoading ? "Messages loading" - : null + : materializationTaskCardReadPending + ? "Reading task card" + : null } isPreparingWorktree={isPreparingWorktree} bannerItems={composerBannerItems} @@ -7997,6 +8144,92 @@ function ChatViewContent(props: ChatViewProps) { > {mountComposerContextStrip && (
+ {envMode === "worktree" && + (isLocalDraftThread || canOverrideServerThreadEnvMode) && + materializationContract && + materializationContractSha256 ? ( +
+ + {requestedMaterializationProfileId !== "full" ? ( + + ) : null} + {materializationTaskCardReadPending ? ( + + Reading task card. Sending will be available when it finishes. + + ) : null} + {requestedMaterializationProfileId !== "full" && + !materializationTaskCardReadPending && + requestedWorktreeMaterialization?.taskClasses?.includes( + "unclassified", + ) ? ( + + A missing or mismatched task card will safely materialize + full. + + ) : null} +
+ ) : null} + {isServerThread && + activeThread.worktreePath && + activeMaterializationPresentation ? ( +
+ + {activeMaterializationPresentation.label} + + {activeMaterializationPresentation.canExpand ? ( + + ) : null} +
+ ) : null} ( concurrency: vcsCommandConcurrency, onSettled: invalidateRefs, }), + expandWorktreeMaterialization: createEnvironmentRpcCommand(runtime, { + label: "environment-data:vcs:expand-worktree-materialization", + tag: WS_METHODS.vcsExpandWorktreeMaterialization, + scheduler: vcsCommandScheduler, + concurrency: vcsCommandConcurrency, + onSettled: invalidateRefs, + }), removeWorktree: createEnvironmentRpcCommand(runtime, { label: "environment-data:vcs:remove-worktree", tag: WS_METHODS.vcsRemoveWorktree, diff --git a/packages/contracts/src/git.test.ts b/packages/contracts/src/git.test.ts index 5dda491b009b..65587cdf5c00 100644 --- a/packages/contracts/src/git.test.ts +++ b/packages/contracts/src/git.test.ts @@ -44,6 +44,43 @@ describe("VcsCreateWorktreeInput", () => { expect(parsed.baseRefName).toBe("origin/main"); }); + + it("accepts an explicit hash-bound materialization request", () => { + const parsed = decodeCreateWorktreeInput({ + cwd: "/repo", + refName: "main", + path: "/tmp/worktree", + materialization: { + requestedProfileId: "governance-review", + expectedContractSha256: "a".repeat(64), + taskId: "OC-1", + taskSlug: "governance-task", + taskCardPath: "ops/stef-task/governance-task/stef-task.json", + scopePaths: ["docs/spec.md"], + }, + }); + + expect(parsed.materialization?.requestedProfileId).toBe("governance-review"); + expect(parsed.materialization?.expectedContractSha256).toBe("a".repeat(64)); + }); + + it("rejects a non-SHA contract identity", () => { + expect(() => + decodeCreateWorktreeInput({ + cwd: "/repo", + refName: "main", + path: "/tmp/worktree", + materialization: { + requestedProfileId: "governance-review", + expectedContractSha256: "not-a-sha", + taskId: "OC-1", + taskSlug: "governance-task", + taskCardPath: "ops/stef-task/governance-task/stef-task.json", + scopePaths: ["docs/spec.md"], + }, + }), + ).toThrow(); + }); }); describe("GitPreparePullRequestThreadInput", () => { diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index d39be34bf6e9..ba4958459d3e 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -47,6 +47,61 @@ const VcsStatusChangeRequestState = Schema.Literals(["open", "closed", "merged"] const GitPullRequestReference = TrimmedNonEmptyStringSchema; const GitPullRequestState = Schema.Literals(["open", "closed", "merged"]); const GitPreparePullRequestThreadMode = Schema.Literals(["local", "worktree"]); +const Sha256Hex = Schema.String.check(Schema.isPattern(/^[a-f0-9]{64}$/)); +const GitCommitHex = Schema.String.check(Schema.isPattern(/^[a-f0-9]{40,64}$/)); + +export const VcsWorktreeMaterializationRequest = Schema.Struct({ + requestedProfileId: TrimmedNonEmptyStringSchema, + expectedContractSha256: Sha256Hex, + taskId: TrimmedNonEmptyStringSchema, + taskSlug: TrimmedNonEmptyStringSchema, + taskCardPath: TrimmedNonEmptyStringSchema, + scopePaths: Schema.Array(TrimmedNonEmptyStringSchema).check(Schema.isMinLength(1)), + taskClasses: Schema.optional(Schema.Array(TrimmedNonEmptyStringSchema)), + includeResearchTask: Schema.optional(Schema.Boolean), +}); +export type VcsWorktreeMaterializationRequest = typeof VcsWorktreeMaterializationRequest.Type; + +export const VcsWorktreeMaterializationState = Schema.Struct({ + status: Schema.optional(Schema.Literals(["ready", "failed"])), + requestedProfileId: TrimmedNonEmptyStringSchema, + effectiveProfileId: TrimmedNonEmptyStringSchema, + mode: Schema.Literals(["full", "sparse"]), + reason: Schema.NullOr(Schema.String), + expectedContractSha256: Schema.NullOr(Sha256Hex), + contractSha256: Schema.NullOr(Sha256Hex), + manifestSha256: Schema.NullOr(Sha256Hex), + conePaths: Schema.Array(TrimmedNonEmptyStringSchema), + requiredPaths: Schema.Array(TrimmedNonEmptyStringSchema), + taskId: Schema.NullOr(TrimmedNonEmptyStringSchema), + taskSlug: Schema.NullOr(TrimmedNonEmptyStringSchema), + taskCardPath: Schema.optional(Schema.NullOr(TrimmedNonEmptyStringSchema)), + taskCardSha256: Schema.optional(Schema.NullOr(Sha256Hex)), + taskCardGenerated: Schema.optional(Schema.Boolean), + baseSha: Schema.optional(Schema.NullOr(GitCommitHex)), + scopePaths: Schema.optional(Schema.Array(TrimmedNonEmptyStringSchema)), + taskClasses: Schema.optional(Schema.Array(TrimmedNonEmptyStringSchema)), + includeResearchTask: Schema.optional(Schema.Boolean), + declaredDynamicPaths: Schema.optional(Schema.Array(TrimmedNonEmptyStringSchema)), +}); +export type VcsWorktreeMaterializationState = typeof VcsWorktreeMaterializationState.Type; + +export const FULL_WORKTREE_MATERIALIZATION_STATE: VcsWorktreeMaterializationState = { + status: "ready", + requestedProfileId: "full", + effectiveProfileId: "full", + mode: "full", + reason: "default-full", + expectedContractSha256: null, + contractSha256: null, + manifestSha256: null, + conePaths: [], + requiredPaths: [], + taskId: null, + taskSlug: null, + taskCardPath: null, + baseSha: null, +}; export const GitRunStackedActionToastRunAction = Schema.Struct({ kind: GitStackedAction, }); @@ -141,9 +196,18 @@ export const VcsCreateWorktreeInput = Schema.Struct({ newRefName: Schema.optional(TrimmedNonEmptyStringSchema), baseRefName: Schema.optional(TrimmedNonEmptyStringSchema), path: Schema.NullOr(TrimmedNonEmptyStringSchema), + materialization: Schema.optional(VcsWorktreeMaterializationRequest), }); export type VcsCreateWorktreeInput = typeof VcsCreateWorktreeInput.Type; +export const VcsExpandWorktreeMaterializationInput = Schema.Struct({ + cwd: TrimmedNonEmptyStringSchema, + threadId: ThreadId, + reason: Schema.optional(TrimmedNonEmptyStringSchema), +}); +export type VcsExpandWorktreeMaterializationInput = + typeof VcsExpandWorktreeMaterializationInput.Type; + export const GitPullRequestRefInput = Schema.Struct({ cwd: TrimmedNonEmptyStringSchema, reference: GitPullRequestReference, @@ -273,9 +337,16 @@ export type VcsListRefsResult = typeof VcsListRefsResult.Type; export const VcsCreateWorktreeResult = Schema.Struct({ worktree: VcsWorktree, + materialization: Schema.optional(VcsWorktreeMaterializationState), }); export type VcsCreateWorktreeResult = typeof VcsCreateWorktreeResult.Type; +export const VcsExpandWorktreeMaterializationResult = Schema.Struct({ + materialization: VcsWorktreeMaterializationState, +}); +export type VcsExpandWorktreeMaterializationResult = + typeof VcsExpandWorktreeMaterializationResult.Type; + export const GitResolvePullRequestResult = Schema.Struct({ pullRequest: GitResolvedPullRequest, }); diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 798d5a777d5d..05bf24457526 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -3,6 +3,8 @@ import type { VcsCreateRefResult, VcsCreateWorktreeInput, VcsCreateWorktreeResult, + VcsExpandWorktreeMaterializationInput, + VcsExpandWorktreeMaterializationResult, VcsInitInput, VcsListRefsInput, VcsListRefsResult, @@ -1338,6 +1340,9 @@ export interface EnvironmentApi { vcs: { listRefs: (input: VcsListRefsInput) => Promise; createWorktree: (input: VcsCreateWorktreeInput) => Promise; + expandWorktreeMaterialization: ( + input: VcsExpandWorktreeMaterializationInput, + ) => Promise; removeWorktree: (input: VcsRemoveWorktreeInput) => Promise; createRef: (input: VcsCreateRefInput) => Promise; switchRef: (input: VcsSwitchRefInput) => Promise; diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index d7e33ebb713b..e79c1092fef2 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -65,6 +65,33 @@ const decodeOrchestrationEvent = Schema.decodeUnknownEffect(OrchestrationEvent); const decodeThreadMetaUpdatedPayload = Schema.decodeUnknownEffect(ThreadMetaUpdatedPayload); const decodeDispatchCommandError = Schema.decodeUnknownEffect(OrchestrationDispatchCommandError); +it.effect("keeps materialization persistence off the client command surface", () => + Effect.gen(function* () { + const result = yield* Effect.result( + decodeClientOrchestrationCommand({ + type: "thread.materialization.set", + commandId: "cmd-forged-materialization", + threadId: "thread-1", + materialization: { + requestedProfileId: "governance-review", + effectiveProfileId: "governance-review", + mode: "sparse", + reason: null, + expectedContractSha256: "a".repeat(64), + contractSha256: "a".repeat(64), + manifestSha256: "b".repeat(64), + conePaths: ["docs"], + requiredPaths: ["docs/spec.md"], + taskId: "OC-1", + taskSlug: "task", + }, + createdAt: "2026-01-01T00:00:00.000Z", + }), + ); + assert.strictEqual(result._tag, "Failure"); + }), +); + it.effect("decodes a dispatch error after its bootstrap thread was deleted", () => Effect.gen(function* () { const error = yield* decodeDispatchCommandError({ diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 17cadc6d1d7f..e4b5250dd2ac 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -23,6 +23,7 @@ import { TurnId, } from "./baseSchemas.ts"; import { ProviderInstanceId } from "./providerInstance.ts"; +import { VcsWorktreeMaterializationRequest, VcsWorktreeMaterializationState } from "./git.ts"; export const ORCHESTRATION_WS_METHODS = { dispatchCommand: "orchestration.dispatchCommand", @@ -493,6 +494,7 @@ export const OrchestrationThread = Schema.Struct({ ), branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), + materialization: Schema.optional(VcsWorktreeMaterializationState), linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)), latestTurn: Schema.NullOr(OrchestrationLatestTurn), createdAt: IsoDateTime, @@ -571,6 +573,7 @@ export const OrchestrationThreadShell = Schema.Struct({ ), branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), + materialization: Schema.optional(VcsWorktreeMaterializationState), linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)), latestTurn: Schema.NullOr(OrchestrationLatestTurn), createdAt: IsoDateTime, @@ -943,6 +946,7 @@ const ThreadTurnStartBootstrapPrepareWorktree = Schema.Struct({ baseBranch: TrimmedNonEmptyString, branch: Schema.optional(TrimmedNonEmptyString), startFromOrigin: Schema.optional(Schema.Boolean), + materialization: Schema.optional(VcsWorktreeMaterializationRequest), }); const ThreadTurnStartBootstrap = Schema.Struct({ @@ -1168,6 +1172,14 @@ const ThreadTitleRegenerationCompleteCommand = Schema.Struct({ title: Schema.optional(TrimmedNonEmptyString), }); +const ThreadMaterializationSetCommand = Schema.Struct({ + type: Schema.Literal("thread.materialization.set"), + commandId: CommandId, + threadId: ThreadId, + materialization: VcsWorktreeMaterializationState, + createdAt: IsoDateTime, +}); + const InternalOrchestrationCommand = Schema.Union([ ThreadAutoSettleCommand, ThreadSessionSetCommand, @@ -1178,6 +1190,7 @@ const InternalOrchestrationCommand = Schema.Union([ ThreadActivityAppendCommand, ThreadRevertCompleteCommand, ThreadTitleRegenerationCompleteCommand, + ThreadMaterializationSetCommand, ]); export type InternalOrchestrationCommand = typeof InternalOrchestrationCommand.Type; @@ -1203,6 +1216,7 @@ export const OrchestrationEventType = Schema.Literals([ "thread.unpinned", "thread.pin-reordered", "thread.meta-updated", + "thread.materialization-set", "thread.runtime-mode-set", "thread.interaction-mode-set", "thread.message-sent", @@ -1272,6 +1286,12 @@ export const ThreadCreatedPayload = Schema.Struct({ updatedAt: IsoDateTime, }); +export const ThreadMaterializationSetPayload = Schema.Struct({ + threadId: ThreadId, + materialization: VcsWorktreeMaterializationState, + updatedAt: IsoDateTime, +}); + export const ThreadDeletedPayload = Schema.Struct({ threadId: ThreadId, deletedAt: IsoDateTime, @@ -1510,6 +1530,11 @@ export const OrchestrationEvent = Schema.Union([ type: Schema.Literal("thread.created"), payload: ThreadCreatedPayload, }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("thread.materialization-set"), + payload: ThreadMaterializationSetPayload, + }), Schema.Struct({ ...EventBaseFields, type: Schema.Literal("thread.deleted"), diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index c0ef8cd56d6d..ab1a1f7cbdf4 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -46,6 +46,8 @@ import { VcsCreateRefResult, VcsCreateWorktreeInput, VcsCreateWorktreeResult, + VcsExpandWorktreeMaterializationInput, + VcsExpandWorktreeMaterializationResult, VcsInitInput, VcsListRefsInput, VcsListRefsResult, @@ -262,6 +264,7 @@ export const WS_METHODS = { vcsRefreshStatus: "vcs.refreshStatus", vcsListRefs: "vcs.listRefs", vcsCreateWorktree: "vcs.createWorktree", + vcsExpandWorktreeMaterialization: "vcs.expandWorktreeMaterialization", vcsRemoveWorktree: "vcs.removeWorktree", vcsCreateRef: "vcs.createRef", vcsSwitchRef: "vcs.switchRef", @@ -885,6 +888,19 @@ export const WsVcsCreateWorktreeRpc = Rpc.make(WS_METHODS.vcsCreateWorktree, { error: Schema.Union([GitCommandError, EnvironmentAuthorizationError]), }); +export const WsVcsExpandWorktreeMaterializationRpc = Rpc.make( + WS_METHODS.vcsExpandWorktreeMaterialization, + { + payload: VcsExpandWorktreeMaterializationInput, + success: VcsExpandWorktreeMaterializationResult, + error: Schema.Union([ + GitCommandError, + OrchestrationDispatchCommandError, + EnvironmentAuthorizationError, + ]), + }, +); + export const WsVcsRemoveWorktreeRpc = Rpc.make(WS_METHODS.vcsRemoveWorktree, { payload: VcsRemoveWorktreeInput, error: Schema.Union([GitCommandError, EnvironmentAuthorizationError]), @@ -1241,6 +1257,7 @@ export const WsRpcGroup = RpcGroup.make( WsGitPreparePullRequestThreadRpc, WsVcsListRefsRpc, WsVcsCreateWorktreeRpc, + WsVcsExpandWorktreeMaterializationRpc, WsVcsRemoveWorktreeRpc, WsVcsCreateRefRpc, WsVcsSwitchRefRpc,