| 1 | import type { OrchestrationV2AppThread, OrchestrationV2CheckpointScope } from "@t3tools/contracts"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import type * as FileSystem from "effect/FileSystem"; |
| 4 | import * as Option from "effect/Option"; |
| 5 | import type * as Path from "effect/Path"; |
| 6 | import type { ProjectionStoreV2 } from "./ProjectionStore.ts"; |
| 7 | import type * as ProjectStore from "./ProjectStore.ts"; |
| 8 | |
| 9 | export const SHARED_WORKSPACE_RESTORE_MESSAGE = |
| 10 | "File restore requires an isolated worktree. This workspace may contain changes from another thread. Rewind the conversation without restoring files instead."; |
| 11 | |
| 12 | // A checkpoint snapshots the whole checkout. Check at command admission and |
| 13 | // again before provider rollback so a newly shared worktree is rejected too. |
| 14 | export const isCheckpointRestoreIsolated = Effect.fn("orchestrationV2.isCheckpointRestoreIsolated")( |
| 15 | function* ( |
| 16 | thread: Pick<OrchestrationV2AppThread, "id" | "worktreePath">, |
| 17 | scope: Pick<OrchestrationV2CheckpointScope, "cwd">, |
| 18 | dependencies: { |
| 19 | readonly fileSystem: FileSystem.FileSystem; |
| 20 | readonly projections: ProjectionStoreV2["Service"]; |
| 21 | readonly projects: ProjectStore.ProjectStoreV2["Service"]; |
| 22 | readonly path: Path.Path; |
| 23 | }, |
| 24 | ) { |
| 25 | const { fileSystem, projections, projects, path } = dependencies; |
| 26 | const contains = (parent: string, child: string) => { |
| 27 | const relative = path.relative(parent, child); |
| 28 | return ( |
| 29 | relative === "" || |
| 30 | (!path.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path.sep}`)) |
| 31 | ); |
| 32 | }; |
| 33 | const worktreePath = thread.worktreePath; |
| 34 | let shared = worktreePath == null; |
| 35 | if (!shared && worktreePath !== null) { |
| 36 | // Storage cleanup may have removed the worktree; rollback recreates it. |
| 37 | const resolve = (target: string) => |
| 38 | fileSystem |
| 39 | .realPath(target) |
| 40 | .pipe( |
| 41 | Effect.catch((error) => |
| 42 | error.reason._tag === "NotFound" |
| 43 | ? Effect.succeed(path.resolve(target)) |
| 44 | : Effect.fail(error), |
| 45 | ), |
| 46 | ); |
| 47 | const cwd = yield* resolve(scope.cwd); |
| 48 | const worktreeCwd = yield* resolve(worktreePath); |
| 49 | shared = cwd !== worktreeCwd; |
| 50 | if (!shared) { |
| 51 | const shell = yield* projections.getShellSnapshot(); |
| 52 | const checkedPaths = new Set<string>(); |
| 53 | for (const otherThread of [...shell.threads, ...shell.archivedThreads]) { |
| 54 | if (otherThread.id === thread.id || otherThread.deletedAt !== null) continue; |
| 55 | const other = yield* projections.getCheckpointContext(otherThread.id); |
| 56 | const providerContext = yield* projections.getThreadProviderContext(otherThread.id); |
| 57 | const paths = [ |
| 58 | otherThread.worktreePath, |
| 59 | ...other.checkpointScopes.map((candidate) => candidate.cwd), |
| 60 | // A failed turn can leave an errored session with a live event stream. |
| 61 | // A session shared across threads keeps the cwd it was opened with, |
| 62 | // often another thread's; each turn runs in the thread's own |
| 63 | // worktree or project root, which this list already covers. |
| 64 | ...providerContext.providerSessions |
| 65 | .filter( |
| 66 | (session) => |
| 67 | session.status !== "stopped" && |
| 68 | !session.capabilities.sessions.supportsMultipleProviderThreadsPerSession, |
| 69 | ) |
| 70 | .map((session) => session.cwd), |
| 71 | ].filter((value): value is string => value !== null); |
| 72 | if (otherThread.worktreePath === null) { |
| 73 | // A thread without a worktree works in its project's checkout. |
| 74 | const project = yield* projects.get(otherThread.projectId, { includeDeleted: true }); |
| 75 | if (Option.isNone(project)) return false; |
| 76 | paths.push(project.value.workspaceRoot); |
| 77 | } |
| 78 | for (const candidate of paths) { |
| 79 | if (checkedPaths.has(candidate)) continue; |
| 80 | checkedPaths.add(candidate); |
| 81 | const otherCwd = yield* fileSystem |
| 82 | .realPath(candidate) |
| 83 | .pipe( |
| 84 | Effect.catch((error) => |
| 85 | error.reason._tag === "NotFound" ? Effect.succeed(null) : Effect.fail(error), |
| 86 | ), |
| 87 | ); |
| 88 | if (otherCwd !== null && (contains(cwd, otherCwd) || contains(otherCwd, cwd))) { |
| 89 | shared = true; |
| 90 | break; |
| 91 | } |
| 92 | } |
| 93 | if (shared) break; |
| 94 | } |
| 95 | } |
| 96 | } |
| 97 | return !shared; |
| 98 | }, |
| 99 | ); |