diff --git a/docker-compose.api.gpu.yml b/docker-compose.api.gpu.yml new file mode 100644 index 00000000..6ac52ba6 --- /dev/null +++ b/docker-compose.api.gpu.yml @@ -0,0 +1,3 @@ +services: + api: + gpus: all diff --git a/docker-compose.api.yml b/docker-compose.api.yml index bc8519c0..bee3eb1b 100644 --- a/docker-compose.api.yml +++ b/docker-compose.api.yml @@ -33,7 +33,6 @@ services: volumes: - docker_git_projects:${DOCKER_GIT_PROJECTS_ROOT:-/home/dev/.docker-git} - docker_git_docker_data:/var/lib/docker - gpus: all privileged: true cgroup: host init: true diff --git a/docker-compose.gpu.yml b/docker-compose.gpu.yml new file mode 100644 index 00000000..6ac52ba6 --- /dev/null +++ b/docker-compose.gpu.yml @@ -0,0 +1,3 @@ +services: + api: + gpus: all diff --git a/docker-compose.yml b/docker-compose.yml index 4093f174..e955aff2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -35,7 +35,6 @@ services: volumes: - docker_git_projects:${DOCKER_GIT_PROJECTS_ROOT:-/home/dev/.docker-git} - docker_git_docker_data:/var/lib/docker - gpus: all privileged: true cgroup: host init: true diff --git a/packages/app/src/docker-git/controller-compose.ts b/packages/app/src/docker-git/controller-compose.ts new file mode 100644 index 00000000..48341bc1 --- /dev/null +++ b/packages/app/src/docker-git/controller-compose.ts @@ -0,0 +1,147 @@ +import type { PlatformError } from "@effect/platform/Error" +import * as FileSystem from "@effect/platform/FileSystem" +import * as Path from "@effect/platform/Path" +import { Effect } from "effect" + +import { computeLocalControllerRevision, controllerRevisionEnvKey } from "./controller-revision.js" +import { findExistingUpwards } from "./frontend-lib/usecases/path-helpers.js" +import type { ControllerBootstrapError } from "./host-errors.js" + +export const controllerGpuModeEnvKey = "DOCKER_GIT_CONTROLLER_GPU" + +export type ControllerGpuMode = "none" | "all" + +export type ControllerComposeFiles = { + readonly composePath: string + readonly gpuOverlayPath: string | null +} + +const controllerBootstrapError = (message: string): ControllerBootstrapError => ({ + _tag: "ControllerBootstrapError", + message +}) + +export const parseControllerGpuMode = (raw?: string): ControllerGpuMode | null => { + const trimmed = raw?.trim() ?? "" + if (trimmed.length === 0 || trimmed === "none") { + return "none" + } + return trimmed === "all" ? "all" : null +} + +export const controllerRevisionForMode = ( + sourceRevision: string, + gpuMode: ControllerGpuMode +): string => `${sourceRevision}-${gpuMode}` + +const loadControllerGpuMode = (): Effect.Effect => { + const raw = process.env[controllerGpuModeEnvKey] + const parsed = parseControllerGpuMode(raw) + if (parsed !== null) { + return Effect.succeed(parsed) + } + return Effect.fail( + controllerBootstrapError( + `${controllerGpuModeEnvKey} must be unset or one of: none, all. Received: ${raw ?? ""}` + ) + ) +} + +const composeFilePath = (): Effect.Effect => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + const found = yield* _(findExistingUpwards(fs, path, process.cwd(), "docker-compose.yml", 20)) + return found ?? path.resolve(process.cwd(), "docker-compose.yml") + }) + +const mapComposePathError = (error: PlatformError): ControllerBootstrapError => + controllerBootstrapError(`Failed to resolve docker-compose.yml path.\nDetails: ${String(error)}`) + +const mapControllerRevisionError = (error: PlatformError): ControllerBootstrapError => + controllerBootstrapError(`Failed to compute docker-git controller revision.\nDetails: ${String(error)}`) + +export const composeFilesForMode = ( + composePath: string, + gpuOverlayPath: string | null +): ReadonlyArray => + gpuOverlayPath === null + ? ["-f", composePath] + : ["-f", composePath, "-f", gpuOverlayPath] + +const requireGpuOverlayPath = ( + composePath: string +): Effect.Effect => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + const gpuOverlayPath = path.join(path.dirname(composePath), "docker-compose.gpu.yml") + const exists = yield* _(fs.exists(gpuOverlayPath).pipe(Effect.mapError(mapComposePathError))) + return exists + ? gpuOverlayPath + : yield* _( + Effect.fail( + controllerBootstrapError(`${controllerGpuModeEnvKey}=all requires ${gpuOverlayPath}, but it was not found.`) + ) + ) + }) + +const composeFilesForGpuMode = ( + composePath: string, + gpuMode: ControllerGpuMode +): Effect.Effect => + gpuMode === "none" + ? Effect.succeed({ composePath, gpuOverlayPath: null }) + : requireGpuOverlayPath(composePath).pipe( + Effect.map((gpuOverlayPath) => ({ composePath, gpuOverlayPath })) + ) + +type ComposePathAndGpuMode = { + readonly composePath: string + readonly gpuMode: ControllerGpuMode +} + +const withComposePathAndGpuMode = ( + effect: (input: ComposePathAndGpuMode) => Effect.Effect< + A, + ControllerBootstrapError, + FileSystem.FileSystem | Path.Path + > +): Effect.Effect => + composeFilePath().pipe( + Effect.mapError(mapComposePathError), + Effect.flatMap((composePath) => + loadControllerGpuMode().pipe( + Effect.flatMap((gpuMode) => effect({ composePath, gpuMode })) + ) + ) + ) + +export const resolveControllerComposeFiles = (): Effect.Effect< + ControllerComposeFiles, + ControllerBootstrapError, + FileSystem.FileSystem | Path.Path +> => withComposePathAndGpuMode(({ composePath, gpuMode }) => composeFilesForGpuMode(composePath, gpuMode)) + +const computeControllerRevision = ( + composePath: string, + gpuMode: ControllerGpuMode +): Effect.Effect => + computeLocalControllerRevision(composePath).pipe( + Effect.mapError(mapControllerRevisionError), + Effect.map((revision) => controllerRevisionForMode(revision, gpuMode)) + ) + +const persistControllerRevision = (revision: string): Effect.Effect => + Effect.sync(() => { + process.env[controllerRevisionEnvKey] = revision + }) + +export const prepareControllerRevision = (): Effect.Effect< + string, + ControllerBootstrapError, + FileSystem.FileSystem | Path.Path +> => + withComposePathAndGpuMode(({ composePath, gpuMode }) => computeControllerRevision(composePath, gpuMode)).pipe( + Effect.tap((revision) => persistControllerRevision(revision)) + ) diff --git a/packages/app/src/docker-git/controller-docker.ts b/packages/app/src/docker-git/controller-docker.ts index 13dd1e30..491dfdae 100644 --- a/packages/app/src/docker-git/controller-docker.ts +++ b/packages/app/src/docker-git/controller-docker.ts @@ -1,9 +1,9 @@ import type * as CommandExecutor from "@effect/platform/CommandExecutor" -import type { PlatformError } from "@effect/platform/Error" -import * as FileSystem from "@effect/platform/FileSystem" -import * as Path from "@effect/platform/Path" +import type * as FileSystem from "@effect/platform/FileSystem" +import type * as Path from "@effect/platform/Path" import { Effect } from "effect" +import { composeFilesForMode, prepareControllerRevision, resolveControllerComposeFiles } from "./controller-compose.js" import { runCommandCapture, runCommandExitCode, @@ -18,13 +18,11 @@ import { resolveConfiguredApiBaseUrl, uniqueStrings } from "./controller-reachability.js" -import { - computeLocalControllerRevision, - controllerRevisionEnvKey, - parseControllerRevisionEnvOutput -} from "./controller-revision.js" +import { parseControllerRevisionEnvOutput } from "./controller-revision.js" import type { ControllerBootstrapError } from "./host-errors.js" +export { controllerGpuModeEnvKey, controllerRevisionForMode, parseControllerGpuMode } from "./controller-compose.js" + export type ControllerRuntime = | CommandExecutor.CommandExecutor | FileSystem.FileSystem @@ -41,33 +39,6 @@ const controllerBootstrapError = (message: string): ControllerBootstrapError => message }) -const composeFilePath = (): Effect.Effect => - Effect.gen(function*(_) { - const fs = yield* _(FileSystem.FileSystem) - const path = yield* _(Path.Path) - let current = process.cwd() - - for (;;) { - const candidate = path.join(current, "docker-compose.yml") - const exists = yield* _(fs.exists(candidate)) - if (exists) { - return candidate - } - - const parent = path.dirname(current) - if (parent === current) { - return path.resolve(process.cwd(), "docker-compose.yml") - } - current = parent - } - }) - -const mapComposePathError = (error: PlatformError): ControllerBootstrapError => - controllerBootstrapError(`Failed to resolve docker-compose.yml path.\nDetails: ${String(error)}`) - -const mapControllerRevisionError = (error: PlatformError): ControllerBootstrapError => - controllerBootstrapError(`Failed to compute docker-git controller revision.\nDetails: ${String(error)}`) - const currentProcessEnv = (): Readonly> => Object.fromEntries( Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined) @@ -211,11 +182,10 @@ export const runCompose = ( ): Effect.Effect => Effect.gen(function*(_) { const dockerCommand = yield* _(resolveDockerCommand()) - const composePath = yield* _(composeFilePath().pipe(Effect.mapError(mapComposePathError))) + const composeFiles = yield* _(resolveControllerComposeFiles()) const invocation = buildDockerInvocation(dockerCommand, [ "compose", - "-f", - composePath, + ...composeFilesForMode(composeFiles.composePath, composeFiles.gpuOverlayPath), ...args ]) const exitCode = yield* _( @@ -269,18 +239,7 @@ export const inspectControllerRevision = (): Effect.Effect< ) export const prepareLocalControllerRevision = (): Effect.Effect => - Effect.gen(function*(_) { - const composePath = yield* _(composeFilePath().pipe(Effect.mapError(mapComposePathError))) - const revision = yield* _( - computeLocalControllerRevision(composePath).pipe(Effect.mapError(mapControllerRevisionError)) - ) - yield* _( - Effect.sync(() => { - process.env[controllerRevisionEnvKey] = revision - }) - ) - return revision - }) + prepareControllerRevision() export const inspectContainerNetworks = ( containerName: string diff --git a/packages/app/src/docker-git/controller-revision.ts b/packages/app/src/docker-git/controller-revision.ts index 40a97f5e..57d7f147 100644 --- a/packages/app/src/docker-git/controller-revision.ts +++ b/packages/app/src/docker-git/controller-revision.ts @@ -7,6 +7,7 @@ export const controllerRevisionEnvKey = "DOCKER_GIT_CONTROLLER_REV" const controllerRevisionInputs: ReadonlyArray = [ "docker-compose.yml", + "docker-compose.gpu.yml", "package.json", "bun.lock", "bunfig.toml", diff --git a/packages/app/src/docker-git/frontend-lib/core/domain.ts b/packages/app/src/docker-git/frontend-lib/core/domain.ts index d27977b5..6adfc2d9 100644 --- a/packages/app/src/docker-git/frontend-lib/core/domain.ts +++ b/packages/app/src/docker-git/frontend-lib/core/domain.ts @@ -247,8 +247,7 @@ export type Command = export const isDockerNetworkMode = (value: string): value is DockerNetworkMode => value === "shared" || value === "project" -export const isGpuMode = (value: string): value is GpuMode => - value === "none" || value === "all" +export const isGpuMode = (value: string): value is GpuMode => value === "none" || value === "all" // CHANGE: derive compose network name from typed template config // WHY: keep network naming deterministic across template generation and runtime checks diff --git a/packages/app/src/lib/core/domain.ts b/packages/app/src/lib/core/domain.ts index edbeca2f..394acd2c 100644 --- a/packages/app/src/lib/core/domain.ts +++ b/packages/app/src/lib/core/domain.ts @@ -242,8 +242,7 @@ export type Command = export const isDockerNetworkMode = (value: string): value is DockerNetworkMode => value === "shared" || value === "project" -export const isGpuMode = (value: string): value is GpuMode => - value === "none" || value === "all" +export const isGpuMode = (value: string): value is GpuMode => value === "none" || value === "all" // CHANGE: derive compose network name from typed template config // WHY: keep network naming deterministic across template generation and runtime checks diff --git a/packages/app/src/lib/core/templates/docker-compose.ts b/packages/app/src/lib/core/templates/docker-compose.ts index 604ac1ca..ede0767b 100644 --- a/packages/app/src/lib/core/templates/docker-compose.ts +++ b/packages/app/src/lib/core/templates/docker-compose.ts @@ -181,7 +181,9 @@ const renderComposeServices = ( build: . container_name: ${config.containerName} restart: unless-stopped -${renderGpu(config.gpu)}${renderEnvFiles(config)} # runtime auth/env must be loaded into the container process, not only bootstrap scripts +${renderGpu(config.gpu)}${ + renderEnvFiles(config) + } # runtime auth/env must be loaded into the container process, not only bootstrap scripts environment: REPO_URL: "${config.repoUrl}" REPO_REF: "${config.repoRef}" diff --git a/packages/app/src/lib/usecases/apply-overrides.ts b/packages/app/src/lib/usecases/apply-overrides.ts index 5ec8eff4..8083111a 100644 --- a/packages/app/src/lib/usecases/apply-overrides.ts +++ b/packages/app/src/lib/usecases/apply-overrides.ts @@ -2,16 +2,20 @@ import type { ApplyCommand, TemplateConfig } from "../core/domain.js" import { normalizeAuthLabel, normalizeGitTokenLabel } from "../core/token-labels.js" +const applyOverrideKeys = [ + "gitTokenLabel", + "codexTokenLabel", + "claudeTokenLabel", + "cpuLimit", + "ramLimit", + "playwrightCpuLimit", + "playwrightRamLimit", + "gpu", + "enableMcpPlaywright" +] satisfies ReadonlyArray + export const hasApplyOverrides = (command: ApplyCommand): boolean => - command.gitTokenLabel !== undefined || - command.codexTokenLabel !== undefined || - command.claudeTokenLabel !== undefined || - command.cpuLimit !== undefined || - command.ramLimit !== undefined || - command.playwrightCpuLimit !== undefined || - command.playwrightRamLimit !== undefined || - command.gpu !== undefined || - command.enableMcpPlaywright !== undefined + applyOverrideKeys.some((key) => command[key] !== undefined) const applyTokenOverrides = (template: TemplateConfig, command: ApplyCommand): TemplateConfig => { let next = template diff --git a/packages/app/src/web/app-ready-main-panels.tsx b/packages/app/src/web/app-ready-main-panels.tsx index 92c0921b..bc0624bf 100644 --- a/packages/app/src/web/app-ready-main-panels.tsx +++ b/packages/app/src/web/app-ready-main-panels.tsx @@ -42,6 +42,19 @@ const actionLabels: Record = { const actionLabel = (menu: MainPanelsProps["currentMenu"]): string => actionLabels[menu] +type ProjectActionBarProps = Pick< + MainPanelsProps, + | "currentMenu" + | "onApplyAllProjects" + | "onApplySelectedProject" + | "onRunCurrentMenuAction" + | "project" + | "projectBrowser" + | "selectedProjectSummary" +> + +type ProjectGpuMode = NonNullable["gpu"] + const screenTitle = (props: Pick): string => { if (props.activeScreen.tag === "Create") { return "docker-git / Create" @@ -80,29 +93,129 @@ const MainMenuRoute = ( ) -const ProjectActionBar = ( +const selectedProjectGpu = ( + { project, selectedProjectSummary }: Pick +): ProjectGpuMode | null => + selectedProjectSummary !== undefined && project !== null && project.id === selectedProjectSummary.id + ? project.gpu + : null + +type ActionButtonProps = { + readonly fg?: string | undefined + readonly label: string + readonly onClick: () => void +} + +const ActionButton = ({ fg = "#78f0a3", label, onClick }: ActionButtonProps): JSX.Element => ( + + {label} + +) + +const ProjectSelectionSummary = ( + { currentMenu, project, selectedProjectSummary }: Pick< + ProjectActionBarProps, + "currentMenu" | "project" | "selectedProjectSummary" + > +): JSX.Element => { + const selectedGpu = selectedProjectGpu({ project, selectedProjectSummary }) + const showGpu = currentMenu === "Select" && selectedProjectSummary !== undefined + + return ( + + + {selectedProjectSummary === undefined ? "No project selected." : selectedProjectSummary.displayName} + + {showGpu ? GPU: {selectedGpu ?? "unknown"} : null} + + ) +} + +const ProjectGpuControls = ( + { onApplySelectedProject, selectedGpu }: Pick & { + readonly selectedGpu: ProjectGpuMode | null + } +): JSX.Element => ( + <> + { + onApplySelectedProject("all") + }} + /> + { + onApplySelectedProject("none") + }} + /> + +) + +const SelectProjectControls = ( { currentMenu, onApplyAllProjects, onApplySelectedProject, + selectedGpu, + selectedProjectSummary + }: + & Pick< + ProjectActionBarProps, + "currentMenu" | "onApplyAllProjects" | "onApplySelectedProject" | "selectedProjectSummary" + > + & { + readonly selectedGpu: ProjectGpuMode | null + } +): JSX.Element | null => { + if (currentMenu !== "Select") { + return null + } + + return ( + <> + {selectedProjectSummary === undefined + ? null + : } + {selectedProjectSummary === undefined + ? null + : ( + { + onApplySelectedProject() + }} + /> + )} + + + ) +} + +const PrimaryMenuAction = ( + { + currentMenu, onRunCurrentMenuAction, - project, projectBrowser, selectedProjectSummary }: Pick< - MainPanelsProps, - | "currentMenu" - | "onApplyAllProjects" - | "onApplySelectedProject" - | "onRunCurrentMenuAction" - | "project" - | "projectBrowser" - | "selectedProjectSummary" + ProjectActionBarProps, + "currentMenu" | "onRunCurrentMenuAction" | "projectBrowser" | "selectedProjectSummary" > ): JSX.Element => { - const selectedGpu = selectedProjectSummary !== undefined && project !== null && project.id === selectedProjectSummary.id - ? project.gpu - : null + const label = actionLabel(currentMenu) + const browserUnavailable = currentMenu === "Browser" && + !canOpenProjectBrowser(projectBrowser, selectedProjectSummary?.id ?? null) + + return browserUnavailable + ? {label} + : +} + +const ProjectActionBar = (props: ProjectActionBarProps): JSX.Element => { + const selectedGpu = selectedProjectGpu(props) return ( - - - {selectedProjectSummary === undefined ? "No project selected." : selectedProjectSummary.displayName} - - {currentMenu === "Select" && selectedProjectSummary !== undefined - ? GPU: {selectedGpu ?? "unknown"} - : null} - + - {currentMenu === "Select" && selectedProjectSummary !== undefined - ? ( - { - onApplySelectedProject("all") - }} width="auto"> - GPU on - - ) - : null} - {currentMenu === "Select" && selectedProjectSummary !== undefined - ? ( - { - onApplySelectedProject("none") - }} width="auto"> - GPU off - - ) - : null} - {currentMenu === "Select" && selectedProjectSummary !== undefined - ? ( - { - onApplySelectedProject() - }} width="auto"> - Apply - - ) - : null} - {currentMenu === "Select" - ? ( - - Apply all - - ) - : null} - {currentMenu === "Browser" && !canOpenProjectBrowser(projectBrowser, selectedProjectSummary?.id ?? null) - ? {actionLabel(currentMenu)} - : ( - - {actionLabel(currentMenu)} - - )} + + ) diff --git a/packages/app/tests/docker-git/actions-projects.test.ts b/packages/app/tests/docker-git/actions-projects.test.ts index 01260572..d9a85502 100644 --- a/packages/app/tests/docker-git/actions-projects.test.ts +++ b/packages/app/tests/docker-git/actions-projects.test.ts @@ -255,7 +255,7 @@ describe("web project actions", () => { applyProjectById("project-1", context) yield* _(waitForAssertion(() => { - expect(applyProjectMock).toHaveBeenCalledWith("project-1") + expect(applyProjectMock).toHaveBeenCalledWith("project-1", undefined) })) expect(confirmMock).toHaveBeenCalledWith( @@ -265,7 +265,7 @@ describe("web project actions", () => { expect(context.setSelectedProjectId).toHaveBeenCalledWith("project-1") expect(context.setSelectedProject).toHaveBeenCalledWith(project) expect(reloadDashboard).toHaveBeenCalledTimes(1) - expect(setMessage).toHaveBeenLastCalledWith("Applied octocat/hello-world.") + expect(setMessage).toHaveBeenLastCalledWith("Applied octocat/hello-world (GPU none).") })) it("does not apply a project when the user declines confirmation", () => { diff --git a/packages/app/tests/docker-git/app-ready-create.test.ts b/packages/app/tests/docker-git/app-ready-create.test.ts index 18352a68..80fc7900 100644 --- a/packages/app/tests/docker-git/app-ready-create.test.ts +++ b/packages/app/tests/docker-git/app-ready-create.test.ts @@ -64,6 +64,7 @@ describe("app-ready-create", () => { "repoUrl", "cpuLimit", "ramLimit", + "gpu", "runUp", "mcpPlaywright" ]) diff --git a/packages/app/tests/docker-git/controller.test.ts b/packages/app/tests/docker-git/controller.test.ts index 1872c890..9889c2de 100644 --- a/packages/app/tests/docker-git/controller.test.ts +++ b/packages/app/tests/docker-git/controller.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "@effect/vitest" import { Effect } from "effect" +import { controllerRevisionForMode, parseControllerGpuMode } from "../../src/docker-git/controller-docker.js" import { parseControllerRevisionEnvOutput, shouldForceRecreateController @@ -98,4 +99,19 @@ describe("controller reachability", () => { expect(shouldForceRecreateController(true, "local-a", "local-b")).toBe(true) expect(shouldForceRecreateController(true, "local-a", null)).toBe(true) })) + + it.effect("parses controller GPU mode from environment values", () => + Effect.sync(() => { + expect(parseControllerGpuMode()).toBe("none") + expect(parseControllerGpuMode("")).toBe("none") + expect(parseControllerGpuMode("none")).toBe("none") + expect(parseControllerGpuMode("all")).toBe("all") + expect(parseControllerGpuMode("gpu")).toBeNull() + })) + + it.effect("includes controller GPU mode in the revision", () => + Effect.sync(() => { + expect(controllerRevisionForMode("abc123def4567890", "none")).toBe("abc123def4567890-none") + expect(controllerRevisionForMode("abc123def4567890", "all")).toBe("abc123def4567890-all") + })) }) diff --git a/packages/app/tests/docker-git/menu-create-shared.test.ts b/packages/app/tests/docker-git/menu-create-shared.test.ts index e01c979b..875cb62c 100644 --- a/packages/app/tests/docker-git/menu-create-shared.test.ts +++ b/packages/app/tests/docker-git/menu-create-shared.test.ts @@ -56,6 +56,7 @@ describe("menu-create-shared", () => { "repoUrl", "cpuLimit", "ramLimit", + "gpu", "runUp", "mcpPlaywright", "force" @@ -86,7 +87,8 @@ describe("menu-create-shared", () => { expect(resolveCreateFlowSteps(view.values)).toEqual([ "repoUrl", "cpuLimit", - "ramLimit" + "ramLimit", + "gpu" ]) }) @@ -94,13 +96,14 @@ describe("menu-create-shared", () => { const inputs = expectCompleteResult(advanceCreateFlow( cwd, createInitialFlowView( - "https://github.com/org/repo/tree/feature-x --cpu 25% --ram 4g --no-up --mcp-playwright --force" + "https://github.com/org/repo/tree/feature-x --cpu 25% --ram 4g --gpu all --no-up --mcp-playwright --force" ) )) expectFeatureRepoDefaults(inputs, defaultRoot) expect(inputs.cpuLimit).toBe("25%") expect(inputs.ramLimit).toBe("4g") + expect(inputs.gpu).toBe("all") expect(inputs.runUp).toBe(false) expect(inputs.enableMcpPlaywright).toBe(true) expect(inputs.force).toBe(true) diff --git a/packages/app/tests/docker-git/parser-project-actions.test.ts b/packages/app/tests/docker-git/parser-project-actions.test.ts new file mode 100644 index 00000000..d533fb0d --- /dev/null +++ b/packages/app/tests/docker-git/parser-project-actions.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "@effect/vitest" +import { Effect } from "effect" + +import { expectProjectDirRunUpCommand, parseOrThrow } from "./parser-helpers.js" + +describe("parseArgs project actions", () => { + it.effect("parses mcp-playwright command in current directory", () => + expectProjectDirRunUpCommand(["mcp-playwright"], "McpPlaywrightUp", ".", true)) + + it.effect("parses mcp-playwright command with --no-up", () => + expectProjectDirRunUpCommand(["mcp-playwright", "--no-up"], "McpPlaywrightUp", ".", false)) + + it.effect("parses mcp-playwright with positional repo url into project dir", () => + Effect.sync(() => { + const command = parseOrThrow(["mcp-playwright", "https://github.com/org/repo.git"]) + if (command._tag !== "McpPlaywrightUp") { + throw new Error("expected McpPlaywrightUp command") + } + expect(command.projectDir).toBe(".docker-git/org/repo") + })) + + it.effect("parses apply command in current directory", () => + expectProjectDirRunUpCommand(["apply"], "Apply", ".", true)) + + it.effect("parses apply command with --no-up", () => + expectProjectDirRunUpCommand(["apply", "--no-up"], "Apply", ".", false)) + + it.effect("parses apply with positional repo url into project dir", () => + Effect.sync(() => { + const command = parseOrThrow(["apply", "https://github.com/org/repo.git"]) + if (command._tag !== "Apply") { + throw new Error("expected Apply command") + } + expect(command.projectDir).toBe(".docker-git/org/repo") + })) + + it.effect("parses apply token and mcp overrides", () => + Effect.sync(() => { + const command = parseOrThrow([ + "apply", + "--git-token=agien_main", + "--codex-token=Team A", + "--claude-token=Team B", + "--cpu=2", + "--ram=4g", + "--gpu=all", + "--mcp-playwright", + "--no-up" + ]) + if (command._tag !== "Apply") { + throw new Error("expected Apply command") + } + expect(command.runUp).toBe(false) + expect(command.gitTokenLabel).toBe("agien_main") + expect(command.codexTokenLabel).toBe("Team A") + expect(command.claudeTokenLabel).toBe("Team B") + expect(command.cpuLimit).toBe("2") + expect(command.ramLimit).toBe("4g") + expect(command.gpu).toBe("all") + expect(command.enableMcpPlaywright).toBe(true) + })) + + it.effect("parses apply-all and update-all commands", () => + Effect.sync(() => { + expect(parseOrThrow(["apply-all"])._tag).toBe("ApplyAll") + expect(parseOrThrow(["update-all"])._tag).toBe("ApplyAll") + })) + + it.effect("parses down-all command", () => + Effect.sync(() => { + const command = parseOrThrow(["down-all"]) + expect(command._tag).toBe("DownAll") + })) + + it.effect("parses state path command", () => + Effect.sync(() => { + const command = parseOrThrow(["state", "path"]) + expect(command._tag).toBe("StatePath") + })) + + it.effect("parses state init command", () => + Effect.sync(() => { + const command = parseOrThrow(["state", "init", "--repo-url", "https://github.com/org/state.git"]) + if (command._tag !== "StateInit") { + throw new Error("expected StateInit command") + } + expect(command.repoUrl).toBe("https://github.com/org/state.git") + expect(command.repoRef).toBe("main") + })) + + it.effect("parses state commit command", () => + Effect.sync(() => { + const command = parseOrThrow(["state", "commit", "-m", "sync state"]) + if (command._tag !== "StateCommit") { + throw new Error("expected StateCommit command") + } + expect(command.message).toBe("sync state") + })) + + it.effect("parses state sync command", () => + Effect.sync(() => { + const command = parseOrThrow(["state", "sync", "-m", "sync state"]) + if (command._tag !== "StateSync") { + throw new Error("expected StateSync command") + } + expect(command.message).toBe("sync state") + })) +}) diff --git a/packages/app/tests/docker-git/parser.test.ts b/packages/app/tests/docker-git/parser.test.ts index d41ac444..5fa97f0d 100644 --- a/packages/app/tests/docker-git/parser.test.ts +++ b/packages/app/tests/docker-git/parser.test.ts @@ -1,5 +1,4 @@ import { describe, expect, it } from "@effect/vitest" -import { Effect } from "effect" import { defaultTemplateConfig } from "../../src/docker-git/frontend-lib/core/domain.js" import { expandContainerHome } from "../../src/docker-git/frontend-lib/usecases/scrap-path.js" @@ -8,9 +7,7 @@ import { expectAttachProjectDirCommand, expectCreateCommand, expectOpenCommand, - expectParseErrorTag, - expectProjectDirRunUpCommand, - parseOrThrow + expectParseErrorTag } from "./parser-helpers.js" const expectCreateDefaults = (command: CreateCommand) => { @@ -253,106 +250,4 @@ describe("parseArgs", () => { expect(command.projectRef).toBeUndefined() expect(command.projectDir).toBeUndefined() })) - - it.effect("parses mcp-playwright command in current directory", () => - expectProjectDirRunUpCommand(["mcp-playwright"], "McpPlaywrightUp", ".", true)) - - it.effect("parses mcp-playwright command with --no-up", () => - expectProjectDirRunUpCommand(["mcp-playwright", "--no-up"], "McpPlaywrightUp", ".", false)) - - it.effect("parses mcp-playwright with positional repo url into project dir", () => - Effect.sync(() => { - const command = parseOrThrow(["mcp-playwright", "https://github.com/org/repo.git"]) - if (command._tag !== "McpPlaywrightUp") { - throw new Error("expected McpPlaywrightUp command") - } - expect(command.projectDir).toBe(".docker-git/org/repo") - })) - - it.effect("parses apply command in current directory", () => - expectProjectDirRunUpCommand(["apply"], "Apply", ".", true)) - - it.effect("parses apply command with --no-up", () => - expectProjectDirRunUpCommand(["apply", "--no-up"], "Apply", ".", false)) - - it.effect("parses apply with positional repo url into project dir", () => - Effect.sync(() => { - const command = parseOrThrow(["apply", "https://github.com/org/repo.git"]) - if (command._tag !== "Apply") { - throw new Error("expected Apply command") - } - expect(command.projectDir).toBe(".docker-git/org/repo") - })) - - it.effect("parses apply token and mcp overrides", () => - Effect.sync(() => { - const command = parseOrThrow([ - "apply", - "--git-token=agien_main", - "--codex-token=Team A", - "--claude-token=Team B", - "--cpu=2", - "--ram=4g", - "--gpu=all", - "--mcp-playwright", - "--no-up" - ]) - if (command._tag !== "Apply") { - throw new Error("expected Apply command") - } - expect(command.runUp).toBe(false) - expect(command.gitTokenLabel).toBe("agien_main") - expect(command.codexTokenLabel).toBe("Team A") - expect(command.claudeTokenLabel).toBe("Team B") - expect(command.cpuLimit).toBe("2") - expect(command.ramLimit).toBe("4g") - expect(command.gpu).toBe("all") - expect(command.enableMcpPlaywright).toBe(true) - })) - - it.effect("parses apply-all and update-all commands", () => - Effect.sync(() => { - expect(parseOrThrow(["apply-all"])._tag).toBe("ApplyAll") - expect(parseOrThrow(["update-all"])._tag).toBe("ApplyAll") - })) - - it.effect("parses down-all command", () => - Effect.sync(() => { - const command = parseOrThrow(["down-all"]) - expect(command._tag).toBe("DownAll") - })) - - it.effect("parses state path command", () => - Effect.sync(() => { - const command = parseOrThrow(["state", "path"]) - expect(command._tag).toBe("StatePath") - })) - - it.effect("parses state init command", () => - Effect.sync(() => { - const command = parseOrThrow(["state", "init", "--repo-url", "https://github.com/org/state.git"]) - if (command._tag !== "StateInit") { - throw new Error("expected StateInit command") - } - expect(command.repoUrl).toBe("https://github.com/org/state.git") - expect(command.repoRef).toBe("main") - })) - - it.effect("parses state commit command", () => - Effect.sync(() => { - const command = parseOrThrow(["state", "commit", "-m", "sync state"]) - if (command._tag !== "StateCommit") { - throw new Error("expected StateCommit command") - } - expect(command.message).toBe("sync state") - })) - - it.effect("parses state sync command", () => - Effect.sync(() => { - const command = parseOrThrow(["state", "sync", "-m", "sync state"]) - if (command._tag !== "StateSync") { - throw new Error("expected StateSync command") - } - expect(command.message).toBe("sync state") - })) }) diff --git a/packages/lib/src/core/domain.ts b/packages/lib/src/core/domain.ts index 70f7721a..c1c7d0b6 100644 --- a/packages/lib/src/core/domain.ts +++ b/packages/lib/src/core/domain.ts @@ -241,8 +241,7 @@ export type Command = export const isDockerNetworkMode = (value: string): value is DockerNetworkMode => value === "shared" || value === "project" -export const isGpuMode = (value: string): value is GpuMode => - value === "none" || value === "all" +export const isGpuMode = (value: string): value is GpuMode => value === "none" || value === "all" // CHANGE: derive compose network name from typed template config // WHY: keep network naming deterministic across template generation and runtime checks diff --git a/packages/lib/src/core/templates/docker-compose.ts b/packages/lib/src/core/templates/docker-compose.ts index 20cb7825..c37f959c 100644 --- a/packages/lib/src/core/templates/docker-compose.ts +++ b/packages/lib/src/core/templates/docker-compose.ts @@ -180,7 +180,9 @@ const renderComposeServices = ( build: . container_name: ${config.containerName} restart: unless-stopped -${renderGpu(config.gpu)}${renderEnvFiles(config)} # runtime auth/env must be loaded into the container process, not only bootstrap scripts +${renderGpu(config.gpu)}${ + renderEnvFiles(config) + } # runtime auth/env must be loaded into the container process, not only bootstrap scripts environment: REPO_URL: "${config.repoUrl}" REPO_REF: "${config.repoRef}" diff --git a/packages/lib/src/usecases/apply-overrides.ts b/packages/lib/src/usecases/apply-overrides.ts index 5040704f..3b2e0289 100644 --- a/packages/lib/src/usecases/apply-overrides.ts +++ b/packages/lib/src/usecases/apply-overrides.ts @@ -1,16 +1,20 @@ import type { ApplyCommand, TemplateConfig } from "../core/domain.js" import { normalizeAuthLabel, normalizeGitTokenLabel } from "../core/token-labels.js" +const applyOverrideKeys = [ + "gitTokenLabel", + "codexTokenLabel", + "claudeTokenLabel", + "cpuLimit", + "ramLimit", + "playwrightCpuLimit", + "playwrightRamLimit", + "gpu", + "enableMcpPlaywright" +] satisfies ReadonlyArray + export const hasApplyOverrides = (command: ApplyCommand): boolean => - command.gitTokenLabel !== undefined || - command.codexTokenLabel !== undefined || - command.claudeTokenLabel !== undefined || - command.cpuLimit !== undefined || - command.ramLimit !== undefined || - command.playwrightCpuLimit !== undefined || - command.playwrightRamLimit !== undefined || - command.gpu !== undefined || - command.enableMcpPlaywright !== undefined + applyOverrideKeys.some((key) => command[key] !== undefined) const applyTokenOverrides = (template: TemplateConfig, command: ApplyCommand): TemplateConfig => { let next = template