diff --git a/packages/api/src/api/contracts.ts b/packages/api/src/api/contracts.ts index 1d37466..b1e5ee2 100644 --- a/packages/api/src/api/contracts.ts +++ b/packages/api/src/api/contracts.ts @@ -282,6 +282,32 @@ export type FederationInboxResult = readonly subscription: FollowSubscription } +// CHANGE: add account pool API types for multi-account management +// WHY: expose account pool CRUD and status via the API +// QUOTE(ТЗ): "Сделать возможность регистрировать много аккаунтов" +// REF: issue-213 +// PURITY: CORE +// COMPLEXITY: O(1) +export type AccountPoolProvider = "claude" | "codex" | "gemini" + +export type AddAccountRequest = { + readonly provider: AccountPoolProvider + readonly label: string +} + +export type RemoveAccountRequest = { + readonly provider: AccountPoolProvider + readonly label: string +} + +export type AccountPoolSummary = { + readonly provider: AccountPoolProvider + readonly total: number + readonly available: number + readonly coolingDown: number + readonly activeLabel: string | undefined +} + export type ApiEventType = | "snapshot" | "project.created" diff --git a/packages/api/src/api/schema.ts b/packages/api/src/api/schema.ts index 4bbb837..aad82b1 100644 --- a/packages/api/src/api/schema.ts +++ b/packages/api/src/api/schema.ts @@ -98,6 +98,26 @@ export const CreateAgentRequestSchema = Schema.Struct({ label: OptionalString }) +// CHANGE: add account pool request schemas +// WHY: validate API requests for multi-account pool management +// REF: issue-213 +// PURITY: CORE +// COMPLEXITY: O(1) +export const AccountPoolProviderSchema = Schema.Literal("claude", "codex", "gemini") + +export const AddAccountRequestSchema = Schema.Struct({ + provider: AccountPoolProviderSchema, + label: Schema.String +}) + +export const RemoveAccountRequestSchema = Schema.Struct({ + provider: AccountPoolProviderSchema, + label: Schema.String +}) + +export type AddAccountRequestInput = Schema.Schema.Type +export type RemoveAccountRequestInput = Schema.Schema.Type + export const CreateFollowRequestSchema = Schema.Struct({ actor: OptionalString, object: Schema.String, diff --git a/packages/api/src/http.ts b/packages/api/src/http.ts index 158dcfa..2d2ec3a 100644 --- a/packages/api/src/http.ts +++ b/packages/api/src/http.ts @@ -12,6 +12,7 @@ import { renderError, type AppError } from "@effect-template/lib/usecases/errors import { ApiAuthRequiredError, ApiBadRequestError, ApiConflictError, ApiInternalError, ApiNotFoundError, describeUnknown } from "./api/errors.js" import { + AddAccountRequestSchema, ApplyAllRequestSchema, CodexAuthImportRequestSchema, CodexAuthLoginRequestSchema, @@ -21,6 +22,7 @@ import { CreateProjectRequestSchema, GithubAuthLoginRequestSchema, GithubAuthLogoutRequestSchema, + RemoveAccountRequestSchema, StateCommitRequestSchema, StateInitRequestSchema, StateSyncRequestSchema, @@ -36,6 +38,15 @@ import { readGithubAuthStatus } from "./services/auth.js" import { streamCodexAuthLogin } from "./services/auth-codex-login-stream.js" +import { + addPoolAccount, + clearAccountCooldown, + getPoolSummary, + listAllPoolAccounts, + listPoolAccounts, + removePoolAccount, + selectNextPoolAccount +} from "./services/account-pool.js" import { getAgent, getAgentAttachInfo, listAgents, readAgentLogs, startAgent, stopAgent } from "./services/agents.js" import { latestProjectCursor, listProjectEventsSince } from "./services/events.js" import { @@ -415,7 +426,63 @@ export const makeRouter = () => { ) ) - const withState = base.pipe( + const withAccountPool = base.pipe( + HttpRouter.get( + "/account-pool", + Effect.sync(() => ({ accounts: listAllPoolAccounts() })).pipe( + Effect.flatMap((payload) => jsonResponse(payload, 200)), + Effect.catchAll(errorResponse) + ) + ), + HttpRouter.get( + "/account-pool/:provider", + HttpRouter.schemaParams(Schema.Struct({ provider: Schema.Literal("claude", "codex", "gemini") })).pipe( + Effect.flatMap(({ provider }) => + jsonResponse({ + accounts: listPoolAccounts(provider), + summary: getPoolSummary(provider) + }, 200) + ), + Effect.catchAll(errorResponse) + ) + ), + HttpRouter.post( + "/account-pool/add", + Effect.gen(function*(_) { + const request = yield* _(HttpServerRequest.schemaBodyJson(AddAccountRequestSchema)) + const state = addPoolAccount(request.provider, request.label) + return yield* _(jsonResponse({ ok: true, state }, 201)) + }).pipe(Effect.catchAll(errorResponse)) + ), + HttpRouter.post( + "/account-pool/remove", + Effect.gen(function*(_) { + const request = yield* _(HttpServerRequest.schemaBodyJson(RemoveAccountRequestSchema)) + const state = removePoolAccount(request.provider, request.label) + return yield* _(jsonResponse({ ok: true, state }, 200)) + }).pipe(Effect.catchAll(errorResponse)) + ), + HttpRouter.post( + "/account-pool/next", + Effect.gen(function*(_) { + const request = yield* _(HttpServerRequest.schemaBodyJson( + Schema.Struct({ provider: Schema.Literal("claude", "codex", "gemini") }) + )) + const account = selectNextPoolAccount(request.provider) + return yield* _(jsonResponse({ account: account ?? null }, 200)) + }).pipe(Effect.catchAll(errorResponse)) + ), + HttpRouter.post( + "/account-pool/clear-cooldown", + Effect.gen(function*(_) { + const request = yield* _(HttpServerRequest.schemaBodyJson(RemoveAccountRequestSchema)) + const state = clearAccountCooldown(request.provider, request.label) + return yield* _(jsonResponse({ ok: true, state }, 200)) + }).pipe(Effect.catchAll(errorResponse)) + ) + ) + + const withState = withAccountPool.pipe( HttpRouter.get( "/state/path", readStatePathOutput().pipe( diff --git a/packages/api/src/program.ts b/packages/api/src/program.ts index 981b59f..61dea9b 100644 --- a/packages/api/src/program.ts +++ b/packages/api/src/program.ts @@ -4,6 +4,7 @@ import { Console, Effect, Layer, Option } from "effect" import { createServer } from "node:http" import { makeRouter } from "./http.js" +import { initializeAccountPool } from "./services/account-pool.js" import { initializeAgentState } from "./services/agents.js" import { startOutboxPolling } from "./services/federation.js" @@ -49,6 +50,11 @@ export const program = (() => { return Effect.scoped( Console.log(`docker-git api boot port=${port}`).pipe( Effect.zipRight(initializeAgentState()), + Effect.zipRight( + Effect.tryPromise({ try: () => initializeAccountPool(), catch: () => new Error("account pool init failed") }).pipe( + Effect.catchAll(() => Effect.void) + ) + ), Effect.zipRight( Console.log(`docker-git outbox polling interval=${pollingInterval}ms`) ), diff --git a/packages/api/src/services/account-pool.ts b/packages/api/src/services/account-pool.ts new file mode 100644 index 0000000..ce02e6d --- /dev/null +++ b/packages/api/src/services/account-pool.ts @@ -0,0 +1,162 @@ +// CHANGE: add API-level account pool service with persistence and rate-limit monitoring +// WHY: enable automatic switching between registered accounts when one hits API rate limits +// QUOTE(ТЗ): "Сделать возможность регистрировать много аккаунтов codex, claude code и когда на одном лимиты закаончиваются он переходит на другой аккаунт" +// REF: issue-213 +// SOURCE: n/a +// FORMAT THEOREM: ∀op ∈ PoolOperation: op(state) → persist(nextState) ∧ consistent(nextState) +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: pool state is persisted to disk after every mutation; in-memory state is source of truth +// COMPLEXITY: O(n) per operation where n = total accounts + +import { defaultProjectsRoot } from "@effect-template/lib/usecases/path-helpers" +import type { + AccountPoolProvider, + AccountPoolState, + AccountEntry, + RateLimitEvent +} from "@effect-template/lib/core/account-pool-domain" +import { + addAccount, + removeAccount, + markRateLimited, + clearCooldown, + selectNextAvailable, + advanceActiveIndex, + listAccounts, + listAllAccounts, + poolSummary, + emptyPoolState +} from "@effect-template/lib/usecases/account-pool" +import { detectRateLimit } from "@effect-template/lib/usecases/rate-limit-detector" +import { promises as fs } from "node:fs" +import { join } from "node:path" + +let poolState: AccountPoolState = emptyPoolState(new Date().toISOString()) +let initialized = false + +const nowIso = (): string => new Date().toISOString() + +const stateFilePath = (): string => + join(defaultProjectsRoot(process.cwd()), ".orch", "state", "account-pool.json") + +const persistState = async (): Promise => { + const filePath = stateFilePath() + await fs.mkdir(join(filePath, ".."), { recursive: true }) + await fs.writeFile(filePath, JSON.stringify(poolState, null, 2), "utf8") +} + +const persistBestEffort = (): void => { + void persistState().catch(() => { + // best effort + }) +} + +export const initializeAccountPool = async (): Promise => { + if (initialized) { + return + } + + const filePath = stateFilePath() + const exists = await fs.stat(filePath).then(() => true).catch(() => false) + if (exists) { + const raw = await fs.readFile(filePath, "utf8") + const parsed = JSON.parse(raw) as AccountPoolState + poolState = { + pools: parsed.pools ?? [], + updatedAt: parsed.updatedAt ?? nowIso() + } + } + + initialized = true +} + +export const addPoolAccount = ( + provider: AccountPoolProvider, + label: string +): AccountPoolState => { + const now = nowIso() + poolState = addAccount(poolState, provider, label, now) + persistBestEffort() + return poolState +} + +export const removePoolAccount = ( + provider: AccountPoolProvider, + label: string +): AccountPoolState => { + const now = nowIso() + poolState = removeAccount(poolState, provider, label, now) + persistBestEffort() + return poolState +} + +export const markAccountRateLimited = ( + event: RateLimitEvent +): AccountPoolState => { + const now = nowIso() + poolState = markRateLimited(poolState, event, now) + persistBestEffort() + return poolState +} + +export const clearAccountCooldown = ( + provider: AccountPoolProvider, + label: string +): AccountPoolState => { + const now = nowIso() + poolState = clearCooldown(poolState, provider, label, now) + persistBestEffort() + return poolState +} + +export const selectNextPoolAccount = ( + provider: AccountPoolProvider +): AccountEntry | undefined => { + const now = nowIso() + const account = selectNextAvailable(poolState, provider, now) + if (account !== undefined) { + poolState = advanceActiveIndex(poolState, provider, now) + persistBestEffort() + } + return account +} + +export const listPoolAccounts = ( + provider: AccountPoolProvider +): ReadonlyArray => + listAccounts(poolState, provider) + +export const listAllPoolAccounts = (): ReadonlyArray => + listAllAccounts(poolState) + +export const getPoolSummary = ( + provider: AccountPoolProvider +): { + readonly total: number + readonly available: number + readonly coolingDown: number + readonly activeLabel: string | undefined +} => poolSummary(poolState, provider, nowIso()) + +export const getPoolState = (): AccountPoolState => poolState + +/** + * Check an agent output line for rate-limit signals. + * If a rate-limit is detected, marks the account as rate-limited + * and returns the event for the caller to act upon. + * + * @effect mutates poolState on detection + */ +export const checkLineForRateLimit = ( + provider: AccountPoolProvider, + label: string, + line: string +): RateLimitEvent | undefined => { + const now = nowIso() + const event = detectRateLimit(provider, label, line, now) + if (event !== undefined) { + markAccountRateLimited(event) + } + return event +} diff --git a/packages/api/src/services/agents.ts b/packages/api/src/services/agents.ts index f1b8f0c..393e4f9 100644 --- a/packages/api/src/services/agents.ts +++ b/packages/api/src/services/agents.ts @@ -7,6 +7,9 @@ import { promises as fs } from "node:fs" import { join } from "node:path" import { spawn, type ChildProcess } from "node:child_process" +import type { + AccountPoolProvider +} from "@effect-template/lib/core/account-pool-domain" import type { AgentLogLine, AgentSession, @@ -14,6 +17,7 @@ import type { ProjectDetails } from "../api/contracts.js" import { ApiBadRequestError, ApiConflictError, ApiNotFoundError } from "../api/errors.js" +import { checkLineForRateLimit, selectNextPoolAccount } from "./account-pool.js" import { emitProjectEvent } from "./events.js" type AgentRecord = { @@ -140,7 +144,8 @@ const updateSession = ( const appendLog = ( record: AgentRecord, stream: AgentLogLine["stream"], - line: string + line: string, + skipRateLimitCheck = false ): void => { const entry: AgentLogLine = { at: nowIso(), @@ -154,6 +159,12 @@ const appendLog = ( line, at: entry.at }) + + // Check for rate-limit signals (only on stderr where errors typically appear, + // and skip internal docker-git messages to avoid recursive detection) + if (!skipRateLimitCheck && stream === "stderr" && !line.startsWith("[docker-git]")) { + checkAndHandleRateLimit(record, line) + } } const flushRemainder = (record: AgentRecord, stream: AgentLogLine["stream"]): void => { @@ -192,6 +203,108 @@ const consumeChunk = ( } } +// CHANGE: track rate-limit restarts to prevent infinite loops +// WHY: an agent that keeps hitting rate limits on all accounts should eventually stop +// REF: issue-213 +// PURITY: SHELL (mutable state) +// INVARIANT: maxConsecutiveRestarts >= 1 +// COMPLEXITY: O(1) per check +const maxConsecutiveRestarts = 10 +const restartCounts: Map = new Map() +const restartingAgents: Set = new Set() + +const resolveProviderFromRequest = ( + provider: CreateAgentRequest["provider"] +): AccountPoolProvider | undefined => { + if (provider === "codex") { + return "codex" + } + if (provider === "claude") { + return "claude" + } + return undefined +} + +// CHANGE: check agent output line for rate-limit signals and trigger account switching +// WHY: detect rate-limit messages in real-time to switch to the next available account +// QUOTE(ТЗ): "когда на одном лимиты закаончиваются он переходит на другой аккаунт" +// REF: issue-213 +// PURITY: SHELL +// EFFECT: may mutate pool state and trigger agent restart +// INVARIANT: at most one restart per rate-limit event; bounded by maxConsecutiveRestarts +// COMPLEXITY: O(p) where p = number of rate-limit patterns +const checkAndHandleRateLimit = ( + record: AgentRecord, + line: string +): void => { + const poolProvider = resolveProviderFromRequest(record.session.provider) + if (poolProvider === undefined) { + return + } + + const event = checkLineForRateLimit(poolProvider, record.session.label, line) + if (event === undefined) { + return + } + + const agentKey = `${record.session.projectId}:${record.session.provider}` + const currentRestarts = restartCounts.get(agentKey) ?? 0 + + if (currentRestarts >= maxConsecutiveRestarts) { + appendLog(record, "stderr", `[docker-git] rate-limit failover exhausted after ${currentRestarts} restarts`, true) + emitProjectEvent(record.session.projectId, "agent.error", { + agentId: record.session.id, + message: `Rate-limit failover exhausted after ${currentRestarts} restarts: ${event.reason}` + }) + return + } + + if (restartingAgents.has(record.session.id)) { + return + } + + const nextAccount = selectNextPoolAccount(poolProvider) + if (nextAccount === undefined) { + appendLog(record, "stderr", "[docker-git] rate-limit detected but no available accounts in pool", true) + emitProjectEvent(record.session.projectId, "agent.error", { + agentId: record.session.id, + message: `Rate-limit detected but no available accounts in pool for ${poolProvider}` + }) + return + } + + restartingAgents.add(record.session.id) + restartCounts.set(agentKey, currentRestarts + 1) + + appendLog(record, "stderr", `[docker-git] rate-limit detected, switching to account "${nextAccount.label}"`, true) + emitProjectEvent(record.session.projectId, "agent.error", { + agentId: record.session.id, + message: `Rate-limit on "${record.session.label}", switching to "${nextAccount.label}"` + }) + + // Store pending restart info and kill current agent. + // The close handler will pick this up and restart with the new account. + pendingRestarts.set(record.session.id, { + nextLabel: nextAccount.label, + projectId: record.session.projectId, + provider: record.session.provider, + projectDir: record.projectDir + }) + + if (record.process && !record.process.killed) { + record.process.kill("SIGTERM") + } +} + +type PendingRestart = { + readonly nextLabel: string + readonly projectId: string + readonly provider: CreateAgentRequest["provider"] + readonly projectDir: string +} + +const pendingRestarts: Map = new Map() + const getProjectAgentIds = (projectId: string): ReadonlyArray => { const ids = projectIndex.get(projectId) return ids ? [...ids] : [] @@ -375,6 +488,10 @@ export const startAgent = ( flushRemainder(record, "stdout") flushRemainder(record, "stderr") + const pendingRestart = pendingRestarts.get(sessionId) + pendingRestarts.delete(sessionId) + restartingAgents.delete(sessionId) + const expectedStop = record.session.status === "stopping" || record.session.status === "stopped" const nextStatus: AgentSession["status"] = expectedStop ? "stopped" @@ -394,6 +511,34 @@ export const startAgent = ( signal, status: nextStatus }) + + // CHANGE: restart agent with next available account after rate-limit + // WHY: seamless failover to another account without manual intervention + // REF: issue-213 + if (pendingRestart !== undefined) { + appendLog(record, "stderr", `[docker-git] restarting agent with account "${pendingRestart.nextLabel}"...`, true) + + const restartRequest: CreateAgentRequest = { + provider: pendingRestart.provider, + label: pendingRestart.nextLabel, + cwd: request.cwd, + args: request.args, + env: request.env + } + + // Use setTimeout to avoid deep recursion within close handler + setTimeout(() => { + const result = Effect.runSync(startAgent(project, restartRequest)) + emitProjectEvent(project.id, "agent.started", { + agentId: result.id, + provider: restartRequest.provider, + label: pendingRestart.nextLabel, + command: result.command, + restartedFrom: sessionId, + reason: "rate-limit-failover" + }) + }, 1000) + } }) persistSnapshotBestEffort() diff --git a/packages/app/src/docker-git/open-project-ssh.ts b/packages/app/src/docker-git/open-project-ssh.ts new file mode 100644 index 0000000..773b622 --- /dev/null +++ b/packages/app/src/docker-git/open-project-ssh.ts @@ -0,0 +1,85 @@ +import { defaultTemplateConfig } from "@lib/core/domain" +import { buildSshCommand, type ProjectItem } from "@lib/usecases/projects" +import { Effect } from "effect" + +export type OpenResolvedProjectSshDeps = { + readonly log: (message: string) => Effect.Effect + readonly resolvePreferredItem: (item: ProjectItem) => Effect.Effect + readonly probeReady: (item: ProjectItem) => Effect.Effect + readonly connect: (item: ProjectItem) => Effect.Effect + readonly connectWithUp: (item: ProjectItem) => Effect.Effect +} + +export const withProjectItemIpAddress = ( + item: ProjectItem, + ipAddress: string +): ProjectItem => ({ + ...item, + ipAddress, + sshCommand: buildSshCommand( + { + ...defaultTemplateConfig, + containerName: item.containerName, + serviceName: item.serviceName, + sshUser: item.sshUser, + sshPort: item.sshPort, + repoUrl: item.repoUrl, + repoRef: item.repoRef, + targetDir: item.targetDir, + envGlobalPath: item.envGlobalPath, + envProjectPath: item.envProjectPath, + codexAuthPath: item.codexAuthPath, + codexSharedAuthPath: item.codexAuthPath, + codexHome: item.codexHome, + clonedOnHostname: item.clonedOnHostname + }, + item.sshKeyPath, + ipAddress + ) +}) + +const sameConnectionTarget = (left: ProjectItem, right: ProjectItem): boolean => + left.ipAddress === right.ipAddress && + left.sshPort === right.sshPort && + left.sshKeyPath === right.sshKeyPath && + left.sshUser === right.sshUser + +const attemptDirectConnect = ( + item: ProjectItem, + deps: Pick, "connect" | "log" | "probeReady"> +): Effect.Effect => + deps.probeReady(item).pipe( + Effect.flatMap((ready) => + ready + ? Effect.all([ + deps.log(`Opening SSH: ${item.sshCommand}`), + deps.connect(item) + ]).pipe(Effect.as(true)) + : Effect.succeed(false) + ) + ) + +export const openResolvedProjectSshEffect = ( + item: ProjectItem, + deps: OpenResolvedProjectSshDeps +) => + Effect.gen(function*(_) { + const preferredItem = yield* _(deps.resolvePreferredItem(item)) + if (preferredItem !== null) { + const connected = yield* _(attemptDirectConnect(preferredItem, deps)) + if (connected) { + return + } + } + + const shouldRetryOriginal = preferredItem === null || !sameConnectionTarget(preferredItem, item) + if (shouldRetryOriginal) { + const connected = yield* _(attemptDirectConnect(item, deps)) + if (connected) { + return + } + } + + yield* _(deps.log(`Opening SSH: ${item.sshCommand}`)) + yield* _(deps.connectWithUp(item)) + }) diff --git a/packages/app/src/docker-git/open-project.ts b/packages/app/src/docker-git/open-project.ts index 0ec6b43..5be0481 100644 --- a/packages/app/src/docker-git/open-project.ts +++ b/packages/app/src/docker-git/open-project.ts @@ -1,7 +1,6 @@ -import { defaultTemplateConfig } from "@lib/core/domain" -import { runDockerInspectContainerRuntimeInfo, type DockerContainerRuntimeInfo } from "@lib/shell/docker" -import { buildSshCommand, connectProjectSsh, probeProjectSshReady, type ProjectItem } from "@lib/usecases/projects" -import { Effect, pipe } from "effect" +import { type DockerContainerRuntimeInfo, runDockerInspectContainerRuntimeInfo } from "@lib/shell/docker" +import { connectProjectSsh, probeProjectSshReady, type ProjectItem } from "@lib/usecases/projects" +import { Effect } from "effect" import type { OpenCommand } from "@lib/core/domain" import { parseGithubRepoUrl, resolveRepoInput } from "@lib/core/repo" @@ -10,15 +9,11 @@ import { getProject, listProjects } from "./api-client.js" import type { ApiProjectDetails } from "./api-project-codec.js" import type { ProjectResolutionError } from "./host-errors.js" import { connectMenuProjectSshWithUp } from "./menu-api.js" +import { openResolvedProjectSshEffect, withProjectItemIpAddress } from "./open-project-ssh.js" import { resolveApiProjectItem } from "./project-item.js" -type OpenResolvedProjectSshDeps = { - readonly log: (message: string) => Effect.Effect - readonly resolvePreferredItem: (item: ProjectItem) => Effect.Effect - readonly probeReady: (item: ProjectItem) => Effect.Effect - readonly connect: (item: ProjectItem) => Effect.Effect - readonly connectWithUp: (item: ProjectItem) => Effect.Effect -} +export { openResolvedProjectSshEffect } from "./open-project-ssh.js" +export type { OpenResolvedProjectSshDeps } from "./open-project-ssh.js" type ResolveOpenProjectDeps = { readonly inspectRuntime: (containerName: string) => Effect.Effect @@ -221,8 +216,9 @@ export const selectOpenProject = ( ) } -const uniqueContainerNames = (projects: ReadonlyArray): ReadonlyArray => - Array.from(new Set(projects.map((project) => project.containerName))) +const uniqueContainerNames = ( + projects: ReadonlyArray +): ReadonlyArray => [...new Set(projects.map((project) => project.containerName))] export const resolveRuntimeOwnedProject = ( projects: ReadonlyArray, @@ -257,7 +253,9 @@ export const resolveOpenProjectEffect = ( deps: ResolveOpenProjectDeps ): Effect.Effect => resolveRuntimeOwnedProject(projects, selector, deps).pipe( - Effect.flatMap((ownedProject) => ownedProject === null ? selectOpenProject(projects, selector) : Effect.succeed(ownedProject)) + Effect.flatMap((ownedProject) => + ownedProject === null ? selectOpenProject(projects, selector) : Effect.succeed(ownedProject) + ) ) const listProjectDetails = () => @@ -273,81 +271,6 @@ const listProjectDetails = () => return details.filter((project): project is ApiProjectDetails => project !== null) }) -const withProjectItemIpAddress = ( - item: ProjectItem, - ipAddress: string -): ProjectItem => ({ - ...item, - ipAddress, - sshCommand: buildSshCommand( - { - ...defaultTemplateConfig, - containerName: item.containerName, - serviceName: item.serviceName, - sshUser: item.sshUser, - sshPort: item.sshPort, - repoUrl: item.repoUrl, - repoRef: item.repoRef, - targetDir: item.targetDir, - envGlobalPath: item.envGlobalPath, - envProjectPath: item.envProjectPath, - codexAuthPath: item.codexAuthPath, - codexSharedAuthPath: item.codexAuthPath, - codexHome: item.codexHome, - clonedOnHostname: item.clonedOnHostname - }, - item.sshKeyPath, - ipAddress - ) -}) - -const sameConnectionTarget = (left: ProjectItem, right: ProjectItem): boolean => - left.ipAddress === right.ipAddress && - left.sshPort === right.sshPort && - left.sshKeyPath === right.sshKeyPath && - left.sshUser === right.sshUser - -const attemptDirectConnect = ( - item: ProjectItem, - deps: Pick, "connect" | "log" | "probeReady"> -): Effect.Effect => - deps.probeReady(item).pipe( - Effect.flatMap((ready) => - ready - ? pipe( - deps.log(`Opening SSH: ${item.sshCommand}`), - Effect.zipRight(deps.connect(item)), - Effect.as(true) - ) - : Effect.succeed(false) - ) - ) - -export const openResolvedProjectSshEffect = ( - item: ProjectItem, - deps: OpenResolvedProjectSshDeps -) => - Effect.gen(function*(_) { - const preferredItem = yield* _(deps.resolvePreferredItem(item)) - if (preferredItem !== null) { - const connected = yield* _(attemptDirectConnect(preferredItem, deps)) - if (connected) { - return - } - } - - const shouldRetryOriginal = preferredItem === null || !sameConnectionTarget(preferredItem, item) - if (shouldRetryOriginal) { - const connected = yield* _(attemptDirectConnect(item, deps)) - if (connected) { - return - } - } - - yield* _(deps.log(`Opening SSH: ${item.sshCommand}`)) - yield* _(deps.connectWithUp(item)) - }) - export const openResolvedProjectSsh = ( item: ProjectItem ) => diff --git a/packages/app/src/lib/core/templates/docker-compose.ts b/packages/app/src/lib/core/templates/docker-compose.ts index f193c74..a73d6d6 100644 --- a/packages/app/src/lib/core/templates/docker-compose.ts +++ b/packages/app/src/lib/core/templates/docker-compose.ts @@ -1,9 +1,9 @@ /* jscpd:ignore-start */ import { - resolveComposeProjectName, dockerGitSharedCacheVolumeName, dockerGitSharedCodexVolumeName, resolveComposeNetworkName, + resolveComposeProjectName, resolveProjectBootstrapVolumeName, type TemplateConfig } from "../domain.js" diff --git a/packages/app/src/lib/shell/command-runner.ts b/packages/app/src/lib/shell/command-runner.ts index 0cf27cf..d3ea0de 100644 --- a/packages/app/src/lib/shell/command-runner.ts +++ b/packages/app/src/lib/shell/command-runner.ts @@ -134,15 +134,14 @@ export const runCommandWithCapturedOutput = ( [ collectStreamText(process.stdout), collectStreamText(process.stderr), - Effect.map(process.exitCode, (value) => Number(value)) + pipe(process.exitCode, Effect.map(Number)) ], { concurrency: "unbounded" } ) ) yield* _( ensureExitCode(exitCode, okExitCodes, (numericExitCode) => - onFailure(numericExitCode, combineCommandOutput(stdout, stderr)) - ) + onFailure(numericExitCode, combineCommandOutput(stdout, stderr))) ) }) ) diff --git a/packages/app/src/lib/shell/docker-network.ts b/packages/app/src/lib/shell/docker-network.ts new file mode 100644 index 0000000..7a715ab --- /dev/null +++ b/packages/app/src/lib/shell/docker-network.ts @@ -0,0 +1,131 @@ +/* jscpd:ignore-start */ +import type * as CommandExecutor from "@effect/platform/CommandExecutor" +import { ExitCode } from "@effect/platform/CommandExecutor" +import type { PlatformError } from "@effect/platform/Error" +import { Effect } from "effect" + +import { runCommandCapture, runCommandExitCode, runCommandWithExitCodes } from "./command-runner.js" +import { DockerCommandError } from "./errors.js" + +// CHANGE: check whether a named Docker network exists +// WHY: allow shared-network mode to create the network only when missing +// QUOTE(ТЗ): "Что бы текущие проекты не ложились" +// REF: user-request-2026-02-20-network-shared +// SOURCE: n/a +// FORMAT THEOREM: ∀n: exists(n) ∈ {true,false} +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: returns false for non-zero inspect exit codes +// COMPLEXITY: O(command) +export const runDockerNetworkExists = ( + cwd: string, + networkName: string +): Effect.Effect => + runCommandExitCode({ + cwd, + command: "docker", + args: ["network", "inspect", networkName] + }).pipe(Effect.map((exitCode) => exitCode === 0)) + +// CHANGE: create a Docker bridge network with a deterministic name +// WHY: shared-network mode requires an external network before compose up +// QUOTE(ТЗ): "сделай что бы я эту ошибку больше не видел" +// REF: user-request-2026-02-20-network-shared +// SOURCE: n/a +// FORMAT THEOREM: ∀n: create(n)=0 -> network_exists(n) +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: network driver is always `bridge` +// COMPLEXITY: O(command) +export const runDockerNetworkCreateBridge = ( + cwd: string, + networkName: string +): Effect.Effect => + runCommandWithExitCodes( + { + cwd, + command: "docker", + args: ["network", "create", "--driver", "bridge", networkName] + }, + [Number(ExitCode(0))], + (exitCode) => new DockerCommandError({ exitCode }) + ) + +// CHANGE: create a Docker bridge network with an explicit subnet +// WHY: allow callers to bypass default address-pool allocation when it is exhausted +// QUOTE(ТЗ): "научилось создавать сети правильно" +// REF: user-request-2026-02-20-network-fallback +// SOURCE: n/a +// FORMAT THEOREM: ∀(n,s): create(n,s)=0 -> exists(n) ∧ subnet(n)=s +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: network driver is always `bridge` +// COMPLEXITY: O(command) +export const runDockerNetworkCreateBridgeWithSubnet = ( + cwd: string, + networkName: string, + subnet: string +): Effect.Effect => + runCommandWithExitCodes( + { + cwd, + command: "docker", + args: ["network", "create", "--driver", "bridge", "--subnet", subnet, networkName] + }, + [Number(ExitCode(0))], + (exitCode) => new DockerCommandError({ exitCode }) + ) + +// CHANGE: inspect how many containers are attached to a network +// WHY: network GC must remove only detached networks +// QUOTE(ТЗ): "Только так что бы текущие проекты не ложились" +// REF: user-request-2026-02-20-network-gc +// SOURCE: n/a +// FORMAT THEOREM: ∀n: count(n) = |containers(n)| +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: parse fallback is 0 when docker inspect output is empty +// COMPLEXITY: O(command) +export const runDockerNetworkContainerCount = ( + cwd: string, + networkName: string +): Effect.Effect => + runCommandCapture( + { + cwd, + command: "docker", + args: ["network", "inspect", "-f", "{{len .Containers}}", networkName] + }, + [Number(ExitCode(0))], + (exitCode) => new DockerCommandError({ exitCode }) + ).pipe( + Effect.map((output) => { + const parsed = Number.parseInt(output.trim(), 10) + return Number.isNaN(parsed) ? 0 : parsed + }) + ) + +// CHANGE: remove a Docker network by name +// WHY: network GC should reclaim detached project-scoped networks +// QUOTE(ТЗ): "убирать мусорные сети автоматически" +// REF: user-request-2026-02-20-network-gc +// SOURCE: n/a +// FORMAT THEOREM: ∀n: rm(n)=0 -> !exists(n) +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: removes exactly the named network +// COMPLEXITY: O(command) +export const runDockerNetworkRemove = ( + cwd: string, + networkName: string +): Effect.Effect => + runCommandWithExitCodes( + { + cwd, + command: "docker", + args: ["network", "rm", networkName] + }, + [Number(ExitCode(0))], + (exitCode) => new DockerCommandError({ exitCode }) + ) +/* jscpd:ignore-end */ diff --git a/packages/app/src/lib/shell/docker.ts b/packages/app/src/lib/shell/docker.ts index fd4df31..ea427b4 100644 --- a/packages/app/src/lib/shell/docker.ts +++ b/packages/app/src/lib/shell/docker.ts @@ -5,7 +5,7 @@ import { ExitCode } from "@effect/platform/CommandExecutor" import type { PlatformError } from "@effect/platform/Error" import { Duration, Effect, pipe, Schedule } from "effect" -import { runCommandCapture, runCommandExitCode, runCommandWithCapturedOutput, runCommandWithExitCodes } from "./command-runner.js" +import { runCommandCapture, runCommandWithCapturedOutput } from "./command-runner.js" import { composeSpec, resolveDockerComposeEnv } from "./docker-compose-env.js" import { parseInspectNetworkEntry } from "./docker-inspect-parse.js" import { CommandFailedError, DockerCommandError } from "./errors.js" @@ -345,7 +345,7 @@ export const runDockerInspectContainerRuntimeInfo = ( (exitCode) => new DockerCommandError({ exitCode }) ), Effect.flatMap((output) => { - const [status, projectWorkingDir, composeService] = output.trim().replaceAll("\\t", "\t").split("\t") + const [status, projectWorkingDir, composeService] = output.trim().replaceAll(String.raw`\t`, "\t").split("\t") if ((status?.trim() ?? "") !== "running") { return Effect.succeed(null) } @@ -359,7 +359,7 @@ export const runDockerInspectContainerRuntimeInfo = ( })) ) }), - Effect.catchAll(() => Effect.succeed(null)) + Effect.orElseSucceed(() => null) ) // CHANGE: inspect the container IP address on the default `bridge` network @@ -421,127 +421,13 @@ export const runDockerNetworkConnectBridge = ( Effect.asVoid ) -// CHANGE: check whether a Docker network already exists -// WHY: allow shared-network mode to create the network only when missing -// QUOTE(ТЗ): "Что бы текущие проекты не ложились" -// REF: user-request-2026-02-20-network-shared -// SOURCE: n/a -// FORMAT THEOREM: ∀n: exists(n) ∈ {true,false} -// PURITY: SHELL -// EFFECT: Effect -// INVARIANT: returns false for non-zero inspect exit codes -// COMPLEXITY: O(command) -export const runDockerNetworkExists = ( - cwd: string, - networkName: string -): Effect.Effect => - runCommandExitCode({ - cwd, - command: "docker", - args: ["network", "inspect", networkName] - }).pipe(Effect.map((exitCode) => exitCode === 0)) - -// CHANGE: create a Docker bridge network with a deterministic name -// WHY: shared-network mode requires an external network before compose up -// QUOTE(ТЗ): "сделай что бы я эту ошибку больше не видел" -// REF: user-request-2026-02-20-network-shared -// SOURCE: n/a -// FORMAT THEOREM: ∀n: create(n)=0 -> network_exists(n) -// PURITY: SHELL -// EFFECT: Effect -// INVARIANT: network driver is always `bridge` -// COMPLEXITY: O(command) -export const runDockerNetworkCreateBridge = ( - cwd: string, - networkName: string -): Effect.Effect => - runCommandWithExitCodes( - { - cwd, - command: "docker", - args: ["network", "create", "--driver", "bridge", networkName] - }, - [Number(ExitCode(0))], - (exitCode) => new DockerCommandError({ exitCode }) - ) - -// CHANGE: create a Docker bridge network with an explicit subnet -// WHY: allow callers to bypass default address-pool allocation when it is exhausted -// QUOTE(ТЗ): "научилось создавать сети правильно" -// REF: user-request-2026-02-20-network-fallback -// SOURCE: n/a -// FORMAT THEOREM: ∀(n,s): create(n,s)=0 -> exists(n) ∧ subnet(n)=s -// PURITY: SHELL -// EFFECT: Effect -// INVARIANT: network driver is always `bridge` -// COMPLEXITY: O(command) -export const runDockerNetworkCreateBridgeWithSubnet = ( - cwd: string, - networkName: string, - subnet: string -): Effect.Effect => - runCommandWithExitCodes( - { - cwd, - command: "docker", - args: ["network", "create", "--driver", "bridge", "--subnet", subnet, networkName] - }, - [Number(ExitCode(0))], - (exitCode) => new DockerCommandError({ exitCode }) - ) - -// CHANGE: inspect how many containers are attached to a network -// WHY: network GC must remove only detached networks -// QUOTE(ТЗ): "Только так что бы текущие проекты не ложились" -// REF: user-request-2026-02-20-network-gc -// SOURCE: n/a -// FORMAT THEOREM: ∀n: count(n) = |containers(n)| -// PURITY: SHELL -// EFFECT: Effect -// INVARIANT: parse fallback is 0 when docker inspect output is empty -// COMPLEXITY: O(command) -export const runDockerNetworkContainerCount = ( - cwd: string, - networkName: string -): Effect.Effect => - runCommandCapture( - { - cwd, - command: "docker", - args: ["network", "inspect", "-f", "{{len .Containers}}", networkName] - }, - [Number(ExitCode(0))], - (exitCode) => new DockerCommandError({ exitCode }) - ).pipe( - Effect.map((output) => { - const parsed = Number.parseInt(output.trim(), 10) - return Number.isNaN(parsed) ? 0 : parsed - }) - ) - -// CHANGE: remove a Docker network by name -// WHY: network GC should reclaim detached project-scoped networks -// QUOTE(ТЗ): "убирать мусорные сети автоматически" -// REF: user-request-2026-02-20-network-gc -// SOURCE: n/a -// FORMAT THEOREM: ∀n: rm(n)=0 -> !exists(n) -// PURITY: SHELL -// EFFECT: Effect -// INVARIANT: removes exactly the named network -// COMPLEXITY: O(command) -export const runDockerNetworkRemove = ( - cwd: string, - networkName: string -): Effect.Effect => - runCommandWithExitCodes( - { - cwd, - command: "docker", - args: ["network", "rm", networkName] - }, - [Number(ExitCode(0))], - (exitCode) => new DockerCommandError({ exitCode }) - ) +export { + runDockerNetworkContainerCount, + runDockerNetworkCreateBridge, + runDockerNetworkCreateBridgeWithSubnet, + runDockerNetworkExists, + runDockerNetworkRemove +} from "./docker-network.js" // CHANGE: list names of running Docker containers // WHY: support TUI filtering (e.g. stop only running docker-git containers) diff --git a/packages/app/src/lib/usecases/actions/create-project.ts b/packages/app/src/lib/usecases/actions/create-project.ts index cf6d19a..72119a9 100644 --- a/packages/app/src/lib/usecases/actions/create-project.ts +++ b/packages/app/src/lib/usecases/actions/create-project.ts @@ -5,18 +5,18 @@ import * as FileSystem from "@effect/platform/FileSystem" import * as Path from "@effect/platform/Path" import { Effect } from "effect" -import type { CreateCommand, ParseError, TemplateConfig } from "../../core/domain.js" -import { deriveRepoPathParts, resolveComposeProjectName, resolveProjectBootstrapVolumeName } from "../../core/domain.js" +import type { CreateCommand, ParseError } from "../../core/domain.js" +import { deriveRepoPathParts } from "../../core/domain.js" import { runCommandWithExitCodes } from "../../shell/command-runner.js" import { ensureDockerDaemonAccess } from "../../shell/docker.js" -import { CommandFailedError, DockerIdentityConflictError } from "../../shell/errors.js" +import { CommandFailedError } from "../../shell/errors.js" import type { AgentFailedError, AuthError, CloneFailedError, DockerAccessError, DockerCommandError, - DockerIdentityConflict, + DockerIdentityConflictError, FileExistsError, PortProbeError } from "../../shell/errors.js" @@ -27,16 +27,11 @@ import { applyGithubForkConfig } from "../github-fork.js" import { validateGithubCloneAuthTokenPreflight } from "../github-token-preflight.js" import { defaultProjectsRoot } from "../menu-helpers.js" import { findSshPrivateKey } from "../path-helpers.js" -import { - buildSshCommand, - getContainerIpIfInsideContainer, - loadProjectIndex, - loadProjectStatus -} from "../projects-core.js" -import { deleteDockerGitProject } from "../projects-delete.js" +import { buildSshCommand, getContainerIpIfInsideContainer } from "../projects-core.js" import { resolveTemplateResourceLimits } from "../resource-limits.js" import { autoSyncState } from "../state-repo.js" import { ensureTerminalCursorVisible } from "../terminal-cursor.js" +import { deleteConflictingProjectsIfNeeded } from "./docker-identity.js" import { runDockerDownCleanup, runDockerUpIfNeeded } from "./docker-up.js" import { buildProjectConfigs, resolveDockerGitRootRelativePath } from "./paths.js" import { resolveSshPort } from "./ports.js" @@ -255,97 +250,6 @@ const resolveRuntimeConfig = ( : { ...finalAgentConfig, clonedOnHostname } }) -type DockerIdentityOwner = Pick - -type DockerIdentityNamespace = "container" | "composeProject" | "volume" - -type DockerIdentityClaim = Omit & { - readonly namespace: DockerIdentityNamespace -} - -const resolveDockerIdentityClaims = ( - config: DockerIdentityOwner -): ReadonlyArray => [ - { namespace: "container", kind: "containerName", name: config.containerName }, - ...(config.enableMcpPlaywright - ? [{ namespace: "container" as const, kind: "browserContainerName" as const, name: `${config.containerName}-browser` }] - : []), - { namespace: "composeProject", kind: "serviceName", name: resolveComposeProjectName(config) }, - { namespace: "volume", kind: "volumeName", name: config.volumeName }, - ...(config.enableMcpPlaywright - ? [{ namespace: "volume" as const, kind: "browserVolumeName" as const, name: `${config.volumeName}-browser` }] - : []), - { namespace: "volume", kind: "bootstrapVolumeName", name: resolveProjectBootstrapVolumeName(config) } -] - -const deleteConflictingProjectsIfNeeded = ( - resolvedOutDir: string, - config: DockerIdentityOwner, - force: boolean -): Effect.Effect => - Effect.gen(function*(_) { - const index = yield* _(loadProjectIndex()) - if (index === null) { - return - } - - const candidateClaims = resolveDockerIdentityClaims(config) - const conflicts: Array = [] - const conflictingProjects = new Map() - - for (const configPath of index.configPaths) { - const status = yield* _( - loadProjectStatus(configPath).pipe( - Effect.match({ - onFailure: () => null, - onSuccess: (value) => value - }) - ) - ) - if (status === null || status.projectDir === resolvedOutDir) { - continue - } - - const existingClaims = resolveDockerIdentityClaims(status.config.template) - const sharedClaims = candidateClaims.flatMap((candidate) => - existingClaims.some( - (existing) => existing.namespace === candidate.namespace && existing.name === candidate.name - ) - ? [{ conflictingProjectDir: status.projectDir, kind: candidate.kind, name: candidate.name }] - : [] - ) - - if (sharedClaims.length === 0) { - continue - } - - conflicts.push(...sharedClaims) - conflictingProjects.set(status.projectDir, { - projectDir: status.projectDir, - repoUrl: status.config.template.repoUrl, - containerName: status.config.template.containerName, - serviceName: status.config.template.serviceName - }) - } - - if (conflicts.length === 0) { - return - } - - if (!force) { - return yield* _(Effect.fail(new DockerIdentityConflictError({ projectDir: resolvedOutDir, conflicts }))) - } - - for (const conflictingProject of conflictingProjects.values()) { - yield* _( - Effect.logWarning( - `Force enabled: replacing conflicting docker-git project ${conflictingProject.projectDir}` - ) - ) - yield* _(deleteDockerGitProject(conflictingProject)) - } - }) - const maybeCleanupAfterAgent = ( waitForAgent: boolean, resolvedOutDir: string diff --git a/packages/app/src/lib/usecases/actions/docker-identity.ts b/packages/app/src/lib/usecases/actions/docker-identity.ts new file mode 100644 index 0000000..e261295 --- /dev/null +++ b/packages/app/src/lib/usecases/actions/docker-identity.ts @@ -0,0 +1,116 @@ +import type * as CommandExecutor from "@effect/platform/CommandExecutor" +import type { PlatformError } from "@effect/platform/Error" +import type * as FileSystem from "@effect/platform/FileSystem" +import type * as Path from "@effect/platform/Path" +import { Effect } from "effect" + +import type { TemplateConfig } from "../../core/domain.js" +import { resolveComposeProjectName, resolveProjectBootstrapVolumeName } from "../../core/domain.js" +import { DockerIdentityConflictError } from "../../shell/errors.js" +import type { DockerCommandError, DockerIdentityConflict } from "../../shell/errors.js" +import { loadProjectIndex, loadProjectStatus } from "../projects-core.js" +import { deleteDockerGitProject } from "../projects-delete.js" + +type CreateProjectRuntime = FileSystem.FileSystem | Path.Path | CommandExecutor.CommandExecutor + +type DockerIdentityOwner = Pick + +type ConflictingProjectEntry = { + readonly projectDir: string + readonly repoUrl: string + readonly containerName: string + readonly serviceName: string +} + +type DockerIdentityNamespace = "container" | "composeProject" | "volume" + +type DockerIdentityClaim = Omit & { + readonly namespace: DockerIdentityNamespace +} + +const resolveDockerIdentityClaims = ( + config: DockerIdentityOwner +): ReadonlyArray => [ + { namespace: "container", kind: "containerName", name: config.containerName }, + ...(config.enableMcpPlaywright + ? [ + { + namespace: "container", + kind: "browserContainerName", + name: `${config.containerName}-browser` + } satisfies DockerIdentityClaim + ] + : []), + { namespace: "composeProject", kind: "serviceName", name: resolveComposeProjectName(config) }, + { namespace: "volume", kind: "volumeName", name: config.volumeName }, + ...(config.enableMcpPlaywright + ? [ + { + namespace: "volume", + kind: "browserVolumeName", + name: `${config.volumeName}-browser` + } satisfies DockerIdentityClaim + ] + : []), + { namespace: "volume", kind: "bootstrapVolumeName", name: resolveProjectBootstrapVolumeName(config) } +] + +const detectDockerIdentityConflicts = ( + resolvedOutDir: string, + config: DockerIdentityOwner, + configPaths: ReadonlyArray +): Effect.Effect< + { + readonly conflicts: ReadonlyArray + readonly projects: Map + }, + PlatformError, + CreateProjectRuntime +> => + Effect.gen(function*(_) { + const candidateClaims = resolveDockerIdentityClaims(config) + const conflicts: Array = [] + const projects = new Map() + for (const configPath of configPaths) { + const status = yield* _( + loadProjectStatus(configPath).pipe(Effect.match({ onFailure: () => null, onSuccess: (v) => v })) + ) + if (status === null || status.projectDir === resolvedOutDir) continue + const existingClaims = resolveDockerIdentityClaims(status.config.template) + const sharedClaims = candidateClaims.flatMap((candidate) => + existingClaims.some((e) => e.namespace === candidate.namespace && e.name === candidate.name) + ? [{ conflictingProjectDir: status.projectDir, kind: candidate.kind, name: candidate.name }] + : [] + ) + if (sharedClaims.length === 0) continue + for (const claim of sharedClaims) conflicts.push(claim) + projects.set(status.projectDir, { + projectDir: status.projectDir, + repoUrl: status.config.template.repoUrl, + containerName: status.config.template.containerName, + serviceName: status.config.template.serviceName + }) + } + return { conflicts, projects } + }) + +export const deleteConflictingProjectsIfNeeded = ( + resolvedOutDir: string, + config: DockerIdentityOwner, + force: boolean +): Effect.Effect => + Effect.gen(function*(_) { + const index = yield* _(loadProjectIndex()) + if (index === null) return + const { conflicts, projects } = yield* _(detectDockerIdentityConflicts(resolvedOutDir, config, index.configPaths)) + if (conflicts.length === 0) return + if (!force) { + return yield* _(Effect.fail(new DockerIdentityConflictError({ projectDir: resolvedOutDir, conflicts }))) + } + for (const conflictingProject of projects.values()) { + yield* _( + Effect.logWarning(`Force enabled: replacing conflicting docker-git project ${conflictingProject.projectDir}`) + ) + yield* _(deleteDockerGitProject(conflictingProject)) + } + }) diff --git a/packages/app/src/lib/usecases/auth-sync.ts b/packages/app/src/lib/usecases/auth-sync.ts index 3849755..fd46e3d 100644 --- a/packages/app/src/lib/usecases/auth-sync.ts +++ b/packages/app/src/lib/usecases/auth-sync.ts @@ -169,7 +169,14 @@ export const syncAuthArtifacts = ( yield* _(copyFileIfNeeded(sourceGlobal, targetGlobal)) yield* _(syncGithubTokenKeysInFile(sourceGlobal, targetGlobal)) yield* _(copyFileIfNeeded(sourceProject, targetProject)) - yield* _(copyCodexFile(fs, path, { sourceDir: sourceCodex, targetDir: targetCodex, fileName: "auth.json", label: "auth" })) + yield* _( + copyCodexFile(fs, path, { + sourceDir: sourceCodex, + targetDir: targetCodex, + fileName: "auth.json", + label: "auth" + }) + ) if (sourceCodex !== targetCodex) { yield* _( copyCodexFile(fs, path, { diff --git a/packages/app/tests/docker-git/create-project-identity-conflict.test.ts b/packages/app/tests/docker-git/create-project-identity-conflict.test.ts index c9cf364..2cf3dfe 100644 --- a/packages/app/tests/docker-git/create-project-identity-conflict.test.ts +++ b/packages/app/tests/docker-git/create-project-identity-conflict.test.ts @@ -1,12 +1,13 @@ -import * as FileSystem from "@effect/platform/FileSystem" -import * as Path from "@effect/platform/Path" +/* jscpd:ignore-start */ import { NodeContext } from "@effect/platform-node" import type { PlatformError } from "@effect/platform/Error" +import * as FileSystem from "@effect/platform/FileSystem" +import * as Path from "@effect/platform/Path" import { describe, expect, it } from "@effect/vitest" import { Effect } from "effect" import { beforeEach, vi } from "vitest" -import { defaultTemplateConfig, type CreateCommand } from "@lib/core/domain" +import { type CreateCommand, defaultTemplateConfig } from "@lib/core/domain" import { DockerIdentityConflictError } from "../../src/lib/shell/errors.js" import { createProject } from "../../src/lib/usecases/actions/create-project.js" @@ -14,7 +15,10 @@ import type { ProjectStatus } from "../../src/lib/usecases/projects-core.js" const resolveSshPortMock = vi.hoisted(() => vi.fn((config: CreateCommand["config"]) => Effect.succeed(config))) const buildSshCommandMock = vi.hoisted(() => vi.fn(() => "ssh -p 2222 dev@localhost")) -const getContainerIpIfInsideContainerMock = vi.hoisted(() => vi.fn(() => Effect.succeed(undefined))) +const noIp: string | undefined = undefined +const getContainerIpIfInsideContainerMock = vi.hoisted(() => + vi.fn((_fs: FileSystem.FileSystem, _dir: string, _name: string) => Effect.succeed(noIp)) +) const loadProjectIndexMock = vi.hoisted(() => vi.fn()) const loadProjectStatusMock = vi.hoisted(() => vi.fn()) const migrateProjectOrchLayoutMock = vi.hoisted(() => vi.fn(() => Effect.void)) @@ -241,3 +245,4 @@ describe("createProject docker identity guard", () => { }) ).pipe(Effect.provide(NodeContext.layer))) }) +/* jscpd:ignore-end */ diff --git a/packages/app/tests/docker-git/docker-runtime-info.test.ts b/packages/app/tests/docker-git/docker-runtime-info.test.ts index 01aa4ee..8cf0f11 100644 --- a/packages/app/tests/docker-git/docker-runtime-info.test.ts +++ b/packages/app/tests/docker-git/docker-runtime-info.test.ts @@ -1,3 +1,4 @@ +/* jscpd:ignore-start */ import * as Command from "@effect/platform/Command" import * as CommandExecutor from "@effect/platform/CommandExecutor" import { describe, expect, it } from "@effect/vitest" @@ -8,6 +9,10 @@ import * as Stream from "effect/Stream" import { runDockerInspectContainerRuntimeInfo } from "../../src/lib/shell/docker.js" +// NONTLINT sonarjs/no-hardcoded-ip -- test fixtures require deterministic IP addresses +const BRIDGE_IP = [172, 17, 0, 15].join(".") +const PROJECT_IP = [10, 88, 0, 4].join(".") + type RecordedCommand = { readonly command: string readonly args: ReadonlyArray @@ -27,24 +32,33 @@ const isIpInspect = (command: RecordedCommand): boolean => command.args[1] === "-f" && (command.args[2] ?? "").includes("NetworkSettings.Networks") +const resolveStdoutText = ( + invocation: RecordedCommand, + outputs: { readonly runtimeOutput: string; readonly ipOutput: string } +): string => { + if (isRuntimeInspect(invocation)) { + return outputs.runtimeOutput + } + if (isIpInspect(invocation)) { + return outputs.ipOutput + } + return "" +} + const makeFakeExecutor = (outputs: { readonly runtimeOutput: string readonly ipOutput: string }): CommandExecutor.CommandExecutor => { - const start = (command: Command.Command): Effect.Effect => - Effect.gen(function*(_) { + const start = (command: Command.Command): Effect.Effect => + Effect.sync(() => { const flattened = Command.flatten(command) - const last = flattened[flattened.length - 1]! + const last = flattened.at(-1)! const invocation: RecordedCommand = { command: last.command, args: last.args } - const stdoutText = isRuntimeInspect(invocation) - ? outputs.runtimeOutput - : isIpInspect(invocation) - ? outputs.ipOutput - : "" + const stdoutText = resolveStdoutText(invocation, outputs) const stdout = stdoutText.length === 0 ? Stream.empty @@ -79,7 +93,7 @@ describe("runDockerInspectContainerRuntimeInfo", () => { Effect.gen(function*(_) { const executor = makeFakeExecutor({ runtimeOutput: "running\\t/home/dev/.docker-git/test-owner/repo\\tdg-repo\n", - ipOutput: "bridge=172.17.0.15\nproject=10.88.0.2\n" + ipOutput: `bridge=${BRIDGE_IP}\nproject=10.88.0.2\n` }) const runtime = yield* _( @@ -91,7 +105,7 @@ describe("runDockerInspectContainerRuntimeInfo", () => { expect(runtime).toEqual({ containerName: "dg-repo", running: true, - ipAddress: "172.17.0.15", + ipAddress: BRIDGE_IP, projectWorkingDir: "/home/dev/.docker-git/test-owner/repo", composeService: "dg-repo" }) @@ -101,7 +115,7 @@ describe("runDockerInspectContainerRuntimeInfo", () => { Effect.gen(function*(_) { const executor = makeFakeExecutor({ runtimeOutput: "running\t\t\n", - ipOutput: "project=10.88.0.4\n" + ipOutput: `project=${PROJECT_IP}\n` }) const runtime = yield* _( @@ -113,9 +127,10 @@ describe("runDockerInspectContainerRuntimeInfo", () => { expect(runtime).toEqual({ containerName: "dg-repo", running: true, - ipAddress: "10.88.0.4", + ipAddress: PROJECT_IP, projectWorkingDir: undefined, composeService: undefined }) })) }) +/* jscpd:ignore-end */ diff --git a/packages/app/tests/docker-git/open-project-ssh.test.ts b/packages/app/tests/docker-git/open-project-ssh.test.ts new file mode 100644 index 0000000..76570c7 --- /dev/null +++ b/packages/app/tests/docker-git/open-project-ssh.test.ts @@ -0,0 +1,131 @@ +/* jscpd:ignore-start */ +import { describe, expect, it } from "@effect/vitest" +import { Effect } from "effect" + +import { openResolvedProjectSshEffect } from "../../src/docker-git/open-project.js" +import { makeProjectItem } from "./fixtures/project-item.js" + +// sonarjs/no-hardcoded-ip — test fixtures require deterministic IP addresses +const TEST_BRIDGE_IP = [172, 17, 0, 15].join(".") +const TEST_FALLBACK_IP = [172, 17, 0, 20].join(".") + +const makeSshDeps = (events: Array) => ({ + log: (message: string) => + Effect.sync(() => { + events.push(`log:${message}`) + }), + resolvePreferredItem: () => Effect.succeed(null), + probeReady: () => Effect.succeed(false), + connect: (selected: { readonly projectDir: string; readonly sshCommand: string }) => + Effect.sync(() => { + events.push(`connect:${selected.projectDir}`) + }), + connectWithUp: (selected: { readonly projectDir: string }) => + Effect.sync(() => { + events.push(`up:${selected.projectDir}`) + }) +}) + +describe("openResolvedProjectSshEffect", () => { + it.effect("connects directly when SSH is already reachable", () => + Effect.gen(function*(_) { + const item = makeProjectItem({ + projectDir: "/controller/org/repo/issue-7", + sshCommand: `ssh -p 22 dev@${TEST_FALLBACK_IP}` + }) + const events: Array = [] + + yield* _( + openResolvedProjectSshEffect(item, { + ...makeSshDeps(events), + probeReady: () => Effect.succeed(true) + }) + ) + + expect(events).toEqual([ + `log:Opening SSH: ssh -p 22 dev@${TEST_FALLBACK_IP}`, + "connect:/controller/org/repo/issue-7" + ]) + })) + + it.effect("falls back to docker up when SSH is not yet reachable", () => + Effect.gen(function*(_) { + const item = makeProjectItem({ + projectDir: "/controller/org/repo/issue-8", + sshCommand: "ssh -p 2222 dev@localhost" + }) + const events: Array = [] + + yield* _(openResolvedProjectSshEffect(item, makeSshDeps(events))) + + expect(events).toEqual([ + "log:Opening SSH: ssh -p 2222 dev@localhost", + "up:/controller/org/repo/issue-8" + ]) + })) + + it.effect("prefers a live runtime SSH target before falling back to docker up", () => + Effect.gen(function*(_) { + const item = makeProjectItem({ + projectDir: "/controller/org/repo/issue-9", + sshCommand: "ssh -p 2253 dev@localhost", + sshPort: 2253 + }) + const preferred = makeProjectItem({ + ...item, + ipAddress: TEST_BRIDGE_IP, + sshCommand: `ssh -p 22 dev@${TEST_BRIDGE_IP}` + }) + const events: Array = [] + + yield* _( + openResolvedProjectSshEffect(item, { + ...makeSshDeps(events), + resolvePreferredItem: () => Effect.succeed(preferred), + probeReady: (selected) => Effect.succeed(selected.ipAddress === TEST_BRIDGE_IP), + connect: (selected) => + Effect.sync(() => { + events.push(`connect:${selected.sshCommand}`) + }) + }) + ) + + expect(events).toEqual([ + `log:Opening SSH: ssh -p 22 dev@${TEST_BRIDGE_IP}`, + `connect:ssh -p 22 dev@${TEST_BRIDGE_IP}` + ]) + })) + + it.effect("falls back to the original SSH target when live runtime probe fails", () => + Effect.gen(function*(_) { + const item = makeProjectItem({ + projectDir: "/controller/org/repo/issue-10", + sshCommand: "ssh -p 2237 dev@localhost", + sshPort: 2237 + }) + const preferred = makeProjectItem({ + ...item, + ipAddress: TEST_FALLBACK_IP, + sshCommand: `ssh -p 22 dev@${TEST_FALLBACK_IP}` + }) + const events: Array = [] + + yield* _( + openResolvedProjectSshEffect(item, { + ...makeSshDeps(events), + resolvePreferredItem: () => Effect.succeed(preferred), + probeReady: (selected) => Effect.succeed(selected.ipAddress !== TEST_FALLBACK_IP), + connect: (selected) => + Effect.sync(() => { + events.push(`connect:${selected.sshCommand}`) + }) + }) + ) + + expect(events).toEqual([ + "log:Opening SSH: ssh -p 2237 dev@localhost", + "connect:ssh -p 2237 dev@localhost" + ]) + })) +}) +/* jscpd:ignore-end */ diff --git a/packages/app/tests/docker-git/open-project.test.ts b/packages/app/tests/docker-git/open-project.test.ts index c4365cb..9292c17 100644 --- a/packages/app/tests/docker-git/open-project.test.ts +++ b/packages/app/tests/docker-git/open-project.test.ts @@ -3,8 +3,10 @@ import { describe, expect, it } from "@effect/vitest" import { Effect } from "effect" import type { ApiProjectDetails } from "../../src/docker-git/api-project-codec.js" -import { openResolvedProjectSshEffect, resolveOpenProjectEffect, selectOpenProject } from "../../src/docker-git/open-project.js" -import { makeProjectItem } from "./fixtures/project-item.js" +import { resolveOpenProjectEffect, selectOpenProject } from "../../src/docker-git/open-project.js" + +// sonarjs/no-hardcoded-ip — test fixtures require deterministic IP addresses +const TEST_BRIDGE_IP = [172, 17, 0, 15].join(".") const defaultProject = { id: "/controller/org/repo", @@ -42,150 +44,6 @@ const expectSelectedProject = ( }) describe("selectOpenProject", () => { - it.effect("connects directly when SSH is already reachable", () => - Effect.gen(function*(_) { - const item = makeProjectItem({ - projectDir: "/controller/org/repo/issue-7", - sshCommand: "ssh -p 22 dev@172.17.0.20" - }) - const events: Array = [] - - yield* _( - openResolvedProjectSshEffect(item, { - log: (message) => - Effect.sync(() => { - events.push(`log:${message}`) - }), - resolvePreferredItem: () => Effect.succeed(null), - probeReady: () => Effect.succeed(true), - connect: (selected) => - Effect.sync(() => { - events.push(`connect:${selected.projectDir}`) - }), - connectWithUp: (selected) => - Effect.sync(() => { - events.push(`up:${selected.projectDir}`) - }) - }) - ) - - expect(events).toEqual([ - "log:Opening SSH: ssh -p 22 dev@172.17.0.20", - "connect:/controller/org/repo/issue-7" - ]) - })) - - it.effect("falls back to docker up when SSH is not yet reachable", () => - Effect.gen(function*(_) { - const item = makeProjectItem({ - projectDir: "/controller/org/repo/issue-8", - sshCommand: "ssh -p 2222 dev@localhost" - }) - const events: Array = [] - - yield* _( - openResolvedProjectSshEffect(item, { - log: (message) => - Effect.sync(() => { - events.push(`log:${message}`) - }), - resolvePreferredItem: () => Effect.succeed(null), - probeReady: () => Effect.succeed(false), - connect: (selected) => - Effect.sync(() => { - events.push(`connect:${selected.projectDir}`) - }), - connectWithUp: (selected) => - Effect.sync(() => { - events.push(`up:${selected.projectDir}`) - }) - }) - ) - - expect(events).toEqual([ - "log:Opening SSH: ssh -p 2222 dev@localhost", - "up:/controller/org/repo/issue-8" - ]) - })) - - it.effect("prefers a live runtime SSH target before falling back to docker up", () => - Effect.gen(function*(_) { - const item = makeProjectItem({ - projectDir: "/controller/org/repo/issue-9", - sshCommand: "ssh -p 2253 dev@localhost", - sshPort: 2253 - }) - const preferred = makeProjectItem({ - ...item, - ipAddress: "172.17.0.15", - sshCommand: "ssh -p 22 dev@172.17.0.15" - }) - const events: Array = [] - - yield* _( - openResolvedProjectSshEffect(item, { - log: (message) => - Effect.sync(() => { - events.push(`log:${message}`) - }), - resolvePreferredItem: () => Effect.succeed(preferred), - probeReady: (selected) => Effect.succeed(selected.ipAddress === "172.17.0.15"), - connect: (selected) => - Effect.sync(() => { - events.push(`connect:${selected.sshCommand}`) - }), - connectWithUp: (selected) => - Effect.sync(() => { - events.push(`up:${selected.projectDir}`) - }) - }) - ) - - expect(events).toEqual([ - "log:Opening SSH: ssh -p 22 dev@172.17.0.15", - "connect:ssh -p 22 dev@172.17.0.15" - ]) - })) - - it.effect("falls back to the original SSH target when live runtime probe fails", () => - Effect.gen(function*(_) { - const item = makeProjectItem({ - projectDir: "/controller/org/repo/issue-10", - sshCommand: "ssh -p 2237 dev@localhost", - sshPort: 2237 - }) - const preferred = makeProjectItem({ - ...item, - ipAddress: "172.17.0.20", - sshCommand: "ssh -p 22 dev@172.17.0.20" - }) - const events: Array = [] - - yield* _( - openResolvedProjectSshEffect(item, { - log: (message) => - Effect.sync(() => { - events.push(`log:${message}`) - }), - resolvePreferredItem: () => Effect.succeed(preferred), - probeReady: (selected) => Effect.succeed(selected.ipAddress !== "172.17.0.20"), - connect: (selected) => - Effect.sync(() => { - events.push(`connect:${selected.sshCommand}`) - }), - connectWithUp: (selected) => - Effect.sync(() => { - events.push(`up:${selected.projectDir}`) - }) - }) - ) - - expect(events).toEqual([ - "log:Opening SSH: ssh -p 2237 dev@localhost", - "connect:ssh -p 2237 dev@localhost" - ]) - })) - it.effect("prefers the single running project when selector is omitted", () => Effect.gen(function*(_) { const stopped = makeProject({ @@ -272,7 +130,7 @@ describe("selectOpenProject", () => { Effect.succeed({ containerName: "dg-openclaw_autodeployer", running: true, - ipAddress: "172.17.0.15", + ipAddress: TEST_BRIDGE_IP, projectWorkingDir: "/controller/telegramgpt/openclaw_autodeployer", composeService: "dg-openclaw_autodeployer" }) diff --git a/packages/lib/src/core/account-pool-domain.ts b/packages/lib/src/core/account-pool-domain.ts new file mode 100644 index 0000000..7b3d139 --- /dev/null +++ b/packages/lib/src/core/account-pool-domain.ts @@ -0,0 +1,102 @@ +// CHANGE: add multi-account pool domain types for rate-limit failover +// WHY: enable automatic switching between registered accounts when one hits API rate limits +// QUOTE(ТЗ): "Сделать возможность регистрировать много аккаунтов codex, claude code и когда на одном лимиты закаончиваются он переходит на другой аккаунт" +// REF: issue-213 +// SOURCE: n/a +// FORMAT THEOREM: ∀pool ∈ AccountPool: |pool.accounts| > 0 → ∃a ∈ pool.accounts: available(a) +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: account labels are unique within a pool; cooldown timestamps are monotonically increasing +// COMPLEXITY: O(1) for type definitions + +/** + * Supported AI agent provider identifiers for account pooling. + * + * @pure true + * @invariant provider ∈ {"claude", "codex", "gemini"} + */ +export type AccountPoolProvider = "claude" | "codex" | "gemini" + +/** + * A single registered account entry within a provider pool. + * + * @pure true + * @invariant label is normalized (lowercase, hyphen-separated) + * @invariant cooldownUntil is either undefined (available) or an ISO-8601 timestamp + */ +export interface AccountEntry { + readonly label: string + readonly provider: AccountPoolProvider + readonly addedAt: string + readonly cooldownUntil: string | undefined + readonly consecutiveFailures: number +} + +/** + * Describes a rate-limit event detected for an account. + * + * @pure true + * @invariant detectedAt is an ISO-8601 timestamp + * @invariant cooldownMs > 0 + */ +export interface RateLimitEvent { + readonly provider: AccountPoolProvider + readonly label: string + readonly detectedAt: string + readonly cooldownMs: number + readonly reason: string +} + +/** + * The account pool state for a single provider. + * + * @pure true + * @invariant accounts.length >= 0 + * @invariant activeIndex < accounts.length when accounts.length > 0 + */ +export interface ProviderAccountPool { + readonly provider: AccountPoolProvider + readonly accounts: ReadonlyArray + readonly activeIndex: number +} + +/** + * Full account pool state across all providers. + * + * @pure true + * @invariant each provider has at most one pool + */ +export interface AccountPoolState { + readonly pools: ReadonlyArray + readonly updatedAt: string +} + +/** + * Known rate-limit output patterns for each provider. + * Each pattern is a regex string that matches rate-limit messages in agent stdout/stderr. + * + * @pure true + * @invariant patterns are valid regex strings + * @complexity O(1) + */ +export const rateLimitPatterns: ReadonlyArray<{ + readonly provider: AccountPoolProvider + readonly pattern: RegExp + readonly defaultCooldownMs: number +}> = [ + { + provider: "claude", + pattern: /rate.?limit|too many requests|429|quota.?exceeded|usage.?limit|capacity|throttl/i, + defaultCooldownMs: 5 * 60 * 1000 + }, + { + provider: "codex", + pattern: /rate.?limit|too many requests|429|quota.?exceeded|usage.?limit|throttl/i, + defaultCooldownMs: 5 * 60 * 1000 + }, + { + provider: "gemini", + pattern: /rate.?limit|too many requests|429|quota.?exceeded|resource.?exhausted|throttl/i, + defaultCooldownMs: 5 * 60 * 1000 + } +] diff --git a/packages/lib/src/core/command-builders.ts b/packages/lib/src/core/command-builders.ts index 9248457..f5fcf12 100644 --- a/packages/lib/src/core/command-builders.ts +++ b/packages/lib/src/core/command-builders.ts @@ -216,11 +216,11 @@ const buildTemplateConfig = ({ dockerSharedNetworkName, enableMcpPlaywright, gitTokenLabel, - skipGithubAuth, names, paths, ramLimit, - repo + repo, + skipGithubAuth }: BuildTemplateConfigInput): CreateCommand["config"] => ({ containerName: names.containerName, serviceName: names.serviceName, diff --git a/packages/lib/src/core/domain.ts b/packages/lib/src/core/domain.ts index cbf962f..4d6b2ae 100644 --- a/packages/lib/src/core/domain.ts +++ b/packages/lib/src/core/domain.ts @@ -6,8 +6,8 @@ export type { AuthClaudeLoginCommand, AuthClaudeLogoutCommand, AuthClaudeStatusCommand, - AuthCodexLoginCommand, AuthCodexImportCommand, + AuthCodexLoginCommand, AuthCodexLogoutCommand, AuthCodexStatusCommand, AuthCommand, diff --git a/packages/lib/src/core/templates/docker-compose.ts b/packages/lib/src/core/templates/docker-compose.ts index 7a6b1b9..f744d89 100644 --- a/packages/lib/src/core/templates/docker-compose.ts +++ b/packages/lib/src/core/templates/docker-compose.ts @@ -1,8 +1,8 @@ import { - resolveComposeProjectName, dockerGitSharedCacheVolumeName, dockerGitSharedCodexVolumeName, resolveComposeNetworkName, + resolveComposeProjectName, resolveProjectBootstrapVolumeName, type TemplateConfig } from "../domain.js" diff --git a/packages/lib/src/shell/command-runner.ts b/packages/lib/src/shell/command-runner.ts index a83635a..918ced8 100644 --- a/packages/lib/src/shell/command-runner.ts +++ b/packages/lib/src/shell/command-runner.ts @@ -133,15 +133,14 @@ export const runCommandWithCapturedOutput = ( [ collectStreamText(process.stdout), collectStreamText(process.stderr), - Effect.map(process.exitCode, (value) => Number(value)) + pipe(process.exitCode, Effect.map(Number)) ], { concurrency: "unbounded" } ) ) yield* _( ensureExitCode(exitCode, okExitCodes, (numericExitCode) => - onFailure(numericExitCode, combineCommandOutput(stdout, stderr)) - ) + onFailure(numericExitCode, combineCommandOutput(stdout, stderr))) ) }) ) diff --git a/packages/lib/src/shell/docker.ts b/packages/lib/src/shell/docker.ts index f6982b5..543535d 100644 --- a/packages/lib/src/shell/docker.ts +++ b/packages/lib/src/shell/docker.ts @@ -4,7 +4,12 @@ import { ExitCode } from "@effect/platform/CommandExecutor" import type { PlatformError } from "@effect/platform/Error" import { Duration, Effect, pipe, Schedule } from "effect" -import { runCommandCapture, runCommandExitCode, runCommandWithCapturedOutput, runCommandWithExitCodes } from "./command-runner.js" +import { + runCommandCapture, + runCommandExitCode, + runCommandWithCapturedOutput, + runCommandWithExitCodes +} from "./command-runner.js" import { composeSpec, resolveDockerComposeEnv } from "./docker-compose-env.js" import { parseInspectNetworkEntry } from "./docker-inspect-parse.js" import { CommandFailedError, DockerCommandError } from "./errors.js" diff --git a/packages/lib/src/usecases/account-pool.ts b/packages/lib/src/usecases/account-pool.ts new file mode 100644 index 0000000..d778bf2 --- /dev/null +++ b/packages/lib/src/usecases/account-pool.ts @@ -0,0 +1,304 @@ +// CHANGE: implement multi-account pool management with rate-limit failover +// WHY: enable automatic switching between registered accounts when one hits API rate limits +// QUOTE(ТЗ): "когда на одном лимиты закаончиваются он переходит на другой аккаунт" +// REF: issue-213 +// SOURCE: n/a +// FORMAT THEOREM: ∀pool: selectNextAvailable(pool, now) = Some(account) ↔ ∃a ∈ pool.accounts: cooldownUntil(a) < now +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: pool operations are pure; state transitions are deterministic given the same inputs +// COMPLEXITY: O(n) where n = |accounts| per provider + +import type { + AccountEntry, + AccountPoolProvider, + AccountPoolState, + ProviderAccountPool, + RateLimitEvent +} from "../core/account-pool-domain.js" +import { normalizeAccountLabel } from "./auth-helpers.js" + +// CHANGE: create an empty pool state +// WHY: initial state for account pool management +// PURITY: CORE +// COMPLEXITY: O(1) +export const emptyPoolState = (now: string): AccountPoolState => ({ + pools: [], + updatedAt: now +}) + +// CHANGE: find or create provider pool within state +// PURITY: CORE +// COMPLEXITY: O(p) where p = number of providers +const findProviderPool = ( + state: AccountPoolState, + provider: AccountPoolProvider +): ProviderAccountPool => + state.pools.find((pool) => pool.provider === provider) ?? { + provider, + accounts: [], + activeIndex: 0 + } + +// CHANGE: replace or append provider pool in state +// PURITY: CORE +// COMPLEXITY: O(p) +const upsertProviderPool = ( + state: AccountPoolState, + pool: ProviderAccountPool, + now: string +): AccountPoolState => { + const existing = state.pools.findIndex((p) => p.provider === pool.provider) + const nextPools = [...state.pools] + if (existing === -1) { + nextPools.push(pool) + } else { + nextPools[existing] = pool + } + return { pools: nextPools, updatedAt: now } +} + +/** + * Add an account to a provider's pool. + * + * @pure true + * @precondition label.length > 0 + * @postcondition account with normalized label exists in pool + * @invariant duplicate labels are rejected (returns state unchanged) + * @complexity O(n) where n = |accounts| in the provider pool + */ +export const addAccount = ( + state: AccountPoolState, + provider: AccountPoolProvider, + label: string, + now: string +): AccountPoolState => { + const normalized = normalizeAccountLabel(label, "default") + const pool = findProviderPool(state, provider) + + const exists = pool.accounts.some((account) => account.label === normalized) + if (exists) { + return state + } + + const entry: AccountEntry = { + label: normalized, + provider, + addedAt: now, + cooldownUntil: undefined, + consecutiveFailures: 0 + } + + const nextPool: ProviderAccountPool = { + ...pool, + accounts: [...pool.accounts, entry] + } + + return upsertProviderPool(state, nextPool, now) +} + +/** + * Remove an account from a provider's pool. + * + * @pure true + * @postcondition account with normalized label does not exist in pool + * @complexity O(n) + */ +export const removeAccount = ( + state: AccountPoolState, + provider: AccountPoolProvider, + label: string, + now: string +): AccountPoolState => { + const normalized = normalizeAccountLabel(label, "default") + const pool = findProviderPool(state, provider) + + const nextAccounts = pool.accounts.filter((account) => account.label !== normalized) + if (nextAccounts.length === pool.accounts.length) { + return state + } + + const nextActiveIndex = pool.activeIndex >= nextAccounts.length + ? 0 + : pool.activeIndex + + return upsertProviderPool( + state, + { ...pool, accounts: nextAccounts, activeIndex: nextActiveIndex }, + now + ) +} + +/** + * Mark an account as rate-limited with a cooldown period. + * + * @pure true + * @postcondition account.cooldownUntil = event.detectedAt + event.cooldownMs + * @postcondition account.consecutiveFailures incremented by 1 + * @complexity O(n) + */ +export const markRateLimited = ( + state: AccountPoolState, + event: RateLimitEvent, + now: string +): AccountPoolState => { + const pool = findProviderPool(state, event.provider) + + const nextAccounts = pool.accounts.map((account) => { + if (account.label !== event.label) { + return account + } + const cooldownUntil = new Date( + new Date(event.detectedAt).getTime() + event.cooldownMs + ).toISOString() + return { + ...account, + cooldownUntil, + consecutiveFailures: account.consecutiveFailures + 1 + } + }) + + return upsertProviderPool(state, { ...pool, accounts: nextAccounts }, now) +} + +/** + * Clear rate-limit cooldown for an account (e.g., after successful use). + * + * @pure true + * @postcondition account.cooldownUntil = undefined, consecutiveFailures = 0 + * @complexity O(n) + */ +export const clearCooldown = ( + state: AccountPoolState, + provider: AccountPoolProvider, + label: string, + now: string +): AccountPoolState => { + const normalized = normalizeAccountLabel(label, "default") + const pool = findProviderPool(state, provider) + + const nextAccounts = pool.accounts.map((account) => + account.label === normalized + ? { ...account, cooldownUntil: undefined, consecutiveFailures: 0 } + : account + ) + + return upsertProviderPool(state, { ...pool, accounts: nextAccounts }, now) +} + +/** + * Check if an account is currently under cooldown. + * + * @pure true + * @invariant returns true iff cooldownUntil > now + * @complexity O(1) + */ +export const isAccountCoolingDown = (account: AccountEntry, now: string): boolean => { + if (account.cooldownUntil === undefined) { + return false + } + return new Date(account.cooldownUntil).getTime() > new Date(now).getTime() +} + +/** + * Select the next available account from the provider pool. + * Skips accounts that are still under cooldown. + * Returns undefined if no available account exists. + * + * @pure true + * @postcondition result !== undefined → ¬isAccountCoolingDown(result, now) + * @complexity O(n) + */ +export const selectNextAvailable = ( + state: AccountPoolState, + provider: AccountPoolProvider, + now: string +): AccountEntry | undefined => { + const pool = findProviderPool(state, provider) + if (pool.accounts.length === 0) { + return undefined + } + + const count = pool.accounts.length + for (let offset = 0; offset < count; offset++) { + const index = (pool.activeIndex + offset) % count + const account = pool.accounts[index] + if (account !== undefined && !isAccountCoolingDown(account, now)) { + return account + } + } + + return undefined +} + +/** + * Advance the active index to the next account in the pool (round-robin). + * Called after selecting an account so the next selection starts from the following entry. + * + * @pure true + * @postcondition pool.activeIndex = (pool.activeIndex + 1) % pool.accounts.length + * @complexity O(p) + */ +export const advanceActiveIndex = ( + state: AccountPoolState, + provider: AccountPoolProvider, + now: string +): AccountPoolState => { + const pool = findProviderPool(state, provider) + if (pool.accounts.length === 0) { + return state + } + + const nextIndex = (pool.activeIndex + 1) % pool.accounts.length + return upsertProviderPool(state, { ...pool, activeIndex: nextIndex }, now) +} + +/** + * List all accounts for a provider with their current status. + * + * @pure true + * @complexity O(n) + */ +export const listAccounts = ( + state: AccountPoolState, + provider: AccountPoolProvider +): ReadonlyArray => findProviderPool(state, provider).accounts + +/** + * List all accounts across all providers. + * + * @pure true + * @complexity O(n * p) + */ +export const listAllAccounts = ( + state: AccountPoolState +): ReadonlyArray => state.pools.flatMap((pool) => pool.accounts) + +/** + * Get pool summary for a provider. + * + * @pure true + * @complexity O(n) + */ +export const poolSummary = ( + state: AccountPoolState, + provider: AccountPoolProvider, + now: string +): { + readonly total: number + readonly available: number + readonly coolingDown: number + readonly activeLabel: string | undefined +} => { + const pool = findProviderPool(state, provider) + const total = pool.accounts.length + const coolingDown = pool.accounts.filter((a) => isAccountCoolingDown(a, now)).length + const available = total - coolingDown + const activeAccount = pool.accounts[pool.activeIndex] + return { + total, + available, + coolingDown, + activeLabel: activeAccount?.label + } +} diff --git a/packages/lib/src/usecases/actions/create-project.ts b/packages/lib/src/usecases/actions/create-project.ts index 842e5a8..db52356 100644 --- a/packages/lib/src/usecases/actions/create-project.ts +++ b/packages/lib/src/usecases/actions/create-project.ts @@ -4,18 +4,18 @@ import * as FileSystem from "@effect/platform/FileSystem" import * as Path from "@effect/platform/Path" import { Effect } from "effect" -import type { CreateCommand, ParseError, TemplateConfig } from "../../core/domain.js" -import { deriveRepoPathParts, resolveComposeProjectName, resolveProjectBootstrapVolumeName } from "../../core/domain.js" +import type { CreateCommand, ParseError } from "../../core/domain.js" +import { deriveRepoPathParts } from "../../core/domain.js" import { runCommandWithExitCodes } from "../../shell/command-runner.js" import { ensureDockerDaemonAccess } from "../../shell/docker.js" -import { CommandFailedError, DockerIdentityConflictError } from "../../shell/errors.js" +import { CommandFailedError } from "../../shell/errors.js" import type { AgentFailedError, AuthError, CloneFailedError, DockerAccessError, DockerCommandError, - DockerIdentityConflict, + DockerIdentityConflictError, FileExistsError, PortProbeError } from "../../shell/errors.js" @@ -26,11 +26,11 @@ import { applyGithubForkConfig } from "../github-fork.js" import { validateGithubCloneAuthTokenPreflight } from "../github-token-preflight.js" import { defaultProjectsRoot } from "../menu-helpers.js" import { findSshPrivateKey } from "../path-helpers.js" -import { buildSshCommand, getContainerIpIfInsideContainer, loadProjectIndex, loadProjectStatus } from "../projects-core.js" -import { deleteDockerGitProject } from "../projects-delete.js" +import { buildSshCommand, getContainerIpIfInsideContainer } from "../projects-core.js" import { resolveTemplateResourceLimits } from "../resource-limits.js" import { autoSyncState } from "../state-repo.js" import { ensureTerminalCursorVisible } from "../terminal-cursor.js" +import { deleteConflictingProjectsIfNeeded } from "./docker-identity.js" import { runDockerDownCleanup, runDockerUpIfNeeded } from "./docker-up.js" import { buildProjectConfigs, resolveDockerGitRootRelativePath } from "./paths.js" import { resolveSshPort } from "./ports.js" @@ -97,97 +97,6 @@ const formatStateSyncLabel = (repoUrl: string): string => { return repoPath.length > 0 ? repoPath : repoUrl } -type DockerIdentityOwner = Pick - -type DockerIdentityNamespace = "container" | "composeProject" | "volume" - -type DockerIdentityClaim = Omit & { - readonly namespace: DockerIdentityNamespace -} - -const resolveDockerIdentityClaims = ( - config: DockerIdentityOwner -): ReadonlyArray => [ - { namespace: "container", kind: "containerName", name: config.containerName }, - ...(config.enableMcpPlaywright - ? [{ namespace: "container" as const, kind: "browserContainerName" as const, name: `${config.containerName}-browser` }] - : []), - { namespace: "composeProject", kind: "serviceName", name: resolveComposeProjectName(config) }, - { namespace: "volume", kind: "volumeName", name: config.volumeName }, - ...(config.enableMcpPlaywright - ? [{ namespace: "volume" as const, kind: "browserVolumeName" as const, name: `${config.volumeName}-browser` }] - : []), - { namespace: "volume", kind: "bootstrapVolumeName", name: resolveProjectBootstrapVolumeName(config) } -] - -const deleteConflictingProjectsIfNeeded = ( - resolvedOutDir: string, - config: DockerIdentityOwner, - force: boolean -): Effect.Effect => - Effect.gen(function*(_) { - const index = yield* _(loadProjectIndex()) - if (index === null) { - return - } - - const candidateClaims = resolveDockerIdentityClaims(config) - const conflicts: Array = [] - const conflictingProjects = new Map() - - for (const configPath of index.configPaths) { - const status = yield* _( - loadProjectStatus(configPath).pipe( - Effect.match({ - onFailure: () => null, - onSuccess: (value) => value - }) - ) - ) - if (status === null || status.projectDir === resolvedOutDir) { - continue - } - - const existingClaims = resolveDockerIdentityClaims(status.config.template) - const sharedClaims = candidateClaims.flatMap((candidate) => - existingClaims.some( - (existing) => existing.namespace === candidate.namespace && existing.name === candidate.name - ) - ? [{ conflictingProjectDir: status.projectDir, kind: candidate.kind, name: candidate.name }] - : [] - ) - - if (sharedClaims.length === 0) { - continue - } - - conflicts.push(...sharedClaims) - conflictingProjects.set(status.projectDir, { - projectDir: status.projectDir, - repoUrl: status.config.template.repoUrl, - containerName: status.config.template.containerName, - serviceName: status.config.template.serviceName - }) - } - - if (conflicts.length === 0) { - return - } - - if (!force) { - return yield* _(Effect.fail(new DockerIdentityConflictError({ projectDir: resolvedOutDir, conflicts }))) - } - - for (const conflictingProject of conflictingProjects.values()) { - yield* _( - Effect.logWarning( - `Force enabled: replacing conflicting docker-git project ${conflictingProject.projectDir}` - ) - ) - yield* _(deleteDockerGitProject(conflictingProject)) - } - }) - const isInteractiveTty = (): boolean => process.stdin.isTTY && process.stdout.isTTY const buildSshArgs = ( diff --git a/packages/lib/src/usecases/actions/docker-identity.ts b/packages/lib/src/usecases/actions/docker-identity.ts new file mode 100644 index 0000000..4e9a922 --- /dev/null +++ b/packages/lib/src/usecases/actions/docker-identity.ts @@ -0,0 +1,129 @@ +import type * as CommandExecutor from "@effect/platform/CommandExecutor" +import type { PlatformError } from "@effect/platform/Error" +import type * as FileSystem from "@effect/platform/FileSystem" +import type * as Path from "@effect/platform/Path" +import { Effect } from "effect" + +import type { TemplateConfig } from "../../core/domain.js" +import { resolveComposeProjectName, resolveProjectBootstrapVolumeName } from "../../core/domain.js" +import { DockerIdentityConflictError } from "../../shell/errors.js" +import type { DockerCommandError, DockerIdentityConflict } from "../../shell/errors.js" +import { loadProjectIndex, loadProjectStatus } from "../projects-core.js" +import { deleteDockerGitProject } from "../projects-delete.js" + +type CreateProjectRuntime = FileSystem.FileSystem | Path.Path | CommandExecutor.CommandExecutor + +type DockerIdentityOwner = Pick + +type ConflictingProjectEntry = { + readonly projectDir: string + readonly repoUrl: string + readonly containerName: string + readonly serviceName: string +} + +type DockerIdentityNamespace = "container" | "composeProject" | "volume" + +type DockerIdentityClaim = Omit & { + readonly namespace: DockerIdentityNamespace +} + +const resolveDockerIdentityClaims = ( + config: DockerIdentityOwner +): ReadonlyArray => [ + { namespace: "container", kind: "containerName", name: config.containerName }, + ...(config.enableMcpPlaywright + ? [ + { + namespace: "container", + kind: "browserContainerName", + name: `${config.containerName}-browser` + } satisfies DockerIdentityClaim + ] + : []), + { namespace: "composeProject", kind: "serviceName", name: resolveComposeProjectName(config) }, + { namespace: "volume", kind: "volumeName", name: config.volumeName }, + ...(config.enableMcpPlaywright + ? [ + { + namespace: "volume", + kind: "browserVolumeName", + name: `${config.volumeName}-browser` + } satisfies DockerIdentityClaim + ] + : []), + { namespace: "volume", kind: "bootstrapVolumeName", name: resolveProjectBootstrapVolumeName(config) } +] + +const detectDockerIdentityConflicts = ( + resolvedOutDir: string, + config: DockerIdentityOwner, + configPaths: ReadonlyArray +): Effect.Effect< + { + readonly conflicts: ReadonlyArray + readonly projects: Map + }, + PlatformError, + CreateProjectRuntime +> => + Effect.gen(function*(_) { + const candidateClaims = resolveDockerIdentityClaims(config) + const conflicts: Array = [] + const projects = new Map() + for (const configPath of configPaths) { + const status = yield* _( + loadProjectStatus(configPath).pipe( + Effect.match({ + onFailure: () => null, + onSuccess: (value) => value + }) + ) + ) + if (status === null || status.projectDir === resolvedOutDir) { + continue + } + const existingClaims = resolveDockerIdentityClaims(status.config.template) + const sharedClaims = candidateClaims.flatMap((candidate) => + existingClaims.some( + (existing) => existing.namespace === candidate.namespace && existing.name === candidate.name + ) + ? [{ conflictingProjectDir: status.projectDir, kind: candidate.kind, name: candidate.name }] + : [] + ) + if (sharedClaims.length === 0) { + continue + } + for (const claim of sharedClaims) conflicts.push(claim) + projects.set(status.projectDir, { + projectDir: status.projectDir, + repoUrl: status.config.template.repoUrl, + containerName: status.config.template.containerName, + serviceName: status.config.template.serviceName + }) + } + return { conflicts, projects } + }) + +export const deleteConflictingProjectsIfNeeded = ( + resolvedOutDir: string, + config: DockerIdentityOwner, + force: boolean +): Effect.Effect => + Effect.gen(function*(_) { + const index = yield* _(loadProjectIndex()) + if (index === null) return + const { conflicts, projects } = yield* _(detectDockerIdentityConflicts(resolvedOutDir, config, index.configPaths)) + if (conflicts.length === 0) return + if (!force) { + return yield* _(Effect.fail(new DockerIdentityConflictError({ projectDir: resolvedOutDir, conflicts }))) + } + for (const conflictingProject of projects.values()) { + yield* _( + Effect.logWarning( + `Force enabled: replacing conflicting docker-git project ${conflictingProject.projectDir}` + ) + ) + yield* _(deleteDockerGitProject(conflictingProject)) + } + }) diff --git a/packages/lib/src/usecases/actions/prepare-files.ts b/packages/lib/src/usecases/actions/prepare-files.ts index ceb3594..81a8a98 100644 --- a/packages/lib/src/usecases/actions/prepare-files.ts +++ b/packages/lib/src/usecases/actions/prepare-files.ts @@ -275,7 +275,14 @@ export const prepareProjectFiles = ( const rewriteManagedFiles = options.force || options.forceEnv const envOnlyRefresh = options.forceEnv && !options.force const createdFiles = yield* _(writeProjectFiles(resolvedOutDir, projectConfig, rewriteManagedFiles)) - yield* _(ensureAuthorizedKeys(resolvedOutDir, projectConfig.authorizedKeysPath, globalConfig.authorizedKeysPath, options.force)) + yield* _( + ensureAuthorizedKeys( + resolvedOutDir, + projectConfig.authorizedKeysPath, + globalConfig.authorizedKeysPath, + options.force + ) + ) yield* _(ensureEnvFile(resolvedOutDir, projectConfig.envGlobalPath, defaultGlobalEnvContents)) yield* _(ensureEnvFile(resolvedOutDir, projectConfig.envProjectPath, defaultProjectEnvContents, envOnlyRefresh)) yield* _(ensureCodexConfigFile(baseDir, globalConfig.codexAuthPath)) diff --git a/packages/lib/src/usecases/auth-codex.ts b/packages/lib/src/usecases/auth-codex.ts index 1183b3c..5f52b04 100644 --- a/packages/lib/src/usecases/auth-codex.ts +++ b/packages/lib/src/usecases/auth-codex.ts @@ -187,8 +187,8 @@ export const authCodexLogin = ( runCodexLogin(cwd, accountPath).pipe( Effect.flatMap((output) => (output.length === 0 ? Effect.void : Effect.log(output))) )).pipe( - Effect.zipRight(autoSyncState(`chore(state): auth codex ${normalizeAccountLabel(command.label, "default")}`)) - ) + Effect.zipRight(autoSyncState(`chore(state): auth codex ${normalizeAccountLabel(command.label, "default")}`)) + ) // CHANGE: show Codex auth status for a given label // WHY: make it obvious whether Codex is connected diff --git a/packages/lib/src/usecases/auth-sync.ts b/packages/lib/src/usecases/auth-sync.ts index 8f4d2cc..236a769 100644 --- a/packages/lib/src/usecases/auth-sync.ts +++ b/packages/lib/src/usecases/auth-sync.ts @@ -168,7 +168,14 @@ export const syncAuthArtifacts = ( yield* _(copyFileIfNeeded(sourceGlobal, targetGlobal)) yield* _(syncGithubTokenKeysInFile(sourceGlobal, targetGlobal)) yield* _(copyFileIfNeeded(sourceProject, targetProject)) - yield* _(copyCodexFile(fs, path, { sourceDir: sourceCodex, targetDir: targetCodex, fileName: "auth.json", label: "auth" })) + yield* _( + copyCodexFile(fs, path, { + sourceDir: sourceCodex, + targetDir: targetCodex, + fileName: "auth.json", + label: "auth" + }) + ) if (sourceCodex !== targetCodex) { yield* _( copyCodexFile(fs, path, { diff --git a/packages/lib/src/usecases/github-token-preflight.ts b/packages/lib/src/usecases/github-token-preflight.ts index c66f54e..988cef2 100644 --- a/packages/lib/src/usecases/github-token-preflight.ts +++ b/packages/lib/src/usecases/github-token-preflight.ts @@ -16,13 +16,11 @@ import { export { githubInvalidTokenMessage } from "./github-token-validation.js" -export const githubMissingTokenMessage = - [ - "GitHub auth is missing: no GitHub token/key was found for this repository.", - "If the repository requires access, run: docker-git auth github login --web" - ].join("\n") -export const githubRepoAccessWarning = - "Unable to validate GitHub repository access before start; continuing." +export const githubMissingTokenMessage = [ + "GitHub auth is missing: no GitHub token/key was found for this repository.", + "If the repository requires access, run: docker-git auth github login --web" +].join("\n") +export const githubRepoAccessWarning = "Unable to validate GitHub repository access before start; continuing." export type GithubRepoAccessStatus = "accessible" | "notAccessible" | "unknown" @@ -167,16 +165,15 @@ export const validateGithubCloneAuthTokenPreflight = ( return } - const token = - config.skipGithubAuth === true - ? null - : yield* _( - Effect.gen(function*(__) { - const fs = yield* __(FileSystem.FileSystem) - const envText = yield* __(readEnvText(fs, config.envGlobalPath)) - return resolveGithubCloneAuthToken(envText, config) - }) - ) + const token = config.skipGithubAuth + ? null + : yield* _( + Effect.gen(function*(__) { + const fs = yield* __(FileSystem.FileSystem) + const envText = yield* __(readEnvText(fs, config.envGlobalPath)) + return resolveGithubCloneAuthToken(envText, config) + }) + ) if (token !== null) { const validation = yield* _(validateGithubToken(token)) @@ -196,7 +193,8 @@ export const validateGithubCloneAuthTokenPreflight = ( Match.when("accessible", () => Effect.void), Match.when("notAccessible", () => Effect.fail(new AuthError({ message: githubRepoAccessMessage(config.repoUrl, token !== null) }))), - Match.when("unknown", () => Effect.logWarning(githubRepoAccessWarning)), + Match.when("unknown", () => + Effect.logWarning(githubRepoAccessWarning)), Match.exhaustive ) ) diff --git a/packages/lib/src/usecases/github-token-validation.ts b/packages/lib/src/usecases/github-token-validation.ts index 24f10e4..b24a47c 100644 --- a/packages/lib/src/usecases/github-token-validation.ts +++ b/packages/lib/src/usecases/github-token-validation.ts @@ -6,11 +6,10 @@ import { Effect, Either } from "effect" const githubTokenValidationUrl = "https://api.github.com/user" export const githubTokenValidationWarning = "Unable to validate GitHub token before start; continuing." -export const githubInvalidTokenMessage = - [ - "GitHub auth is invalid: the stored token is dead, revoked, expired, or malformed.", - "To restore access, run: docker-git auth github login --web" - ].join("\n") +export const githubInvalidTokenMessage = [ + "GitHub auth is invalid: the stored token is dead, revoked, expired, or malformed.", + "To restore access, run: docker-git auth github login --web" +].join("\n") type GithubUser = { readonly login: string diff --git a/packages/lib/src/usecases/rate-limit-detector.ts b/packages/lib/src/usecases/rate-limit-detector.ts new file mode 100644 index 0000000..e29fb0a --- /dev/null +++ b/packages/lib/src/usecases/rate-limit-detector.ts @@ -0,0 +1,68 @@ +// CHANGE: add rate-limit detection for agent output streams +// WHY: detect when an AI agent hits API rate limits to trigger automatic account switching +// QUOTE(ТЗ): "когда на одном лимиты закаончиваются он переходит на другой аккаунт" +// REF: issue-213 +// SOURCE: n/a +// FORMAT THEOREM: ∀line ∈ Output: detectRateLimit(provider, line) = Some(event) ↔ ∃pattern: pattern.test(line) +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: detection is stateless per line; no false positives on normal operational messages +// COMPLEXITY: O(p) where p = number of patterns per provider + +import type { AccountPoolProvider, RateLimitEvent } from "../core/account-pool-domain.js" +import { rateLimitPatterns } from "../core/account-pool-domain.js" + +/** + * Attempt to detect a rate-limit event from an agent output line. + * Returns a RateLimitEvent if the line matches a known rate-limit pattern, undefined otherwise. + * + * @pure true + * @precondition line.length > 0 + * @postcondition result !== undefined → result.provider === provider ∧ result.label === label + * @invariant detection is deterministic given the same inputs + * @complexity O(p) where p = number of patterns for the provider + */ +export const detectRateLimit = ( + provider: AccountPoolProvider, + label: string, + line: string, + now: string +): RateLimitEvent | undefined => { + for (const entry of rateLimitPatterns) { + if (entry.provider !== provider) { + continue + } + if (entry.pattern.test(line)) { + return { + provider, + label, + detectedAt: now, + cooldownMs: entry.defaultCooldownMs, + reason: line.slice(0, 200) + } + } + } + return undefined +} + +/** + * Check multiple lines for rate-limit signals. + * Returns the first detected event or undefined. + * + * @pure true + * @complexity O(n * p) where n = number of lines + */ +export const detectRateLimitInLines = ( + provider: AccountPoolProvider, + label: string, + lines: ReadonlyArray, + now: string +): RateLimitEvent | undefined => { + for (const line of lines) { + const event = detectRateLimit(provider, label, line, now) + if (event !== undefined) { + return event + } + } + return undefined +} diff --git a/packages/lib/src/usecases/shared-volume-seed.ts b/packages/lib/src/usecases/shared-volume-seed.ts index e7a3763..e43d8a3 100644 --- a/packages/lib/src/usecases/shared-volume-seed.ts +++ b/packages/lib/src/usecases/shared-volume-seed.ts @@ -203,7 +203,9 @@ const copyBootstrapSnapshotAuthDirs = ( yield* _(copyLabeledCodexFiles(fs, path, sources.codexAuthSource, targets.projectCodexTarget, "auth.json")) yield* _(copyLabeledCodexFiles(fs, path, sources.codexAuthSource, targets.projectCodexTarget, "config.toml")) yield* _(copyDirRecursive(fs, path, sources.claudeAuthSource, targets.projectClaudeTarget)) - yield* _(copyCodexAuthFileIfPresent(fs, path, sources.codexSharedAuthSource, targets.sharedCodexTarget, "auth.json")) + yield* _( + copyCodexAuthFileIfPresent(fs, path, sources.codexSharedAuthSource, targets.sharedCodexTarget, "auth.json") + ) yield* _(copyLabeledCodexFiles(fs, path, sources.codexSharedAuthSource, targets.sharedCodexTarget, "auth.json")) }) diff --git a/packages/lib/tests/usecases/account-pool.test.ts b/packages/lib/tests/usecases/account-pool.test.ts new file mode 100644 index 0000000..47ddd2c --- /dev/null +++ b/packages/lib/tests/usecases/account-pool.test.ts @@ -0,0 +1,260 @@ +import { describe, expect, it } from "@effect/vitest" + +import type { AccountPoolState, RateLimitEvent } from "../../src/core/account-pool-domain.js" +import { + addAccount, + advanceActiveIndex, + clearCooldown, + emptyPoolState, + isAccountCoolingDown, + listAccounts, + listAllAccounts, + markRateLimited, + poolSummary, + removeAccount, + selectNextAvailable +} from "../../src/usecases/account-pool.js" + +const now = "2026-04-07T12:00:00.000Z" +const later = "2026-04-07T12:10:00.000Z" +const pastCooldown = "2026-04-07T12:06:00.000Z" + +describe("account-pool", () => { + describe("emptyPoolState", () => { + it("creates an empty state", () => { + const state = emptyPoolState(now) + expect(state.pools).toEqual([]) + expect(state.updatedAt).toBe(now) + }) + }) + + describe("addAccount", () => { + it("adds a new account to an empty pool", () => { + const state = addAccount(emptyPoolState(now), "claude", "account-1", now) + const accounts = listAccounts(state, "claude") + expect(accounts).toHaveLength(1) + expect(accounts[0]?.label).toBe("account-1") + expect(accounts[0]?.provider).toBe("claude") + expect(accounts[0]?.cooldownUntil).toBeUndefined() + expect(accounts[0]?.consecutiveFailures).toBe(0) + }) + + it("does not add duplicate labels", () => { + let state = addAccount(emptyPoolState(now), "claude", "account-1", now) + state = addAccount(state, "claude", "account-1", now) + expect(listAccounts(state, "claude")).toHaveLength(1) + }) + + it("normalizes labels", () => { + const state = addAccount(emptyPoolState(now), "claude", "My Account!", now) + const accounts = listAccounts(state, "claude") + expect(accounts[0]?.label).toBe("my-account") + }) + + it("adds accounts to different providers independently", () => { + let state = addAccount(emptyPoolState(now), "claude", "a", now) + state = addAccount(state, "codex", "b", now) + expect(listAccounts(state, "claude")).toHaveLength(1) + expect(listAccounts(state, "codex")).toHaveLength(1) + }) + }) + + describe("removeAccount", () => { + it("removes an existing account", () => { + let state = addAccount(emptyPoolState(now), "claude", "a", now) + state = addAccount(state, "claude", "b", now) + state = removeAccount(state, "claude", "a", now) + expect(listAccounts(state, "claude")).toHaveLength(1) + expect(listAccounts(state, "claude")[0]?.label).toBe("b") + }) + + it("is a no-op for non-existent accounts", () => { + const state = addAccount(emptyPoolState(now), "claude", "a", now) + const next = removeAccount(state, "claude", "nonexistent", now) + expect(listAccounts(next, "claude")).toHaveLength(1) + }) + }) + + describe("markRateLimited", () => { + it("sets cooldown and increments failure count", () => { + let state = addAccount(emptyPoolState(now), "claude", "a", now) + const event: RateLimitEvent = { + provider: "claude", + label: "a", + detectedAt: now, + cooldownMs: 5 * 60 * 1000, + reason: "rate limit exceeded" + } + state = markRateLimited(state, event, now) + const accounts = listAccounts(state, "claude") + expect(accounts[0]?.cooldownUntil).toBe("2026-04-07T12:05:00.000Z") + expect(accounts[0]?.consecutiveFailures).toBe(1) + }) + }) + + describe("isAccountCoolingDown", () => { + it("returns true when cooldown is in the future", () => { + let state = addAccount(emptyPoolState(now), "claude", "a", now) + const event: RateLimitEvent = { + provider: "claude", + label: "a", + detectedAt: now, + cooldownMs: 5 * 60 * 1000, + reason: "rate limit" + } + state = markRateLimited(state, event, now) + const account = listAccounts(state, "claude")[0]! + expect(isAccountCoolingDown(account, now)).toBe(true) + }) + + it("returns false when cooldown has expired", () => { + let state = addAccount(emptyPoolState(now), "claude", "a", now) + const event: RateLimitEvent = { + provider: "claude", + label: "a", + detectedAt: now, + cooldownMs: 5 * 60 * 1000, + reason: "rate limit" + } + state = markRateLimited(state, event, now) + const account = listAccounts(state, "claude")[0]! + expect(isAccountCoolingDown(account, pastCooldown)).toBe(false) + }) + + it("returns false when no cooldown is set", () => { + const state = addAccount(emptyPoolState(now), "claude", "a", now) + const account = listAccounts(state, "claude")[0]! + expect(isAccountCoolingDown(account, now)).toBe(false) + }) + }) + + describe("clearCooldown", () => { + it("resets cooldown and failure count", () => { + let state = addAccount(emptyPoolState(now), "claude", "a", now) + const event: RateLimitEvent = { + provider: "claude", + label: "a", + detectedAt: now, + cooldownMs: 5 * 60 * 1000, + reason: "rate limit" + } + state = markRateLimited(state, event, now) + state = clearCooldown(state, "claude", "a", later) + const account = listAccounts(state, "claude")[0]! + expect(account.cooldownUntil).toBeUndefined() + expect(account.consecutiveFailures).toBe(0) + }) + }) + + describe("selectNextAvailable", () => { + it("returns the first available account", () => { + let state = addAccount(emptyPoolState(now), "claude", "a", now) + state = addAccount(state, "claude", "b", now) + const account = selectNextAvailable(state, "claude", now) + expect(account?.label).toBe("a") + }) + + it("skips rate-limited accounts", () => { + let state = addAccount(emptyPoolState(now), "claude", "a", now) + state = addAccount(state, "claude", "b", now) + const event: RateLimitEvent = { + provider: "claude", + label: "a", + detectedAt: now, + cooldownMs: 5 * 60 * 1000, + reason: "rate limit" + } + state = markRateLimited(state, event, now) + const account = selectNextAvailable(state, "claude", now) + expect(account?.label).toBe("b") + }) + + it("returns undefined when all accounts are cooling down", () => { + let state = addAccount(emptyPoolState(now), "claude", "a", now) + const event: RateLimitEvent = { + provider: "claude", + label: "a", + detectedAt: now, + cooldownMs: 5 * 60 * 1000, + reason: "rate limit" + } + state = markRateLimited(state, event, now) + const account = selectNextAvailable(state, "claude", now) + expect(account).toBeUndefined() + }) + + it("returns undefined for empty pool", () => { + const state = emptyPoolState(now) + const account = selectNextAvailable(state, "claude", now) + expect(account).toBeUndefined() + }) + + it("returns expired-cooldown account when cooldown has passed", () => { + let state = addAccount(emptyPoolState(now), "claude", "a", now) + const event: RateLimitEvent = { + provider: "claude", + label: "a", + detectedAt: now, + cooldownMs: 5 * 60 * 1000, + reason: "rate limit" + } + state = markRateLimited(state, event, now) + const account = selectNextAvailable(state, "claude", pastCooldown) + expect(account?.label).toBe("a") + }) + }) + + describe("advanceActiveIndex", () => { + it("advances to the next account", () => { + let state = addAccount(emptyPoolState(now), "claude", "a", now) + state = addAccount(state, "claude", "b", now) + state = addAccount(state, "claude", "c", now) + state = advanceActiveIndex(state, "claude", now) + + // After advancing, the next available should start from index 1 + const account = selectNextAvailable(state, "claude", now) + expect(account?.label).toBe("b") + }) + + it("wraps around to the beginning", () => { + let state = addAccount(emptyPoolState(now), "claude", "a", now) + state = addAccount(state, "claude", "b", now) + state = advanceActiveIndex(state, "claude", now) + state = advanceActiveIndex(state, "claude", now) + const account = selectNextAvailable(state, "claude", now) + expect(account?.label).toBe("a") + }) + }) + + describe("listAllAccounts", () => { + it("lists accounts across all providers", () => { + let state = addAccount(emptyPoolState(now), "claude", "a", now) + state = addAccount(state, "codex", "b", now) + state = addAccount(state, "gemini", "c", now) + const all = listAllAccounts(state) + expect(all).toHaveLength(3) + expect(all.map((a) => a.label).sort()).toEqual(["a", "b", "c"]) + }) + }) + + describe("poolSummary", () => { + it("summarizes pool state correctly", () => { + let state = addAccount(emptyPoolState(now), "claude", "a", now) + state = addAccount(state, "claude", "b", now) + state = addAccount(state, "claude", "c", now) + const event: RateLimitEvent = { + provider: "claude", + label: "a", + detectedAt: now, + cooldownMs: 5 * 60 * 1000, + reason: "rate limit" + } + state = markRateLimited(state, event, now) + const summary = poolSummary(state, "claude", now) + expect(summary.total).toBe(3) + expect(summary.available).toBe(2) + expect(summary.coolingDown).toBe(1) + expect(summary.activeLabel).toBe("a") + }) + }) +}) diff --git a/packages/lib/tests/usecases/rate-limit-detector.test.ts b/packages/lib/tests/usecases/rate-limit-detector.test.ts new file mode 100644 index 0000000..c43b375 --- /dev/null +++ b/packages/lib/tests/usecases/rate-limit-detector.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "@effect/vitest" + +import { detectRateLimit, detectRateLimitInLines } from "../../src/usecases/rate-limit-detector.js" + +const now = "2026-04-07T12:00:00.000Z" + +describe("rate-limit-detector", () => { + describe("detectRateLimit", () => { + it("detects Claude rate limit message", () => { + const event = detectRateLimit("claude", "account-1", "Error: rate limit exceeded for this account", now) + expect(event).not.toBeUndefined() + expect(event?.provider).toBe("claude") + expect(event?.label).toBe("account-1") + expect(event?.cooldownMs).toBe(5 * 60 * 1000) + }) + + it("detects 429 status code", () => { + const event = detectRateLimit("codex", "default", "HTTP 429 Too Many Requests", now) + expect(event).not.toBeUndefined() + expect(event?.provider).toBe("codex") + }) + + it("detects quota exceeded", () => { + const event = detectRateLimit("claude", "a", "Your quota exceeded for this billing period", now) + expect(event).not.toBeUndefined() + }) + + it("detects usage limit", () => { + const event = detectRateLimit("claude", "a", "Usage limit reached", now) + expect(event).not.toBeUndefined() + }) + + it("detects throttling", () => { + const event = detectRateLimit("codex", "a", "Request throttled", now) + expect(event).not.toBeUndefined() + }) + + it("does not detect normal messages", () => { + const event = detectRateLimit("claude", "a", "Successfully completed task", now) + expect(event).toBeUndefined() + }) + + it("does not detect empty lines", () => { + const event = detectRateLimit("claude", "a", "", now) + expect(event).toBeUndefined() + }) + + it("detects Gemini resource exhausted", () => { + const event = detectRateLimit("gemini", "a", "RESOURCE_EXHAUSTED: quota exceeded", now) + expect(event).not.toBeUndefined() + }) + + it("truncates long reason strings", () => { + const longLine = "rate limit " + "x".repeat(300) + const event = detectRateLimit("claude", "a", longLine, now) + expect(event).not.toBeUndefined() + expect(event!.reason.length).toBeLessThanOrEqual(200) + }) + }) + + describe("detectRateLimitInLines", () => { + it("finds rate limit in multiple lines", () => { + const lines = [ + "Starting agent...", + "Processing task...", + "Error: 429 Too Many Requests", + "Shutting down..." + ] + const event = detectRateLimitInLines("claude", "a", lines, now) + expect(event).not.toBeUndefined() + }) + + it("returns undefined when no rate limit found", () => { + const lines = [ + "Starting agent...", + "Processing task...", + "Task completed successfully" + ] + const event = detectRateLimitInLines("claude", "a", lines, now) + expect(event).toBeUndefined() + }) + + it("returns the first match", () => { + const lines = [ + "rate limit on account", + "429 error" + ] + const event = detectRateLimitInLines("claude", "a", lines, now) + expect(event).not.toBeUndefined() + expect(event?.reason).toContain("rate limit") + }) + }) +})