diff --git a/apps/server/src/serverRuntimeStartup.autoPull.test.ts b/apps/server/src/serverRuntimeStartup.autoPull.test.ts index 2bc8f31c8f8b..c1c5884fb333 100644 --- a/apps/server/src/serverRuntimeStartup.autoPull.test.ts +++ b/apps/server/src/serverRuntimeStartup.autoPull.test.ts @@ -163,7 +163,7 @@ it.effect("parks automatic pull until activation without delaying command readin }), Layer.mock(AnalyticsService.AnalyticsService)({ record: () => Effect.void }), // No provider logins or Connect link, so startup records no owners. - FileSystem.layerNoop({}), + FileSystem.layerNoop({ exists: () => Effect.succeed(true) }), Layer.mock(ServerSecretStore.ServerSecretStore)({ get: () => Effect.succeedNone }), NodeCrypto.layer, Layer.mock(EnvironmentAuth.EnvironmentAuth)({}), diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index 180defacbfcc..58e93da08268 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -365,3 +365,37 @@ it.effect("automatic pull only updates enabled, behind, clean default-branch che assert.deepStrictEqual(pulled, ["/inherited"]); }), ); + +it.effect("startup archives active projects whose folder is gone", () => + Effect.gen(function* () { + const archived: string[] = []; + const projects = { + update: (input: { projectId: string; archived?: boolean }) => + Effect.sync(() => { + if (input.archived) archived.push(input.projectId); + return {} as never; + }), + } as unknown as ProjectService.ProjectService["Service"]; + const fileSystem = { + exists: (path: string) => + path === "/unreadable" + ? Effect.fail(new Error("EACCES") as never) + : Effect.succeed(path === "/present"), + } as unknown as FileSystem.FileSystem; + const project = (workspaceRoot: string, archivedAt: string | null = null) => + ({ id: ProjectId.make(workspaceRoot), workspaceRoot, archivedAt }) as never; + + yield* ServerRuntimeStartup.archiveMissingProjects([ + project("/present"), + project("/gone"), + project("/already-archived", "2026-01-01T00:00:00.000Z"), + project("/unreadable"), + ]).pipe( + Effect.provideService(ProjectService.ProjectService, projects), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provide(NodeCrypto.layer), + ); + + assert.deepStrictEqual(archived, ["/gone"]); + }), +); diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 689659ab23f5..bb2c91edeede 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -5,6 +5,7 @@ import { DEFAULT_SERVER_SETTINGS, isListedThreadLineage, type ModelSelection, + type OrchestrationProjectShell, type Project, ProjectId, ProviderInstanceId, @@ -21,6 +22,7 @@ import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as Queue from "effect/Queue"; @@ -245,6 +247,39 @@ export const autoPullProjects = Effect.fn("autoPullProjects")(function* ( ); }); +// Archive, not delete: a missing folder is often an unplugged drive, and archiving keeps threads. +export const archiveMissingProjects = Effect.fn("archiveMissingProjects")(function* ( + projects: ReadonlyArray>, +) { + const fileSystem = yield* FileSystem.FileSystem; + const projectService = yield* ProjectService.ProjectService; + const random = yield* SecureRandom.SecureRandom; + yield* Effect.forEach( + projects.filter((project) => !project.archivedAt), + (project) => + Effect.gen(function* () { + if (yield* fileSystem.exists(project.workspaceRoot)) return; + yield* projectService.update({ + commandId: CommandId.make(yield* random.uuidV4()), + projectId: project.id, + archived: true, + }); + yield* Effect.logInfo("Archived project whose folder is missing", { + projectId: project.id, + workspaceRoot: project.workspaceRoot, + }); + }).pipe( + Effect.catch((cause) => + Effect.logWarning("Failed to check project folder", { + projectId: project.id, + cause, + }), + ), + ), + { concurrency: 8, discard: true }, + ); +}); + const resolveWelcomeBase = Effect.gen(function* () { const serverConfig = yield* ServerConfig.ServerConfig; const segments = serverConfig.cwd.split(/[/\\]/).filter(Boolean); @@ -568,6 +603,9 @@ const make = Effect.fn("serverRuntimeStartup.make")(function* (options?: Startup "projects.auto-pull", Effect.gen(function* () { const projects = yield* (yield* ProjectStore.ProjectStoreV2).listShells(); + yield* archiveMissingProjects(projects).pipe( + Effect.provideService(SecureRandom.SecureRandom, random), + ); const settings = yield* serverSettings.getSettings; yield* autoPullProjects(projects, settings); }), diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md index 8880dcb3e64c..1165b98a961a 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -60,6 +60,9 @@ Its threads, settings, and files stay as they are, and archived projects still s Settings project list, marked **Archived**. Choose one there and select **Unarchive** to bring it back. On mobile, both are in **Settings → Projects → Organize**. +When T3 Code starts, it archives any project whose folder no longer exists, such as one you +deleted or moved, or one on a drive that isn't connected. Unarchive it once the folder is back. + A project on several machines must have every machine connected to pin or archive it. ## Worktree branch names