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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ function makeSnapshot(input: {
id: input.projectId,
title: "Project",
workspaceRoot: input.workspaceRoot,
workspaceState: "available",
defaultModelSelection: null,
scripts: [],
createdAt: "2026-01-01T00:00:00.000Z",
Expand All @@ -51,6 +52,9 @@ function makeSnapshot(input: {
runtimeMode: "full-access",
branch: null,
worktreePath: input.worktreePath,
effectiveCwd: input.worktreePath ?? input.workspaceRoot,
effectiveCwdSource: input.worktreePath ? "worktree" : "project",
effectiveCwdState: "available",
latestTurn: {
turnId: TurnId.makeUnsafe("turn-1"),
state: "completed",
Expand Down
17 changes: 16 additions & 1 deletion apps/server/src/git/Layers/GitCore.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { existsSync } from "node:fs";
import os from "node:os";
import path from "node:path";

import * as NodeServices from "@effect/platform-node/NodeServices";
import { it } from "@effect/vitest";
import { Effect, FileSystem, Layer, PlatformError, Scope } from "effect";
import { Cause, Effect, Exit, FileSystem, Layer, PlatformError, Scope } from "effect";
import { describe, expect, vi } from "vitest";

import { GitCoreLive, makeGitCore } from "./GitCore.ts";
Expand Down Expand Up @@ -1283,6 +1284,20 @@ it.layer(TestLayer)("git integration", (it) => {
}),
);

it.effect("fails cleanly when statusDetails is requested for a missing workspace", () =>
Effect.gen(function* () {
const missingCwd = path.join(os.tmpdir(), `git-missing-${crypto.randomUUID()}`);
const result = yield* Effect.exit((yield* GitCore).statusDetails(missingCwd));

expect(Exit.isFailure(result)).toBe(true);
if (Exit.isFailure(result)) {
const error = Cause.squash(result.cause);
expect(error).toBeInstanceOf(GitCommandError);
expect((error as GitCommandError).detail).toContain("Workspace folder is missing");
}
}),
);

it.effect("computes ahead count against base branch when no upstream is configured", () =>
Effect.gen(function* () {
const tmp = yield* makeTmpDir();
Expand Down
41 changes: 39 additions & 2 deletions apps/server/src/git/Layers/GitCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
} from "../Services/GitCore.ts";
import { ServerConfig } from "../../config.ts";
import { decodeJsonResult } from "@t3tools/shared/schemaJson";
import { assertWorkspaceDirectory } from "../../workspacePaths.ts";

const DEFAULT_TIMEOUT_MS = 30_000;
const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000;
Expand Down Expand Up @@ -518,12 +519,13 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {
const { worktreesDir } = yield* ServerConfig;

let execute: GitCoreShape["execute"];
let rawExecute: GitCoreShape["execute"];

if (options?.executeOverride) {
execute = options.executeOverride;
rawExecute = options.executeOverride;
} else {
const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
execute = Effect.fnUntraced(function* (input) {
rawExecute = Effect.fnUntraced(function* (input) {
const commandInput = {
...input,
args: [...input.args],
Expand Down Expand Up @@ -613,6 +615,22 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {
});
}

execute = Effect.fnUntraced(function* (input) {
yield* assertWorkspaceDirectory(input.cwd, input.operation).pipe(
Effect.mapError(
(error) =>
new GitCommandError({
operation: input.operation,
command: quoteGitCommand(input.args),
cwd: input.cwd,
detail: error.message,
cause: error,
}),
),
);
return yield* rawExecute(input);
});

const executeGit = (
operation: string,
cwd: string,
Expand Down Expand Up @@ -655,6 +673,20 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {
}),
);

const ensureGitWorkspace = (operation: string, cwd: string, args: readonly string[] = []) =>
assertWorkspaceDirectory(cwd, operation).pipe(
Effect.mapError(
(error) =>
new GitCommandError({
operation,
command: quoteGitCommand(args),
cwd,
detail: error.message,
cause: error,
}),
),
);

const runGit = (
operation: string,
cwd: string,
Expand Down Expand Up @@ -1041,6 +1073,11 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {
});

const statusDetails: GitCoreShape["statusDetails"] = Effect.fn("statusDetails")(function* (cwd) {
yield* ensureGitWorkspace("GitCore.statusDetails", cwd, [
"status",
"--porcelain=2",
"--branch",
]);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant workspace assertion in statusDetails before execute

Low Severity

ensureGitWorkspace in statusDetails is redundant because the execute wrapper (line 618–632) already calls assertWorkspaceDirectory for every git command. The three subsequent runGitStdout calls each go through execute, so the workspace is validated four times total. The ensureGitWorkspace helper itself is only used in this one location.

Fix in Cursor Fix in Web

yield* refreshStatusUpstreamIfStale(cwd).pipe(Effect.ignoreCause({ log: true }));

const [statusStdout, unstagedNumstatStdout, stagedNumstatStdout] = yield* Effect.all(
Expand Down
27 changes: 25 additions & 2 deletions apps/server/src/orchestration/Layers/CheckpointReactor.ts
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High

const cwd = input.preferSessionRuntime
? (Option.match(fromSession, {
onNone: () => undefined,
onSome: (runtime) => runtime.cwd,
}) ?? fromThread)
: (fromThread ??
Option.match(fromSession, {
onNone: () => undefined,
onSome: (runtime) => runtime.cwd,
}));
if (input.thread.effectiveCwdState !== "available" || !cwd) {
return undefined;
}
if (!isGitWorkspace(cwd)) {
return undefined;
}
return cwd;

When preferSessionRuntime is true and the session provides a valid cwd, the function still checks input.thread.effectiveCwdState !== "available" and returns undefined if the thread's cached state is stale. This incorrectly blocks checkpoint operations even though a usable workspace exists via the session. Consider validating the state of the actually-resolved cwd (as done in handleRevertRequested) rather than the thread's cached property.

    const cwd = input.preferSessionRuntime
      ? (Option.match(fromSession, {
          onNone: () => undefined,
          onSome: (runtime) => runtime.cwd,
        }) ?? fromThread)
      : (fromThread ??
        Option.match(fromSession, {
          onNone: () => undefined,
          onSome: (runtime) => runtime.cwd,
        }));

-    if (input.thread.effectiveCwdState !== "available" || !cwd) {
+    const resolvedState = yield* Effect.promise(() =>
+      inspectWorkspacePathState(cwd ?? input.thread.effectiveCwd ?? ""),
+    );
+    if (resolvedState !== "available" || !cwd) {
       return undefined;
     }
     if (!isGitWorkspace(cwd)) {
Also found in 1 other location(s)

apps/web/src/components/BranchToolbar.tsx:50

When there is no serverThread (draft thread) but draftThread?.worktreePath is set (e.g., when the user selected a branch that already lives in an existing worktree), the new branchCwd computation ignores the worktree path entirely and falls back to activeProject.cwd. The old code used activeWorktreePath ?? activeProject?.cwd ?? null, which correctly prioritized the worktree path. This causes git operations (branch listing, checkout) to run against the wrong directory.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/server/src/orchestration/Layers/CheckpointReactor.ts around lines 176-193:

When `preferSessionRuntime` is true and the session provides a valid `cwd`, the function still checks `input.thread.effectiveCwdState !== "available"` and returns `undefined` if the thread's cached state is stale. This incorrectly blocks checkpoint operations even though a usable workspace exists via the session. Consider validating the state of the actually-resolved `cwd` (as done in `handleRevertRequested`) rather than the thread's cached property.

Evidence trail:
apps/server/src/orchestration/Layers/CheckpointReactor.ts lines 159-193 (REVIEWED_COMMIT): `resolveCheckpointCwd` function resolves cwd preferring session runtime (lines 176-185) but checks `input.thread.effectiveCwdState !== "available"` at line 186 regardless of cwd source.

apps/server/src/orchestration/Layers/CheckpointReactor.ts lines 568-607 (REVIEWED_COMMIT): `handleRevertRequested` validates session runtime's cwd state using `inspectWorkspacePathState(sessionRuntime.value.cwd)` at line 596-598, checking the actual resolved cwd's state.

Also found in 1 other location(s):
- apps/web/src/components/BranchToolbar.tsx:50 -- When there is no `serverThread` (draft thread) but `draftThread?.worktreePath` is set (e.g., when the user selected a branch that already lives in an existing worktree), the new `branchCwd` computation ignores the worktree path entirely and falls back to `activeProject.cwd`. The old code used `activeWorktreePath ?? activeProject?.cwd ?? null`, which correctly prioritized the worktree path. This causes git operations (branch listing, checkout) to run against the wrong directory.

Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ import { RuntimeReceiptBus } from "../Services/RuntimeReceiptBus.ts";
import { CheckpointStoreError } from "../../checkpointing/Errors.ts";
import { OrchestrationDispatchError } from "../Errors.ts";
import { isGitRepository } from "../../git/Utils.ts";
import {
inspectWorkspacePathState,
resolveWorkspaceUnavailableReason,
} from "../../workspacePaths.ts";

type ReactorInput =
| {
Expand Down Expand Up @@ -154,7 +158,12 @@ const make = Effect.gen(function* () {
// a git repository.
const resolveCheckpointCwd = Effect.fnUntraced(function* (input: {
readonly threadId: ThreadId;
readonly thread: { readonly projectId: ProjectId; readonly worktreePath: string | null };
readonly thread: {
readonly projectId: ProjectId;
readonly worktreePath: string | null;
readonly effectiveCwd: string | null;
readonly effectiveCwdState: string;
};
readonly projects: ReadonlyArray<{ readonly id: ProjectId; readonly workspaceRoot: string }>;
readonly preferSessionRuntime: boolean;
}): Effect.fn.Return<string | undefined> {
Expand All @@ -175,7 +184,7 @@ const make = Effect.gen(function* () {
onSome: (runtime) => runtime.cwd,
}));

if (!cwd) {
if (input.thread.effectiveCwdState !== "available" || !cwd) {
return undefined;
}
if (!isGitWorkspace(cwd)) {
Expand Down Expand Up @@ -583,6 +592,20 @@ const make = Effect.gen(function* () {
}).pipe(Effect.catch(() => Effect.void));
return;
}
const sessionWorkspaceState = yield* Effect.promise(() =>
inspectWorkspacePathState(sessionRuntime.value.cwd),
);
if (sessionWorkspaceState !== "available") {
yield* appendRevertFailureActivity({
threadId: event.payload.threadId,
turnCount: event.payload.turnCount,
detail:
resolveWorkspaceUnavailableReason(sessionWorkspaceState) ??
"Workspace folder is unavailable.",
createdAt: now,
}).pipe(Effect.catch(() => Effect.void));
return;
}
if (!isGitWorkspace(sessionRuntime.value.cwd)) {
yield* appendRevertFailureActivity({
threadId: event.payload.threadId,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";

import { CheckpointRef, EventId, MessageId, ProjectId, ThreadId, TurnId } from "@t3tools/contracts";
import { assert, it } from "@effect/vitest";
import { Effect, Layer } from "effect";
Expand All @@ -23,6 +27,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
Effect.gen(function* () {
const snapshotQuery = yield* ProjectionSnapshotQuery;
const sql = yield* SqlClient.SqlClient;
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), "projection-snapshot-"));

yield* sql`DELETE FROM projection_projects`;
yield* sql`DELETE FROM projection_state`;
Expand All @@ -43,7 +48,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
VALUES (
'project-1',
'Project 1',
'/tmp/project-1',
${workspaceRoot},
'{"provider":"codex","model":"gpt-5-codex"}',
'[{"id":"script-1","name":"Build","command":"bun run build","icon":"build","runOnWorktreeCreate":false}]',
'2026-02-24T00:00:00.000Z',
Expand Down Expand Up @@ -233,7 +238,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
{
id: asProjectId("project-1"),
title: "Project 1",
workspaceRoot: "/tmp/project-1",
workspaceRoot,
workspaceState: "available",
defaultModelSelection: {
provider: "codex",
model: "gpt-5-codex",
Expand Down Expand Up @@ -265,6 +271,9 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
runtimeMode: "full-access",
branch: null,
worktreePath: null,
effectiveCwd: workspaceRoot,
effectiveCwdSource: "project",
effectiveCwdState: "available",
latestTurn: {
turnId: asTurnId("turn-1"),
state: "completed",
Expand Down
95 changes: 75 additions & 20 deletions apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
type OrchestrationThread,
type OrchestrationThreadActivity,
ModelSelection,
type WorkspaceAvailabilityState,
} from "@t3tools/contracts";
import { Effect, Layer, Schema, Struct } from "effect";
import * as SqlClient from "effect/unstable/sql/SqlClient";
Expand All @@ -42,6 +43,7 @@ import {
ProjectionSnapshotQuery,
type ProjectionSnapshotQueryShape,
} from "../Services/ProjectionSnapshotQuery.ts";
import { inspectWorkspacePathState } from "../../workspacePaths.ts";

const decodeReadModel = Schema.decodeUnknownEffect(OrchestrationReadModel);
const ProjectionProjectDbRowSchema = ProjectionProject.mapFields(
Expand Down Expand Up @@ -135,6 +137,12 @@ function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: st
: toPersistenceSqlError(sqlOperation)(cause);
}

type EffectiveWorkspaceMetadata = {
readonly effectiveCwd: string | null;
readonly effectiveCwdSource: "project" | "worktree" | null;
readonly effectiveCwdState: WorkspaceAvailabilityState;
};

const makeProjectionSnapshotQuery = Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;

Expand Down Expand Up @@ -538,37 +546,84 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
});
}

const workspaceRoots = new Set(projectRows.map((row) => row.workspaceRoot));
const worktreePaths = new Set(
threadRows.flatMap((row) => (row.worktreePath ? [row.worktreePath] : [])),
);
const workspaceStateEntries = yield* Effect.promise(() =>
Promise.all(
[...new Set([...workspaceRoots, ...worktreePaths])].map(async (workspacePath) => {
const workspaceState = await inspectWorkspacePathState(workspacePath);
return [workspacePath, workspaceState] as const;
}),
),
);
const workspaceStates = new Map(workspaceStateEntries);
const projectRowsById = new Map(projectRows.map((row) => [row.projectId, row] as const));
const projectWorkspaceStateById = new Map(
projectRows.map((row) => [
row.projectId,
workspaceStates.get(row.workspaceRoot) ??
("inaccessible" satisfies WorkspaceAvailabilityState),
]),
);

const projects: ReadonlyArray<OrchestrationProject> = projectRows.map((row) => ({
id: row.projectId,
title: row.title,
workspaceRoot: row.workspaceRoot,
workspaceState:
projectWorkspaceStateById.get(row.projectId) ??
("inaccessible" satisfies WorkspaceAvailabilityState),
defaultModelSelection: row.defaultModelSelection,
scripts: row.scripts,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
deletedAt: row.deletedAt,
}));

const threads: ReadonlyArray<OrchestrationThread> = threadRows.map((row) => ({
id: row.threadId,
projectId: row.projectId,
title: row.title,
modelSelection: row.modelSelection,
runtimeMode: row.runtimeMode,
interactionMode: row.interactionMode,
branch: row.branch,
worktreePath: row.worktreePath,
latestTurn: latestTurnByThread.get(row.threadId) ?? null,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
archivedAt: row.archivedAt,
deletedAt: row.deletedAt,
messages: messagesByThread.get(row.threadId) ?? [],
proposedPlans: proposedPlansByThread.get(row.threadId) ?? [],
activities: activitiesByThread.get(row.threadId) ?? [],
checkpoints: checkpointsByThread.get(row.threadId) ?? [],
session: sessionsByThread.get(row.threadId) ?? null,
}));
const threads: ReadonlyArray<OrchestrationThread> = threadRows.map((row) => {
const effectiveWorkspace: EffectiveWorkspaceMetadata =
row.worktreePath !== null
? {
effectiveCwd: row.worktreePath,
effectiveCwdSource: "worktree",
effectiveCwdState:
workspaceStates.get(row.worktreePath) ??
("inaccessible" satisfies WorkspaceAvailabilityState),
}
: {
effectiveCwd: projectRowsById.get(row.projectId)?.workspaceRoot ?? null,
effectiveCwdSource: projectRowsById.has(row.projectId) ? "project" : null,
effectiveCwdState:
projectWorkspaceStateById.get(row.projectId) ??
("inaccessible" satisfies WorkspaceAvailabilityState),
};

return {
id: row.threadId,
projectId: row.projectId,
title: row.title,
modelSelection: row.modelSelection,
runtimeMode: row.runtimeMode,
interactionMode: row.interactionMode,
branch: row.branch,
worktreePath: row.worktreePath,
effectiveCwd: effectiveWorkspace.effectiveCwd,
effectiveCwdSource: effectiveWorkspace.effectiveCwdSource,
effectiveCwdState: effectiveWorkspace.effectiveCwdState,
latestTurn: latestTurnByThread.get(row.threadId) ?? null,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
archivedAt: row.archivedAt,
deletedAt: row.deletedAt,
messages: messagesByThread.get(row.threadId) ?? [],
proposedPlans: proposedPlansByThread.get(row.threadId) ?? [],
activities: activitiesByThread.get(row.threadId) ?? [],
checkpoints: checkpointsByThread.get(row.threadId) ?? [],
session: sessionsByThread.get(row.threadId) ?? null,
};
});

const snapshot = {
snapshotSequence: computeSnapshotSequence(stateRows),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value);
const deriveServerPathsSync = (baseDir: string, devUrl: URL | undefined) =>
Effect.runSync(deriveServerPaths(baseDir, devUrl).pipe(Effect.provide(NodeServices.layer)));

fs.mkdirSync("/tmp/provider-project", { recursive: true });

async function waitFor(
predicate: () => boolean | Promise<boolean>,
timeoutMs = 2000,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
type ProviderCommandReactorShape,
} from "../Services/ProviderCommandReactor.ts";
import { ServerSettingsService } from "../../serverSettings.ts";
import { assertWorkspaceDirectory } from "../../workspacePaths.ts";

type ProviderIntentEvent = Extract<
OrchestrationEvent,
Expand Down Expand Up @@ -254,6 +255,12 @@ const make = Effect.gen(function* () {
thread,
projects: readModel.projects,
});
if (effectiveCwd) {
yield* assertWorkspaceDirectory(
effectiveCwd,
"ProviderCommandReactor.ensureSessionForThread",
);
}

const resolveActiveSession = (threadId: ThreadId) =>
providerService
Expand Down
Loading
Loading