diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts index 69c553a4deaf..52f57165ba58 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -149,6 +149,75 @@ describe("ProjectSetupScriptRunner", () => { ); }); + it.effect("gives a multi-repo cousin its own terminal and its original repo root", () => { + const open = vi.fn(() => + Effect.succeed({ + threadId: "thread-1", + terminalId: "setup-default-setup-web", + cwd: "/repo/worktrees/t/web", + worktreePath: "/repo/worktrees/t/web", + status: "running" as const, + pid: 123, + history: "", + exitCode: null, + exitSignal: null, + label: "setup-default-setup-web", + updatedAt: "2026-01-01T00:00:00.000Z", + }), + ); + return Effect.gen(function* () { + const runner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; + yield* runner.runForThread({ + threadId: "thread-1", + projectId: "project-1", + worktreePath: "/repo/worktrees/t/web", + repoRoot: "/repo/web", + terminalIdSuffix: "web", + }); + expect(open).toHaveBeenCalledWith({ + threadId: "thread-1", + terminalId: "setup-default-setup-web", + cwd: "/repo/worktrees/t/web", + worktreePath: "/repo/worktrees/t/web", + env: { + T3CODE_PROJECT_ROOT: "/repo/project", + T3CODE_WORKTREE_PATH: "/repo/worktrees/t/web", + T3CODE_REPO_ROOT: "/repo/web", + NO_COLOR: "1", + FORCE_COLOR: "0", + }, + }); + }).pipe( + Effect.provide( + testLayer( + makeProject([]), + { open, write: () => Effect.void }, + ServerSettings.layerTest({ + defaultProjectScripts: [ + { + id: "default-setup", + name: "Setup", + command: "npm install", + icon: "configure", + runOnWorktreeCreate: true, + }, + ], + }), + ), + ), + ); + }); + + it("keeps long setup terminal ids distinct within the terminal id limit", () => { + const repoPrefix = "service-".repeat(20); + const api = ProjectSetupScriptRunner.setupTerminalId("setup", `${repoPrefix}api`); + const web = ProjectSetupScriptRunner.setupTerminalId("setup", `${repoPrefix}web`); + expect(api).not.toBe(web); + expect(api.length).toBeLessThanOrEqual(128); + expect(web.length).toBeLessThanOrEqual(128); + expect(ProjectSetupScriptRunner.setupTerminalId("setup", "web")).toBe("setup-setup-web"); + }); + it.effect("returns no-script when no setup script exists", () => { const open = vi.fn(() => Effect.die("unexpected open")); const write = vi.fn(() => Effect.die("unexpected write")); diff --git a/apps/server/src/project/ProjectSetupScriptRunner.ts b/apps/server/src/project/ProjectSetupScriptRunner.ts index 738440c8531d..c171e43883d6 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.ts @@ -199,6 +199,20 @@ function wrapCommandForCompletion( } } +const TERMINAL_ID_MAX_LENGTH = 128; + +/** + * The setup terminal's id. Ids over the terminal limit keep a hash of the full + * id, so two repos whose suffixes differ only past the limit still get their + * own terminal instead of one stopping the other's script. + */ +export function setupTerminalId(scriptId: string, suffix?: string): string { + const id = suffix ? `setup-${scriptId}-${suffix}` : `setup-${scriptId}`; + if (id.length <= TERMINAL_ID_MAX_LENGTH) return id; + const hash = NodeCrypto.createHash("sha256").update(id).digest("hex").slice(0, 12); + return `${id.slice(0, TERMINAL_ID_MAX_LENGTH - hash.length - 1)}-${hash}`; +} + /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; @@ -354,10 +368,7 @@ export const make = Effect.gen(function* () { } const terminalId = - input.preferredTerminalId ?? - (input.terminalIdSuffix - ? `setup-${script.id}-${input.terminalIdSuffix}`.slice(0, 128) - : `setup-${script.id}`); + input.preferredTerminalId ?? setupTerminalId(script.id, input.terminalIdSuffix); const cwd = input.worktreePath; const env = projectScriptRuntimeEnv({ project: { cwd: project.workspaceRoot },