Skip to content

Commit 22d75f4

Browse files
committed
feat(server): make rollback recovery durable
Refs #200
1 parent c908bb2 commit 22d75f4

35 files changed

Lines changed: 4532 additions & 31 deletions

apps/server/src/git/GitWorkflowService.test.ts

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,16 @@ import { assert, describe, expect, it, vi } from "@effect/vitest";
22
import * as Effect from "effect/Effect";
33
import * as Layer from "effect/Layer";
44

5-
import { VcsRepositoryDetectionError } from "@t3tools/contracts";
5+
import { ProjectId, ThreadId, VcsRepositoryDetectionError } from "@t3tools/contracts";
66

77
import * as GitManager from "./GitManager.ts";
88
import * as GitWorkflowService from "./GitWorkflowService.ts";
99
import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
1010
import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
11+
import {
12+
RollbackSagaRepository,
13+
type RollbackSagaRecord,
14+
} from "../persistence/Services/RollbackSagas.ts";
1115

1216
function makeLayer(input: {
1317
readonly detect: VcsDriverRegistry.VcsDriverRegistry["Service"]["detect"];
@@ -189,4 +193,77 @@ describe("GitWorkflowService", () => {
189193
),
190194
);
191195
});
196+
197+
it.effect(
198+
"fails closed before a Git mutation when the workspace rollback lease is active",
199+
() => {
200+
const threadId = ThreadId.make("thread-git-fence");
201+
const projectId = ProjectId.make("project-git-fence");
202+
const record = {
203+
operationId: "operation-git-fence",
204+
requestEventId: "event-git-fence",
205+
threadId,
206+
projectId,
207+
workspaceKey: "workspace-git-fence",
208+
phase: "workspace-apply-started",
209+
terminal: false,
210+
ownerId: null,
211+
version: 1,
212+
state: {
213+
operationId: "operation-git-fence",
214+
requestEventId: "event-git-fence",
215+
threadId,
216+
projectId,
217+
workspaceKey: "workspace-git-fence",
218+
workspaceCwd: "/repo",
219+
sourceRevision: 2,
220+
targetRevision: 1,
221+
sourceCheckpointRef: "refs/source" as never,
222+
sourceCheckpointOid: "a".repeat(40),
223+
targetCheckpointRef: "refs/target" as never,
224+
targetCheckpointOid: "b".repeat(40),
225+
targetCheckpointDigest: "target-tree",
226+
providerInstanceId: "fake" as never,
227+
sessionIncarnationId: "session" as never,
228+
phase: "workspace-apply-started" as const,
229+
attempt: 0,
230+
lastErrorCode: null,
231+
compensation: "none" as const,
232+
cleanup: "pending" as const,
233+
sourceAnchor: null,
234+
sourceAnchorDigest: null,
235+
desiredAnchor: { leaf: "private" },
236+
desiredAnchorDigest: "target",
237+
preimage: { path: "private" },
238+
workspaceReceiptDigest: null,
239+
providerReceiptDigest: null,
240+
projectionCommitSequence: null,
241+
createdAt: "2026-08-31T00:00:00.000Z",
242+
updatedAt: "2026-08-31T00:00:00.000Z",
243+
},
244+
createdAt: "2026-08-31T00:00:00.000Z",
245+
updatedAt: "2026-08-31T00:00:00.000Z",
246+
} satisfies RollbackSagaRecord;
247+
const repository = Layer.succeed(RollbackSagaRepository, {
248+
listNonterminal: () => Effect.succeed([record]),
249+
listNonterminalForFence: () => Effect.succeed([record]),
250+
} as never);
251+
const testLayer = makeLayer({
252+
detect: () => Effect.die("VCS detection must not run through a rollback fence"),
253+
}).pipe(Layer.provideMerge(repository));
254+
255+
return Effect.gen(function* () {
256+
const workflow = yield* GitWorkflowService.GitWorkflowService;
257+
const result = yield* workflow.pullCurrentBranch("/repo").pipe(Effect.result);
258+
assert.equal(result._tag, "Failure");
259+
if (result._tag === "Failure") {
260+
expect(result.failure).toMatchObject({
261+
_tag: "GitCommandError",
262+
command: "rollback-fence",
263+
cwd: "/repo",
264+
});
265+
}
266+
}).pipe(Effect.provide(testLayer));
267+
},
268+
);
192269
});

