Skip to content

feat(cli): prisma-composer dev — bring the whole app up locally, credential-free - #164

Merged
wmadden-electric merged 44 commits into
mainfrom
claude/local-dev-s5-dev-command
Jul 23, 2026
Merged

feat(cli): prisma-composer dev — bring the whole app up locally, credential-free#164
wmadden-electric merged 44 commits into
mainfrom
claude/local-dev-s5-dev-command

Conversation

@wmadden-electric

@wmadden-electric wmadden-electric commented Jul 23, 2026

Copy link
Copy Markdown
Contributor
$ prisma-composer dev module.ts        # examples/store — no PRISMA_* env set
[dev] ready:
[dev] storefront        http://localhost:3004
[dev] catalog.service   http://localhost:3000
[dev] cron.runner       http://localhost:3001
[dev] cron.scheduler    http://localhost:3002
[dev] orders.service    http://localhost:3003
[storefront] ✓ Ready in 0ms
[cron-runner] special rotated to Flat White      # cron fires for real
^C
[dev] stopping — the app's services are stopping; emulators and data stay up.
[dev] stopped.
$ prisma-composer dev module.ts        # the next start is warm: same ports, data intact

The decision: prisma-composer dev is a view over the running app, not a process manager.

Context in one breath: this is the final layer of the local-dev stack (ADR-0041, in the base branch), on top of #162. Everything underneath already works — emulator daemons own the processes (#160), and the deploy pipeline provisions against them (#162). This PR is the command a person actually types. Stacked PR: the diff here is only this layer.

What the command does

