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 or a side chat. Rewind the conversation without restoring files instead, or delete side chats you no longer need.";
11
12// Check isolation at admission and rollback; checkpoints restore whole checkouts, which may become shared.
13export const isCheckpointRestoreIsolated = Effect.fn("orchestrationV2.isCheckpointRestoreIsolated")(
14 function* (
15 thread: Pick<OrchestrationV2AppThread, "id" | "worktreePath">,
16 scope: Pick<OrchestrationV2CheckpointScope, "cwd">,
17 dependencies: {
18 readonly fileSystem: FileSystem.FileSystem;
19 readonly projections: ProjectionStoreV2["Service"];
20 readonly projects: ProjectStore.ProjectStoreV2["Service"];
21 readonly path: Path.Path;
22 },
23 ) {
24 const { fileSystem, projections, projects, path } = dependencies;
25 const contains = (parent: string, child: string) => {
26 const relative = path.relative(parent, child);
27 return (
28 relative === "" ||
29 (!path.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path.sep}`))
30 );
31 };
32 const worktreePath = thread.worktreePath;
33 let shared = worktreePath == null;
34 if (!shared && worktreePath !== null) {
35 // Storage cleanup may have removed the worktree; rollback recreates it.
36 const resolve = (target: string) =>
37 fileSystem.realPath(target).pipe(
38 Effect.catchIf(
39 (error) => error.reason._tag === "NotFound",
40 (_error) => Effect.succeed(path.resolve(target)),
41 ),
42 );
43 const cwd = yield* resolve(scope.cwd);
44 const worktreeCwd = yield* resolve(worktreePath);
45 shared = cwd !== worktreeCwd;
46 if (!shared) {
47 const shell = yield* projections.getShellSnapshot();
48 const checkedPaths = new Set<string>();
49 for (const otherThread of [...shell.threads, ...shell.archivedThreads]) {
50 if (otherThread.id === thread.id || otherThread.deletedAt !== null) continue;
51 // An idle side chat that only answered questions never touched the shared worktree.
52 if (
53 otherThread.lineage.sideChat === true &&
54 otherThread.activeRunId === null &&
55 otherThread.worktreePath === worktreePath
56 ) {
57 const { turnItems } = yield* projections.getThreadRecords(
58 otherThread.id,
59 ["turnItems"],
60 {
61 turnItemTypes: ["command_execution", "file_change", "dynamic_tool"],
62 },
63 );
64 if (turnItems.length === 0) continue;
65 }
66 const other = yield* projections.getCheckpointContext(otherThread.id);
67 const providerContext = yield* projections.getThreadProviderContext(otherThread.id);
68 const paths = [
69 otherThread.worktreePath,
70 ...other.checkpointScopes.map((candidate) => candidate.cwd),
71 // Errored sessions can still stream. Shared sessions retain another thread's opening cwd;
72 // turn worktrees and project roots are already covered here.
73 ...providerContext.providerSessions
74 .filter(
75 (session) =>
76 session.status !== "stopped" &&
77 !session.capabilities.sessions.supportsMultipleProviderThreadsPerSession,
78 )
79 .map((session) => session.cwd),
80 ].filter((value): value is string => value !== null);
81 if (otherThread.worktreePath === null) {
82 const project = yield* projects.get(otherThread.projectId, { includeDeleted: true });
83 if (Option.isNone(project)) return false;
84 paths.push(project.value.workspaceRoot);
85 }
86 for (const candidate of paths) {
87 if (checkedPaths.has(candidate)) continue;
88 checkedPaths.add(candidate);
89 const otherCwd = yield* fileSystem.realPath(candidate).pipe(
90 Effect.catchIf(
91 (error) => error.reason._tag === "NotFound",
92 (_error) => Effect.succeed(null),
93 ),
94 );
95 if (otherCwd !== null && (contains(cwd, otherCwd) || contains(otherCwd, cwd))) {
96 shared = true;
97 break;
98 }
99 }
100 if (shared) break;
101 }
102 }
103 }
104 return !shared;
105 },
106);