diff --git a/packages/api/src/api/contracts.ts b/packages/api/src/api/contracts.ts index 9af63ebb..9a3f0d60 100644 --- a/packages/api/src/api/contracts.ts +++ b/packages/api/src/api/contracts.ts @@ -42,6 +42,8 @@ export type CreateProjectRequest = { readonly envProjectPath?: string | undefined readonly codexAuthPath?: string | undefined readonly codexHome?: string | undefined + readonly cpuLimit?: string | undefined + readonly ramLimit?: string | undefined readonly dockerNetworkMode?: string | undefined readonly dockerSharedNetworkName?: string | undefined readonly enableMcpPlaywright?: boolean | undefined diff --git a/packages/api/src/api/schema.ts b/packages/api/src/api/schema.ts index 8b563c8f..eadcd700 100644 --- a/packages/api/src/api/schema.ts +++ b/packages/api/src/api/schema.ts @@ -18,6 +18,8 @@ export const CreateProjectRequestSchema = Schema.Struct({ envProjectPath: OptionalString, codexAuthPath: OptionalString, codexHome: OptionalString, + cpuLimit: OptionalString, + ramLimit: OptionalString, dockerNetworkMode: OptionalString, dockerSharedNetworkName: OptionalString, enableMcpPlaywright: OptionalBoolean, diff --git a/packages/api/src/services/projects.ts b/packages/api/src/services/projects.ts index 15b0a792..c91f7e4c 100644 --- a/packages/api/src/services/projects.ts +++ b/packages/api/src/services/projects.ts @@ -192,6 +192,8 @@ export const createProjectFromRequest = ( ...(request.envProjectPath === undefined ? {} : { envProjectPath: request.envProjectPath }), ...(request.codexAuthPath === undefined ? {} : { codexAuthPath: request.codexAuthPath }), ...(request.codexHome === undefined ? {} : { codexHome: request.codexHome }), + ...(request.cpuLimit === undefined ? {} : { cpuLimit: request.cpuLimit }), + ...(request.ramLimit === undefined ? {} : { ramLimit: request.ramLimit }), ...(request.dockerNetworkMode === undefined ? {} : { dockerNetworkMode: request.dockerNetworkMode }), ...(request.dockerSharedNetworkName === undefined ? {} : { dockerSharedNetworkName: request.dockerSharedNetworkName }), ...(request.enableMcpPlaywright === undefined ? {} : { enableMcpPlaywright: request.enableMcpPlaywright }), diff --git a/packages/api/src/ui.ts b/packages/api/src/ui.ts index 55e47f32..4e85a4b3 100644 --- a/packages/api/src/ui.ts +++ b/packages/api/src/ui.ts @@ -383,6 +383,16 @@ export const uiHtml = ` +
+
+ + +
+
+ + +
+
@@ -530,6 +540,8 @@ export const uiScript = ` createRepoRef: byId('create-repo-ref'), createSshPort: byId('create-ssh-port'), createNetworkMode: byId('create-network-mode'), + createCpu: byId('create-cpu'), + createRam: byId('create-ram'), createUp: byId('create-up'), createForce: byId('create-force'), createForceEnv: byId('create-force-env') @@ -818,6 +830,8 @@ export const uiScript = ` repoUrl: views.createRepoUrl.value.trim() || undefined, repoRef: views.createRepoRef.value.trim() || undefined, sshPort: views.createSshPort.value.trim() || undefined, + cpuLimit: views.createCpu.value.trim() || undefined, + ramLimit: views.createRam.value.trim() || undefined, dockerNetworkMode: views.createNetworkMode.value.trim() || undefined, up: views.createUp.checked, force: views.createForce.checked, diff --git a/packages/app/src/docker-git/cli/parser-apply.ts b/packages/app/src/docker-git/cli/parser-apply.ts index a3615eb9..38230071 100644 --- a/packages/app/src/docker-git/cli/parser-apply.ts +++ b/packages/app/src/docker-git/cli/parser-apply.ts @@ -1,6 +1,7 @@ import { Either } from "effect" import { type ApplyCommand, type ParseError } from "@effect-template/lib/core/domain" +import { normalizeCpuLimit, normalizeRamLimit } from "@effect-template/lib/core/resource-limits" import { parseProjectDirWithOptions } from "./parser-shared.js" @@ -17,12 +18,19 @@ import { parseProjectDirWithOptions } from "./parser-shared.js" export const parseApply = ( args: ReadonlyArray ): Either.Either => - Either.map(parseProjectDirWithOptions(args), ({ projectDir, raw }) => ({ - _tag: "Apply", - projectDir, - runUp: raw.up ?? true, - gitTokenLabel: raw.gitTokenLabel, - codexTokenLabel: raw.codexTokenLabel, - claudeTokenLabel: raw.claudeTokenLabel, - enableMcpPlaywright: raw.enableMcpPlaywright - })) + Either.gen(function*(_) { + const { projectDir, raw } = yield* _(parseProjectDirWithOptions(args)) + const cpuLimit = yield* _(normalizeCpuLimit(raw.cpuLimit, "--cpu")) + const ramLimit = yield* _(normalizeRamLimit(raw.ramLimit, "--ram")) + return { + _tag: "Apply", + projectDir, + runUp: raw.up ?? true, + gitTokenLabel: raw.gitTokenLabel, + codexTokenLabel: raw.codexTokenLabel, + claudeTokenLabel: raw.claudeTokenLabel, + cpuLimit, + ramLimit, + 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 9923480f..aed13181 100644 --- a/packages/app/src/docker-git/cli/parser-options.ts +++ b/packages/app/src/docker-git/cli/parser-options.ts @@ -20,6 +20,8 @@ interface ValueOptionSpec { | "envProjectPath" | "codexAuthPath" | "codexHome" + | "cpuLimit" + | "ramLimit" | "dockerNetworkMode" | "dockerSharedNetworkName" | "archivePath" @@ -54,6 +56,10 @@ const valueOptionSpecs: ReadonlyArray = [ { flag: "--env-project", key: "envProjectPath" }, { flag: "--codex-auth", key: "codexAuthPath" }, { flag: "--codex-home", key: "codexHome" }, + { flag: "--cpu", key: "cpuLimit" }, + { flag: "--cpus", key: "cpuLimit" }, + { flag: "--ram", key: "ramLimit" }, + { flag: "--memory", key: "ramLimit" }, { flag: "--network-mode", key: "dockerNetworkMode" }, { flag: "--shared-network", key: "dockerSharedNetworkName" }, { flag: "--archive", key: "archivePath" }, @@ -109,6 +115,8 @@ const valueFlagUpdaters: { readonly [K in ValueKey]: (raw: RawOptions, value: st envProjectPath: (raw, value) => ({ ...raw, envProjectPath: value }), codexAuthPath: (raw, value) => ({ ...raw, codexAuthPath: value }), codexHome: (raw, value) => ({ ...raw, codexHome: value }), + cpuLimit: (raw, value) => ({ ...raw, cpuLimit: value }), + ramLimit: (raw, value) => ({ ...raw, ramLimit: 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 33e129bb..53d7cad4 100644 --- a/packages/app/src/docker-git/cli/usage.ts +++ b/packages/app/src/docker-git/cli/usage.ts @@ -50,6 +50,8 @@ Options: --env-project Host path to project env file (default: ./.orch/env/project.env) --codex-auth Host path for Codex auth cache (default: /.orch/auth/codex) --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%) --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/menu-create.ts b/packages/app/src/docker-git/menu-create.ts index cc96be95..9a5223ef 100644 --- a/packages/app/src/docker-git/menu-create.ts +++ b/packages/app/src/docker-git/menu-create.ts @@ -45,28 +45,38 @@ type CreateReturnContext = CreateContext & { readonly view: Extract } +type OptionalCreateArg = { + readonly value: string + readonly args: readonly [string, string] +} + +const optionalCreateArgs = (input: CreateInputs): ReadonlyArray => [ + { value: input.repoUrl, args: ["--repo-url", input.repoUrl] }, + { value: input.repoRef, args: ["--repo-ref", input.repoRef] }, + { value: input.outDir, args: ["--out-dir", input.outDir] }, + { value: input.cpuLimit, args: ["--cpu", input.cpuLimit] }, + { value: input.ramLimit, args: ["--ram", input.ramLimit] } +] + +const booleanCreateFlags = (input: CreateInputs): ReadonlyArray => + [ + input.runUp ? null : "--no-up", + input.enableMcpPlaywright ? "--mcp-playwright" : null, + input.force ? "--force" : null, + input.forceEnv ? "--force-env" : null + ].filter((value): value is string => value !== null) + export const buildCreateArgs = (input: CreateInputs): ReadonlyArray => { const args: Array = ["create"] - if (input.repoUrl.length > 0) { - args.push("--repo-url", input.repoUrl) - } - if (input.repoRef.length > 0) { - args.push("--repo-ref", input.repoRef) - } - if (input.outDir.length > 0) { - args.push("--out-dir", input.outDir) - } - if (!input.runUp) { - args.push("--no-up") - } - if (input.enableMcpPlaywright) { - args.push("--mcp-playwright") - } - if (input.force) { - args.push("--force") + + for (const spec of optionalCreateArgs(input)) { + if (spec.value.length > 0) { + args.push(spec.args[0], spec.args[1]) + } } - if (input.forceEnv) { - args.push("--force-env") + + for (const flag of booleanCreateFlags(input)) { + args.push(flag) } return args } @@ -118,6 +128,8 @@ export const resolveCreateInputs = ( repoUrl, repoRef: values.repoRef ?? resolvedRepoRef ?? "main", outDir, + cpuLimit: values.cpuLimit ?? "", + ramLimit: values.ramLimit ?? "", runUp: values.runUp !== false, enableMcpPlaywright: values.enableMcpPlaywright === true, force: values.force === true, @@ -196,6 +208,14 @@ const applyCreateStep = (input: { input.nextValues.outDir = input.buffer.length > 0 ? input.buffer : input.currentDefaults.outDir return true }), + Match.when("cpuLimit", () => { + input.nextValues.cpuLimit = input.buffer.length > 0 ? input.buffer : input.currentDefaults.cpuLimit + return true + }), + Match.when("ramLimit", () => { + input.nextValues.ramLimit = input.buffer.length > 0 ? input.buffer : input.currentDefaults.ramLimit + return true + }), Match.when("runUp", () => { input.nextValues.runUp = parseYesDefault(input.buffer, input.currentDefaults.runUp) return true diff --git a/packages/app/src/docker-git/menu-render.ts b/packages/app/src/docker-git/menu-render.ts index 68a64acd..3318ea6f 100644 --- a/packages/app/src/docker-git/menu-render.ts +++ b/packages/app/src/docker-git/menu-render.ts @@ -31,6 +31,8 @@ export const renderStepLabel = (step: CreateStep, defaults: CreateInputs): strin Match.when("repoUrl", () => "Repo URL (optional for empty workspace)"), Match.when("repoRef", () => `Repo ref [${defaults.repoRef}]`), Match.when("outDir", () => `Output dir [${defaults.outDir}]`), + Match.when("cpuLimit", () => `CPU limit [${defaults.cpuLimit || "30%"}]`), + Match.when("ramLimit", () => `RAM limit [${defaults.ramLimit || "30%"}]`), Match.when("runUp", () => `Run docker compose up now? [${defaults.runUp ? "Y" : "n"}]`), Match.when( "mcpPlaywright", diff --git a/packages/app/src/docker-git/menu-types.ts b/packages/app/src/docker-git/menu-types.ts index 65f245e3..b80fa389 100644 --- a/packages/app/src/docker-git/menu-types.ts +++ b/packages/app/src/docker-git/menu-types.ts @@ -48,6 +48,8 @@ export type CreateInputs = { readonly repoUrl: string readonly repoRef: string readonly outDir: string + readonly cpuLimit: string + readonly ramLimit: string readonly runUp: boolean readonly enableMcpPlaywright: boolean readonly force: boolean @@ -58,6 +60,8 @@ export type CreateStep = | "repoUrl" | "repoRef" | "outDir" + | "cpuLimit" + | "ramLimit" | "runUp" | "mcpPlaywright" | "force" @@ -66,6 +70,8 @@ export const createSteps: ReadonlyArray = [ "repoUrl", "repoRef", "outDir", + "cpuLimit", + "ramLimit", "runUp", "mcpPlaywright", "force" diff --git a/packages/app/tests/docker-git/parser.test.ts b/packages/app/tests/docker-git/parser.test.ts index 8f39f7e0..47d807b9 100644 --- a/packages/app/tests/docker-git/parser.test.ts +++ b/packages/app/tests/docker-git/parser.test.ts @@ -18,6 +18,8 @@ const expectCreateDefaults = (command: CreateCommand) => { expect(command.outDir).toBe(".docker-git/org/repo") expect(command.runUp).toBe(true) expect(command.forceEnv).toBe(false) + expect(command.config.cpuLimit).toBe("30%") + expect(command.config.ramLimit).toBe("30%") expect(command.config.dockerNetworkMode).toBe("shared") expect(command.config.dockerSharedNetworkName).toBe("docker-git-shared") } @@ -36,6 +38,27 @@ describe("parseArgs", () => { expect(command.config.sshPort).toBe(defaultTemplateConfig.sshPort) })) + it.effect("parses create resource limit flags", () => + expectCreateCommand( + ["create", "--repo-url", "https://github.com/org/repo.git", "--cpu", "30%", "--ram", "3072m"], + (command) => { + expect(command.config.cpuLimit).toBe("30%") + expect(command.config.ramLimit).toBe("3072m") + } + )) + + it.effect("accepts legacy compose-style limit aliases", () => + expectCreateCommand( + ["create", "--repo-url", "https://github.com/org/repo.git", "--cpus", "1.5", "--memory", "4g"], + (command) => { + expect(command.config.cpuLimit).toBe("1.5") + expect(command.config.ramLimit).toBe("4g") + } + )) + + it.effect("rejects unitless RAM absolute limit", () => + expectParseErrorTag(["create", "--repo-url", "https://github.com/org/repo.git", "--ram", "4096"], "InvalidOption")) + it.effect("parses create command with issue url into isolated defaults", () => expectCreateCommand(["create", "--repo-url", "https://github.com/org/repo/issues/9"], (command) => { expect(command.config.repoUrl).toBe("https://github.com/org/repo.git") @@ -225,6 +248,8 @@ describe("parseArgs", () => { "--git-token=agien_main", "--codex-token=Team A", "--claude-token=Team B", + "--cpu=2", + "--ram=4g", "--mcp-playwright", "--no-up" ]) @@ -235,6 +260,8 @@ describe("parseArgs", () => { 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.enableMcpPlaywright).toBe(true) })) diff --git a/packages/lib/src/core/command-builders-shared.ts b/packages/lib/src/core/command-builders-shared.ts new file mode 100644 index 00000000..82535698 --- /dev/null +++ b/packages/lib/src/core/command-builders-shared.ts @@ -0,0 +1,53 @@ +import { Either } from "effect" + +import { type CreateCommand, defaultTemplateConfig, isDockerNetworkMode, type ParseError } from "./domain.js" + +const parsePort = (value: string): Either.Either => { + const parsed = Number(value) + if (!Number.isInteger(parsed)) { + return Either.left({ + _tag: "InvalidOption", + option: "--ssh-port", + reason: `expected integer, got: ${value}` + }) + } + if (parsed < 1 || parsed > 65_535) { + return Either.left({ + _tag: "InvalidOption", + option: "--ssh-port", + reason: "must be between 1 and 65535" + }) + } + return Either.right(parsed) +} + +export const parseSshPort = (value: string): Either.Either => parsePort(value) + +export const parseDockerNetworkMode = ( + value: string | undefined +): Either.Either => { + const candidate = value?.trim() ?? defaultTemplateConfig.dockerNetworkMode + if (isDockerNetworkMode(candidate)) { + return Either.right(candidate) + } + return Either.left({ + _tag: "InvalidOption", + option: "--network-mode", + reason: "expected one of: shared, project" + }) +} + +export const nonEmpty = ( + option: string, + value: string | undefined, + fallback?: string +): Either.Either => { + const candidate = value?.trim() ?? fallback + if (candidate === undefined || candidate.length === 0) { + return Either.left({ + _tag: "MissingRequiredOption", + option + }) + } + return Either.right(candidate) +} diff --git a/packages/lib/src/core/command-builders.ts b/packages/lib/src/core/command-builders.ts index 8b5d8510..4435cc89 100644 --- a/packages/lib/src/core/command-builders.ts +++ b/packages/lib/src/core/command-builders.ts @@ -2,67 +2,24 @@ import { Either } from "effect" import { expandContainerHome } from "../usecases/scrap-path.js" import { resolveAutoAgentFlags } from "./auto-agent-flags.js" +import { nonEmpty, parseDockerNetworkMode, parseSshPort } from "./command-builders-shared.js" import { type RawOptions } from "./command-options.js" import { type AgentMode, type CreateCommand, + defaultCpuLimit, + defaultRamLimit, defaultTemplateConfig, deriveRepoPathParts, deriveRepoSlug, - isDockerNetworkMode, type ParseError, resolveRepoInput } from "./domain.js" +import { normalizeCpuLimit, normalizeRamLimit } from "./resource-limits.js" import { trimRightChar } from "./strings.js" import { normalizeAuthLabel, normalizeGitTokenLabel } from "./token-labels.js" -const parsePort = (value: string): Either.Either => { - const parsed = Number(value) - if (!Number.isInteger(parsed)) { - return Either.left({ - _tag: "InvalidOption", - option: "--ssh-port", - reason: `expected integer, got: ${value}` - }) - } - if (parsed < 1 || parsed > 65_535) { - return Either.left({ - _tag: "InvalidOption", - option: "--ssh-port", - reason: "must be between 1 and 65535" - }) - } - return Either.right(parsed) -} - -const parseDockerNetworkMode = ( - value: string | undefined -): Either.Either => { - const candidate = value?.trim() ?? defaultTemplateConfig.dockerNetworkMode - if (isDockerNetworkMode(candidate)) { - return Either.right(candidate) - } - return Either.left({ - _tag: "InvalidOption", - option: "--network-mode", - reason: "expected one of: shared, project" - }) -} - -export const nonEmpty = ( - option: string, - value: string | undefined, - fallback?: string -): Either.Either => { - const candidate = value?.trim() ?? fallback - if (candidate === undefined || candidate.length === 0) { - return Either.left({ - _tag: "MissingRequiredOption", - option - }) - } - return Either.right(candidate) -} +export { nonEmpty } from "./command-builders-shared.js" const normalizeSecretsRoot = (value: string): string => trimRightChar(value, "/") @@ -95,7 +52,7 @@ const resolveRepoBasics = (raw: RawOptions): Either.Either ({ containerName: names.containerName, @@ -263,6 +224,8 @@ const buildTemplateConfig = ({ codexAuthPath: paths.codexAuthPath, codexSharedAuthPath: paths.codexSharedAuthPath, codexHome: paths.codexHome, + cpuLimit, + ramLimit, dockerNetworkMode, dockerSharedNetworkName, enableMcpPlaywright, @@ -292,6 +255,8 @@ 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 dockerNetworkMode = yield* _(parseDockerNetworkMode(raw.dockerNetworkMode)) const dockerSharedNetworkName = yield* _( nonEmpty("--shared-network", raw.dockerSharedNetworkName, defaultTemplateConfig.dockerSharedNetworkName) @@ -310,6 +275,8 @@ export const buildCreateCommand = ( repo, names, paths, + cpuLimit, + ramLimit, dockerNetworkMode, dockerSharedNetworkName, gitTokenLabel, diff --git a/packages/lib/src/core/command-options.ts b/packages/lib/src/core/command-options.ts index cd2bf820..3afdfe8a 100644 --- a/packages/lib/src/core/command-options.ts +++ b/packages/lib/src/core/command-options.ts @@ -25,6 +25,8 @@ export interface RawOptions { readonly envProjectPath?: string readonly codexAuthPath?: string readonly codexHome?: string + readonly cpuLimit?: string + readonly ramLimit?: 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 7944f0cd..5026fcf5 100644 --- a/packages/lib/src/core/domain.ts +++ b/packages/lib/src/core/domain.ts @@ -10,6 +10,10 @@ export const defaultDockerNetworkMode: DockerNetworkMode = "shared" export const defaultDockerSharedNetworkName = "docker-git-shared" +export const defaultCpuLimit = "30%" + +export const defaultRamLimit = "30%" + export interface TemplateConfig { readonly containerName: string readonly serviceName: string @@ -30,6 +34,8 @@ export interface TemplateConfig { readonly codexAuthPath: string readonly codexSharedAuthPath: string readonly codexHome: string + readonly cpuLimit?: string | undefined + readonly ramLimit?: string | undefined readonly dockerNetworkMode: DockerNetworkMode readonly dockerSharedNetworkName: string readonly enableMcpPlaywright: boolean @@ -127,6 +133,8 @@ export interface ApplyCommand { readonly gitTokenLabel?: string | undefined readonly codexTokenLabel?: string | undefined readonly claudeTokenLabel?: string | undefined + readonly cpuLimit?: string | undefined + readonly ramLimit?: string | undefined readonly enableMcpPlaywright?: boolean | undefined } @@ -319,6 +327,8 @@ export const defaultTemplateConfig = { codexAuthPath: "./.docker-git/.orch/auth/codex", codexSharedAuthPath: "./.docker-git/.orch/auth/codex", codexHome: "/home/dev/.codex", + cpuLimit: defaultCpuLimit, + ramLimit: defaultRamLimit, dockerNetworkMode: defaultDockerNetworkMode, dockerSharedNetworkName: defaultDockerSharedNetworkName, enableMcpPlaywright: false, diff --git a/packages/lib/src/core/resource-limits.ts b/packages/lib/src/core/resource-limits.ts new file mode 100644 index 00000000..7d0e046e --- /dev/null +++ b/packages/lib/src/core/resource-limits.ts @@ -0,0 +1,143 @@ +import { Either } from "effect" + +import { defaultCpuLimit, defaultRamLimit, type ParseError, type TemplateConfig } from "./domain.js" + +const mebibyte = 1024 ** 2 +const minimumResolvedCpuLimit = 0.25 +const minimumResolvedRamLimitMib = 512 +const precisionScale = 100 + +type HostResources = { + readonly cpuCount: number + readonly totalMemoryBytes: number +} + +export type ResolvedComposeResourceLimits = { + readonly cpuLimit: number + readonly ramLimit: string +} + +const cpuAbsolutePattern = /^\d+(?:\.\d+)?$/u +const ramAbsolutePattern = /^\d+(?:\.\d+)?(?:b|k|kb|m|mb|g|gb|t|tb)$/iu +const percentPattern = /^\d+(?:\.\d+)?%$/u + +const normalizePrecision = (value: number): number => Math.round(value * precisionScale) / precisionScale + +const missingLimit = (): string | undefined => undefined + +const parsePercent = (candidate: string): number | null => { + if (!percentPattern.test(candidate)) { + return null + } + const parsed = Number(candidate.slice(0, -1)) + if (!Number.isFinite(parsed) || parsed <= 0 || parsed > 100) { + return null + } + return normalizePrecision(parsed) +} + +const percentReason = (kind: "cpu" | "ram"): string => + kind === "cpu" + ? "expected CPU like 30% or 1.5" + : "expected RAM like 30%, 512m or 4g" + +const normalizePercent = (candidate: string, kind: "cpu" | "ram"): Either.Either => { + const parsed = parsePercent(candidate) + if (parsed === null) { + return Either.left({ + _tag: "InvalidOption", + option: kind === "cpu" ? "--cpu" : "--ram", + reason: percentReason(kind) + }) + } + return Either.right(`${parsed}%`) +} + +export const normalizeCpuLimit = ( + value: string | undefined, + option: string +): Either.Either => { + const candidate = value?.trim().toLowerCase() ?? "" + if (candidate.length === 0) { + return Either.right(missingLimit()) + } + if (candidate.endsWith("%")) { + return normalizePercent(candidate, "cpu") + } + if (!cpuAbsolutePattern.test(candidate)) { + return Either.left({ + _tag: "InvalidOption", + option, + reason: "expected CPU like 30% or 1.5" + }) + } + const parsed = Number(candidate) + if (!Number.isFinite(parsed) || parsed <= 0) { + return Either.left({ + _tag: "InvalidOption", + option, + reason: "must be greater than 0" + }) + } + return Either.right(String(normalizePrecision(parsed))) +} + +export const normalizeRamLimit = ( + value: string | undefined, + option: string +): Either.Either => { + const candidate = value?.trim().toLowerCase() ?? "" + if (candidate.length === 0) { + return Either.right(missingLimit()) + } + if (candidate.endsWith("%")) { + return normalizePercent(candidate, "ram") + } + if (!ramAbsolutePattern.test(candidate)) { + return Either.left({ + _tag: "InvalidOption", + option, + reason: "expected RAM like 30%, 512m or 4g" + }) + } + return Either.right(candidate) +} + +export const withDefaultResourceLimitIntent = ( + template: TemplateConfig +): TemplateConfig => ({ + ...template, + cpuLimit: template.cpuLimit ?? defaultCpuLimit, + ramLimit: template.ramLimit ?? defaultRamLimit +}) + +const resolvePercentCpuLimit = (percent: number, cpuCount: number): number => + Math.max( + minimumResolvedCpuLimit, + normalizePrecision((Math.max(1, cpuCount) * percent) / 100) + ) + +const resolvePercentRamLimit = (percent: number, totalMemoryBytes: number): string => { + const totalMib = Math.max(minimumResolvedRamLimitMib, Math.floor(totalMemoryBytes / mebibyte)) + const targetMib = Math.max(minimumResolvedRamLimitMib, Math.floor((totalMib * percent) / 100)) + return `${targetMib}m` +} + +export const resolveComposeResourceLimits = ( + template: Pick, + hostResources: HostResources +): ResolvedComposeResourceLimits => { + const cpuLimitIntent = template.cpuLimit ?? defaultCpuLimit + const ramLimitIntent = template.ramLimit ?? defaultRamLimit + const cpuPercent = parsePercent(cpuLimitIntent) + const ramPercent = parsePercent(ramLimitIntent) + + return { + cpuLimit: cpuPercent === null + ? Number(cpuLimitIntent) + : resolvePercentCpuLimit(cpuPercent, hostResources.cpuCount), + ramLimit: ramPercent === null + ? ramLimitIntent + : resolvePercentRamLimit(ramPercent, hostResources.totalMemoryBytes) + } +} diff --git a/packages/lib/src/core/templates.ts b/packages/lib/src/core/templates.ts index 00b3b610..356215cb 100644 --- a/packages/lib/src/core/templates.ts +++ b/packages/lib/src/core/templates.ts @@ -1,4 +1,5 @@ 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 { renderDockerfile } from "./templates/dockerfile.js" @@ -30,7 +31,10 @@ const renderConfigJson = (config: TemplateConfig): string => `${JSON.stringify({ schemaVersion: 1, template: config }, null, 2)} ` -export const planFiles = (config: TemplateConfig): ReadonlyArray => { +export const planFiles = ( + config: TemplateConfig, + composeResourceLimits?: ResolvedComposeResourceLimits +): ReadonlyArray => { const maybePlaywrightFiles = config.enableMcpPlaywright ? ([ { _tag: "File", relativePath: "Dockerfile.browser", contents: renderPlaywrightBrowserDockerfile() }, @@ -46,7 +50,11 @@ export const planFiles = (config: TemplateConfig): ReadonlyArray => { return [ { _tag: "File", relativePath: "Dockerfile", contents: renderDockerfile(config) }, { _tag: "File", relativePath: "entrypoint.sh", contents: renderEntrypoint(config), mode: 0o755 }, - { _tag: "File", relativePath: "docker-compose.yml", contents: renderDockerCompose(config) }, + { + _tag: "File", + relativePath: "docker-compose.yml", + contents: renderDockerCompose(config, composeResourceLimits) + }, { _tag: "File", relativePath: ".dockerignore", contents: renderDockerignore() }, { _tag: "File", relativePath: "docker-git.json", contents: renderConfigJson(config) }, { _tag: "File", relativePath: ".gitignore", contents: renderGitignore() }, diff --git a/packages/lib/src/core/templates/docker-compose.ts b/packages/lib/src/core/templates/docker-compose.ts index a17b02bb..e8657cc2 100644 --- a/packages/lib/src/core/templates/docker-compose.ts +++ b/packages/lib/src/core/templates/docker-compose.ts @@ -1,4 +1,5 @@ import { resolveComposeNetworkName, type TemplateConfig } from "../domain.js" +import type { ResolvedComposeResourceLimits } from "../resource-limits.js" type ComposeFragments = { readonly networkMode: TemplateConfig["dockerNetworkMode"] @@ -51,9 +52,15 @@ const renderProjectsRootHostMount = (projectsRoot: string): string => const renderSharedCodexHostMount = (projectsRoot: string): string => `\${DOCKER_GIT_PROJECTS_ROOT_HOST:-${projectsRoot}}/.orch/auth/codex` +const renderResourceLimits = (resourceLimits: ResolvedComposeResourceLimits | undefined): string => + resourceLimits === undefined + ? "" + : ` cpus: ${resourceLimits.cpuLimit}\n mem_limit: "${resourceLimits.ramLimit}"\n memswap_limit: "${resourceLimits.ramLimit}"\n` + const buildPlaywrightFragments = ( config: TemplateConfig, - networkName: string + networkName: string, + resourceLimits: ResolvedComposeResourceLimits | undefined ): PlaywrightFragments => { if (!config.enableMcpPlaywright) { return { @@ -75,12 +82,17 @@ const buildPlaywrightFragments = ( maybePlaywrightEnv: ` MCP_PLAYWRIGHT_ENABLE: "1"\n MCP_PLAYWRIGHT_CDP_ENDPOINT: "${browserCdpEndpoint}"\n`, maybeBrowserService: - `\n ${browserServiceName}:\n build:\n context: .\n dockerfile: ${browserDockerfile}\n container_name: ${browserContainerName}\n restart: unless-stopped\n environment:\n VNC_NOPW: "1"\n shm_size: "2gb"\n expose:\n - "9223"\n volumes:\n - ${browserVolumeName}:/data\n networks:\n - ${networkName}\n`, + `\n ${browserServiceName}:\n build:\n context: .\n dockerfile: ${browserDockerfile}\n container_name: ${browserContainerName}\n restart: unless-stopped\n${ + renderResourceLimits(resourceLimits) + } environment:\n VNC_NOPW: "1"\n shm_size: "2gb"\n expose:\n - "9223"\n volumes:\n - ${browserVolumeName}:/data\n networks:\n - ${networkName}\n`, maybeBrowserVolume: ` ${browserVolumeName}:\n` } } -const buildComposeFragments = (config: TemplateConfig): ComposeFragments => { +const buildComposeFragments = ( + config: TemplateConfig, + resourceLimits: ResolvedComposeResourceLimits | undefined +): ComposeFragments => { const networkMode = config.dockerNetworkMode const networkName = resolveComposeNetworkName(config) const forkRepoUrl = config.forkRepoUrl ?? "" @@ -92,7 +104,7 @@ const buildComposeFragments = (config: TemplateConfig): ComposeFragments => { const maybeClaudeAuthLabelEnv = renderClaudeAuthLabelEnv(claudeAuthLabel) const maybeAgentModeEnv = renderAgentModeEnv(config.agentMode) const maybeAgentAutoEnv = renderAgentAutoEnv(config.agentAuto) - const playwright = buildPlaywrightFragments(config, networkName) + const playwright = buildPlaywrightFragments(config, networkName, resourceLimits) return { networkMode, @@ -110,7 +122,11 @@ const buildComposeFragments = (config: TemplateConfig): ComposeFragments => { } } -const renderComposeServices = (config: TemplateConfig, fragments: ComposeFragments): string => +const renderComposeServices = ( + config: TemplateConfig, + fragments: ComposeFragments, + resourceLimits: ResolvedComposeResourceLimits | undefined +): string => `services: ${config.serviceName}: build: . @@ -130,7 +146,7 @@ ${fragments.maybePlaywrightEnv}${fragments.maybeDependsOn} env_file: - ${config.envProjectPath} ports: - "127.0.0.1:${config.sshPort}:22" - volumes: +${renderResourceLimits(resourceLimits)} volumes: - ${config.volumeName}:/home/${config.sshUser} - ${renderProjectsRootHostMount(config.dockerGitPath)}:/home/${config.sshUser}/.docker-git - ${config.authorizedKeysPath}:/authorized_keys:ro @@ -158,10 +174,13 @@ const renderComposeVolumes = (config: TemplateConfig, maybeBrowserVolume: string ${config.volumeName}: ${maybeBrowserVolume}` -export const renderDockerCompose = (config: TemplateConfig): string => { - const fragments = buildComposeFragments(config) +export const renderDockerCompose = ( + config: TemplateConfig, + resourceLimits?: ResolvedComposeResourceLimits +): string => { + const fragments = buildComposeFragments(config, resourceLimits) return [ - renderComposeServices(config, fragments), + renderComposeServices(config, fragments, resourceLimits), renderComposeNetworks(fragments.networkMode, fragments.networkName), renderComposeVolumes(config, fragments.maybeBrowserVolume) ].join("\n\n") diff --git a/packages/lib/src/shell/config.ts b/packages/lib/src/shell/config.ts index b39d19b3..ff2a0e48 100644 --- a/packages/lib/src/shell/config.ts +++ b/packages/lib/src/shell/config.ts @@ -37,6 +37,12 @@ const TemplateConfigSchema = Schema.Struct({ default: () => defaultTemplateConfig.codexSharedAuthPath }), codexHome: Schema.String, + cpuLimit: Schema.optionalWith(Schema.String, { + default: () => defaultTemplateConfig.cpuLimit + }), + ramLimit: Schema.optionalWith(Schema.String, { + default: () => defaultTemplateConfig.ramLimit + }), dockerNetworkMode: Schema.optionalWith(Schema.Literal("shared", "project"), { default: () => defaultTemplateConfig.dockerNetworkMode }), diff --git a/packages/lib/src/shell/files.ts b/packages/lib/src/shell/files.ts index dfa2f470..c527eef4 100644 --- a/packages/lib/src/shell/files.ts +++ b/packages/lib/src/shell/files.ts @@ -4,6 +4,7 @@ import type * as Path from "@effect/platform/Path" import { Effect, Match } from "effect" import { type TemplateConfig } from "../core/domain.js" +import { resolveComposeResourceLimits, withDefaultResourceLimitIntent } from "../core/resource-limits.js" import { type FileSpec, planFiles } from "../core/templates.js" import { FileExistsError } from "./errors.js" import { resolveBaseDir } from "./paths.js" @@ -11,6 +12,28 @@ import { resolveBaseDir } from "./paths.js" const ensureParentDir = (path: Path.Path, fs: FileSystem.FileSystem, filePath: string) => fs.makeDirectory(path.dirname(filePath), { recursive: true }) +const fallbackHostResources = { + cpuCount: 1, + totalMemoryBytes: 1024 ** 3 +} + +const loadHostResources = (): Effect.Effect< + { readonly cpuCount: number; readonly totalMemoryBytes: number } +> => + Effect.tryPromise({ + try: () => + import("node:os").then((os) => ({ + cpuCount: os.availableParallelism(), + totalMemoryBytes: os.totalmem() + })), + catch: (error) => new Error(String(error)) + }).pipe( + Effect.match({ + onFailure: () => fallbackHostResources, + onSuccess: (value) => value + }) + ) + const isFileSpec = (spec: FileSpec): spec is Extract => spec._tag === "File" const resolveSpecPath = ( @@ -104,7 +127,10 @@ export const writeProjectFiles = ( yield* _(fs.makeDirectory(baseDir, { recursive: true })) - const specs = planFiles(config) + const normalizedConfig = withDefaultResourceLimitIntent(config) + const hostResources = yield* _(loadHostResources()) + const composeResourceLimits = resolveComposeResourceLimits(normalizedConfig, hostResources) + const specs = planFiles(normalizedConfig, composeResourceLimits) const created: Array = [] const existingFilePaths = force ? [] : yield* _(collectExistingFilePaths(fs, path, baseDir, specs)) const existingSet = new Set(existingFilePaths) diff --git a/packages/lib/src/usecases/actions/create-project.ts b/packages/lib/src/usecases/actions/create-project.ts index 5f990fe6..dc61b555 100644 --- a/packages/lib/src/usecases/actions/create-project.ts +++ b/packages/lib/src/usecases/actions/create-project.ts @@ -24,6 +24,7 @@ import { applyGithubForkConfig } from "../github-fork.js" import { defaultProjectsRoot } from "../menu-helpers.js" import { findSshPrivateKey } from "../path-helpers.js" import { buildSshCommand } from "../projects-core.js" +import { resolveTemplateResourceLimits } from "../resource-limits.js" import { autoSyncState } from "../state-repo.js" import { ensureTerminalCursorVisible } from "../terminal-cursor.js" import { runDockerDownCleanup, runDockerUpIfNeeded } from "./docker-up.js" @@ -74,7 +75,8 @@ const resolveCreateConfig = ( FileSystem.FileSystem | Path.Path | CommandExecutor.CommandExecutor > => resolveSshPort(resolveRootedConfig(command, ctx), resolvedOutDir).pipe( - Effect.flatMap((config) => applyGithubForkConfig(config)) + Effect.flatMap((config) => applyGithubForkConfig(config)), + Effect.flatMap((config) => resolveTemplateResourceLimits(config)) ) const logCreatedProject = (resolvedOutDir: string, createdFiles: ReadonlyArray) => diff --git a/packages/lib/src/usecases/apply-overrides.ts b/packages/lib/src/usecases/apply-overrides.ts index 86a3adf5..e330938a 100644 --- a/packages/lib/src/usecases/apply-overrides.ts +++ b/packages/lib/src/usecases/apply-overrides.ts @@ -5,6 +5,8 @@ export const hasApplyOverrides = (command: ApplyCommand): boolean => command.gitTokenLabel !== undefined || command.codexTokenLabel !== undefined || command.claudeTokenLabel !== undefined || + command.cpuLimit !== undefined || + command.ramLimit !== undefined || command.enableMcpPlaywright !== undefined export const applyTemplateOverrides = ( @@ -35,6 +37,18 @@ export const applyTemplateOverrides = ( claudeAuthLabel: normalizeAuthLabel(command.claudeTokenLabel) } } + if (command.cpuLimit !== undefined) { + nextTemplate = { + ...nextTemplate, + cpuLimit: command.cpuLimit + } + } + if (command.ramLimit !== undefined) { + nextTemplate = { + ...nextTemplate, + ramLimit: command.ramLimit + } + } if (command.enableMcpPlaywright !== undefined) { nextTemplate = { ...nextTemplate, diff --git a/packages/lib/src/usecases/apply-project-discovery.ts b/packages/lib/src/usecases/apply-project-discovery.ts new file mode 100644 index 00000000..c775409f --- /dev/null +++ b/packages/lib/src/usecases/apply-project-discovery.ts @@ -0,0 +1,210 @@ +import type { CommandExecutor } from "@effect/platform/CommandExecutor" +import type { PlatformError } from "@effect/platform/Error" +import type { FileSystem } from "@effect/platform/FileSystem" +import type { Path } from "@effect/platform/Path" +import { Effect } from "effect" + +import { deriveRepoPathParts } from "../core/domain.js" +import { parseGithubRepoUrl } from "../core/repo.js" +import { runCommandCapture, runCommandExitCode } from "../shell/command-runner.js" +import { readProjectConfig } from "../shell/config.js" +import { resolveBaseDir } from "../shell/paths.js" +import { findDockerGitConfigPaths } from "./docker-git-config-search.js" + +export type RepoIdentity = { + readonly fullPath: string + readonly repo: string +} + +export type ProjectCandidate = { + readonly projectDir: string + readonly repoUrl: string + readonly repoRef: string +} + +const gitSuccessExitCode = 0 +const gitBaseEnv: Readonly> = { + GIT_TERMINAL_PROMPT: "0" +} + +const emptyConfigPaths = (): ReadonlyArray => [] +const nullProjectCandidate = (): ProjectCandidate | null => null +const nullString = (): string | null => null + +export const normalizeRepoIdentity = (repoUrl: string): RepoIdentity => { + const github = parseGithubRepoUrl(repoUrl) + if (github !== null) { + const owner = github.owner.trim().toLowerCase() + const repo = github.repo.trim().toLowerCase() + return { fullPath: `${owner}/${repo}`, repo } + } + + const parts = deriveRepoPathParts(repoUrl) + const normalizedParts = parts.pathParts.map((part) => part.toLowerCase()) + const repo = parts.repo.toLowerCase() + return { + fullPath: normalizedParts.join("/"), + repo + } +} + +const toProjectDirBaseName = (projectDir: string): string => { + const normalized = projectDir.replaceAll("\\", "/") + const parts = normalized.split("/").filter((part) => part.length > 0) + return parts.at(-1)?.toLowerCase() ?? "" +} + +const parsePrRefFromBranch = (branch: string): string | null => { + const prefix = "pr-" + if (!branch.toLowerCase().startsWith(prefix)) { + return null + } + const id = branch.slice(prefix.length).trim() + return id.length > 0 ? `refs/pull/${id}/head` : null +} + +const scoreBranchMatch = ( + branch: string | null, + candidate: ProjectCandidate +): number => { + if (branch === null) { + return 0 + } + + const branchLower = branch.toLowerCase() + const candidateRef = candidate.repoRef.toLowerCase() + const prRef = parsePrRefFromBranch(branchLower) + const branchRefScore = candidateRef === branchLower ? 8 : 0 + const prRefScore = prRef !== null && candidateRef === prRef.toLowerCase() ? 8 : 0 + const dirNameScore = toProjectDirBaseName(candidate.projectDir) === branchLower ? 5 : 0 + return branchRefScore + prRefScore + dirNameScore +} + +const scoreCandidate = ( + remoteIdentities: ReadonlyArray, + branch: string | null, + candidate: ProjectCandidate +): number => { + const candidateIdentity = normalizeRepoIdentity(candidate.repoUrl) + const hasFullPathMatch = remoteIdentities.some((remote) => remote.fullPath === candidateIdentity.fullPath) + const hasRepoMatch = remoteIdentities.some((remote) => remote.repo === candidateIdentity.repo) + if (!hasFullPathMatch && !hasRepoMatch) { + return 0 + } + + const repoScore = hasFullPathMatch ? 100 : 10 + return repoScore + scoreBranchMatch(branch, candidate) +} + +export const selectCandidateProjectDir = ( + remoteIdentities: ReadonlyArray, + branch: string | null, + candidates: ReadonlyArray +): string | null => { + const scored = candidates + .map((candidate) => ({ candidate, score: scoreCandidate(remoteIdentities, branch, candidate) })) + .filter((entry) => entry.score > 0) + + if (scored.length === 0) { + return null + } + + const topScore = Math.max(...scored.map((entry) => entry.score)) + const topCandidates = scored.filter((entry) => entry.score === topScore) + if (topCandidates.length !== 1) { + return null + } + + return topCandidates[0]?.candidate.projectDir ?? null +} + +const tryGitCapture = ( + cwd: string, + args: ReadonlyArray +): Effect.Effect => { + const spec = { cwd, command: "git", args, env: gitBaseEnv } + + return runCommandExitCode(spec).pipe( + Effect.matchEffect({ + onFailure: () => Effect.succeed(null), + onSuccess: (exitCode) => + exitCode === gitSuccessExitCode + ? runCommandCapture(spec, [gitSuccessExitCode], (code) => ({ _tag: "ApplyGitCaptureError", code })).pipe( + Effect.map((value) => value.trim()), + Effect.match({ + onFailure: nullString, + onSuccess: (value) => value + }) + ) + : Effect.succeed(null) + }) + ) +} + +export const listProjectCandidates = ( + projectsRoot: string +): Effect.Effect, PlatformError, FileSystem | Path> => + Effect.gen(function*(_) { + const { fs, path, resolved } = yield* _(resolveBaseDir(projectsRoot)) + const configPaths = yield* _( + findDockerGitConfigPaths(fs, path, resolved).pipe( + Effect.match({ + onFailure: emptyConfigPaths, + onSuccess: (value) => value + }) + ) + ) + + const candidates: Array = [] + for (const configPath of configPaths) { + const projectDir = path.dirname(configPath) + const candidate = yield* _( + readProjectConfig(projectDir).pipe( + Effect.match({ + onFailure: nullProjectCandidate, + onSuccess: (config) => ({ + projectDir, + repoUrl: config.template.repoUrl, + repoRef: config.template.repoRef + }) + }) + ) + ) + if (candidate !== null) { + candidates.push(candidate) + } + } + + return candidates + }) + +export const collectRemoteIdentities = ( + repoRoot: string +): Effect.Effect, never, CommandExecutor> => + Effect.gen(function*(_) { + const listedRemotes = yield* _(tryGitCapture(repoRoot, ["remote"])) + const dynamicNames = listedRemotes === null + ? [] + : listedRemotes + .split(/\r?\n/) + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0) + const remoteNames = [...new Set([...dynamicNames, "origin", "upstream"])] + const urls: Array = [] + + for (const remoteName of remoteNames) { + const url = yield* _(tryGitCapture(repoRoot, ["remote", "get-url", remoteName])) + if (url !== null && url.length > 0) { + urls.push(url) + } + } + + const identityMap = new Map() + for (const url of urls) { + const identity = normalizeRepoIdentity(url) + identityMap.set(`${identity.fullPath}|${identity.repo}`, identity) + } + return [...identityMap.values()] + }) + +export const gitCapture = tryGitCapture diff --git a/packages/lib/src/usecases/apply.ts b/packages/lib/src/usecases/apply.ts index ec8b20a7..4aabcfd5 100644 --- a/packages/lib/src/usecases/apply.ts +++ b/packages/lib/src/usecases/apply.ts @@ -4,19 +4,23 @@ import type { FileSystem } from "@effect/platform/FileSystem" import type { Path } from "@effect/platform/Path" import { Effect } from "effect" -import { type ApplyCommand, deriveRepoPathParts, type TemplateConfig } from "../core/domain.js" -import { parseGithubRepoUrl } from "../core/repo.js" -import { runCommandCapture, runCommandExitCode } from "../shell/command-runner.js" +import { type ApplyCommand, type TemplateConfig } from "../core/domain.js" import { readProjectConfig } from "../shell/config.js" import { ensureDockerDaemonAccess } from "../shell/docker.js" import type * as ShellErrors from "../shell/errors.js" import { writeProjectFiles } from "../shell/files.js" import { resolveBaseDir } from "../shell/paths.js" import { applyTemplateOverrides, hasApplyOverrides } from "./apply-overrides.js" +import { + collectRemoteIdentities, + gitCapture, + listProjectCandidates, + selectCandidateProjectDir +} from "./apply-project-discovery.js" import { ensureClaudeAuthSeedFromHome, ensureCodexConfigFile } from "./auth-sync.js" -import { findDockerGitConfigPaths } from "./docker-git-config-search.js" import { defaultProjectsRoot, findExistingUpwards } from "./path-helpers.js" import { runDockerComposeUpWithPortCheck } from "./projects-up.js" +import { resolveTemplateResourceLimits } from "./resource-limits.js" type ApplyProjectFilesError = | ShellErrors.ConfigNotFoundError @@ -42,7 +46,9 @@ export const applyProjectFiles = ( Effect.gen(function*(_) { yield* _(Effect.log(`Applying docker-git config files in ${projectDir}...`)) const config = yield* _(readProjectConfig(projectDir)) - const resolvedTemplate = applyTemplateOverrides(config.template, command) + const resolvedTemplate = yield* _( + resolveTemplateResourceLimits(applyTemplateOverrides(config.template, command)) + ) yield* _(writeProjectFiles(projectDir, resolvedTemplate, true)) yield* _(ensureCodexConfigFile(projectDir, resolvedTemplate.codexAuthPath)) yield* _(ensureClaudeAuthSeedFromHome(defaultProjectsRoot(projectDir), ".orch/auth/claude")) @@ -57,204 +63,10 @@ export type ApplyProjectConfigError = type ApplyProjectConfigEnv = ApplyProjectFilesEnv | CommandExecutor -type RepoIdentity = { - readonly fullPath: string - readonly repo: string -} - -type ProjectCandidate = { - readonly projectDir: string - readonly repoUrl: string - readonly repoRef: string -} - -const gitSuccessExitCode = 0 const gitBranchDetached = "HEAD" const maxLocalConfigSearchDepth = 6 -const gitBaseEnv: Readonly> = { - GIT_TERMINAL_PROMPT: "0" -} - -const emptyConfigPaths = (): ReadonlyArray => [] -const nullProjectCandidate = (): ProjectCandidate | null => null const nullString = (): string | null => null -const normalizeRepoIdentity = (repoUrl: string): RepoIdentity => { - const github = parseGithubRepoUrl(repoUrl) - if (github !== null) { - const owner = github.owner.trim().toLowerCase() - const repo = github.repo.trim().toLowerCase() - return { fullPath: `${owner}/${repo}`, repo } - } - - const parts = deriveRepoPathParts(repoUrl) - const normalizedParts = parts.pathParts.map((part) => part.toLowerCase()) - const repo = parts.repo.toLowerCase() - return { - fullPath: normalizedParts.join("/"), - repo - } -} - -const toProjectDirBaseName = (projectDir: string): string => { - const normalized = projectDir.replaceAll("\\", "/") - const parts = normalized.split("/").filter((part) => part.length > 0) - return parts.at(-1)?.toLowerCase() ?? "" -} - -const parsePrRefFromBranch = (branch: string): string | null => { - const prefix = "pr-" - if (!branch.toLowerCase().startsWith(prefix)) { - return null - } - const id = branch.slice(prefix.length).trim() - return id.length > 0 ? `refs/pull/${id}/head` : null -} - -const scoreBranchMatch = ( - branch: string | null, - candidate: ProjectCandidate -): number => { - if (branch === null) { - return 0 - } - - const branchLower = branch.toLowerCase() - const candidateRef = candidate.repoRef.toLowerCase() - const prRef = parsePrRefFromBranch(branchLower) - const branchRefScore = candidateRef === branchLower ? 8 : 0 - const prRefScore = prRef !== null && candidateRef === prRef.toLowerCase() ? 8 : 0 - const dirNameScore = toProjectDirBaseName(candidate.projectDir) === branchLower ? 5 : 0 - return branchRefScore + prRefScore + dirNameScore -} - -const scoreCandidate = ( - remoteIdentities: ReadonlyArray, - branch: string | null, - candidate: ProjectCandidate -): number => { - const candidateIdentity = normalizeRepoIdentity(candidate.repoUrl) - const hasFullPathMatch = remoteIdentities.some((remote) => remote.fullPath === candidateIdentity.fullPath) - const hasRepoMatch = remoteIdentities.some((remote) => remote.repo === candidateIdentity.repo) - if (!hasFullPathMatch && !hasRepoMatch) { - return 0 - } - - const repoScore = hasFullPathMatch ? 100 : 10 - return repoScore + scoreBranchMatch(branch, candidate) -} - -const selectCandidateProjectDir = ( - remoteIdentities: ReadonlyArray, - branch: string | null, - candidates: ReadonlyArray -): string | null => { - const scored = candidates - .map((candidate) => ({ candidate, score: scoreCandidate(remoteIdentities, branch, candidate) })) - .filter((entry) => entry.score > 0) - - if (scored.length === 0) { - return null - } - - const topScore = Math.max(...scored.map((entry) => entry.score)) - const topCandidates = scored.filter((entry) => entry.score === topScore) - if (topCandidates.length !== 1) { - return null - } - - return topCandidates[0]?.candidate.projectDir ?? null -} - -const tryGitCapture = ( - cwd: string, - args: ReadonlyArray -): Effect.Effect => { - const spec = { cwd, command: "git", args, env: gitBaseEnv } - - return runCommandExitCode(spec).pipe( - Effect.matchEffect({ - onFailure: () => Effect.succeed(null), - onSuccess: (exitCode) => - exitCode === gitSuccessExitCode - ? runCommandCapture(spec, [gitSuccessExitCode], (code) => ({ _tag: "ApplyGitCaptureError", code })).pipe( - Effect.map((value) => value.trim()), - Effect.match({ - onFailure: nullString, - onSuccess: (value) => value - }) - ) - : Effect.succeed(null) - }) - ) -} - -const listProjectCandidates = ( - projectsRoot: string -): Effect.Effect, PlatformError, ApplyProjectFilesEnv> => - Effect.gen(function*(_) { - const { fs, path, resolved } = yield* _(resolveBaseDir(projectsRoot)) - const configPaths = yield* _( - findDockerGitConfigPaths(fs, path, resolved).pipe( - Effect.match({ - onFailure: emptyConfigPaths, - onSuccess: (value) => value - }) - ) - ) - - const candidates: Array = [] - for (const configPath of configPaths) { - const projectDir = path.dirname(configPath) - const candidate = yield* _( - readProjectConfig(projectDir).pipe( - Effect.match({ - onFailure: nullProjectCandidate, - onSuccess: (config) => ({ - projectDir, - repoUrl: config.template.repoUrl, - repoRef: config.template.repoRef - }) - }) - ) - ) - if (candidate !== null) { - candidates.push(candidate) - } - } - - return candidates - }) - -const collectRemoteIdentities = ( - repoRoot: string -): Effect.Effect, never, CommandExecutor> => - Effect.gen(function*(_) { - const listedRemotes = yield* _(tryGitCapture(repoRoot, ["remote"])) - const dynamicNames = listedRemotes === null - ? [] - : listedRemotes - .split(/\r?\n/) - .map((entry) => entry.trim()) - .filter((entry) => entry.length > 0) - const remoteNames = [...new Set([...dynamicNames, "origin", "upstream"])] - const urls: Array = [] - - for (const remoteName of remoteNames) { - const url = yield* _(tryGitCapture(repoRoot, ["remote", "get-url", remoteName])) - if (url !== null && url.length > 0) { - urls.push(url) - } - } - - const identityMap = new Map() - for (const url of urls) { - const identity = normalizeRepoIdentity(url) - identityMap.set(`${identity.fullPath}|${identity.repo}`, identity) - } - return [...identityMap.values()] - }) - const resolveFromCurrentTree = (): Effect.Effect => Effect.gen(function*(_) { const { fs, path, resolved } = yield* _(resolveBaseDir(".")) @@ -280,7 +92,7 @@ const normalizeBranch = (branch: string | null): string | null => { const resolveFromCurrentRepository = (): Effect.Effect => Effect.gen(function*(_) { const cwd = process.cwd() - const repoRoot = yield* _(tryGitCapture(cwd, ["rev-parse", "--show-toplevel"])) + const repoRoot = yield* _(gitCapture(cwd, ["rev-parse", "--show-toplevel"])) if (repoRoot === null) { return null } @@ -290,7 +102,7 @@ const resolveFromCurrentRepository = (): Effect.Effect runDockerInspectContainerBridgeIp(projectDir, containerName).pipe( @@ -210,10 +212,10 @@ export const runDockerComposeUpWithPortCheck = ( }) ) - yield* _(ensureBridgeAccess(updated.containerName)) - if (updated.enableMcpPlaywright) { - yield* _(ensureBridgeAccess(`${updated.containerName}-browser`)) + yield* _(ensureBridgeAccess(resolvedTemplate.containerName)) + if (resolvedTemplate.enableMcpPlaywright) { + yield* _(ensureBridgeAccess(`${resolvedTemplate.containerName}-browser`)) } - return updated + return resolvedTemplate }) diff --git a/packages/lib/src/usecases/resource-limits.ts b/packages/lib/src/usecases/resource-limits.ts new file mode 100644 index 00000000..83481b65 --- /dev/null +++ b/packages/lib/src/usecases/resource-limits.ts @@ -0,0 +1,18 @@ +import { Effect } from "effect" + +import type { TemplateConfig } from "../core/domain.js" +import { withDefaultResourceLimitIntent } from "../core/resource-limits.js" + +// CHANGE: backfill default resource limit intent for projects that do not specify it. +// WHY: docker-git should persist the safe 30% CPU/RAM default unless the user overrides it. +// QUOTE(ТЗ): "надо поставить лимит что если контейнер жрёт под максимум то не забивает всё" +// REF: issue-135 +// SOURCE: n/a +// FORMAT THEOREM: forall t: missing_limits(t) -> default_intent(resolve(t)) +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: explicit user limits always win over derived defaults +// COMPLEXITY: O(1) +export const resolveTemplateResourceLimits = ( + template: TemplateConfig +): Effect.Effect => Effect.succeed(withDefaultResourceLimitIntent(template)) diff --git a/packages/lib/tests/core/resource-limits.test.ts b/packages/lib/tests/core/resource-limits.test.ts new file mode 100644 index 00000000..992643c4 --- /dev/null +++ b/packages/lib/tests/core/resource-limits.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "@effect/vitest" + +import { + resolveComposeResourceLimits, + withDefaultResourceLimitIntent +} from "../../src/core/resource-limits.js" +import { defaultTemplateConfig, type TemplateConfig } from "../../src/core/domain.js" + +const makeTemplate = (): TemplateConfig => ({ + ...defaultTemplateConfig, + repoUrl: "https://github.com/org/repo.git" +}) + +describe("withDefaultResourceLimitIntent", () => { + it("fills missing limit intent with 30%", () => { + const resolved = withDefaultResourceLimitIntent(makeTemplate()) + + expect(resolved.cpuLimit).toBe("30%") + expect(resolved.ramLimit).toBe("30%") + }) + + it("preserves explicit limit intent", () => { + const resolved = withDefaultResourceLimitIntent({ + ...makeTemplate(), + cpuLimit: "1.25", + ramLimit: "3g" + }) + + expect(resolved.cpuLimit).toBe("1.25") + expect(resolved.ramLimit).toBe("3g") + }) +}) + +describe("resolveComposeResourceLimits", () => { + it("resolves percent intent against host capacity", () => { + const resolved = resolveComposeResourceLimits( + { + cpuLimit: "30%", + ramLimit: "30%" + }, + { + cpuCount: 8, + totalMemoryBytes: 16 * 1024 ** 3 + } + ) + + expect(resolved.cpuLimit).toBe(2.4) + expect(resolved.ramLimit).toBe("4915m") + }) + + it("applies minimum caps for small hosts", () => { + const resolved = resolveComposeResourceLimits( + { + cpuLimit: "30%", + ramLimit: "30%" + }, + { + cpuCount: 1, + totalMemoryBytes: 1024 ** 3 + } + ) + + expect(resolved.cpuLimit).toBe(0.3) + expect(resolved.ramLimit).toBe("512m") + }) + + it("keeps absolute intent as-is", () => { + const resolved = resolveComposeResourceLimits( + { + cpuLimit: "1.25", + ramLimit: "3g" + }, + { + cpuCount: 32, + totalMemoryBytes: 64 * 1024 ** 3 + } + ) + + expect(resolved.cpuLimit).toBe(1.25) + expect(resolved.ramLimit).toBe("3g") + }) +}) diff --git a/packages/lib/tests/usecases/apply.test.ts b/packages/lib/tests/usecases/apply.test.ts index fde16c87..b2f63353 100644 --- a/packages/lib/tests/usecases/apply.test.ts +++ b/packages/lib/tests/usecases/apply.test.ts @@ -145,9 +145,17 @@ describe("applyProjectFiles", () => { const appliedTemplate = yield* _(applyProjectFiles(outDir)) expect(appliedTemplate.targetDir).toBe(updatedTargetDir) + expect(appliedTemplate.cpuLimit).toBe("30%") + expect(appliedTemplate.ramLimit).toBe("30%") const composeAfter = yield* _(fs.readFileString(path.join(outDir, "docker-compose.yml"))) expect(composeAfter).toContain(`TARGET_DIR: "${updatedTargetDir}"`) + expect(composeAfter).toContain("cpus:") + expect(composeAfter).toContain('mem_limit: "') + + const configAfter = yield* _(fs.readFileString(configPath)) + expect(configAfter).toContain('"cpuLimit": "30%"') + expect(configAfter).toContain('"ramLimit": "30%"') const dockerfileAfter = yield* _(fs.readFileString(path.join(outDir, "Dockerfile"))) expect(dockerfileAfter).toContain(`RUN mkdir -p ${updatedTargetDir}`) @@ -182,12 +190,16 @@ describe("applyProjectFiles", () => { gitTokenLabel: "agien_main", codexTokenLabel: "Team A", claudeTokenLabel: "Team B", + cpuLimit: "2", + ramLimit: "4g", enableMcpPlaywright: true }) ) expect(appliedTemplate.gitTokenLabel).toBe("AGIEN_MAIN") expect(appliedTemplate.codexAuthLabel).toBe("team-a") expect(appliedTemplate.claudeAuthLabel).toBe("team-b") + expect(appliedTemplate.cpuLimit).toBe("2") + expect(appliedTemplate.ramLimit).toBe("4g") expect(appliedTemplate.enableMcpPlaywright).toBe(true) const composeAfter = yield* _(fs.readFileString(path.join(outDir, "docker-compose.yml"))) @@ -195,8 +207,15 @@ describe("applyProjectFiles", () => { expect(composeAfter).toContain('GIT_AUTH_LABEL: "AGIEN_MAIN"') expect(composeAfter).toContain('CODEX_AUTH_LABEL: "team-a"') expect(composeAfter).toContain('CLAUDE_AUTH_LABEL: "team-b"') + expect(composeAfter).toContain("cpus: 2") + expect(composeAfter).toContain('mem_limit: "4g"') + expect(composeAfter).toContain('memswap_limit: "4g"') expect(composeAfter).toContain('MCP_PLAYWRIGHT_ENABLE: "1"') expect(composeAfter).toContain("dg-test-browser") + + const configAfter = yield* _(fs.readFileString(path.join(outDir, "docker-git.json"))) + expect(configAfter).toContain('"cpuLimit": "2"') + expect(configAfter).toContain('"ramLimit": "4g"') }) ).pipe(Effect.provide(NodeContext.layer))) }) diff --git a/packages/lib/tests/usecases/prepare-files.test.ts b/packages/lib/tests/usecases/prepare-files.test.ts index 99e13c96..1c8df2ae 100644 --- a/packages/lib/tests/usecases/prepare-files.test.ts +++ b/packages/lib/tests/usecases/prepare-files.test.ts @@ -150,6 +150,8 @@ describe("prepareProjectFiles", () => { expect(composeBefore).toContain("container_name: dg-test") expect(composeBefore).toContain("restart: unless-stopped") expect(composeBefore).toContain(":/home/dev/.docker-git") + expect(composeBefore).toContain("cpus:") + expect(composeBefore).toContain('mem_limit: "') expect(composeBefore).not.toContain("dg-test-browser") expect(composeBefore).toContain("docker-git-shared") expect(composeBefore).toContain("external: true") @@ -177,6 +179,8 @@ describe("prepareProjectFiles", () => { expect(composeAfter).toContain("docker-git-shared") expect(composeAfter).toContain("external: true") expect(readEnableMcpPlaywrightFlag(configAfter)).toBe(true) + expect(configAfterText).toContain('"cpuLimit": "30%"') + expect(configAfterText).toContain('"ramLimit": "30%"') }) ).pipe(Effect.provide(NodeContext.layer))) diff --git a/packages/lib/tests/usecases/projects-up.test.ts b/packages/lib/tests/usecases/projects-up.test.ts index 7ec589f5..6d477d1f 100644 --- a/packages/lib/tests/usecases/projects-up.test.ts +++ b/packages/lib/tests/usecases/projects-up.test.ts @@ -187,10 +187,18 @@ describe("runDockerComposeUpWithPortCheck", () => { ) expect(updated.targetDir).toBe(updatedTargetDir) + expect(updated.cpuLimit).toBe("30%") + expect(updated.ramLimit).toBe("30%") const composeAfter = yield* _(fs.readFileString(path.join(outDir, "docker-compose.yml"))) expect(composeAfter).toContain(`TARGET_DIR: "${updatedTargetDir}"`) expect(composeAfter).not.toContain("# stale compose") + expect(composeAfter).toContain("cpus:") + expect(composeAfter).toContain('mem_limit: "') + + const configAfter = yield* _(fs.readFileString(path.join(outDir, "docker-git.json"))) + expect(configAfter).toContain('"cpuLimit": "30%"') + expect(configAfter).toContain('"ramLimit": "30%"') expect(recorded.some((entry) => isDockerComposePsFormatted(entry))).toBe(true) expect(recorded.some((entry) => isDockerComposeUp(entry))).toBe(true)