| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import { assert, it } from "@effect/vitest"; |
| 3 | import { |
| 4 | CheckpointId, |
| 5 | CheckpointScopeId, |
| 6 | type OrchestrationV2ThreadProjection, |
| 7 | ProjectId, |
| 8 | ProviderInstanceId, |
| 9 | ProviderSessionId, |
| 10 | ProviderThreadId, |
| 11 | ThreadId, |
| 12 | } from "@t3tools/contracts"; |
| 13 | import * as Effect from "effect/Effect"; |
| 14 | import * as FileSystem from "effect/FileSystem"; |
| 15 | import * as Layer from "effect/Layer"; |
| 16 | import * as Option from "effect/Option"; |
| 17 | import * as Path from "effect/Path"; |
| 18 | import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; |
| 19 | import * as GitWorkflowService from "../git/GitWorkflowService.ts"; |
| 20 | import * as CheckpointRollbackService from "./CheckpointRollbackService.ts"; |
| 21 | import { isCheckpointRestoreIsolated } from "./CheckpointRestoreSafety.ts"; |
| 22 | import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; |
| 23 | import { CheckpointServiceV2 } from "./CheckpointService.ts"; |
| 24 | import { EventSinkV2 } from "./EventSink.ts"; |
| 25 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; |
| 26 | import { ProjectionStoreV2 } from "./ProjectionStore.ts"; |
| 27 | import * as ProjectStore from "./ProjectStore.ts"; |
| 28 | import { ProviderSessionManagerV2 } from "./ProviderSessionManager.ts"; |
| 29 | import { RuntimePolicyV2 } from "./RuntimePolicy.ts"; |
| 30 | |
| 31 | it.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 | |
| 204 | it.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 | ); |