1// @effect-diagnostics nodeBuiltinImport:off - Owns isolated filesystem fixtures.
2import { afterEach, beforeEach, describe, expect, vi } from "vite-plus/test";
3import { it } from "@effect/vitest";
4import * as NodeServices from "@effect/platform-node/NodeServices";
5import {
6 ProjectId,
7 ThreadId,
8 ProviderInstanceId,
9 SourceControlProviderKind,
10 type OrchestrationV2ThreadShell,
11 type ThreadPullRequestLink,
12 type PullRequestSummary,
13 PullRequestOperationError,
14} from "@t3tools/contracts";
15import * as HostProcess from "@t3tools/shared/HostProcess";
16import * as DateTime from "effect/DateTime";
17import * as Effect from "effect/Effect";
18import * as Layer from "effect/Layer";
19import * as Option from "effect/Option";
20import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
21import * as NodeFSP from "node:fs/promises";
22import * as NodeOS from "node:os";
23import * as NodePath from "node:path";
24
25import * as ServerConfig from "../config.ts";
26import * as ProcessRunner from "../processRunner.ts";
27import * as Orchestrator from "../orchestration-v2/Orchestrator.ts";
28import * as ProjectStore from "../orchestration-v2/ProjectStore.ts";
29import * as PullRequestService from "../pullRequest/PullRequestService.ts";
30import * as ManagedWorkspaces from "./ManagedWorkspaces.ts";
31
32const now = DateTime.makeUnsafe("2026-10-13T12:00:00.000Z");
33const projectId = ProjectId.make("managed-project");
34const threadId = ThreadId.make("managed-thread");
35let home: string;
36let workspacePath: string;
37let threads: OrchestrationV2ThreadShell[];
38const launches = vi.fn<ProcessRunner.ProcessRunner["Service"]["run"]>();
39const dispatch = vi.fn<Orchestrator.OrchestratorV2["Service"]["dispatch"]>();
40const summary = vi.fn<PullRequestService.PullRequestService["Service"]["summary"]>();
41const invalidate = vi.fn<PullRequestService.PullRequestService["Service"]["invalidate"]>();
42const link = (number: number): ThreadPullRequestLink => ({
43 host: "github.com",
44 repository: "work/app",
45 number,
46 url: `https://github.com/work/app/pull/${number}`,
47 source: "manual",
48 linkedAt: DateTime.formatIso(now),
49 snapshot: null,
50 stack: null,
51});
52const pullRequest = (state: PullRequestSummary["state"], number = 1): PullRequestSummary => ({
53 provider: SourceControlProviderKind.make("github"),
54 projectId,
55 repository: "work/app",
56 number,
57 title: "Work",
58 url: link(number).url,
59 state,
60 headBranch: "feature",
61 baseBranch: "main",
62 mergedAt: state === "merged" ? DateTime.formatIso(now) : null,
63 updatedAt: DateTime.formatIso(now),
64});
65const thread = (): OrchestrationV2ThreadShell => ({
66 id: threadId,
67 projectId,
68 title: "Managed workspace",
69 providerInstanceId: ProviderInstanceId.make("codex"),
70 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-6.1-sol" },
71 runtimeMode: "full-access",
72 interactionMode: "default",
73 branch: null,
74 worktreePath: null,
75 activeProviderThreadId: null,
76 lineage: { rootThreadId: threadId, parentThreadId: null, relationshipToParent: null },
77 forkedFrom: null,
78 createdBy: "user",
79 creationSource: "web",
80 activeRunId: null,
81 latestRunId: null,
82 status: "idle",
83 pendingRuntimeRequest: null,
84 latestVisibleMessage: null,
85 latestUserMessageAt: null,
86 hasActionableProposedPlan: false,
87 itemCount: 0,
88 visibleItemCount: 0,
89 createdAt: now,
90 updatedAt: now,
91 archivedAt: null,
92 settledOverride: "settled",
93 settledAt: now,
94 deletedAt: null,
95 pullRequests: [link(1)],
96});
97
98beforeEach(async () => {
99 home = await NodeFSP.realpath(
100 await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-managed-archive-")),
101 );
102 workspacePath = NodePath.join(home, "workspaces/1");
103 await NodeFSP.mkdir(workspacePath, { recursive: true });
104 const records = NodePath.join(home, ".local/state/workspaces/workspaces");
105 await NodeFSP.mkdir(records, { recursive: true });
106 await NodeFSP.writeFile(NodePath.join(records, "1.json"), JSON.stringify({ threadId }));
107 await NodeFSP.mkdir(NodePath.join(home, ".config"));
108 await NodeFSP.mkdir(NodePath.join(home, "source/client"), { recursive: true });
109 await NodeFSP.mkdir(NodePath.join(home, "bin"));
110 await NodeFSP.writeFile(
111 NodePath.join(home, ".config/workspaces.json"),
112 JSON.stringify({ repository: NodePath.join(home, "source") }),
113 );
114 await NodeFSP.writeFile(
115 NodePath.join(home, "source/client/package.json"),
116 '{"name":"@work/client"}',
117 );
118 await NodeFSP.writeFile(NodePath.join(home, "bin/ws"), "#!/bin/sh\nexit 0\n", { mode: 0o755 });
119 threads = [thread()];
120 launches.mockReset();
121 launches.mockImplementation(() =>
122 Effect.succeed({
123 stdout: '{"abandoned":false}',
124 stderr: "",
125 code: ChildProcessSpawner.ExitCode(0),
126 timedOut: false,
127 stdoutTruncated: false,
128 stderrTruncated: false,
129 stdoutInvalidUtf8: false,
130 stderrInvalidUtf8: false,
131 }),
132 );
133 dispatch.mockReset();
134 summary.mockReset();
135 invalidate.mockReset();
136 dispatch.mockImplementation(() => Effect.succeed({ sequence: 1, storedEvents: [] }));
137 summary.mockImplementation((ref) => Effect.succeed(pullRequest("merged", ref.number)));
138 invalidate.mockImplementation(() => Effect.void);
139});
140afterEach(async () => {
141 await NodeFSP.rm(home, { recursive: true, force: true });
142});
143
144const layer = () =>
145 ManagedWorkspaces.layer.pipe(
146 Layer.provide(
147 Layer.mergeAll(
148 NodeServices.layer,
149 Layer.succeed(HostProcess.HomeDirectory, home),
150 Layer.succeed(HostProcess.Environment, { PATH: NodePath.join(home, "bin") }),
151 ServerConfig.layerTest(home, NodePath.join(home, "isolated-t3")).pipe(
152 Layer.provide(NodeServices.layer),
153 ),
154 Layer.succeed(ProcessRunner.ProcessRunner, { run: launches }),
155 Layer.mock(Orchestrator.OrchestratorV2)({
156 getThreadShell: (id) =>
157 Effect.succeed(threads.find((thread) => thread.id === id) ?? null),
158 getShellSnapshot: () =>
159 Effect.succeed({ schemaVersion: 1, snapshotSequence: 1, threads, archivedThreads: [] }),
160 dispatch,
161 }),
162 Layer.mock(ProjectStore.ProjectStoreV2)({
163 getShell: () =>
164 Effect.succeed(
165 Option.some({
166 id: projectId,
167 title: "Workspace",
168 workspaceRoot: workspacePath,
169 defaultModelSelection: null,
170 scripts: [],
171 createdAt: DateTime.formatIso(now),
172 updatedAt: DateTime.formatIso(now),
173 }),
174 ),
175 listShells: () =>
176 Effect.succeed([
177 {
178 id: projectId,
179 title: "Workspace",
180 workspaceRoot: workspacePath,
181 repositoryIdentity: null,
182 defaultModelSelection: null,
183 defaultThreadEnvMode: "local",
184 autoPull: false,
185 faviconPath: null,
186 projectIcon: null,
187 scripts: [],
188 createdAt: DateTime.formatIso(now),
189 updatedAt: DateTime.formatIso(now),
190 },
191 ]),
192 }),
193 Layer.mock(PullRequestService.PullRequestService)({ summary, invalidate }),
194 ),
195 ),
196 );
197const use = <A, E>(effect: Effect.Effect<A, E, ManagedWorkspaces.ManagedWorkspaces>) =>
198 effect.pipe(Effect.provide(layer()));
199
200describe("managed workspace archival", () => {
201 it.effect("archives JJ project-root threads only after every linked PR is freshly verified", () =>
202 use(
203 Effect.gen(function* () {
204 threads[0] = { ...threads[0]!, pullRequests: [link(1), link(2)] };
205 const service = yield* ManagedWorkspaces.ManagedWorkspaces;
206 expect(yield* service.archiveCompleted({ workspacePath })).toEqual({
207 archivedThreadIds: [threadId],
208 });
209 expect(invalidate).toHaveBeenCalledTimes(2);
210 expect(summary).toHaveBeenCalledTimes(2);
211 expect(summary.mock.calls[0]).toEqual([
212 {
213 host: "github.com",
214 repository: "work/app",
215 number: 1,
216 projectId,
217 allowStale: false,
218 },
219 { recoverTransientFailure: false },
220 ]);
221 expect(dispatch.mock.calls[0]?.[0]).toMatchObject({
222 type: "thread.archive",
223 threadId,
224 snapshotSequence: 1,
225 });
226 }),
227 ),
228 );
229 it.effect.each(["open", "closed"] as const)("retains a workspace with any %s PR", (state) =>
230 use(
231 Effect.gen(function* () {
232 threads[0] = { ...threads[0]!, pullRequests: [link(1), link(2)] };
233 summary.mockImplementation((ref) =>
234 Effect.succeed(pullRequest(ref.number === 1 ? "merged" : state, ref.number)),
235 );
236 const service = yield* ManagedWorkspaces.ManagedWorkspaces;
237 expect(yield* service.archiveCompleted({ workspacePath })).toEqual({
238 archivedThreadIds: [],
239 });
240 expect(dispatch).not.toHaveBeenCalled();
241 }),
242 ),
243 );
244 it.effect.each(["active", "unlinked", "shared active"] as const)(
245 "retains %s work without host reads",
246 (kind) =>
247 use(
248 Effect.gen(function* () {
249 if (kind === "active") threads[0] = { ...threads[0]!, settledOverride: "active" };
250 if (kind === "unlinked") threads[0] = { ...threads[0]!, pullRequests: [] };
251 if (kind === "shared active")
252 threads.push({ ...thread(), id: ThreadId.make("active-peer"), settledOverride: null });
253 const service = yield* ManagedWorkspaces.ManagedWorkspaces;
254 expect(yield* service.archiveCompleted({ workspacePath })).toEqual({
255 archivedThreadIds: [],
256 });
257 expect(dispatch).not.toHaveBeenCalled();
258 expect(summary).not.toHaveBeenCalled();
259 }),
260 ),
261 );
262 it.effect("fails closed when a host cannot confirm the merge", () =>
263 use(
264 Effect.gen(function* () {
265 summary.mockImplementation(
266 () => new PullRequestOperationError({ operation: "summary", detail: "Host unavailable" }),
267 );
268 const service = yield* ManagedWorkspaces.ManagedWorkspaces;
269 expect((yield* service.archiveCompleted({ workspacePath }).pipe(Effect.flip)).reason).toBe(
270 "archive",
271 );
272 expect(dispatch).not.toHaveBeenCalled();
273 }),
274 ),
275 );
276 it.effect("retains an unknown merge timestamp", () =>
277 use(
278 Effect.gen(function* () {
279 summary.mockImplementation(() =>
280 Effect.succeed({ ...pullRequest("merged"), mergedAt: null }),
281 );
282 const service = yield* ManagedWorkspaces.ManagedWorkspaces;
283 expect(yield* service.archiveCompleted({ workspacePath })).toEqual({
284 archivedThreadIds: [],
285 });
286 expect(dispatch).not.toHaveBeenCalled();
287 }),
288 ),
289 );
290 it.effect("refuses an arbitrary project checkout", () =>
291 use(
292 Effect.gen(function* () {
293 const service = yield* ManagedWorkspaces.ManagedWorkspaces;
294 expect(
295 (yield* service.archiveCompleted({ workspacePath: home }).pipe(Effect.flip)).reason,
296 ).toBe("workspace");
297 expect(dispatch).not.toHaveBeenCalled();
298 }),
299 ),
300 );
301});
302
303it.effect("retains an abandoned settled workspace without a merged PR", () =>
304 use(
305 Effect.gen(function* () {
306 threads[0] = { ...threads[0]!, pullRequests: [] };
307 const service = yield* ManagedWorkspaces.ManagedWorkspaces;
308 expect(yield* service.archiveCompleted({ workspacePath })).toEqual({ archivedThreadIds: [] });
309 expect(launches).not.toHaveBeenCalled();
310 expect(dispatch).not.toHaveBeenCalled();
311 }),
312 ),
313);
314
315it.effect("recognizes managed project roots and managed thread worktrees", () =>
316 use(
317 Effect.gen(function* () {
318 const service = yield* ManagedWorkspaces.ManagedWorkspaces;
319 expect(yield* service.isManagedThread(threadId)).toBe(true);
320 threads[0] = { ...threads[0]!, worktreePath: home };
321 expect(yield* service.isManagedThread(threadId)).toBe(false);
322 threads[0] = { ...threads[0]!, worktreePath: workspacePath };
323 expect(yield* service.isManagedThread(threadId)).toBe(true);
324 expect(yield* service.isManagedThread(ThreadId.make("missing-thread"))).toBe(false);
325 expect(launches).not.toHaveBeenCalled();
326 }),
327 ),
328);
329
330it.effect("keeps merged workspaces available while another chat references them", () =>
331 use(
332 Effect.gen(function* () {
333 threads.push({ ...thread(), id: ThreadId.make("active-peer"), settledOverride: "active" });
334 const service = yield* ManagedWorkspaces.ManagedWorkspaces;
335 expect(yield* service.archiveCompleted({ workspacePath })).toEqual({ archivedThreadIds: [] });
336 expect(dispatch).not.toHaveBeenCalled();
337 expect(summary).not.toHaveBeenCalled();
338 }),
339 ),
340);