apps/server/src/git/GitWorkflowService.ts

Lines changed: 69 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
// @effect-diagnostics nodeBuiltinImport:off
12
import * as Context from "effect/Context";
23
import * as Effect from "effect/Effect";
34
import * as Layer from "effect/Layer";
5+
import * as Option from "effect/Option";
6+
import * as NodeFS from "node:fs";
47

58
import {
69
GitManagerError,
@@ -31,6 +34,15 @@ import {
3134
import * as GitManager from "./GitManager.ts";
3235
import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
3336
import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
37+
import { RollbackSagaRepository } from "../persistence/Services/RollbackSagas.ts";
38+
39+
function canonicalWorkspacePath(cwd: string): string {
40+
try {
41+
return NodeFS.realpathSync(cwd);
42+
} catch {
43+
return cwd;
44+
}
45+
}
3446

3547
export class GitWorkflowService extends Context.Service<
3648
GitWorkflowService,
@@ -141,6 +153,44 @@ export const make = Effect.gen(function* () {
141153
const registry = yield* VcsDriverRegistry.VcsDriverRegistry;
142154
const git = yield* GitVcsDriver.GitVcsDriver;
143155
const gitManager = yield* GitManager.GitManager;
156+
const rollbackRepository = yield* Effect.serviceOption(RollbackSagaRepository);
157+
158+
const workspaceIsRollbackFenced = Effect.fn("GitWorkflowService.workspaceIsRollbackFenced")(
159+
function* (cwd: string) {
160+
if (Option.isNone(rollbackRepository)) return false;
161+
const canonical = canonicalWorkspacePath(cwd);
162+
const active = yield* rollbackRepository.value
163+
.listNonterminalForFence()
164+
.pipe(Effect.orElseSucceed(() => null));
165+
if (active === null) return true;
166+
return active.some((record) => record.state.workspaceCwd === canonical);
167+
},
168+
);
169+
const ensureMutationCommand = Effect.fn("GitWorkflowService.ensureMutationCommand")(function* (
170+
operation: string,
171+
cwd: string,
172+
) {
173+
if (yield* workspaceIsRollbackFenced(cwd)) {
174+
return yield* new GitCommandError({
175+
operation,
176+
command: "rollback-fence",
177+
cwd,
178+
detail: "The workspace is fenced by an active rollback operation.",
179+
});
180+
}
181+
});
182+
const ensureMutationWorkflow = Effect.fn("GitWorkflowService.ensureMutationWorkflow")(function* (
183+
operation: string,
184+
cwd: string,
185+
) {
186+
if (yield* workspaceIsRollbackFenced(cwd)) {
187+
return yield* new GitManagerError({
188+
operation,
189+
cwd,
190+
detail: "The workspace is fenced by an active rollback operation.",
191+
});
192+
}
193+
});
144194

145195
const ensureGit = Effect.fn("GitWorkflowService.ensureGit")(function* (
146196
operation: string,
@@ -281,29 +331,33 @@ export const make = Effect.gen(function* () {
281331
invalidateRemoteStatus: gitManager.invalidateRemoteStatus,
282332
invalidateStatus: gitManager.invalidateStatus,
283333
pullCurrentBranch: (cwd) =>
284-
ensureGitCommand("GitWorkflowService.pullCurrentBranch", cwd).pipe(
334+
ensureMutationCommand("GitWorkflowService.pullCurrentBranch", cwd).pipe(
335+
Effect.andThen(ensureGitCommand("GitWorkflowService.pullCurrentBranch", cwd)),
285336
Effect.andThen(git.pullCurrentBranch(cwd)),
286337
),
287338
runStackedAction: (input, options) =>
288-
ensureGit("GitWorkflowService.runStackedAction", input.cwd).pipe(
339+
ensureMutationWorkflow("GitWorkflowService.runStackedAction", input.cwd).pipe(
340+
Effect.andThen(ensureGit("GitWorkflowService.runStackedAction", input.cwd)),
289341
Effect.andThen(gitManager.runStackedAction(input, options)),
290342
),
291343
resolvePullRequest: routeGitManager(
292344
"GitWorkflowService.resolvePullRequest",
293345
gitManager.resolvePullRequest,
294346
),
295-
preparePullRequestThread: routeGitManager(
296-
"GitWorkflowService.preparePullRequestThread",
297-
gitManager.preparePullRequestThread,
298-
),
347+
preparePullRequestThread: (input) =>
348+
ensureMutationWorkflow("GitWorkflowService.preparePullRequestThread", input.cwd).pipe(
349+
Effect.andThen(ensureGit("GitWorkflowService.preparePullRequestThread", input.cwd)),
350+
Effect.andThen(gitManager.preparePullRequestThread(input)),
351+
),
299352
listRefs: (input) =>
300353
detectGitRepositoryForCommand("GitWorkflowService.listRefs", input.cwd).pipe(
301354
Effect.flatMap((isGitRepository) =>
302355
isGitRepository ? git.listRefs(input) : Effect.succeed(nonRepositoryListRefs()),
303356
),
304357
),
305358
createWorktree: (input) =>
306-
ensureGitCommand("GitWorkflowService.createWorktree", input.cwd).pipe(
359+
ensureMutationCommand("GitWorkflowService.createWorktree", input.cwd).pipe(
360+
Effect.andThen(ensureGitCommand("GitWorkflowService.createWorktree", input.cwd)),
307361
Effect.andThen(git.createWorktree(input)),
308362
),
309363
fetchRemote: (input) =>
@@ -319,23 +373,27 @@ export const make = Effect.gen(function* () {
319373
Effect.andThen(git.resolveRemoteTrackingCommit(input)),
320374
),
321375
removeWorktree: (input) =>
322-
ensureGitCommand("GitWorkflowService.removeWorktree", input.cwd).pipe(
376+
ensureMutationCommand("GitWorkflowService.removeWorktree", input.cwd).pipe(
377+
Effect.andThen(ensureGitCommand("GitWorkflowService.removeWorktree", input.cwd)),
323378
Effect.andThen(git.removeWorktree(input)),
324379
),
325380
pruneWorktrees: (input) =>
326381
ensureGitCommand("GitWorkflowService.pruneWorktrees", input.cwd).pipe(
327382
Effect.andThen(git.pruneWorktrees(input)),
328383
),
329384
createRef: (input) =>
330-
ensureGitCommand("GitWorkflowService.createRef", input.cwd).pipe(
385+
ensureMutationCommand("GitWorkflowService.createRef", input.cwd).pipe(
386+
Effect.andThen(ensureGitCommand("GitWorkflowService.createRef", input.cwd)),
331387
Effect.andThen(git.createRef(input)),
332388
),
333389
switchRef: (input) =>
334-
ensureGitCommand("GitWorkflowService.switchRef", input.cwd).pipe(
390+
ensureMutationCommand("GitWorkflowService.switchRef", input.cwd).pipe(
391+
Effect.andThen(ensureGitCommand("GitWorkflowService.switchRef", input.cwd)),
335392
Effect.andThen(Effect.scoped(git.switchRef(input))),
336393
),
337394
renameBranch: (input) =>
338-
ensureGit("GitWorkflowService.renameBranch", input.cwd).pipe(
395+
ensureMutationWorkflow("GitWorkflowService.renameBranch", input.cwd).pipe(
396+
Effect.andThen(ensureGit("GitWorkflowService.renameBranch", input.cwd)),
339397
Effect.andThen(git.renameBranch(input)),
340398
),
341399
});

0 commit comments

Comments
 (0)