From 94783e4036a47fc55e162dced2c1ec75a4c70485 Mon Sep 17 00:00:00 2001 From: konard Date: Sat, 9 May 2026 17:35:24 +0000 Subject: [PATCH 1/3] Initial commit with task details Adding .gitkeep for PR creation (default mode). This file will be removed when the task is complete. Issue: https://github.com/ProverCoderAI/docker-git/issues/259 --- .gitkeep | 1 + 1 file changed, 1 insertion(+) create mode 100644 .gitkeep diff --git a/.gitkeep b/.gitkeep new file mode 100644 index 00000000..fa600f02 --- /dev/null +++ b/.gitkeep @@ -0,0 +1 @@ +# .gitkeep file auto-generated at 2026-05-09T17:35:24.814Z for PR creation at branch issue-259-9a9eea9aba5c for issue https://github.com/ProverCoderAI/docker-git/issues/259 \ No newline at end of file From bda7e84f761c922557d1e286ccb0c39b8627b580 Mon Sep 17 00:00:00 2001 From: konard Date: Sat, 9 May 2026 18:28:36 +0000 Subject: [PATCH 2/3] feat(docker-git): add resource limits for MCP Playwright sidecar Introduce configurable CPU and RAM limits for the MCP Playwright browser sidecar, separate from the main service container. - Add `--playwright-cpu`/`--playwright-cpus` and `--playwright-ram`/`--playwright-memory` CLI flags - Default to 30% of host resources, falling back to the main service limits when not set - Wire limits through template defaults, parser, apply overrides, and the docker-compose template - Mirror changes across canonical lib, app/lib, and frontend-lib paths Closes #259 --- .changeset/playwright-resource-limits.md | 6 + .../app/src/docker-git/cli/parser-apply.ts | 4 + .../app/src/docker-git/cli/parser-options.ts | 8 ++ packages/app/src/docker-git/cli/usage.ts | 2 + .../frontend-lib/core/command-builders.ts | 118 ++++++++++-------- .../frontend-lib/core/command-options.ts | 2 + .../docker-git/frontend-lib/core/domain.ts | 6 + .../frontend-lib/core/resource-limits.ts | 48 ++++++- .../frontend-lib/core/template-defaults.ts | 8 ++ packages/app/src/lib/core/command-builders.ts | 118 ++++++++++-------- packages/app/src/lib/core/command-options.ts | 2 + packages/app/src/lib/core/domain.ts | 6 + packages/app/src/lib/core/resource-limits.ts | 48 ++++++- .../app/src/lib/core/template-defaults.ts | 8 ++ packages/app/src/lib/core/templates.ts | 4 +- .../src/lib/core/templates/docker-compose.ts | 36 ++++-- packages/app/src/lib/shell/files.ts | 11 +- .../app/src/lib/usecases/apply-overrides.ts | 66 +++++----- .../parser-playwright-resource.test.ts | 56 +++++++++ packages/lib/src/core/command-builders.ts | 118 ++++++++++-------- packages/lib/src/core/command-options.ts | 2 + packages/lib/src/core/domain.ts | 6 + packages/lib/src/core/resource-limits.ts | 48 ++++++- packages/lib/src/core/template-defaults.ts | 8 ++ packages/lib/src/core/templates.ts | 4 +- .../lib/src/core/templates/docker-compose.ts | 36 ++++-- packages/lib/src/shell/files.ts | 11 +- packages/lib/src/usecases/apply-overrides.ts | 66 +++++----- .../lib/tests/core/resource-limits.test.ts | 83 ++++++++++++ packages/lib/tests/core/templates.test.ts | 41 ++++++ 30 files changed, 723 insertions(+), 257 deletions(-) create mode 100644 .changeset/playwright-resource-limits.md create mode 100644 packages/app/tests/docker-git/parser-playwright-resource.test.ts diff --git a/.changeset/playwright-resource-limits.md b/.changeset/playwright-resource-limits.md new file mode 100644 index 00000000..b6fec008 --- /dev/null +++ b/.changeset/playwright-resource-limits.md @@ -0,0 +1,6 @@ +--- +"@prover-coder-ai/docker-git": minor +"@effect-template/lib": minor +--- + +Add configurable CPU and RAM limits for the MCP Playwright sidecar container, separate from the main service container. Exposed via `--playwright-cpu`/`--playwright-cpus` and `--playwright-ram`/`--playwright-memory` CLI flags. Defaults to 30% of host resources, falling back to the main service limits when not set. diff --git a/packages/app/src/docker-git/cli/parser-apply.ts b/packages/app/src/docker-git/cli/parser-apply.ts index 384265e9..3990353f 100644 --- a/packages/app/src/docker-git/cli/parser-apply.ts +++ b/packages/app/src/docker-git/cli/parser-apply.ts @@ -22,6 +22,8 @@ export const parseApply = ( const { projectDir, raw } = yield* _(parseProjectDirWithOptions(args)) const cpuLimit = yield* _(normalizeCpuLimit(raw.cpuLimit, "--cpu")) const ramLimit = yield* _(normalizeRamLimit(raw.ramLimit, "--ram")) + const playwrightCpuLimit = yield* _(normalizeCpuLimit(raw.playwrightCpuLimit, "--playwright-cpu")) + const playwrightRamLimit = yield* _(normalizeRamLimit(raw.playwrightRamLimit, "--playwright-ram")) return { _tag: "Apply", projectDir, @@ -31,6 +33,8 @@ export const parseApply = ( claudeTokenLabel: raw.claudeTokenLabel, cpuLimit, ramLimit, + playwrightCpuLimit, + playwrightRamLimit, enableMcpPlaywright: raw.enableMcpPlaywright } }) diff --git a/packages/app/src/docker-git/cli/parser-options.ts b/packages/app/src/docker-git/cli/parser-options.ts index a3dd9ab3..e1bb1c54 100644 --- a/packages/app/src/docker-git/cli/parser-options.ts +++ b/packages/app/src/docker-git/cli/parser-options.ts @@ -22,6 +22,8 @@ interface ValueOptionSpec { | "codexHome" | "cpuLimit" | "ramLimit" + | "playwrightCpuLimit" + | "playwrightRamLimit" | "dockerNetworkMode" | "dockerSharedNetworkName" | "archivePath" @@ -60,6 +62,10 @@ const valueOptionSpecs: ReadonlyArray = [ { flag: "--cpus", key: "cpuLimit" }, { flag: "--ram", key: "ramLimit" }, { flag: "--memory", key: "ramLimit" }, + { flag: "--playwright-cpu", key: "playwrightCpuLimit" }, + { flag: "--playwright-cpus", key: "playwrightCpuLimit" }, + { flag: "--playwright-ram", key: "playwrightRamLimit" }, + { flag: "--playwright-memory", key: "playwrightRamLimit" }, { flag: "--network-mode", key: "dockerNetworkMode" }, { flag: "--shared-network", key: "dockerSharedNetworkName" }, { flag: "--archive", key: "archivePath" }, @@ -118,6 +124,8 @@ const valueFlagUpdaters: { readonly [K in ValueKey]: (raw: RawOptions, value: st codexHome: (raw, value) => ({ ...raw, codexHome: value }), cpuLimit: (raw, value) => ({ ...raw, cpuLimit: value }), ramLimit: (raw, value) => ({ ...raw, ramLimit: value }), + playwrightCpuLimit: (raw, value) => ({ ...raw, playwrightCpuLimit: value }), + playwrightRamLimit: (raw, value) => ({ ...raw, playwrightRamLimit: value }), dockerNetworkMode: (raw, value) => ({ ...raw, dockerNetworkMode: value }), dockerSharedNetworkName: (raw, value) => ({ ...raw, dockerSharedNetworkName: value }), archivePath: (raw, value) => ({ ...raw, archivePath: value }), diff --git a/packages/app/src/docker-git/cli/usage.ts b/packages/app/src/docker-git/cli/usage.ts index c672cdda..9ecbebd5 100644 --- a/packages/app/src/docker-git/cli/usage.ts +++ b/packages/app/src/docker-git/cli/usage.ts @@ -54,6 +54,8 @@ Options: --codex-home Container path for Codex auth (default: /home/dev/.codex) --cpu CPU limit: percent or cores (examples: 30%, 1.5; default: 30%) --ram RAM limit: percent or size (examples: 30%, 512m, 4g; default: 30%) + --playwright-cpu CPU limit for the MCP Playwright browser sidecar (default: 30% or --cpu when set) + --playwright-ram RAM limit for the MCP Playwright browser sidecar (default: 30% or --ram when set) --network-mode Compose network mode: shared|project (default: shared) --shared-network Shared Docker network name when network-mode=shared (default: docker-git-shared) --out-dir Output directory (default: //[/issue-|/pr-]) diff --git a/packages/app/src/docker-git/frontend-lib/core/command-builders.ts b/packages/app/src/docker-git/frontend-lib/core/command-builders.ts index d3c74b06..8cd168e3 100644 --- a/packages/app/src/docker-git/frontend-lib/core/command-builders.ts +++ b/packages/app/src/docker-git/frontend-lib/core/command-builders.ts @@ -8,15 +8,13 @@ import { type RawOptions } from "./command-options.js" import { type AgentMode, type CreateCommand, - defaultCpuLimit, - defaultRamLimit, defaultTemplateConfig, deriveRepoPathParts, deriveRepoSlug, type ParseError, resolveRepoInput } from "./domain.js" -import { normalizeCpuLimit, normalizeRamLimit } from "./resource-limits.js" +import { resolveResourceLimitsIntent } from "./resource-limits.js" import { trimRightChar } from "./strings.js" import { normalizeAuthLabel, normalizeGitTokenLabel } from "./token-labels.js" @@ -193,6 +191,8 @@ type BuildTemplateConfigInput = { readonly paths: PathConfig readonly cpuLimit: string | undefined readonly ramLimit: string | undefined + readonly playwrightCpuLimit: string | undefined + readonly playwrightRamLimit: string | undefined readonly dockerNetworkMode: CreateCommand["config"]["dockerNetworkMode"] readonly dockerSharedNetworkName: string readonly gitTokenLabel: string | undefined @@ -205,53 +205,64 @@ type BuildTemplateConfigInput = { readonly clonedOnHostname?: string | undefined } -const buildTemplateConfig = ({ - agentAuto, - agentMode, - claudeAuthLabel, - clonedOnHostname, - codexAuthLabel, - cpuLimit, - dockerNetworkMode, - dockerSharedNetworkName, - enableMcpPlaywright, - gitTokenLabel, - names, - paths, - ramLimit, - repo, - skipGithubAuth -}: BuildTemplateConfigInput): CreateCommand["config"] => ({ - containerName: names.containerName, - serviceName: names.serviceName, - sshUser: repo.sshUser, - sshPort: repo.sshPort, - repoUrl: repo.repoUrl, - repoRef: repo.repoRef, - gitTokenLabel, - skipGithubAuth, - codexAuthLabel, - claudeAuthLabel, - targetDir: repo.targetDir, - volumeName: names.volumeName, - dockerGitPath: paths.dockerGitPath, - authorizedKeysPath: paths.authorizedKeysPath, - envGlobalPath: paths.envGlobalPath, - envProjectPath: paths.envProjectPath, - codexAuthPath: paths.codexAuthPath, - codexSharedAuthPath: paths.codexSharedAuthPath, - codexHome: paths.codexHome, - geminiAuthPath: paths.geminiAuthPath, - geminiHome: paths.geminiHome, - cpuLimit, - ramLimit, - dockerNetworkMode, - dockerSharedNetworkName, - enableMcpPlaywright, +const buildTemplateConfigBase = ( + input: Pick +): Pick< + CreateCommand["config"], + | "containerName" + | "serviceName" + | "sshUser" + | "sshPort" + | "repoUrl" + | "repoRef" + | "targetDir" + | "volumeName" + | "dockerGitPath" + | "authorizedKeysPath" + | "envGlobalPath" + | "envProjectPath" + | "codexAuthPath" + | "codexSharedAuthPath" + | "codexHome" + | "geminiAuthPath" + | "geminiHome" +> => ({ + containerName: input.names.containerName, + serviceName: input.names.serviceName, + sshUser: input.repo.sshUser, + sshPort: input.repo.sshPort, + repoUrl: input.repo.repoUrl, + repoRef: input.repo.repoRef, + targetDir: input.repo.targetDir, + volumeName: input.names.volumeName, + dockerGitPath: input.paths.dockerGitPath, + authorizedKeysPath: input.paths.authorizedKeysPath, + envGlobalPath: input.paths.envGlobalPath, + envProjectPath: input.paths.envProjectPath, + codexAuthPath: input.paths.codexAuthPath, + codexSharedAuthPath: input.paths.codexSharedAuthPath, + codexHome: input.paths.codexHome, + geminiAuthPath: input.paths.geminiAuthPath, + geminiHome: input.paths.geminiHome +}) + +const buildTemplateConfig = (input: BuildTemplateConfigInput): CreateCommand["config"] => ({ + ...buildTemplateConfigBase(input), + gitTokenLabel: input.gitTokenLabel, + skipGithubAuth: input.skipGithubAuth, + codexAuthLabel: input.codexAuthLabel, + claudeAuthLabel: input.claudeAuthLabel, + cpuLimit: input.cpuLimit, + ramLimit: input.ramLimit, + playwrightCpuLimit: input.playwrightCpuLimit, + playwrightRamLimit: input.playwrightRamLimit, + dockerNetworkMode: input.dockerNetworkMode, + dockerSharedNetworkName: input.dockerSharedNetworkName, + enableMcpPlaywright: input.enableMcpPlaywright, bunVersion: defaultTemplateConfig.bunVersion, - agentMode, - agentAuto, - clonedOnHostname + agentMode: input.agentMode, + agentAuto: input.agentAuto, + clonedOnHostname: input.clonedOnHostname }) // CHANGE: build a typed create command from raw options (CLI or API) @@ -275,8 +286,7 @@ export const buildCreateCommand = ( const gitTokenLabel = normalizeGitTokenLabel(raw.gitTokenLabel) const codexAuthLabel = normalizeAuthLabel(raw.codexTokenLabel) const claudeAuthLabel = normalizeAuthLabel(raw.claudeTokenLabel) - const cpuLimit = yield* _(normalizeCpuLimit(raw.cpuLimit ?? defaultCpuLimit, "--cpu")) - const ramLimit = yield* _(normalizeRamLimit(raw.ramLimit ?? defaultRamLimit, "--ram")) + const limits = yield* _(resolveResourceLimitsIntent(raw)) const dockerNetworkMode = yield* _(parseDockerNetworkMode(raw.dockerNetworkMode)) const dockerSharedNetworkName = yield* _( nonEmpty("--shared-network", raw.dockerSharedNetworkName, defaultTemplateConfig.dockerSharedNetworkName) @@ -295,8 +305,10 @@ export const buildCreateCommand = ( repo, names, paths, - cpuLimit, - ramLimit, + cpuLimit: limits.cpuLimit, + ramLimit: limits.ramLimit, + playwrightCpuLimit: limits.playwrightCpuLimit, + playwrightRamLimit: limits.playwrightRamLimit, dockerNetworkMode, dockerSharedNetworkName, gitTokenLabel, diff --git a/packages/app/src/docker-git/frontend-lib/core/command-options.ts b/packages/app/src/docker-git/frontend-lib/core/command-options.ts index 908aee39..200a1051 100644 --- a/packages/app/src/docker-git/frontend-lib/core/command-options.ts +++ b/packages/app/src/docker-git/frontend-lib/core/command-options.ts @@ -28,6 +28,8 @@ export interface RawOptions { readonly codexHome?: string readonly cpuLimit?: string readonly ramLimit?: string + readonly playwrightCpuLimit?: string + readonly playwrightRamLimit?: string readonly dockerNetworkMode?: string readonly dockerSharedNetworkName?: string readonly enableMcpPlaywright?: boolean 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 d241d12f..d799bccb 100644 --- a/packages/app/src/docker-git/frontend-lib/core/domain.ts +++ b/packages/app/src/docker-git/frontend-lib/core/domain.ts @@ -42,6 +42,8 @@ export { defaultCpuLimit, defaultDockerNetworkMode, defaultDockerSharedNetworkName, + defaultPlaywrightCpuLimit, + defaultPlaywrightRamLimit, defaultRamLimit, defaultTemplateConfig, dockerGitSharedCacheVolumeName, @@ -78,6 +80,8 @@ export interface TemplateConfig { readonly geminiHome: string readonly cpuLimit?: string | undefined readonly ramLimit?: string | undefined + readonly playwrightCpuLimit?: string | undefined + readonly playwrightRamLimit?: string | undefined readonly dockerNetworkMode: DockerNetworkMode readonly dockerSharedNetworkName: string readonly enableMcpPlaywright: boolean @@ -170,6 +174,8 @@ export interface ApplyCommand { readonly geminiTokenLabel?: string | undefined readonly cpuLimit?: string | undefined readonly ramLimit?: string | undefined + readonly playwrightCpuLimit?: string | undefined + readonly playwrightRamLimit?: string | undefined readonly enableMcpPlaywright?: boolean | undefined } diff --git a/packages/app/src/docker-git/frontend-lib/core/resource-limits.ts b/packages/app/src/docker-git/frontend-lib/core/resource-limits.ts index 1402a4b8..4a077fbc 100644 --- a/packages/app/src/docker-git/frontend-lib/core/resource-limits.ts +++ b/packages/app/src/docker-git/frontend-lib/core/resource-limits.ts @@ -1,7 +1,15 @@ /* jscpd:ignore-start */ import { Either } from "effect" -import { defaultCpuLimit, defaultRamLimit, type ParseError, type TemplateConfig } from "./domain.js" +import { type RawOptions } from "./command-options.js" +import { + defaultCpuLimit, + defaultPlaywrightCpuLimit, + defaultPlaywrightRamLimit, + defaultRamLimit, + type ParseError, + type TemplateConfig +} from "./domain.js" const mebibyte = 1024 ** 2 const minimumResolvedCpuLimit = 0.25 @@ -109,7 +117,9 @@ export const withDefaultResourceLimitIntent = ( ): TemplateConfig => ({ ...template, cpuLimit: template.cpuLimit ?? defaultCpuLimit, - ramLimit: template.ramLimit ?? defaultRamLimit + ramLimit: template.ramLimit ?? defaultRamLimit, + playwrightCpuLimit: template.playwrightCpuLimit ?? defaultPlaywrightCpuLimit, + playwrightRamLimit: template.playwrightRamLimit ?? defaultPlaywrightRamLimit }) const resolvePercentCpuLimit = (percent: number, cpuCount: number): number => @@ -142,4 +152,38 @@ export const resolveComposeResourceLimits = ( : resolvePercentRamLimit(ramPercent, hostResources.totalMemoryBytes) } } + +export const resolvePlaywrightComposeResourceLimits = ( + template: Pick, + hostResources: HostResources +): ResolvedComposeResourceLimits => + resolveComposeResourceLimits( + { + cpuLimit: template.playwrightCpuLimit ?? template.cpuLimit ?? defaultPlaywrightCpuLimit, + ramLimit: template.playwrightRamLimit ?? template.ramLimit ?? defaultPlaywrightRamLimit + }, + hostResources + ) + +export type ResolvedResourceLimitsIntent = { + readonly cpuLimit: string | undefined + readonly ramLimit: string | undefined + readonly playwrightCpuLimit: string | undefined + readonly playwrightRamLimit: string | undefined +} + +export const resolveResourceLimitsIntent = ( + raw: RawOptions +): Either.Either => + Either.gen(function*(_) { + const cpuLimit = yield* _(normalizeCpuLimit(raw.cpuLimit ?? defaultCpuLimit, "--cpu")) + const ramLimit = yield* _(normalizeRamLimit(raw.ramLimit ?? defaultRamLimit, "--ram")) + const playwrightCpuLimit = yield* _( + normalizeCpuLimit(raw.playwrightCpuLimit ?? cpuLimit, "--playwright-cpu") + ) + const playwrightRamLimit = yield* _( + normalizeRamLimit(raw.playwrightRamLimit ?? ramLimit, "--playwright-ram") + ) + return { cpuLimit, ramLimit, playwrightCpuLimit, playwrightRamLimit } + }) /* jscpd:ignore-end */ diff --git a/packages/app/src/docker-git/frontend-lib/core/template-defaults.ts b/packages/app/src/docker-git/frontend-lib/core/template-defaults.ts index 8743d8ff..cf38e9f7 100644 --- a/packages/app/src/docker-git/frontend-lib/core/template-defaults.ts +++ b/packages/app/src/docker-git/frontend-lib/core/template-defaults.ts @@ -22,6 +22,8 @@ type DefaultTemplateConfig = Pick< | "geminiHome" | "cpuLimit" | "ramLimit" + | "playwrightCpuLimit" + | "playwrightRamLimit" | "dockerNetworkMode" | "dockerSharedNetworkName" | "enableMcpPlaywright" @@ -38,6 +40,10 @@ export const defaultCpuLimit = "30%" export const defaultRamLimit = "30%" +export const defaultPlaywrightCpuLimit = "30%" + +export const defaultPlaywrightRamLimit = "30%" + export const defaultTemplateConfig = { containerName: "dev-ssh", serviceName: "dev", @@ -58,6 +64,8 @@ export const defaultTemplateConfig = { geminiHome: "/home/dev/.gemini", cpuLimit: defaultCpuLimit, ramLimit: defaultRamLimit, + playwrightCpuLimit: defaultPlaywrightCpuLimit, + playwrightRamLimit: defaultPlaywrightRamLimit, dockerNetworkMode: defaultDockerNetworkMode, dockerSharedNetworkName: defaultDockerSharedNetworkName, enableMcpPlaywright: false, diff --git a/packages/app/src/lib/core/command-builders.ts b/packages/app/src/lib/core/command-builders.ts index d3c74b06..8cd168e3 100644 --- a/packages/app/src/lib/core/command-builders.ts +++ b/packages/app/src/lib/core/command-builders.ts @@ -8,15 +8,13 @@ import { type RawOptions } from "./command-options.js" import { type AgentMode, type CreateCommand, - defaultCpuLimit, - defaultRamLimit, defaultTemplateConfig, deriveRepoPathParts, deriveRepoSlug, type ParseError, resolveRepoInput } from "./domain.js" -import { normalizeCpuLimit, normalizeRamLimit } from "./resource-limits.js" +import { resolveResourceLimitsIntent } from "./resource-limits.js" import { trimRightChar } from "./strings.js" import { normalizeAuthLabel, normalizeGitTokenLabel } from "./token-labels.js" @@ -193,6 +191,8 @@ type BuildTemplateConfigInput = { readonly paths: PathConfig readonly cpuLimit: string | undefined readonly ramLimit: string | undefined + readonly playwrightCpuLimit: string | undefined + readonly playwrightRamLimit: string | undefined readonly dockerNetworkMode: CreateCommand["config"]["dockerNetworkMode"] readonly dockerSharedNetworkName: string readonly gitTokenLabel: string | undefined @@ -205,53 +205,64 @@ type BuildTemplateConfigInput = { readonly clonedOnHostname?: string | undefined } -const buildTemplateConfig = ({ - agentAuto, - agentMode, - claudeAuthLabel, - clonedOnHostname, - codexAuthLabel, - cpuLimit, - dockerNetworkMode, - dockerSharedNetworkName, - enableMcpPlaywright, - gitTokenLabel, - names, - paths, - ramLimit, - repo, - skipGithubAuth -}: BuildTemplateConfigInput): CreateCommand["config"] => ({ - containerName: names.containerName, - serviceName: names.serviceName, - sshUser: repo.sshUser, - sshPort: repo.sshPort, - repoUrl: repo.repoUrl, - repoRef: repo.repoRef, - gitTokenLabel, - skipGithubAuth, - codexAuthLabel, - claudeAuthLabel, - targetDir: repo.targetDir, - volumeName: names.volumeName, - dockerGitPath: paths.dockerGitPath, - authorizedKeysPath: paths.authorizedKeysPath, - envGlobalPath: paths.envGlobalPath, - envProjectPath: paths.envProjectPath, - codexAuthPath: paths.codexAuthPath, - codexSharedAuthPath: paths.codexSharedAuthPath, - codexHome: paths.codexHome, - geminiAuthPath: paths.geminiAuthPath, - geminiHome: paths.geminiHome, - cpuLimit, - ramLimit, - dockerNetworkMode, - dockerSharedNetworkName, - enableMcpPlaywright, +const buildTemplateConfigBase = ( + input: Pick +): Pick< + CreateCommand["config"], + | "containerName" + | "serviceName" + | "sshUser" + | "sshPort" + | "repoUrl" + | "repoRef" + | "targetDir" + | "volumeName" + | "dockerGitPath" + | "authorizedKeysPath" + | "envGlobalPath" + | "envProjectPath" + | "codexAuthPath" + | "codexSharedAuthPath" + | "codexHome" + | "geminiAuthPath" + | "geminiHome" +> => ({ + containerName: input.names.containerName, + serviceName: input.names.serviceName, + sshUser: input.repo.sshUser, + sshPort: input.repo.sshPort, + repoUrl: input.repo.repoUrl, + repoRef: input.repo.repoRef, + targetDir: input.repo.targetDir, + volumeName: input.names.volumeName, + dockerGitPath: input.paths.dockerGitPath, + authorizedKeysPath: input.paths.authorizedKeysPath, + envGlobalPath: input.paths.envGlobalPath, + envProjectPath: input.paths.envProjectPath, + codexAuthPath: input.paths.codexAuthPath, + codexSharedAuthPath: input.paths.codexSharedAuthPath, + codexHome: input.paths.codexHome, + geminiAuthPath: input.paths.geminiAuthPath, + geminiHome: input.paths.geminiHome +}) + +const buildTemplateConfig = (input: BuildTemplateConfigInput): CreateCommand["config"] => ({ + ...buildTemplateConfigBase(input), + gitTokenLabel: input.gitTokenLabel, + skipGithubAuth: input.skipGithubAuth, + codexAuthLabel: input.codexAuthLabel, + claudeAuthLabel: input.claudeAuthLabel, + cpuLimit: input.cpuLimit, + ramLimit: input.ramLimit, + playwrightCpuLimit: input.playwrightCpuLimit, + playwrightRamLimit: input.playwrightRamLimit, + dockerNetworkMode: input.dockerNetworkMode, + dockerSharedNetworkName: input.dockerSharedNetworkName, + enableMcpPlaywright: input.enableMcpPlaywright, bunVersion: defaultTemplateConfig.bunVersion, - agentMode, - agentAuto, - clonedOnHostname + agentMode: input.agentMode, + agentAuto: input.agentAuto, + clonedOnHostname: input.clonedOnHostname }) // CHANGE: build a typed create command from raw options (CLI or API) @@ -275,8 +286,7 @@ export const buildCreateCommand = ( const gitTokenLabel = normalizeGitTokenLabel(raw.gitTokenLabel) const codexAuthLabel = normalizeAuthLabel(raw.codexTokenLabel) const claudeAuthLabel = normalizeAuthLabel(raw.claudeTokenLabel) - const cpuLimit = yield* _(normalizeCpuLimit(raw.cpuLimit ?? defaultCpuLimit, "--cpu")) - const ramLimit = yield* _(normalizeRamLimit(raw.ramLimit ?? defaultRamLimit, "--ram")) + const limits = yield* _(resolveResourceLimitsIntent(raw)) const dockerNetworkMode = yield* _(parseDockerNetworkMode(raw.dockerNetworkMode)) const dockerSharedNetworkName = yield* _( nonEmpty("--shared-network", raw.dockerSharedNetworkName, defaultTemplateConfig.dockerSharedNetworkName) @@ -295,8 +305,10 @@ export const buildCreateCommand = ( repo, names, paths, - cpuLimit, - ramLimit, + cpuLimit: limits.cpuLimit, + ramLimit: limits.ramLimit, + playwrightCpuLimit: limits.playwrightCpuLimit, + playwrightRamLimit: limits.playwrightRamLimit, dockerNetworkMode, dockerSharedNetworkName, gitTokenLabel, diff --git a/packages/app/src/lib/core/command-options.ts b/packages/app/src/lib/core/command-options.ts index 908aee39..200a1051 100644 --- a/packages/app/src/lib/core/command-options.ts +++ b/packages/app/src/lib/core/command-options.ts @@ -28,6 +28,8 @@ export interface RawOptions { readonly codexHome?: string readonly cpuLimit?: string readonly ramLimit?: string + readonly playwrightCpuLimit?: string + readonly playwrightRamLimit?: string readonly dockerNetworkMode?: string readonly dockerSharedNetworkName?: string readonly enableMcpPlaywright?: boolean diff --git a/packages/app/src/lib/core/domain.ts b/packages/app/src/lib/core/domain.ts index 420a2193..d9992535 100644 --- a/packages/app/src/lib/core/domain.ts +++ b/packages/app/src/lib/core/domain.ts @@ -42,6 +42,8 @@ export { defaultCpuLimit, defaultDockerNetworkMode, defaultDockerSharedNetworkName, + defaultPlaywrightCpuLimit, + defaultPlaywrightRamLimit, defaultRamLimit, defaultTemplateConfig, dockerGitSharedCacheVolumeName, @@ -78,6 +80,8 @@ export interface TemplateConfig { readonly geminiHome: string readonly cpuLimit?: string | undefined readonly ramLimit?: string | undefined + readonly playwrightCpuLimit?: string | undefined + readonly playwrightRamLimit?: string | undefined readonly dockerNetworkMode: DockerNetworkMode readonly dockerSharedNetworkName: string readonly enableMcpPlaywright: boolean @@ -166,6 +170,8 @@ export interface ApplyCommand { readonly geminiTokenLabel?: string | undefined readonly cpuLimit?: string | undefined readonly ramLimit?: string | undefined + readonly playwrightCpuLimit?: string | undefined + readonly playwrightRamLimit?: string | undefined readonly enableMcpPlaywright?: boolean | undefined } diff --git a/packages/app/src/lib/core/resource-limits.ts b/packages/app/src/lib/core/resource-limits.ts index 1402a4b8..4a077fbc 100644 --- a/packages/app/src/lib/core/resource-limits.ts +++ b/packages/app/src/lib/core/resource-limits.ts @@ -1,7 +1,15 @@ /* jscpd:ignore-start */ import { Either } from "effect" -import { defaultCpuLimit, defaultRamLimit, type ParseError, type TemplateConfig } from "./domain.js" +import { type RawOptions } from "./command-options.js" +import { + defaultCpuLimit, + defaultPlaywrightCpuLimit, + defaultPlaywrightRamLimit, + defaultRamLimit, + type ParseError, + type TemplateConfig +} from "./domain.js" const mebibyte = 1024 ** 2 const minimumResolvedCpuLimit = 0.25 @@ -109,7 +117,9 @@ export const withDefaultResourceLimitIntent = ( ): TemplateConfig => ({ ...template, cpuLimit: template.cpuLimit ?? defaultCpuLimit, - ramLimit: template.ramLimit ?? defaultRamLimit + ramLimit: template.ramLimit ?? defaultRamLimit, + playwrightCpuLimit: template.playwrightCpuLimit ?? defaultPlaywrightCpuLimit, + playwrightRamLimit: template.playwrightRamLimit ?? defaultPlaywrightRamLimit }) const resolvePercentCpuLimit = (percent: number, cpuCount: number): number => @@ -142,4 +152,38 @@ export const resolveComposeResourceLimits = ( : resolvePercentRamLimit(ramPercent, hostResources.totalMemoryBytes) } } + +export const resolvePlaywrightComposeResourceLimits = ( + template: Pick, + hostResources: HostResources +): ResolvedComposeResourceLimits => + resolveComposeResourceLimits( + { + cpuLimit: template.playwrightCpuLimit ?? template.cpuLimit ?? defaultPlaywrightCpuLimit, + ramLimit: template.playwrightRamLimit ?? template.ramLimit ?? defaultPlaywrightRamLimit + }, + hostResources + ) + +export type ResolvedResourceLimitsIntent = { + readonly cpuLimit: string | undefined + readonly ramLimit: string | undefined + readonly playwrightCpuLimit: string | undefined + readonly playwrightRamLimit: string | undefined +} + +export const resolveResourceLimitsIntent = ( + raw: RawOptions +): Either.Either => + Either.gen(function*(_) { + const cpuLimit = yield* _(normalizeCpuLimit(raw.cpuLimit ?? defaultCpuLimit, "--cpu")) + const ramLimit = yield* _(normalizeRamLimit(raw.ramLimit ?? defaultRamLimit, "--ram")) + const playwrightCpuLimit = yield* _( + normalizeCpuLimit(raw.playwrightCpuLimit ?? cpuLimit, "--playwright-cpu") + ) + const playwrightRamLimit = yield* _( + normalizeRamLimit(raw.playwrightRamLimit ?? ramLimit, "--playwright-ram") + ) + return { cpuLimit, ramLimit, playwrightCpuLimit, playwrightRamLimit } + }) /* jscpd:ignore-end */ diff --git a/packages/app/src/lib/core/template-defaults.ts b/packages/app/src/lib/core/template-defaults.ts index 8743d8ff..cf38e9f7 100644 --- a/packages/app/src/lib/core/template-defaults.ts +++ b/packages/app/src/lib/core/template-defaults.ts @@ -22,6 +22,8 @@ type DefaultTemplateConfig = Pick< | "geminiHome" | "cpuLimit" | "ramLimit" + | "playwrightCpuLimit" + | "playwrightRamLimit" | "dockerNetworkMode" | "dockerSharedNetworkName" | "enableMcpPlaywright" @@ -38,6 +40,10 @@ export const defaultCpuLimit = "30%" export const defaultRamLimit = "30%" +export const defaultPlaywrightCpuLimit = "30%" + +export const defaultPlaywrightRamLimit = "30%" + export const defaultTemplateConfig = { containerName: "dev-ssh", serviceName: "dev", @@ -58,6 +64,8 @@ export const defaultTemplateConfig = { geminiHome: "/home/dev/.gemini", cpuLimit: defaultCpuLimit, ramLimit: defaultRamLimit, + playwrightCpuLimit: defaultPlaywrightCpuLimit, + playwrightRamLimit: defaultPlaywrightRamLimit, dockerNetworkMode: defaultDockerNetworkMode, dockerSharedNetworkName: defaultDockerSharedNetworkName, enableMcpPlaywright: false, diff --git a/packages/app/src/lib/core/templates.ts b/packages/app/src/lib/core/templates.ts index d701b735..4869d290 100644 --- a/packages/app/src/lib/core/templates.ts +++ b/packages/app/src/lib/core/templates.ts @@ -2,7 +2,7 @@ import type { TemplateConfig } from "./domain.js" import type { ResolvedComposeResourceLimits } from "./resource-limits.js" import { renderEntrypoint } from "./templates-entrypoint.js" -import { renderDockerCompose } from "./templates/docker-compose.js" +import { type ComposeResourceLimits, renderDockerCompose } from "./templates/docker-compose.js" import { renderDockerfile } from "./templates/dockerfile.js" import { renderPlaywrightBrowserDockerfile, renderPlaywrightStartExtra } from "./templates/playwright.js" @@ -46,7 +46,7 @@ const renderConfigJson = (config: TemplateConfig): string => export const planFiles = ( config: TemplateConfig, - composeResourceLimits?: ResolvedComposeResourceLimits + composeResourceLimits?: ResolvedComposeResourceLimits | ComposeResourceLimits ): ReadonlyArray => { const maybePlaywrightFiles = config.enableMcpPlaywright ? ([ diff --git a/packages/app/src/lib/core/templates/docker-compose.ts b/packages/app/src/lib/core/templates/docker-compose.ts index 737fa909..2cba4b74 100644 --- a/packages/app/src/lib/core/templates/docker-compose.ts +++ b/packages/app/src/lib/core/templates/docker-compose.ts @@ -31,6 +31,11 @@ type PlaywrightFragments = Pick< "maybeDependsOn" | "maybePlaywrightEnv" | "maybeBrowserService" | "maybeBrowserVolume" > +export type ComposeResourceLimits = { + readonly main: ResolvedComposeResourceLimits | undefined + readonly playwright: ResolvedComposeResourceLimits | undefined +} + const sharedCodexVolumeKey = "docker_git_shared_codex" const sharedCacheVolumeKey = "docker_git_shared_cache" const bootstrapVolumeKey = "docker_git_bootstrap" @@ -109,9 +114,25 @@ const buildPlaywrightFragments = ( } } +const isResolvedComposeResourceLimits = ( + value: ResolvedComposeResourceLimits | ComposeResourceLimits +): value is ResolvedComposeResourceLimits => "cpuLimit" in value && "ramLimit" in value + +const normalizeComposeResourceLimits = ( + resourceLimits: ResolvedComposeResourceLimits | ComposeResourceLimits | undefined +): ComposeResourceLimits => { + if (resourceLimits === undefined) { + return { main: undefined, playwright: undefined } + } + if (isResolvedComposeResourceLimits(resourceLimits)) { + return { main: resourceLimits, playwright: resourceLimits } + } + return resourceLimits +} + const buildComposeFragments = ( config: TemplateConfig, - resourceLimits: ResolvedComposeResourceLimits | undefined + resourceLimits: ComposeResourceLimits ): ComposeFragments => { const networkMode = config.dockerNetworkMode const networkName = resolveComposeNetworkName(config) @@ -125,7 +146,7 @@ const buildComposeFragments = ( const maybeClaudeAuthLabelEnv = renderClaudeAuthLabelEnv(claudeAuthLabel) const maybeAgentModeEnv = renderAgentModeEnv(config.agentMode) const maybeAgentAutoEnv = renderAgentAutoEnv(config.agentAuto) - const playwright = buildPlaywrightFragments(config, networkName, resourceLimits) + const playwright = buildPlaywrightFragments(config, networkName, resourceLimits.playwright) return { networkMode, @@ -148,7 +169,7 @@ const buildComposeFragments = ( const renderComposeServices = ( config: TemplateConfig, fragments: ComposeFragments, - resourceLimits: ResolvedComposeResourceLimits | undefined + resourceLimits: ComposeResourceLimits ): string => `services: ${config.serviceName}: @@ -171,7 +192,7 @@ ${fragments.maybeClaudeAuthLabelEnv}${fragments.maybeAgentModeEnv}${fragments.ma ${fragments.maybePlaywrightEnv}${fragments.maybeDependsOn} # bootstrap auth/env arrives through docker_git_bootstrap ports: - "\${DOCKER_GIT_PROJECT_SSH_BIND_HOST:-127.0.0.1}:${config.sshPort}:22" -${renderResourceLimits(resourceLimits)} volumes: +${renderResourceLimits(resourceLimits.main)} volumes: - ${config.volumeName}:/home/${config.sshUser} - ${sharedCacheVolumeKey}:/home/${config.sshUser}/.docker-git/.cache - ${sharedCodexVolumeKey}:${config.codexHome}-shared @@ -215,12 +236,13 @@ const renderComposeVolumes = (config: TemplateConfig, maybeBrowserVolume: string export const renderDockerCompose = ( config: TemplateConfig, - resourceLimits?: ResolvedComposeResourceLimits + resourceLimits?: ResolvedComposeResourceLimits | ComposeResourceLimits ): string => { - const fragments = buildComposeFragments(config, resourceLimits) + const limits = normalizeComposeResourceLimits(resourceLimits) + const fragments = buildComposeFragments(config, limits) return [ `name: ${resolveComposeProjectName(config)}`, - renderComposeServices(config, fragments, resourceLimits), + renderComposeServices(config, fragments, limits), renderComposeNetworks(fragments.networkMode, fragments.networkName), renderComposeVolumes(config, fragments.maybeBrowserVolume) ].join("\n\n") diff --git a/packages/app/src/lib/shell/files.ts b/packages/app/src/lib/shell/files.ts index 25442ed1..ee98731e 100644 --- a/packages/app/src/lib/shell/files.ts +++ b/packages/app/src/lib/shell/files.ts @@ -6,7 +6,11 @@ import { Effect, Match } from "effect" import { dockerGitScriptNames } from "../core/docker-git-scripts.js" import { type TemplateConfig } from "../core/domain.js" -import { resolveComposeResourceLimits, withDefaultResourceLimitIntent } from "../core/resource-limits.js" +import { + resolveComposeResourceLimits, + resolvePlaywrightComposeResourceLimits, + withDefaultResourceLimitIntent +} from "../core/resource-limits.js" import { type FileSpec, planFiles } from "../core/templates.js" import { FileExistsError } from "./errors.js" import { resolveBaseDir } from "./paths.js" @@ -235,7 +239,10 @@ export const writeProjectFiles = ( const normalizedConfig = withDefaultResourceLimitIntent(config) const hostResources = yield* _(loadHostResources()) - const composeResourceLimits = resolveComposeResourceLimits(normalizedConfig, hostResources) + const composeResourceLimits = { + main: resolveComposeResourceLimits(normalizedConfig, hostResources), + playwright: resolvePlaywrightComposeResourceLimits(normalizedConfig, hostResources) + } const specs = planFiles(normalizedConfig, composeResourceLimits) const created: Array = [] const existingFilePaths = force ? [] : yield* _(collectExistingFilePaths(fs, path, baseDir, specs)) diff --git a/packages/app/src/lib/usecases/apply-overrides.ts b/packages/app/src/lib/usecases/apply-overrides.ts index a4e7a653..6bbd3ec8 100644 --- a/packages/app/src/lib/usecases/apply-overrides.ts +++ b/packages/app/src/lib/usecases/apply-overrides.ts @@ -8,55 +8,51 @@ export const hasApplyOverrides = (command: ApplyCommand): boolean => command.claudeTokenLabel !== undefined || command.cpuLimit !== undefined || command.ramLimit !== undefined || + command.playwrightCpuLimit !== undefined || + command.playwrightRamLimit !== undefined || command.enableMcpPlaywright !== undefined -export const applyTemplateOverrides = ( - template: TemplateConfig, - command: ApplyCommand | undefined -): TemplateConfig => { - if (command === undefined) { - return template - } - - let nextTemplate = template - +const applyTokenOverrides = (template: TemplateConfig, command: ApplyCommand): TemplateConfig => { + let next = template if (command.gitTokenLabel !== undefined) { - nextTemplate = { - ...nextTemplate, - gitTokenLabel: normalizeGitTokenLabel(command.gitTokenLabel) - } + next = { ...next, gitTokenLabel: normalizeGitTokenLabel(command.gitTokenLabel) } } if (command.codexTokenLabel !== undefined) { - nextTemplate = { - ...nextTemplate, - codexAuthLabel: normalizeAuthLabel(command.codexTokenLabel) - } + next = { ...next, codexAuthLabel: normalizeAuthLabel(command.codexTokenLabel) } } if (command.claudeTokenLabel !== undefined) { - nextTemplate = { - ...nextTemplate, - claudeAuthLabel: normalizeAuthLabel(command.claudeTokenLabel) - } + next = { ...next, claudeAuthLabel: normalizeAuthLabel(command.claudeTokenLabel) } } + return next +} + +const applyResourceOverrides = (template: TemplateConfig, command: ApplyCommand): TemplateConfig => { + let next = template if (command.cpuLimit !== undefined) { - nextTemplate = { - ...nextTemplate, - cpuLimit: command.cpuLimit - } + next = { ...next, cpuLimit: command.cpuLimit } } if (command.ramLimit !== undefined) { - nextTemplate = { - ...nextTemplate, - ramLimit: command.ramLimit - } + next = { ...next, ramLimit: command.ramLimit } + } + if (command.playwrightCpuLimit !== undefined) { + next = { ...next, playwrightCpuLimit: command.playwrightCpuLimit } + } + if (command.playwrightRamLimit !== undefined) { + next = { ...next, playwrightRamLimit: command.playwrightRamLimit } } if (command.enableMcpPlaywright !== undefined) { - nextTemplate = { - ...nextTemplate, - enableMcpPlaywright: command.enableMcpPlaywright - } + next = { ...next, enableMcpPlaywright: command.enableMcpPlaywright } } + return next +} - return nextTemplate +export const applyTemplateOverrides = ( + template: TemplateConfig, + command: ApplyCommand | undefined +): TemplateConfig => { + if (command === undefined) { + return template + } + return applyResourceOverrides(applyTokenOverrides(template, command), command) } /* jscpd:ignore-end */ diff --git a/packages/app/tests/docker-git/parser-playwright-resource.test.ts b/packages/app/tests/docker-git/parser-playwright-resource.test.ts new file mode 100644 index 00000000..f29d73d8 --- /dev/null +++ b/packages/app/tests/docker-git/parser-playwright-resource.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "@effect/vitest" + +import { expectCreateCommand, expectParseErrorTag } from "./parser-helpers.js" + +describe("parseArgs playwright resource limits", () => { + it.effect("parses dedicated playwright resource limit flags", () => + expectCreateCommand( + [ + "create", + "--repo-url", + "https://github.com/org/repo.git", + "--playwright-cpu", + "0.5", + "--playwright-ram", + "1g" + ], + (command) => { + expect(command.config.playwrightCpuLimit).toBe("0.5") + expect(command.config.playwrightRamLimit).toBe("1g") + } + )) + + it.effect("falls back playwright limits to main limits when not specified", () => + expectCreateCommand( + ["create", "--repo-url", "https://github.com/org/repo.git", "--cpu", "1.5", "--ram", "2g"], + (command) => { + expect(command.config.cpuLimit).toBe("1.5") + expect(command.config.ramLimit).toBe("2g") + expect(command.config.playwrightCpuLimit).toBe("1.5") + expect(command.config.playwrightRamLimit).toBe("2g") + } + )) + + it.effect("accepts compose-style aliases for playwright limits", () => + expectCreateCommand( + [ + "create", + "--repo-url", + "https://github.com/org/repo.git", + "--playwright-cpus", + "0.75", + "--playwright-memory", + "1500m" + ], + (command) => { + expect(command.config.playwrightCpuLimit).toBe("0.75") + expect(command.config.playwrightRamLimit).toBe("1500m") + } + )) + + it.effect("rejects invalid playwright RAM limit", () => + expectParseErrorTag( + ["create", "--repo-url", "https://github.com/org/repo.git", "--playwright-ram", "not-a-size"], + "InvalidOption" + )) +}) diff --git a/packages/lib/src/core/command-builders.ts b/packages/lib/src/core/command-builders.ts index 23eb2057..dad7f8dd 100644 --- a/packages/lib/src/core/command-builders.ts +++ b/packages/lib/src/core/command-builders.ts @@ -8,15 +8,13 @@ import { type RawOptions } from "./command-options.js" import { type AgentMode, type CreateCommand, - defaultCpuLimit, - defaultRamLimit, defaultTemplateConfig, deriveRepoPathParts, deriveRepoSlug, type ParseError, resolveRepoInput } from "./domain.js" -import { normalizeCpuLimit, normalizeRamLimit } from "./resource-limits.js" +import { resolveResourceLimitsIntent } from "./resource-limits.js" import { trimRightChar } from "./strings.js" import { normalizeAuthLabel, normalizeGitTokenLabel } from "./token-labels.js" @@ -193,6 +191,8 @@ type BuildTemplateConfigInput = { readonly paths: PathConfig readonly cpuLimit: string | undefined readonly ramLimit: string | undefined + readonly playwrightCpuLimit: string | undefined + readonly playwrightRamLimit: string | undefined readonly dockerNetworkMode: CreateCommand["config"]["dockerNetworkMode"] readonly dockerSharedNetworkName: string readonly gitTokenLabel: string | undefined @@ -205,53 +205,64 @@ type BuildTemplateConfigInput = { readonly clonedOnHostname: string } -const buildTemplateConfig = ({ - agentAuto, - agentMode, - claudeAuthLabel, - clonedOnHostname, - codexAuthLabel, - cpuLimit, - dockerNetworkMode, - dockerSharedNetworkName, - enableMcpPlaywright, - gitTokenLabel, - names, - paths, - ramLimit, - repo, - skipGithubAuth -}: BuildTemplateConfigInput): CreateCommand["config"] => ({ - containerName: names.containerName, - serviceName: names.serviceName, - sshUser: repo.sshUser, - sshPort: repo.sshPort, - repoUrl: repo.repoUrl, - repoRef: repo.repoRef, - gitTokenLabel, - skipGithubAuth, - codexAuthLabel, - claudeAuthLabel, - targetDir: repo.targetDir, - volumeName: names.volumeName, - dockerGitPath: paths.dockerGitPath, - authorizedKeysPath: paths.authorizedKeysPath, - envGlobalPath: paths.envGlobalPath, - envProjectPath: paths.envProjectPath, - codexAuthPath: paths.codexAuthPath, - codexSharedAuthPath: paths.codexSharedAuthPath, - codexHome: paths.codexHome, - geminiAuthPath: paths.geminiAuthPath, - geminiHome: paths.geminiHome, - cpuLimit, - ramLimit, - dockerNetworkMode, - dockerSharedNetworkName, - enableMcpPlaywright, +const buildTemplateConfigBase = ( + input: Pick +): Pick< + CreateCommand["config"], + | "containerName" + | "serviceName" + | "sshUser" + | "sshPort" + | "repoUrl" + | "repoRef" + | "targetDir" + | "volumeName" + | "dockerGitPath" + | "authorizedKeysPath" + | "envGlobalPath" + | "envProjectPath" + | "codexAuthPath" + | "codexSharedAuthPath" + | "codexHome" + | "geminiAuthPath" + | "geminiHome" +> => ({ + containerName: input.names.containerName, + serviceName: input.names.serviceName, + sshUser: input.repo.sshUser, + sshPort: input.repo.sshPort, + repoUrl: input.repo.repoUrl, + repoRef: input.repo.repoRef, + targetDir: input.repo.targetDir, + volumeName: input.names.volumeName, + dockerGitPath: input.paths.dockerGitPath, + authorizedKeysPath: input.paths.authorizedKeysPath, + envGlobalPath: input.paths.envGlobalPath, + envProjectPath: input.paths.envProjectPath, + codexAuthPath: input.paths.codexAuthPath, + codexSharedAuthPath: input.paths.codexSharedAuthPath, + codexHome: input.paths.codexHome, + geminiAuthPath: input.paths.geminiAuthPath, + geminiHome: input.paths.geminiHome +}) + +const buildTemplateConfig = (input: BuildTemplateConfigInput): CreateCommand["config"] => ({ + ...buildTemplateConfigBase(input), + gitTokenLabel: input.gitTokenLabel, + skipGithubAuth: input.skipGithubAuth, + codexAuthLabel: input.codexAuthLabel, + claudeAuthLabel: input.claudeAuthLabel, + cpuLimit: input.cpuLimit, + ramLimit: input.ramLimit, + playwrightCpuLimit: input.playwrightCpuLimit, + playwrightRamLimit: input.playwrightRamLimit, + dockerNetworkMode: input.dockerNetworkMode, + dockerSharedNetworkName: input.dockerSharedNetworkName, + enableMcpPlaywright: input.enableMcpPlaywright, bunVersion: defaultTemplateConfig.bunVersion, - agentMode, - agentAuto, - clonedOnHostname + agentMode: input.agentMode, + agentAuto: input.agentAuto, + clonedOnHostname: input.clonedOnHostname }) // CHANGE: build a typed create command from raw options (CLI or API) @@ -275,8 +286,7 @@ export const buildCreateCommand = ( const gitTokenLabel = normalizeGitTokenLabel(raw.gitTokenLabel) const codexAuthLabel = normalizeAuthLabel(raw.codexTokenLabel) const claudeAuthLabel = normalizeAuthLabel(raw.claudeTokenLabel) - const cpuLimit = yield* _(normalizeCpuLimit(raw.cpuLimit ?? defaultCpuLimit, "--cpu")) - const ramLimit = yield* _(normalizeRamLimit(raw.ramLimit ?? defaultRamLimit, "--ram")) + const limits = yield* _(resolveResourceLimitsIntent(raw)) const dockerNetworkMode = yield* _(parseDockerNetworkMode(raw.dockerNetworkMode)) const dockerSharedNetworkName = yield* _( nonEmpty("--shared-network", raw.dockerSharedNetworkName, defaultTemplateConfig.dockerSharedNetworkName) @@ -295,8 +305,10 @@ export const buildCreateCommand = ( repo, names, paths, - cpuLimit, - ramLimit, + cpuLimit: limits.cpuLimit, + ramLimit: limits.ramLimit, + playwrightCpuLimit: limits.playwrightCpuLimit, + playwrightRamLimit: limits.playwrightRamLimit, dockerNetworkMode, dockerSharedNetworkName, gitTokenLabel, diff --git a/packages/lib/src/core/command-options.ts b/packages/lib/src/core/command-options.ts index cf352da4..94b4678e 100644 --- a/packages/lib/src/core/command-options.ts +++ b/packages/lib/src/core/command-options.ts @@ -27,6 +27,8 @@ export interface RawOptions { readonly codexHome?: string readonly cpuLimit?: string readonly ramLimit?: string + readonly playwrightCpuLimit?: string + readonly playwrightRamLimit?: string readonly dockerNetworkMode?: string readonly dockerSharedNetworkName?: string readonly enableMcpPlaywright?: boolean diff --git a/packages/lib/src/core/domain.ts b/packages/lib/src/core/domain.ts index 8d3d3fb7..db1c1d43 100644 --- a/packages/lib/src/core/domain.ts +++ b/packages/lib/src/core/domain.ts @@ -41,6 +41,8 @@ export { defaultCpuLimit, defaultDockerNetworkMode, defaultDockerSharedNetworkName, + defaultPlaywrightCpuLimit, + defaultPlaywrightRamLimit, defaultRamLimit, defaultTemplateConfig, dockerGitSharedCacheVolumeName, @@ -77,6 +79,8 @@ export interface TemplateConfig { readonly geminiHome: string readonly cpuLimit?: string | undefined readonly ramLimit?: string | undefined + readonly playwrightCpuLimit?: string | undefined + readonly playwrightRamLimit?: string | undefined readonly dockerNetworkMode: DockerNetworkMode readonly dockerSharedNetworkName: string readonly enableMcpPlaywright: boolean @@ -165,6 +169,8 @@ export interface ApplyCommand { readonly geminiTokenLabel?: string | undefined readonly cpuLimit?: string | undefined readonly ramLimit?: string | undefined + readonly playwrightCpuLimit?: string | undefined + readonly playwrightRamLimit?: string | undefined readonly enableMcpPlaywright?: boolean | undefined } diff --git a/packages/lib/src/core/resource-limits.ts b/packages/lib/src/core/resource-limits.ts index 7d0e046e..8dc7ec26 100644 --- a/packages/lib/src/core/resource-limits.ts +++ b/packages/lib/src/core/resource-limits.ts @@ -1,6 +1,14 @@ import { Either } from "effect" -import { defaultCpuLimit, defaultRamLimit, type ParseError, type TemplateConfig } from "./domain.js" +import { type RawOptions } from "./command-options.js" +import { + defaultCpuLimit, + defaultPlaywrightCpuLimit, + defaultPlaywrightRamLimit, + defaultRamLimit, + type ParseError, + type TemplateConfig +} from "./domain.js" const mebibyte = 1024 ** 2 const minimumResolvedCpuLimit = 0.25 @@ -108,7 +116,9 @@ export const withDefaultResourceLimitIntent = ( ): TemplateConfig => ({ ...template, cpuLimit: template.cpuLimit ?? defaultCpuLimit, - ramLimit: template.ramLimit ?? defaultRamLimit + ramLimit: template.ramLimit ?? defaultRamLimit, + playwrightCpuLimit: template.playwrightCpuLimit ?? defaultPlaywrightCpuLimit, + playwrightRamLimit: template.playwrightRamLimit ?? defaultPlaywrightRamLimit }) const resolvePercentCpuLimit = (percent: number, cpuCount: number): number => @@ -141,3 +151,37 @@ export const resolveComposeResourceLimits = ( : resolvePercentRamLimit(ramPercent, hostResources.totalMemoryBytes) } } + +export const resolvePlaywrightComposeResourceLimits = ( + template: Pick, + hostResources: HostResources +): ResolvedComposeResourceLimits => + resolveComposeResourceLimits( + { + cpuLimit: template.playwrightCpuLimit ?? template.cpuLimit ?? defaultPlaywrightCpuLimit, + ramLimit: template.playwrightRamLimit ?? template.ramLimit ?? defaultPlaywrightRamLimit + }, + hostResources + ) + +export type ResolvedResourceLimitsIntent = { + readonly cpuLimit: string | undefined + readonly ramLimit: string | undefined + readonly playwrightCpuLimit: string | undefined + readonly playwrightRamLimit: string | undefined +} + +export const resolveResourceLimitsIntent = ( + raw: RawOptions +): Either.Either => + Either.gen(function*(_) { + const cpuLimit = yield* _(normalizeCpuLimit(raw.cpuLimit ?? defaultCpuLimit, "--cpu")) + const ramLimit = yield* _(normalizeRamLimit(raw.ramLimit ?? defaultRamLimit, "--ram")) + const playwrightCpuLimit = yield* _( + normalizeCpuLimit(raw.playwrightCpuLimit ?? cpuLimit, "--playwright-cpu") + ) + const playwrightRamLimit = yield* _( + normalizeRamLimit(raw.playwrightRamLimit ?? ramLimit, "--playwright-ram") + ) + return { cpuLimit, ramLimit, playwrightCpuLimit, playwrightRamLimit } + }) diff --git a/packages/lib/src/core/template-defaults.ts b/packages/lib/src/core/template-defaults.ts index c1370c2e..b736f6c9 100644 --- a/packages/lib/src/core/template-defaults.ts +++ b/packages/lib/src/core/template-defaults.ts @@ -21,6 +21,8 @@ type DefaultTemplateConfig = Pick< | "geminiHome" | "cpuLimit" | "ramLimit" + | "playwrightCpuLimit" + | "playwrightRamLimit" | "dockerNetworkMode" | "dockerSharedNetworkName" | "enableMcpPlaywright" @@ -37,6 +39,10 @@ export const defaultCpuLimit = "30%" export const defaultRamLimit = "30%" +export const defaultPlaywrightCpuLimit = "30%" + +export const defaultPlaywrightRamLimit = "30%" + export const defaultTemplateConfig = { containerName: "dev-ssh", serviceName: "dev", @@ -57,6 +63,8 @@ export const defaultTemplateConfig = { geminiHome: "/home/dev/.gemini", cpuLimit: defaultCpuLimit, ramLimit: defaultRamLimit, + playwrightCpuLimit: defaultPlaywrightCpuLimit, + playwrightRamLimit: defaultPlaywrightRamLimit, dockerNetworkMode: defaultDockerNetworkMode, dockerSharedNetworkName: defaultDockerSharedNetworkName, enableMcpPlaywright: false, diff --git a/packages/lib/src/core/templates.ts b/packages/lib/src/core/templates.ts index f5980e73..d17d71e1 100644 --- a/packages/lib/src/core/templates.ts +++ b/packages/lib/src/core/templates.ts @@ -1,7 +1,7 @@ import type { TemplateConfig } from "./domain.js" import type { ResolvedComposeResourceLimits } from "./resource-limits.js" import { renderEntrypoint } from "./templates-entrypoint.js" -import { renderDockerCompose } from "./templates/docker-compose.js" +import { type ComposeResourceLimits, renderDockerCompose } from "./templates/docker-compose.js" import { renderDockerfile } from "./templates/dockerfile.js" import { renderPlaywrightBrowserDockerfile, renderPlaywrightStartExtra } from "./templates/playwright.js" @@ -45,7 +45,7 @@ const renderConfigJson = (config: TemplateConfig): string => export const planFiles = ( config: TemplateConfig, - composeResourceLimits?: ResolvedComposeResourceLimits + composeResourceLimits?: ResolvedComposeResourceLimits | ComposeResourceLimits ): ReadonlyArray => { const maybePlaywrightFiles = config.enableMcpPlaywright ? ([ diff --git a/packages/lib/src/core/templates/docker-compose.ts b/packages/lib/src/core/templates/docker-compose.ts index 990b28f7..1b27be56 100644 --- a/packages/lib/src/core/templates/docker-compose.ts +++ b/packages/lib/src/core/templates/docker-compose.ts @@ -30,6 +30,11 @@ type PlaywrightFragments = Pick< "maybeDependsOn" | "maybePlaywrightEnv" | "maybeBrowserService" | "maybeBrowserVolume" > +export type ComposeResourceLimits = { + readonly main: ResolvedComposeResourceLimits | undefined + readonly playwright: ResolvedComposeResourceLimits | undefined +} + const sharedCodexVolumeKey = "docker_git_shared_codex" const sharedCacheVolumeKey = "docker_git_shared_cache" const bootstrapVolumeKey = "docker_git_bootstrap" @@ -108,9 +113,25 @@ const buildPlaywrightFragments = ( } } +const isResolvedComposeResourceLimits = ( + value: ResolvedComposeResourceLimits | ComposeResourceLimits +): value is ResolvedComposeResourceLimits => "cpuLimit" in value && "ramLimit" in value + +const normalizeComposeResourceLimits = ( + resourceLimits: ResolvedComposeResourceLimits | ComposeResourceLimits | undefined +): ComposeResourceLimits => { + if (resourceLimits === undefined) { + return { main: undefined, playwright: undefined } + } + if (isResolvedComposeResourceLimits(resourceLimits)) { + return { main: resourceLimits, playwright: resourceLimits } + } + return resourceLimits +} + const buildComposeFragments = ( config: TemplateConfig, - resourceLimits: ResolvedComposeResourceLimits | undefined + resourceLimits: ComposeResourceLimits ): ComposeFragments => { const networkMode = config.dockerNetworkMode const networkName = resolveComposeNetworkName(config) @@ -124,7 +145,7 @@ const buildComposeFragments = ( const maybeClaudeAuthLabelEnv = renderClaudeAuthLabelEnv(claudeAuthLabel) const maybeAgentModeEnv = renderAgentModeEnv(config.agentMode) const maybeAgentAutoEnv = renderAgentAutoEnv(config.agentAuto) - const playwright = buildPlaywrightFragments(config, networkName, resourceLimits) + const playwright = buildPlaywrightFragments(config, networkName, resourceLimits.playwright) return { networkMode, @@ -147,7 +168,7 @@ const buildComposeFragments = ( const renderComposeServices = ( config: TemplateConfig, fragments: ComposeFragments, - resourceLimits: ResolvedComposeResourceLimits | undefined + resourceLimits: ComposeResourceLimits ): string => `services: ${config.serviceName}: @@ -170,7 +191,7 @@ ${fragments.maybeClaudeAuthLabelEnv}${fragments.maybeAgentModeEnv}${fragments.ma ${fragments.maybePlaywrightEnv}${fragments.maybeDependsOn} # bootstrap auth/env arrives through docker_git_bootstrap ports: - "\${DOCKER_GIT_PROJECT_SSH_BIND_HOST:-127.0.0.1}:${config.sshPort}:22" -${renderResourceLimits(resourceLimits)} volumes: +${renderResourceLimits(resourceLimits.main)} volumes: - ${config.volumeName}:/home/${config.sshUser} - ${sharedCacheVolumeKey}:/home/${config.sshUser}/.docker-git/.cache - ${sharedCodexVolumeKey}:${config.codexHome}-shared @@ -214,12 +235,13 @@ const renderComposeVolumes = (config: TemplateConfig, maybeBrowserVolume: string export const renderDockerCompose = ( config: TemplateConfig, - resourceLimits?: ResolvedComposeResourceLimits + resourceLimits?: ResolvedComposeResourceLimits | ComposeResourceLimits ): string => { - const fragments = buildComposeFragments(config, resourceLimits) + const limits = normalizeComposeResourceLimits(resourceLimits) + const fragments = buildComposeFragments(config, limits) return [ `name: ${resolveComposeProjectName(config)}`, - renderComposeServices(config, fragments, resourceLimits), + renderComposeServices(config, fragments, limits), renderComposeNetworks(fragments.networkMode, fragments.networkName), renderComposeVolumes(config, fragments.maybeBrowserVolume) ].join("\n\n") diff --git a/packages/lib/src/shell/files.ts b/packages/lib/src/shell/files.ts index 03f1ba08..07f62211 100644 --- a/packages/lib/src/shell/files.ts +++ b/packages/lib/src/shell/files.ts @@ -5,7 +5,11 @@ import { Effect, Match } from "effect" import { dockerGitScriptNames } from "../core/docker-git-scripts.js" import { type TemplateConfig } from "../core/domain.js" -import { resolveComposeResourceLimits, withDefaultResourceLimitIntent } from "../core/resource-limits.js" +import { + resolveComposeResourceLimits, + resolvePlaywrightComposeResourceLimits, + withDefaultResourceLimitIntent +} from "../core/resource-limits.js" import { type FileSpec, planFiles } from "../core/templates.js" import { FileExistsError } from "./errors.js" import { resolveBaseDir } from "./paths.js" @@ -234,7 +238,10 @@ export const writeProjectFiles = ( const normalizedConfig = withDefaultResourceLimitIntent(config) const hostResources = yield* _(loadHostResources()) - const composeResourceLimits = resolveComposeResourceLimits(normalizedConfig, hostResources) + const composeResourceLimits = { + main: resolveComposeResourceLimits(normalizedConfig, hostResources), + playwright: resolvePlaywrightComposeResourceLimits(normalizedConfig, hostResources) + } const specs = planFiles(normalizedConfig, composeResourceLimits) const created: Array = [] const existingFilePaths = force ? [] : yield* _(collectExistingFilePaths(fs, path, baseDir, specs)) diff --git a/packages/lib/src/usecases/apply-overrides.ts b/packages/lib/src/usecases/apply-overrides.ts index e330938a..9ae2253a 100644 --- a/packages/lib/src/usecases/apply-overrides.ts +++ b/packages/lib/src/usecases/apply-overrides.ts @@ -7,54 +7,50 @@ export const hasApplyOverrides = (command: ApplyCommand): boolean => command.claudeTokenLabel !== undefined || command.cpuLimit !== undefined || command.ramLimit !== undefined || + command.playwrightCpuLimit !== undefined || + command.playwrightRamLimit !== undefined || command.enableMcpPlaywright !== undefined -export const applyTemplateOverrides = ( - template: TemplateConfig, - command: ApplyCommand | undefined -): TemplateConfig => { - if (command === undefined) { - return template - } - - let nextTemplate = template - +const applyTokenOverrides = (template: TemplateConfig, command: ApplyCommand): TemplateConfig => { + let next = template if (command.gitTokenLabel !== undefined) { - nextTemplate = { - ...nextTemplate, - gitTokenLabel: normalizeGitTokenLabel(command.gitTokenLabel) - } + next = { ...next, gitTokenLabel: normalizeGitTokenLabel(command.gitTokenLabel) } } if (command.codexTokenLabel !== undefined) { - nextTemplate = { - ...nextTemplate, - codexAuthLabel: normalizeAuthLabel(command.codexTokenLabel) - } + next = { ...next, codexAuthLabel: normalizeAuthLabel(command.codexTokenLabel) } } if (command.claudeTokenLabel !== undefined) { - nextTemplate = { - ...nextTemplate, - claudeAuthLabel: normalizeAuthLabel(command.claudeTokenLabel) - } + next = { ...next, claudeAuthLabel: normalizeAuthLabel(command.claudeTokenLabel) } } + return next +} + +const applyResourceOverrides = (template: TemplateConfig, command: ApplyCommand): TemplateConfig => { + let next = template if (command.cpuLimit !== undefined) { - nextTemplate = { - ...nextTemplate, - cpuLimit: command.cpuLimit - } + next = { ...next, cpuLimit: command.cpuLimit } } if (command.ramLimit !== undefined) { - nextTemplate = { - ...nextTemplate, - ramLimit: command.ramLimit - } + next = { ...next, ramLimit: command.ramLimit } + } + if (command.playwrightCpuLimit !== undefined) { + next = { ...next, playwrightCpuLimit: command.playwrightCpuLimit } + } + if (command.playwrightRamLimit !== undefined) { + next = { ...next, playwrightRamLimit: command.playwrightRamLimit } } if (command.enableMcpPlaywright !== undefined) { - nextTemplate = { - ...nextTemplate, - enableMcpPlaywright: command.enableMcpPlaywright - } + next = { ...next, enableMcpPlaywright: command.enableMcpPlaywright } } + return next +} - return nextTemplate +export const applyTemplateOverrides = ( + template: TemplateConfig, + command: ApplyCommand | undefined +): TemplateConfig => { + if (command === undefined) { + return template + } + return applyResourceOverrides(applyTokenOverrides(template, command), command) } diff --git a/packages/lib/tests/core/resource-limits.test.ts b/packages/lib/tests/core/resource-limits.test.ts index 992643c4..848eb1ef 100644 --- a/packages/lib/tests/core/resource-limits.test.ts +++ b/packages/lib/tests/core/resource-limits.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "@effect/vitest" import { resolveComposeResourceLimits, + resolvePlaywrightComposeResourceLimits, withDefaultResourceLimitIntent } from "../../src/core/resource-limits.js" import { defaultTemplateConfig, type TemplateConfig } from "../../src/core/domain.js" @@ -17,6 +18,8 @@ describe("withDefaultResourceLimitIntent", () => { expect(resolved.cpuLimit).toBe("30%") expect(resolved.ramLimit).toBe("30%") + expect(resolved.playwrightCpuLimit).toBe("30%") + expect(resolved.playwrightRamLimit).toBe("30%") }) it("preserves explicit limit intent", () => { @@ -29,6 +32,19 @@ describe("withDefaultResourceLimitIntent", () => { expect(resolved.cpuLimit).toBe("1.25") expect(resolved.ramLimit).toBe("3g") }) + + it("preserves explicit playwright intent independently of main", () => { + const resolved = withDefaultResourceLimitIntent({ + ...makeTemplate(), + cpuLimit: "1.25", + ramLimit: "3g", + playwrightCpuLimit: "0.5", + playwrightRamLimit: "1g" + }) + + expect(resolved.playwrightCpuLimit).toBe("0.5") + expect(resolved.playwrightRamLimit).toBe("1g") + }) }) describe("resolveComposeResourceLimits", () => { @@ -80,3 +96,70 @@ describe("resolveComposeResourceLimits", () => { expect(resolved.ramLimit).toBe("3g") }) }) + +describe("resolvePlaywrightComposeResourceLimits", () => { + it("uses dedicated playwright intent when provided", () => { + const resolved = resolvePlaywrightComposeResourceLimits( + { + cpuLimit: "2", + ramLimit: "4g", + playwrightCpuLimit: "0.5", + playwrightRamLimit: "1g" + }, + { + cpuCount: 8, + totalMemoryBytes: 16 * 1024 ** 3 + } + ) + + expect(resolved.cpuLimit).toBe(0.5) + expect(resolved.ramLimit).toBe("1g") + }) + + it("falls back to main intent when playwright intent is missing", () => { + const resolved = resolvePlaywrightComposeResourceLimits( + { + cpuLimit: "1.5", + ramLimit: "2g" + }, + { + cpuCount: 8, + totalMemoryBytes: 16 * 1024 ** 3 + } + ) + + expect(resolved.cpuLimit).toBe(1.5) + expect(resolved.ramLimit).toBe("2g") + }) + + it("falls back to default 30% when neither playwright nor main intent is set", () => { + const resolved = resolvePlaywrightComposeResourceLimits( + {}, + { + cpuCount: 8, + totalMemoryBytes: 16 * 1024 ** 3 + } + ) + + expect(resolved.cpuLimit).toBe(2.4) + expect(resolved.ramLimit).toBe("4915m") + }) + + it("supports percent intent for playwright independently", () => { + const resolved = resolvePlaywrightComposeResourceLimits( + { + cpuLimit: "60%", + ramLimit: "60%", + playwrightCpuLimit: "10%", + playwrightRamLimit: "10%" + }, + { + cpuCount: 8, + totalMemoryBytes: 16 * 1024 ** 3 + } + ) + + expect(resolved.cpuLimit).toBe(0.8) + expect(resolved.ramLimit).toBe("1638m") + }) +}) diff --git a/packages/lib/tests/core/templates.test.ts b/packages/lib/tests/core/templates.test.ts index 72cf4348..91072431 100644 --- a/packages/lib/tests/core/templates.test.ts +++ b/packages/lib/tests/core/templates.test.ts @@ -297,6 +297,47 @@ describe("renderDockerCompose", () => { expect((compose.match(/\n dns:\n/g) ?? []).length).toBe(2) }) + it("applies separate resource limits for the browser sidecar when provided", () => { + const compose = renderDockerCompose( + makeTemplateConfig({ + enableMcpPlaywright: true + }), + { + main: { cpuLimit: 2, ramLimit: "4g" }, + playwright: { cpuLimit: 0.5, ramLimit: "1g" } + } + ) + const browserServiceIndex = compose.indexOf("\n dg-test-browser:\n") + const browserSection = compose.slice(browserServiceIndex) + const mainSection = compose.slice(0, browserServiceIndex) + + expect(browserServiceIndex).toBeGreaterThanOrEqual(0) + expect(mainSection).toContain(" cpus: 2\n") + expect(mainSection).toContain(' mem_limit: "4g"\n') + expect(mainSection).toContain(' memswap_limit: "4g"\n') + expect(browserSection).toContain(" cpus: 0.5\n") + expect(browserSection).toContain(' mem_limit: "1g"\n') + expect(browserSection).toContain(' memswap_limit: "1g"\n') + }) + + it("backward-compatibly applies single resource limit shape to both services", () => { + const compose = renderDockerCompose( + makeTemplateConfig({ + enableMcpPlaywright: true + }), + { + cpuLimit: 1.5, + ramLimit: "2g" + } + ) + const browserServiceIndex = compose.indexOf("\n dg-test-browser:\n") + const browserSection = compose.slice(browserServiceIndex) + + expect(browserServiceIndex).toBeGreaterThanOrEqual(0) + expect(browserSection).toContain(" cpus: 1.5\n") + expect(browserSection).toContain(' mem_limit: "2g"\n') + }) + it("renders explicit anonymous GitHub clone override for public repos", () => { const compose = renderDockerCompose( makeTemplateConfig({ From f9f01a5bed40f418515b98accc7e0cd38003fc86 Mon Sep 17 00:00:00 2001 From: konard Date: Sat, 9 May 2026 18:37:34 +0000 Subject: [PATCH 3/3] Revert "Initial commit with task details" This reverts commit 94783e4036a47fc55e162dced2c1ec75a4c70485. --- .gitkeep | 1 - 1 file changed, 1 deletion(-) delete mode 100644 .gitkeep diff --git a/.gitkeep b/.gitkeep deleted file mode 100644 index fa600f02..00000000 --- a/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -# .gitkeep file auto-generated at 2026-05-09T17:35:24.814Z for PR creation at branch issue-259-9a9eea9aba5c for issue https://github.com/ProverCoderAI/docker-git/issues/259 \ No newline at end of file