feat(backend): HTTP daemon skeleton (Phase 1a) — #10#14
Merged
Conversation
…ul shutdown (#10) Phase 1a of the Go HTTP daemon lane (#10). Stands up the loopback-only sidecar skeleton the later REST/SSE/WS/static surfaces build on: - config: env-driven (AO_HOST/PORT/ENV/timeouts/run-file) with zero-config defaults; binds 127.0.0.1:3001; validates and fails fast on bad input. - httpd: chi router with the recoverer → request-id → logger → real-ip middleware stack and /healthz + /readyz probes. Per-request timeout is carried in config but intentionally not global — it scopes to /api/v1 in Phase 1b so it never throttles SSE/WS/health. - runfile: atomic PID + port handshake (running.json) for the Electron supervisor, with a dead-PID stale check so a crashed predecessor doesn't block startup while a live one fails fast. - server: bind-before-publish (port conflict fails fast), graceful shutdown on SIGINT/SIGTERM via signal.NotifyContext with a 10s hard timeout, and run-file cleanup on exit. Why: the daemon must be safely supervisable as a child process — the supervisor needs a discoverable PID/port and the daemon must not leave a half-started process or stale handshake behind. Locking the lifecycle down now keeps the future port split a small change rather than a rewrite. Tests cover config defaults/overrides/validation, run-file round-trip and live/dead PID detection, health probes, full Run lifecycle, and port-conflict fail-fast. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Per review on #14: AO_ENV / Config.Env / IsProduction() weren't load-bearing for Phase 1a — they only switched the slog handler. Removing them now keeps the surface minimal; the env knob can come back later when a real consumer needs it. - config: remove Env field, AO_ENV parsing, and IsProduction helper. - main: collapse newLogger to a single text-handler path. - httpd: drop the env field from the listening log line. - tests: drop the env assertions and AO_ENV fixture. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Collaborator
Author
|
Done in 61c5b8a — dropped |
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Introduces the Phase 1a Go HTTP daemon skeleton for the backend, establishing a loopback-bound server lifecycle with health probes, env-driven configuration, and an Electron-oriented running.json handshake file.
Changes:
- Added
internal/httpdserver/router skeleton with/healthzand/readyz, plus graceful shutdown wiring. - Added
internal/configfor env-based configuration with defaults and validation. - Added
internal/runfilefor atomic-ishrunning.jsonwrite/read/remove and stale/live PID checks, with accompanying tests and README run instructions.
Reviewed changes
Copilot reviewed 11 out of 12 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| README.md | Documents how to run the new Go daemon, probe health, and configure via env vars. |
| backend/main.go | Boots config + logger, checks run-file staleness, and runs the HTTP server with SIGINT/SIGTERM shutdown. |
| backend/internal/config/config.go | Implements env-driven config defaults and parsing/validation for port and timeouts. |
| backend/internal/config/config_test.go | Adds tests for config defaults, overrides, and invalid inputs. |
| backend/internal/httpd/router.go | Creates the chi router and mounts health/readiness probes with the chosen middleware ordering. |
| backend/internal/httpd/json.go | Adds a small helper for writing JSON responses consistently. |
| backend/internal/httpd/server.go | Implements bind-before-publish, serving loop, graceful shutdown, and run-file cleanup. |
| backend/internal/httpd/server_test.go | Adds tests for probes, full server lifecycle, and port-conflict fail-fast behavior. |
| backend/internal/runfile/runfile.go | Implements running.json handshake file write/read/remove and stale/live detection. |
| backend/internal/runfile/runfile_test.go | Adds run-file round-trip and stale/live PID detection tests. |
| backend/go.mod | Adds chi dependency for the new HTTP daemon skeleton. |
| backend/go.sum | Records module checksums for the new dependency. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- config: drop AO_HOST entirely — the daemon is loopback-only by design, so making the bind host env-configurable was a security footgun - config: use net.JoinHostPort in Addr() so IPv6 literals stay valid - config: reject zero/negative AO_REQUEST_TIMEOUT and AO_SHUTDOWN_TIMEOUT (time.ParseDuration accepts both; either would silently break the daemon — instant request expiry / no graceful drain) - runfile: split processAlive into unix/windows build-tagged files so liveness detection is reliable on both platforms (Windows uses OpenProcess; POSIX keeps signal 0) - runfile: document os.Rename overwrite semantics (atomic on POSIX, REPLACE_EXISTING on Windows) so the temp-then-rename pattern's cross-platform behaviour is explicit - httpd tests: give probe/waitForHealth clients an explicit per-request timeout so a stalled connect can't hang the test on the outer deadline
gofmt CI was failing because removing the orphan processAlive doc comment left an extra newline at EOF.
- runfile: introduce build-tagged atomicReplace — POSIX rename(2) on Unix, MoveFileEx with MOVEFILE_REPLACE_EXISTING on Windows. The Go runtime happens to do the Windows call internally already, but invoking it directly makes the cross-platform contract explicit instead of a runtime implementation detail - runfile: tighten process_unix.go build tag from `!windows` to `unix` so plan9/js/wasm fail to build rather than silently using a broken signal-0 probe - runfile: add TestWriteOverwritesExisting covering the stale run-file replace path that none of the previous tests exercised - config: anchor the loopback-only decision in the LoopbackHost doc so the next contributor doesn't reintroduce AO_HOST without the security rationale
This was referenced May 28, 2026
illegalcall
reviewed
May 28, 2026
illegalcall
approved these changes
May 28, 2026
chi's middleware.Logger writes via stdlib log to stdout, but the daemon's slog logger writes to stderr — so REST traffic and daemon logs landed on different streams in different formats. Replace it with a small slog-backed requestLogger that: - Wraps the response writer via middleware.NewWrapResponseWriter so status/bytes are accurate even when handlers return without an explicit WriteHeader. - Reads the request id off the context set by middleware.RequestID (kept mounted just before this middleware so the id is available). - Emits one structured Info line per request with method, path, status, bytes, duration, and remote — same key=value shape as the rest of the daemon, one stream for the Electron supervisor to capture.
5 tasks
neversettle17-101
added a commit
that referenced
this pull request
May 29, 2026
Mounts the /api/v1 surface on the skeleton router (#10·1a) and registers the 7 canonical project routes as 501 stubs that emit a structured PlannedRoute body documenting the future contract. Shared scaffolding landed here (api.go, errors.go, stubs/, controllers/) so #21/#22 plug in without re-touching the wiring. WHY: opens the route-shell PRs in the Go HTTP daemon lane. Doing it interface-first lets the dashboard team build against the contract before any handler logic exists; the locked APIError envelope and PlannedRoute shape become #19's OpenAPI source-of-truth. REST audit corrections vs the legacy TS surface: R3 PUT /projects/:id alias of PATCH: PUT not registered → 405. R4 POST /projects/:id repair overload: canonical /repair; legacy 405. R5 degraded GET returns 200 with error field: discriminator status. R6 ok/success flag flips: drop on 2xx; return affected resource. R9 bare {error: msg}: locked {error,code,message,requestId,details?}. Legacy paths are deliberately NOT registered; each canonical handler carries PlannedRoute.Legacy so consumers can discover the migration. Zod schemas (TrackerConfig, SCMConfig, AgentConfig, ReactionConfig, LocalProjectConfig, RoleAgentConfig) ported to typed Go structs with an Extra map reserved for .passthrough() round-tripping in later PRs. Closes part of #18; targets feat/issue-10 until #14 merges.
neversettle17-101
added a commit
that referenced
this pull request
May 31, 2026
Mounts the /api/v1 surface on the skeleton router (#10·1a) and registers the 7 canonical project routes as 501 stubs that emit a structured PlannedRoute body documenting the future contract. Shared scaffolding landed here (api.go, errors.go, stubs/, controllers/) so #21/#22 plug in without re-touching the wiring. WHY: opens the route-shell PRs in the Go HTTP daemon lane. Doing it interface-first lets the dashboard team build against the contract before any handler logic exists; the locked APIError envelope and PlannedRoute shape become #19's OpenAPI source-of-truth. REST audit corrections vs the legacy TS surface: R3 PUT /projects/:id alias of PATCH: PUT not registered → 405. R4 POST /projects/:id repair overload: canonical /repair; legacy 405. R5 degraded GET returns 200 with error field: discriminator status. R6 ok/success flag flips: drop on 2xx; return affected resource. R9 bare {error: msg}: locked {error,code,message,requestId,details?}. Legacy paths are deliberately NOT registered; each canonical handler carries PlannedRoute.Legacy so consumers can discover the migration. Zod schemas (TrackerConfig, SCMConfig, AgentConfig, ReactionConfig, LocalProjectConfig, RoleAgentConfig) ported to typed Go structs with an Extra map reserved for .passthrough() round-tripping in later PRs. Closes part of #18; targets feat/issue-10 until #14 merges.
illegalcall
pushed a commit
that referenced
this pull request
May 31, 2026
* feat(backend): HTTP daemon skeleton — config, health, runfile, graceful shutdown (#10) Phase 1a of the Go HTTP daemon lane (#10). Stands up the loopback-only sidecar skeleton the later REST/SSE/WS/static surfaces build on: - config: env-driven (AO_HOST/PORT/ENV/timeouts/run-file) with zero-config defaults; binds 127.0.0.1:3001; validates and fails fast on bad input. - httpd: chi router with the recoverer → request-id → logger → real-ip middleware stack and /healthz + /readyz probes. Per-request timeout is carried in config but intentionally not global — it scopes to /api/v1 in Phase 1b so it never throttles SSE/WS/health. - runfile: atomic PID + port handshake (running.json) for the Electron supervisor, with a dead-PID stale check so a crashed predecessor doesn't block startup while a live one fails fast. - server: bind-before-publish (port conflict fails fast), graceful shutdown on SIGINT/SIGTERM via signal.NotifyContext with a 10s hard timeout, and run-file cleanup on exit. Why: the daemon must be safely supervisable as a child process — the supervisor needs a discoverable PID/port and the daemon must not leave a half-started process or stale handshake behind. Locking the lifecycle down now keeps the future port split a small change rather than a rewrite. Tests cover config defaults/overrides/validation, run-file round-trip and live/dead PID detection, health probes, full Run lifecycle, and port-conflict fail-fast. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(backend): drop Env config field — not needed yet (#10) Per review on #14: AO_ENV / Config.Env / IsProduction() weren't load-bearing for Phase 1a — they only switched the slog handler. Removing them now keeps the surface minimal; the env knob can come back later when a real consumer needs it. - config: remove Env field, AO_ENV parsing, and IsProduction helper. - main: collapse newLogger to a single text-handler path. - httpd: drop the env field from the listening log line. - tests: drop the env assertions and AO_ENV fixture. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * docs: add backend run + config quick-start to README (#10) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(backend): address Phase 1a review comments (#10) - config: drop AO_HOST entirely — the daemon is loopback-only by design, so making the bind host env-configurable was a security footgun - config: use net.JoinHostPort in Addr() so IPv6 literals stay valid - config: reject zero/negative AO_REQUEST_TIMEOUT and AO_SHUTDOWN_TIMEOUT (time.ParseDuration accepts both; either would silently break the daemon — instant request expiry / no graceful drain) - runfile: split processAlive into unix/windows build-tagged files so liveness detection is reliable on both platforms (Windows uses OpenProcess; POSIX keeps signal 0) - runfile: document os.Rename overwrite semantics (atomic on POSIX, REPLACE_EXISTING on Windows) so the temp-then-rename pattern's cross-platform behaviour is explicit - httpd tests: give probe/waitForHealth clients an explicit per-request timeout so a stalled connect can't hang the test on the outer deadline * fix(backend): strip trailing blank line from runfile.go (#10) gofmt CI was failing because removing the orphan processAlive doc comment left an extra newline at EOF. * fix(backend): cross-platform run-file replace + AO_HOST rationale (#10) - runfile: introduce build-tagged atomicReplace — POSIX rename(2) on Unix, MoveFileEx with MOVEFILE_REPLACE_EXISTING on Windows. The Go runtime happens to do the Windows call internally already, but invoking it directly makes the cross-platform contract explicit instead of a runtime implementation detail - runfile: tighten process_unix.go build tag from `!windows` to `unix` so plan9/js/wasm fail to build rather than silently using a broken signal-0 probe - runfile: add TestWriteOverwritesExisting covering the stale run-file replace path that none of the previous tests exercised - config: anchor the loopback-only decision in the LoopbackHost doc so the next contributor doesn't reintroduce AO_HOST without the security rationale * fix(backend): route chi access logs through slog/stderr (#10) chi's middleware.Logger writes via stdlib log to stdout, but the daemon's slog logger writes to stderr — so REST traffic and daemon logs landed on different streams in different formats. Replace it with a small slog-backed requestLogger that: - Wraps the response writer via middleware.NewWrapResponseWriter so status/bytes are accurate even when handlers return without an explicit WriteHeader. - Reads the request id off the context set by middleware.RequestID (kept mounted just before this middleware so the id is available). - Emits one structured Info line per request with method, path, status, bytes, duration, and remote — same key=value shape as the rest of the daemon, one stream for the Electron supervisor to capture. * feat(api): projects route shell (7 routes, REST-corrected) — #20 Mounts the /api/v1 surface on the skeleton router (#10·1a) and registers the 7 canonical project routes as 501 stubs that emit a structured PlannedRoute body documenting the future contract. Shared scaffolding landed here (api.go, errors.go, stubs/, controllers/) so #21/#22 plug in without re-touching the wiring. WHY: opens the route-shell PRs in the Go HTTP daemon lane. Doing it interface-first lets the dashboard team build against the contract before any handler logic exists; the locked APIError envelope and PlannedRoute shape become #19's OpenAPI source-of-truth. REST audit corrections vs the legacy TS surface: R3 PUT /projects/:id alias of PATCH: PUT not registered → 405. R4 POST /projects/:id repair overload: canonical /repair; legacy 405. R5 degraded GET returns 200 with error field: discriminator status. R6 ok/success flag flips: drop on 2xx; return affected resource. R9 bare {error: msg}: locked {error,code,message,requestId,details?}. Legacy paths are deliberately NOT registered; each canonical handler carries PlannedRoute.Legacy so consumers can discover the migration. Zod schemas (TrackerConfig, SCMConfig, AgentConfig, ReactionConfig, LocalProjectConfig, RoleAgentConfig) ported to typed Go structs with an Extra map reserved for .passthrough() round-tripping in later PRs. Closes part of #18; targets feat/issue-10 until #14 merges. * refactor(api): collapse ProjectService → ProjectManager — #20 Controllers now depend on ONE inbound interface per resource — ports.ProjectManager — mirroring the existing ports.SessionManager + LifecycleManager pattern. Whether the manager impl reaches into the registry, the LCM, an outbound port, or all three is its own concern; the HTTP layer no longer has to know any of that. WHY: the original split named the boundary type "ProjectService" and put it in a sibling services.go. That implied a second category of port distinct from inbound.go's *Manager interfaces, even though they play the same role (things HTTP/CLI call into the core). Per review feedback, collapse them onto one Manager-per-resource pattern. Mechanical changes: - ports/inbound.go gains ProjectManager next to SessionManager. - ports/services.go renamed to projects.go; keeps only the DTOs the ProjectManager methods take/return. - ProjectsController.Svc renamed to Mgr; APIDeps.Projects type bumped to ports.ProjectManager. All tests pass unchanged; no behavioural change. * refactor(api): replace stubs/ with OpenAPI-as-source-of-truth — #20 The first cut of the route shell duplicated each route's contract twice: once as a Go literal (stubs.PlannedRoute{...}) in the controller, and implicitly in the PR description. The Go literal was ~230 LoC of pure throwaway that would be deleted in handler-impl PRs. This commit eliminates the duplication: - backend/internal/httpd/apispec/openapi.yaml: full OpenAPI 3.1 doc covering the 7 project routes + shared schemas (Project, APIError, config types). x-replaces records the legacy → canonical mapping REST-audit corrections produced. - apispec/apispec.go: //go:embed the YAML, expose Operation(method, path) → the spec slice as a map, NotImplemented(w, r, method, path) → 501 with that slice embedded as `spec`. - controllers/projects.go: each of 7 handlers is now a one-liner: apispec.NotImplemented(w, r, "GET", "/api/v1/projects"). - /api/v1/openapi.yaml serves the embedded document so tooling (SDK gen, the validator slated for #19, dashboard dev tools) can fetch the whole spec from the same origin as the routes. - stubs/ package deleted. When a real handler lands, only the apispec.NotImplemented line goes away — nothing else does. The spec stays as documentation; consumers never had to know it was throwaway. #19 (OpenAPI follow-up) is now half-folded into this PR; the validation middleware remains its own follow-up. Tests reshaped: assert envelope + spec.operationId + spec.x-replaces (replaces the old planned.legacy assertion); add TestOpenAPIYAMLServed to cover the static spec serve; add apispec_test.go for embed/lookup behaviour. * refactor(api): move projects contract to internal/project package — #20 Pilots the feature-package layout the backend is migrating toward: a resource's inbound interface and its DTOs live with the resource, not in a central ports/ catch-all. WHY: review flagged ports/ as vague. It conflates three jobs — the outbound capability seam (legit), single-impl inbound interfaces (Go idiom wants these consumer-side), and DTOs that aren't ports at all. This moves the projects contract out as the reference shape #21/#22 follow; the merged session/lifecycle/outbound contracts are left untouched and migrated separately. Scope: INTERFACE ONLY. No implementation — handlers still answer via apispec.NotImplemented and the injected project.Manager stays nil. The impl lands in a later handler-impl PR. Changes: - new internal/project: project.go (Manager interface, 7 endpoints) + dto.go (AddInput/GetResult/UpdateConfigInput/RemoveResult/ReloadResult, moved verbatim from ports/projects.go, Project-prefix dropped). - ports/projects.go deleted; ProjectManager removed from ports/inbound.go. outbound.go and facts.go untouched. - controllers/projects.go and httpd/api.go depend on project.Manager. Domain entities (Project, ProjectSummary, DegradedProject, config types) stay in domain/ as shared vocabulary. go build/vet/test/gofmt all clean; no behavioural change. * refactor(api): consolidate project types into internal/project — #20 Addresses PR review: (1) "why are config_types required at the moment?" and (2) "project objects already defined in project/ — how do we differentiate?" Both had the same root cause: project types were split across domain/ and project/. Fix — keep ALL project types in the project package; only domain.ProjectID (shared with sessions/lifecycle/workspace) stays in domain. - domain/project.go → project/types.go: Project, Summary, Degraded (renamed from ProjectSummary/DegradedProject; the package name carries the "Project" prefix now). - domain/config_types.go deleted. Kept only the 4 shapes the projects API actually exposes — TrackerConfig, SCMConfig, SCMWebhookConfig, ReactionConfig — moved into project/types.go. Dropped AgentConfig, AgentPermission, RoleAgentConfig, LocalProjectConfig (zero references) and the speculative `Extra map[string]any` passthrough fields (no marshaller existed, so they silently dropped data — premature). - project/dto.go + project/project.go reference the local types; ids stay domain.ProjectID. Net: one home for project types, no dead code. go build/vet/test/gofmt clean; no behavioural change (handlers still 501 via apispec). * feat(api): implement project routes with mock manager/store * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * merge: resolve conflicts with origin/main * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * refactor(httpd): share JSON/API error envelope helpers * fix(api): align project mock store with sqlite schema * fix(api): address project API review semantics * canonicalize both paths with filepath.EvalSymlinks before comparing * style(project): gofmt git repo validation --------- Co-authored-by: Aditi Chauhan <aditi1178@gmail.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> Co-authored-by: Vaibhaav <user@example.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Phase 1a — HTTP server skeleton
First sub-PR of the Go HTTP Daemon lane (#10). Stands up the loopback-only sidecar skeleton; REST routes (1b), Electron integration (1c), SSE, WS and static serving land in later phases on top of this.
What's in 1a
internal/config— env-driven config (AO_PORT,AO_REQUEST_TIMEOUT,AO_SHUTDOWN_TIMEOUT,AO_RUN_FILE) with zero-config defaults. Host is hardcoded to127.0.0.1(deliberately noAO_HOST: this daemon has no auth/CORS/TLS, so a strayAO_HOST=0.0.0.0would turn it into a public no-auth service).AO_PORTis range-validated; timeouts must be> 0(rejecting zero/negative durations that would silently break the daemon).internal/httpd— chi router with the recoverer → request-id → requestLogger → real-ip middleware stack and/healthz+/readyzprobes. The access log is a small slog-backed middleware so REST traffic lands on the same stderr stream and key=value shape as the rest of the daemon. The per-request timeout is carried in config but deliberately not applied globally — it scopes to/api/v1in Phase 1b so it never throttles the long-lived SSE/WS surfaces or the always-answer health probes (per the decision table).internal/runfile— atomic PID + port handshake (running.json) for the Electron supervisor, with a dead-PID stale check: a crashed predecessor's file is treated as stale and overwritten, while a live daemon makes startup fail fast. Cross-platform atomic replace via build-taggedatomicReplace(POSIXrename(2)/ WindowsMoveFileExwithMOVEFILE_REPLACE_EXISTING); cross-platform PID liveness via build-taggedprocessAlive(POSIXsignal 0/ WindowsOpenProcesswithPROCESS_QUERY_LIMITED_INFORMATION).internal/httpd/server.go— bind-before-publish (port conflict fails fast), graceful shutdown on SIGINT/SIGTERM viasignal.NotifyContextwith a 10s hard timeout, and run-file cleanup on exit.Decisions honoured
chi framework · single loopback port 3001 · fail-fast on conflict · PID check on startup ·
signal.NotifyContext+ 10s hard shutdown · middleware order recoverer→request-id→requestLogger→real-ip→(timeout, /api/v1-scoped later) · OS-agnostic (macOS / Linux / Windows) via build tags.Out of scope (later phases)
Route registration (1b),
embed.FSstatic + Electron spawn/supervise + dev proxy (1c), EventBus/SSE (2),/muxWS (4).Verification
Static checks + unit tests + end-to-end smoke run, all on
5bca1a2+24f57fd:gofmt -l .go build ./...go vet ./...go test -race ./...GET /healthzon the built binary200 {"status":"ok"}GET /readyzon the built binary200 {"status":"ready"}running.jsonhandshakepid/port/startedAt; removed on shutdownrequestLogger(no chi stdout writes)daemon already running (pid X, port Y); refusing to start→ exit 1shutdown signal received, draining connections→daemon stopped cleanly;running.jsonremovedNew tests added in this PR cover: config defaults / overrides / validation (including zero/negative timeout rejection), run-file round-trip + overwrite-existing + live/dead PID detection, health probes, the full
Runlifecycle (boot →running.jsonpublished → serve → SIGTERM → clean shutdown → run-file removed), and port-conflict fail-fast.Tracking: #10