Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/browser-daemon-mode.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@prover-coder-ai/docker-git": minor
---

Add daemon mode for `docker-git browser` via `-d` and `--daemon`.
129 changes: 107 additions & 22 deletions packages/app/src/docker-git/browser-frontend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,23 @@ type BrowserFrontendRuntimeState = {
readonly webState: BrowserFrontendStateFile | null
}

export interface BrowserFrontendCommandOptions {
readonly daemon: boolean
}

const browserFrontendForegroundOptions: BrowserFrontendCommandOptions = { daemon: false }

type BrowserFrontendLaunch = {
readonly env: Readonly<Record<string, string>>
readonly localUrl: string
}

type BrowserFrontendRunnerEffect = Effect.Effect<
void,
ControllerBootstrapError | PlatformError,
CommandExecutor.CommandExecutor
>

const browserEnv = (decision: BrowserFrontendStartDecision): Readonly<Record<string, string>> => ({
...copyProcessEnv(),
DOCKER_GIT_API_URL: decision.apiBaseUrl,
Expand All @@ -76,19 +93,56 @@ const runStreaming = (
args: ReadonlyArray<string>,
env: Readonly<Record<string, string>>
): Effect.Effect<number, PlatformError, CommandExecutor.CommandExecutor> =>
runCommandExitCodeStreaming({
args,
command: "bun",
cwd: process.cwd(),
env
})
runCommandExitCodeStreaming({ args, command: "bun", cwd: process.cwd(), env })

const parsePids = (output: string): ReadonlyArray<string> =>
output
.split(/\s+/u)
.map((pid) => pid.trim())
.filter((pid) => /^\d+$/u.test(pid))

// CHANGE: derive a stable daemon log path beside the browser runtime state file.
// WHY: detached mode must preserve diagnostics after the parent CLI exits.
// QUOTE(ТЗ): "Run browser with support dameon mode, like a flag -d"
// REF: issue-373
// SOURCE: n/a
// FORMAT THEOREM: suffix(statePath,".json") -> logPath = prefix(statePath,".json") + ".log"
// PURITY: CORE
// EFFECT: n/a
// INVARIANT: every state path maps deterministically to exactly one log path
// COMPLEXITY: O(n)/O(n) where n = |statePath|
const browserFrontendLogPath = (statePath: string): string =>
statePath.endsWith(".json") ? `${statePath.slice(0, -".json".length)}.log` : `${statePath}.log`

const parseDaemonPid = (output: string): Effect.Effect<string, ControllerBootstrapError> => {
const pid = parsePids(output)[0]
return pid === undefined
? Effect.fail(browserFrontendError("Browser frontend daemon did not report a pid."))
: Effect.succeed(pid)
}

const startDaemon = (
args: ReadonlyArray<string>,
env: Readonly<Record<string, string>>,
logPath: string
): Effect.Effect<string, ControllerBootstrapError | PlatformError, CommandExecutor.CommandExecutor> => {
const script = [
"log_path=\"$1\"",
"shift",
"command -v nohup >/dev/null 2>&1 || exit 127",
"command -v \"$1\" >/dev/null 2>&1 || exit 127",
"mkdir -p \"$(dirname \"$log_path\")\"",
"nohup \"$@\" >>\"$log_path\" 2>&1 < /dev/null &",
String.raw`printf '%s\n' "$!"`
].join("\n")

return runCommandCapture(
{ args: ["-c", script, "sh", logPath, "bun", ...args], command: "sh", cwd: process.cwd(), env },
[0],
() => browserFrontendError("Failed to start browser frontend daemon.")
).pipe(Effect.flatMap((output) => parseDaemonPid(output)))
}

const findWebServerPids = (): Effect.Effect<ReadonlyArray<string>, never, CommandExecutor.CommandExecutor> => {
const script = [
"port=\"$1\"",
Expand Down Expand Up @@ -271,27 +325,48 @@ const ensureSuccess = (
? Effect.void
: Effect.fail(browserFrontendError(`${action} failed with exit code ${exitCode}.`))

export const runBrowserFrontend = (
// CHANGE: share the browser frontend build phase between foreground and daemon modes.
// WHY: daemon mode must not drift from foreground mode in revision, environment, or build failure semantics.
// QUOTE(ТЗ): "Run browser with support dameon mode, like a flag -d"
// REF: issue-373
// SOURCE: n/a
// FORMAT THEOREM: forall mode in {foreground,daemon}: launch(mode) -> built(webRevision)
// PURITY: SHELL
// EFFECT: Effect<BrowserFrontendLaunch, ControllerBootstrapError | PlatformError, CommandExecutor>
// INVARIANT: launch env is derived exactly once from BrowserFrontendStartDecision
// COMPLEXITY: O(build)/O(env)
const buildBrowserFrontendLaunch = (
decision: BrowserFrontendStartDecision
): Effect.Effect<
void,
ControllerBootstrapError | PlatformError,
CommandExecutor.CommandExecutor
> =>
): Effect.Effect<BrowserFrontendLaunch, ControllerBootstrapError | PlatformError, CommandExecutor.CommandExecutor> =>
Effect.gen(function*(_) {
const env = browserEnv(decision)
const localUrl = `http://${decision.host}:${decision.port}/`

yield* _(Effect.log(`Building docker-git browser frontend ${decision.webRevision} for API ${decision.apiBaseUrl}.`))
const buildExitCode = yield* _(runStreaming(["run", "--cwd", "packages/app", "build:web"], env))
yield* _(ensureSuccess(buildExitCode, "Browser frontend build"))
return { env, localUrl }
})

yield* _(Effect.log(`docker-git browser frontend: ${localUrl}`))
export const runBrowserFrontend = (decision: BrowserFrontendStartDecision): BrowserFrontendRunnerEffect =>
Effect.gen(function*(_) {
const launch = yield* _(buildBrowserFrontendLaunch(decision))
yield* _(Effect.log(`docker-git browser frontend: ${launch.localUrl}`))
yield* _(Effect.log("Press Ctrl+C to stop the browser frontend."))
const serveExitCode = yield* _(runStreaming(["run", "--cwd", "packages/app", "serve:web"], env))
const serveExitCode = yield* _(runStreaming(["run", "--cwd", "packages/app", "serve:web"], launch.env))
yield* _(ensureSuccess(serveExitCode, "Browser frontend server"))
})

export const runBrowserFrontendDaemon = (decision: BrowserFrontendStartDecision): BrowserFrontendRunnerEffect =>
Effect.gen(function*(_) {
const launch = yield* _(buildBrowserFrontendLaunch(decision))
const logPath = browserFrontendLogPath(decision.statePath)

const pid = yield* _(startDaemon(["run", "--cwd", "packages/app", "serve:web"], launch.env, logPath))
yield* _(Effect.log(`docker-git browser frontend daemon: ${launch.localUrl} (pid ${pid})`))
yield* _(Effect.log(`docker-git browser frontend daemon log: ${logPath}`))
})

// CHANGE: make `docker-git browser` idempotent for local development
// WHY: repeated invocations should deploy only changed API or browser code
// QUOTE(ТЗ): "Надо перезапускать только те контейнеры у которых изменился код"
Expand All @@ -302,15 +377,25 @@ export const runBrowserFrontend = (
// EFFECT: Effect<void, ControllerBootstrapError | PlatformError, ControllerRuntime>
// INVARIANT: controller readiness is checked independently from browser runtime reuse
// COMPLEXITY: O(total_bytes(web_inputs) + processes + controller_probe)
export const runBrowserFrontendCommand: Effect.Effect<
export const runBrowserFrontendCommandWithOptions = (
options: BrowserFrontendCommandOptions
): Effect.Effect<
void,
ControllerBootstrapError | PlatformError,
ControllerRuntime
> = pipe(
prepareBrowserStack(),
Effect.flatMap((decision) =>
decision.shouldStartWeb
? runBrowserFrontend(decision)
: Effect.log(`docker-git browser frontend is already running at http://${decision.host}:${decision.port}/`)
> =>
pipe(
prepareBrowserStack(),
Effect.flatMap((decision) => {
if (!decision.shouldStartWeb) {
return Effect.log(`docker-git browser frontend is already running at http://${decision.host}:${decision.port}/`)
}
return options.daemon ? runBrowserFrontendDaemon(decision) : runBrowserFrontend(decision)
})
)
)

export const runBrowserFrontendCommand: Effect.Effect<
void,
ControllerBootstrapError | PlatformError,
ControllerRuntime
> = runBrowserFrontendCommandWithOptions(browserFrontendForegroundOptions)
19 changes: 16 additions & 3 deletions packages/app/src/docker-git/cli/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,22 @@ const isHelpFlag = (token: string): boolean => token === "--help" || token === "

const helpCommand: Command = { _tag: "Help", message: usageText }
const menuCommand: Command = { _tag: "Menu" }
const browserCommand: Command = { _tag: "Browser" }
const statusCommand: Command = { _tag: "Status" }
const downAllCommand: Command = { _tag: "DownAll" }
const browserDaemonFlags = new Set(["-d", "--daemon"])

// CHANGE: parse browser daemon mode without side effects.
// WHY: CLI intent must be a typed pure value before the shell starts web processes.
// QUOTE(ТЗ): "Run browser with support dameon mode, like a flag -d"
// REF: issue-373
// SOURCE: n/a
// FORMAT THEOREM: forall args: daemon(parseBrowser(args)) <-> exists a in args: a in {"-d","--daemon"}
// PURITY: CORE
// EFFECT: n/a
// INVARIANT: browser foreground mode is the default when no daemon flag is present
// COMPLEXITY: O(n)/O(1) where n = |args|
const parseBrowser = (args: ReadonlyArray<string>): Either.Either<Command, ParseError> =>
Either.right({ _tag: "Browser", daemon: args.some((arg) => browserDaemonFlags.has(arg)) })

// CHANGE: parse --active flag for apply-all command to restrict to running containers
// WHY: allow users to apply config only to currently active containers via --active flag
Expand Down Expand Up @@ -90,8 +103,8 @@ export const parseArgs = (args: ReadonlyArray<string>): Either.Either<Command, P
Match.when("ui", () => Either.right(menuCommand))
)
.pipe(
Match.when("browser", () => Either.right(browserCommand)),
Match.when("web", () => Either.right(browserCommand)),
Match.when("browser", () => parseBrowser(rest)),
Match.when("web", () => parseBrowser(rest)),
Match.when("apply-all", () => parseApplyAll(rest)),
Match.when("update-all", () => parseApplyAll(rest)),
Match.when("auth", () => parseAuth(rest)),
Expand Down
5 changes: 3 additions & 2 deletions packages/app/src/docker-git/cli/usage.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
export { formatParseError } from "../frontend-lib/core/parse-errors.js"

export const usageText = `docker-git menu
docker-git browser
docker-git browser [-d|--daemon]
docker-git create [--repo-url <url>] [options]
docker-git clone <url> [options]
docker-git open [<selector>] [options]
Expand All @@ -21,7 +21,7 @@ docker-git state <action> [options]

Commands:
menu Interactive menu (default when no args)
browser Build and serve the browser frontend for the docker-git controller
browser Build and serve the browser frontend for the docker-git controller; use -d to run it as a daemon
create, init Generate docker development environment (repo URL optional)
clone Create + run container and clone repo
open Open an existing docker-git project by selector, URL, or path
Expand Down Expand Up @@ -79,6 +79,7 @@ Options:
--ssh | --no-ssh Auto-open SSH after create/clone (default: clone=--ssh, create=--no-ssh)
--mcp-playwright | --no-mcp-playwright Enable Rust browser MCP + noVNC/CDP session (default: --no-mcp-playwright)
--auto[=claude|codex|gemini|grok] Auto-execute an agent; without value picks by auth, random if multiple are available
-d, --daemon browser: run the browser frontend server in the background after build
--active apply-all: apply only to currently running containers (skip stopped ones)
--force Overwrite existing files, replace conflicting docker-git projects/containers, and wipe compose volumes
--force-env Reset project env defaults only (keep workspace volume/data)
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/docker-git/frontend-lib/core/domain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ export interface MenuCommand {

export interface BrowserCommand {
readonly _tag: "Browser"
readonly daemon: boolean
}

export interface AttachCommand {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { Effect, pipe } from "effect"
import * as Chunk from "effect/Chunk"
import * as Stream from "effect/Stream"

type RunCommandSpec = {
export type RunCommandSpec = {
readonly cwd: string
readonly command: string
readonly args: ReadonlyArray<string>
Expand Down
4 changes: 2 additions & 2 deletions packages/app/src/docker-git/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import {
stopContainerTask,
syncState
} from "./api-client.js"
import { runBrowserFrontendCommand } from "./browser-frontend.js"
import { runBrowserFrontendCommandWithOptions } from "./browser-frontend.js"
import { readCommand } from "./cli/read-command.js"
import { usageText } from "./cli/usage.js"
import { type ControllerRuntime, ensureControllerReady } from "./controller.js"
Expand Down Expand Up @@ -209,7 +209,7 @@ const dispatchOperationalCommand = (
): Effect.Effect<void, CliError, ControllerRuntime> =>
Match.value(command).pipe(
Match.when({ _tag: "Menu" }, () => withControllerReady(runMenu)),
Match.when({ _tag: "Browser" }, () => runBrowserFrontendCommand),
Match.when({ _tag: "Browser" }, (command) => runBrowserFrontendCommandWithOptions({ daemon: command.daemon })),
Match.when({ _tag: "Create" }, handleCreateCommand),
Match.when({ _tag: "Open" }, handleOpenCommand),
Match.when({ _tag: "Status" }, handleStatusCommand),
Expand Down
99 changes: 99 additions & 0 deletions packages/app/tests/docker-git/browser-frontend-daemon.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { NodeContext as BrowserFrontendDaemonTestNodeContext } from "@effect/platform-node"
import { describe, expect, it } from "@effect/vitest"
import { Effect } from "effect"
import { beforeEach, type MockInstance, vi } from "vitest"

import type { BrowserFrontendStartDecision } from "../../src/docker-git/browser-frontend-state.js"
import type { RunCommandSpec } from "../../src/docker-git/frontend-lib/shell/command-runner.js"

type DaemonNumericCommandMock = MockInstance<(spec: RunCommandSpec) => Effect.Effect<number>>

const captureDaemonCommandMock = vi.hoisted(
() => vi.fn<(spec: RunCommandSpec) => Effect.Effect<string>>(() => Effect.succeed("456\n"))
)
const exitDaemonCommandMock = vi.hoisted(
() => vi.fn<(spec: RunCommandSpec) => Effect.Effect<number>>(() => Effect.succeed(0))
)
const streamDaemonCommandMock = vi.hoisted(
() => vi.fn<(spec: RunCommandSpec) => Effect.Effect<number>>(() => Effect.succeed(0))
)

vi.mock("../../src/docker-git/frontend-lib/shell/command-runner.js", () => ({
runCommandCapture: captureDaemonCommandMock,
runCommandExitCode: exitDaemonCommandMock,
runCommandExitCodeStreaming: streamDaemonCommandMock
}))

const decision: BrowserFrontendStartDecision = {
apiBaseUrl: "http://127.0.0.1:3334",
host: "0.0.0.0",
port: "4174",
shouldStartWeb: true,
statePath: "/home/dev/.docker-git/.orch/state/browser-frontend.json",
webRevision: "revision-1"
}

const runDaemonUnderTest = Effect.gen(function*(_) {
const { runBrowserFrontendDaemon } = yield* _(
Effect.promise(() => import("../../src/docker-git/browser-frontend.js"))
)
yield* _(runBrowserFrontendDaemon(decision).pipe(Effect.provide(BrowserFrontendDaemonTestNodeContext.layer)))
})

const requireDaemonStartSpec = (): RunCommandSpec => {
const spec = captureDaemonCommandMock.mock.calls[0]?.[0]
if (spec === undefined) {
throw new Error("expected daemon start command")
}
return spec
}

const resetDaemonCommandMock = (mock: DaemonNumericCommandMock): void => {
mock.mockReset()
mock.mockImplementation(() => Effect.succeed(0))
}

const resetDaemonCommandMocks = (): void => {
vi.resetModules()
captureDaemonCommandMock.mockReset()
captureDaemonCommandMock.mockImplementation(() => Effect.succeed("456\n"))
resetDaemonCommandMock(exitDaemonCommandMock)
resetDaemonCommandMock(streamDaemonCommandMock)
}

describe("browser frontend daemon mode", () => {
beforeEach(resetDaemonCommandMocks)

it.effect("builds in the foreground and starts serve:web as a daemon", () =>
Effect.gen(function*(_) {
yield* _(runDaemonUnderTest)

const daemonStartSpec = requireDaemonStartSpec()
expect(streamDaemonCommandMock).toHaveBeenCalledTimes(1)
expect(streamDaemonCommandMock).toHaveBeenCalledWith(
expect.objectContaining({
args: ["run", "--cwd", "packages/app", "build:web"],
command: "bun"
})
)
expect(daemonStartSpec.command).toBe("sh")
expect(daemonStartSpec.args).toEqual([
"-c",
expect.stringContaining("nohup \"$@\""),
"sh",
"/home/dev/.docker-git/.orch/state/browser-frontend.log",
"bun",
"run",
"--cwd",
"packages/app",
"serve:web"
])
expect(daemonStartSpec.env).toEqual(
expect.objectContaining({
DOCKER_GIT_API_URL: "http://127.0.0.1:3334",
DOCKER_GIT_WEB_PORT: "4174",
DOCKER_GIT_WEB_STATE_PATH: "/home/dev/.docker-git/.orch/state/browser-frontend.json"
})
)
}))
})
Loading
Loading