From 0190aff105ed68bdd4d3db37135995a751a8a1c0 Mon Sep 17 00:00:00 2001 From: Jessica Black Date: Wed, 1 Jul 2026 01:26:48 -0700 Subject: [PATCH 1/3] Add service roles and `roles_order` for dependency vs app tiers Introduce an explicit tier concept so a `.eph` file can distinguish dependency services (databases, caches: safe to start eagerly) from the first-party app (start on demand). This lets a Claude Code SessionStart hook prewarm just the dependency tier and inject its connection env without starting the app, so an agent opening a worktree finds its services already running. - `role=` tags each service with a free-form tier name. - `roles_order` orders the tiers, written as a linear `roles_order=dep,app` chain or a `[roles_order]` DAG section (`app=dep`, a bare `dep=` for a root). The two forms are mutually exclusive. - Roles mode is validated at parse time: once any service has a role, `roles_order` is required, every service must carry a listed role, every role must back at least one service, edges must reference known roles, and the graph must be acyclic. A file with no roles keeps the old behavior (declaration order, `run=` services last). - Bring-up follows the role graph topologically; teardown reverses it. The legacy "run= last" heuristic is off in roles mode. - `eph up --role R` starts a role and its dependency closure; `eph down --role R` stops a role and its dependent closure. Both are repeatable and combine with positional service names. - `eph dev` now tears down only the services it started, leaving any that were already running (a prewarmed dependency tier) up; `--clean` still bulldozes the workspace. `eph down` and `eph clean` stay absolute. - `eph check` reports each service's role and the resulting bring-up order. Covered by parser unit tests, a service ordering test, and non-Docker integration tests. Docs, the using-eph skill, and example.eph document the tiers and the SessionStart prewarm recipe (`eph up --role dep` plus `$CLAUDE_ENV_FILE`). Co-Authored-By: Claude Opus 4.8 --- README.md | 5 + docs/user-guide/command-reference.md | 51 ++- docs/user-guide/concepts.md | 41 +- docs/user-guide/eph-file.md | 90 ++++ docs/user-guide/for-agents.md | 57 ++- docs/user-guide/recipes.md | 102 +++++ example.eph | 26 +- skills/using-eph/SKILL.md | 84 ++++ src/main.rs | 166 ++++++-- src/parser.rs | 595 ++++++++++++++++++++++++++- src/service.rs | 93 ++++- tests/integration.rs | 63 +++ 12 files changed, 1306 insertions(+), 67 deletions(-) diff --git a/README.md b/README.md index 5c82a63..1aa7325 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,11 @@ eval "$(eph env)" # load connection strings into your shell eph down # stop when you are done ``` +Tag services with a `role=` (and a `roles_order`) to split the stack into tiers and +bring up one at a time: `eph up --role dep` starts just the dependency services (for +example to prewarm databases in a Claude Code SessionStart hook) without starting +your app. See [Roles and ordering](docs/user-guide/eph-file.md#roles-and-ordering). + [Getting Started](docs/user-guide/getting-started.md) walks through writing the `.eph` file from scratch. diff --git a/docs/user-guide/command-reference.md b/docs/user-guide/command-reference.md index 88858fb..702420c 100644 --- a/docs/user-guide/command-reference.md +++ b/docs/user-guide/command-reference.md @@ -24,14 +24,24 @@ service names, starts only those. | Flag | Description | |------|-------------| +| `--role ROLE` | Bring up this role and everything it depends on (its forward/dependency closure). Repeatable. Requires a `roles_order`; combines with any SERVICE names. | | `--skip-hooks` | Bring services up healthy but do not run their `pre-start` or `post-start` hooks. | ```sh eph up # all services eph up postgres redis # just these two +eph up --role dep # the dep tier and its dependencies (e.g. prewarm the database) +eph up --role app # the app plus every role it depends on eph up --skip-hooks # start everything but skip pre-start/post-start (e.g. codegen, migrations) ``` +- `--role ROLE` (repeatable) selects a role and its **dependency closure**: the + role plus every role it transitively depends on, since a role cannot run without + the roles below it. `--role app` with `roles_order=dep,app` starts both `dep` and + `app`; `--role dep` starts only `dep`. It combines with positional SERVICE names, + starting the union. Using `--role` on a file that defines no `roles_order` is an + error saying so. See [Roles and ordering](eph-file.md#roles-and-ordering). + - Idempotent: a running service is reused; a stopped-but-present container is restarted; otherwise a fresh container is created. - Each service runs its `pre-start` hooks just before it is created (the place @@ -60,6 +70,7 @@ already stopped. | Flag | Description | |------|-------------| +| `--role ROLE` | Stop this role and everything that depends on it (its reverse/dependent closure), in reverse start order. Repeatable. Requires a `roles_order`; combines with any SERVICE names. | | `-r`, `--rm` | Also remove the stopped containers (not just stop them). | | `--skip-hooks` | Stop without running `pre-stop` or `post-stop` hooks (escape hatch for a broken hook). | @@ -67,9 +78,18 @@ already stopped. eph down # stop all, keep containers eph down --rm # stop all and remove containers eph down postgres # stop just postgres +eph down --role dep # stop the dep tier and everything that depends on it eph down --skip-hooks # stop without running pre-stop/post-stop hooks ``` +- `--role ROLE` (repeatable) selects a role and its **dependent closure**: the role + plus every role that transitively depends on it, torn down in reverse start order, + because a dependency cannot be removed while the roles that need it are still up. + With `roles_order=dep,app`, `eph down --role dep` stops both `app` and `dep`. It + combines with positional SERVICE names, and requires a `roles_order` (an error + otherwise). `eph down` is otherwise absolute: it stops exactly what it targets, + with no ownership logic. See [Roles and ordering](eph-file.md#roles-and-ordering). + Without `--rm`, containers and their data remain for a fast restart. With `--rm`, containers are removed (named-volume data is kept); the next `eph up` creates fresh containers. @@ -116,11 +136,13 @@ Run the whole dev stack in the foreground, built for a Claude Desktop preview server (see [Recipes](recipes.md#claude-desktop-preview-servers)). It brings every service up (running `post-start` hooks, e.g. seeding), foregrounds a `run=` service with eph's own stdin, stdout, and stderr wired through to it, and -stays attached until it is stopped. On stop it tears the stack down. +stays attached until it is stopped. On stop it tears down only the services it +brought up itself, leaving any that were already running when it started (a +prewarmed dependency tier, typically) up. | Flag | Description | |------|-------------| -| `--clean` | Tear down with `eph clean` (drop named volumes and their data) instead of the default `eph down` (keep them for a fast restart). | +| `--clean` | On the final stop, tear the **whole** workspace down with `eph clean` (drop named volumes and their data), rather than the default of stopping only the services `eph dev` brought up and keeping the rest. | | `--watch GLOB` | Restart the whole stack when a file matching GLOB changes. Repeatable; globs are relative to the workspace root with gitignore-style separators. | ```sh @@ -151,10 +173,14 @@ eph dev --watch "**/*.rs" --watch "*.toml" # restart on source changes instead of the moment the server can answer its health check. Give the foreground app `port=auto` and read its `${service.port}`; do not also pin a fixed port. -- **Teardown on stop**: a stop signal (the preview server's, or Ctrl-C) runs - `eph down` by default, or `eph clean` with `--clean`, then exits zero. A hard - kill (`SIGKILL` / `TerminateProcess`) cannot be caught, so it skips teardown - and leaves the stack up, recoverable with `eph down`. +- **Teardown on stop**: a stop signal (the preview server's, or Ctrl-C) stops only + the services `eph dev` brought up, then exits zero. `eph dev` snapshots what was + already running at startup (a simple in-memory record, no persisted refcount) and + leaves those services up, so a dependency tier a SessionStart hook prewarmed stays + warm for the next command. With `--clean` it instead runs `eph clean` and + bulldozes the whole workspace, volumes included. A hard kill (`SIGKILL` / + `TerminateProcess`) cannot be caught, so it skips teardown and leaves the stack + up, recoverable with `eph down`. - **App exit**: if the foregrounded app exits on its own (a crash), `eph dev` leaves the backing services up and exits non-zero, so the preview server sees the dev server went down. The app's own output already streamed to your @@ -163,12 +189,13 @@ eph dev --watch "**/*.rs" --watch "*.toml" # restart on source changes paths relative to the workspace root, using gitignore-style separators: `*` stays within a directory and `**` spans them, so `*.toml` matches a top-level `Cargo.toml` while `**/*.rs` matches a `.rs` file at any depth. When a matching - file changes, eph tears the whole stack down (running `pre-stop` and - `post-stop` hooks) and brings it back up (running `pre-start` and `post-start` - hooks), so a restart is a full `eph down` + `eph dev`, not a bare process - bounce, and every lifecycle hook fires just as it would on a manual restart. A - restart always keeps volumes for speed, even under `--clean`; that reset is - reserved for the final stop. Changes are debounced, so one save is one restart, + file changes, eph tears down the services it brought up (running `pre-stop` and + `post-stop` hooks) and brings them back up (running `pre-start` and `post-start` + hooks), so a restart is a full down + up, not a bare process bounce, and every + lifecycle hook fires just as it would on a manual restart. Only the services + `eph dev` brought up are bounced: an adopted, already-running dependency tier + stays hot across restarts. A restart always keeps volumes for speed, even under + `--clean`; that reset is reserved for the final stop. Changes are debounced, so one save is one restart, and git's own churn under `.git` never triggers one. Without any `--watch` the stack never restarts. In watch mode an app that exits on its own (a crash) does not end the session: diff --git a/docs/user-guide/concepts.md b/docs/user-guide/concepts.md index f217620..1e0e593 100644 --- a/docs/user-guide/concepts.md +++ b/docs/user-guide/concepts.md @@ -1,8 +1,8 @@ # Core Concepts -This page explains the model behind `eph`. Once these five ideas click - -workspaces, isolation, automatic ports, persisted state, and the lifecycle - -the commands and the file format are obvious. +This page explains the model behind `eph`. Once these ideas click (workspaces, +isolation, automatic ports, persisted state, the lifecycle, and the split between +dependency services and the app), the commands and the file format are obvious. ## Workspaces @@ -178,6 +178,41 @@ but the service is already stopped. Both are skippable with `--skip-hooks`. > named volumes declared with `volume=` in your `.eph` file; volumes declared > inside a Compose file are left to `docker compose`. +## Dependency services vs the app + +Most stacks split in two: **dependency services** (databases, caches, queues, mail +catchers) that your app talks to, and the **first-party app** you are building. +The dependency tier is stable and can run ahead of time; the app is what you +restart constantly and want to control precisely. + +`role=` names that split, and `roles_order` orders it. Tag the backing services +`role=dep` and the app `role=app`, then declare `roles_order=dep,app` ("app depends +on dep"). Now the two tiers are addressable: + +- **Start order follows the graph.** `eph up` brings the `dep` tier up first and + waits for it healthy, then the app, so the app's `DATABASE_URL` resolves the + moment it starts. Teardown reverses that. This replaces the legacy "start `run=` + services last" rule with an explicit ordering you control. +- **You can bring up one tier.** `eph up --role dep` starts the dependency services + and everything they depend on, without touching the app. `eph up --role app` + starts the app and pulls its dependencies in with it (see + [Command Reference](command-reference.md#eph-up-service)). + +The motivating case is prewarming. A dependency tier is known-good and slow to +build (image pulls, migrations, seeds), so warming it once and reusing it beats +starting it per task. A Claude Code SessionStart hook can run `eph up --role dep` +and inject the resolved connection env before an agent's first command, leaving the +app for later. Because `eph up` is idempotent, a later `eph up` or `eph dev` reuses +the already-running dependency services rather than restarting them, and `eph dev` +on exit leaves the tier it adopted running. See +[For Agents](for-agents.md#prewarm-dependency-services-on-session-start) for the +recipe. + +A file that uses no `role=` and no `roles_order` stays in **legacy mode** and +behaves exactly as before: declaration order, `run=` services last. Roles are +opt-in. Full rules are in +[The `.eph` File](eph-file.md#roles-and-ordering). + ## Next Now that you have the model, see [The `.eph` File](eph-file.md) for the complete diff --git a/docs/user-guide/eph-file.md b/docs/user-guide/eph-file.md index f72510b..345bcaf 100644 --- a/docs/user-guide/eph-file.md +++ b/docs/user-guide/eph-file.md @@ -111,6 +111,7 @@ The four source types are covered in detail in | Property | Repeatable | Description | |----------|:----------:|-------------| +| `role=` | no | The role (tier) this service belongs to, a free-form name you choose (e.g. `dep`, `app`). Optional, but once any service sets it every service must, and a `roles_order` must list the roles (see [Roles and ordering](#roles-and-ordering)). | | `image=` | no | Docker image to pull and run. | | `dockerfile=` | no | Path to a Dockerfile to build (relative to workspace). | | `context=` | no | Build context for `dockerfile=` (defaults to the Dockerfile's directory). | @@ -303,6 +304,95 @@ Important behavior: See [Core Concepts](concepts.md#the-service-lifecycle) for the full lifecycle. +## Roles and ordering + +A `role=` tags a service with a tier, and `roles_order` orders those tiers. The +usual split is dependency services (a `dep` tier: databases, caches, queues) that +must be up before the first-party app (an `app` tier) can talk to them. Naming the +tiers lets you bring up one on its own, for example `eph up --role dep` to prewarm +the backing services without starting the app. See +[Core Concepts](concepts.md#dependency-services-vs-the-app) for the model. + +```ini +roles_order=dep,app + +[postgres] +image=postgres:16-alpine +role=dep +port=5432 + +[web] +run=npm run dev +role=app +port=auto +``` + +### Legacy mode vs roles mode + +A file is in **legacy mode** when no service declares a `role=` and there is no +`roles_order`. Ordering is unchanged from before roles existed: services start in +declaration order with `run=` services deferred to the end, and teardown reverses +that. Existing `.eph` files need no changes. + +A file is in **roles mode** the moment any service declares a `role=` or a +`roles_order` is present. Roles mode then requires all of the following, checked at +parse time (by `eph check` and before any `eph up`): + +- a `roles_order` is present (linear or section form); +- every service declares a `role`; +- every service's role is listed in `roles_order`; +- every role in `roles_order` is backed by at least one service; +- every dependency edge names a known role; +- the role graph is acyclic. + +A violation is a hard parse error naming the offending service or role, so a +half-specified graph never reaches `eph up`. + +### `roles_order` + +`roles_order` is the dependency graph over roles. "Depends on" means "must come up +first": if `app` depends on `dep`, `dep` starts before `app`, and requesting `app` +pulls `dep` in with it. Write it in one of two forms. Declaring both is an error. + +**Linear form** (top-level key). A comma-separated chain where each role depends on +the one before it: + +```ini +roles_order=dep,app +``` + +This reads "app depends on dep": `dep` comes up first, then `app`. Extend the chain +with more roles (`roles_order=dep,cache,app`) when every tier depends on the whole +tier before it. + +**DAG form** (a reserved `[roles_order]` section). One `role=dep1,dep2` line per +role, spelling out each role's dependencies explicitly. A bare `role=` (empty value) +declares a root that depends on nothing. Every role must appear as a key here, roots +included: + +```ini +[roles_order] +dep= +app=dep +worker=dep +``` + +Here both `app` and `worker` depend on `dep`, but not on each other, so a `worker` +that needs the database but not the app can start without it. Use the DAG form when +a role needs some but not all of the others; use the linear form for a straight +chain. The section may appear anywhere in the file, including before the services it +names. + +### Ordering in roles mode + +In roles mode the role graph is the single source of truth for order. Bring-up is +the topological order of the graph (dependencies first), with services grouped by +role and declaration order preserved within a role. Teardown is the exact reverse. + +The legacy "`run=` services start last" heuristic is off in roles mode. A `run=` +service tagged as a dependency role comes up before the app that needs it, exactly +where the graph places it: the role, not the source type, decides order. + ## Interpolation Top-level environment variable values may reference running services: diff --git a/docs/user-guide/for-agents.md b/docs/user-guide/for-agents.md index b8a58d2..55087e5 100644 --- a/docs/user-guide/for-agents.md +++ b/docs/user-guide/for-agents.md @@ -37,12 +37,65 @@ Prefer `eph env -f json` for parsing: DATABASE_URL=$(eph env -f json | jq -r .DATABASE_URL) ``` +## Prewarm dependency services on session start + +If the `.eph` file defines roles (a `roles_order` and a `role=` on every service), +you can bring up just the **dependency tier** without starting the first-party app. +This is the recommended agent integration: a Claude Code **SessionStart hook** that +prewarms the databases and caches, injects their connection env, and leaves the app +alone (starting it could bind preview ports or trigger side effects the agent did +not ask for). + +The hook runs `eph up --role dep` (substitute your actual dependency role name), +then appends `eph env` to the file named by `$CLAUDE_ENV_FILE`, which Claude Code +sources so later Bash tool calls inherit `DATABASE_URL` and friends: + +```sh +#!/usr/bin/env bash +# SessionStart hook: prewarm dependency services and inject their env. +eph up --role dep >/dev/null 2>&1 || exit 0 +[ -n "$CLAUDE_ENV_FILE" ] && eph env >> "$CLAUDE_ENV_FILE" +``` + +Wire it in `.claude/settings.json` (project scope, so everyone opening the +repo/worktree gets it): + +```json +{ + "hooks": { + "SessionStart": [ + { + "matcher": "startup|resume", + "hooks": [ { "type": "command", "command": ".claude/hooks/eph-prewarm.sh" } ] + } + ] + } +} +``` + +Notes: + +- `eph up` is idempotent, so a later `eph up` or `eph dev` reuses the prewarmed + dependency services instead of restarting them. `eph dev` on exit leaves the tier + it adopted running, so it stays warm for the next command. +- `--role dep` brings up the dependency role and its dependency closure only, never + the `app`. Add `--skip-hooks` if you want to prewarm without running `post-start` + seeding; the plain form above runs it. +- There is no `eph hooks install`: roles are user-defined names, so substitute your + own dependency role. For a personal, cross-repo version put the same block in + `~/.claude/settings.json` instead. +- Optional: a `SessionEnd` hook running `eph down --role dep` cleans the tier when a + session ends. The default is to leave it warm for reuse. + +See [Recipes](recipes.md#prewarm-dependency-services-on-claude-code-session-start) +for the full write-up. + ## Command cheat sheet | Command | Effect | |---------|--------| -| `eph up [svc...]` | Start all / named services. Runs each service's `pre-start` just before it is created, pulls/builds, waits for health, then runs `post-start` for every service. Both run on **every** `eph up`. A failing `pre-start` aborts the `up` before its service starts; a failing `post-start` aborts the `up`. `--skip-hooks` skips both. | -| `eph down [--rm \| -r] [svc...]` | Stop all / named. `--rm` (alias `-r`) also removes containers. Compose is always fully torn down. Runs `pre-stop` before each service stops and `post-stop` after. A failing `pre-stop` aborts the `down` (service left running); a failing `post-stop` aborts the rest of teardown. `--skip-hooks` bypasses both. | +| `eph up [svc...] [--role R]...` | Start all / named services. `--role R` (repeatable) adds a role plus its dependency closure (needs a `roles_order`); combines with names. Runs each service's `pre-start` just before it is created, pulls/builds, waits for health, then runs `post-start` for every service. Both run on **every** `eph up`. A failing `pre-start` aborts the `up` before its service starts; a failing `post-start` aborts the `up`. `--skip-hooks` skips both. | +| `eph down [--rm \| -r] [svc...] [--role R]...` | Stop all / named. `--role R` (repeatable) adds a role plus everything that depends on it. `--rm` (alias `-r`) also removes containers. Compose is always fully torn down. Runs `pre-stop` before each service stops and `post-stop` after. A failing `pre-stop` aborts the `down` (service left running); a failing `post-stop` aborts the rest of teardown. `--skip-hooks` bypasses both. | | `eph clean` | Full reset: remove containers + named volumes + state. Deletes data. Runs `pre-stop` / `post-stop` like `eph down`; a failing hook aborts it; `--skip-hooks` bypasses both. | | `eph run ...` | Run a command in the workspace root with the resolved env + `EPH_*` metadata. Exits with the command's code. | | `eph logs [svc] [-f] [-n N]` | Show logs. No svc: all services interleaved, each line tagged `[name]`. One svc: raw. `run=` reads a captured log file; Docker/compose proxy `docker logs`. Shows even for stopped services. `-f` follows (all or one). | diff --git a/docs/user-guide/recipes.md b/docs/user-guide/recipes.md index 038d805..0f69bb1 100644 --- a/docs/user-guide/recipes.md +++ b/docs/user-guide/recipes.md @@ -214,6 +214,108 @@ launch the app through `eph run` so it still gets the resolved environment: (`eph up`) and teardown (`eph down`) are no longer automatic, which is the manual work `eph dev` does for you. +## Prewarm dependency services on Claude Code session start + +When an agent opens a worktree, its dev services are usually not running, and the +dependency tier (databases, caches) is the slow, known-good part to start: image +pulls, migrations, seeds. Warm it once on session start and reuse it. A Claude Code +**SessionStart hook** can bring up just the dependency tier and inject its +connection env before the agent's first command, without starting the first-party +app (which could bind preview ports or cause surprising side effects). + +This needs a `.eph` file that uses [roles](eph-file.md#roles-and-ordering): tag the +backing services `role=dep`, the app `role=app`, and declare `roles_order=dep,app`. + +```ini +roles_order=dep,app + +[postgres] +image=postgres:16-alpine +role=dep +port=5432 +env.POSTGRES_USER=dev +env.POSTGRES_PASSWORD=dev +env.POSTGRES_DB=myapp +healthcheck=pg_isready -U dev +post-start=npm run db:migrate + +[web] +run=npm run dev +role=app +port=auto +env.PORT=${web.port} + +DATABASE_URL=postgres://dev:dev@localhost:${postgres.port}/myapp +``` + +The hook script runs `eph up --role dep` (which starts the `dep` tier and its +dependency closure, never the app), then appends `eph env` to the file named by +`$CLAUDE_ENV_FILE`. Claude Code sources that file, so subsequent Bash tool calls +inherit `DATABASE_URL` and the rest: + +```sh +#!/usr/bin/env bash +# .claude/hooks/eph-prewarm.sh +# SessionStart hook: prewarm dependency services and inject their env. +eph up --role dep >/dev/null 2>&1 || exit 0 +[ -n "$CLAUDE_ENV_FILE" ] && eph env >> "$CLAUDE_ENV_FILE" +``` + +Make it executable (`chmod +x .claude/hooks/eph-prewarm.sh`) and wire it in +`.claude/settings.json` so it applies to everyone who opens the repo or a worktree +of it: + +```json +{ + "hooks": { + "SessionStart": [ + { + "matcher": "startup|resume", + "hooks": [ { "type": "command", "command": ".claude/hooks/eph-prewarm.sh" } ] + } + ] + } +} +``` + +How it behaves: + +- **The app is left alone.** `--role dep` resolves to the dependency role and its + dependency closure only. The `run=` app never starts, so no preview port is bound + and no app-side side effects fire on session start. +- **The tier is reused, not restarted.** `eph up` is idempotent (see + [Core Concepts](concepts.md#the-service-lifecycle)), so when you later run + `eph up` or `eph dev`, the already-running dependency services are reused. `eph + dev` on exit tears down only the app it foregrounded and leaves the prewarmed tier + running, so it stays warm across sessions and dev runs. +- **Seeding is included.** The plain `eph up --role dep` runs each dependency's + `post-start` (migrations, seeds). Add `--skip-hooks` if you want the services up + without seeding. +- **No install command.** Roles are names you choose, so there is deliberately no + `eph hooks install`: copy the recipe and substitute your dependency role name. For + a personal version that follows you across repos, put the same `SessionStart` + block in `~/.claude/settings.json` instead of the project file. +- **Optional cleanup on exit.** To stop the tier when a session ends rather than + leaving it warm, add a `SessionEnd` hook running `eph down --role dep`. Leaving it + running (the default here) is usually what you want, so the next session reuses it. + +## Bring up only one tier + +Once a `.eph` file defines [roles](eph-file.md#roles-and-ordering), `--role` starts +or stops a tier and its closure without naming individual services: + +```sh +eph up --role dep # dependency services (+ anything they depend on), not the app +eph up --role app # the app plus every role it depends on (here: dep, then app) +eph down --role dep # stop the dep tier AND everything that depends on it +``` + +`eph up --role` resolves the **dependency** closure (the role plus what it needs +below it); `eph down --role` resolves the **dependent** closure (the role plus +everything above it that would break without it), torn down in reverse start order. +Both combine with positional service names. This is what the prewarm hook above uses +to start the backing tier alone. + ## Multiple checkouts side by side This is what `eph` is built for. Clone the same repo twice: diff --git a/example.eph b/example.eph index 882d8c8..c9a3dca 100644 --- a/example.eph +++ b/example.eph @@ -1,11 +1,31 @@ # Example .eph file for a typical web application +# ============================================================================= +# Roles: dependency services vs the first-party app +# ============================================================================= + +# `roles_order` splits the stack into tiers and orders them. Here the backing +# services (`dep`) come up before the app (`app`). The linear form below is +# shorthand for "app depends on dep"; a `[roles_order]` section can spell out a +# full dependency graph instead (e.g. a worker that needs `dep` but not `app`). +# +# Once roles exist you can bring up one tier on its own: `eph up --role dep` +# starts the dependency services (and anything they depend on) without touching +# the app. A Claude Code SessionStart hook can use that to prewarm the databases +# and caches so they are known-good and their connection env is ready the moment +# a session opens, leaving the app for you to start with `eph up` or `eph dev` +# when you actually want it. A later `eph dev` reuses the already-running +# dependency services and, on exit, tears down only the app it started, leaving +# the prewarmed tier hot. +roles_order=dep,app + # ============================================================================= # Services # ============================================================================= [postgres] image=postgres:16-alpine +role=dep port=5432 env.POSTGRES_USER=dev env.POSTGRES_PASSWORD=dev @@ -16,11 +36,13 @@ post-start=cargo sqlx migrate run [redis] image=redis:7-alpine +role=dep port=6379 healthcheck=redis-cli ping [minio] image=minio/minio +role=dep port.api=9000 port.console=9001 env.MINIO_ROOT_USER=minioadmin @@ -30,15 +52,17 @@ volume=miniodata:/data [mailhog] image=mailhog/mailhog +role=dep port.smtp=1025 port.web=8025 # The app you are building. eph allocates a free host port (so two checkouts # never collide on 3000), injects it as PORT, and re-launches on a fresh port if -# the process dies on a port conflict. Backing services above start first, so +# the process dies on a port conflict. The `dep` tier above starts first, so # DATABASE_URL etc. are already set in this process's environment. [web] run=npm run dev +role=app port=auto env.PORT=${web.port} # pre-start runs before the dev server boots: regenerate the typed API client diff --git a/skills/using-eph/SKILL.md b/skills/using-eph/SKILL.md index 49eb5fd..2c5dca4 100644 --- a/skills/using-eph/SKILL.md +++ b/skills/using-eph/SKILL.md @@ -110,6 +110,86 @@ DATABASE_URL=postgres://dev@localhost:${postgres.port}/app hooks run on the host via `sh -c` with eph's resolved environment injected (see below). +## Roles: dependency services vs the app + +A `.eph` file can split its services into tiers with a `role=` on each service +and a top-level `roles_order`. The usual split is dependency services (Postgres, +Redis, object storage: things the code talks to, safe to start eagerly) from the +first-party app you are building (start it on demand; it may bind preview ports +or run side effects). Roles let you bring up one tier without the other. + +```ini +roles_order=dep,app # dep services come up before the app + +[postgres] +image=postgres:16 +role=dep + +[web] +run=npm run dev +port=auto +role=app +``` + +- `roles_order=dep,app` is the linear form: each role depends on the one before + it. For a graph (a `worker` that needs `dep` but not `app`), use a section + instead, where each line is `role=dependencies` and a bare `role=` is a root: + + ```ini + [roles_order] + dep= + app=dep + worker=dep + ``` + +- Roles are all-or-nothing: once any service has a `role=`, a `roles_order` is + required, every service must declare a role listed in it, every listed role + must have a service, and the graph must be acyclic. `eph check` reports any + violation. A file with no roles at all keeps the old behavior (declaration + order, `run=` services last), so nothing needs roles. +- Bring-up follows the role graph (dependencies first); teardown reverses it. +- `eph up --role ` starts that role **and everything it depends on**, and + nothing else. Repeatable, and it unions with any positional service names. + `eph up --role dep` starts just the dependency tier. `eph down --role ` + tears down that role and everything that depends on it. + +## Prewarm dependency services at session start + +Because `eph up --role dep` starts the dependency tier without the app, it is the +natural thing to run from a Claude Code **SessionStart hook**: the databases and +caches come up known-good and their connection env is ready before your first +command, so you never hit "the service isn't running" and restart your work. A +later `eph up` or `eph dev` reuses those already-running services, and `eph dev` +leaves them up when it exits (it tears down only the app it started). + +```sh +#!/usr/bin/env bash +# .claude/hooks/eph-prewarm.sh: prewarm deps and inject their connection env. +# $CLAUDE_ENV_FILE is sourced by Claude Code, so later Bash tool calls in the +# session inherit DATABASE_URL, REDIS_URL, and the rest. +test -f .eph || exit 0 +eph up --role dep || exit 0 +[ -n "$CLAUDE_ENV_FILE" ] && eph env >> "$CLAUDE_ENV_FILE" +``` + +```json +// .claude/settings.json: run it on session start (project scope: everyone in +// the repo/worktree gets it). Use ~/.claude/settings.json for a personal one. +{ + "hooks": { + "SessionStart": [ + { "matcher": "startup|resume", + "hooks": [ { "type": "command", "command": ".claude/hooks/eph-prewarm.sh" } ] } + ] + } +} +``` + +Substitute your own dependency role name for `dep`. To also seed on prewarm, drop +the default (post-start hooks run); to prewarm bare, add `--skip-hooks`. If you +want the tier torn down when a session ends, add a `SessionEnd` hook running +`eph down --role dep`; the default is to leave it warm for the next session. + ## Lifecycle hooks see eph's environment Four hooks bracket a service, in order: `pre-start` (before it is created), @@ -209,6 +289,10 @@ default, or `eph clean` with `--clean` (each running `pre-stop` then - Teardown defaults to `eph down` (keeps data for a fast relaunch, since Claude restarts the server during a session). Use `eph dev --clean` (`runtimeArgs: ["dev", "--clean"]`) for a pristine reset on every launch. +- `eph dev` tears down only the services it actually started. Any that were + already running when it launched (a dependency tier a SessionStart hook + prewarmed) are left up, so the deps stay hot across `eph dev` runs. `--clean` + overrides this and bulldozes everything. - A hard kill (not a normal stop) skips teardown and leaves services up, recoverable with `eph down`. If the app crashes on its own, `eph dev` leaves services up for inspection (`eph logs `) and exits non-zero. diff --git a/src/main.rs b/src/main.rs index 3013257..0b224d2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -46,6 +46,12 @@ enum Commands { #[arg(value_name = "SERVICE")] services: Vec, + /// Bring up only these roles and everything they depend on. Repeatable + /// (`--role dep --role app`). Requires a `roles_order` in the `.eph` file. + /// Combines with any SERVICE names (their union is started). + #[arg(long = "role", value_name = "ROLE")] + roles: Vec, + /// Bring services up healthy but do not run their post-start hooks #[arg(long = "skip-hooks")] skip_hooks: bool, @@ -57,6 +63,12 @@ enum Commands { #[arg(value_name = "SERVICE")] services: Vec, + /// Stop only these roles and everything that depends on them. Repeatable + /// (`--role dep`). Requires a `roles_order` in the `.eph` file. Combines + /// with any SERVICE names (their union is stopped). + #[arg(long = "role", value_name = "ROLE")] + roles: Vec, + /// Remove containers after stopping them (instead of just stopping) #[arg(short = 'r', long = "rm")] rm: bool, @@ -231,15 +243,17 @@ async fn main() -> Result { match cli.command { Commands::Up { services, + roles, skip_hooks, - } => cmd_up(services, skip_hooks) + } => cmd_up(services, roles, skip_hooks) .await .map(|()| ExitCode::SUCCESS), Commands::Down { services, + roles, rm, skip_hooks, - } => cmd_down(services, rm, skip_hooks) + } => cmd_down(services, roles, rm, skip_hooks) .await .map(|()| ExitCode::SUCCESS), Commands::Clean { skip_hooks } => cmd_clean(skip_hooks).await.map(|()| ExitCode::SUCCESS), @@ -280,10 +294,56 @@ async fn main() -> Result { } } -async fn cmd_up(service_filter: Vec, skip_hooks: bool) -> Result<()> { +/// Which direction a `--role` selection resolves in: `Up` pulls in each role's +/// dependencies, `Down` pulls in each role's dependents. See +/// [`resolve_service_selection`]. +#[derive(Clone, Copy)] +enum Direction { + Up, + Down, +} + +/// Turn a command's positional SERVICE names and `--role` values into the set of +/// service names to act on. +/// +/// Positional names are validated against the file and taken as-is. Each `--role` +/// expands to its services plus, in the requested direction, the services it +/// depends on (`Up`) or that depend on it (`Down`). The two are unioned, order +/// preserved and duplicates dropped, so `eph up web --role dep` starts `web` and +/// the whole dependency tier. An empty result means "act on everything", matching +/// a bare `eph up` / `eph down`. +fn resolve_service_selection( + eph: &EphFile, + services: Vec, + roles: &[String], + dir: Direction, +) -> Result> { + for name in &services { + if !eph.services.contains_key(name) { + anyhow::bail!("unknown service: {}", name); + } + } + let mut names = services; + if !roles.is_empty() { + let from_roles = match dir { + Direction::Up => eph.services_for_roles_up(roles)?, + Direction::Down => eph.services_for_roles_down(roles)?, + }; + for name in from_roles { + if !names.contains(&name) { + names.push(name); + } + } + } + Ok(names) +} + +async fn cmd_up(services: Vec, roles: Vec, skip_hooks: bool) -> Result<()> { let workspace = Workspace::find_from_cwd()?; let eph = load_eph_file(&workspace)?; + let service_filter = resolve_service_selection(&eph, services, &roles, Direction::Up)?; + let mut manager = ServiceManager::new(workspace).await?; let running = manager @@ -307,35 +367,33 @@ async fn cmd_up(service_filter: Vec, skip_hooks: bool) -> Result<()> { Ok(()) } -async fn cmd_down(service_filter: Vec, rm: bool, skip_hooks: bool) -> Result<()> { +async fn cmd_down( + services: Vec, + roles: Vec, + rm: bool, + skip_hooks: bool, +) -> Result<()> { let workspace = Workspace::find_from_cwd()?; let eph = load_eph_file(&workspace)?; + let targets = resolve_service_selection(&eph, services, &roles, Direction::Down)?; + let mut manager = ServiceManager::new(workspace).await?; let action = if rm { "stopped and removed" } else { "stopped" }; - if service_filter.is_empty() { + if targets.is_empty() { manager.stop_all(&eph, rm, skip_hooks).await?; println!("All services {}", action); } else { - // Snapshot running services once so pre-stop hooks see the full - // environment as it was before teardown began. - let running = manager.status().await?; - for name in &service_filter { - let service = eph - .services - .get(name) - .with_context(|| format!("unknown service: {}", name))?; - manager - .stop_service(name, service, rm, &eph, &running, skip_hooks) - .await?; + // Tear the subset down in reverse start order (dependents before the + // dependencies they need), persisting the dropped state entries. + manager + .stop_selected(&eph, &targets, rm, skip_hooks) + .await?; + for name in &targets { println!("{} {}", if rm { "Removed" } else { "Stopped" }, name); } - // Persist so the stopped services are dropped from state.json, not just - // from the in-memory copy. stop_all already saves; a targeted down must - // too, or the file keeps stale entries until the next `eph status`. - manager.save_state().await?; } Ok(()) @@ -408,6 +466,22 @@ async fn cmd_dev(service: Option, clean: bool, watch: Vec) -> Re let mut manager = ServiceManager::new(workspace).await?; + // Services already running before `eph dev` starts (typically dependency + // services a SessionStart hook prewarmed with `eph up --role=`) are + // adopted, not owned: eph dev reuses them and must leave them running when it + // tears down. Everything else it brings up itself and is responsible for + // stopping. Snapshotting here, before the first bring-up, is the whole + // ownership model: no persisted refcount required. `--clean` overrides this + // and bulldozes everything, since it is an explicit full-reset request. + let brought_up: Vec = { + let already_running = manager.status().await?; + eph.services + .keys() + .filter(|name| !already_running.contains_key(*name)) + .cloned() + .collect() + }; + // A preview server (Claude Desktop) assigns a host port, passes it as $PORT, // then polls it and reveals the app the instant it accepts a connection. We // deliberately do NOT let the app bind that port itself: if it did, the @@ -459,7 +533,7 @@ async fn cmd_dev(service: Option, clean: bool, watch: Vec) -> Re match stop { DevStop::Signal => { - final_teardown(&mut manager, &eph, clean).await?; + final_teardown(&mut manager, &eph, clean, &brought_up).await?; // Reap the foreground child we just tore down. let _ = child.wait().await; return Ok(ExitCode::SUCCESS); @@ -486,24 +560,27 @@ async fn cmd_dev(service: Option, clean: bool, watch: Vec) -> Re eprintln!("dev server '{foreground}' {how}; waiting for a change to restart"); tokio::select! { () = wait_for_shutdown() => { - final_teardown(&mut manager, &eph, clean).await?; + final_teardown(&mut manager, &eph, clean, &brought_up).await?; return Ok(ExitCode::SUCCESS); } path = watcher.changed_or_pending() => { restart_banner(&path); // Fall through to the uniform full restart below. - manager.stop_all(&eph, false, false).await?; + manager.stop_selected(&eph, &brought_up, false, false).await?; } } } DevStop::FileChanged(path) => { // A restart is a full down + up, including hooks: stop the stack // (pre-stop hooks and all), keeping containers and volume data for - // a fast restart, then loop to bring it back up. A change never - // drops volumes, even under `--clean`; that teardown is reserved - // for the final stop. + // a fast restart, then loop to bring it back up. Only the services + // eph dev brought up are bounced; adopted prewarmed dependencies + // stay up across the restart. A change never drops volumes, even + // under `--clean`; that teardown is reserved for the final stop. restart_banner(&path); - manager.stop_all(&eph, false, false).await?; + manager + .stop_selected(&eph, &brought_up, false, false) + .await?; // Reap the foreground child torn down with the stack before the // next pass spawns its replacement. let _ = child.wait().await; @@ -512,17 +589,26 @@ async fn cmd_dev(service: Option, clean: bool, watch: Vec) -> Re } } -/// Tear the whole stack down on a final stop: `eph clean` (drop volumes and their -/// data) with `--clean`, otherwise `eph down` (keep them for a fast restart). -/// Shared by the stop-signal path and the watch-mode "crashed, then stopped" path -/// so both honor `--clean` identically. -async fn final_teardown(manager: &mut ServiceManager, eph: &EphFile, clean: bool) -> Result<()> { +/// Tear the stack down on a final stop. +/// +/// With `--clean` this bulldozes the whole workspace (`eph clean`: drop every +/// service and its volume data), the explicit full-reset path. Without it, only +/// the services `eph dev` brought up are stopped (`brought_up`), leaving any it +/// adopted (a session hook's prewarmed dependencies) running for a fast restart +/// or the next command. Shared by the stop-signal path and the watch-mode +/// "crashed, then stopped" path so both honor `--clean` identically. +async fn final_teardown( + manager: &mut ServiceManager, + eph: &EphFile, + clean: bool, + brought_up: &[String], +) -> Result<()> { eprintln!(); if clean { manager.clean(eph, false).await?; eprintln!("Workspace cleaned"); } else { - manager.stop_all(eph, false, false).await?; + manager.stop_selected(eph, brought_up, false, false).await?; eprintln!("Services stopped"); } Ok(()) @@ -1036,7 +1122,19 @@ async fn cmd_check() -> Result<()> { ServiceSource::Compose(path) => format!("compose: {}", path), ServiceSource::Command(cmd) => format!("command: {}", cmd), }; - println!(" {} ({})", name, source); + match &svc.role { + Some(role) => println!(" {} [{}] ({})", name, role, source), + None => println!(" {} ({})", name, source), + } + } + + // In roles mode, show the tiers and the resulting bring-up order so the + // dependency-vs-app split (and what `--role` will select) is visible at a + // glance without running Docker. + if eph.roles_order.is_some() { + let order: Vec<&str> = eph.start_order().iter().map(|s| s.as_str()).collect(); + println!(); + println!("Bring-up order: {}", order.join(", ")); } Ok(()) diff --git a/src/parser.rs b/src/parser.rs index bc1dd12..fff841d 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -43,6 +43,221 @@ pub struct EphFile { /// declaration order so start sequencing and command output are /// reproducible (the parser preserves section order end to end). pub services: IndexMap, + /// The role dependency graph, when the file uses roles. + /// + /// `None` in "legacy mode" (no service declares a `role=` and there is no + /// `roles_order`), where ordering falls back to declaration order with `run=` + /// services last. `Some` in "roles mode", where it is the single source of + /// truth for bring-up order: services are grouped by role, roles are brought + /// up in topological order of this graph, and teardown is the exact reverse. + /// The parser guarantees the graph is consistent with the services (every + /// service role is a node, every node has at least one service, every edge + /// points at a known role, no cycles). + pub roles_order: Option, +} + +/// The role dependency graph for a `.eph` file in "roles mode". +/// +/// Written either as a linear top-level `roles_order=a,b,c` (sugar: `b` depends +/// on `a`, `c` on `b`) or as a `[roles_order]` section giving each role's +/// dependencies explicitly (`app=dep,cache`, a bare `dep=` for a root). Both +/// desugar to this adjacency list. "Depends on" means "must come up first": an +/// edge `app -> dep` orders `dep` before `app` and pulls `dep` in whenever `app` +/// is requested. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct RolesOrder { + /// Each role mapped to the roles it depends on. Keys are every role in the + /// graph (roots included, with an empty dependency list), kept in declaration + /// order so the topological sort is a deterministic, stable tie-break. + pub deps: IndexMap>, +} + +impl RolesOrder { + /// The roles in topological order: every role appears after all the roles it + /// depends on. Ties (roles with no ordering constraint between them) break by + /// declaration order, so the result is deterministic. + /// + /// # Errors + /// + /// Returns an error naming a role involved in a dependency cycle. The parser + /// calls this during validation, so a `RolesOrder` that escaped parsing is + /// always acyclic and this cannot fail at runtime. + pub fn topo_roles(&self) -> Result> { + let mut ordered: Vec = Vec::with_capacity(self.deps.len()); + // Kahn-style, but scanning in declaration order each round so ties break + // deterministically. n is tiny (a handful of roles), so the simple + // O(n^2) scan is not worth optimizing. + while ordered.len() < self.deps.len() { + let next = self.deps.keys().find(|role| { + !ordered.contains(*role) && self.deps[*role].iter().all(|dep| ordered.contains(dep)) + }); + match next { + Some(role) => ordered.push(role.clone()), + None => { + let stuck: Vec<&str> = self + .deps + .keys() + .filter(|r| !ordered.contains(*r)) + .map(String::as_str) + .collect(); + bail!( + "roles_order has a dependency cycle among: {}", + stuck.join(", ") + ); + } + } + } + Ok(ordered) + } + + /// The transitive closure of `roles` over their dependencies: every requested + /// role plus everything it (transitively) depends on. This is the set brought + /// up by `eph up --role=`, since a role cannot run without the roles it + /// depends on. + /// + /// # Errors + /// + /// Returns an error if a requested role is not part of the graph. + pub fn forward_closure(&self, roles: &[String]) -> Result> { + self.closure(roles, |role| { + self.deps.get(role).cloned().unwrap_or_default() + }) + } + + /// The transitive closure of `roles` over their dependents: every requested + /// role plus everything that (transitively) depends on it. This is the set + /// torn down by `eph down --role=`, since a dependency cannot be removed + /// while the roles that need it are still up. + /// + /// # Errors + /// + /// Returns an error if a requested role is not part of the graph. + pub fn reverse_closure(&self, roles: &[String]) -> Result> { + self.closure(roles, |role| { + self.deps + .iter() + .filter(|(_, deps)| deps.iter().any(|d| d == role)) + .map(|(r, _)| r.clone()) + .collect() + }) + } + + /// Shared transitive-closure walk. `neighbors` yields the roles to follow + /// from a given role (its dependencies for the forward closure, its + /// dependents for the reverse). Validates that every seed role exists. + fn closure(&self, roles: &[String], neighbors: F) -> Result> + where + F: Fn(&str) -> Vec, + { + for role in roles { + if !self.deps.contains_key(role) { + bail!( + "unknown role '{}' (known roles: {})", + role, + self.deps.keys().cloned().collect::>().join(", ") + ); + } + } + let mut seen: Vec = Vec::new(); + let mut stack: Vec = roles.to_vec(); + while let Some(role) = stack.pop() { + if seen.contains(&role) { + continue; + } + seen.push(role.clone()); + stack.extend(neighbors(&role)); + } + Ok(seen) + } +} + +impl EphFile { + /// The order services are brought up in. + /// + /// In roles mode this is the topological order of the role graph (roles + /// grouped, dependencies first), with services inside a role kept in + /// declaration order. In legacy mode (no roles) it is declaration order with + /// `run=` services deferred to the end, so a managed app starts after the + /// backing services it references. Teardown is the exact reverse either way. + /// + /// This is the single source of truth for start sequencing across the + /// codebase; `service.rs` calls it rather than re-deriving order. + #[must_use] + pub fn start_order(&self) -> Vec<&String> { + match &self.roles_order { + Some(order) => { + // Safe to unwrap: the parser rejected cycles, so a parsed + // `EphFile` always has an acyclic graph. + let topo = order + .topo_roles() + .expect("roles_order validated acyclic at parse time"); + let mut names: Vec<&String> = Vec::with_capacity(self.services.len()); + for role in &topo { + for (name, svc) in &self.services { + if svc.role.as_deref() == Some(role.as_str()) { + names.push(name); + } + } + } + names + } + None => { + let mut names: Vec<&String> = self.services.keys().collect(); + names.sort_by_key(|name| { + matches!(self.services[*name].source, ServiceSource::Command(_)) + }); + names + } + } + } + + /// The service names to bring up for `eph up --role=`: every service + /// whose role is in the forward (dependency) closure of `roles`, returned in + /// bring-up order. + /// + /// # Errors + /// + /// Returns an error if the file does not use roles, or if a requested role is + /// not defined. + pub fn services_for_roles_up(&self, roles: &[String]) -> Result> { + let order = self.roles_order.as_ref().context( + "this .eph file does not define roles, so `--role` cannot be used; \ + pass service names instead, or add a `roles_order`", + )?; + let role_set = order.forward_closure(roles)?; + Ok(self.services_in_role_set(&role_set)) + } + + /// The service names to tear down for `eph down --role=`: every service + /// whose role is in the reverse (dependent) closure of `roles`, returned in + /// bring-up order (the caller stops in reverse). + /// + /// # Errors + /// + /// Returns an error if the file does not use roles, or if a requested role is + /// not defined. + pub fn services_for_roles_down(&self, roles: &[String]) -> Result> { + let order = self.roles_order.as_ref().context( + "this .eph file does not define roles, so `--role` cannot be used; \ + pass service names instead, or add a `roles_order`", + )?; + let role_set = order.reverse_closure(roles)?; + Ok(self.services_in_role_set(&role_set)) + } + + /// Service names whose role is in `role_set`, in bring-up order. + fn services_in_role_set(&self, role_set: &[String]) -> Vec { + self.start_order() + .into_iter() + .filter(|name| { + self.services[*name] + .role + .as_ref() + .is_some_and(|r| role_set.contains(r)) + }) + .cloned() + .collect() + } } /// An environment variable definition. @@ -67,6 +282,17 @@ pub struct EnvVar { pub struct Service { /// Service name (matches section header) pub name: String, + /// The role this service belongs to, if any (`role=` in the `.eph` file). + /// + /// `None` means the service is unclassified. A file with no roles anywhere + /// (and no `roles_order`) behaves exactly as it did before roles existed: + /// declaration order, `run=` services last. Once any service declares a role + /// the file is in "roles mode", where every service must have a role that + /// appears in [`EphFile::roles_order`] and that ordering drives start + /// sequencing instead of the source-based heuristic. The parser enforces this + /// invariant, so a `Service` seen at runtime is either wholly unclassified or + /// part of a fully specified role graph. + pub role: Option, /// How to start this service pub source: ServiceSource, /// Port mappings (container ports that will be mapped to random host ports) @@ -144,6 +370,7 @@ pub struct PortMapping { #[derive(Default)] struct ServiceBuilder { name: String, + role: Option, source: Option, ports: Vec, env: HashMap, @@ -189,6 +416,7 @@ impl ServiceBuilder { } Ok(Service { name: self.name, + role: self.role, source, ports: self.ports, env: self.env, @@ -247,6 +475,14 @@ pub fn parse(input: &str) -> Result { let mut index_by_name: HashMap = HashMap::new(); let mut current_service: Option = None; + // The role graph, accumulated from whichever form the file uses. The linear + // `roles_order=a,b,c` key and the `[roles_order]` section are mutually + // exclusive; `roles_order_dag` holds the section form's adjacency list, and + // `in_roles_order` tracks whether we are currently inside that section. + let mut roles_order_linear: Option> = None; + let mut roles_order_dag: Option>> = None; + let mut in_roles_order = false; + for (line_num, line) in input.lines().enumerate() { let line_num = line_num + 1; // 1-indexed let line = line.trim(); @@ -262,6 +498,22 @@ pub fn parse(input: &str) -> Result { if name.is_empty() { bail!("empty section name at line {}", line_num); } + // `[roles_order]` is a reserved section, not a service: its lines are + // `role=dependencies` edges rather than service properties. + if name == "roles_order" { + if roles_order_linear.is_some() { + bail!( + "line {}: cannot use both a top-level `roles_order=` and a \ + [roles_order] section; pick one", + line_num + ); + } + roles_order_dag.get_or_insert_with(IndexMap::new); + current_service = None; + in_roles_order = true; + continue; + } + in_roles_order = false; let index = *index_by_name.entry(name.to_string()).or_insert_with(|| { builders.push(ServiceBuilder { name: name.to_string(), @@ -284,6 +536,34 @@ pub fn parse(input: &str) -> Result { // Remove optional quotes from value let value = strip_quotes(value); + // Inside `[roles_order]`, each line is a `role=dep1,dep2` edge. An empty + // value declares a root role that depends on nothing. + if in_roles_order { + let deps = split_roles(value); + roles_order_dag + .as_mut() + .expect("dag is initialized on entering [roles_order]") + .insert(key.to_string(), deps); + continue; + } + + // A top-level `roles_order=a,b,c` is the linear shorthand for the graph. + // It is mutually exclusive with the `[roles_order]` section. + if current_service.is_none() && key == "roles_order" { + if roles_order_dag.is_some() { + bail!( + "line {}: cannot use both a top-level `roles_order=` and a \ + [roles_order] section; pick one", + line_num + ); + } + if roles_order_linear.is_some() { + bail!("line {}: duplicate top-level `roles_order=`", line_num); + } + roles_order_linear = Some(split_roles(value)); + continue; + } + if let Some(index) = current_service { // We're inside a service section - try to parse as service property let service = &mut builders[index]; @@ -331,7 +611,135 @@ pub fn parse(input: &str) -> Result { services.insert(service.name.clone(), service); } - Ok(EphFile { env_vars, services }) + // Collapse the two spellings into one graph: the linear form desugars into an + // adjacency list (each role depends on the one before it), the section form + // is already one. Then check the graph and the services agree before handing + // back an `EphFile`, so "roles mode" is either fully specified or absent. + let roles_order = match (roles_order_linear, roles_order_dag) { + (Some(linear), None) => Some(RolesOrder { + deps: desugar_linear_order(&linear), + }), + (None, Some(deps)) => Some(RolesOrder { deps }), + (None, None) => None, + // The parse loop rejects declaring both, so this pair cannot occur. + (Some(_), Some(_)) => unreachable!("both roles_order forms present"), + }; + validate_roles(&services, roles_order.as_ref())?; + + Ok(EphFile { + env_vars, + services, + roles_order, + }) +} + +/// Split a comma-separated role list (`a, b ,c`), trimming each entry and +/// dropping empties, into owned role names. Used for both the linear +/// `roles_order=` value and a `[roles_order]` line's dependency list. +fn split_roles(value: &str) -> Vec { + value + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(String::from) + .collect() +} + +/// Desugar the linear `roles_order=a,b,c` form into an adjacency list: `a` is a +/// root, and every later role depends on the one immediately before it, so the +/// chain topologically sorts back to the written order and `--role=c` pulls in +/// `a` and `b`. Declaration order is preserved in the returned map. +fn desugar_linear_order(roles: &[String]) -> IndexMap> { + let mut deps = IndexMap::with_capacity(roles.len()); + let mut prev: Option<&String> = None; + for role in roles { + let edges = prev.map(|p| vec![p.clone()]).unwrap_or_default(); + deps.insert(role.clone(), edges); + prev = Some(role); + } + deps +} + +/// Enforce the "roles mode" invariants tying the role graph to the services. +/// +/// A file is in roles mode when any service declares a `role=` or a `roles_order` +/// is present; the two must then be fully consistent. This is where the mutual +/// completeness the format promises is checked, so nothing downstream has to +/// cope with a half-specified graph: +/// +/// - a `roles_order` requires every service to declare a role, and vice versa; +/// - every service role, and every dependency edge, names a role in the graph; +/// - every role in the graph has at least one service; and +/// - the graph is acyclic. +/// +/// In legacy mode (no roles anywhere) there is nothing to check. +fn validate_roles( + services: &IndexMap, + roles_order: Option<&RolesOrder>, +) -> Result<()> { + let any_role = services.values().any(|s| s.role.is_some()); + let Some(order) = roles_order else { + // No graph. Legal only if no service is tagged either. + if any_role { + bail!( + "services declare a `role=` but the file has no `roles_order`; add a \ + top-level `roles_order=...` or a [roles_order] section listing the roles" + ); + } + return Ok(()); + }; + + if order.deps.is_empty() { + bail!("roles_order is empty; list at least one role"); + } + + // Every service must be tagged, and with a role the graph knows. + for service in services.values() { + let Some(role) = &service.role else { + bail!( + "service '{}' has no `role=`, but this file uses `roles_order`; every \ + service must declare a role when roles_order is set", + service.name + ); + }; + if !order.deps.contains_key(role) { + bail!( + "service '{}' has role '{}', which is not listed in roles_order (known \ + roles: {})", + service.name, + role, + order.deps.keys().cloned().collect::>().join(", ") + ); + } + } + + // Every edge must point at a known role, and every role must be backed by at + // least one service (an empty role is almost certainly a typo). + for (role, deps) in &order.deps { + for dep in deps { + if !order.deps.contains_key(dep) { + bail!( + "roles_order: role '{}' depends on unknown role '{}'", + role, + dep + ); + } + } + if !services + .values() + .any(|s| s.role.as_deref() == Some(role.as_str())) + { + bail!( + "roles_order lists role '{}', but no service declares it", + role + ); + } + } + + // Reject cycles up front so `topo_roles` is infallible everywhere else. + order.topo_roles()?; + + Ok(()) } /// Returns `true` if `key` looks like an environment variable name, i.e. a @@ -387,6 +795,8 @@ fn parse_service_property( "compose" => service.source = Some(ServiceSource::Compose(value.to_string())), // Shell command to run (non-Docker) "run" => service.source = Some(ServiceSource::Command(value.to_string())), + // The role this service belongs to (see `Service::role`). + "role" => service.role = Some(value.to_string()), // Container command override (for use with image/dockerfile) "command" => service.command_override = Some(value.to_string()), "port" => { @@ -848,4 +1258,187 @@ port.api=5000 assert!(!is_env_var_name("2FOO")); assert!(!is_env_var_name("env.FOO")); } + + // ======================================================================== + // Roles and roles_order + // ======================================================================== + + #[test] + fn parse_service_role() { + let eph = parse("roles_order=dep\n\n[postgres]\nimage=postgres:16\nrole=dep\n").unwrap(); + assert_eq!(eph.services["postgres"].role.as_deref(), Some("dep")); + } + + #[test] + fn no_roles_is_legacy_mode() { + // A file with no role= and no roles_order stays in legacy mode: no graph, + // and start order is declaration order with run= services last. + let eph = parse("[postgres]\nimage=postgres:16\n\n[web]\nrun=serve\n").unwrap(); + assert!(eph.roles_order.is_none()); + assert!(eph.services["postgres"].role.is_none()); + let order: Vec<&str> = eph.start_order().iter().map(|s| s.as_str()).collect(); + assert_eq!(order, ["postgres", "web"]); + } + + #[test] + fn linear_roles_order_desugars_to_a_chain() { + let eph = parse( + "roles_order=dep,app\n\n[db]\nimage=postgres:16\nrole=dep\n\n[web]\nrun=serve\nrole=app\n", + ) + .unwrap(); + let order = eph.roles_order.as_ref().unwrap(); + assert_eq!(order.deps["dep"], Vec::::new()); + assert_eq!(order.deps["app"], vec!["dep".to_string()]); + assert_eq!(order.topo_roles().unwrap(), vec!["dep", "app"]); + } + + #[test] + fn dag_roles_order_section_parses_edges() { + // worker depends on dep but NOT app, so it can come up without app. + let eph = parse( + "[db]\nimage=postgres:16\nrole=dep\n\ + [web]\nrun=serve\nrole=app\n\ + [jobs]\nrun=worker\nrole=worker\n\ + [roles_order]\ndep=\napp=dep\nworker=dep\n", + ) + .unwrap(); + let order = eph.roles_order.as_ref().unwrap(); + assert_eq!(order.deps["dep"], Vec::::new()); + assert_eq!(order.deps["app"], vec!["dep".to_string()]); + assert_eq!(order.deps["worker"], vec!["dep".to_string()]); + // dep sorts first; app and worker both follow it, breaking the tie by + // declaration order in the section (app before worker). + assert_eq!(order.topo_roles().unwrap(), vec!["dep", "app", "worker"]); + } + + #[test] + fn start_order_groups_by_role_in_topological_order() { + // Services are declared out of role order; start_order regroups them by + // the role graph, keeping declaration order within a role. + let eph = parse( + "roles_order=dep,app\n\ + [web]\nrun=serve\nrole=app\n\ + [db]\nimage=postgres:16\nrole=dep\n\ + [cache]\nimage=redis:7\nrole=dep\n", + ) + .unwrap(); + let order: Vec<&str> = eph.start_order().iter().map(|s| s.as_str()).collect(); + // Both dep services (in declaration order db, cache) before the app. + assert_eq!(order, ["db", "cache", "web"]); + } + + #[test] + fn forward_closure_pulls_in_dependencies_only() { + let eph = parse( + "[db]\nimage=postgres:16\nrole=dep\n\ + [web]\nrun=serve\nrole=app\n\ + [jobs]\nrun=worker\nrole=worker\n\ + [roles_order]\ndep=\napp=dep\nworker=dep\n", + ) + .unwrap(); + // --role=worker brings up worker + dep, but NOT app. + assert_eq!( + eph.services_for_roles_up(&["worker".to_string()]).unwrap(), + vec!["db".to_string(), "jobs".to_string()] + ); + // --role=app brings up app + dep, but NOT worker. + assert_eq!( + eph.services_for_roles_up(&["app".to_string()]).unwrap(), + vec!["db".to_string(), "web".to_string()] + ); + } + + #[test] + fn reverse_closure_pulls_in_dependents() { + let eph = parse( + "[db]\nimage=postgres:16\nrole=dep\n\ + [web]\nrun=serve\nrole=app\n\ + [jobs]\nrun=worker\nrole=worker\n\ + [roles_order]\ndep=\napp=dep\nworker=dep\n", + ) + .unwrap(); + // Tearing down dep must also take everything that depends on it, returned + // in bring-up order (the caller stops in reverse). + assert_eq!( + eph.services_for_roles_down(&["dep".to_string()]).unwrap(), + vec!["db".to_string(), "web".to_string(), "jobs".to_string()] + ); + } + + #[test] + fn role_flag_on_a_file_without_roles_is_an_error() { + let eph = parse("[postgres]\nimage=postgres:16\n").unwrap(); + assert!(eph.services_for_roles_up(&["dep".to_string()]).is_err()); + } + + #[test] + fn unknown_role_in_selection_is_an_error() { + let eph = parse("roles_order=dep\n\n[db]\nimage=postgres:16\nrole=dep\n").unwrap(); + let err = eph + .services_for_roles_up(&["nope".to_string()]) + .expect_err("unknown role must error"); + assert!(err.to_string().contains("nope")); + } + + #[test] + fn tagging_a_role_without_roles_order_is_rejected() { + let input = "[postgres]\nimage=postgres:16\nrole=dep\n"; + let err = parse(input).expect_err("role without roles_order must be rejected"); + assert!(err.to_string().contains("roles_order")); + } + + #[test] + fn roles_order_with_an_untagged_service_is_rejected() { + let input = "roles_order=dep\n\n[db]\nimage=postgres:16\nrole=dep\n\n[web]\nrun=serve\n"; + let err = parse(input).expect_err("untagged service under roles_order must be rejected"); + let msg = err.to_string(); + assert!(msg.contains("web") && msg.contains("role")); + } + + #[test] + fn service_role_not_in_roles_order_is_rejected() { + let input = "roles_order=dep\n\n[web]\nrun=serve\nrole=app\n"; + let err = parse(input).expect_err("service role missing from roles_order must be rejected"); + assert!(err.to_string().contains("app")); + } + + #[test] + fn roles_order_role_without_a_service_is_rejected() { + // `cache` is listed in roles_order but no service declares it. + let input = "roles_order=dep,cache\n\n[db]\nimage=postgres:16\nrole=dep\n"; + let err = parse(input).expect_err("role with no service must be rejected"); + assert!(err.to_string().contains("cache")); + } + + #[test] + fn dependency_on_unknown_role_is_rejected() { + let input = "[db]\nimage=postgres:16\nrole=dep\n[roles_order]\ndep=ghost\n"; + let err = parse(input).expect_err("edge to unknown role must be rejected"); + assert!(err.to_string().contains("ghost")); + } + + #[test] + fn cyclic_roles_order_is_rejected() { + let input = "[a]\nrun=a\nrole=x\n[b]\nrun=b\nrole=y\n[roles_order]\nx=y\ny=x\n"; + let err = parse(input).expect_err("a role cycle must be rejected"); + assert!(err.to_string().contains("cycle")); + } + + #[test] + fn declaring_both_roles_order_forms_is_rejected() { + let input = "roles_order=dep\n\n[db]\nimage=postgres:16\nrole=dep\n\n[roles_order]\ndep=\n"; + let err = parse(input).expect_err("both linear and section forms must be rejected"); + assert!(err.to_string().contains("both")); + } + + #[test] + fn roles_order_section_can_precede_the_services() { + // The section may appear anywhere, including before the services it names. + let eph = parse( + "[roles_order]\ndep=\napp=dep\n\n[db]\nimage=postgres:16\nrole=dep\n\n[web]\nrun=serve\nrole=app\n", + ) + .unwrap(); + let order: Vec<&str> = eph.start_order().iter().map(|s| s.as_str()).collect(); + assert_eq!(order, ["db", "web"]); + } } diff --git a/src/service.rs b/src/service.rs index c704953..92edc54 100644 --- a/src/service.rs +++ b/src/service.rs @@ -12,7 +12,7 @@ use bollard::query_parameters::{ }; use futures_util::StreamExt; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::io::{Read, Seek, SeekFrom, Write}; use std::num::NonZeroU32; use std::path::PathBuf; @@ -568,19 +568,16 @@ async fn wait_until_ready( } } -/// The order services are brought up in: declaration order, but with `run=` -/// (command) services moved to the end so a managed app starts after the -/// backing services it can reference (e.g. `${postgres.port}`). The sort is -/// stable, so declaration order is preserved within each group. +/// The order services are brought up in. /// -/// This is the single source of truth for start sequencing: `start_services` +/// Delegates to [`EphFile::start_order`], the single source of truth for start +/// sequencing: in roles mode it is the role graph's topological order, and in +/// legacy mode declaration order with `run=` services last. `start_services` /// uses it to pick the phase-1 order, and `stop_all` / `clean` tear down in its /// reverse, so a dependent is always stopped before the dependency it relies on /// (its `pre-stop` hook sees the dependency still up). fn start_order(eph: &EphFile) -> Vec<&String> { - let mut names: Vec<&String> = eph.services.keys().collect(); - names.sort_by_key(|name| matches!(eph.services[*name].source, ServiceSource::Command(_))); - names + eph.start_order() } /// Whether a dead process's captured `log` names a port conflict, matched @@ -1394,11 +1391,15 @@ impl ServiceManager { bail!("unknown service: {}", name); } } - let mut targets: Vec<&String> = filter.iter().collect(); - targets.sort_by_key(|name| { - matches!(eph.services[*name].source, ServiceSource::Command(_)) - }); - targets + // Keep the requested subset, but bring them up in the global start + // order (topological in roles mode, command-last in legacy mode) + // rather than the order the names were passed, so a filtered `eph up` + // still respects dependencies. + let wanted: HashSet<&str> = filter.iter().map(String::as_str).collect(); + start_order(eph) + .into_iter() + .filter(|name| wanted.contains(name.as_str())) + .collect() }; // Phase 1: run each target's pre-start hook, then create or reuse it, @@ -2201,6 +2202,38 @@ impl ServiceManager { Ok(()) } + /// Stop a specific subset of services, in the reverse of the start order, so + /// a dependent is always stopped before the dependency it relies on (its + /// `pre-stop` hook still sees that dependency up). + /// + /// Used by a filtered `eph down` (explicit service names or `--role`) and by + /// `eph dev` to tear down only the services it brought up while leaving any + /// that were already running (a session hook's prewarmed dependencies) in + /// place. Names not in `targets` are skipped; names in `targets` that are not + /// running are a harmless no-op. + pub async fn stop_selected( + &mut self, + eph: &EphFile, + targets: &[String], + remove: bool, + skip_hooks: bool, + ) -> Result<()> { + let wanted: HashSet<&str> = targets.iter().map(String::as_str).collect(); + // Snapshot running services once so every pre-stop/post-stop hook sees the + // full environment as it was before teardown began. + let running = self.status().await?; + for name in start_order(eph).into_iter().rev() { + if !wanted.contains(name.as_str()) { + continue; + } + let service = &eph.services[name]; + self.stop_service(name, service, remove, eph, &running, skip_hooks) + .await?; + } + self.state.save(&self.workspace).await?; + Ok(()) + } + /// Stop a single service, running its `pre-stop` hooks before it stops and /// its `post-stop` hooks after. /// @@ -3430,6 +3463,38 @@ image=redis:7 assert_eq!(teardown, ["app", "redis", "postgres"]); } + /// In roles mode, `start_order` follows the role graph rather than the + /// source-based heuristic: a `run=` service tagged as a dependency (a mock + /// server, say) comes up before the app even though the legacy rule would + /// defer every `run=` service to the end. + #[test] + fn start_order_follows_roles_over_the_run_last_heuristic() { + let eph = crate::parser::parse( + r#" +roles_order=dep,app + +[web] +run=./serve +port=auto +role=app + +[postgres] +image=postgres:16 +role=dep + +[mock-auth] +run=./mock-auth +role=dep +"#, + ) + .unwrap(); + + // Both dep services (including the run= mock) precede the run= app, in + // declaration order within the dep role. + let order: Vec<&str> = start_order(&eph).iter().map(|s| s.as_str()).collect(); + assert_eq!(order, ["postgres", "mock-auth", "web"]); + } + #[tokio::test] async fn wait_until_ready_returns_the_first_some_after_polling() { let mut calls = 0; diff --git a/tests/integration.rs b/tests/integration.rs index 2c45133..7deac2b 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -228,6 +228,69 @@ DATABASE_URL=postgres://dev:dev@localhost:${postgres.port}/test ); } +// ============================================================================ +// Roles Tests (no Docker: parsing, validation, and selection only) +// ============================================================================ + +/// `eph check` reports each service's role and the resulting bring-up order when +/// the file uses roles, so the dependency-vs-app split is visible without Docker. +#[tokio::test] +async fn check_reports_roles_and_bring_up_order() { + let ws = TestWorkspace::new( + r#" +roles_order=dep,app + +[web] +run=serve +role=app + +[postgres] +image=postgres:16 +role=dep +"#, + ); + + let out = ws.eph_ok(&["check"]).await; + assert!(out.contains("postgres [dep]"), "roles not shown:\n{out}"); + assert!(out.contains("web [app]"), "roles not shown:\n{out}"); + // dep comes up before app regardless of declaration order. + assert!( + out.contains("Bring-up order: postgres, web"), + "bring-up order missing or wrong:\n{out}" + ); +} + +/// `--role` on a file that does not define roles is a clear error, and it fails +/// during selection (before touching Docker), so it is safe to assert here. +#[tokio::test] +async fn up_role_without_roles_order_errors() { + let ws = TestWorkspace::new("[redis]\nimage=redis:7-alpine\nport=6379\n"); + let out = ws.eph(&["up", "--role", "dep"]).await; + assert!(!out.status.success(), "up --role should fail without roles"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("does not define roles"), + "expected a no-roles error, got: {stderr}" + ); +} + +/// A file that tags a role but omits `roles_order` is rejected by `eph check`, +/// enforcing the mutual-completeness invariant with a message naming roles_order. +#[tokio::test] +async fn check_rejects_role_without_roles_order() { + let ws = TestWorkspace::new("[postgres]\nimage=postgres:16\nrole=dep\n"); + let out = ws.eph(&["check"]).await; + assert!( + !out.status.success(), + "check should reject a role without roles_order" + ); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("roles_order"), + "expected roles_order in the error, got: {stderr}" + ); +} + // ============================================================================ // Service Lifecycle Tests // ============================================================================ From e0d8f03a7b5d2d211e11e10a2d0bbe59f96e01a4 Mon Sep 17 00:00:00 2001 From: Jessica Black Date: Wed, 1 Jul 2026 01:41:01 -0700 Subject: [PATCH 2/3] Address code review: eph dev foreground guard, hook order, roles_order edges Fixes from a codex review pass over the roles feature: - `eph dev`: fail fast when the foreground service is already running rather than spawning a second copy and orphaning the original (its state entry would be overwritten, leaving it unstoppable). With that case rejected, the `brought_up` ownership set is just "every defined service not already running"; the foreground is always eph dev's to tear down, so the final `child.wait()` can never block on an unstopped process. - `run_all_post_start`: run hooks in start order (topological in roles mode), matching `eph up` and `run_all_pre_start`, instead of declaration order, so a dependency's post-start runs before a dependent's even when declared out of order. - `roles_order`: reject a duplicate role key in the `[roles_order]` section and a repeated role in the linear form, instead of silently overwriting an edge or desugaring to a self-cycle. - `roles_order`: keep role names uniformly free-form (drop the env-looking reclassification inside `[roles_order]`, which would have blocked an uppercase role name in DAG form while linear/`role=` accepted it). Document that top-level env vars belong outside the section. Co-Authored-By: Claude Opus 4.8 --- docs/user-guide/eph-file.md | 4 ++- src/main.rs | 40 ++++++++++++++--------- src/parser.rs | 64 +++++++++++++++++++++++++++++++++---- src/service.rs | 14 +++++--- 4 files changed, 95 insertions(+), 27 deletions(-) diff --git a/docs/user-guide/eph-file.md b/docs/user-guide/eph-file.md index 345bcaf..2d6b802 100644 --- a/docs/user-guide/eph-file.md +++ b/docs/user-guide/eph-file.md @@ -381,7 +381,9 @@ Here both `app` and `worker` depend on `dep`, but not on each other, so a `worke that needs the database but not the app can start without it. Use the DAG form when a role needs some but not all of the others; use the linear form for a straight chain. The section may appear anywhere in the file, including before the services it -names. +names. Every line inside it is a role edge (role names are free-form, so nothing is +reinterpreted as an env var), so keep top-level environment variables outside the +section: before the first section, or after the services. ### Ordering in roles mode diff --git a/src/main.rs b/src/main.rs index 0b224d2..e68c582 100644 --- a/src/main.rs +++ b/src/main.rs @@ -466,21 +466,31 @@ async fn cmd_dev(service: Option, clean: bool, watch: Vec) -> Re let mut manager = ServiceManager::new(workspace).await?; - // Services already running before `eph dev` starts (typically dependency - // services a SessionStart hook prewarmed with `eph up --role=`) are - // adopted, not owned: eph dev reuses them and must leave them running when it - // tears down. Everything else it brings up itself and is responsible for - // stopping. Snapshotting here, before the first bring-up, is the whole - // ownership model: no persisted refcount required. `--clean` overrides this - // and bulldozes everything, since it is an explicit full-reset request. - let brought_up: Vec = { - let already_running = manager.status().await?; - eph.services - .keys() - .filter(|name| !already_running.contains_key(*name)) - .cloned() - .collect() - }; + let already_running = manager.status().await?; + // `eph dev` spawns and attaches to the foreground app itself (see + // `start_foreground`, which never adopts an existing process). It therefore + // cannot foreground one that is already running: doing so would spawn a second + // copy and overwrite the original's state entry, orphaning it beyond eph's + // reach. Fail fast with a clear message instead. A prewarmed dependency tier + // is fine; only the app being foregrounded is the conflict. + if already_running.contains_key(foreground.as_str()) { + anyhow::bail!( + "the foreground service '{foreground}' is already running; stop it first \ + with `eph down {foreground}` (eph dev starts and attaches to it itself)" + ); + } + // Services already running now (a SessionStart hook's prewarmed dependency + // tier, typically) are adopted and left running on teardown. Everything else, + // including the foreground just guaranteed not to be running, eph dev brings up + // and is responsible for tearing back down. Snapshotting here, before the first + // bring-up, is the whole ownership model: no persisted refcount required. + // `--clean` overrides this and bulldozes everything, as an explicit full reset. + let brought_up: Vec = eph + .services + .keys() + .filter(|name| !already_running.contains_key(name.as_str())) + .cloned() + .collect(); // A preview server (Claude Desktop) assigns a host port, passes it as $PORT, // then polls it and reveals the app the instant it accepts a connection. We diff --git a/src/parser.rs b/src/parser.rs index fff841d..530baa8 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -536,14 +536,23 @@ pub fn parse(input: &str) -> Result { // Remove optional quotes from value let value = strip_quotes(value); - // Inside `[roles_order]`, each line is a `role=dep1,dep2` edge. An empty - // value declares a root role that depends on nothing. + // Inside `[roles_order]`, every line is a `role=dep1,dep2` edge (an empty + // value declares a root that depends on nothing). Role names are + // free-form, so a key here is never reinterpreted as an env var the way a + // service-section key can be: declare top-level env vars outside the + // section (before the first section, or after the services). if in_roles_order { - let deps = split_roles(value); - roles_order_dag + let dag = roles_order_dag .as_mut() - .expect("dag is initialized on entering [roles_order]") - .insert(key.to_string(), deps); + .expect("dag is initialized on entering [roles_order]"); + if dag.contains_key(key) { + bail!( + "line {}: duplicate role '{}' in [roles_order]", + line_num, + key + ); + } + dag.insert(key.to_string(), split_roles(value)); continue; } @@ -560,7 +569,7 @@ pub fn parse(input: &str) -> Result { if roles_order_linear.is_some() { bail!("line {}: duplicate top-level `roles_order=`", line_num); } - roles_order_linear = Some(split_roles(value)); + roles_order_linear = Some(split_roles_checked(value, line_num)?); continue; } @@ -645,6 +654,24 @@ fn split_roles(value: &str) -> Vec { .collect() } +/// Like [`split_roles`], but rejects a repeated role. Used for the linear +/// `roles_order=a,b,c` form, where a duplicate is a mistake: it would desugar to +/// a role depending on itself (a cycle) and, more usefully, almost always means a +/// typo. The list is short, so the quadratic scan is fine. +fn split_roles_checked(value: &str, line_num: usize) -> Result> { + let roles = split_roles(value); + for (i, role) in roles.iter().enumerate() { + if roles[..i].contains(role) { + bail!( + "line {}: duplicate role '{}' in roles_order", + line_num, + role + ); + } + } + Ok(roles) +} + /// Desugar the linear `roles_order=a,b,c` form into an adjacency list: `a` is a /// root, and every later role depends on the one immediately before it, so the /// chain topologically sorts back to the written order and `--role=c` pulls in @@ -1431,6 +1458,29 @@ port.api=5000 assert!(err.to_string().contains("both")); } + #[test] + fn duplicate_role_key_in_dag_is_rejected() { + let input = "[db]\nimage=postgres:16\nrole=dep\n[roles_order]\ndep=\ndep=\n"; + let err = parse(input).expect_err("a duplicate role key must be rejected"); + assert!(err.to_string().contains("duplicate role 'dep'")); + } + + #[test] + fn duplicate_role_in_linear_form_is_rejected() { + let input = "roles_order=dep,dep\n\n[db]\nimage=postgres:16\nrole=dep\n"; + let err = parse(input).expect_err("a repeated role in the linear form must be rejected"); + assert!(err.to_string().contains("duplicate role 'dep'")); + } + + #[test] + fn role_names_are_free_form_including_uppercase() { + // Role names are not restricted to any case: an uppercase role works in + // both the section and linear forms, and is never mistaken for an env var. + let eph = parse("[db]\nimage=postgres:16\nrole=DEP\n\n[roles_order]\nDEP=\n").unwrap(); + assert_eq!(eph.services["db"].role.as_deref(), Some("DEP")); + assert!(eph.roles_order.as_ref().unwrap().deps.contains_key("DEP")); + } + #[test] fn roles_order_section_can_precede_the_services() { // The section may appear anywhere, including before the services it names. diff --git a/src/service.rs b/src/service.rs index 92edc54..19a0810 100644 --- a/src/service.rs +++ b/src/service.rs @@ -1527,16 +1527,22 @@ impl ServiceManager { /// `eph dev` calls this once the backing services and the foreground app are /// all up, preserving the `eph up` guarantee that a hook may reference any /// service's assigned port (a seed whose `DATABASE_URL` interpolates - /// `${postgres.port}`, say). Hooks run in declaration order against a single - /// resolved snapshot of the running services. + /// `${postgres.port}`, say). Hooks run in start order (topological in roles + /// mode, matching `eph up`) against a single resolved snapshot of the running + /// services. /// /// # Errors /// /// Returns an error if any `post-start` hook fails. pub async fn run_all_post_start(&self, eph: &EphFile) -> Result<()> { let running = self.status().await?; - for service in eph.services.values() { - self.run_service_post_start(eph, &running, service).await?; + // Run in start order (topological in roles mode), matching `eph up`, so a + // dependency role's post-start hook runs before a dependent's even when + // the services are declared out of role order. `run_all_pre_start` already + // does this; keep the two consistent. + for name in start_order(eph) { + self.run_service_post_start(eph, &running, &eph.services[name]) + .await?; } Ok(()) } From 6271ead7ae01fd4852b77df1684508ab4cada612 Mon Sep 17 00:00:00 2001 From: Jessica Black Date: Wed, 1 Jul 2026 01:42:54 -0700 Subject: [PATCH 3/3] Regenerate committed skill copies for the roles update `eph skills check` in CI compares the checked-in `.claude/skills` and `.agents/skills` copies against the skill bundled in the binary. The roles section added to the source skill left those copies drifted; regenerate them with `eph skills install --force`. Co-Authored-By: Claude Opus 4.8 --- .agents/skills/using-eph/SKILL.md | 84 +++++++++++++++++++++++++++++++ .claude/skills/using-eph/SKILL.md | 84 +++++++++++++++++++++++++++++++ 2 files changed, 168 insertions(+) diff --git a/.agents/skills/using-eph/SKILL.md b/.agents/skills/using-eph/SKILL.md index 4c81e90..4167a94 100644 --- a/.agents/skills/using-eph/SKILL.md +++ b/.agents/skills/using-eph/SKILL.md @@ -110,6 +110,86 @@ DATABASE_URL=postgres://dev@localhost:${postgres.port}/app hooks run on the host via `sh -c` with eph's resolved environment injected (see below). +## Roles: dependency services vs the app + +A `.eph` file can split its services into tiers with a `role=` on each service +and a top-level `roles_order`. The usual split is dependency services (Postgres, +Redis, object storage: things the code talks to, safe to start eagerly) from the +first-party app you are building (start it on demand; it may bind preview ports +or run side effects). Roles let you bring up one tier without the other. + +```ini +roles_order=dep,app # dep services come up before the app + +[postgres] +image=postgres:16 +role=dep + +[web] +run=npm run dev +port=auto +role=app +``` + +- `roles_order=dep,app` is the linear form: each role depends on the one before + it. For a graph (a `worker` that needs `dep` but not `app`), use a section + instead, where each line is `role=dependencies` and a bare `role=` is a root: + + ```ini + [roles_order] + dep= + app=dep + worker=dep + ``` + +- Roles are all-or-nothing: once any service has a `role=`, a `roles_order` is + required, every service must declare a role listed in it, every listed role + must have a service, and the graph must be acyclic. `eph check` reports any + violation. A file with no roles at all keeps the old behavior (declaration + order, `run=` services last), so nothing needs roles. +- Bring-up follows the role graph (dependencies first); teardown reverses it. +- `eph up --role ` starts that role **and everything it depends on**, and + nothing else. Repeatable, and it unions with any positional service names. + `eph up --role dep` starts just the dependency tier. `eph down --role ` + tears down that role and everything that depends on it. + +## Prewarm dependency services at session start + +Because `eph up --role dep` starts the dependency tier without the app, it is the +natural thing to run from a Claude Code **SessionStart hook**: the databases and +caches come up known-good and their connection env is ready before your first +command, so you never hit "the service isn't running" and restart your work. A +later `eph up` or `eph dev` reuses those already-running services, and `eph dev` +leaves them up when it exits (it tears down only the app it started). + +```sh +#!/usr/bin/env bash +# .claude/hooks/eph-prewarm.sh: prewarm deps and inject their connection env. +# $CLAUDE_ENV_FILE is sourced by Claude Code, so later Bash tool calls in the +# session inherit DATABASE_URL, REDIS_URL, and the rest. +test -f .eph || exit 0 +eph up --role dep || exit 0 +[ -n "$CLAUDE_ENV_FILE" ] && eph env >> "$CLAUDE_ENV_FILE" +``` + +```json +// .claude/settings.json: run it on session start (project scope: everyone in +// the repo/worktree gets it). Use ~/.claude/settings.json for a personal one. +{ + "hooks": { + "SessionStart": [ + { "matcher": "startup|resume", + "hooks": [ { "type": "command", "command": ".claude/hooks/eph-prewarm.sh" } ] } + ] + } +} +``` + +Substitute your own dependency role name for `dep`. To also seed on prewarm, drop +the default (post-start hooks run); to prewarm bare, add `--skip-hooks`. If you +want the tier torn down when a session ends, add a `SessionEnd` hook running +`eph down --role dep`; the default is to leave it warm for the next session. + ## Lifecycle hooks see eph's environment Four hooks bracket a service, in order: `pre-start` (before it is created), @@ -209,6 +289,10 @@ default, or `eph clean` with `--clean` (each running `pre-stop` then - Teardown defaults to `eph down` (keeps data for a fast relaunch, since Claude restarts the server during a session). Use `eph dev --clean` (`runtimeArgs: ["dev", "--clean"]`) for a pristine reset on every launch. +- `eph dev` tears down only the services it actually started. Any that were + already running when it launched (a dependency tier a SessionStart hook + prewarmed) are left up, so the deps stay hot across `eph dev` runs. `--clean` + overrides this and bulldozes everything. - A hard kill (not a normal stop) skips teardown and leaves services up, recoverable with `eph down`. If the app crashes on its own, `eph dev` leaves services up for inspection (`eph logs `) and exits non-zero. diff --git a/.claude/skills/using-eph/SKILL.md b/.claude/skills/using-eph/SKILL.md index 4c81e90..4167a94 100644 --- a/.claude/skills/using-eph/SKILL.md +++ b/.claude/skills/using-eph/SKILL.md @@ -110,6 +110,86 @@ DATABASE_URL=postgres://dev@localhost:${postgres.port}/app hooks run on the host via `sh -c` with eph's resolved environment injected (see below). +## Roles: dependency services vs the app + +A `.eph` file can split its services into tiers with a `role=` on each service +and a top-level `roles_order`. The usual split is dependency services (Postgres, +Redis, object storage: things the code talks to, safe to start eagerly) from the +first-party app you are building (start it on demand; it may bind preview ports +or run side effects). Roles let you bring up one tier without the other. + +```ini +roles_order=dep,app # dep services come up before the app + +[postgres] +image=postgres:16 +role=dep + +[web] +run=npm run dev +port=auto +role=app +``` + +- `roles_order=dep,app` is the linear form: each role depends on the one before + it. For a graph (a `worker` that needs `dep` but not `app`), use a section + instead, where each line is `role=dependencies` and a bare `role=` is a root: + + ```ini + [roles_order] + dep= + app=dep + worker=dep + ``` + +- Roles are all-or-nothing: once any service has a `role=`, a `roles_order` is + required, every service must declare a role listed in it, every listed role + must have a service, and the graph must be acyclic. `eph check` reports any + violation. A file with no roles at all keeps the old behavior (declaration + order, `run=` services last), so nothing needs roles. +- Bring-up follows the role graph (dependencies first); teardown reverses it. +- `eph up --role ` starts that role **and everything it depends on**, and + nothing else. Repeatable, and it unions with any positional service names. + `eph up --role dep` starts just the dependency tier. `eph down --role ` + tears down that role and everything that depends on it. + +## Prewarm dependency services at session start + +Because `eph up --role dep` starts the dependency tier without the app, it is the +natural thing to run from a Claude Code **SessionStart hook**: the databases and +caches come up known-good and their connection env is ready before your first +command, so you never hit "the service isn't running" and restart your work. A +later `eph up` or `eph dev` reuses those already-running services, and `eph dev` +leaves them up when it exits (it tears down only the app it started). + +```sh +#!/usr/bin/env bash +# .claude/hooks/eph-prewarm.sh: prewarm deps and inject their connection env. +# $CLAUDE_ENV_FILE is sourced by Claude Code, so later Bash tool calls in the +# session inherit DATABASE_URL, REDIS_URL, and the rest. +test -f .eph || exit 0 +eph up --role dep || exit 0 +[ -n "$CLAUDE_ENV_FILE" ] && eph env >> "$CLAUDE_ENV_FILE" +``` + +```json +// .claude/settings.json: run it on session start (project scope: everyone in +// the repo/worktree gets it). Use ~/.claude/settings.json for a personal one. +{ + "hooks": { + "SessionStart": [ + { "matcher": "startup|resume", + "hooks": [ { "type": "command", "command": ".claude/hooks/eph-prewarm.sh" } ] } + ] + } +} +``` + +Substitute your own dependency role name for `dep`. To also seed on prewarm, drop +the default (post-start hooks run); to prewarm bare, add `--skip-hooks`. If you +want the tier torn down when a session ends, add a `SessionEnd` hook running +`eph down --role dep`; the default is to leave it warm for the next session. + ## Lifecycle hooks see eph's environment Four hooks bracket a service, in order: `pre-start` (before it is created), @@ -209,6 +289,10 @@ default, or `eph clean` with `--clean` (each running `pre-stop` then - Teardown defaults to `eph down` (keeps data for a fast relaunch, since Claude restarts the server during a session). Use `eph dev --clean` (`runtimeArgs: ["dev", "--clean"]`) for a pristine reset on every launch. +- `eph dev` tears down only the services it actually started. Any that were + already running when it launched (a dependency tier a SessionStart hook + prewarmed) are left up, so the deps stay hot across `eph dev` runs. `--clean` + overrides this and bulldozes everything. - A hard kill (not a normal stop) skips teardown and leaves services up, recoverable with `eph down`. If the app crashes on its own, `eph dev` leaves services up for inspection (`eph logs `) and exits non-zero.