1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";
3import {
4 CheckpointId,
5 CheckpointScopeId,
6 type OrchestrationV2ThreadProjection,
7 ProjectId,
8 ProviderInstanceId,
9 ProviderSessionId,
10 ProviderThreadId,
11 ThreadId,
12} from "@t3tools/contracts";
13import * as Effect from "effect/Effect";
14import * as FileSystem from "effect/FileSystem";
15import * as Layer from "effect/Layer";
16import * as Option from "effect/Option";
17import * as Path from "effect/Path";
18import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
19import * as GitWorkflowService from "../git/GitWorkflowService.ts";
20import * as CheckpointRollbackService from "./CheckpointRollbackService.ts";
21import { isCheckpointRestoreIsolated } from "./CheckpointRestoreSafety.ts";
22import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts";
23import { CheckpointServiceV2 } from "./CheckpointService.ts";
24import { EventSinkV2 } from "./EventSink.ts";
25import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
26import { ProjectionStoreV2 } from "./ProjectionStore.ts";
27import * as ProjectStore from "./ProjectStore.ts";
28import { ProviderSessionManagerV2 } from "./ProviderSessionManager.ts";
29import { RuntimePolicyV2 } from "./RuntimePolicy.ts";
30
31it.effect.each([
32 "nested",
33 "ancestor",
34 "archived-nested",
35 ...(symlinksSupported ? (["aliased-nested"] as const) : []),
36 "project",
37 "provider",
38 "scope",
39 "sibling",
40 "stopped-provider",
41 "errored-provider",
42 "shared-provider",
43 "conversation",
44] as const)("preserves overlapping workspace files, owner=%s", (owner) =>
45 Effect.gen(function* () {
46 const fs = yield* FileSystem.FileSystem;
47 const path = yield* Path.Path;
48 const parent = yield* fs.makeTempDirectoryScoped({ prefix: "t3-v2-restore-" });
49 const cwd = path.join(parent, "worktree");
50 const nested = path.join(cwd, "nested");
51 const sibling = path.join(parent, "worktree2");
52 yield* fs.makeDirectory(nested, { recursive: true });
53 yield* fs.makeDirectory(sibling);
54 const otherFile = path.join(nested, "other-thread.txt");
55 yield* fs.writeFileString(otherFile, "other thread's uncommitted work");
56 const alias = path.join(parent, "alias");
57 if (owner === "aliased-nested") yield* fs.symlink(nested, alias);
58 const threadId = ThreadId.make("restore-current");
59 const otherId = ThreadId.make("restore-other");
60 const projectId = ProjectId.make("restore-project");
61 const providerThreadId = ProviderThreadId.make("restore-provider");
62 const providerSessionId = ProviderSessionId.make("restore-session");
63 const instanceId = ProviderInstanceId.make("restore-instance");
64 const checkpointId = CheckpointId.make("restore-checkpoint");
65 const scopeId = CheckpointScopeId.make("restore-scope");
66 const calls: string[] = [];
67 const providerThread = {
68 id: providerThreadId,
69 providerSessionId,
70 providerInstanceId: instanceId,
71 };
72 const otherThread = {
73 id: otherId,
74 projectId,
75 deletedAt: null,
76 worktreePath:
77 owner === "project"
78 ? null
79 : owner === "ancestor" || owner === "conversation"
80 ? parent
81 : owner === "nested" || owner === "archived-nested"
82 ? nested
83 : owner === "aliased-nested"
84 ? alias
85 : sibling,
86 };
87 const projection = {
88 thread: {
89 id: threadId,
90 projectId,
91 worktreePath: cwd,
92 activeProviderThreadId: providerThreadId,
93 modelSelection: { instanceId, model: "test" },
94 },
95 providerThreads: [providerThread],
96 providerSessions: [],
97 providerTurns: [],
98 nodes: [],
99 attempts: [],
100 checkpoints: [{ id: checkpointId, scopeId, status: "ready", appRunOrdinal: null }],
101 checkpointScopes: [{ id: scopeId, cwd }],
102 runs: [{ id: "later-run", ordinal: 1, status: "completed", rootNodeId: null }],
103 } as unknown as OrchestrationV2ThreadProjection;
104 const layerTest = CheckpointRollbackService.layer.pipe(
105 Layer.provide(
106 Layer.mergeAll(
107 NodeServices.layer,
108 ThreadCommandExecutor.layer,
109 IdAllocator.layer,
110 Layer.mock(GitWorkflowService.GitWorkflowService)({}),
111 Layer.mock(ProjectStore.ProjectStoreV2)({
112 get: () => Effect.succeed(Option.some({ workspaceRoot: parent } as never)),
113 }),
114 Layer.mock(ProjectionStoreV2)({
115 getThreadRecords: () => Effect.succeed(projection),
116 getShellSnapshot: () =>
117 Effect.succeed({
118 schemaVersion: 1,
119 snapshotSequence: 0,
120 threads: owner === "archived-nested" ? [] : ([otherThread] as never),
121 archivedThreads: owner === "archived-nested" ? ([otherThread] as never) : [],
122 }),
123 getCheckpointContext: () =>
124 Effect.succeed({
125 runs: [],
126 checkpoints: [],
127 checkpointScopes: owner === "scope" ? ([{ cwd: nested }] as never) : [],
128 }),
129 getThreadProviderContext: () =>
130 Effect.succeed({
131 thread: otherThread as never,
132 providerThreads: [],
133 providerSessions: [
134 "provider",
135 "stopped-provider",
136 "errored-provider",
137 "shared-provider",
138 ].includes(owner)
139 ? ([
140 {
141 // One session shared by every thread keeps the cwd of
142 // the thread that opened it, here this thread's.
143 cwd: owner === "shared-provider" ? cwd : nested,
144 status:
145 owner === "provider" || owner === "shared-provider"
146 ? "running"
147 : owner === "errored-provider"
148 ? "error"
149 : "stopped",
150 capabilities: {
151 sessions: {
152 supportsMultipleProviderThreadsPerSession: owner === "shared-provider",
153 },
154 },
155 },
156 ] as never)
157 : [],
158 }),
159 }),
160 Layer.mock(CheckpointServiceV2)({
161 restore: () =>
162 Effect.gen(function* () {
163 calls.push("files");
164 yield* fs.remove(otherFile).pipe(Effect.orDie);
165 }),
166 }),
167 Layer.mock(EventSinkV2)({ write: () => Effect.succeed([]) }),
168 Layer.mock(ProviderSessionManagerV2)({
169 open: () =>
170 Effect.succeed({
171 rollbackThread: () =>
172 Effect.sync(() => {
173 calls.push("provider");
174 return { providerThread };
175 }),
176 } as never),
177 }),
178 Layer.mock(RuntimePolicyV2)({ resolve: () => Effect.succeed({} as never) }),
179 ),
180 ),
181 );
182 const service = yield* CheckpointRollbackService.CheckpointRollbackServiceV2.pipe(
183 Effect.provide(layerTest),
184 );
185 const restoreFiles = owner !== "conversation";
186 const rejected = !["sibling", "stopped-provider", "shared-provider", "conversation"].includes(
187 owner,
188 );
189 if (rejected) {
190 const error = yield* service
191 .execute({ threadId, providerThreadId, checkpointId, scopeId, restoreFiles })
192 .pipe(Effect.flip);
193 assert.equal(error.reason, "shared-workspace");
194 assert.deepEqual(calls, []);
195 assert.equal(yield* fs.readFileString(otherFile), "other thread's uncommitted work");
196 } else {
197 yield* service.execute({ threadId, providerThreadId, checkpointId, scopeId, restoreFiles });
198 assert.deepEqual(calls, restoreFiles ? ["provider", "files"] : ["provider"]);
199 assert.equal(yield* fs.exists(otherFile), !restoreFiles);
200 }
201 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
202);
203
204it.effect("lets rollback recreate a worktree storage cleanup removed", () =>
205 Effect.gen(function* () {
206 const fileSystem = yield* FileSystem.FileSystem;
207 const path = yield* Path.Path;
208 const parent = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-v2-restore-removed-" });
209 const worktreePath = path.join(parent, "worktree");
210 const isolated = yield* isCheckpointRestoreIsolated(
211 { id: ThreadId.make("restore-removed"), worktreePath },
212 { cwd: worktreePath },
213 {
214 fileSystem,
215 path,
216 projections: {
217 getShellSnapshot: () => Effect.succeed({ threads: [], archivedThreads: [] }),
218 } as never,
219 projects: {} as never,
220 },
221 );
222 assert.isTrue(isolated);
223 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
224);