Skip to content
Open
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
66 changes: 66 additions & 0 deletions apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import {
type OrchestrationGetFullThreadDiffInput,
type OrchestrationGetFullThreadDiffResult,
type OrchestrationGetTurnDiffResult as OrchestrationGetTurnDiffResultType,
type OrchestrationGetWorkingTreeDiffInput,
type OrchestrationGetWorkingTreeDiffResult,
} from "@t3tools/contracts";
import { Effect, Layer, Option, Schema } from "effect";

Expand Down Expand Up @@ -159,9 +161,73 @@ const make = Effect.gen(function* () {
toTurnCount: input.toTurnCount,
}).pipe(Effect.map((result): OrchestrationGetFullThreadDiffResult => result));

const getWorkingTreeDiff: CheckpointDiffQueryShape["getWorkingTreeDiff"] = Effect.fn(
"getWorkingTreeDiff",
)(function* (input: OrchestrationGetWorkingTreeDiffInput) {
const operation = "CheckpointDiffQuery.getWorkingTreeDiff";

const threadContext = yield* projectionSnapshotQuery.getThreadCheckpointContext(input.threadId);

if (Option.isNone(threadContext)) {
return yield* new CheckpointInvariantError({
operation,
detail: `Thread '${input.threadId}' not found.`,
});
}

const workspaceCwd = threadContext.value.worktreePath ?? threadContext.value.workspaceRoot;
if (!workspaceCwd) {
return yield* new CheckpointInvariantError({
operation,
detail: `Workspace path missing for thread '${input.threadId}' when computing working tree diff.`,
});
}

// Find the latest checkpoint to diff against.
const maxTurnCount = threadContext.value.checkpoints.reduce(
(max, checkpoint) => Math.max(max, checkpoint.checkpointTurnCount),
0,
);

// Use the latest checkpoint as the base, or fallback to checkpoint 0 (initial state).
const baseCheckpointRef =
maxTurnCount > 0
? (threadContext.value.checkpoints.find(
(checkpoint) => checkpoint.checkpointTurnCount === maxTurnCount,
)?.checkpointRef ?? checkpointRefForThreadTurn(input.threadId, 0))
: checkpointRefForThreadTurn(input.threadId, 0);

const refExists = yield* checkpointStore.hasCheckpointRef({
cwd: workspaceCwd,
checkpointRef: baseCheckpointRef,
});

if (!refExists) {
// If no checkpoint exists yet, return an empty diff.
const emptyResult: OrchestrationGetWorkingTreeDiffResult = {
threadId: input.threadId,
diff: "",
};
return emptyResult;
}

const diff = yield* checkpointStore.diffCheckpointToWorkTree({
cwd: workspaceCwd,
fromCheckpointRef: baseCheckpointRef,
fallbackFromToHead: true,
});

const result: OrchestrationGetWorkingTreeDiffResult = {
threadId: input.threadId,
diff,
};
return result;
});

return {
getTurnDiff,
getFullThreadDiff,
getWorkingTreeDiff,
} satisfies CheckpointDiffQueryShape;
});

Expand Down
34 changes: 34 additions & 0 deletions apps/server/src/checkpointing/Layers/CheckpointStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,39 @@ const makeCheckpointStore = Effect.gen(function* () {
},
);

const diffCheckpointToWorkTree: CheckpointStoreShape["diffCheckpointToWorkTree"] = Effect.fn(
"diffCheckpointToWorkTree",
)(function* (input) {
const operation = "CheckpointStore.diffCheckpointToWorkTree";

let fromCommitOid = yield* resolveCheckpointCommit(input.cwd, input.fromCheckpointRef);

if (!fromCommitOid && input.fallbackFromToHead === true) {
const headCommit = yield* resolveHeadCommit(input.cwd);
if (headCommit) {
fromCommitOid = headCommit;
}
}

if (!fromCommitOid) {
return yield* new GitCommandError({
operation,
command: "git diff",
cwd: input.cwd,
detail: "Checkpoint ref is unavailable for working tree diff operation.",
});
}

const result = yield* git.execute({
operation,
cwd: input.cwd,
args: ["diff", "--patch", "--minimal", "--no-color", fromCommitOid],
maxOutputBytes: CHECKPOINT_DIFF_MAX_OUTPUT_BYTES,
});

return result.stdout;
});

