Skip to content

feat: /dispatch as the default setup route, and a service-unit fix for npm deployments (issue #96) - #97

Merged
edgehero merged 4 commits into
mainfrom
feat/dispatch-default-route
Aug 4, 2026
Merged

feat: /dispatch as the default setup route, and a service-unit fix for npm deployments (issue #96)#97
edgehero merged 4 commits into
mainfrom
feat/dispatch-default-route

Conversation

@edgehero

@edgehero edgehero commented Aug 4, 2026

Copy link
Copy Markdown
Owner

Closes #96. Makes /dispatch the default way to set up pi-dispatch, and fixes a shipped bug found while verifying it.

Fixed: service install was broken for every npm deployment

service.mjs derived a "repo root" two directories above its own module. Right in a checkout; under npm install that is node_modules/@edgehero, the scope directory, so every rendered unit pointed at files that do not exist: the worker ExecStart, the receiver ExecStart, EnvironmentFile, the plist wrapper path, the nssm paths. The wizard's service step has been installing those units since it shipped. Tests never caught it because they injected a correct repoRoot; no npm-layout fixture existed.

Three anchors replace the guess, each correct in both layouts: deployDir (the cwd service runs from) owns everything host-side (WorkingDirectory, .env, and logs/, created at install time since launchd and nssm create log files but never their directory), cliPath is cli.mjs beside the service module, and receiverStart comes from import.meta.resolve("@edgehero/pi-dispatch-receiver/start"), with a loud refusal naming the install command when the package is absent instead of writing a unit that crash-loops at boot. The wrapper stops guessing too: cwd is the deployment folder, it refuses empty argv and a missing .env naming $PWD, then runs "$@" verbatim. The exit-2 conversion and SIGTERM forwarding are preserved and still asserted against the real shipped wrapper. worker/deploy also gains docker-compose.yml, so npm deployments can run the receiver profile.

Existing service installs from 0.1.1 should re-run pi-dispatch service install --force from their deployment folder.

pi compatibility becomes strategy, not luck

As the advertised front door, a pi release must not break onboarding silently. The extension now reads pi's exported VERSION and, when it differs from the tested pin while the capability probe still passes, surfaces one info line on the first /dispatch. Never a refusal: the all-or-nothing capability probe stays the only hard gate. The peer pin widens to the "*" range pi's own packages doc prescribes for host-provided packages (the exact devDep stays the tested marker). And a weekly canary installs latest pi into a scratch dir (never the repo root, so the pinned assertions keep asserting the pin) and fails CI if any used API member or type needle disappears. Verified against pi 0.83.0, today's latest: the canary passes, so the advisory is what real operators on a newer pi will see.

The default route

  • Direct entry: bare /dispatch with nothing configured no longer asks a yes/no before showing the wizard's own opening select. That select (Guided setup / Open the panel anyway / Cancel) is the consent, and one keypress no longer costs two detections. A configured deployment with a down queue still keeps the unreachable banner and never enters the wizard.
  • Docker pre-check (new step 3): distinguishes not-on-PATH from daemon-down, then a Re-check / Continue anyway / Stop loop with per-OS vendor pointers and never a command to pipe into a shell. The probe carries a 10 second timeout, because it is a synchronous exec on a dialog path and a wedged docker socket would otherwise freeze pi's render loop.
  • Trigger edge (new step 10): the receiver as a user-level service (consented pinned install, version assertion, then service install --receiver, correct after the fix above), or the compose profile (compose file copied create-only), or the polling command printed, or skip. A receiver version mismatch skips the unit and continues; the receiver is optional, unlike the runtime install, which stops.
  • Skew notice: when the deployed runtime falls behind the console's pin, one notice per process points at /dispatch setup, which is already the upgrade path.

Docs

README quickstart leads with the extension route; the npx block moves to "Servers and headless" unchanged; the clone note stays for custom images. admin/README follows. docs/launch-kit.md gains the install line it never had. No dashes in README prose, per the house style.

Specs

DES-FIRST-RUN-SETUP-WIZARD amended (direct entry, both new steps, the skew notice, RECEIVER_VERSION, the outage rule restated as load-bearing); REQ-ADMIN-VIA-PI-EXTENSION acceptance amended (lands in the select, Cancel spawns and writes nothing; an untested-but-complete pi is an advisory; the skew notice). Revision rows in design.md and requirements.md record the service-unit fix and the compatibility strategy. CONST-BUDGET-BEFORE-TOKENS, CONST-RETRY-INFRA-ONLY, REQ-DEPLOYMENT-BOOTSTRAP unchanged, checked.

Tests

Full suite: 1871 tests, 1855 pass, 0 fail, 16 pre-existing skips (from 1843). Worker service suite 30 → 37 (real npm-layout fixtures asserting rendered targets exist and never take the scope shape, the missing-receiver refusal, live-shell wrapper tests under the new argv contract); admin 341 → 360. Workflow YAML validated; sh -n, xmllint, and plutil -lint pass on the wrapper and a live render; CI greps and control-byte scans clean.

…ssed repo root (issue #96)

service.mjs derived a repo root from its own location ("../.."). Right in a
checkout; WRONG under npm install, where src/ lives at
node_modules/@edgehero/pi-dispatch/src and "../.." is the @edgehero SCOPE dir.
Every rendered unit pointed at files that do not exist: worker ExecStart,
receiver ExecStart, EnvironmentFile, the plist wrapper path, the nssm paths. The
wizard's service step has been installing those broken units. The tests never
caught it because they injected a correct repoRoot -- no npm-layout fixture
existed.

Three anchors replace the guess, each correct in BOTH layouts:
- deployDir (the cwd service runs from) owns everything host-side:
  WorkingDirectory, <deployDir>/.env, and <deployDir>/logs (created at install
  time -- launchd and nssm create log files, never their directory). Never the
  package dir, which npm may replace wholesale on update.
- cliPath = join(moduleDir, "cli.mjs") -- cli.mjs sits beside service.mjs in
  src/ in both layouts, so the worker ExecStart needs no repo root at all.
- receiverStart = import.meta.resolve("@edgehero/pi-dispatch-receiver/start")
  -- the receiver package's own exported entry, wherever npm or the workspace
  symlink put it. null (not installed) makes render AND install refuse loudly
  with the install command, instead of writing a unit that crash-loops at boot.

The wrapper stops guessing too: no cd, cwd IS the deployment folder (plist
WorkingDirectory / nssm AppDirectory guarantee it), it refuses empty argv and a
missing ./.env naming $PWD, then runs "$@" verbatim -- the render substitutes
the exec argv. The trap/double-wait SIGTERM forwarding and the exit-2 -> 0
conversion are preserved and still asserted against the real shipped wrapper.

worker/deploy also gains docker-compose.yml, so an npm deployment can run the
receiver compose profile; the exact-list mirror sync test grows with it.

Tests: 37 in the service suite (was 30) -- 3 real npm-layout fixtures asserting
rendered targets EXIST and never take the scope shape, the missing-receiver
refusal, and 3 live-sh wrapper tests under the new argv contract.
CONST-RETRY-INFRA-ONLY unchanged, checked.

Signed-off-by: Rob Boerman <robboerman@live.nl>
As the default setup route, the console must not scare or block anyone on a
newer pi, and a breaking pi release must fail CI before a newcomer finds it.

- runtime advisory: the extension now reads pi's exported VERSION (a real
  runtime export; the bundle keeps pi external, so it is the HOST's pi). When it
  differs from the tested pin and the capability probe still passes, the first
  /dispatch surfaces ONE info line naming both versions. Never a refusal -- the
  all-or-nothing capability probe stays the only hard gate. Computed at factory
  time into a memoized module slot; nothing prints at factory time, which keeps
  load.test's exactly-one-stderr-line wall intact.
- peer range: the exact pi peer pin widens to "*", which is what pi's own
  packages doc prescribes for host-provided packages (pi never installs them,
  and an exact pin ERESOLVEs plain-npm consumers). The devDependency keeps the
  exact pin -- it is the tested marker, and load.test still ties
  SUPPORTED_PI_VERSION to it.
- canary: a weekly job installs latest pi into a SCRATCH dir (never the repo
  root -- the pinned assertions must keep asserting the pin) and asserts every
  type needle, every USED_API member, the VERSION export, and that the built
  bundle loads and registers /dispatch against it. admin/** joins both path
  filters. An anti-drift test parses the pinned test's needle list so a stale
  canary list fails the suite in either direction.

Verified against pi 0.83.0 (current latest): the canary passes, so the advisory
is what real operators on newer pi will see today.

Signed-off-by: Rob Boerman <robboerman@live.nl>
- direct entry: bare /dispatch with nothing configured no longer asks a yes/no
  before showing the wizard's own opening select -- that select (Guided setup /
  Open the panel anyway / Cancel) IS the consent, and one keypress no longer
  costs two detections (the branch hands its detection to the wizard). A
  configured deployment with a down queue still keeps the unreachable banner and
  never enters the wizard.
- docker pre-check (new step 3): a probe distinguishing not-on-PATH from
  daemon-down, then a Re-check / Continue anyway / Stop loop with per-OS vendor
  pointers and never an install command to pipe. Bounded at 5 rounds. The probe
  carries a 10s timeout: it is a synchronous exec on a dialog path, so a wedged
  docker socket would otherwise freeze pi's render loop; a timeout reads as
  daemon-down, which is the right verdict. `pi-dispatch up` keeps its own hard
  refusal, so this is a better message earlier, not the enforcement.
- the trigger edge (new step 10): receiver as a user-level service (consented
  pinned install of @edgehero/pi-dispatch-receiver, post-install version
  assertion, then `service install --receiver` -- correct since the
  unit-anchoring fix earlier in this branch), or the compose profile (the
  compose file now ships in the runtime package; copied create-only), or the
  polling command printed, or skip. A receiver version mismatch skips the unit
  and continues: the receiver is optional, unlike the runtime install, which
  stops.
- runtime skew notice: when the pointer names a deployment whose installed
  runtime is older than this console's pin, one notice per process points at
  /dispatch setup, which is already the upgrade path. Absent or unreadable
  version stays silent (an operator may run the worker from a clone).
- RECEIVER_VERSION joins RUNTIME_VERSION with its own anti-drift test, and both
  npm argv wrappers keep the path-free exact-argv pin.

Tests: 360 admin (was 341) -- 49 in the wizard suite, incl. every FIFO answer
array renumbered for the two inserted steps, all four edge answers asserted by
exact spawns, create-only compose copy, and the skew latch.

Signed-off-by: Rob Boerman <robboerman@live.nl>
- README quickstart splits in two: "From pi (the default route)" leads with
  `pi install npm:@edgehero/pi-dispatch-admin` then `/dispatch`, and the npx
  block moves to "Servers and headless" unchanged. The admin section drops the
  word "offers" for what the branch now does, states the outage rule (setup
  appears when there is nothing, never over a down queue), and mentions the skew
  notice. The service section documents the new unit anchoring, and the
  trigger-edge list notes the wizard walks the same choice.
- admin/README: the same flip, plus the docker check and trigger-edge steps.
- docs/launch-kit.md gains the install line it never had, in both the elevator
  pitch and the Show HN body, scoped form only per its own naming rule.
- Specs: DES-FIRST-RUN-SETUP-WIZARD amended (direct entry, the two new steps,
  the skew notice, RECEIVER_VERSION, and the outage rule restated as
  load-bearing); REQ-ADMIN-VIA-PI-EXTENSION acceptance amended (lands in the
  select, Cancel spawns and writes nothing; an untested pi version is an
  advisory, never a refusal; the skew notice). Revision History rows in both
  files record the service-unit fix and the pi-compatibility strategy.
  CONST-BUDGET-BEFORE-TOKENS, CONST-RETRY-INFRA-ONLY, REQ-DEPLOYMENT-BOOTSTRAP
  unchanged, checked.

No dashes in the README prose, per the house style.

Signed-off-by: Rob Boerman <robboerman@live.nl>
@edgehero
edgehero merged commit 030aa31 into main Aug 4, 2026
12 checks passed
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.

/dispatch becomes the default setup route; service units are broken for npm deployments

1 participant