It runs the same pipeline steps as deploy — shared, extracted code, so the two cannot drift and deploy's behavior is provably unchanged — then diverges after assembly: resolve a local app identity, apply --fresh if asked (wipe this app's local instances and data), run the dev value-sourcing checks, make sure the emulator daemons are up, write the generated dev stack file, and converge. Then it attaches: prints the front door (every service's URL, shown above), streams every service's logs with a [service] prefix, and watches for rebuilds. A rebuild triggers re-assemble and re-converge, and the emulator restarts exactly the services whose deployment actually changed. Ctrl-C stops the app's services and exits; the daemons and all data persist, which is why the second run above is warm.

Watching is driven by the adapter-declared watch paths from #158. Until that merges, every bundle takes the graceful path (a printed "no watchable inputs" note); the loop itself is live and hardened by a bug found while proving it — a single-file watch dies silently when a rebuild deletes and recreates its file, so file targets watch their parent directory instead.

Two things worth a reviewer's attention. First, a documented hack: merely importing Alchemy's library code registers exit-on-signal handlers in the CLI process, and whichever fired first killed the CLI before cleanup could run — observed live as exit 130 with every service left running. The dev command strips those handlers and becomes the sole signal listener, at one pinned moment, after which no other listener legitimately exists. Recorded limitation: a second Ctrl-C won't force-quit a hung stop. Second, a real defect fixed here: rebuilding one service used to restart its dependents too, because each service's environment was assembled from an app-wide snapshot that only "completed" on the second converge. Environments are now scoped to each service's own configuration rows. That matches how the platform itself decides restarts, and it's recorded in the design docs as a deliberate, reasoned deviation.

This PR also makes the emulators publish-safe: the daemon programs ship inside the public package's own dist and are resolved against it, so a real npm install no longer references a private workspace package.

Tested

Every behavior below is a required CI gate driving the real CLI binary as a child process against examples/store:

  • Brings up all five services with no PRISMA_* variables set; HTTP round-trip through the front-door URL.
  • Rebuilding one service restarts exactly that service — asserted twice via emulator pids.
  • Data written to Postgres survives Ctrl-C and a warm restart on the same ports; --fresh removes it.
  • An object PUT through the app appears as a plain file under .prisma-composer/dev/buckets/; a file dropped there reads back through the app.
  • An unset secret prints the pinned placeholder warning and the app still serves; an unset environment-sourced param exits nonzero naming it.

Plus unit suites for the watch debounce, front-door ordering, and the generated stack file's exact source. Manual terminal session transcribed above. Workspace gates green; process-leak audit clean.

Alternatives considered

  • The CLI supervising child processes itself — ties every running app to an open terminal and puts process semantics into framework core; rejected at design time.
  • Reusing the deploy report hook for the front door — it runs inside the short-lived converge process; the front door belongs to the long-running view and reprints after every re-converge.
  • Keeping the app-wide environment snapshot — structurally restart-amplifies; the platform itself diffs deployments only on their own configuration, so scoping matches the behavior that matters.

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@wmadden-electric, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 35 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 5586cebf-6315-4f98-803a-333bbdab9043

📥 Commits

Reviewing files that changed from the base of the PR and between 93267e5 and 13e7de7.

📒 Files selected for processing (4)
  • packages/0-framework/3-tooling/cli/src/dev/run-dev.ts
  • packages/0-framework/3-tooling/cli/src/dev/watch.ts
  • packages/1-prisma-cloud/0-lowering/dev-emulators/src/postgres-main.ts
  • test/integration/test/local-dev-criteria-4-5.integration.ts

Walkthrough

The CLI now supports a dev command with shared pipeline loading, generated orchestration stacks, local convergence, endpoint reporting, log streaming, filesystem watching, reconvergence, and controlled shutdown. Local targets and emulators now coordinate service startup, scoped environments, slugged identifiers, Postgres recovery, and concurrent database ensures. Integration fixtures and proving scripts cover secrets, parameters, S3-backed blobs, rebuilds, warm restarts, fresh cleanup, and diagnostics. CI warms the Prisma dev engine cache before tests.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.56% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the new CLI dev command and its credential-free local app startup behavior.
Description check ✅ Passed The description directly matches the changeset, describing the new dev command, its runtime behavior, and the warm restart flow.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/local-dev-s5-dev-command
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch claude/local-dev-s5-dev-command

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@pkg-pr-new

pkg-pr-new Bot commented Jul 23, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@prisma/composer@164
npm i https://pkg.pr.new/@prisma/composer-prisma-cloud@164

commit: 13e7de7

@wmadden-electric wmadden-electric changed the title feat(cli): prisma-composer dev — the local dev command feat(cli): prisma-composer dev — bring the whole app up locally, credential-free Jul 23, 2026
wmadden-electric added a commit that referenced this pull request Jul 23, 2026
Run 30003079171 (#164's Test job) died undiagnosable: the teed
converge log the wrapping error names is on the dead runner, so its
content never reached anyone. All three proving scripts now dump
diagnostics to stderr on ANY failure, before exiting — every converge/
session log's tail (bounded ~60 lines, never a full dump), both
emulator daemons' log tails, and `prisma dev ls` — so the NEXT failure
is readable straight from the GitHub Actions log, no runner access
needed.

Root-cause reasoning (confirmed vs hypothesis):

CONFIRMED, by local reproduction: a fresh (never-used) `prisma dev`
state produces a FAST, clean, non-timeout provisioning failure when it
can't claim a port — reproduced locally by forcing a genuinely empty
`HOME` (a fresh emulator registry AND a fresh prisma-dev config/cache
in one move) via a small wrapper process, since the sandbox blocks a
bare `HOME=... bun run ...` invocation directly. This reproduction's
SPECIFIC failure ("Port 51215 is not available") was itself a
machine-local artifact — my own workstation's pre-existing `open-chat`
prisma-dev instance squats that exact port range, which a genuinely
clean CI runner would not have — so it does not by itself prove CI's
exact port collision. What it DOES confirm is the general shape: a
`prisma dev` provisioning failure surfaces exactly this way — fast,
clean, embedded three provider frames deep inside the alchemy
converge — matching CI's `timedOut=false` signature precisely, and
CONFIRMS the failure text (the actual provider error, e.g. "Port ...
not available" or a download error) was ALREADY being captured
correctly into the converge log the whole time; the only reason CI
"learned nothing" is that nothing printed that log's content to
stdout/stderr before the runner died — exactly what this commit fixes.

HYPOTHESIS, not locally confirmed (my dev workstation has 85MB of
`~/.cache/prisma` accumulated over many sessions; nothing in this repo
or CI currently warms it, and I could not force a truly clean
"never-downloaded-anything" state without also colliding with my own
machine's other real instances): `prisma dev`'s FIRST-EVER invocation
on a given machine downloads its database engine binaries; a CI runner
starts genuinely cold every time, unlike a dev workstation. Addressed
regardless of whether this is the exact CI trigger — it can only help,
never weaken anything: CI now runs `prisma dev --name ci-engine-warmup
--detach` (then removes it) as its own isolated step, between building
and testing. If a cold download is (part of) the cause, this either
fixes it outright or turns it into its own clearly-attributable,
early-failing step instead of something buried inside a 5-service
`alchemy deploy`.

Ruled out (checked directly, not just assumed): the fixture app's own
deps/build — `pnpm build` (turbo, every package) already runs before
`pnpm test` in the Test job, and the S4 fixture's "built output" is
hand-written source checked into git, not generated, so no missing
build step there; `@aws-sdk/client-s3` resolution — committed to
pnpm-lock.yaml, `pnpm install --frozen-lockfile` verified locally
against the committed lockfile; turbo's env passthrough — no
`envMode`/`strict` restriction in turbo.json, so `HOME`/`PATH` reach
the test process unrestricted; the `prisma` bin's own resolution walk
(root `node_modules/.bin/prisma`, not a per-package one) — verified
locally via `pnpm exec prisma`, which is what the new CI step uses
rather than assuming a path that turned out not to exist per-package.

Not confirmed as the actual CI cause — this is what the diagnostics
land to determine on the next run: whether the real trigger is the
cold engine download, a genuine port collision unique to the runner,
or something this reasoning didn't anticipate.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
wmadden-electric added a commit that referenced this pull request Jul 23, 2026
…t for free-port checks

Dependency razor (spec § 2, design commit 904d23a): commodity
infrastructure with latent edge cases uses maintained libraries. Our
own hand-rolled versions of both already produced real cross-platform
bugs this project hit directly — a BSD/GNU pgrep divergence (round 5)
and a Linux-only port-probe self-collision (the errno-driven fix two
commits back on this branch). Retires the earlier no-new-deps
contract for exactly this class of code.

daemon.ts: the concurrent-ensure protocol's mkdir+pid-file lock is
replaced by proper-lockfile locking a stable per-daemon target file
under registryRoot. Its own staleness/compromise semantics are adopted
wholesale — no pid liveness checking on top. Our wrapper keeps only
what spec § 2 pins: a ~10s bounded wait (proper-lockfile's retries
option), the pinned timeout error on ELOCKED exhaustion, re-reading the
registry after acquiring, and releasing in a finally on every path.
Port allocation (smallestUnused) now calls get-port for the actual
bind check; persistence and the >= 4300 range/exclusion policy stay
ours.

ensureDaemon's signature gains an explicit entry parameter —
ensureDaemon(name, entry, opts) — instead of resolving
@internal/dev-emulators/<name>-main internally. This mirrors what the
S5 branch (bot/claude/local-dev-s5-dev-command) already needs for its
own reason (a published dist can't import.meta.resolve a private
workspace package), threading it now so that cascade needs no manual
reconciliation inside the retry loop.

Test helpers: isPortFree/findFreePort's hand-rolled bind probing is
deleted; every scratch-port need in the test suite now calls get-port
directly. The two lock-behavior tests in daemon.test.ts are rewritten
to proper-lockfile's actual on-disk mechanism (an aged .lock directory
for staleness; a real second process genuinely holding the same lock
via proper-lockfile itself for the live-holder timeout) rather than
asserting against the old hand-rolled pid-file format — their
assertions (one daemon exists, a stale lock is broken, a live holder
times out with the pinned message) are unchanged.

New dependencies of @internal/dev-emulators (private, workspace-only):
proper-lockfile, get-port, @types/proper-lockfile (dev). tsdown's
skipNodeModulesBundle keeps both external in dist/*.mjs, confirmed by
inspection of the built output — this package gains no new *transitive*
runtime deps for its current (private, in-repo) consumers. Packaging
note for S5: when the public package re-emits these daemon entry
points for npm consumers (spec § 2's publish note), that public
package will need proper-lockfile and get-port as its OWN dependencies
too, since they are not inlined — flagging now, no action taken here
since no public consumer exists yet on this branch.

CI read for the next merge (not fixed here — the cascade replaces
S5's daemon.ts with this one): diffed this branch's post-swap
daemon.ts against bot/claude/local-dev-s5-dev-command's daemon.ts.
The retry loop (exit-detection via awaitHealthy, port-exclusion via
usedPorts, error classification via exitedBeforeHealthy) is otherwise
identical between the two branches; the only structural difference is
the entry-parameter threading this commit adopts. The Linux-only CI
failure on #164 (run 30007836065, "a bind failure on a fresh
allocation retries the next free port", 529ms fail, "Expected length:
1, Received length: 0" on the listening-line count) is therefore not
explained by any logic divergence in the retry path itself between
the two branches.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@wmadden-electric
wmadden-electric force-pushed the claude/local-dev-s4-dev-target branch 3 times, most recently from 954ebec to 57e7374 Compare July 23, 2026 15:15
wmadden-electric added a commit that referenced this pull request Jul 23, 2026
Run 30003079171 (#164's Test job) died undiagnosable: the teed
converge log the wrapping error names is on the dead runner, so its
content never reached anyone. All three proving scripts now dump
diagnostics to stderr on ANY failure, before exiting — every converge/
session log's tail (bounded ~60 lines, never a full dump), both
emulator daemons' log tails, and `prisma dev ls` — so the NEXT failure
is readable straight from the GitHub Actions log, no runner access
needed.

Root-cause reasoning (confirmed vs hypothesis):

CONFIRMED, by local reproduction: a fresh (never-used) `prisma dev`
state produces a FAST, clean, non-timeout provisioning failure when it
can't claim a port — reproduced locally by forcing a genuinely empty
`HOME` (a fresh emulator registry AND a fresh prisma-dev config/cache
in one move) via a small wrapper process, since the sandbox blocks a
bare `HOME=... bun run ...` invocation directly. This reproduction's
SPECIFIC failure ("Port 51215 is not available") was itself a
machine-local artifact — my own workstation's pre-existing `open-chat`
prisma-dev instance squats that exact port range, which a genuinely
clean CI runner would not have — so it does not by itself prove CI's
exact port collision. What it DOES confirm is the general shape: a
`prisma dev` provisioning failure surfaces exactly this way — fast,
clean, embedded three provider frames deep inside the alchemy
converge — matching CI's `timedOut=false` signature precisely, and
CONFIRMS the failure text (the actual provider error, e.g. "Port ...
not available" or a download error) was ALREADY being captured
correctly into the converge log the whole time; the only reason CI
"learned nothing" is that nothing printed that log's content to
stdout/stderr before the runner died — exactly what this commit fixes.

HYPOTHESIS, not locally confirmed (my dev workstation has 85MB of
`~/.cache/prisma` accumulated over many sessions; nothing in this repo
or CI currently warms it, and I could not force a truly clean
"never-downloaded-anything" state without also colliding with my own
machine's other real instances): `prisma dev`'s FIRST-EVER invocation
on a given machine downloads its database engine binaries; a CI runner
starts genuinely cold every time, unlike a dev workstation. Addressed
regardless of whether this is the exact CI trigger — it can only help,
never weaken anything: CI now runs `prisma dev --name ci-engine-warmup
--detach` (then removes it) as its own isolated step, between building
and testing. If a cold download is (part of) the cause, this either
fixes it outright or turns it into its own clearly-attributable,
early-failing step instead of something buried inside a 5-service
`alchemy deploy`.

Ruled out (checked directly, not just assumed): the fixture app's own
deps/build — `pnpm build` (turbo, every package) already runs before
`pnpm test` in the Test job, and the S4 fixture's "built output" is
hand-written source checked into git, not generated, so no missing
build step there; `@aws-sdk/client-s3` resolution — committed to
pnpm-lock.yaml, `pnpm install --frozen-lockfile` verified locally
against the committed lockfile; turbo's env passthrough — no
`envMode`/`strict` restriction in turbo.json, so `HOME`/`PATH` reach
the test process unrestricted; the `prisma` bin's own resolution walk
(root `node_modules/.bin/prisma`, not a per-package one) — verified
locally via `pnpm exec prisma`, which is what the new CI step uses
rather than assuming a path that turned out not to exist per-package.

Not confirmed as the actual CI cause — this is what the diagnostics
land to determine on the next run: whether the real trigger is the
cold engine download, a genuine port collision unique to the runner,
or something this reasoning didn't anticipate.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@wmadden-electric
wmadden-electric force-pushed the claude/local-dev-s5-dev-command branch from 1d46fde to bed2cf7 Compare July 23, 2026 15:53
wmadden-electric and others added 20 commits July 23, 2026 17:55
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… command

pipeline.ts pulls steps 1-6 of run() (config discovery/load, entry load,
Load, coverage validation, name resolution, assemble) into one function
consumed by both deploy/destroy and the new dev command, so the two
pipelines cannot drift (local-dev spec S6). run() is refactored to call
it; deploy/destroy behavior is unchanged (run.test.ts passes unmodified).

DevCommand adds `dev <entry>` with --name/--fresh only; --stage and
--production are rejected as unknown flags. ParsedArgs.command widens to
deploy | destroy | dev.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
.prisma-composer/dev/alchemy.run.ts, mirroring generate-stack.ts:
dev: true, localState() from alchemy/State/LocalState, no report (dev
prints its own front door), header comment naming the --stage dev
reproduction command.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
watchTargetsFrom() reads each bundle's watch paths (a bundle without
any is reported unwatchable, the pinned startup note); startWatch()
fs.watches them (recursive for directories, plain for files), debounced
300ms and coalesced across every service into one callback per burst.

Bundle.watch does not exist on this branch yet (spec Section 3, the S2
slice — see .drive/projects/local-dev/spec.md Open Questions for the
branch-dependency gap this records). watchTargetsFrom() reads it through
a locally-declared structural extension with one justified blindCast, so
this is fully wired the moment S2 lands; every bundle takes the pinned
"not watched" fallback until then.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… loop

Local-dev spec Section 6's run-dev.ts: reuses pipeline.ts for the shared
prefix, then the dev-only steps in order — Windows guard, dev-capability
check (every non-build-only extension must declare `dev`), per-extension
containers, `--fresh` teardown, preflight, emulators, writes the dev
stack file and converges via runAlchemy with --stage dev (a nonzero exit
prints the reproduction hint and returns that status without attaching),
attach (merged endpoints rendered as the pinned front door, logs pumped
prefixed `[<service>] `), and the watch loop — debounced rebuild
re-assembles all services, rewrites the stack file, re-converges; a
converge failure during watch leaves the running app untouched and keeps
watching, a success re-prints the front door. SIGINT/SIGTERM stop every
attachment's services and exit 0 — emulators and their data stay up.

main.ts's run() dispatches to runDev() as soon as args.command is 'dev',
before any deploy/destroy-only step runs.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ve the Bundle.watch gap note

Takes .drive/projects/local-dev/spec.md wholesale from
bot/claude/local-dev-feature-design-0b49fd (4ce7160) — picks up the
fresh-allocation port-retry protocol for daemon.ts (S3 scope, not
touched here). Rewrites the S5 Open Questions entry from an open gap to
a resolved note: the Bundle.watch structural-type fallback already
shipped in watch.ts covers it, and stays documented in code.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…sses

Two real defects surfaced while building the proving pass against
examples/store (a real multi-module app, unlike the flat-address S4
fixture) — both fixed here, verified live against examples/store with
Ctrl-C observed end to end.

1. run-dev.ts's SIGINT/SIGTERM handling never ran. alchemy's own library
   code (pulled in transitively while loading the app's config/providers,
   e.g. `alchemy/Provider` from the local dev providers) registers its
   own process-level SIGINT/SIGTERM listeners for its own in-process
   bookkeeping. Those listeners run before this module's (registered
   later, in the watch loop), and whichever of them fires first can call
   process.exit() synchronously, tearing the process down before the
   watch loop's own async cleanup (stopping the app's services through
   the attachment) gets a turn — confirmed live: the CLI process died on
   SIGINT with exit code 130 (killed by the raw signal, default
   disposition) and every service child was left running. Those listeners
   are irrelevant here regardless: the actual converge runs in a
   separately spawned `alchemy` child process (run-alchemy.ts), never in
   this one. Fixed by stripping whatever is registered and becoming the
   sole SIGINT/SIGTERM listener right before entering the watch loop.
   Also adds the "[dev] stopping…"/"[dev] stopped." lines the spec left
   unpinned but a session needs to observe.

2. LocalComputeServiceProvider/LocalDeploymentProvider
   (@internal/lowering/src/dev/compute.ts) passed a service's own
   hierarchical address (e.g. "orders.service", a nested module's
   service) straight through as the Compute emulator's `<id>` path
   segment, which the emulator's own API hygiene rule rejects
   (`/^[a-z0-9][a-z0-9-]*$/` — no dots). Every non-flat-address app (any
   module nesting a service under an `id`) failed to converge in dev at
   all. Fixed with a `slugServiceId()` helper applied only at the two
   emulator call sites; the real address still rides the deployment
   body's `address` field untouched, so `attach.ts`'s front door and
   `listServices()` still report it verbatim — attach.ts's log follower
   is updated alongside to label lines with that real address (not the
   slugged id) so a service's log prefix matches its front-door line.

Both are outside the narrowly sanctioned S5 change to S1-S4 files, but
block the coordinator-directed proving pass entirely (examples/store
cannot converge or shut down without them) and are minimal, targeted,
and verified against the live app — flagged prominently in the S5 report
rather than landed silently.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…real CLI

The plan-S5 proving pass the previous session ran out of budget for.
Unlike every other integration script in this package, this one drives
the real, published `examples/store/node_modules/.bin/prisma-composer`
binary as a genuine child process — argv parsing, the CLI's own SIGINT
handling, and its own process lifetime are themselves what's under test,
against a real four-service, two-Postgres, cron-bearing app.

Proves, against three real `dev` sessions:
- Criterion 1: credential-free bring-up (PRISMA_WORKSPACE_ID/
  PRISMA_SERVICE_TOKEN/PRISMA_REGION stripped from the child's env), the
  `[dev] ready:` front door parsed from real stdout, an HTTP round-trip.
- Criterion 3: a row written directly against the real local Postgres URL
  (read from postgres.json — the storage layer, never the app's own RPC)
  survives a Ctrl-C + warm restart, and is gone after `--fresh` (the
  fresh instance re-migrates as part of the very same converge, so the
  proof is the proving row's absence, not the table's).
- Criterion 6: the second (warm) session's front-door ports match the
  first's exactly.

Two real bugs surfaced and fixed while building this (see the prior
commit): run-dev.ts's SIGINT handling never actually ran, and nested
service addresses ("orders.service") failed the Compute emulator's own
segment rule. Without both fixes, examples/store cannot converge or
shut down at all, so this script could not have existed against real
store; it now runs clean, 0 failures, three consecutive dev sessions.

Wired into the package's own `test` script as a second `bun run` step,
matching local-dev.integration.ts's own pattern; `@prisma/example-store`
added as a devDependency so turbo builds it (and its four modules)
before this runs.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Found on the scratch/s5-watch-proof branch (S2 merged in temporarily to
exercise Bundle.watch for real against examples/store — see the S5
report for the full transcript): `fs.watch(filePath)` bound to a single
file's inode goes silently dead after that file is deleted and
recreated at the same path — exactly what `rm -rf dist && bun build
--outfile dist/server.mjs` does on every rebuild (also confirmed with
plain `bun build --outfile` alone, no `rm -rf` — bun's own writer
replaces the file via an atomic rename, which is the same class of
inode change). The watch fired once, on the very first rebuild, then
never again.

Fixed by watching the file's parent directory (non-recursive) and
filtering events to the file's own basename — a directory watch's
handle isn't tied to any one child's inode, so it survives however the
build tool replaces the file. Directory targets are unaffected (already
watched recursively at the target path itself).

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Local-dev spec § 4's rewritten step 3 (pinned, design tip b2cb63e) —
the fix for restart amplification: the hosted platform materializes the
app-wide env row set into every deployment but DIFFS a deployment only
on its own referenced rows. Local dev's materializeEnv was copying the
FULL env.json into every service's deployment unscoped, so an
early-deployed service's snapshot looks incomplete on the first
converge and "completes" (and diffs as changed) on the very next one —
restart amplification.

scopedEnvRows() fixes it: a service keeps only its own
`COMPOSER_<its address>_*` rows plus every row OUTSIDE the `COMPOSER_`
namespace entirely (the poison DATABASE_URL/DATABASE_URL_POOLED rows
are deliberately unprefixed and app-wide, per the pinned parity note —
kept for every service, matching the platform). The dropped sibling
rows have no sanctioned reader: run()/load() consume only own-address
rows.

Note on provenance: the coordinator's cascade instructions named two
commits on `claude/local-dev-s5-fix-restart-amplification`
(`a745e07` fix, `2dcbb32` test) to cherry-pick. That branch is not
present on any remote reachable from this worktree (`git ls-remote bot`
lists s1 through s5-dev-command only) — fetching it by name or by
commit sha both fail. Reimplemented directly from the pinned spec text
instead, verified against it line by line, and added new coverage
(dev-compute-scoped-env.test.ts) rather than porting the referenced
test commit sight-unseen. Re-ran the S4 integration proof
(test/local-dev.integration.ts) against the real converge — still
passes, including its DATABASE_URL/DATABASE_URL_POOLED poison-row
assertions (both stay app-wide, per the pinned note).

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
)

Takes both files verbatim from bot/claude/local-dev-feature-design-0b49fd
so the diff against it is empty on this branch — picks up the pinned
restart-amplification fix text (spec Section 4) implemented in the
prior commit, and the fresh-allocation port-retry protocol (spec
Section 2, S3 scope, already on this branch via the S3 rebase).

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Extends the S4 fixture (test/fixtures/local-dev/):
- web-service's built server gains a real @aws-sdk/client-s3-backed
  /blobs/:key PUT/GET, reusing its already-bound bucket() dependency
  (the same client examples/bucket's blob app uses).
- bg-service gains a secret slot (apiKey) and an envParam (greeting),
  bound in module.ts to LOCALDEV_FIXTURE_API_KEY /
  LOCALDEV_FIXTURE_GREETING; its built server exposes GET /status
  reporting whether the secret looks like a minted placeholder and
  echoing the bound greeting.

New local-dev-criteria-4-5.integration.ts drives the real CLI against
this fixture (same pattern as local-dev-store.integration.ts): PUTs an
object through the app and confirms it lands as a plain file under
.prisma-composer/dev/buckets/files/, then drops a file directly on disk
and reads it back through the app (criterion 4); one session with the
secret unset confirms the pinned placeholder warning appears and the
topology still serves, a second with the envParam unset confirms dev
exits nonzero with the pinned listing error naming it (criterion 5).
Wired into the package's test script as a third step.

local-dev.integration.ts (S4's own direct-driving script) needed two
fixes to keep passing against the extended fixture:
- It sets both env vars before calling dev.preflight() so its own
  (unrelated) proofs are unaffected by the new required slots, and
  updates the "secrets.json is empty" assertion to the shell-sourced
  values it now sets.
- Found live: dev.preflight() computes its state directory from
  process.cwd() rather than accepting one, unlike every other dev hook
  this script calls (all handed devDir explicitly, fixture-relative).
  This script runs from the package root, not the fixture dir, so
  preflight's write previously landed nowhere this script ever looked —
  invisible only because preflight was a total no-op before this
  fixture had any secrets/params to iterate over. Fixed by temporarily
  chdir'ing to the fixture around the preflight call (restored in
  finally); preflight.ts itself is untouched.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…are in the proving script

Two real, reproducible flakes surfaced running the store proving script
repeatedly against a 5-service app (examples/store) rather than the
S4 fixtures 2-service one:

1. run-dev.ts: attach()s endpoints() fetch, called immediately after a
   converge that just PUT dozens of resources through the same
   loopback connection, occasionally hit a transient refused/reset
   connection on a real, busier app — confirmed live (converge
   succeeded, "Done: 102 succeeded", then "Error: fetch failed" tore
   the whole dev session down). Retried up to 3 times with a 500ms
   delay before giving up; a genuine failure still surfaces after the
   retries exhaust.

2. local-dev-store.integration.ts: opening a fresh Bun.SQL client per
   call against the same URL (this scripts own pattern, not app code)
   could race the previous connections prepared-statement cleanup,
   surfacing as `prepared statement "..." already exists`. This is a
   storage-layer proof, not a prepared-statement-caching one, so
   `prepare: false` turns caching off.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The design branch advanced again during this session (strikes the
dir() adapter entirely - node()s existing directory form already
covers the need, per the operators catch). Re-synced wholesale so the
diff against bot/claude/local-dev-feature-design-0b49fd stays empty;
does not touch any S5 code (dir() was S2s scope, never referenced from
this branch).

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…EnvRows

Equivalence check against bot/claude/local-dev-s5-fix-restart-amplification
(a745e07, now reachable): its scopedEnv() and this branchs
scopedEnvRows() are algorithmically identical — same
COMPOSER_<SEGMENTS>_ prefix construction (trailing underscore
included), same startsWith(prefix) || !startsWith(COMPOSER_) filter,
same untouched port-override/secrets/PATH/HOME handling below it. No
behavioral divergence found. Adds the one case the coordinator asked
to verify explicitly: web vs web2 is a literal string prefix but the
trailing underscore on the match prefix (COMPOSER_WEB_) correctly
excludes COMPOSER_WEB2_PORT — both implementations already get this
right; this pins it against future regression.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e service)

Regression proof for the restart-amplification fix in compute.ts:
touch ONLY catalog's built artifact, re-converge, assert exactly
catalog.service gets a new pid and all four other services' pids are
unchanged. Run twice against the same live session.

Not driven through the CLI's own file-watch loop: Bundle doesn't
declare `watch` on this branch yet (spec S2 slice), so every service
is currently unwatchable and a file edit is never detected. Instead:
session 1's real `prisma-composer dev` still writes the dev stack file
and sets up containers/emulators exactly as a real session would; the
artifact is touched, the node build adapter's own assemble() is
re-run for catalog specifically (it copies the built runnable into
.prisma-composer/artifacts/catalog.service/bundle/ — touching the
source alone doesn't move anything alchemy actually reads), then the
same stack file is re-converged directly with the real alchemy binary.
Session 1's services are never stopped in between — SIGINT is
app-scoped and unconditionally restarts every service on the next
redeploy (dev-emulators' own pinned "a stopped service always starts
on redeploy" behavior), which would defeat this proof entirely.

Also fixed a real cleanup gap found while validating this: the
script's final --fresh-then-SIGINT teardown left the prisma dev
postgres instances that cleanup session's own (mandatory) converge had
just recreated, since --fresh only removes what existed BEFORE it
runs, never what it creates during its own converge. Call
dev.teardown directly afterward to actually remove them.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Run 30003079171 (#164's Test job) died undiagnosable: the teed
converge log the wrapping error names is on the dead runner, so its
content never reached anyone. All three proving scripts now dump
diagnostics to stderr on ANY failure, before exiting — every converge/
session log's tail (bounded ~60 lines, never a full dump), both
emulator daemons' log tails, and `prisma dev ls` — so the NEXT failure
is readable straight from the GitHub Actions log, no runner access
needed.

Root-cause reasoning (confirmed vs hypothesis):

CONFIRMED, by local reproduction: a fresh (never-used) `prisma dev`
state produces a FAST, clean, non-timeout provisioning failure when it
can't claim a port — reproduced locally by forcing a genuinely empty
`HOME` (a fresh emulator registry AND a fresh prisma-dev config/cache
in one move) via a small wrapper process, since the sandbox blocks a
bare `HOME=... bun run ...` invocation directly. This reproduction's
SPECIFIC failure ("Port 51215 is not available") was itself a
machine-local artifact — my own workstation's pre-existing `open-chat`
prisma-dev instance squats that exact port range, which a genuinely
clean CI runner would not have — so it does not by itself prove CI's
exact port collision. What it DOES confirm is the general shape: a
`prisma dev` provisioning failure surfaces exactly this way — fast,
clean, embedded three provider frames deep inside the alchemy
converge — matching CI's `timedOut=false` signature precisely, and
CONFIRMS the failure text (the actual provider error, e.g. "Port ...
not available" or a download error) was ALREADY being captured
correctly into the converge log the whole time; the only reason CI
"learned nothing" is that nothing printed that log's content to
stdout/stderr before the runner died — exactly what this commit fixes.

HYPOTHESIS, not locally confirmed (my dev workstation has 85MB of
`~/.cache/prisma` accumulated over many sessions; nothing in this repo
or CI currently warms it, and I could not force a truly clean
"never-downloaded-anything" state without also colliding with my own
machine's other real instances): `prisma dev`'s FIRST-EVER invocation
on a given machine downloads its database engine binaries; a CI runner
starts genuinely cold every time, unlike a dev workstation. Addressed
regardless of whether this is the exact CI trigger — it can only help,
never weaken anything: CI now runs `prisma dev --name ci-engine-warmup
--detach` (then removes it) as its own isolated step, between building
and testing. If a cold download is (part of) the cause, this either
fixes it outright or turns it into its own clearly-attributable,
early-failing step instead of something buried inside a 5-service
`alchemy deploy`.

Ruled out (checked directly, not just assumed): the fixture app's own
deps/build — `pnpm build` (turbo, every package) already runs before
`pnpm test` in the Test job, and the S4 fixture's "built output" is
hand-written source checked into git, not generated, so no missing
build step there; `@aws-sdk/client-s3` resolution — committed to
pnpm-lock.yaml, `pnpm install --frozen-lockfile` verified locally
against the committed lockfile; turbo's env passthrough — no
`envMode`/`strict` restriction in turbo.json, so `HOME`/`PATH` reach
the test process unrestricted; the `prisma` bin's own resolution walk
(root `node_modules/.bin/prisma`, not a per-package one) — verified
locally via `pnpm exec prisma`, which is what the new CI step uses
rather than assuming a path that turned out not to exist per-package.

Not confirmed as the actual CI cause — this is what the diagnostics
land to determine on the next run: whether the real trigger is the
cold engine download, a genuine port collision unique to the runner,
or something this reasoning didn't anticipate.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Operator-approved exception to the no-new-runtime-deps contract
(design tip 74272d8, spec S6 + behavior-contracts): chokidar absorbs
the atomic-rename/inode-swap class watch.ts used to hand-fix with a
parent-directory workaround (found live proving criterion 2 against
examples/store), plus the cross-platform recursive-watch differences.
v4 is pure JS, no native code; its glob support is irrelevant here —
every target is a literal file or directory path from Bundle.watch,
never a pattern.

Deletes the parent-directory workaround entirely; the debounce stays
on our side (chokidars own awaitWriteFinish is a different semantic —
per-file "has this files size stopped changing," not "coalesce a burst
across many files into one callback"). Reads Bundle.watch directly, no
blindCast — this branch now has the real field via the S2 merge in the
next commit; the design doc comment explaining the temporary structural
cast is gone with it.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…lete+recreate test

chokidar's watch() returns synchronously but finishes attaching OS-level
watches shortly after. The delete+recreate regression test wrote to the
file immediately after startWatch(), racing that setup and missing the
first change. Added a 100ms grace wait before the first write; the
assertions themselves (calls===1, then calls===2) are unchanged.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
wmadden-electric and others added 2 commits July 23, 2026 17:55
…broke ensure

A nested module Database resource name is dot-separated
("catalog.database"), but the postgres-main daemon path-segment rule
forbids dots — the client threw invalid path segment before the request
left the process (observed live against examples/store). Same seam as
compute.ts slugServiceId: the daemon-facing id is the slug; slug() is
idempotent, so the daemon-side instanceNameFor(app, slug(name)) equals
the recorded instanceNameFor(app, name) and Connection lookup is
unaffected. Proven end to end against the real daemon.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Follow-up to the base rename (95e4229): run-dev.ts imports
resolveLocalTargets/LocalTargetDescriptor/LocalTargetAttachment from
@internal/core/local-target; the generated dev stack template imports
localTargetProviders/resolveLocalTargets from
@prisma/composer/local-target (rendering test updated); the store proving
script resolves descriptor.localTarget; the dotted-id drift test uses
LocalTargetProvidersInput. spec.md/plan.md synced wholesale from design
tip 12956d9.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@wmadden-electric
wmadden-electric force-pushed the claude/local-dev-s5-dev-command branch from bed2cf7 to 8d0f135 Compare July 23, 2026 16:00
Base automatically changed from claude/local-dev-s4-dev-target to main July 23, 2026 17:26
wmadden-electric and others added 4 commits July 23, 2026 19:26
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CodeQL (js/clear-text-logging): the top-level catch prints the raw error,
and a converge failure can embed a live connection string; apply the
spec's masking contract before the text reaches CI's public log.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…efused

Seen on CI: @prisma/dev refused a start because an aux listener port
(51301) 'belongs to another Prisma Dev server' — its registry claims
ports no bind probe sees, and the previous retry only recognized a
conflict on the database port itself, so the refusal surfaced as a 500.
All three of @prisma/dev's port-refusal errors carry the offending port;
treat a conflict on any of the four requested ports as retryable and
exclude conflicted ports from re-allocation.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread test/integration/test/local-dev.integration.ts Fixed
…rrors

CodeQL (js/clear-text-logging): the secrets.json read could ride an
assertEqual failure message into the harness's failure dump; compare as
a boolean so the value never enters the error text.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
wmadden-electric and others added 2 commits July 23, 2026 20:04
…ame registered

Seen on CI directly after the port-refusal retry landed: attempt 2 was
refused with 'A Prisma Dev server with the name ... is already running'
— a start that fails mid-boot can leave its state entry (and sometimes
the server itself) behind, and the name is namespaced to this
app+database, so the ghost is ours by construction. Recovery: adopt the
live server when the refusal carries one (ServerAlreadyRunningError's
.server getter, duck-typed), clear a dead state entry via the internal
state module's deleteServer and retry. Concurrent PUTs for the same
database now also coalesce onto one in-flight start.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tach time

Seen on CI: the delete-recreate watch test saw zero callbacks — the
change landed before chokidar's OS-level watches attached, so it was
missed entirely, and the test's fixed 100ms grace (plus a 100ms
post-debounce margin) is exactly the kind of guess a loaded runner
breaks. startWatch now returns { ready, stop }: ready resolves on
chokidar's 'ready' (and on stop, so an awaiting caller can't hang), the
dev loop awaits it before handing over — closing the same real startup
race for users — and the tests await readiness and poll to a deadline
instead of sleeping fixed margins.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/1-prisma-cloud/0-lowering/dev-emulators/src/postgres-main.ts (1)

449-523: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Stale-name cleanup retry is unreachable for existing (warm-restart) records.

maxAttempts is 1 whenever existingRecord is defined (a warm restart), and the stale-name recovery branch only fires if (attempt < maxAttempts) — so for an existing record, attempt(1) < maxAttempts(1) is always false. If the daemon restarts and finds on-disk @prisma/dev state for an instance with no live server to adopt (ServerStateAlreadyExistsError, no .server ghost), it falls straight through to the generic throw instead of ever calling deleteServer + retrying — exactly the warm-restart self-healing this PR's own comment (481-486) describes. The port-retry budget (MAX_FRESH_PORT_CANDIDATES vs. 1) is a different concern (existing records must keep their fixed port) and shouldn't also gate the orthogonal name-cleanup retry.

🔧 Proposed fix: separate the name-retry budget from the port-retry budget
-    const conflicted = new Set<number>();
-    for (let attempt = 1; ; attempt++) {
+    const conflicted = new Set<number>();
+    let nameRetriesLeft = 1; // one stale-name cleanup retry, regardless of fresh vs. existing
+    for (let attempt = 1; ; attempt++) {
       const aux = await allocateAuxPorts(new Set([dbPort, ...conflicted]));
       try {
         ...
       } catch (err) {
         if (isNameAlreadyTaken(err)) {
           const ghost = await adoptableServerOf(err);
           if (ghost !== undefined) {
             ...
             return { url };
           }
-          if (attempt < maxAttempts) {
+          if (nameRetriesLeft > 0) {
+            nameRetriesLeft--;
             const internalState = await importPrismaDevInternalState(prismaDevModulePath);
             await internalState.deleteServer(instanceName);
             continue;
           }
         }
         ...
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/1-prisma-cloud/0-lowering/dev-emulators/src/postgres-main.ts` around
lines 449 - 523, Separate stale-name cleanup retries from the port candidate
budget in the startup loop around startPrismaDevServer. Keep existing records
fixed to their stored database port, but allow one deleteServer(instanceName)
cleanup and retry when isNameAlreadyTaken(err) has no adoptable ghost, including
warm restarts; track this independently from maxAttempts so the cleanup path is
reachable for existing records while preserving the existing fresh-port retry
behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/0-framework/3-tooling/cli/src/dev/run-dev.ts`:
- Around line 267-299: Move the SIGINT/SIGTERM listener replacement to the
beginning of runDev(), before any startup pipeline work, while preserving
removal of Alchemy's inherited listeners. Refactor finish() to safely handle
partial startup state, conditionally stopping watch, aborting logs, and stopping
any attachments that have been created, then resolve cleanup without assuming
await watch.ready or full service startup completed.
- Around line 87-101: Update pumpLogs so its catch block suppresses errors only
when signal.aborted is true; otherwise report the log-stream failure with
appropriate error details. Preserve the current behavior of stopping quietly
when abortion interrupts attachment.logs(signal).
- Around line 231-262: Wrap the entire fire-and-forget rebuild body in the watch
callback, including writeDevStackFile, the alchemy deployment, and
printFrontDoor, with a try/catch so exceptions are handled instead of becoming
unhandled promise rejections. Preserve the existing runPipeline failure handling
and ensure caught errors are logged while the watcher continues running.
- Around line 205-218: Update the attachment startup loop around startServices()
to track successfully started attachments and, on failure, stop those
attachments in reverse order before rethrowing the CLI error. Preserve the
existing error conversion via toCliError(error), and ensure cleanup is attempted
only for attachments that started successfully.

In `@test/integration/test/local-dev-criteria-4-5.integration.ts`:
- Around line 319-338: Update the final fresh cleanup flow around
startDevUntilReady and stopDev to invoke the CLI’s app-scoped teardown after
stopDev completes, mirroring the post-stop dev.teardown call used by
local-dev-store.integration.ts. Keep the existing error handling and ensure
teardown runs for the cleanup session so the mandatory converge does not leave
the recreated postgres-main server behind.

---

Outside diff comments:
In `@packages/1-prisma-cloud/0-lowering/dev-emulators/src/postgres-main.ts`:
- Around line 449-523: Separate stale-name cleanup retries from the port
candidate budget in the startup loop around startPrismaDevServer. Keep existing
records fixed to their stored database port, but allow one
deleteServer(instanceName) cleanup and retry when isNameAlreadyTaken(err) has no
adoptable ghost, including warm restarts; track this independently from
maxAttempts so the cleanup path is reachable for existing records while
preserving the existing fresh-port retry behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 3fff45a3-bbc3-4c71-b289-c714f4b4528e

📥 Commits

Reviewing files that changed from the base of the PR and between 248827b and cca301e.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (27)
  • .github/workflows/ci.yml
  • packages/0-framework/1-core/core/src/control/app-config.ts
  • packages/0-framework/3-tooling/cli/package.json
  • packages/0-framework/3-tooling/cli/src/__tests__/main.test.ts
  • packages/0-framework/3-tooling/cli/src/dev/__tests__/generate-dev-stack.test.ts
  • packages/0-framework/3-tooling/cli/src/dev/__tests__/run-dev.test.ts
  • packages/0-framework/3-tooling/cli/src/dev/__tests__/watch.test.ts
  • packages/0-framework/3-tooling/cli/src/dev/generate-dev-stack.ts
  • packages/0-framework/3-tooling/cli/src/dev/run-dev.ts
  • packages/0-framework/3-tooling/cli/src/dev/watch.ts
  • packages/0-framework/3-tooling/cli/src/main.ts
  • packages/0-framework/3-tooling/cli/src/pipeline.ts
  • packages/1-prisma-cloud/0-lowering/dev-emulators/src/postgres-main.ts
  • packages/1-prisma-cloud/0-lowering/local-target/src/__tests__/compute-scoped-env.test.ts
  • packages/1-prisma-cloud/0-lowering/local-target/src/__tests__/postgres-instance-name-drift.test.ts
  • packages/1-prisma-cloud/0-lowering/local-target/src/compute.ts
  • packages/1-prisma-cloud/0-lowering/local-target/src/exports/index.ts
  • packages/1-prisma-cloud/0-lowering/local-target/src/postgres.ts
  • packages/1-prisma-cloud/1-extensions/target/src/local-target/attach.ts
  • test/integration/package.json
  • test/integration/test/fixtures/local-dev/bg-service.ts
  • test/integration/test/fixtures/local-dev/built/bg-server.mjs
  • test/integration/test/fixtures/local-dev/built/web-server.mjs
  • test/integration/test/fixtures/local-dev/module.ts
  • test/integration/test/local-dev-criteria-4-5.integration.ts
  • test/integration/test/local-dev-store.integration.ts
  • test/integration/test/local-dev.integration.ts

Comment thread packages/0-framework/3-tooling/cli/src/dev/run-dev.ts
Comment thread packages/0-framework/3-tooling/cli/src/dev/run-dev.ts
Comment thread packages/0-framework/3-tooling/cli/src/dev/run-dev.ts
Comment thread packages/0-framework/3-tooling/cli/src/dev/run-dev.ts
Comment thread test/integration/test/local-dev-criteria-4-5.integration.ts
wmadden-electric and others added 6 commits July 23, 2026 20:34
The delete-recreate test kept failing on Linux CI with zero events even
after readiness was awaited: an inotify watch bound directly to a file
dies with the file's inode, and chokidar v4 absorbs atomic renames but
NOT unlink+recreate of a directly watched file — which is the shape of
every 'rm -rf dist && build --outfile' rebuild. File targets are now
watched via a depth-0 chokidar watch on their parent, filtered to the
exact path (the approach the pre-chokidar implementation had already
proven on Linux); directory targets stay recursive watches of
themselves. A nonexistent path counts as a file target, so it starts
reporting the moment something creates it.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Seen on CI: an ensure for a database with a persisted port was refused
because its freshly allocated aux HTTP port was claimed by another
Prisma Dev server's registry, and the retry cap for existing records was
1 — so a conflict on a port we would happily re-pick surfaced as a 500.
The aux listener ports are never persisted; a refusal of one now always
retries with fresh candidates. Only the DATABASE port stays frozen once
a record exists (endpoints in deploy state reference it) — a refusal
there still fails fast for existing records.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ctions

Seen on CI: the daemon died mid-request (connection refused on an
ensure, pid gone), taking every app's databases with it. The daemon
hosts @prisma/dev's runtime in-process, and a failed or abandoned start
attempt can leave background async work behind whose eventual rejection
kills the process — bun and node both exit on an unhandled rejection. A
machine-shared daemon must not die because one attempt's debris
rejected: log it (credential-masked, into the registry log) and keep
serving; request paths still report their own errors as 500s. The drift
test now prints the daemon's log tail on failure, so the next crash is
diagnosable instead of a bare connection error.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Seen on CI: right after a 102-resource converge the session died on a
bare 'fetch failed' from startServices() — the transient-loopback retry
existed only for endpoints(). One generic retry now wraps every attach
admin call the dev session makes, with a slightly deeper budget
(5 x 500ms) for loaded runners.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rors

Seen on CI: a converge died on 'fetch failed' / 'other side closed' from
a local provider's emulator call — the keep-alive reuse race (the
runtime reuses a pooled connection the daemon's HTTP server just closed
idle), plus briefly overloaded daemons under CI load. Every admin call
is idempotent by design, so the loopback client now retries each one a
few times before failing; a caller-driven abort is never retried. This
covers converge-time provider calls too, which the CLI-level attach
retry could not reach.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Four CodeRabbit findings on #164, three code + one test:
- the rebuild handler runs fire-and-forget, but only runPipeline was
  inside its catch — a throw from writeDevStackFile or the alchemy spawn
  would be an unhandled rejection killing the process; the whole rebuild
  is now inside one try/catch and prints '[dev] rebuild failed' instead.
- pumpLogs stayed quiet on EVERY stream error; now only the session-
  ending abort is quiet — a genuinely broken log stream says so.
- a startServices() failure partway through the attachment list left the
  earlier attachments running; they are put back to stopped, so a
  session that never began leaves the machine as the previous Ctrl-C
  did.
- the criteria-4-5 cleanup now runs the direct app-scoped teardown after
  its --fresh session, mirroring the store test — --fresh tears down
  BEFORE its own converge, which recreates the postgres server the
  cleanup meant to remove.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/0-framework/3-tooling/cli/src/dev/watch.ts`:
- Around line 101-112: Register Chokidar error handlers on both watchers created
in the watcher setup, alongside their existing all listeners, so emitted errors
are handled without becoming uncaught emitter errors or bypassing cleanup.
Update the related watch tests to verify that both directory and parent watcher
error events are handled.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: c4d0cabf-e94e-414e-871f-f0b13bef220b

📥 Commits

Reviewing files that changed from the base of the PR and between cca301e and 93267e5.

📒 Files selected for processing (5)
  • packages/0-framework/3-tooling/cli/src/dev/run-dev.ts
  • packages/0-framework/3-tooling/cli/src/dev/watch.ts
  • packages/1-prisma-cloud/0-lowering/dev-emulators/src/client.ts
  • packages/1-prisma-cloud/0-lowering/dev-emulators/src/postgres-main.ts
  • packages/1-prisma-cloud/0-lowering/local-target/src/__tests__/postgres-instance-name-drift.test.ts

Comment thread packages/0-framework/3-tooling/cli/src/dev/watch.ts
wmadden-electric and others added 2 commits July 23, 2026 21:39
…rently

Seen on CI: the postgres daemon died SILENTLY (no JS error, no
rejection-guard output, just gone) during a --fresh teardown, then a
converge hit ECONNREFUSED. The servers run pglite's native/WASM runtime
inside the daemon process, and the only concurrency we control in that
window is deleteApp/shutdown closing several servers at once via
Promise.all — a native abort takes the process out with no trace a JS
guard can leave. All close and delete-state paths now run one server at
a time, and a single close failure no longer aborts the rest of the
teardown.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
An 'error' emitted with no listener throws out of the emitter and would
take the dev session down; a watch error (EMFILE, a vanished directory)
now prints '[dev] watch error: ...' and the session keeps running.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@wmadden-electric
wmadden-electric merged commit 3142847 into main Jul 23, 2026
17 of 18 checks passed
@wmadden-electric
wmadden-electric deleted the claude/local-dev-s5-dev-command branch July 23, 2026 19:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants