1import type { OrchestrationV2AppThread, OrchestrationV2CheckpointScope } from "@t3tools/contracts";
2import * as Effect from "effect/Effect";
3import type * as FileSystem from "effect/FileSystem";
4import * as Option from "effect/Option";
5import type * as Path from "effect/Path";
6import type { ProjectionStoreV2 } from "./ProjectionStore.ts";
7import type * as ProjectStore from "./ProjectStore.ts";
8
9export 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.
14export 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);