Skip to content
Merged
8 changes: 5 additions & 3 deletions packages/api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,11 @@ glance and the CLI now distinguishes them in its error output:
(`docker` group / rootless Docker / socket ownership). This is a host
configuration problem, not a `docker-git` outage.
- **Controller container not running / unreachable** – the API at
`DOCKER_GIT_API_URL` (default `http://127.0.0.1:3334`) does not answer.
Bring the controller up with `docker compose up -d --build` or point the
CLI at an existing controller via `DOCKER_GIT_API_URL`.
a custom `DOCKER_GIT_API_URL` does not answer. Bring the controller up
with `docker compose up -d --build` or point the CLI at an existing
controller via `DOCKER_GIT_API_URL`. The default local value
(`http://127.0.0.1:3334`, `http://localhost:3334`, or `http://[::1]:3334`)
does not block local Docker bootstrap.

Diagnostic classification + remediation messages live in
`packages/app/src/docker-git/controller-docker-diagnostics.ts` and are
Expand Down
23 changes: 16 additions & 7 deletions packages/app/src/docker-git/browser-frontend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,12 @@ import {
shouldReuseBrowserFrontend
} from "./browser-frontend-state.js"
import { findReachableApiBaseUrl } from "./controller-health.js"
import { resolveConfiguredApiBaseUrl, resolveExplicitApiBaseUrl } from "./controller-reachability.js"
import {
resolveConfiguredApiBaseUrl,
resolveDefaultLocalApiBaseUrl,
resolveExplicitApiBaseUrl,
uniqueStrings
} from "./controller-reachability.js"
import { type ControllerRuntime, ensureControllerReady, resolveApiBaseUrl } from "./controller.js"
import {
runCommandCapture,
Expand Down Expand Up @@ -153,19 +158,19 @@ const readBrowserFrontendRuntimeState = (
// QUOTE(ТЗ): "комментарии ребита надо было тоже поддержать"
// REF: PR #344 E2E (Browser command) regression.
// SOURCE: n/a
// FORMAT THEOREM: explicit_api -> explicit_api; reachable(configured_api) -> configured_api; otherwise -> selected_api
// FORMAT THEOREM: strict_explicit_api -> strict_explicit_api; reachable(local_api) -> local_api; otherwise -> selected_api
// PURITY: SHELL
// EFFECT: Effect<string, never, ControllerRuntime>
// INVARIANT: explicit DOCKER_GIT_API_URL is never overridden by auto-discovery.
// INVARIANT: strict explicit DOCKER_GIT_API_URL is never overridden by auto-discovery.
// COMPLEXITY: O(1) probes/O(1) space.
/**
* Resolves the API URL used by the browser frontend proxy.
*
* @returns Effect with the explicit API URL, the reachable configured host URL, or the selected controller URL.
* @returns Effect with the strict explicit API URL, a reachable local host URL, or the selected controller URL.
*
* @pure false
* @effect FetchHttpClient through controller health probing.
* @invariant Explicit `DOCKER_GIT_API_URL` has precedence over all inferred endpoints.
* @invariant Strict explicit `DOCKER_GIT_API_URL` has precedence over all inferred endpoints.
* @precondition `ensureControllerReady` has already completed for inferred endpoints.
* @postcondition A configured host URL is used only after a successful health probe.
* @complexity O(1) time and O(1) space for the bounded candidate set.
Expand All @@ -179,11 +184,15 @@ const resolveBrowserFrontendApiBaseUrl = (): Effect.Effect<string, never, Contro
}

const configuredApiBaseUrl = resolveConfiguredApiBaseUrl()
if (configuredApiBaseUrl === selectedApiBaseUrl) {
const candidateApiBaseUrls = uniqueStrings([
resolveDefaultLocalApiBaseUrl() ?? "",
configuredApiBaseUrl
].filter((value) => value.length > 0))
if (candidateApiBaseUrls.includes(selectedApiBaseUrl)) {
return Effect.succeed(selectedApiBaseUrl)
}

return findReachableApiBaseUrl([configuredApiBaseUrl]).pipe(
return findReachableApiBaseUrl(candidateApiBaseUrls).pipe(
Effect.match({
onFailure: () => selectedApiBaseUrl,
onSuccess: (apiBaseUrl) => apiBaseUrl
Expand Down
2 changes: 2 additions & 0 deletions packages/app/src/docker-git/controller-health.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,11 +100,13 @@ export const findReachableApiBaseUrl = (

export const findReachableDirectHealthProbe = (options: {
readonly explicitApiBaseUrl: string | undefined
readonly defaultLocalApiBaseUrl: string | undefined
readonly cachedApiBaseUrl: string | undefined
}): Effect.Effect<HealthProbeResult | null> =>
findReachableHealthProbeOrNull(
buildApiBaseUrlCandidates({
explicitApiBaseUrl: options.explicitApiBaseUrl,
defaultLocalApiBaseUrl: options.defaultLocalApiBaseUrl,
cachedApiBaseUrl: options.cachedApiBaseUrl,
defaultApiBaseUrl: resolveConfiguredApiBaseUrl(),
currentContainerNetworks: {},
Expand Down
65 changes: 63 additions & 2 deletions packages/app/src/docker-git/controller-reachability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export type DockerNetworkIps = Readonly<Record<string, string>>

export type ApiBaseUrlCandidatesInput = {
readonly explicitApiBaseUrl?: string | undefined
readonly defaultLocalApiBaseUrl?: string | undefined
readonly cachedApiBaseUrl?: string | undefined
readonly defaultApiBaseUrl: string
readonly currentContainerNetworks: DockerNetworkIps
Expand All @@ -28,11 +29,69 @@ const normalizePort = (value: string | undefined): string => {
return trimmed.length > 0 ? trimmed : defaultApiPort
}

const normalizeApiBaseUrl = (value: string | undefined): string | undefined => {
const trimmed = value?.trim()
return trimmed !== undefined && trimmed.length > 0 ? trimTrailingSlashes(trimmed) : undefined
}

export const resolveApiPort = (): string => normalizePort(process.env["DOCKER_GIT_API_PORT"])

const defaultLocalHostnames = new Set(["127.0.0.1", "localhost", "[::1]"])

const isRootApiUrlPath = (url: URL): boolean => url.pathname === "/" && url.search.length === 0 && url.hash.length === 0

const isDefaultLocalApiUrlObject = (url: URL, port: string): boolean =>
url.protocol === "http:" &&
defaultLocalHostnames.has(url.hostname) &&
url.port === port &&
isRootApiUrlPath(url)

// CHANGE: classify default localhost API URLs as non-strict bootstrap hints.
// WHY: Windows shells can persist DOCKER_GIT_API_URL=http://127.0.0.1:3334, which should not block local controller startup.
// QUOTE(ТЗ): "сделать из коробки что бы всё само работало"
// REF: user-request-2026-05-29-default-local-api-url-bootstrap
// SOURCE: n/a
// FORMAT THEOREM: local_http(url, port) and empty(path, query, hash) -> default_local(url)
// PURITY: CORE
// EFFECT: n/a
// INVARIANT: only localhost loopback HTTP URLs on the configured API port are default-local.
// COMPLEXITY: O(n) where n = |value|.
export const isDefaultLocalApiBaseUrl = (value: string, port = resolveApiPort()): boolean => {
const normalized = normalizeApiBaseUrl(value)
if (normalized === undefined || !URL.canParse(normalized)) {
return false
}
return isDefaultLocalApiUrlObject(new URL(normalized), port)
}

// CHANGE: preserve default-local DOCKER_GIT_API_URL as an endpoint candidate instead of a strict override.
// WHY: a stale default localhost env var should still allow compose bootstrap when nothing is listening yet.
// QUOTE(ТЗ): "fallback только для дефолтного localhost URL"
// REF: user-request-2026-05-29-default-local-api-url-bootstrap
// SOURCE: n/a
// FORMAT THEOREM: default_local(env) -> env; otherwise -> undefined
// PURITY: SHELL
// EFFECT: reads process.env
// INVARIANT: custom DOCKER_GIT_API_URL values are never returned here.
// COMPLEXITY: O(n) where n = |DOCKER_GIT_API_URL|.
export const resolveDefaultLocalApiBaseUrl = (): string | undefined => {
const explicit = normalizeApiBaseUrl(process.env["DOCKER_GIT_API_URL"])
return explicit !== undefined && isDefaultLocalApiBaseUrl(explicit) ? explicit : undefined
}

// CHANGE: treat only custom DOCKER_GIT_API_URL values as strict explicit controller endpoints.
// WHY: custom remote backends should fail loudly when unreachable, while default localhost should bootstrap locally.
// QUOTE(ТЗ): "кастомные URL остаются строгими"
// REF: user-request-2026-05-29-default-local-api-url-bootstrap
// SOURCE: n/a
// FORMAT THEOREM: nonempty(env) and not default_local(env) -> env; otherwise -> undefined
// PURITY: SHELL
// EFFECT: reads process.env
// INVARIANT: default-local URLs do not block local bootstrap.
// COMPLEXITY: O(n) where n = |DOCKER_GIT_API_URL|.
export const resolveExplicitApiBaseUrl = (): string | undefined => {
const explicit = process.env["DOCKER_GIT_API_URL"]?.trim()
return explicit !== undefined && explicit.length > 0 ? trimTrailingSlashes(explicit) : undefined
const explicit = normalizeApiBaseUrl(process.env["DOCKER_GIT_API_URL"])
return explicit !== undefined && !isDefaultLocalApiBaseUrl(explicit) ? explicit : undefined
}

export const resolveConfiguredApiBaseUrl = (): string => {
Expand Down Expand Up @@ -126,6 +185,7 @@ export const buildApiBaseUrlCandidates = ({
controllerNetworks,
currentContainerNetworks,
defaultApiBaseUrl,
defaultLocalApiBaseUrl,
explicitApiBaseUrl,
port
}: ApiBaseUrlCandidatesInput): ReadonlyArray<string> => {
Expand All @@ -150,6 +210,7 @@ export const buildApiBaseUrlCandidates = ({

return uniqueStrings(
[
defaultLocalApiBaseUrl ?? "",
cachedApiBaseUrl ?? "",
defaultApiBaseUrl,
resolveControllerDnsApiBaseUrl(),
Expand Down
69 changes: 34 additions & 35 deletions packages/app/src/docker-git/controller.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,7 @@
import { Duration, Effect, pipe, Schedule } from "effect"

import { resolveControllerComposeUpArgs, shouldBuildControllerImage } from "./controller-bootstrap-plan.js"
import {
controllerContainerName,
controllerExists,
type ControllerRuntime,
ensureControllerReachabilityNetworks,
inspectContainerNetworks,
inspectControllerPublishedPorts,
inspectControllerRevision,
prepareLocalControllerRevision,
resolveCurrentContainerNetworks,
runCompose
} from "./controller-docker.js"
import * as ControllerDocker from "./controller-docker.js"
import { findReachableApiBaseUrl, findReachableDirectHealthProbe } from "./controller-health.js"
import { inspectControllerImageRevision } from "./controller-image-revision.js"
import {
Expand All @@ -21,6 +10,7 @@ import {
formatNetworkIps,
resolveApiPort,
resolveConfiguredApiBaseUrl,
resolveDefaultLocalApiBaseUrl,
resolveExplicitApiBaseUrl,
shouldRequireExplicitApiUrlForRemoteDocker,
trimTrailingSlashes
Expand All @@ -43,6 +33,8 @@ const controllerBootstrapError = (message: string): ControllerBootstrapError =>
message
})

type ControllerEffect<A> = Effect.Effect<A, ControllerBootstrapError, ControllerDocker.ControllerRuntime>

const rememberSelectedApiBaseUrl = (value: string): void => {
selectedApiBaseUrl = trimTrailingSlashes(value)
}
Expand All @@ -54,9 +46,9 @@ const collectReachabilityDiagnostics = (
candidateUrls: ReadonlyArray<string>,
currentContainerNetworks: DockerNetworkIps,
controllerNetworks: DockerNetworkIps
): Effect.Effect<string, never, ControllerRuntime> =>
): Effect.Effect<string, never, ControllerDocker.ControllerRuntime> =>
Effect.gen(function*(_) {
const publishedPorts = yield* _(inspectControllerPublishedPorts())
const publishedPorts = yield* _(ControllerDocker.inspectControllerPublishedPorts())

return [
"Tried endpoints:",
Expand All @@ -71,7 +63,7 @@ const waitForReachableApiBaseUrl = (
candidateUrls: ReadonlyArray<string>,
currentContainerNetworks: DockerNetworkIps,
controllerNetworks: DockerNetworkIps
): Effect.Effect<string, ControllerBootstrapError, ControllerRuntime> =>
): ControllerEffect<string> =>
pipe(
findReachableApiBaseUrl(candidateUrls),
Effect.retry(
Expand Down Expand Up @@ -160,21 +152,19 @@ type ControllerBootstrapContext = {
readonly initialControllerNetworks: DockerNetworkIps
}

const loadControllerBootstrapContext = (): Effect.Effect<
ControllerBootstrapContext,
ControllerBootstrapError,
ControllerRuntime
> =>
const loadControllerBootstrapContext = (): ControllerEffect<ControllerBootstrapContext> =>
Effect.gen(function*(_) {
yield* _(prepareControllerRuntimeEnv())
yield* _(prepareControllerResourceLimitEnv())
const explicitApiBaseUrl = resolveExplicitApiBaseUrl()
const localControllerRevision = yield* _(prepareLocalControllerRevision())
const currentControllerExists = yield* _(controllerExists())
const currentControllerRevision = yield* _(inspectControllerRevision())
const localControllerRevision = yield* _(ControllerDocker.prepareLocalControllerRevision())
const currentControllerExists = yield* _(ControllerDocker.controllerExists())
const currentControllerRevision = yield* _(ControllerDocker.inspectControllerRevision())
const currentImageRevision = yield* _(inspectControllerImageRevision())
const currentContainerNetworks = yield* _(resolveCurrentContainerNetworks())
const initialControllerNetworks = yield* _(inspectContainerNetworks(controllerContainerName))
const currentContainerNetworks = yield* _(ControllerDocker.resolveCurrentContainerNetworks())
const initialControllerNetworks = yield* _(
ControllerDocker.inspectContainerNetworks(ControllerDocker.controllerContainerName)
)
const forceRecreateForResourceLimits = shouldForceRecreateForControllerResourceLimits()
const forceRecreateController = forceRecreateForResourceLimits ||
shouldForceRecreateController(currentControllerExists, localControllerRevision, currentControllerRevision)
Expand Down Expand Up @@ -203,6 +193,7 @@ const buildBootstrapCandidateUrls = (
): ReadonlyArray<string> =>
buildApiBaseUrlCandidates({
explicitApiBaseUrl,
defaultLocalApiBaseUrl: resolveDefaultLocalApiBaseUrl(),
cachedApiBaseUrl: selectedApiBaseUrl,
defaultApiBaseUrl: resolveConfiguredApiBaseUrl(),
currentContainerNetworks,
Expand Down Expand Up @@ -243,16 +234,18 @@ const logControllerStart = (

const startAndRememberController = (
context: ControllerBootstrapContext
): Effect.Effect<void, ControllerBootstrapError, ControllerRuntime> =>
): ControllerEffect<void> =>
Effect.gen(function*(_) {
if (context.forceRecreateController || context.buildController) {
yield* _(logControllerStart(context))
}

yield* _(runCompose(resolveControllerComposeUpArgs(context)))
yield* _(ensureControllerReachabilityNetworks(context.currentContainerNetworks))
yield* _(ControllerDocker.runCompose(resolveControllerComposeUpArgs(context)))
yield* _(ControllerDocker.ensureControllerReachabilityNetworks(context.currentContainerNetworks))

const controllerNetworks = yield* _(inspectContainerNetworks(controllerContainerName))
const controllerNetworks = yield* _(
ControllerDocker.inspectContainerNetworks(ControllerDocker.controllerContainerName)
)
const candidateUrls = buildBootstrapCandidateUrls(
context.explicitApiBaseUrl,
context.currentContainerNetworks,
Expand All @@ -274,12 +267,17 @@ const startAndRememberController = (
// EFFECT: Effect<void, ControllerBootstrapError, CommandExecutor>
// INVARIANT: controller is reachable from the current runtime before any host API dispatch
// COMPLEXITY: O(1) compose + O(k) health checks
export const ensureControllerReady = (): Effect.Effect<void, ControllerBootstrapError, ControllerRuntime> =>
export const ensureControllerReady = (): ControllerEffect<void> =>
Effect.gen(function*(_) {
const explicitApiBaseUrl = resolveExplicitApiBaseUrl()
const defaultLocalApiBaseUrl = resolveDefaultLocalApiBaseUrl()
if (explicitApiBaseUrl !== undefined) {
const reachableBeforeDocker = yield* _(
findReachableDirectHealthProbe({ explicitApiBaseUrl, cachedApiBaseUrl: selectedApiBaseUrl })
findReachableDirectHealthProbe({
cachedApiBaseUrl: selectedApiBaseUrl,
defaultLocalApiBaseUrl,
explicitApiBaseUrl
})
)
if (reachableBeforeDocker !== null) {
rememberSelectedApiBaseUrl(reachableBeforeDocker.apiBaseUrl)
Expand All @@ -288,12 +286,13 @@ export const ensureControllerReady = (): Effect.Effect<void, ControllerBootstrap
yield* _(failIfExplicitApiUrlIsUnreachable(explicitApiBaseUrl))
}

const localControllerRevision = yield* _(prepareLocalControllerRevision())
const localControllerRevision = yield* _(ControllerDocker.prepareLocalControllerRevision())
const forceRecreateForResourceLimits = shouldForceRecreateForControllerResourceLimits()
const reachableBeforeDocker = yield* _(
findReachableDirectHealthProbe({
explicitApiBaseUrl,
cachedApiBaseUrl: selectedApiBaseUrl
cachedApiBaseUrl: selectedApiBaseUrl,
defaultLocalApiBaseUrl,
explicitApiBaseUrl
})
)
if (
Expand All @@ -314,7 +313,7 @@ export const ensureControllerReady = (): Effect.Effect<void, ControllerBootstrap
yield* _(startAndRememberController(bootstrapContext))
})

export const restartController = (): Effect.Effect<void, ControllerBootstrapError, ControllerRuntime> =>
export const restartController = (): ControllerEffect<void> =>
Effect.gen(function*(_) {
const explicitApiBaseUrl = resolveExplicitApiBaseUrl()
if (explicitApiBaseUrl !== undefined) {
Expand Down
Loading
Loading