const deleteCheckpointRefs: CheckpointStoreShape["deleteCheckpointRefs"] = Effect.fn(
"deleteCheckpointRefs",
)(function* (input) {
Expand All @@ -278,6 +311,7 @@ const makeCheckpointStore = Effect.gen(function* () {
hasCheckpointRef,
restoreCheckpoint,
diffCheckpoints,
diffCheckpointToWorkTree,
deleteCheckpointRefs,
} satisfies CheckpointStoreShape;
});
Expand Down
12 changes: 12 additions & 0 deletions apps/server/src/checkpointing/Services/CheckpointDiffQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import type {
OrchestrationGetFullThreadDiffResult,
OrchestrationGetTurnDiffInput,
OrchestrationGetTurnDiffResult,
OrchestrationGetWorkingTreeDiffInput,
OrchestrationGetWorkingTreeDiffResult,
} from "@t3tools/contracts";
import { Context } from "effect";
import type { Effect } from "effect";
Expand Down Expand Up @@ -38,6 +40,16 @@ export interface CheckpointDiffQueryShape {
readonly getFullThreadDiff: (
input: OrchestrationGetFullThreadDiffInput,
) => Effect.Effect<OrchestrationGetFullThreadDiffResult, CheckpointServiceError>;

/**
* Read the working tree diff relative to the latest checkpoint.
*
* Diffs the latest checkpoint commit against the current working tree to
* capture uncommitted user edits.
*/
readonly getWorkingTreeDiff: (
input: OrchestrationGetWorkingTreeDiffInput,
) => Effect.Effect<OrchestrationGetWorkingTreeDiffResult, CheckpointServiceError>;
}

/**
Expand Down
15 changes: 15 additions & 0 deletions apps/server/src/checkpointing/Services/CheckpointStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ export interface DiffCheckpointsInput {
readonly fallbackFromToHead?: boolean;
}

export interface DiffCheckpointToWorkTreeInput {
readonly cwd: string;
readonly fromCheckpointRef: CheckpointRef;
readonly fallbackFromToHead?: boolean;
}

export interface DeleteCheckpointRefsInput {
readonly cwd: string;
readonly checkpointRefs: ReadonlyArray<CheckpointRef>;
Expand Down Expand Up @@ -82,6 +88,15 @@ export interface CheckpointStoreShape {
input: DiffCheckpointsInput,
) => Effect.Effect<string, CheckpointStoreError>;

/**
* Compute patch diff between a checkpoint ref and the current working tree.
*
* Can optionally treat missing checkpoint ref as `HEAD`.
*/
readonly diffCheckpointToWorkTree: (
input: DiffCheckpointToWorkTreeInput,
) => Effect.Effect<string, CheckpointStoreError>;

/**
* Delete the provided checkpoint refs.
*
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/keybindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [
{ key: "mod+n", command: "terminal.new", when: "terminalFocus" },
{ key: "mod+w", command: "terminal.close", when: "terminalFocus" },
{ key: "mod+d", command: "diff.toggle", when: "!terminalFocus" },
{ key: "mod+e", command: "files.toggle", when: "!terminalFocus" },
{ key: "mod+k", command: "commandPalette.toggle", when: "!terminalFocus" },
{ key: "mod+n", command: "chat.new", when: "!terminalFocus" },
{ key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" },
Expand Down
9 changes: 9 additions & 0 deletions apps/server/src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ import {
} from "./environment/Services/ServerEnvironment.ts";
import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries.ts";
import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts";
import { WorkspaceFileExplorerLive } from "./workspace/Layers/WorkspaceFileExplorer.ts";
import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts";
import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts";
import { ServerAuthLive } from "./auth/Layers/ServerAuth.ts";
Expand Down Expand Up @@ -200,6 +201,10 @@ const workspaceAndProjectServicesLayer = Layer.mergeAll(
Layer.provide(WorkspacePathsLive),
Layer.provide(WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive))),
),
WorkspaceFileExplorerLive.pipe(
Layer.provide(WorkspacePathsLive),
Layer.provide(WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive))),
),
ProjectFaviconResolverLive,
);

Expand Down Expand Up @@ -394,6 +399,10 @@ const buildAppUnderTest = (options?: {
Layer.provide(WorkspacePathsLive),
Layer.provide(workspaceEntriesLayer),
),
WorkspaceFileExplorerLive.pipe(
Layer.provide(WorkspacePathsLive),
Layer.provide(workspaceEntriesLayer),
),
ProjectFaviconResolverLive,
);
const gitStatusBroadcasterLayer = options?.layers?.gitStatusBroadcaster
Expand Down
7 changes: 7 additions & 0 deletions apps/server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResol
import { RepositoryIdentityResolverLive } from "./project/Layers/RepositoryIdentityResolver.ts";
import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries.ts";
import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts";
import { WorkspaceFileExplorerLive } from "./workspace/Layers/WorkspaceFileExplorer.ts";
import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts";
import { ProjectSetupScriptRunnerLive } from "./project/Layers/ProjectSetupScriptRunner.ts";
import { ObservabilityLive } from "./observability/Layers/Observability.ts";
Expand Down Expand Up @@ -208,10 +209,16 @@ const WorkspaceFileSystemLayerLive = WorkspaceFileSystemLive.pipe(
Layer.provide(WorkspaceEntriesLayerLive),
);

const WorkspaceFileExplorerLayerLive = WorkspaceFileExplorerLive.pipe(
Layer.provide(WorkspacePathsLive),
Layer.provide(WorkspaceEntriesLayerLive),
);

const WorkspaceLayerLive = Layer.mergeAll(
WorkspacePathsLive,
WorkspaceEntriesLayerLive,
WorkspaceFileSystemLayerLive,
WorkspaceFileExplorerLayerLive,
);

const AuthLayerLive = ServerAuthLive.pipe(
Expand Down
Loading