Skip to content

Commit 08b124c

Browse files
committed
feat(shell): add browser daemon mode
1 parent 40a523d commit 08b124c

10 files changed

Lines changed: 276 additions & 54 deletions

File tree

packages/app/src/docker-git/browser-frontend.ts

Lines changed: 107 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,23 @@ type BrowserFrontendRuntimeState = {
6363
readonly webState: BrowserFrontendStateFile | null
6464
}
6565

66+
export interface BrowserFrontendCommandOptions {
67+
readonly daemon: boolean
68+
}
69+
70+
const browserFrontendForegroundOptions: BrowserFrontendCommandOptions = { daemon: false }
71+
72+
type BrowserFrontendLaunch = {
73+
readonly env: Readonly<Record<string, string>>
74+
readonly localUrl: string
75+
}
76+
77+
type BrowserFrontendRunnerEffect = Effect.Effect<
78+
void,
79+
ControllerBootstrapError | PlatformError,
80+
CommandExecutor.CommandExecutor
81+
>
82+
6683
const browserEnv = (decision: BrowserFrontendStartDecision): Readonly<Record<string, string>> => ({
6784
...copyProcessEnv(),
6885
DOCKER_GIT_API_URL: decision.apiBaseUrl,
@@ -76,19 +93,56 @@ const runStreaming = (
7693
args: ReadonlyArray<string>,
7794
env: Readonly<Record<string, string>>
7895
): Effect.Effect<number, PlatformError, CommandExecutor.CommandExecutor> =>
79-
runCommandExitCodeStreaming({
80-
args,
81-
command: "bun",
82-
cwd: process.cwd(),
83-
env
84-
})
96+
runCommandExitCodeStreaming({ args, command: "bun", cwd: process.cwd(), env })
8597

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

104+
// CHANGE: derive a stable daemon log path beside the browser runtime state file.
105+
// WHY: detached mode must preserve diagnostics after the parent CLI exits.
106+
// QUOTE(ТЗ): "Run browser with support dameon mode, like a flag -d"
107+
// REF: issue-373
108+
// SOURCE: n/a
109+
// FORMAT THEOREM: suffix(statePath,".json") -> logPath = prefix(statePath,".json") + ".log"
110+
// PURITY: CORE
111+
// EFFECT: n/a
112+
// INVARIANT: every state path maps deterministically to exactly one log path
113+
// COMPLEXITY: O(n)/O(n) where n = |statePath|
114+
const browserFrontendLogPath = (statePath: string): string =>
115+
statePath.endsWith(".json") ? `${statePath.slice(0, -".json".length)}.log` : `${statePath}.log`
116+
117+
const parseDaemonPid = (output: string): Effect.Effect<string, ControllerBootstrapError> => {
118+
const pid = parsePids(output)[0]
119+
return pid === undefined
120+
? Effect.fail(browserFrontendError("Browser frontend daemon did not report a pid."))
121+
: Effect.succeed(pid)
122+
}
123+
124+
const startDaemon = (
125+
args: ReadonlyArray<string>,
126+
env: Readonly<Record<string, string>>,
127+
logPath: string
128+
): Effect.Effect<string, ControllerBootstrapError | PlatformError, CommandExecutor.CommandExecutor> => {
129+
const script = [
130+
"log_path=\"$1\"",
131+
"shift",
132+
"command -v nohup >/dev/null 2>&1 || exit 127",
133+
"command -v \"$1\" >/dev/null 2>&1 || exit 127",
134+
"mkdir -p \"$(dirname \"$log_path\")\"",
135+
"nohup \"$@\" >>\"$log_path\" 2>&1 < /dev/null &",
136+
String.raw`printf '%s\n' "$!"`
137+
].join("\n")
138+
139+
return runCommandCapture(
140+
{ args: ["-c", script, "sh", logPath, "bun", ...args], command: "sh", cwd: process.cwd(), env },
141+
[0],
142+
() => browserFrontendError("Failed to start browser frontend daemon.")
143+
).pipe(Effect.flatMap((output) => parseDaemonPid(output)))
144+
}
145+
92146
const findWebServerPids = (): Effect.Effect<ReadonlyArray<string>, never, CommandExecutor.CommandExecutor> => {
93147
const script = [
94148
"port=\"$1\"",
@@ -271,27 +325,48 @@ const ensureSuccess = (
271325
? Effect.void
272326
: Effect.fail(browserFrontendError(`${action} failed with exit code ${exitCode}.`))
273327

274-
export const runBrowserFrontend = (
328+
// CHANGE: share the browser frontend build phase between foreground and daemon modes.
329+
// WHY: daemon mode must not drift from foreground mode in revision, environment, or build failure semantics.
330+
// QUOTE(ТЗ): "Run browser with support dameon mode, like a flag -d"
331+
// REF: issue-373
332+
// SOURCE: n/a
333+
// FORMAT THEOREM: forall mode in {foreground,daemon}: launch(mode) -> built(webRevision)
334+
// PURITY: SHELL
335+
// EFFECT: Effect<BrowserFrontendLaunch, ControllerBootstrapError | PlatformError, CommandExecutor>
336+
// INVARIANT: launch env is derived exactly once from BrowserFrontendStartDecision
337+
// COMPLEXITY: O(build)/O(env)
338+
const buildBrowserFrontendLaunch = (
275339
decision: BrowserFrontendStartDecision
276-
): Effect.Effect<
277-
void,
278-
ControllerBootstrapError | PlatformError,
279-
CommandExecutor.CommandExecutor
280-
> =>
340+
): Effect.Effect<BrowserFrontendLaunch, ControllerBootstrapError | PlatformError, CommandExecutor.CommandExecutor> =>
281341
Effect.gen(function*(_) {
282342
const env = browserEnv(decision)
283343
const localUrl = `http://${decision.host}:${decision.port}/`
284344

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

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

360+
export const runBrowserFrontendDaemon = (decision: BrowserFrontendStartDecision): BrowserFrontendRunnerEffect =>
361+
Effect.gen(function*(_) {
362+
const launch = yield* _(buildBrowserFrontendLaunch(decision))
363+
const logPath = browserFrontendLogPath(decision.statePath)
364+
365+
const pid = yield* _(startDaemon(["run", "--cwd", "packages/app", "serve:web"], launch.env, logPath))
366+
yield* _(Effect.log(`docker-git browser frontend daemon: ${launch.localUrl} (pid ${pid})`))
367+
yield* _(Effect.log(`docker-git browser frontend daemon log: ${logPath}`))
368+
})
369+
295370
// CHANGE: make `docker-git browser` idempotent for local development
296371
// WHY: repeated invocations should deploy only changed API or browser code
297372
// QUOTE(ТЗ): "Надо перезапускать только те контейнеры у которых изменился код"
@@ -302,15 +377,25 @@ export const runBrowserFrontend = (
302377
// EFFECT: Effect<void, ControllerBootstrapError | PlatformError, ControllerRuntime>
303378
// INVARIANT: controller readiness is checked independently from browser runtime reuse
304379
// COMPLEXITY: O(total_bytes(web_inputs) + processes + controller_probe)
305-
export const runBrowserFrontendCommand: Effect.Effect<
380+
export const runBrowserFrontendCommandWithOptions = (
381+
options: BrowserFrontendCommandOptions
382+
): Effect.Effect<
306383
void,
307384
ControllerBootstrapError | PlatformError,
308385
ControllerRuntime
309-
> = pipe(
310-
prepareBrowserStack(),
311-
Effect.flatMap((decision) =>
312-
decision.shouldStartWeb
313-
? runBrowserFrontend(decision)
314-
: Effect.log(`docker-git browser frontend is already running at http://${decision.host}:${decision.port}/`)
386+
> =>
387+
pipe(
388+
prepareBrowserStack(),
389+
Effect.flatMap((decision) => {
390+
if (!decision.shouldStartWeb) {
391+
return Effect.log(`docker-git browser frontend is already running at http://${decision.host}:${decision.port}/`)
392+
}
393+
return options.daemon ? runBrowserFrontendDaemon(decision) : runBrowserFrontend(decision)
394+
})
315395
)
316-
)
396+
397+
export const runBrowserFrontendCommand: Effect.Effect<
398+
void,
399+
ControllerBootstrapError | PlatformError,
400+
ControllerRuntime
401+
> = runBrowserFrontendCommandWithOptions(browserFrontendForegroundOptions)

packages/app/src/docker-git/cli/parser.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,22 @@ const isHelpFlag = (token: string): boolean => token === "--help" || token === "
2020

2121
const helpCommand: Command = { _tag: "Help", message: usageText }
2222
const menuCommand: Command = { _tag: "Menu" }
23-
const browserCommand: Command = { _tag: "Browser" }
2423
const statusCommand: Command = { _tag: "Status" }
2524
const downAllCommand: Command = { _tag: "DownAll" }
25+
const browserDaemonFlags = new Set(["-d", "--daemon"])
26+
27+
// CHANGE: parse browser daemon mode without side effects.
28+
// WHY: CLI intent must be a typed pure value before the shell starts web processes.
29+
// QUOTE(ТЗ): "Run browser with support dameon mode, like a flag -d"
30+
// REF: issue-373
31+
// SOURCE: n/a
32+
// FORMAT THEOREM: forall args: daemon(parseBrowser(args)) <-> exists a in args: a in {"-d","--daemon"}
33+
// PURITY: CORE
34+
// EFFECT: n/a
35+
// INVARIANT: browser foreground mode is the default when no daemon flag is present
36+
// COMPLEXITY: O(n)/O(1) where n = |args|
37+
const parseBrowser = (args: ReadonlyArray<string>): Either.Either<Command, ParseError> =>
38+
Either.right({ _tag: "Browser", daemon: args.some((arg) => browserDaemonFlags.has(arg)) })
2639

2740
// CHANGE: parse --active flag for apply-all command to restrict to running containers
2841
// WHY: allow users to apply config only to currently active containers via --active flag
@@ -90,8 +103,8 @@ export const parseArgs = (args: ReadonlyArray<string>): Either.Either<Command, P
90103
Match.when("ui", () => Either.right(menuCommand))
91104
)
92105
.pipe(
93-
Match.when("browser", () => Either.right(browserCommand)),
94-
Match.when("web", () => Either.right(browserCommand)),
106+
Match.when("browser", () => parseBrowser(rest)),
107+
Match.when("web", () => parseBrowser(rest)),
95108
Match.when("apply-all", () => parseApplyAll(rest)),
96109
Match.when("update-all", () => parseApplyAll(rest)),
97110
Match.when("auth", () => parseAuth(rest)),

packages/app/src/docker-git/cli/usage.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
export { formatParseError } from "../frontend-lib/core/parse-errors.js"
22

33
export const usageText = `docker-git menu
4-
docker-git browser
4+
docker-git browser [-d|--daemon]
55
docker-git create [--repo-url <url>] [options]
66
docker-git clone <url> [options]
77
docker-git open [<selector>] [options]
@@ -21,7 +21,7 @@ docker-git state <action> [options]
2121
2222
Commands:
2323
menu Interactive menu (default when no args)
24-
browser Build and serve the browser frontend for the docker-git controller
24+
browser Build and serve the browser frontend for the docker-git controller; use -d to run it as a daemon
2525
create, init Generate docker development environment (repo URL optional)
2626
clone Create + run container and clone repo
2727
open Open an existing docker-git project by selector, URL, or path
@@ -79,6 +79,7 @@ Options:
7979
--ssh | --no-ssh Auto-open SSH after create/clone (default: clone=--ssh, create=--no-ssh)
8080
--mcp-playwright | --no-mcp-playwright Enable Rust browser MCP + noVNC/CDP session (default: --no-mcp-playwright)
8181
--auto[=claude|codex|gemini|grok] Auto-execute an agent; without value picks by auth, random if multiple are available
82+
-d, --daemon browser: run the browser frontend server in the background after build
8283
--active apply-all: apply only to currently running containers (skip stopped ones)
8384
--force Overwrite existing files, replace conflicting docker-git projects/containers, and wipe compose volumes
8485
--force-env Reset project env defaults only (keep workspace volume/data)

packages/app/src/docker-git/frontend-lib/core/domain.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@ export interface MenuCommand {
139139

140140
export interface BrowserCommand {
141141
readonly _tag: "Browser"
142+
readonly daemon: boolean
142143
}
143144

144145
export interface AttachCommand {

packages/app/src/docker-git/frontend-lib/shell/command-runner.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { Effect, pipe } from "effect"
66
import * as Chunk from "effect/Chunk"
77
import * as Stream from "effect/Stream"
88

9-
type RunCommandSpec = {
9+
export type RunCommandSpec = {
1010
readonly cwd: string
1111
readonly command: string
1212
readonly args: ReadonlyArray<string>

packages/app/src/docker-git/program.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import {
2121
stopContainerTask,
2222
syncState
2323
} from "./api-client.js"
24-
import { runBrowserFrontendCommand } from "./browser-frontend.js"
24+
import { runBrowserFrontendCommandWithOptions } from "./browser-frontend.js"
2525
import { readCommand } from "./cli/read-command.js"
2626
import { usageText } from "./cli/usage.js"
2727
import { type ControllerRuntime, ensureControllerReady } from "./controller.js"
@@ -209,7 +209,7 @@ const dispatchOperationalCommand = (
209209
): Effect.Effect<void, CliError, ControllerRuntime> =>
210210
Match.value(command).pipe(
211211
Match.when({ _tag: "Menu" }, () => withControllerReady(runMenu)),
212-
Match.when({ _tag: "Browser" }, () => runBrowserFrontendCommand),
212+
Match.when({ _tag: "Browser" }, (command) => runBrowserFrontendCommandWithOptions({ daemon: command.daemon })),
213213
Match.when({ _tag: "Create" }, handleCreateCommand),
214214
Match.when({ _tag: "Open" }, handleOpenCommand),
215215
Match.when({ _tag: "Status" }, handleStatusCommand),
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { NodeContext as BrowserFrontendDaemonTestNodeContext } from "@effect/platform-node"
2+
import { describe, expect, it } from "@effect/vitest"
3+
import { Effect } from "effect"
4+
import { beforeEach, type MockInstance, vi } from "vitest"
5+
6+
import type { BrowserFrontendStartDecision } from "../../src/docker-git/browser-frontend-state.js"
7+
import type { RunCommandSpec } from "../../src/docker-git/frontend-lib/shell/command-runner.js"
8+
9+
type DaemonNumericCommandMock = MockInstance<(spec: RunCommandSpec) => Effect.Effect<number>>
10+
11+
const captureDaemonCommandMock = vi.hoisted(
12+
() => vi.fn<(spec: RunCommandSpec) => Effect.Effect<string>>(() => Effect.succeed("456\n"))
13+
)
14+
const exitDaemonCommandMock = vi.hoisted(
15+
() => vi.fn<(spec: RunCommandSpec) => Effect.Effect<number>>(() => Effect.succeed(0))
16+
)
17+
const streamDaemonCommandMock = vi.hoisted(
18+
() => vi.fn<(spec: RunCommandSpec) => Effect.Effect<number>>(() => Effect.succeed(0))
19+
)
20+
21+
vi.mock("../../src/docker-git/frontend-lib/shell/command-runner.js", () => ({
22+
runCommandCapture: captureDaemonCommandMock,
23+
runCommandExitCode: exitDaemonCommandMock,
24+
runCommandExitCodeStreaming: streamDaemonCommandMock
25+
}))
26+
27+
const decision: BrowserFrontendStartDecision = {
28+
apiBaseUrl: "http://127.0.0.1:3334",
29+
host: "0.0.0.0",
30+
port: "4174",
31+
shouldStartWeb: true,
32+
statePath: "/home/dev/.docker-git/.orch/state/browser-frontend.json",
33+
webRevision: "revision-1"
34+
}
35+
36+
const runDaemonUnderTest = Effect.gen(function*(_) {
37+
const { runBrowserFrontendDaemon } = yield* _(
38+
Effect.promise(() => import("../../src/docker-git/browser-frontend.js"))
39+
)
40+
yield* _(runBrowserFrontendDaemon(decision).pipe(Effect.provide(BrowserFrontendDaemonTestNodeContext.layer)))
41+
})
42+
43+
const requireDaemonStartSpec = (): RunCommandSpec => {
44+
const spec = captureDaemonCommandMock.mock.calls[0]?.[0]
45+
if (spec === undefined) {
46+
throw new Error("expected daemon start command")
47+
}
48+
return spec
49+
}
50+
51+
const resetDaemonCommandMock = (mock: DaemonNumericCommandMock): void => {
52+
mock.mockReset()
53+
mock.mockImplementation(() => Effect.succeed(0))
54+
}
55+
56+
const resetDaemonCommandMocks = (): void => {
57+
vi.resetModules()
58+
captureDaemonCommandMock.mockReset()
59+
captureDaemonCommandMock.mockImplementation(() => Effect.succeed("456\n"))
60+
resetDaemonCommandMock(exitDaemonCommandMock)
61+
resetDaemonCommandMock(streamDaemonCommandMock)
62+
}
63+
64+
describe("browser frontend daemon mode", () => {
65+
beforeEach(resetDaemonCommandMocks)
66+
67+
it.effect("builds in the foreground and starts serve:web as a daemon", () =>
68+
Effect.gen(function*(_) {
69+
yield* _(runDaemonUnderTest)
70+
71+
const daemonStartSpec = requireDaemonStartSpec()
72+
expect(streamDaemonCommandMock).toHaveBeenCalledTimes(1)
73+
expect(streamDaemonCommandMock).toHaveBeenCalledWith(
74+
expect.objectContaining({
75+
args: ["run", "--cwd", "packages/app", "build:web"],
76+
command: "bun"
77+
})
78+
)
79+
expect(daemonStartSpec.command).toBe("sh")
80+
expect(daemonStartSpec.args).toEqual([
81+
"-c",
82+
expect.stringContaining("nohup \"$@\""),
83+
"sh",
84+
"/home/dev/.docker-git/.orch/state/browser-frontend.log",
85+
"bun",
86+
"run",
87+
"--cwd",
88+
"packages/app",
89+
"serve:web"
90+
])
91+
expect(daemonStartSpec.env).toEqual(
92+
expect.objectContaining({
93+
DOCKER_GIT_API_URL: "http://127.0.0.1:3334",
94+
DOCKER_GIT_WEB_PORT: "4174",
95+
DOCKER_GIT_WEB_STATE_PATH: "/home/dev/.docker-git/.orch/state/browser-frontend.json"
96+
})
97+
)
98+
}))
99+
})

0 commit comments

Comments
 (0)