From 6022a68bb208cdcd12662b0f51edd3d20093e27e Mon Sep 17 00:00:00 2001 From: neuromaxer Date: Fri, 12 Jun 2026 15:26:40 +0200 Subject: [PATCH 1/9] add inner app containers --- cmd/appx/main.go | 4 + docs/plans/phase_9_plan.md | 181 ++++++++++++++++++ internal/agentserver/client.go | 45 ++++- internal/agentserver/client_test.go | 121 ++++++++++++ .../000006_project_dev_port.down.sql | 2 + .../migrations/000006_project_dev_port.up.sql | 2 + internal/project/manager.go | 55 +++++- internal/project/manager_test.go | 95 ++++++++- internal/project/project.go | 58 +++++- internal/project/store.go | 99 +++++----- internal/project/store_test.go | 143 ++++++++------ internal/server/router.go | 24 ++- internal/server/router_test.go | 9 +- internal/server/subdomain_devprod_test.go | 146 ++++++++++++++ 14 files changed, 840 insertions(+), 144 deletions(-) create mode 100644 docs/plans/phase_9_plan.md create mode 100644 internal/agentserver/client_test.go create mode 100644 internal/db/migrations/000006_project_dev_port.down.sql create mode 100644 internal/db/migrations/000006_project_dev_port.up.sql create mode 100644 internal/server/subdomain_devprod_test.go diff --git a/cmd/appx/main.go b/cmd/appx/main.go index 4e5ecf6..9b07f6c 100644 --- a/cmd/appx/main.go +++ b/cmd/appx/main.go @@ -157,6 +157,10 @@ func main() { } pm := project.NewManager(projectStore, projectRoot) pm.BaseDomain = baseDomain + // External edge knobs for public DEV/PROD URL construction (appx's own + // scheme/host/port, not the app's internal port). + pm.HTTPMode = *httpMode + pm.ExternalPort = *port agentServerURL := envOr("APPX_AGENT_SERVER_URL", "http://127.0.0.1:4001") agentServerToken := os.Getenv("APPX_AGENT_SERVER_TOKEN") diff --git a/docs/plans/phase_9_plan.md b/docs/plans/phase_9_plan.md new file mode 100644 index 0000000..cc591da --- /dev/null +++ b/docs/plans/phase_9_plan.md @@ -0,0 +1,181 @@ +# Phase 9 Plan: Containerised Apps — Builder Container + App Deployment + +**Date:** 2026-06-11 +**Status:** Draft +**Scope:** Deployment metadata handshake (dev + prod) with agent-server, paired port allocation + DEV/PROD subdomain routing, outer-container supervision from appx, port-range publishing, egress wiring, deploy script rewrite +**Prerequisites:** Pi migration complete (agent-server owns projects; appx is the control plane) +**Canonical architecture:** agent-server repo, `docs/architecture/important/builder-container-architecture.md` +**Sibling plan:** agent-server repo, `docs/plans/builder-containers-plan.md` (metadata contract, deploy skill, outer image) + +--- + +## Goal + +End-to-end containerised app flow: + +1. **appx creates the agent-server outer docker container at startup** (one unprivileged container holding agent-server + rootless podman). +2. User creates a project in the appx UI; appx allocates **two ports** (DEV + PROD) and registers the project with agent-server **including both ports and their public URLs**. +3. User prompts the builder agent; the agent builds one image and runs it as **two inner podman containers** (DEV + PROD), each publishing its reserved port. +4. appx's subdomain proxy exposes both: `.` → PROD port, `-dev.` → DEV port. +5. The user iterates against the DEV URL (refinements rebuild + redeploy DEV); **promote** rebuilds PROD, visible at the PROD URL. + +### What already exists (foundation to extend) + +- Port allocation: `project.Store.Create` atomically assigns from 10000–10999 (`internal/project/store.go`) — **extend to allocate a DEV+PROD pair** (Stage 1). +- Subdomain proxy to `127.0.0.1:` with auth wrapping (`internal/server/router.go`) — **extend to select DEV vs PROD port from the subdomain label** (D5). +- agent-server registration + startup reconcile (`internal/agentserver/client.go`, `Manager.ReconcileAgentProjects`) — reused; payload gains dev+prod. +- Health checker → `AppRunning` in the UI (`internal/project/health.go`) — reused as-is (now checks both ports). +- Bearer-token seam (`AGENT_SERVER_TOKEN`) — reused as-is. + +The architecture's key payoff for appx: **the subdomain proxy's *target* is unchanged across all stages** — the outer container publishes app ports on host loopback, so `127.0.0.1:` means the same thing whether agent-server runs on the host (Stage 1) or inside the container (Stages 2+). The proxy gains **one** small change: choosing the DEV vs PROD port from the subdomain label (D5). + +--- + +## Design decisions + +### D1 — Publish the app port range on the outer container at create time + +`docker run -p 127.0.0.1:4001:4001 -p 127.0.0.1:10000-10099:10000-10099 ...` + +- Docker cannot add port mappings to a running container, so the range is fixed at container create. +- **Cap the published (and allocated) range at 100 ports.** Docker spawns one `docker-proxy` process per published port; 100 is plenty for a single admin. **Each project consumes a pair (DEV + PROD), so 100 ports ≈ 50 projects.** Keep the DB range constants; cap allocation via `PublishedPortRangeEnd = 10099` so existing rows above the cap still resolve. +- Loopback-only publish (`127.0.0.1:`) — apps are reachable solely through appx's authenticated proxy (OWASP A01: no direct unauthenticated exposure). +- Rejected: `--network=host` (discards the network isolation the architecture exists for). Escalation if the range/pair model ever hurts: a single in-container reverse proxy on one published port with appx sending the target port via header — pre-designed, not built now; appx routing is already centralised in one handler so it's a clean swap (and would also lift the ~50-project ceiling). + +### D2 — Deployment metadata (dev + prod) flows through `EnsureProject` + +appx sends `{name, deployment: {dev: {port, url}, prod: {port, url}}}` on create **and** on startup reconcile (agent-server treats same-name re-POST as a metadata update, healing drift for pre-existing projects). URL construction (appx already knows scheme/host/port): +- **prod:** `https://.` (`http://.:` in `--http` dev mode) +- **dev:** `https://-dev.` (`http://-dev.:` in `--http` dev mode) + +`-dev` is a **reserved suffix**: reject project names ending in `-dev` at creation so `-dev` is unambiguously project ``'s dev env (see D5). + +### D5 — Subdomain routing: DEV/PROD selection + WebSocket passthrough + +The subdomain dispatcher (`router.go`) parses the label: +- `-dev.` → the project's **DEV** port +- `.` → the project's **PROD** port + +It still proxies to `127.0.0.1:` — only the port *choice* is new. The +`-dev` reserved-suffix guard (D2) prevents name/route ambiguity (a project +`foo-dev` can't exist to collide with `foo`'s dev URL). + +**WebSocket passthrough is a generic requirement, not an HMR one.** The dev=prod +model (agent-server plan D6) drops the hot-reload dev server, so the build/refine +loop does *not* depend on WebSockets. But user apps (chat, live dashboards, +realtime anything) do, so the subdomain proxy must forward `Connection: Upgrade` +/ `ws://` correctly regardless. Go's `httputil.ReverseProxy` has supported this +since 1.12 — **verify with a test**, don't assume; it's table stakes for a +general app proxy. + +### D3 — Outer container management: shell out to the `docker` CLI behind an interface + +New `internal/containerruntime` package: small interface + docker-CLI implementation (`--format json` parsing) + fake for tests — same fake-at-the-seam pattern as `project.AgentRegistrar`. Rationale: one container's lifecycle doesn't justify the Docker Go SDK's dependency tree, and CLI compatibility means the host runtime can be docker **or** podman for free. + +### D4 — Container mode is opt-in config until Stage 3 lands + +`APPX_AGENT_CONTAINER=true` switches appx from "expect agent-server at `APPX_AGENT_SERVER_URL`" to "ensure the outer container is running, then use it". Host mode remains for local dev (macOS cannot run the nested setup natively) and as a fallback. + +--- + +## Staging (shared with agent-server plan) + +| Stage | What | Repo focus | +|---|---|---| +| 0 | Nested rootless podman spike (timeboxed) | agent-server | +| 1 | Full user flow with agent-server **on host** | both | +| 2 | agent-server inside the outer container, started manually | agent-server | +| 3 | appx creates/supervises the outer container at startup | **appx** | +| 4 | Hardening | both | + +--- + +## Stage 1 — Deployment handshake (appx side) + +- [ ] `internal/agentserver/client.go`: `EnsureProject(ctx, name string, dep Deployment) error` with `Deployment{Dev, Prod EnvTarget}` and `EnvTarget{Port int; URL string}`; marshal as the nested `deployment` object (omit empty) +- [ ] `internal/project/store.go`: allocate a **DEV+PROD port pair** atomically; `Project` gains `DevPort` + `ProdPort` (or keep `AssignedPort` as prod, add `DevPort`); cap via `PublishedPortRangeEnd` (≈ 50 projects); `ErrNoPortAvailable` message updated +- [ ] `internal/project/project.go`: `ValidateName` rejects names ending in `-dev` (reserved suffix, D2) +- [ ] `internal/project/manager.go`: + - `AgentRegistrar` interface carries the dev+prod deployment payload + - `Manager` gains URL construction for prod + dev (`` / `-dev`) — needs `HTTPMode`/external-port knowledge threaded from `main.go`, not guessed + - `Create` and `ReconcileAgentProjects` pass `{dev:{devPort, devURL}, prod:{prodPort, prodURL}}` +- [ ] `internal/server/router.go`: subdomain dispatcher selects DEV vs PROD port from the `-dev` label (D5); WebSocket upgrade passes through +- [ ] Tests: fake-registrar payload (create + reconcile, dev+prod), URL construction (prod/dev × https/http modes), pair allocation + cap boundary, `ValidateName` rejects `-dev`, **router: `-dev`→DevPort, ``→ProdPort, and a WebSocket upgrade proxies through** + +**Acceptance (cross-repo, manual):** `task local` + agent-server `npm run dev` (Docker Desktop/podman as the agent's `APP_CONTAINER_RUNTIME`) → create project in UI → prompt agent to build+deploy → DEV app at `http://-dev.127.0.0.1.sslip.io:8080`, PROD at `http://.127.0.0.1.sslip.io:8080` → refine → DEV updates → promote → PROD updates. UI shows running state via `AppRunning`. + +## Stage 2 — Containerised agent-server (no appx code changes) + +Run the outer container manually (script lives in agent-server repo), point appx at it with `APPX_AGENT_SERVER_URL=http://127.0.0.1:4001` and the bearer token set. Re-run the Stage 1 acceptance flow. This isolates "nested environment breaks the flow" from "appx manages containers correctly". + +## Stage 3 — appx supervises the outer container + +### `internal/containerruntime` + +- [ ] Interface (sketch): + ```go + type Supervisor interface { + // EnsureRunning creates the container if absent, starts it if stopped, + // and waits until the readiness URL responds. Idempotent. + EnsureRunning(ctx context.Context, spec ContainerSpec) error + Status(ctx context.Context, name string) (ContainerStatus, error) + } + ``` + `ContainerSpec`: image, name, port publishes (API + app range), volumes (workspace + podman storage), env (`ANTHROPIC_API_KEY` etc. passthrough, `AGENT_SERVER_TOKEN`, `WORKSPACE_DIR=/workspace`, `APPX_TEMPLATE_DIR`, proxy vars), extra flags = the **proven Stage 0 set** transcribed verbatim from `run-outer.sh`: `--device /dev/net/tun`, `--security-opt seccomp=`, `--security-opt apparmor=unconfined`, `--security-opt systempaths=unconfined`, plus `--memory`/`--cpus` and `--add-host=host.docker.internal:host-gateway`. **No `--privileged`, no `--cap-add SYS_ADMIN`, no `/dev/fuse`** (the spike's file-cap `newuidmap` + native overlay removed the need for those) +- [ ] Docker CLI implementation: `docker inspect --format json` for state, `docker run -d` for create, `docker start` for stopped; readiness = poll agent-server `GET /` with timeout; structured errors (image missing vs daemon down vs unhealthy) +- [ ] Fake implementation for unit tests + +### Wiring (`cmd/appx/main.go`) + +- [ ] `APPX_AGENT_CONTAINER=true` → build spec from config (`APPX_AGENT_IMAGE`, ranges, data dirs), `EnsureRunning` **before** `ReconcileAgentProjects`; fail loudly with a remediation hint if docker is unavailable +- [ ] `AGENT_SERVER_TOKEN` becomes **mandatory in container mode**: generate once, persist to `.appx-internals` (0600), pass to both the container env and the proxy clients. The API port is published (even if loopback-only); loopback is no longer a sufficient trust boundary on a multi-process host (OWASP A01/A07) +- [ ] Mismatched config detection: if the existing container's spec (image tag, published range) differs from desired, log instructions (or `--recreate-agent-container` flag); **never silently recreate** — that kills running user apps + +### Egress + +- [ ] Egress CONNECT proxy must be reachable from inside the container: listen on the docker bridge gateway (configurable bind addr) instead of loopback-only; container env sets `HTTPS_PROXY=http://host.docker.internal:9080`, `NODE_USE_ENV_PROXY=1` (mirrors the current `agent-server.service` setup) +- [ ] Verify the egress internal listener (permission requests) path works from the container, or scope it explicitly out with a documented follow-up + +### Deploy scripts + +- [ ] `deploy/system-setup.sh`: install docker (or podman) on the host; drop the `appx-agent` user/`agent-server.service` path for container mode; decide and document how appx invokes docker — recommend **rootless docker or host podman for the appx user** over adding appx to the `docker` group (docker group membership is root-equivalent; avoid if practical, document the trade-off if not) +- [ ] `deploy/tools-install.sh` / `bootstrap.sh`: pull/build the outer image (pin by tag/digest), remove host Node/agent-server install steps for container mode +- [ ] Keep the systemd host-mode path working until container mode has run in production for a while (delete in a later cleanup phase) + +### Tests (Stage 3) + +- [ ] Unit: supervisor logic against fake CLI runner (absent→create, stopped→start, running→noop, unhealthy→error), spec construction from config, token generation/persistence +- [ ] `scripts/smoke-deploy.sh` (Linux, CI nightly): build/pull outer image → start appx in container mode (`--http`) → `POST /api/projects` → assert agent-server inside the container has the project with correct dev+prod port metadata → build **the seeded template** once and run DEV+PROD instances via `docker exec` running the deploy skill's literal commands (**deliberately no LLM** — deterministic infra validation) → `curl` both `http://-dev.127.0.0.1.sslip.io:` and `http://.127.0.0.1.sslip.io:` through the appx proxy → redeploy a modified DEV → assert DEV changed while PROD is unchanged → promote → assert PROD changed → restart outer container → assert registry intact and UI shows apps stopped +- [ ] Router tests: assert DEV/PROD port selection from the `-dev` label and WebSocket upgrade passthrough (the proxy target is still `127.0.0.1:`; only the port choice is new) + +**Acceptance:** fresh Linux VM → bootstrap → appx boots, container exists and is healthy → full UI e2e; appx restart and outer-container restart both recover cleanly. + +## Stage 4 — Hardening (appx items) + +- [ ] Resource limits on the outer container (`--memory`, `--cpus`) via config with sane defaults +- [ ] Dashboard surfacing of builder-container health (degraded banner when `Status` is unhealthy) — small UI addition, big debuggability win +- [ ] Security review pass (precedent: `docs/security/*de-docker*`): token handling, port exposure, docker invocation privilege, egress from inner containers +- [ ] Optional golden-prompt LLM e2e (manual, pre-release) — owned jointly with agent-server plan + +--- + +## Testing strategy summary + +| Layer | What | Gate | +|---|---|---| +| Go unit tests (fakes at seams) | client payloads, manager threading, URL/port logic, supervisor state machine | every PR | +| `scripts/smoke-deploy.sh` | full cross-service chain, no LLM (skill commands run literally) | Linux CI nightly + before merge of Stage 3 | +| Router/proxy `httptest` | DEV/PROD port selection from the `-dev` label + WebSocket upgrade passthrough | every PR | +| Golden-prompt LLM run | prompt/skill quality | manual, pre-release | + +Principle: every networking boundary is exercised by a real connection at exactly one layer and faked everywhere else. No mocked-docker tests pretending to verify port forwarding; no LLM in the loop for infrastructure verification. + +## Risks + +1. **Port-range publish overhead** — capped at 100; in-container reverse proxy is the pre-designed escalation (D1). +2. **Egress proxy reachability from the container** — explicitly scoped (Stage 3); classic "works in dev" trap since host-mode dev never crosses the bridge. +3. **Container recreate destroys running apps** — mitigated by never auto-recreating on spec drift; volumes preserve workspace + podman storage regardless. +4. **Docker invocation privilege** — docker-group ≈ root; prefer rootless docker/podman for the appx user, decide during Stage 3 deploy-script work. +5. **macOS/Linux divergence** — accepted and bounded: macOS = flow/prompt dev (host mode), Linux = container truth (CI + VM). +6. **Two ports/project → ~50-project ceiling** under the 100-port publish cap; the in-container reverse proxy (D1 escalation) lifts it if needed. +7. **Subdomain proxy now selects DEV/PROD port and must pass WebSockets** (generic, for user apps) — covered by router tests; the `-dev` reserved-suffix guard (D2) prevents name/route ambiguity. diff --git a/internal/agentserver/client.go b/internal/agentserver/client.go index b5a7497..8470fb2 100644 --- a/internal/agentserver/client.go +++ b/internal/agentserver/client.go @@ -16,6 +16,8 @@ import ( "net/http" "strings" "time" + + "github.com/neuromaxer/appx/internal/project" ) // Project mirrors the agent-server ProjectInfo response shape. @@ -44,11 +46,23 @@ func NewClient(baseURL, token string) *Client { } } -// EnsureProject creates a project with the given name, or returns the existing -// one — the endpoint is idempotent on name, so this is safe to call on every -// create and to re-run after an agent-server restart. -func (c *Client) EnsureProject(ctx context.Context, name string) error { - body, err := json.Marshal(map[string]string{"name": name}) +// envTarget is the wire shape for one deployment environment. omitempty drops +// unset fields so a partial registration stays compact. +type envTarget struct { + Port int `json:"port,omitempty"` + URL string `json:"url,omitempty"` +} + +// EnsureProject creates a project with the given name and pushes its dev+prod +// deployment metadata, or updates the existing one — the endpoint is idempotent +// on name, so this is safe to call on every create and to re-run after an +// agent-server restart. Empty environments/fields are omitted from the payload. +func (c *Client) EnsureProject(ctx context.Context, name string, dep project.Deployment) error { + payload := map[string]any{"name": name} + if deployment := marshalDeployment(dep); len(deployment) > 0 { + payload["deployment"] = deployment + } + body, err := json.Marshal(payload) if err != nil { return fmt.Errorf("marshal create-project body: %w", err) } @@ -73,6 +87,27 @@ func (c *Client) EnsureProject(ctx context.Context, name string) error { return nil } +// marshalDeployment converts the control-plane Deployment into the nested +// `deployment` object, omitting empty environments so a partial or unset +// registration produces no key at all. +func marshalDeployment(dep project.Deployment) map[string]any { + deployment := map[string]any{} + if target := marshalTarget(dep.Dev); target != nil { + deployment["dev"] = target + } + if target := marshalTarget(dep.Prod); target != nil { + deployment["prod"] = target + } + return deployment +} + +func marshalTarget(target project.EnvTarget) *envTarget { + if target.Port == 0 && target.URL == "" { + return nil + } + return &envTarget{Port: target.Port, URL: target.URL} +} + // DeleteProject removes a project (runtime, metadata, and on-disk dirs) from // agent-server. A 404 is treated as success so deletes are idempotent. func (c *Client) DeleteProject(ctx context.Context, id string) error { diff --git a/internal/agentserver/client_test.go b/internal/agentserver/client_test.go new file mode 100644 index 0000000..98f656f --- /dev/null +++ b/internal/agentserver/client_test.go @@ -0,0 +1,121 @@ +package agentserver + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/neuromaxer/appx/internal/project" +) + +// captureServer records the last request body + bearer token sent to the +// agent-server create-project endpoint. +type captured struct { + name string + deployment map[string]map[string]any + hasDeploy bool + authz string +} + +func newCaptureServer(t *testing.T, sink *captured) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + var payload struct { + Name string `json:"name"` + Deployment map[string]map[string]any `json:"deployment"` + } + if err := json.Unmarshal(body, &payload); err != nil { + t.Errorf("unmarshal request body %q: %v", body, err) + } + sink.name = payload.Name + sink.deployment = payload.Deployment + _, sink.hasDeploy = mapHasKey(body, "deployment") + sink.authz = r.Header.Get("Authorization") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"id":"x","name":"x","projectDir":"/x","createdAt":"t"}`)) + })) +} + +// mapHasKey reports whether the raw JSON object contains the given top-level key. +func mapHasKey(raw []byte, key string) (any, bool) { + var m map[string]json.RawMessage + if err := json.Unmarshal(raw, &m); err != nil { + return nil, false + } + v, ok := m[key] + return v, ok +} + +func TestEnsureProject_SendsNestedDeployment(t *testing.T) { + var sink captured + srv := newCaptureServer(t, &sink) + defer srv.Close() + + client := NewClient(srv.URL, "secret-token") + dep := project.Deployment{ + Dev: project.EnvTarget{Port: 10006, URL: "https://eventx-dev.example.com"}, + Prod: project.EnvTarget{Port: 10007, URL: "https://eventx.example.com"}, + } + if err := client.EnsureProject(context.Background(), "eventx", dep); err != nil { + t.Fatalf("EnsureProject: %v", err) + } + + if sink.name != "eventx" { + t.Errorf("name = %q, want eventx", sink.name) + } + if sink.authz != "Bearer secret-token" { + t.Errorf("authorization = %q", sink.authz) + } + if got := sink.deployment["dev"]["port"]; got != float64(10006) { + t.Errorf("dev.port = %v, want 10006", got) + } + if got := sink.deployment["dev"]["url"]; got != "https://eventx-dev.example.com" { + t.Errorf("dev.url = %v", got) + } + if got := sink.deployment["prod"]["port"]; got != float64(10007) { + t.Errorf("prod.port = %v, want 10007", got) + } + if got := sink.deployment["prod"]["url"]; got != "https://eventx.example.com" { + t.Errorf("prod.url = %v", got) + } +} + +func TestEnsureProject_OmitsEmptyDeployment(t *testing.T) { + var sink captured + srv := newCaptureServer(t, &sink) + defer srv.Close() + + client := NewClient(srv.URL, "") + if err := client.EnsureProject(context.Background(), "plain", project.Deployment{}); err != nil { + t.Fatalf("EnsureProject: %v", err) + } + if sink.hasDeploy { + t.Error("expected no deployment key for an empty Deployment") + } + if sink.name != "plain" { + t.Errorf("name = %q, want plain", sink.name) + } +} + +func TestEnsureProject_OmitsEmptyEnvironment(t *testing.T) { + var sink captured + srv := newCaptureServer(t, &sink) + defer srv.Close() + + client := NewClient(srv.URL, "") + // Only PROD set; DEV must be omitted entirely. + dep := project.Deployment{Prod: project.EnvTarget{Port: 10007, URL: "https://eventx.example.com"}} + if err := client.EnsureProject(context.Background(), "eventx", dep); err != nil { + t.Fatalf("EnsureProject: %v", err) + } + if _, ok := sink.deployment["dev"]; ok { + t.Error("expected dev environment omitted") + } + if _, ok := sink.deployment["prod"]; !ok { + t.Error("expected prod environment present") + } +} diff --git a/internal/db/migrations/000006_project_dev_port.down.sql b/internal/db/migrations/000006_project_dev_port.down.sql new file mode 100644 index 0000000..2668868 --- /dev/null +++ b/internal/db/migrations/000006_project_dev_port.down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS idx_dev_port_unique; +ALTER TABLE projects DROP COLUMN dev_port; diff --git a/internal/db/migrations/000006_project_dev_port.up.sql b/internal/db/migrations/000006_project_dev_port.up.sql new file mode 100644 index 0000000..d92f6aa --- /dev/null +++ b/internal/db/migrations/000006_project_dev_port.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE projects ADD COLUMN dev_port INTEGER; +CREATE UNIQUE INDEX idx_dev_port_unique ON projects(dev_port) WHERE dev_port IS NOT NULL; diff --git a/internal/project/manager.go b/internal/project/manager.go index d7c8c19..4fb0bd8 100644 --- a/internal/project/manager.go +++ b/internal/project/manager.go @@ -13,9 +13,11 @@ import ( // agentserver package) so the project package stays dependency-light and easy // to test with a fake. type AgentRegistrar interface { - // EnsureProject registers a project by name (idempotent on name). The - // agent-server creates WORKSPACE_DIR/{id}/ and persists project metadata. - EnsureProject(ctx context.Context, name string) error + // EnsureProject registers a project by name (idempotent on name) along with + // its dev+prod deployment metadata. The agent-server creates + // WORKSPACE_DIR/{id}/ and persists the metadata; a same-name re-POST updates + // it (healing drift for pre-existing projects). + EnsureProject(ctx context.Context, name string, dep Deployment) error // DeleteProject removes a project by its agent-server id (idempotent), // including its directory and session transcripts. DeleteProject(ctx context.Context, id string) error @@ -33,10 +35,16 @@ type AgentRegistrar interface { type Manager struct { Store *Store ProjectRoot string - // BaseDomain is retained for control-plane URL construction and future - // harness templating; it no longer drives any filesystem scaffolding. + // BaseDomain is the external base domain used to construct each project's + // public DEV/PROD URLs (`` / `-dev`). BaseDomain string - Agent AgentRegistrar // optional; nil disables agent-server registration + // HTTPMode mirrors appx's --http dev mode: it selects the http scheme and + // causes the external listen port to be appended to constructed URLs. + HTTPMode bool + // ExternalPort is appx's own listen port (the edge), used to build URLs in + // dev mode. Not the app's internal/assigned port. + ExternalPort int + Agent AgentRegistrar // optional; nil disables agent-server registration } // NewManager creates a Manager backed by the given project store. The projectRoot @@ -72,7 +80,7 @@ func (m *Manager) Create(ctx context.Context, name string) (*Project, error) { } if m.Agent != nil { - if err := m.Agent.EnsureProject(ctx, proj.Name); err != nil { + if err := m.Agent.EnsureProject(ctx, proj.Name, m.deploymentFor(proj)); err != nil { // Roll back only our own freshly-created record. _ = m.Store.Delete(proj.ID) return nil, fmt.Errorf("register project with agent-server: %w", err) @@ -82,6 +90,37 @@ func (m *Manager) Create(ctx context.Context, name string) (*Project, error) { return proj, nil } +// deploymentFor builds the dev+prod deployment metadata appx pushes to +// agent-server: each environment's host port plus the public URL appx will +// route to it. PROD is `.`, DEV is `-dev.`. +func (m *Manager) deploymentFor(proj *Project) Deployment { + return Deployment{ + Dev: EnvTarget{Port: proj.DevPort, URL: m.appURL(proj.Name + "-dev")}, + Prod: EnvTarget{Port: proj.AssignedPort, URL: m.appURL(proj.Name)}, + } +} + +// appURL constructs a project's public URL from appx's *external* scheme, host, +// and listen port — never the app's internal port. In --http dev mode the +// listen port is appended (e.g. http://