1import {
2 CommandId,
3 type ProjectId,
4 type ThreadId,
5 type ManagedWorkspaceArchived,
6 ManagedWorkspaceCreated,
7 ManagedWorkspaceError,
8} from "@t3tools/contracts";
9import { resolveCommandPath } from "@t3tools/shared/shell";
10import * as HostProcess from "@t3tools/shared/HostProcess";
11import * as Context from "effect/Context";
12import * as Crypto from "effect/Crypto";
13import * as Option from "effect/Option";
14import * as Effect from "effect/Effect";
15import * as FileSystem from "effect/FileSystem";
16import * as Layer from "effect/Layer";
17import * as Path from "effect/Path";
18import * as Schema from "effect/Schema";
19
20import * as ServerConfig from "../config.ts";
21import * as ProcessRunner from "../processRunner.ts";
22import * as Orchestrator from "../orchestration-v2/Orchestrator.ts";
23import * as ProjectStore from "../orchestration-v2/ProjectStore.ts";
24import * as PullRequestService from "../pullRequest/PullRequestService.ts";
25import {
26 normalizeThreadPullRequestKey,
27 visibleThreadPullRequests,
28} from "@t3tools/shared/threadPullRequests";
29import { isManagedWorkspaceArchiveCandidate } from "./managedWorkspaceArchive.ts";
30
31const decodeConfiguration = Schema.decodeUnknownEffect(
32 Schema.fromJsonString(Schema.Struct({ repository: Schema.String })),
33);
34const decodeCreated = Schema.decodeUnknownEffect(Schema.fromJsonString(ManagedWorkspaceCreated));
35
36export class ManagedWorkspaces extends Context.Service<
37 ManagedWorkspaces,
38 {
39 readonly isManagedThread: (threadId: ThreadId) => Effect.Effect<boolean, ManagedWorkspaceError>;
40 readonly status: () => Effect.Effect<{ readonly projectId: ProjectId | null }>;
41 readonly create: () => Effect.Effect<ManagedWorkspaceCreated, ManagedWorkspaceError>;
42 readonly archiveCompleted: (input: {
43 readonly workspacePath: string;
44 }) => Effect.Effect<ManagedWorkspaceArchived, ManagedWorkspaceError>;
45 }
46>()("t3/workspace/ManagedWorkspaces") {}
47
48const make = Effect.gen(function* () {
49 const fs = yield* FileSystem.FileSystem;
50 const path = yield* Path.Path;
51 const home = yield* HostProcess.HomeDirectory;
52 const platform = yield* HostProcess.Platform;
53 const environment = yield* HostProcess.Environment;
54 const config = yield* ServerConfig.ServerConfig;
55 const runner = yield* ProcessRunner.ProcessRunner;
56 const orchestrator = yield* Orchestrator.OrchestratorV2;
57 const projects = yield* ProjectStore.ProjectStoreV2;
58 const pullRequests = yield* PullRequestService.PullRequestService;
59 const crypto = yield* Crypto.Crypto;
60
61 const configuration = Effect.gen(function* () {
62 if (platform === "win32") return null;
63 const value = yield* fs.readFileString(path.join(home, ".config/workspaces.json"));
64 const configured = yield* decodeConfiguration(value);
65 const repository = yield* fs.realPath(configured.repository);
66 const executable = yield* resolveCommandPath("ws", { env: environment }).pipe(
67 Effect.provideService(FileSystem.FileSystem, fs),
68 Effect.provideService(Path.Path, path),
69 Effect.provideService(HostProcess.Platform, platform),
70 );
71 if (executable === null) return null;
72 return { repository, executable };
73 }).pipe(Effect.orElseSucceed(() => null));
74
75 const status = () =>
76 Effect.gen(function* () {
77 const configured = yield* configuration;
78 if (configured === null) return { projectId: null };
79 const sources = yield* projects.listShells();
80 for (const project of sources) {
81 const root = yield* fs
82 .realPath(project.workspaceRoot)
83 .pipe(Effect.orElseSucceed(() => null));
84 if (root === configured.repository) return { projectId: project.id };
85 }
86 return { projectId: null };
87 }).pipe(Effect.orElseSucceed(() => ({ projectId: null })));
88
89 const create = Effect.fn("ManagedWorkspaces.create")(function* () {
90 const configured = yield* configuration;
91 if (configured === null || (yield* status()).projectId === null)
92 return yield* new ManagedWorkspaceError({ reason: "unavailable" });
93 const result = yield* runner
94 .run({
95 command: configured.executable,
96 args: ["new", "--background", "--no-open", "--json"],
97 cwd: configured.repository,
98 env: { T3CODE_HOME: config.baseDir },
99 timeout: "30 seconds",
100 maxOutputBytes: 16 * 1024,
101 })
102 .pipe(Effect.mapError((cause) => new ManagedWorkspaceError({ reason: "create", cause })));
103 if (result.code !== 0 || result.timedOut) {
104 return yield* new ManagedWorkspaceError({ reason: "create" });
105 }
106 return yield* decodeCreated(result.stdout).pipe(
107 Effect.mapError((cause) => new ManagedWorkspaceError({ reason: "reply", cause })),
108 );
109 });
110
111 const managedDirectory = Effect.fn("ManagedWorkspaces.managedDirectory")(function* (
112 directory: string,
113 ) {
114 const root = path.join(home, "workspaces");
115 const id = path.relative(root, directory);
116 if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(id)) return null;
117 const workspacePath = yield* fs
118 .realPath(directory)
119 .pipe(Effect.mapError((cause) => new ManagedWorkspaceError({ reason: "workspace", cause })));
120 if (
121 workspacePath !== path.join(root, id) ||
122 !(yield* fs
123 .exists(path.join(home, ".local/state/workspaces/workspaces", `${id}.json`))
124 .pipe(
125 Effect.mapError((cause) => new ManagedWorkspaceError({ reason: "workspace", cause })),
126 ))
127 ) {
128 return null;
129 }
130 return { id, workspacePath };
131 });
132
133 const isManagedThread = Effect.fn("ManagedWorkspaces.isManagedThread")(function* (
134 threadId: ThreadId,
135 ) {
136 const thread = yield* orchestrator
137 .getThreadShell(threadId)
138 .pipe(Effect.mapError((cause) => new ManagedWorkspaceError({ reason: "workspace", cause })));
139 if (thread === null) return false;
140 const project = yield* projects
141 .getShell(thread.projectId)
142 .pipe(Effect.mapError((cause) => new ManagedWorkspaceError({ reason: "workspace", cause })));
143 if (Option.isNone(project)) return false;
144 return (yield* managedDirectory(thread.worktreePath ?? project.value.workspaceRoot)) !== null;
145 });
146
147 const archiveCompleted = Effect.fn("ManagedWorkspaces.archiveCompleted")(function* (input: {
148 readonly workspacePath: string;
149 }) {
150 const managed = yield* managedDirectory(input.workspacePath);
151 if (managed === null) return yield* new ManagedWorkspaceError({ reason: "workspace" });
152 const { workspacePath } = managed;
153 const configured = yield* configuration;
154 if (configured === null) return yield* new ManagedWorkspaceError({ reason: "unavailable" });
155 const [snapshot, projectShells] = yield* Effect.all([
156 orchestrator.getShellSnapshot({ location: "active", unsettledOnly: false }),
157 projects.listShells(),
158 ]).pipe(Effect.mapError((cause) => new ManagedWorkspaceError({ reason: "archive", cause })));
159 const roots = new Map(projectShells.map((project) => [project.id, project.workspaceRoot]));
160 const threads = [];
161 for (const thread of snapshot.threads) {
162 const cwd = thread.worktreePath ?? roots.get(thread.projectId);
163 if (cwd === undefined) return yield* new ManagedWorkspaceError({ reason: "workspace" });
164 const relative = path.relative(workspacePath, cwd);
165 if (
166 relative === "" ||
167 (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative))
168 )
169 threads.push(thread);
170 }
171 const candidates = threads.filter(isManagedWorkspaceArchiveCandidate);
172 if (candidates.length === 0 || candidates.length !== threads.length)
173 return { archivedThreadIds: [] };
174 for (const thread of candidates) {
175 const links = visibleThreadPullRequests(thread.pullRequests ?? []);
176 if (links.length === 0) return { archivedThreadIds: [] };
177 for (const link of links) {
178 const ref = {
179 ...normalizeThreadPullRequestKey(link),
180 projectId: thread.projectId,
181 allowStale: false,
182 };
183 yield* pullRequests
184 .invalidate({ reference: ref })
185 .pipe(
186 Effect.mapError((cause) => new ManagedWorkspaceError({ reason: "archive", cause })),
187 );
188 const summary = yield* pullRequests
189 .summary(ref, { recoverTransientFailure: false })
190 .pipe(
191 Effect.mapError((cause) => new ManagedWorkspaceError({ reason: "archive", cause })),
192 );
193 if (summary.state !== "merged" || !Number.isFinite(Date.parse(summary.mergedAt ?? "")))
194 return { archivedThreadIds: [] };
195 }
196 }
197 const archivedThreadIds: ThreadId[] = [];
198 for (const thread of candidates) {
199 const uuid = yield* crypto
200 .randomUUIDv4()
201 .pipe(Effect.mapError((cause) => new ManagedWorkspaceError({ reason: "archive", cause })));
202 yield* orchestrator
203 .dispatch({
204 type: "thread.archive",
205 commandId: CommandId.make(`managed-workspace:${uuid}`),
206 threadId: thread.id,
207 snapshotSequence: snapshot.snapshotSequence,
208 })
209 .pipe(Effect.mapError((cause) => new ManagedWorkspaceError({ reason: "archive", cause })));
210 archivedThreadIds.push(thread.id);
211 }
212 return { archivedThreadIds };
213 });
214
215 return {
216 status,
217 create,
218 archiveCompleted,
219 isManagedThread,
220 } satisfies ManagedWorkspaces["Service"];
221});
222
223export const layer = Layer.effect(ManagedWorkspaces, make);