authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-04 20:40:51-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-04 20:40:51-07:00
log9cb40178a53cca279c67a9079afab3cddf6b6ddb
tree21aa255cb097bf342438bbf7c70fcabe3bf2e8b3
parenta0eb23993a3bacf96d618918f43ea18f2da72390
signature Signed by PGP key B5690EEEBB952194

fix(cli): resolve projects with missing workspace directories (#9885)


2 files changed, 224 insertions(+), 4 deletions(-)

apps/server/src/bin.test.ts+220
...@@ -3,6 +3,7 @@ import * as NodeHttp from "node:http";...@@ -3,6 +3,7 @@ import * as NodeHttp from "node:http";
3import * as NodeFS from "node:fs";3import * as NodeFS from "node:fs";
4import * as NodeOS from "node:os";4import * as NodeOS from "node:os";
5import * as NodePath from "node:path";5import * as NodePath from "node:path";
6import * as NodeChildProcess from "node:child_process";
67
7import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";8import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
8import * as NodeServices from "@effect/platform-node/NodeServices";9import * as NodeServices from "@effect/platform-node/NodeServices";
...@@ -137,6 +138,225 @@ const readPersistedSnapshot = (baseDir: string) =>...@@ -137,6 +138,225 @@ const readPersistedSnapshot = (baseDir: string) =>
137 }).pipe(Effect.provide(makeProjectPersistenceLayer(config)));138 }).pipe(Effect.provide(makeProjectPersistenceLayer(config)));
138 });139 });
139140
141const makeProjectLookupFixture = Effect.fn("makeProjectLookupFixture")(function* (
142 withThread: boolean,
143 removeWorkspace: boolean,
144) {
145 const baseDir = NodeFS.mkdtempSync(
146 NodePath.join(NodeOS.tmpdir(), "t3-cli-project-lookup-state-"),
147 );
148 const workspaceRoot = NodeFS.mkdtempSync(
149 NodePath.join(NodeOS.tmpdir(), "t3-cli-project-lookup-git-"),
150 );
151 NodeChildProcess.execFileSync("git", ["init", "--initial-branch=main", workspaceRoot], {
152 stdio: "ignore",
153 });
154 yield* runCliWithRuntime(["project", "add", workspaceRoot, "--base-dir", baseDir]);
155 const snapshot = yield* readPersistedSnapshot(baseDir);
156 const project = snapshot.projects.find((candidate) => candidate.workspaceRoot === workspaceRoot)!;
157 assert.isDefined(project);
158 if (withThread) {
159 const config = yield* makeCliTestServerConfig(baseDir);
160 yield* Effect.gen(function* () {
161 const engine = yield* OrchestrationEngine.OrchestrationEngineService;
162 yield* engine.dispatch({
163 type: "thread.create",
164 commandId: CommandId.make("cmd-project-lookup-thread"),
165 threadId: ThreadId.make("thread-project-lookup"),
166 projectId: project.id,
167 title: "Project lookup test",
168 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex" },
169 interactionMode: "default",
170 runtimeMode: "approval-required",
171 branch: null,
172 worktreePath: null,
173 createdAt: DateTime.formatIso(yield* DateTime.now),
174 });
175 }).pipe(Effect.provide(makeProjectPersistenceLayer(config)));
176 }
177 if (removeWorkspace) {
178 NodeFS.renameSync(workspaceRoot, `${workspaceRoot}-removed`);
179 assert.isFalse(NodeFS.existsSync(workspaceRoot));
180 }
181 return { baseDir, workspaceRoot, project };
182});
183
184it.layer(NodeServices.layer)("project lookup with unavailable workspaces", (it) => {
185 it.effect("removes an empty project by ID without force after its directory is gone", () =>
186 Effect.gen(function* () {
187 const { baseDir, project } = yield* makeProjectLookupFixture(false, true);
188 yield* runCliWithRuntime(["project", "remove", project.id, "--base-dir", baseDir]);
189 const after = yield* readPersistedSnapshot(baseDir);
190 assert.isNotNull(after.projects.find((candidate) => candidate.id === project.id)!.deletedAt);
191 }),
192 );
193
194 it.effect.each([true, false])(
195 "requires force for child threads, then removes by ID; missing=%s",
196 (removeWorkspace) =>
197 Effect.gen(function* () {
198 const { baseDir, project } = yield* makeProjectLookupFixture(true, removeWorkspace);
199 const error = yield* runCliWithRuntime([
200 "project",
201 "remove",
202 project.id,
203 "--base-dir",
204 baseDir,
205 ]).pipe(Effect.flip);
206 assert.include(error.message, "cannot be deleted without force=true");
207 const retained = yield* readPersistedSnapshot(baseDir);
208 assert.isNull(
209 retained.projects.find((candidate) => candidate.id === project.id)!.deletedAt,
210 );
211 assert.isNull(
212 retained.threads.find((thread) => thread.id === "thread-project-lookup")!.deletedAt,
213 );
214 yield* runCliWithRuntime([
215 "project",
216 "remove",
217 project.id,
218 "--force",
219 "--base-dir",
220 baseDir,
221 ]);
222 const after = yield* readPersistedSnapshot(baseDir);
223 assert.isNotNull(
224 after.projects.find((candidate) => candidate.id === project.id)!.deletedAt,
225 );
226 assert.isNotNull(
227 after.threads.find((thread) => thread.id === "thread-project-lookup")!.deletedAt,
228 );
229 }),
230 );
231
232 it.effect("cannot remove the old environment's ID from a replacement empty database", () =>
233 Effect.gen(function* () {
234 const { baseDir, project } = yield* makeProjectLookupFixture(true, true);
235 const replacementDir = NodeFS.mkdtempSync(
236 NodePath.join(NodeOS.tmpdir(), "t3-cli-project-lookup-new-state-"),
237 );
238 const error = yield* runCliWithRuntime([
239 "project",
240 "remove",
241 project.id,
242 "--force",
243 "--base-dir",
244 replacementDir,
245 ]).pipe(Effect.flip);
246 assert.include(error.message, "No active project found");
247 assert.include(String(error.cause), "Workspace root does not exist");
248 const original = yield* readPersistedSnapshot(baseDir);
249 assert.isNull(original.projects.find((candidate) => candidate.id === project.id)!.deletedAt);
250 const replacement = yield* readPersistedSnapshot(replacementDir);
251 assert.equal(replacement.projects.length, 0);
252 }),
253 );
254
255 it.effect("renames by ID and stored path, then force removes after the directory is gone", () =>
256 Effect.gen(function* () {
257 const { baseDir, workspaceRoot, project } = yield* makeProjectLookupFixture(true, true);
258 yield* runCliWithRuntime([
259 "project",
260 "rename",
261 project.id,
262 "Renamed by ID",
263 "--base-dir",
264 baseDir,
265 ]);
266 const afterIdRename = yield* readPersistedSnapshot(baseDir);
267 assert.equal(
268 afterIdRename.projects.find((candidate) => candidate.id === project.id)!.title,
269 "Renamed by ID",
270 );
271 yield* runCliWithRuntime([
272 "project",
273 "rename",
274 workspaceRoot,
275 "Renamed by stored path",
276 "--base-dir",
277 baseDir,
278 ]);
279 const afterPathRename = yield* readPersistedSnapshot(baseDir);
280 assert.equal(
281 afterPathRename.projects.find((candidate) => candidate.id === project.id)!.title,
282 "Renamed by stored path",
283 );
284 const error = yield* runCliWithRuntime([
285 "project",
286 "remove",
287 workspaceRoot,
288 "--base-dir",
289 baseDir,
290 ]).pipe(Effect.flip);
291 assert.include(error.message, "cannot be deleted without force=true");
292 yield* runCliWithRuntime([
293 "project",
294 "remove",
295 workspaceRoot,
296 "--force",
297 "--base-dir",
298 baseDir,
299 ]);
300 const after = yield* readPersistedSnapshot(baseDir);
301 assert.isNotNull(after.projects.find((candidate) => candidate.id === project.id)!.deletedAt);
302 assert.isNotNull(
303 after.threads.find((thread) => thread.id === "thread-project-lookup")!.deletedAt,
304 );
305 assert.isFalse(NodeFS.existsSync(workspaceRoot));
306 }),
307 );
308
309 it.effect("preserves normalized paths and distinct symlink project entries", () =>
310 Effect.gen(function* () {
311 const { baseDir, workspaceRoot, project } = yield* makeProjectLookupFixture(false, false);
312 const normalizedInput = `${workspaceRoot}${NodePath.sep}.`;
313 yield* runCliWithRuntime([
314 "project",
315 "rename",
316 normalizedInput,
317 "Normalized",
318 "--base-dir",
319 baseDir,
320 ]);
321 const renamed = yield* readPersistedSnapshot(baseDir);
322 assert.equal(
323 renamed.projects.find((candidate) => candidate.id === project.id)!.title,
324 "Normalized",
325 );
326 const aliasPath = `${workspaceRoot}-alias`;
327 NodeFS.symlinkSync(workspaceRoot, aliasPath, "junction");
328 const error = yield* runCliWithRuntime([
329 "project",
330 "remove",
331 aliasPath,
332 "--force",
333 "--base-dir",
334 baseDir,
335 ]).pipe(Effect.flip);
336 assert.include(error.message, "No active project found");
337 yield* runCliWithRuntime(["project", "add", aliasPath, "--base-dir", baseDir]);
338 const added = yield* readPersistedSnapshot(baseDir);
339 const aliasProject = added.projects.find(
340 (candidate) => candidate.workspaceRoot === aliasPath,
341 )!;
342 assert.notEqual(aliasProject.id, project.id);
343 yield* runCliWithRuntime([
344 "project",
345 "remove",
346 `${aliasPath}${NodePath.sep}.`,
347 "--base-dir",
348 baseDir,
349 ]);
350 const after = yield* readPersistedSnapshot(baseDir);
351 assert.isNotNull(
352 after.projects.find((candidate) => candidate.id === aliasProject.id)!.deletedAt,
353 );
354 assert.isNull(after.projects.find((candidate) => candidate.id === project.id)!.deletedAt);
355 assert.isTrue(NodeFS.existsSync(workspaceRoot));
356 }),
357 );
358});
359
140const withLiveProjectCliServer = <A, E, R>(baseDir: string, run: () => Effect.Effect<A, E, R>) =>360const withLiveProjectCliServer = <A, E, R>(baseDir: string, run: () => Effect.Effect<A, E, R>) =>
141 Effect.gen(function* () {361 Effect.gen(function* () {
142 const config = yield* makeCliTestServerConfig(baseDir);362 const config = yield* makeCliTestServerConfig(baseDir);
apps/server/src/cli/project.ts+4-4
...@@ -282,10 +282,10 @@ const findActiveProjectTarget = Effect.fn("findActiveProjectTarget")(function* (...@@ -282,10 +282,10 @@ const findActiveProjectTarget = Effect.fn("findActiveProjectTarget")(function* (
282 const normalizedWorkspaceRoot =282 const normalizedWorkspaceRoot =
283 normalizedWorkspaceRootResult._tag === "Success" ? normalizedWorkspaceRootResult.success : null;283 normalizedWorkspaceRootResult._tag === "Success" ? normalizedWorkspaceRootResult.success : null;
284284
285 const exactWorkspaceMatch =285 // A stored workspace path still identifies its project after the directory is gone.
286 normalizedWorkspaceRoot === null286 const exactWorkspaceMatch = activeProjects.find(
287 ? undefined287 (project) => project.workspaceRoot === (normalizedWorkspaceRoot ?? trimmedIdentifier),
288 : activeProjects.find((project) => project.workspaceRoot === normalizedWorkspaceRoot);288 );
289289
290 const resolved = exactWorkspaceMatch;290 const resolved = exactWorkspaceMatch;
291 if (!resolved) {291 if (!resolved) {