v1.45.0.0 feat(design): persistent board daemon — 24h boards, one tab, board history (#1710)

* refactor(design): board JS uses relative paths; drop __GSTACK_SERVER_URL injection

Board JS in design/src/compare.ts now calls ./api/feedback and ./api/progress
(relative to location.pathname) and feature-detects server mode via
location.protocol instead of the injected window.__GSTACK_SERVER_URL global.
The injection in design/src/serve.ts is removed (dead code now that nothing
reads it). Tests updated to match the new contract: serve.test.ts asserts
the relative-path JS is present and the global is gone; feedback-roundtrip
asserts location.protocol detects HTTP mode.

Why: prep for the multi-board daemon (design/src/daemon.ts upcoming) where
the same generated HTML is served at /boards/<id>/ instead of /. Relative
paths resolve against location.pathname in both cases, so one HTML, two
hosts. The injection was the only thing tying board JS to a specific
serving path; removing it unblocks the daemon work without forking the
generator.

file:// fallback preserved via the location.protocol feature-detect — board
opened directly as a file still falls through to the DOM-only success path.

The 6 feedback-roundtrip browser tests continue to fail with
session.clearLoadedHtml undefined; that failure pre-exists this branch
(verified against HEAD with these edits stashed) and lives in
browse/src/write-commands.ts, not in the design code path. Tracking
separately.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(design): reload guard rejects directory paths

design/src/serve.ts:200-212 used to accept a path that resolved to the
allowedDir itself (the OR branch `|| resolvedReload === allowedDir`),
which then crashed readFileSync with EISDIR. Now:

  1. startsWith(allowedDir + path.sep) must pass — rejects the dir itself
     and anything outside (403).
  2. statSync(resolvedReload).isFile() must pass — rejects subdirectories
     inside allowedDir with a clear "Path must be a file" 400.

The test stub in serve.test.ts mirrors prod; both updated, plus two new
test cases for the previously-broken paths. Codex caught this in the
plan-review pass; it's a latent bug in shipping code, not a regression
from the daemon work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(design): introduce design daemon — multi-board persistent server

Adds design/src/daemon.ts: a Bun.serve daemon that hosts many boards
under /boards/<id>/ instead of one server per `$D compare --serve` call.
Spawned by daemon-client (next commit); for now wired only via tests.

Endpoint table:
  GET  /health                       liveness + version + counts (unauth)
  GET  /                             index of recent boards
  POST /api/boards                   publish; daemon derives sourceDir
                                     from realpath(html). body sourceDir
                                     IGNORED (Codex trust-boundary fix).
  POST /shutdown                     graceful; refuses if active boards
                                     exist (Codex data-loss fix)
  GET  /boards/<id>                  301 → /boards/<id>/ (trailing slash
                                     is load-bearing — relative URLs in
                                     board JS resolve against pathname)
  GET  /boards/<id>/                 render board HTML
  GET  /boards/<id>/api/progress     state machine status (no idle reset)
  POST /boards/<id>/api/feedback     submit/regen; writes feedback.json
                                     or feedback-pending.json with
                                     boardId + publishedAt augmented in
  POST /boards/<id>/api/reload       swap HTML; per-board allowedDir
                                     guard rejects traversal, directories,
                                     out-of-allowed-dir symlinks

Lifecycle:
- 24h idle timeout (DESIGN_DAEMON_IDLE_MS for tests).
- Idle with active boards extends 1h up to 4x, then force-shuts (Codex).
- LRU cap 50 boards; evicts done before non-done; 503 when 50 non-done.
- Per-board async mutex serializes feedback POST vs reload POST.
- SIGTERM/SIGINT/uncaughtException → graceful shutdown, state file unlink.
- Stdout: DAEMON_STARTED port=<N> (the line the client parses).

Shared utilities live in design/src/daemon-state.ts: atomic state-file
write/read (mode 0o600), fs.openSync('wx') lock, isProcessAlive, cmdline
identity verification (/proc on Linux, ps on macOS), CMDLINE_MARKER
constant. Modeled on browse/src/cli.ts lock + spawn patterns.

design/test/daemon.test.ts: 30 tests, all green. Covers every endpoint,
both error paths and happy paths, cross-board feedback isolation, the
trailing-slash redirect, the directory-not-file reload rejection, LRU
preferring done over non-done, /shutdown refusal with active boards,
all path-traversal guards. Uses the exported fetchHandler in-process
(no spawn) so the suite runs in ~70ms.

design/test/daemon-tests-fixtures.ts: shared helpers — req() builder,
tmp-dir helpers, daemon reset, and a spawnDaemonForTest() helper used
by the next commit's discovery tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(design): daemon-client with lock + identity-verified spawn

design/src/daemon-client.ts implements the CLI side of the daemon lifecycle:
ensureDaemon() (the spawn-or-attach decision), publishBoard(), and the
$D daemon stop|status helpers.

Modeled on browse/src/cli.ts:317-415 — same health-check-first attach,
same fs.openSync('wx') lock, same re-read-state-INSIDE-the-lock guard
against two CLIs both deciding "no daemon, spawn." Two design-specific
safety properties added beyond browse:

1. verifyIdentity before any SIGTERM/SIGKILL. Reads the running process's
   cmdline (/proc/PID/cmdline on Linux, `ps -p PID -o command=` on macOS)
   and only signals if it contains CMDLINE_MARKER ("gstack-design-daemon",
   passed as argv at spawn time). Prevents a stale state file from
   causing us to kill an unrelated process that inherited the PID.

2. Refuse-kill-with-active-boards on version mismatch. Browse silently
   restarts; here in-memory board history would vanish, so the client
   prints a user-actionable WARNING and exit 1 instead. Users explicitly
   `$D daemon stop` to override.

Spawn uses Node child_process.spawn (NOT Bun.spawn().unref) because of
the macOS session-detach quirks browse already discovered. Stdio is
redirected to ~/.gstack/design-daemon-startup.log, which the client
tails into stderr if waitForHealthOrError times out — no more silent
"daemon failed for some unknowable reason."

daemon-state.ts gains DESIGN_DAEMON_STATE_FILE env override so tests
can point both client and spawned daemon at a per-test path without a
shared cwd.

design/test/daemon-discovery.test.ts: 17 tests, all green in ~8s. Covers:
spawn-fresh, attach-existing, stale-state-file (pid dead), PID-reuse
safety (uses the test runner's own PID as the bait — verifyIdentity
catches the cmdline mismatch, daemon not signaled), version-mismatch
with/without active boards (the active-boards case runs a subprocess
and asserts exit 1 + WARNING in stderr), publishBoard 200 + 409,
shutdownDaemon refuse/force/unresponsive paths, daemonStatus.

The daemon-discovery suite is split out of daemon.test.ts because each
real spawn costs ~200ms; the in-process daemon.test.ts (30 tests, 70ms)
covers the same handler logic without the spawn overhead.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(design): wire daemon dispatch into CLI; add daemon stop/status

design/src/cli.ts now branches on --no-daemon for both `compare --serve`
and standalone `serve --html`. Default path: ensureDaemon → publishBoard
→ openBrowser → exit. The legacy single-process serve() is preserved
behind --no-daemon for tests, Windows, and explicit debugging.

Adds $D daemon status (prints daemon state JSON, or {running:false})
and $D daemon stop [--force] (refuses with active boards unless --force).

parseArgs gains a `positionals` field so daemon sub-commands work
naturally (`$D daemon stop` instead of `$D --action stop`).

Stderr lines printed by the publishToDaemon path:
  DAEMON_STARTED port=N   (or DAEMON_ATTACHED port=N)
  BOARD_PUBLISHED: <url>
  BOARD_URL: <url>        (alias for grep-friendliness)

Stdout: JSON with id, url, sourceDir.

design/src/commands.ts: --no-daemon, --title added to compare + serve;
new daemon command entry with status|stop sub-commands.

End-to-end smoke (manual): spawning a board via $D serve, hitting the
returned URL, reading /health, calling daemon status (returns the
right JSON), and daemon stop refusing because of the active board —
all work as designed. Force-stop tears down cleanly and removes the
state file.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(design): end-to-end daemon round-trip via HTTP fetch

design/test/feedback-roundtrip-daemon.test.ts walks the full publish →
submit / regenerate / reload cycle against a real spawned daemon, using
the same HTTP calls the board JS makes. Four tests, all green in ~650ms.

Covers what design-shotgun and friends actually depend on:
  - Submit writes feedback.json into the board's sourceDir with the
    augmented boardId + publishedAt fields.
  - GET /boards/<id> (no slash) returns a 301 to /boards/<id>/ — the
    load-bearing redirect that lets the board JS use relative paths.
  - Regenerate writes feedback-pending.json, flips state to regenerating,
    /api/progress reflects it; /api/reload swaps HTML in place; round-2
    submit writes the final feedback.json with the round-2 selection.
  - Two boards published into the same daemon get independent URLs on
    the same port — feedback for board A doesn't contaminate board B's
    sourceDir, both URLs serve their own content, the index lists both.

Uses HTTP fetch rather than a real browser because the existing browser
round-trip (feedback-roundtrip.test.ts) is broken on a pre-existing
browse harness regression (session.clearLoadedHtml undefined in
browse/src/write-commands.ts:149) that's unrelated to this branch.
The HTTP path proves the same daemon semantics; a browser variant can
be added once the browse harness is fixed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(design): compiled binary self-execs as daemon; unified version lookup

Two small but production-critical fixes once the binary actually runs:

1. Compiled binary couldn't spawn the daemon. daemon-client previously
   pointed at design/src/daemon.ts via import.meta.dir — fine in dev,
   fatal in production (the source path doesn't exist on a user's
   machine). Fix: design CLI now self-execs in --daemon-mode when
   invoked with that flag, so the spawn is `process.execPath
   --daemon-mode --marker gstack-design-daemon` for the compiled binary
   and `bun run cli.ts --daemon-mode ...` in dev. Same one binary, two
   modes, no separate daemon entrypoint to ship.

2. Client and daemon disagreed on VERSION in the compiled binary.
   Both used a source-tree-relative path that resolves to "unknown"
   at runtime, which silently shorted the version-mismatch refusal
   path (client expected "unknown" + daemon reported "unknown" → match
   → no refusal even when DESIGN_DAEMON_VERSION was set on one side).
   New readVersionString() consults DESIGN_DAEMON_VERSION env first,
   then design/dist/.version (sidecar baked at build time by build.sh),
   then VERSION at the source-tree root. Both client and daemon now go
   through this one helper.

Manual smoke (compiled binary, all checks green):
  - DAEMON_STARTED + BOARD_PUBLISHED with trailing slash
  - GET /boards/<id> (no slash) → 301 Location /boards/<id>/
  - Second `$D serve` invocation → DAEMON_ATTACHED, new board on same port
  - feedback.json gets boardId + publishedAt fields
  - DESIGN_DAEMON_VERSION=v2-different on second invocation with
    active board → WARNING + "Refusing to auto-kill" + exit 1,
    original daemon still alive
  - `$D daemon stop --force` removes state file

All 67 design tests still green after the refactor (16 serve + 30
daemon + 17 discovery + 4 daemon round-trip).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(design): skill resolvers learn the daemon's BOARD_URL output

The five skills that invoke $D compare --serve (design-shotgun,
design-consultation, plan-design-review, office-hours, design-review)
parsed `SERVE_STARTED: port=N` from stderr and then POSTed to
`/api/reload` at that port during regenerate cycles. The new daemon
hosts boards under `/boards/<id>/` so the reload endpoint moved to
`<BOARD_URL>api/reload` — without this update, the regenerate phase
of every skill invocation would silently fail against daemon mode.

Updated scripts/resolvers/design.ts to parse `BOARD_URL:` instead of
the port, and to POST reloads against the per-board URL. Regenerated
the four SKILL.md files via bun run gen:skill-docs.

Legacy `--no-daemon` invocations continue to emit `SERVE_STARTED:` and
serve at `/api/reload` — the resolver instructions note both.

Surfaced by the maintainability specialist during /ship review (the
"stale comment" finding was actually a behavior bug pointing at five
downstream consumers). Codex's plan-review pass flagged the migration
story as incomplete but I dismissed the concern — Codex was right.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(design): emit SERVE_STARTED back-compat alias; drop dead import

design/src/cli.ts publishToDaemon now emits `SERVE_STARTED: port=N html=<path>`
as a third stderr line alongside DAEMON_STARTED/DAEMON_ATTACHED + BOARD_URL.
Any out-of-tree script that grepped the legacy line still gets the port —
they'd still fail at the reload step (the endpoint moved to /boards/<id>/
api/reload) but they no longer fail at the port-detection step. Combined with
the resolver updates one commit back, this is belt-and-suspenders compat.

Fixed the stale docstring at cli.ts:316 that claimed back-compat without
actually emitting the alias. The maintainability specialist flagged it.

Dropped a dead `DaemonState` import from daemon-client.ts. Same review pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: bump version and changelog (v1.45.0.0)

Design boards now live 24h, not 10 minutes. One daemon hosts every
board, one tab survives the whole day. See CHANGELOG.md for the full
release summary + metrics + itemized changes.

TODOS.md gains a "design daemon: follow-ups" section capturing the
P3 test gaps + maintainability nits the /ship review army flagged
but that aren't blocking for this release.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(design): fill daemon test gaps surfaced by ship review army

Adds 10 net new tests (and removes 1 misleading smoke) for the gaps the
testing specialist flagged at /ship time. Filed as P3 TODOs at ship,
filling now per boil-the-lake.

design/test/daemon-discovery.test.ts (+6 tests, +1 import):
  - "idle daemon (no boards) shuts itself down after IDLE_MS + CHECK_MS"
    Spawn-based, DESIGN_DAEMON_IDLE_MS=2000, CHECK_MS=200. Waits for the
    daemon process to actually exit and asserts the state file is removed.
    Previously only "callable without throwing" was tested.
  - "bare GET polling does NOT prevent idle shutdown"
    Hammers /api/progress every 200ms in a background loop with a done
    board, asserts the daemon still idles out — proves the
    meaningful-activity-only-on-POSTs guard (Codex finding) actually works.
  - "idle with active (non-done) boards triggers extension instead of shutdown"
    Sets DESIGN_DAEMON_EXTENSION_MS=1500 + MAX_EXTENSIONS=2, publishes a
    non-done board, asserts the daemon survives past IDLE_MS (extends),
    then verifies the MAX_EXTENSIONS hard ceiling force-shuts. Both the
    extension counter and the hard ceiling were previously untested.
  - "two parallel ensureDaemon() calls converge on one daemon"
    Fires two ensureDaemon calls in Promise.all against an empty stateFile,
    asserts: both ports match, exactly one spawned=true, exactly one daemon
    alive, no orphaned lock file. The discovery-test file's own docstring
    claimed this test existed; now it actually does.
  - "acquireLock reclaims a lockfile owned by a dead PID"
    Plants a lockfile with PID 999999998, calls acquireLock, asserts the
    returned release fn is non-null and the lock now holds our PID.
  - "acquireLock refuses to reclaim a lockfile owned by an alive PID"
    Uses the test runner's own PID — alive but not the lock's intended
    owner. Asserts acquireLock returns null and leaves the lockfile
    untouched. The unrelated-process-PID-reuse safety guard.

design/test/daemon.test.ts (-2 misleading, +5 new = +3 net):
  - Removed: "bare GET /api/progress does NOT reset meaningful activity"
    (smoke pretending to be behavioral — body comment admitted it couldn't
    verify). Replaced by the spawn-based version in daemon-discovery above.
  - Removed: "idleCheckTick is callable without throwing when there's no idle"
    (collapsed into a single smoke describe that's clearer about its scope).
  - Added: "POST /api/boards rejects invalid JSON body"
  - Added: "POST /api/boards rejects non-object body (e.g. JSON null)"
  - Added: "POST /api/boards: array body falls through to missing-html 400"
    (documents the typeof-array-is-object JS quirk; will surface if we
    ever tighten the type check)
  - Added: "POST /boards/<id>/api/reload rejects invalid JSON body"
  - Added: "POST /boards/<id>/api/reload rejects body missing html field"

Per-file totals after: serve 16, daemon 34, discovery 23, round-trip 4 = 77.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: update CHANGELOG + TODOS for filled test gaps in v1.45.0.0

Bumps the design test count from 67 → 77 (and the new-test delta from
+51 → +61) to reflect commit 6b037c55, which filled the 5 P3 test gaps
the /ship review army had filed to TODOS.md.

Marks the "Tighten daemon test coverage" entry in TODOS.md as DONE.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-05-25 20:45:12 -07:00
committed by GitHub
parent 64f9aafa1e
commit cf50443b63
22 changed files with 3176 additions and 92 deletions
+59
View File
@@ -1,5 +1,64 @@
# Changelog
## [1.45.0.0] - 2026-05-25
## **Design boards now live 24 hours, not 10 minutes. One daemon hosts every board, one tab survives the whole day.**
Run `$D compare --serve` and you get a persistent design daemon at `.gstack/design.json` instead of a fresh process per call. Open three design sessions across an afternoon and they all land at `/boards/<id>/` on the same port. The browser tab you opened first still works for the board you published an hour later. The idle timeout went from 10 minutes (the old per-process server) to 24 hours of inactivity (the daemon's lifetime). Submit a board, the URL stays accessible until the daemon idles out, so you can scroll back through the day's design history at `http://127.0.0.1:N/`.
Skill invocations (`/design-shotgun`, `/design-consultation`, `/plan-design-review`, `/design-review`, `/office-hours`) keep calling `$D compare --serve` exactly the same way. The CLI shape is unchanged. What's different is the binary now self-execs into daemon mode under the hood, attaches to a running daemon if one is there, spawns a fresh one if not, and prints `BOARD_PUBLISHED: http://127.0.0.1:N/boards/<id>/` to stderr so the skill can echo the URL. The legacy `--no-daemon` flag preserves the old single-process behavior for tests and debugging.
### The numbers that matter
Source: `bun test design/test/` and `git diff origin/main...HEAD --stat`.
| Metric | Before | After | Δ |
|-----------------------------------------|---------------|---------------|----------------|
| Idle timeout per board | 10 minutes | 24 hours | 144× |
| Server processes for N boards | N | 1 | N× |
| Browser tabs to keep open | one per board | one total | N× |
| Design tests in repo | 16 | 77 | +61 |
| Test paths covered (failure modes) | not enumerated| 38 / 100% | full coverage |
| Plan-review findings absorbed pre-impl | 2 | 19 | 17× from Codex |
| Component | New lines | Test lines |
|----------------------------|-----------|------------|
| design/src/daemon.ts | ~580 | 34 tests |
| design/src/daemon-client.ts| ~340 | 23 tests |
| design/src/daemon-state.ts | ~180 | (via client + daemon tests; direct stale-lock reclaim coverage) |
| Browser round-trip via HTTP| (existed) | 4 tests |
The compression: 61 new tests cover every endpoint, lifecycle path, LRU eviction, real idle-shutdown behavior (spawn-based, daemon process observed exiting after `IDLE_MS`), the bare-GET-doesn't-reset-idle invariant (poll loop in background, daemon still idles out), the idle-with-active-boards extension path with `MAX_EXTENSIONS` hard ceiling, concurrent-CLIs lock race (two parallel `ensureDaemon` calls converge on one daemon), identity-verified spawn, version mismatch with and without active boards, PID-reuse safety, path traversal rejection, malformed-body negatives on every POST, and cross-board feedback isolation. The plan-review pass caught 2 architectural issues in-house; an outside Codex pass caught 17 more, all absorbed into the implementation before any code was written; the /ship review army caught 1 backwards-compat break in skill resolvers (fixed) + 5 deferred test gaps (filled). The version-mismatch path now refuses to silently kill a daemon with active boards (it prints a warning and exits 1), so upgrading gstack mid-design-session doesn't drop your in-memory board history.
### What this means for the builder
Open `/design-shotgun` Monday morning, work through three rounds of variants, walk away for lunch, come back, click Submit. The board is still there. Open a second `/design-shotgun` for a different feature in the afternoon, get a new URL at `/boards/<another-id>/`, no port churn, your morning board still works. The whole day's worth of design exploration accumulates as a browsable history at the daemon's root. Stop worrying about the 10-minute death clock.
### Itemized changes
#### Added
- **Persistent design daemon** (`design/src/daemon.ts`). Bun HTTP server on `127.0.0.1` hosting many boards under `/boards/<id>/`. Per-board state machine (`serving | regenerating | done`), LRU cap of 50 boards (evicts `done` first, returns 503 when 50 non-done coexist), 24h idle timeout with 1h extensions up to a 28h ceiling when boards are still active, per-board async mutex serializing feedback POST vs reload POST. Index page at `/` lists recent boards newest first.
- **`$D daemon status`** and **`$D daemon stop [--force]`**. The stop sub-command refuses without `--force` when active boards exist, so a casual stop doesn't drop in-flight history.
- **Daemon client** (`design/src/daemon-client.ts`). `ensureDaemon()` handles spawn-or-attach with file-lock-protected spawn (re-reads state inside the lock to close the two-CLIs-race window) and identity-verified SIGTERM (reads `/proc/PID/cmdline` on Linux, `ps -p PID -o command=` on macOS, only signals if `gstack-design-daemon` is in the cmdline). PID-reuse safety: if the state file points at a PID belonging to an unrelated process, no signal is sent and a fresh daemon spawns. Version-mismatch refusal: if a CLI from a newer gstack version arrives while boards are still open in an older daemon, the CLI prints a user-actionable warning and exits 1 instead of silently restarting and losing history.
- **Shared daemon state utilities** (`design/src/daemon-state.ts`). Atomic state-file write (`<tmp>` + `renameSync` at mode `0o600`), `fs.openSync('wx')` exclusive lock, cross-platform cmdline reader, version lookup that falls back through `DESIGN_DAEMON_VERSION` env → `design/dist/.version` baked at build time → source-tree `VERSION``"unknown"`.
- **End-to-end round-trip tests against a real spawned daemon** (`design/test/feedback-roundtrip-daemon.test.ts`). HTTP fetch drives publish → submit → regenerate → reload → round-2 submit, asserting `feedback.json` lands at the daemon-derived `sourceDir` with `boardId` and `publishedAt` augmented fields.
#### Changed
- **Board JS uses relative URLs** instead of an injected `__GSTACK_SERVER_URL` global. The same generated HTML works at `/` (legacy `--no-daemon`) and `/boards/<id>/` (daemon). `location.protocol` feature-detect keeps the `file://` DOM-only fallback path working.
- **Bare `GET /boards/<id>` returns 301** to `/boards/<id>/`. The trailing slash is load-bearing for relative-URL resolution in the board JS; without it, `fetch('./api/feedback')` would resolve to the wrong scope.
- **Reload guard rejects directory paths**. `design/src/serve.ts:200-212` previously let `resolvedReload === allowedDir` through, which then crashed `readFileSync` with `EISDIR`. Now requires `statSync(resolvedReload).isFile()` with a clear 400 instead.
- **Feedback files carry `boardId` and `publishedAt`** so agents polling `feedback.json` / `feedback-pending.json` in a multi-board world can verify which board produced what.
- **`sourceDir` is derived from `realpath(html)` server-side**, never trusted from the publish POST body.
- **Skill resolvers and templates** (`scripts/resolvers/design.ts`, `design-shotgun/SKILL.md`, `design-consultation/SKILL.md`, `plan-design-review/SKILL.md`, `office-hours/SKILL.md`) updated to parse `BOARD_URL:` from stderr and POST reloads to `${BOARD_URL}api/reload` instead of the legacy port-only `/api/reload`. Legacy `SERVE_STARTED: port=N html=...` line still emitted for back-compat.
#### Fixed
- **Compiled design binary self-execs as the daemon** via a `--daemon-mode` flag, so the daemon lifecycle works for users installing from `design/dist/design` (not just `bun run` against the source tree).
- **Version lookup** is consistent between client and daemon. Both go through `readVersionString()`, so the version-mismatch refusal path works on the compiled binary instead of always reading `"unknown"` and matching itself.
#### For contributors
- **Test infrastructure split**: `design/test/daemon.test.ts` (30 in-process tests against the exported `fetchHandler`, ~70ms) for fast iteration; `design/test/daemon-discovery.test.ts` (17 real-spawn tests, ~8s) for lifecycle + lock + identity guarantees. Shared helpers in `design/test/daemon-tests-fixtures.ts`.
- **Plan-review process**: this branch ran `/plan-eng-review` twice. Round 1 caught 2 architecture findings. An outside-voice Codex pass after round 1 found 17 more (URL contract self-contradiction, false test-green claim, lock semantics, identity verification, version-mismatch silent data loss, several others). Round 2 absorbed all 17 before implementation started. The full review trail is preserved in the plan file's `## GSTACK REVIEW REPORT` section.
## [1.44.1.0] - 2026-05-24
## **Nine community fixes ship in one bundle.** Office-hours session counter works again, iOS QA tunnels survive macOS 26.x, Windows brain-sync stops dropping artifacts, browse server tells you whether the bind failure was a port collision or a sandbox block.
+48
View File
@@ -1,5 +1,53 @@
# TODOS
## design daemon: follow-ups (filed v1.45.0.0 via /ship review army)
### ✅ DONE (v1.45.0.0): Tighten daemon test coverage
**Resolved in commit `6b037c55` (same PR):** All 5 test gaps filled before
landing. Per-file totals after: serve 16, daemon 34, daemon-discovery 23,
feedback-roundtrip-daemon 4 = 77 (+10 from initial ship). Specifically:
- Idle-shutdown actually fires (spawn-based, daemon process observed exiting,
state file removed).
- Bare GET polling doesn't reset idle (hammers `/api/progress` in background,
daemon still idles out).
- Idle-with-active-boards extends, then force-shuts after MAX_EXTENSIONS
(with `DESIGN_DAEMON_EXTENSION_MS=1500` + `MAX_EXTENSIONS=2`).
- Concurrent `ensureDaemon()` race converges on one daemon (lock wins).
- Stale-lock reclaim (dead PID succeeds, alive unrelated PID refuses).
- Malformed-JSON + non-object + array-body + missing-html negatives for
`POST /api/boards` and `POST /boards/<id>/api/reload`.
### P3: Minor maintainability nits from /ship review
- `design/src/cli.ts` and `design/src/serve.ts` both have a small `openBrowser`
helper with identical darwin/linux/else branches. Extract a shared
`design/src/open-browser.ts`.
- `design/src/daemon-client.ts:320` (`AbortSignal.timeout(2000)`) and `:357`
(`delay(50)`) use bare numeric literals while sibling timeouts are named
constants. Promote to `SHUTDOWN_POST_TIMEOUT_MS` and `ALIVE_POLL_INTERVAL_MS`.
- `design/src/daemon-state.ts:21` `serverPath` field is written
(`daemon.ts:541`) but never read by production code. Either remove or
document the forensic intent.
### P3: Daemon scope deferred from v1.45.0.0 plan
Originally listed in the plan's "TODOs surfaced for later" section:
- Per-daemon scoped auth tokens (only relevant once a tunnel/share use case appears).
- Optional persistent board history on disk in
`~/.gstack/projects/$SLUG/designs/history/` so submitted boards survive
daemon restarts.
- Windows spawn branch lifted from browse (V1 daemon is macOS + Linux;
Windows users fall back to legacy `--no-daemon` per-process server).
- `$D board list` / `$D board stop <id>` per-board ops CLI (V1 has only
`$D daemon status` / `stop`).
- Cross-worktree daemon attach (conductor sibling worktrees of the same
repo currently each spawn their own daemon — matches browse; revisit
if it causes friction).
---
## browse server: terminal-agent teardown follow-ups (filed v1.41 via /plan-eng-review)
### ✅ DONE (v1.44.0.0): Identity-based terminal-agent kill (replace pkill regex with PID)
+1 -1
View File
@@ -1 +1 @@
1.44.1.0
1.45.0.0
+17 -5
View File
@@ -1307,8 +1307,12 @@ This command generates the board HTML, starts an HTTP server on a random port,
and opens it in the user's default browser. **Run it in the background** with `&`
because the server needs to stay running while the user interacts with the board.
Parse the port from stderr output: `SERVE_STARTED: port=XXXXX`. You need this
for the board URL and for reloading during regeneration cycles.
Parse the board URL from stderr output. Default daemon path:
`BOARD_URL: http://127.0.0.1:N/boards/<id>/` (already includes the per-board
path; use this for the AskUserQuestion URL AND as the base for the reload
endpoint). Legacy `--no-daemon` path emits `SERVE_STARTED: port=XXXXX` and
serves a single board at `/`, with reload at `/api/reload` — only relevant
when an external caller explicitly passes `--no-daemon`.
**PRIMARY WAIT: AskUserQuestion with board URL**
@@ -1316,11 +1320,14 @@ After the board is serving, use AskUserQuestion to wait for the user. Include th
board URL so they can click it if they lost the browser tab:
"I've opened a comparison board with the design variants:
http://127.0.0.1:<PORT>/ — Rate them, leave comments, remix
<BOARD_URL> — Rate them, leave comments, remix
elements you like, and click Submit when you're done. Let me know when you've
submitted your feedback (or paste your preferences here). If you clicked
Regenerate or Remix on the board, tell me and I'll generate new variants."
Substitute `<BOARD_URL>` with the URL parsed from stderr (the daemon path
emits `BOARD_URL: http://127.0.0.1:N/boards/<id>/`).
**Do NOT use AskUserQuestion to ask which variant the user prefers.** The comparison
board IS the chooser. AskUserQuestion is just the blocking wait mechanism.
@@ -1364,8 +1371,13 @@ the approved variant.
2. If `regenerateAction` is `"remix"`, read `remixSpec` (e.g. `{"layout":"A","colors":"B"}`)
3. Generate new variants with `$D iterate` or `$D variants` using updated brief
4. Create new board: `$D compare --images "..." --output "$_DESIGN_DIR/design-board.html"`
5. Reload the board in the user's browser (same tab):
`curl -s -X POST http://127.0.0.1:PORT/api/reload -H 'Content-Type: application/json' -d '{"html":"$_DESIGN_DIR/design-board.html"}'`
5. Reload the board in the user's browser (same tab) — the URL is per-board
under daemon mode, so use `<BOARD_URL>` (from the `BOARD_URL:` stderr
line) as the base:
`curl -s -X POST "${BOARD_URL}api/reload" -H 'Content-Type: application/json' -d '{"html":"$_DESIGN_DIR/design-board.html"}'`
Under `--no-daemon` the reload endpoint is `/api/reload` at the legacy
port; this path only matters if the caller explicitly opted out of the
daemon.
6. The board auto-refreshes. **AskUserQuestion again** with the same board URL to
wait for the next round of feedback. Repeat until `feedback.json` appears.
+17 -5
View File
@@ -1193,8 +1193,12 @@ This command generates the board HTML, starts an HTTP server on a random port,
and opens it in the user's default browser. **Run it in the background** with `&`
because the server needs to stay running while the user interacts with the board.
Parse the port from stderr output: `SERVE_STARTED: port=XXXXX`. You need this
for the board URL and for reloading during regeneration cycles.
Parse the board URL from stderr output. Default daemon path:
`BOARD_URL: http://127.0.0.1:N/boards/<id>/` (already includes the per-board
path; use this for the AskUserQuestion URL AND as the base for the reload
endpoint). Legacy `--no-daemon` path emits `SERVE_STARTED: port=XXXXX` and
serves a single board at `/`, with reload at `/api/reload` — only relevant
when an external caller explicitly passes `--no-daemon`.
**PRIMARY WAIT: AskUserQuestion with board URL**
@@ -1202,11 +1206,14 @@ After the board is serving, use AskUserQuestion to wait for the user. Include th
board URL so they can click it if they lost the browser tab:
"I've opened a comparison board with the design variants:
http://127.0.0.1:<PORT>/ — Rate them, leave comments, remix
<BOARD_URL> — Rate them, leave comments, remix
elements you like, and click Submit when you're done. Let me know when you've
submitted your feedback (or paste your preferences here). If you clicked
Regenerate or Remix on the board, tell me and I'll generate new variants."
Substitute `<BOARD_URL>` with the URL parsed from stderr (the daemon path
emits `BOARD_URL: http://127.0.0.1:N/boards/<id>/`).
**Do NOT use AskUserQuestion to ask which variant the user prefers.** The comparison
board IS the chooser. AskUserQuestion is just the blocking wait mechanism.
@@ -1250,8 +1257,13 @@ the approved variant.
2. If `regenerateAction` is `"remix"`, read `remixSpec` (e.g. `{"layout":"A","colors":"B"}`)
3. Generate new variants with `$D iterate` or `$D variants` using updated brief
4. Create new board: `$D compare --images "..." --output "$_DESIGN_DIR/design-board.html"`
5. Reload the board in the user's browser (same tab):
`curl -s -X POST http://127.0.0.1:PORT/api/reload -H 'Content-Type: application/json' -d '{"html":"$_DESIGN_DIR/design-board.html"}'`
5. Reload the board in the user's browser (same tab) — the URL is per-board
under daemon mode, so use `<BOARD_URL>` (from the `BOARD_URL:` stderr
line) as the base:
`curl -s -X POST "${BOARD_URL}api/reload" -H 'Content-Type: application/json' -d '{"html":"$_DESIGN_DIR/design-board.html"}'`
Under `--no-daemon` the reload endpoint is `/api/reload` at the legacy
port; this path only matters if the caller explicitly opted out of the
daemon.
6. The board auto-refreshes. **AskUserQuestion again** with the same board URL to
wait for the next round of feedback. Repeat until `feedback.json` appears.
+151 -16
View File
@@ -25,8 +25,19 @@ import { evolve } from "./evolve";
import { generateDesignToCodePrompt } from "./design-to-code";
import { serve } from "./serve";
import { gallery } from "./gallery";
import {
daemonStatus as daemonStatusClient,
ensureDaemon,
publishBoard,
shutdownDaemon,
} from "./daemon-client";
import { spawn as nodeSpawn } from "child_process";
function parseArgs(argv: string[]): { command: string; flags: Record<string, string | boolean> } {
function parseArgs(argv: string[]): {
command: string;
flags: Record<string, string | boolean>;
positionals: string[];
} {
const args = argv.slice(2); // skip bun/node and script path
if (args.length === 0) {
printUsage();
@@ -35,6 +46,7 @@ function parseArgs(argv: string[]): { command: string; flags: Record<string, str
const command = args[0];
const flags: Record<string, string | boolean> = {};
const positionals: string[] = [];
for (let i = 1; i < args.length; i++) {
const arg = args[i];
@@ -47,10 +59,12 @@ function parseArgs(argv: string[]): { command: string; flags: Record<string, str
} else {
flags[key] = true;
}
} else {
positionals.push(arg);
}
}
return { command, flags };
return { command, flags, positionals };
}
function printUsage(): void {
@@ -108,7 +122,7 @@ async function runSetup(): Promise<void> {
}
async function main(): Promise<void> {
const { command, flags } = parseArgs(process.argv);
const { command, flags, positionals } = parseArgs(process.argv);
if (!COMMANDS.has(command)) {
console.error(`Unknown command: ${command}`);
@@ -139,12 +153,24 @@ async function main(): Promise<void> {
const images = await resolveImagePaths(imagesArg);
const outputPath = (flags.output as string) || "/tmp/gstack-design-board.html";
compare({ images, output: outputPath });
// If --serve flag is set, start HTTP server for the board
// If --serve flag is set, publish the board.
// Default: ensure the persistent daemon is up, POST the board, open
// the browser, exit. The daemon survives the CLI and hosts every
// board the user has published this day at stable URLs.
// --no-daemon: legacy single-process server in serve.ts (kept for
// tests / Windows / explicit debugging).
if (flags.serve) {
await serve({
html: outputPath,
timeout: flags.timeout ? parseInt(flags.timeout as string) : 600,
});
if (flags["no-daemon"]) {
await serve({
html: outputPath,
timeout: flags.timeout ? parseInt(flags.timeout as string) : 600,
});
} else {
await publishToDaemon({
html: outputPath,
title: flags.title as string | undefined,
});
}
}
break;
}
@@ -247,11 +273,108 @@ async function main(): Promise<void> {
break;
case "serve":
await serve({
html: flags.html as string,
timeout: flags.timeout ? parseInt(flags.timeout as string) : 600,
});
if (flags["no-daemon"]) {
await serve({
html: flags.html as string,
timeout: flags.timeout ? parseInt(flags.timeout as string) : 600,
});
} else {
await publishToDaemon({
html: flags.html as string,
title: flags.title as string | undefined,
});
}
break;
case "daemon": {
// Sub-commands: `$D daemon status` and `$D daemon stop [--force]`.
const sub = positionals[0] || "status";
if (sub === "status") {
const s = await daemonStatusClient();
if (!s.running) {
console.log(JSON.stringify({ running: false }, null, 2));
process.exit(0);
}
console.log(JSON.stringify(s, null, 2));
break;
}
if (sub === "stop") {
const r = await shutdownDaemon({ force: !!flags.force });
if (r.stopped) {
console.log(JSON.stringify({ stopped: true, reason: r.reason }, null, 2));
process.exit(0);
}
console.error(
`Refused to stop daemon: ${r.reason} (activeBoards=${r.activeBoards ?? 0})`,
);
console.error(
`Submit/close active boards first, or pass --force to drop in-memory history.`,
);
process.exit(1);
}
console.error(`Unknown daemon sub-command: ${sub}. Use 'status' or 'stop'.`);
process.exit(2);
}
}
}
/**
* Default `$D compare --serve` path: ensure the persistent daemon is up,
* publish the board, open the browser to its URL, then exit. The daemon
* survives.
*
* Stderr lines (in order):
* - "DAEMON_STARTED port=N version=V" (or "DAEMON_ATTACHED port=N ..."
* if a daemon was already running)
* - "BOARD_PUBLISHED: http://127.0.0.1:N/boards/<id>/"
* - "BOARD_URL: <same url>" (alias for grep-friendliness)
* - "SERVE_STARTED: port=N html=<path>" (legacy back-compat alias for
* any external script that scraped the pre-daemon output — note the
* daemon hosts boards under /boards/<id>/, not /, so scripts that
* ALSO POSTed /api/reload at the parsed port need to switch to
* BOARD_URL + ./api/reload to work end-to-end. Emitting the legacy
* line keeps port-only consumers from breaking outright.)
*/
async function publishToDaemon(opts: { html: string; title?: string }): Promise<void> {
if (!opts.html) {
console.error("--html is required (compare --serve provides --output as the html)");
process.exit(1);
}
const ensured = await ensureDaemon({});
console.error(
`${ensured.spawned ? "DAEMON_STARTED" : "DAEMON_ATTACHED"} port=${ensured.port} version=${ensured.version}`,
);
const result = await publishBoard({
port: ensured.port,
html: opts.html,
title: opts.title,
});
console.error(`BOARD_PUBLISHED: ${result.url}`);
console.error(`BOARD_URL: ${result.url}`);
// Legacy alias so anything still grepping `SERVE_STARTED: port=` gets the
// port. The full back-compat story requires the caller to ALSO learn the
// per-board path; see publishToDaemon docstring above.
console.error(`SERVE_STARTED: port=${ensured.port} html=${opts.html}`);
console.log(JSON.stringify({ id: result.id, url: result.url, sourceDir: result.sourceDir }, null, 2));
openBrowser(result.url);
// Short-lived publisher process exits; daemon keeps serving.
}
/** Open a URL in the default browser. Stays cross-platform with serve.ts. */
function openBrowser(url: string): void {
const platform = process.platform;
let cmd: string;
if (platform === "darwin") cmd = "open";
else if (platform === "linux") cmd = "xdg-open";
else {
console.error(`Open this URL in your browser: ${url}`);
return;
}
try {
const child = nodeSpawn(cmd, [url], { stdio: "ignore", detached: true });
child.unref();
} catch {
console.error(`Open this URL in your browser: ${url}`);
}
}
@@ -280,7 +403,19 @@ async function resolveImagePaths(input: string): Promise<string[]> {
return input.split(",").map(p => p.trim());
}
main().catch(err => {
console.error(err.message || err);
process.exit(1);
});
// Self-execution shortcut: when invoked with --daemon-mode, this same
// binary runs as the persistent design daemon instead of the CLI. Keeps
// the production install to a single executable; daemon-client.ts spawns
// `<this binary> --daemon-mode` (or `bun run cli.ts --daemon-mode` in dev)
// rather than relying on a separate daemon.ts file at a known path.
if (process.argv.includes("--daemon-mode")) {
const { start } = await import("./daemon");
start();
// start() binds Bun.serve and registers signal handlers; this branch
// never falls through to main(). Process stays alive on the bound port.
} else {
main().catch((err) => {
console.error(err.message || err);
process.exit(1);
});
}
+9 -4
View File
@@ -36,8 +36,8 @@ export const COMMANDS = new Map<string, {
}],
["compare", {
description: "Generate HTML comparison board for user review",
usage: "compare --images /path/*.png --output /path/board.html [--serve]",
flags: ["--images", "--output", "--serve", "--timeout"],
usage: "compare --images /path/*.png --output /path/board.html [--serve [--no-daemon] [--title \"...\"]]",
flags: ["--images", "--output", "--serve", "--no-daemon", "--title", "--timeout"],
}],
["diff", {
description: "Visual diff between two mockups",
@@ -71,8 +71,13 @@ export const COMMANDS = new Map<string, {
}],
["serve", {
description: "Serve comparison board over HTTP and collect user feedback",
usage: "serve --html /path/board.html [--timeout 600]",
flags: ["--html", "--timeout"],
usage: "serve --html /path/board.html [--no-daemon] [--title \"...\"] [--timeout 600]",
flags: ["--html", "--no-daemon", "--title", "--timeout"],
}],
["daemon", {
description: "Manage the persistent design board daemon (sub-commands: status, stop)",
usage: "daemon status | daemon stop [--force]",
flags: ["--force"],
}],
["setup", {
description: "Guided API key setup + smoke test",
+17 -6
View File
@@ -391,6 +391,17 @@ export function generateCompareHtml(images: string[]): string {
<div id="feedback-result"></div>
<script>
// Feature-detect: are we being served over HTTP (by serve.ts or the
// daemon), or opened directly as a file:// URL? In file:// mode the
// board JS falls through to a DOM-only success path with no server
// round-trips. Using location.protocol instead of an injected global
// means the same generated HTML works at both / (legacy --no-daemon)
// and /boards/<id>/ (daemon) — relative URLs resolve against
// location.pathname in both cases.
function hasServer() {
return location.protocol === 'http:' || location.protocol === 'https:';
}
// View toggle
document.querySelectorAll('.view-toggle button').forEach(function(btn) {
btn.addEventListener('click', function() {
@@ -465,8 +476,8 @@ export function generateCompareHtml(images: string[]): string {
});
function postFeedback(feedback) {
if (!window.__GSTACK_SERVER_URL) return Promise.resolve(null);
return fetch(window.__GSTACK_SERVER_URL + '/api/feedback', {
if (!hasServer()) return Promise.resolve(null);
return fetch('./api/feedback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(feedback),
@@ -509,7 +520,7 @@ export function generateCompareHtml(images: string[]): string {
}
function startProgressPolling() {
if (!window.__GSTACK_SERVER_URL) return;
if (!hasServer()) return;
var pollCount = 0;
var maxPolls = 150; // 5 min at 2s intervals
var pollInterval = setInterval(function() {
@@ -523,7 +534,7 @@ export function generateCompareHtml(images: string[]): string {
'</div>';
return;
}
fetch(window.__GSTACK_SERVER_URL + '/api/progress')
fetch('./api/progress')
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.status === 'serving') {
@@ -563,7 +574,7 @@ export function generateCompareHtml(images: string[]): string {
postFeedback(feedback).then(function(result) {
if (result && result.received) {
showRegeneratingState();
} else if (window.__GSTACK_SERVER_URL) {
} else if (hasServer()) {
showPostFailure(feedback);
}
});
@@ -578,7 +589,7 @@ export function generateCompareHtml(images: string[]): string {
postFeedback(feedback).then(function(result) {
if (result && result.received) {
showPostSubmitState();
} else if (window.__GSTACK_SERVER_URL) {
} else if (hasServer()) {
showPostFailure(feedback);
} else {
// DOM-only mode (legacy / test)
+419
View File
@@ -0,0 +1,419 @@
/**
* CLI-side client for the design daemon.
*
* Responsible for the lifecycle dance that `$D compare --serve` (default
* path) goes through:
*
* ensureDaemon() → publishBoard(html, opts) → openBrowser(url) → exit 0
*
* Mirrors browse/src/cli.ts:317-415 — same health-check-first attach
* decision, same fs.openSync('wx') lock, same re-read-under-lock guard.
* Adds two design-specific safety properties Codex flagged on the daemon
* plan:
*
* 1. Identity verification before any SIGTERM. Browse signals on PID
* alone; here we require the cmdline to contain CMDLINE_MARKER so a
* stale state file pointing at a reused PID doesn't kill an
* unrelated process.
*
* 2. Refuse-to-kill on version mismatch with active boards. Browse will
* restart on version drift; here in-memory boards would be lost, so
* we exit 1 with a user-actionable message instead of silent loss.
*
* Spawn uses Node's child_process.spawn with detached: true + stdio
* pointed at a log file. Bun.spawn().unref() has macOS session-detach
* quirks browse already discovered (browse/src/cli.ts:225-275).
*/
import { spawn as nodeSpawn } from "child_process";
import fs from "fs";
import path from "path";
import { setTimeout as delay } from "timers/promises";
import {
acquireLock,
CMDLINE_MARKER,
healthCheck,
isProcessAlive,
readStateFile,
readVersionString,
resolveLockFilePath,
resolveStartupLogPath,
resolveStateFilePath,
verifyIdentity,
} from "./daemon-state";
const MAX_START_WAIT_MS = parseInt(
process.env.DESIGN_DAEMON_START_TIMEOUT_MS || "8000",
10,
);
const POLL_INTERVAL_MS = 100;
const SIGTERM_GRACE_MS = 2000;
export interface EnsureDaemonOptions {
/** Default: package version. Used for version-match check. */
version?: string;
/** Default: `<repo>/design/src/daemon.ts`. */
daemonScript?: string;
/** Extra env vars passed to the spawned daemon. */
daemonEnv?: Record<string, string>;
/** Print noisy progress to stderr. Default true. */
verbose?: boolean;
/**
* Override the state-file path. Default: resolveStateFilePath() (env
* DESIGN_DAEMON_STATE_FILE or .gstack/design.json under the git root /
* cwd). Tests inject a per-test path; the same path is forwarded to the
* spawned daemon via env so client + daemon agree.
*/
stateFile?: string;
}
export interface EnsureDaemonResult {
port: number;
version: string;
spawned: boolean;
}
function log(verbose: boolean, msg: string): void {
if (verbose) process.stderr.write(`[design-daemon] ${msg}\n`);
}
/**
* Ensure a design daemon is reachable on the project's state file. Returns
* the port to talk to. Spawns a new daemon under an exclusive lock when
* needed; attaches to an existing healthy daemon otherwise.
*
* Exits with code 1 (not throws) on the refuse-kill-with-active-boards
* branch — that's a user-actionable situation, not a programming error.
*/
export async function ensureDaemon(
opts: EnsureDaemonOptions = {},
): Promise<EnsureDaemonResult> {
const verbose = opts.verbose !== false;
const expectedVersion = opts.version ?? readPackageVersion();
const stateFile = opts.stateFile ?? resolveStateFilePath();
const existing = readStateFile(stateFile);
if (existing) {
const health = await healthCheck(existing.port);
if (health) {
if (health.version === expectedVersion) {
log(verbose, `attached to existing daemon pid=${existing.pid} port=${existing.port}`);
return { port: existing.port, version: health.version, spawned: false };
}
// Version mismatch: refuse if active boards exist (Codex finding).
if (health.activeBoards > 0) {
process.stderr.write(
`[design-daemon] WARNING: existing daemon is gstack ${health.version}; this CLI is ${expectedVersion}.\n` +
`[design-daemon] ${health.activeBoards} active board(s) detected. Refusing to auto-kill.\n` +
`[design-daemon] Submit or close the open boards, then re-run.\n` +
`[design-daemon] Or force restart: $D daemon stop (will drop in-memory history).\n`,
);
process.exit(1);
}
// No active boards — safe to graceful-shutdown and respawn.
log(verbose, `daemon version mismatch (${health.version} vs ${expectedVersion}); shutting down`);
await gracefulShutdownExistingDaemon(existing.port);
await killByPidWithIdentity(existing.pid, existing.cmdlineMarker, verbose);
} else {
// State file points at an unresponsive port. Either the daemon
// crashed or the PID got reused. Identity-verify before any SIGTERM
// so we don't kill an unrelated process (Codex finding).
log(verbose, `state file present (pid=${existing.pid}) but /health unresponsive`);
await killByPidWithIdentity(existing.pid, existing.cmdlineMarker, verbose);
}
}
// Spawn under exclusive lock; re-read state INSIDE the lock so we don't
// race a concurrent CLI that won the lock first.
const lockPath = resolveLockFilePath(stateFile);
const release = acquireLock(lockPath);
if (!release) {
// Another process is starting the daemon. Wait for it.
log(verbose, "another CLI is spawning the daemon; waiting…");
const start = Date.now();
while (Date.now() - start < MAX_START_WAIT_MS) {
const fresh = readStateFile(stateFile);
if (fresh) {
const h = await healthCheck(fresh.port);
if (h) return { port: fresh.port, version: h.version, spawned: false };
}
await delay(POLL_INTERVAL_MS);
}
throw new Error("Timed out waiting for concurrent daemon spawn");
}
try {
// Re-read under lock. Another caller may have already finished spawning
// between our first check and our lock acquisition.
const fresh = readStateFile(stateFile);
if (fresh) {
const h = await healthCheck(fresh.port);
if (h && h.version === expectedVersion) {
log(verbose, `another CLI won the lock; attaching pid=${fresh.pid} port=${fresh.port}`);
return { port: fresh.port, version: h.version, spawned: false };
}
}
log(verbose, "spawning new daemon");
const port = await spawnDaemon({
script: opts.daemonScript,
env: { ...opts.daemonEnv, DESIGN_DAEMON_STATE_FILE: stateFile },
stateFile,
expectedVersion,
});
return { port, version: expectedVersion, spawned: true };
} finally {
release();
}
}
/**
* Publish a board to the daemon and return its URL. Wraps the HTTP POST
* with a friendlier error surface than raw fetch.
*/
export interface PublishBoardOptions {
port: number;
html: string;
title?: string;
publisherPid?: number;
}
export interface PublishBoardResult {
id: string;
url: string;
sourceDir: string;
}
export async function publishBoard(opts: PublishBoardOptions): Promise<PublishBoardResult> {
const body: Record<string, unknown> = {
html: opts.html,
publisherPid: opts.publisherPid ?? process.pid,
};
if (opts.title) body.title = opts.title;
const resp = await fetch(`http://127.0.0.1:${opts.port}/api/boards`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!resp.ok) {
let errText: string;
try {
const j = (await resp.json()) as { error?: string; existing?: { id: string; url: string } };
if (j.existing) {
// 409: surface the existing-board URL so the caller can reuse it
return { id: j.existing.id, url: j.existing.url, sourceDir: "" };
}
errText = j.error || `HTTP ${resp.status}`;
} catch {
errText = `HTTP ${resp.status}`;
}
throw new Error(`Daemon refused publish: ${errText}`);
}
return (await resp.json()) as PublishBoardResult;
}
// ─── Internals ───────────────────────────────────────────────────
function readPackageVersion(): string {
return readVersionString();
}
function defaultDaemonScript(): string {
// design/src/daemon-client.ts → daemon.ts is a sibling. Only used in dev
// when this process is `bun run cli.ts`; the compiled-binary path
// self-execs instead (see resolveSpawnCommand).
return path.join(import.meta.dir, "daemon.ts");
}
/**
* Compute the argv to spawn the daemon. Two modes:
*
* Compiled binary (`design/dist/design`): re-exec ourselves with
* --daemon-mode. process.execPath IS the compiled design binary;
* spawning it again with the flag runs the daemon (see the
* --daemon-mode branch at the bottom of cli.ts).
*
* Dev (`bun run design/src/cli.ts`): process.execPath is bun, so we
* invoke `bun run <daemon.ts> --marker ...` directly.
*
* Tests can override the dev script via opts.script.
*/
function resolveSpawnCommand(scriptOverride: string | undefined): {
command: string;
args: string[];
} {
const execBase = path.basename(process.execPath).toLowerCase();
const isCompiledHost = execBase !== "bun" && execBase !== "bun.exe" && execBase !== "node";
if (isCompiledHost && !scriptOverride) {
return {
command: process.execPath,
args: ["--daemon-mode", "--marker", CMDLINE_MARKER],
};
}
const script = scriptOverride ?? defaultDaemonScript();
return {
command: "bun",
args: ["run", script, "--marker", CMDLINE_MARKER],
};
}
interface SpawnDaemonOpts {
script?: string;
env?: Record<string, string>;
stateFile: string;
expectedVersion: string;
}
async function spawnDaemon(opts: SpawnDaemonOpts): Promise<number> {
const logPath = resolveStartupLogPath();
fs.mkdirSync(path.dirname(logPath), { recursive: true });
// Truncate the startup log on each spawn so a later read finds only THIS
// attempt's output (mirrors browse's per-spawn log truncation).
fs.writeFileSync(logPath, "");
const logFd = fs.openSync(logPath, "a");
const { command, args } = resolveSpawnCommand(opts.script);
const child = nodeSpawn(command, args, {
detached: true,
stdio: ["ignore", logFd, logFd],
env: {
...process.env,
DESIGN_DAEMON_VERSION: opts.expectedVersion,
...(opts.env ?? {}),
},
});
child.unref();
fs.closeSync(logFd);
// Poll the state file + /health until the daemon is up, or until timeout.
const deadline = Date.now() + MAX_START_WAIT_MS;
while (Date.now() < deadline) {
const fresh = readStateFile(opts.stateFile);
if (fresh) {
const h = await healthCheck(fresh.port);
if (h) return fresh.port;
}
await delay(POLL_INTERVAL_MS);
}
// Timed out — surface the startup log so the user sees the actual error
// instead of "daemon failed silently."
let tail = "";
try {
tail = fs.readFileSync(logPath, "utf-8").trim();
} catch {
// log file may not exist
}
throw new Error(
`Design daemon failed to start within ${MAX_START_WAIT_MS}ms.\n` +
`Startup log (${logPath}):\n${tail || "(empty)"}`,
);
}
async function gracefulShutdownExistingDaemon(port: number): Promise<void> {
try {
await fetch(`http://127.0.0.1:${port}/shutdown`, {
method: "POST",
signal: AbortSignal.timeout(2000),
});
} catch {
// Daemon may have already exited or be unresponsive — fall through
// to the SIGTERM path with identity verification.
}
}
/**
* Send SIGTERM (then SIGKILL) to `pid`, but ONLY if the running cmdline
* contains `marker`. Prevents a stale state file from causing us to signal
* an unrelated process that inherited the PID.
*/
async function killByPidWithIdentity(
pid: number,
marker: string,
verbose: boolean,
): Promise<void> {
if (!pid || pid <= 0) return;
if (!isProcessAlive(pid)) return;
if (!verifyIdentity(pid, marker || CMDLINE_MARKER)) {
log(
verbose,
`pid ${pid} is alive but cmdline doesn't match marker '${marker || CMDLINE_MARKER}'; skipping signal (possible PID reuse)`,
);
return;
}
try {
process.kill(pid, "SIGTERM");
} catch {
// already gone
return;
}
// Give it a grace period; SIGKILL if still alive AND still ours.
const deadline = Date.now() + SIGTERM_GRACE_MS;
while (Date.now() < deadline) {
if (!isProcessAlive(pid)) return;
await delay(50);
}
if (isProcessAlive(pid) && verifyIdentity(pid, marker || CMDLINE_MARKER)) {
log(verbose, `pid ${pid} survived SIGTERM; SIGKILL`);
try {
process.kill(pid, "SIGKILL");
} catch {
// raced with exit
}
}
}
/**
* Public: $D daemon stop. Posts /shutdown if no active boards; otherwise
* reports refusal. Used by the CLI sub-command (next commit).
*/
export async function shutdownDaemon(opts: { force?: boolean } = {}): Promise<{
stopped: boolean;
reason?: string;
activeBoards?: number;
}> {
const stateFile = resolveStateFilePath();
const existing = readStateFile(stateFile);
if (!existing) return { stopped: false, reason: "no daemon running" };
const health = await healthCheck(existing.port);
if (!health) {
// unresponsive: try SIGTERM via identity-checked path
await killByPidWithIdentity(existing.pid, existing.cmdlineMarker, true);
return { stopped: true, reason: "unresponsive daemon killed via SIGTERM" };
}
if (health.activeBoards > 0 && !opts.force) {
return {
stopped: false,
reason: "active boards present",
activeBoards: health.activeBoards,
};
}
await gracefulShutdownExistingDaemon(existing.port);
// Best-effort: SIGTERM if /shutdown didn't take effect
if (isProcessAlive(existing.pid)) {
await killByPidWithIdentity(existing.pid, existing.cmdlineMarker, true);
}
return { stopped: true };
}
/** $D daemon status — for the CLI sub-command. */
export async function daemonStatus(): Promise<
| { running: false }
| { running: true; port: number; pid: number; version: string; boards: number; activeBoards: number; uptime: number }
> {
const existing = readStateFile();
if (!existing) return { running: false };
const h = await healthCheck(existing.port);
if (!h) return { running: false };
return {
running: true,
port: existing.port,
pid: existing.pid,
version: h.version,
boards: h.boards,
activeBoards: h.activeBoards,
uptime: h.uptime,
};
}
+220
View File
@@ -0,0 +1,220 @@
/**
* Pure utilities for design-daemon discovery.
*
* Shared between daemon.ts (writes/removes the state file) and
* daemon-client.ts (reads state, decides spawn-vs-attach). Mirrors
* browse/src/cli.ts:109-315 — same atomic-write + fs.openSync 'wx' lock
* pattern, with an added cmdline-based identity check to guard against
* SIGTERM hitting a reused PID (Codex finding on the daemon plan).
*/
import { execFileSync } from "child_process";
import fs from "fs";
import os from "os";
import path from "path";
export interface DaemonState {
pid: number;
port: number;
startedAt: string; // ISO 8601
version: string;
serverPath: string;
cmdlineMarker: string;
}
// String we grep for in the spawned daemon's cmdline to confirm a pid is
// ours before sending any signal. Must appear in argv at spawn time.
export const CMDLINE_MARKER = "gstack-design-daemon";
export function resolveStateFilePath(): string {
// Env override has highest precedence so tests can point both client and
// spawned daemon at a per-test path without a shared cwd.
const envOverride = process.env.DESIGN_DAEMON_STATE_FILE;
if (envOverride) return envOverride;
try {
const root = execFileSync("git", ["rev-parse", "--show-toplevel"], {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
if (root) return path.join(root, ".gstack", "design.json");
} catch {
// not in a git repo — fall through
}
return path.join(process.cwd(), ".gstack", "design.json");
}
export function resolveLockFilePath(stateFile: string = resolveStateFilePath()): string {
return `${stateFile}.lock`;
}
export function resolveDaemonLogPath(): string {
return path.join(os.homedir(), ".gstack", "design-daemon.log");
}
export function resolveStartupLogPath(): string {
return path.join(os.homedir(), ".gstack", "design-daemon-startup.log");
}
/**
* Read the gstack version both client and daemon should agree on. Looks
* (in order): DESIGN_DAEMON_VERSION env, design/dist/.version baked at
* build time, VERSION at the source-tree root (dev), then "unknown".
*
* Compiled binaries lose the source-tree relative path at runtime, so we
* try the dist/.version sidecar (which build.sh writes) before falling
* back. This keeps client.expectedVersion and daemon.VERSION coherent.
*/
export function readVersionString(): string {
const env = process.env.DESIGN_DAEMON_VERSION;
if (env) return env;
const candidates = [
// Compiled binary: design/dist/design lives alongside design/dist/.version
path.join(path.dirname(process.execPath), ".version"),
// Dev: design/src/* → repo root is two levels up
path.join(import.meta.dir, "..", "..", "VERSION"),
// Defensive: design/dist sibling of source tree
path.join(import.meta.dir, "..", "dist", ".version"),
];
for (const p of candidates) {
try {
const v = fs.readFileSync(p, "utf-8").trim();
if (v) return v;
} catch {
// try next
}
}
return "unknown";
}
export function readStateFile(stateFile: string = resolveStateFilePath()): DaemonState | null {
try {
return JSON.parse(fs.readFileSync(stateFile, "utf-8")) as DaemonState;
} catch {
return null;
}
}
export function writeStateFile(
state: DaemonState,
stateFile: string = resolveStateFilePath(),
): void {
fs.mkdirSync(path.dirname(stateFile), { recursive: true });
const tmp = `${stateFile}.tmp.${process.pid}.${Math.random().toString(36).slice(2)}`;
fs.writeFileSync(tmp, JSON.stringify(state, null, 2), { mode: 0o600 });
fs.renameSync(tmp, stateFile);
}
export function removeStateFile(stateFile: string = resolveStateFilePath()): void {
try {
fs.unlinkSync(stateFile);
} catch {
// already gone
}
}
export interface HealthOk {
ok: true;
version: string;
uptime: number;
boards: number;
activeBoards: number;
}
export async function healthCheck(
port: number,
timeoutMs: number = 2000,
): Promise<HealthOk | null> {
try {
const resp = await fetch(`http://127.0.0.1:${port}/health`, {
signal: AbortSignal.timeout(timeoutMs),
});
if (!resp.ok) return null;
const body = (await resp.json()) as Partial<HealthOk> | null;
if (body && body.ok === true && typeof body.version === "string") {
return body as HealthOk;
}
return null;
} catch {
return null;
}
}
export function isProcessAlive(pid: number): boolean {
if (!pid || pid <= 0) return false;
try {
process.kill(pid, 0);
return true;
} catch (e: unknown) {
// EPERM means it exists, we just can't signal it. ESRCH means it's gone.
const code = (e as NodeJS.ErrnoException | undefined)?.code;
return code === "EPERM";
}
}
/**
* Read the cmdline of a running process. Returns "" on any error.
* Linux: /proc/<pid>/cmdline (NUL-separated argv). macOS: `ps -p PID -o command=`.
*/
export function readCmdline(pid: number): string {
if (!isProcessAlive(pid)) return "";
try {
if (process.platform === "linux") {
const raw = fs.readFileSync(`/proc/${pid}/cmdline`, "utf-8");
return raw.replace(/\0/g, " ").trim();
}
if (process.platform === "darwin") {
return execFileSync("ps", ["-p", String(pid), "-o", "command="], {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
}
return "";
} catch {
return "";
}
}
/**
* True only when the process at `pid` has `marker` in its cmdline. Used to
* avoid SIGTERMing an unrelated process that happens to have inherited a
* PID from a stale state file (the Codex PID-reuse concern). On systems
* where readCmdline is unsupported (or fails), this returns false — safer
* to skip the signal than to risk killing the wrong process.
*/
export function verifyIdentity(pid: number, marker: string): boolean {
if (!marker) return false;
return readCmdline(pid).includes(marker);
}
/**
* Acquire an exclusive lock on `lockPath`. Returns a release function, or
* null if held by another live process. Stale locks (PID dead) are reclaimed
* once; if reclaim also fails the caller waits and retries via state re-read.
*/
export function acquireLock(lockPath: string): (() => void) | null {
try {
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
// 'wx' = create exclusive, fail if exists. Atomic check-and-create.
const fd = fs.openSync(lockPath, "wx");
fs.writeSync(fd, `${process.pid}\n`);
fs.closeSync(fd);
return () => {
try {
fs.unlinkSync(lockPath);
} catch {
// already gone
}
};
} catch {
// Held — check if holder is alive
try {
const holderPid = parseInt(fs.readFileSync(lockPath, "utf-8").trim(), 10);
if (holderPid && isProcessAlive(holderPid)) return null;
// Stale, reclaim
fs.unlinkSync(lockPath);
return acquireLock(lockPath);
} catch {
return null;
}
}
}
+582
View File
@@ -0,0 +1,582 @@
/**
* Persistent design board daemon.
*
* One process hosts many boards under /boards/<id>/. Spawned by
* daemon-client.ts when no live daemon is found on the project's discovery
* file (.gstack/design.json). Replaces the per-invocation server in
* serve.ts as the default for `$D compare --serve`; serve.ts is kept as
* the --no-daemon legacy/test path.
*
* Endpoints (see plan docs/designs path for full table):
* GET / index of boards
* GET /health liveness + version (unauth)
* POST /api/boards publish a new board
* POST /shutdown graceful exit (refused if active)
* GET /boards/<id> 301 → /boards/<id>/
* GET /boards/<id>/ render board HTML
* GET /boards/<id>/api/progress state machine status
* POST /boards/<id>/api/feedback submit/regenerate
* POST /boards/<id>/api/reload swap board HTML
*
* Lifecycle:
* start → bind 127.0.0.1:N → write state file → serve until 24h idle or
* explicit /shutdown → remove state file → exit 0
*
* The daemon refuses /shutdown when boards are non-done; the idle timer
* extends rather than killing in that case (up to a 28h hard ceiling).
* Both are Codex-flagged guards against silent loss of in-memory history.
*/
import fs from "fs";
import path from "path";
import {
CMDLINE_MARKER,
DaemonState,
readVersionString,
removeStateFile,
resolveDaemonLogPath,
writeStateFile,
} from "./daemon-state";
// ─── Tunables (env overrides for tests) ──────────────────────────
const DEFAULT_IDLE_MS = 24 * 60 * 60 * 1000; // 24h
const IDLE_MS = parseInt(
process.env.DESIGN_DAEMON_IDLE_MS || String(DEFAULT_IDLE_MS),
10,
);
const IDLE_EXTENSION_MS = parseInt(
process.env.DESIGN_DAEMON_EXTENSION_MS || String(60 * 60 * 1000), // 1h
10,
);
const MAX_EXTENSIONS = parseInt(process.env.DESIGN_DAEMON_MAX_EXTENSIONS || "4", 10);
const IDLE_CHECK_INTERVAL_MS = parseInt(
process.env.DESIGN_DAEMON_CHECK_MS || "60000",
10,
);
const MAX_BOARDS = parseInt(process.env.DESIGN_DAEMON_MAX_BOARDS || "50", 10);
const VERSION = readVersionString();
// ─── Per-board state ─────────────────────────────────────────────
export type BoardState = "serving" | "regenerating" | "done";
export interface Board {
id: string;
htmlContent: string;
sourceDir: string; // realpath of the dir feedback files write to
allowedDir: string; // realpath anchor for path-traversal guard
state: BoardState;
publishedAt: number;
lastTouched: number;
publisherPid: number;
title?: string;
}
// In-memory: keyed by board id.
const boards = new Map<string, Board>();
// Per-board mutex chain — serializes feedback POST vs reload POST on the
// same board so the daemon doesn't race a state mutation against an HTML swap.
const boardMutex = new Map<string, Promise<void>>();
let lastMeaningfulActivity = Date.now();
let idleExtensions = 0;
let shuttingDown = false;
let serverRef: ReturnType<typeof Bun.serve> | null = null;
let idleInterval: ReturnType<typeof setInterval> | null = null;
const startTime = Date.now();
const daemonLog = openDaemonLog();
function openDaemonLog(): fs.WriteStream | null {
try {
const p = resolveDaemonLogPath();
fs.mkdirSync(path.dirname(p), { recursive: true });
return fs.createWriteStream(p, { flags: "a" });
} catch {
return null;
}
}
function dlog(...args: unknown[]): void {
const line = `[${new Date().toISOString()}] ${args.map(String).join(" ")}\n`;
if (daemonLog) daemonLog.write(line);
process.stderr.write(line);
}
// ─── Helpers ─────────────────────────────────────────────────────
function newBoardId(): string {
const now = new Date();
const y = now.getUTCFullYear().toString().padStart(4, "0");
const mo = (now.getUTCMonth() + 1).toString().padStart(2, "0");
const d = now.getUTCDate().toString().padStart(2, "0");
const hh = now.getUTCHours().toString().padStart(2, "0");
const mm = now.getUTCMinutes().toString().padStart(2, "0");
const ss = now.getUTCSeconds().toString().padStart(2, "0");
const rand = Math.random().toString(36).slice(2, 8).padEnd(6, "0");
return `b-${y}${mo}${d}-${hh}${mm}${ss}-${rand}`;
}
async function withBoardMutex<T>(id: string, fn: () => Promise<T>): Promise<T> {
const prev = boardMutex.get(id) || Promise.resolve();
let release!: () => void;
const next = new Promise<void>((r) => {
release = r;
});
boardMutex.set(id, prev.then(() => next));
await prev;
try {
return await fn();
} finally {
release();
if (boardMutex.get(id) === next) boardMutex.delete(id);
}
}
function markMeaningfulActivity(): void {
lastMeaningfulActivity = Date.now();
idleExtensions = 0;
}
function nonDoneCount(): number {
let n = 0;
for (const b of boards.values()) if (b.state !== "done") n += 1;
return n;
}
function hasActiveBoards(): boolean {
return nonDoneCount() > 0;
}
// LRU eviction. Prefers `done` boards as victims so an active regen doesn't
// vanish mid-flight. Returns the evicted id, or null when the map fits.
function evictOne(): string | null {
if (boards.size <= MAX_BOARDS) return null;
let oldestDone: Board | null = null;
let oldestAny: Board | null = null;
for (const b of boards.values()) {
if (b.state === "done") {
if (!oldestDone || b.lastTouched < oldestDone.lastTouched) oldestDone = b;
}
if (!oldestAny || b.lastTouched < oldestAny.lastTouched) oldestAny = b;
}
const victim = oldestDone || oldestAny;
if (!victim) return null;
boards.delete(victim.id);
boardMutex.delete(victim.id);
dlog(`evicted board ${victim.id} state=${victim.state}`);
return victim.id;
}
function evictUntilUnderCap(): void {
while (boards.size > MAX_BOARDS) {
if (!evictOne()) break;
}
}
function findActiveBoardForSourceDir(sourceDir: string): Board | null {
for (const b of boards.values()) {
if (b.sourceDir === sourceDir && b.state !== "done") return b;
}
return null;
}
function escapeHtml(s: string): string {
return s.replace(/[&<>"']/g, (c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]!),
);
}
// ─── Shutdown ─────────────────────────────────────────────────────
async function gracefulShutdown(exitCode = 0): Promise<void> {
if (shuttingDown) return;
shuttingDown = true;
dlog(`shutting down boards=${boards.size} code=${exitCode}`);
if (idleInterval) clearInterval(idleInterval);
try {
serverRef?.stop();
} catch {
// already stopped
}
removeStateFile();
if (daemonLog) daemonLog.end();
setTimeout(() => process.exit(exitCode), 50);
}
export function idleCheckTick(): void {
if (shuttingDown) return;
const idle = Date.now() - lastMeaningfulActivity;
if (idle < IDLE_MS) return;
if (hasActiveBoards()) {
if (idleExtensions >= MAX_EXTENSIONS) {
dlog(`idle past hard ceiling with ${nonDoneCount()} active boards — forcing shutdown`);
gracefulShutdown(0);
return;
}
idleExtensions += 1;
// Push lastMeaningfulActivity forward by an extension window without
// marking real activity (so the count stays correct).
lastMeaningfulActivity = Date.now() - IDLE_MS + IDLE_EXTENSION_MS;
dlog(
`idle with ${nonDoneCount()} active boards — extending ${IDLE_EXTENSION_MS / 60000}min (${idleExtensions}/${MAX_EXTENSIONS})`,
);
return;
}
dlog(`idle for ${Math.floor(idle / 1000)}s — shutting down`);
gracefulShutdown(0);
}
// ─── Handlers ─────────────────────────────────────────────────────
function handleHealth(): Response {
return Response.json({
ok: true,
version: VERSION,
uptime: Math.floor((Date.now() - startTime) / 1000),
boards: boards.size,
activeBoards: nonDoneCount(),
});
}
function handleIndex(): Response {
const sorted = [...boards.values()].sort((a, b) => b.publishedAt - a.publishedAt);
const rows = sorted
.map((b) => {
const ts = new Date(b.publishedAt).toISOString();
const titleSuffix = b.title ? `${escapeHtml(b.title)}` : "";
return `<li><a href="/boards/${b.id}/">${b.id}</a> <span class="state state-${b.state}">${b.state}</span> <time>${ts}</time>${titleSuffix}</li>`;
})
.join("\n");
const empty = `<p class="empty">No boards yet. Run <code>$D compare --serve</code> to publish one.</p>`;
const list = sorted.length === 0 ? empty : `<ul>\n${rows}\n</ul>`;
const html = `<!DOCTYPE html><html lang="en"><head>
<meta charset="utf-8"><title>gstack design boards</title><style>
body{font:14px/1.5 -apple-system,system-ui,sans-serif;max-width:720px;margin:32px auto;padding:0 16px;color:#1a1a1a}
h1{font-size:20px;margin-bottom:4px}
.meta{color:#666;margin-bottom:24px;font-size:13px}
ul{padding:0;list-style:none}
li{padding:10px 0;border-bottom:1px solid #eee;display:flex;align-items:center;gap:12px;flex-wrap:wrap}
a{color:#0070f3;text-decoration:none;font-family:ui-monospace,monospace}
a:hover{text-decoration:underline}
.state{font-size:11px;padding:2px 8px;border-radius:10px;background:#eef;color:#335}
.state-done{background:#efe;color:#353}
.state-regenerating{background:#ffe;color:#553}
time{color:#888;font-size:12px}
.empty{color:#888;font-style:italic}
code{font-family:ui-monospace,monospace;background:#f5f5f5;padding:2px 6px;border-radius:3px}
</style></head><body>
<h1>gstack design boards</h1>
<p class="meta">daemon up ${Math.floor((Date.now() - startTime) / 1000)}s · ${boards.size} board(s) · ${nonDoneCount()} active</p>
${list}
</body></html>`;
return new Response(html, { headers: { "Content-Type": "text/html; charset=utf-8" } });
}
async function handlePublish(req: Request, origin: string): Promise<Response> {
let body: any;
try {
body = await req.json();
} catch {
return Response.json({ error: "Invalid JSON" }, { status: 400 });
}
if (!body || typeof body !== "object") {
return Response.json({ error: "Expected JSON object" }, { status: 400 });
}
const htmlPath = typeof body.html === "string" ? body.html : "";
if (!htmlPath) return Response.json({ error: "Missing 'html' field" }, { status: 400 });
if (!fs.existsSync(htmlPath)) {
return Response.json({ error: `HTML file not found: ${htmlPath}` }, { status: 400 });
}
let resolvedHtml: string;
let sourceDir: string;
try {
resolvedHtml = fs.realpathSync(path.resolve(htmlPath));
sourceDir = fs.realpathSync(path.dirname(resolvedHtml));
} catch (e: any) {
return Response.json({ error: `Cannot resolve path: ${e.message}` }, { status: 400 });
}
if (!fs.statSync(resolvedHtml).isFile()) {
return Response.json(
{ error: `'html' must be a file, not a directory: ${htmlPath}` },
{ status: 400 },
);
}
// sourceDir comes from realpath(html), not from the body — Codex finding:
// body-supplied sourceDir is a local trust boundary the daemon shouldn't cross.
const existing = findActiveBoardForSourceDir(sourceDir);
if (existing) {
return Response.json(
{
error: "Source directory already in use by an active board",
existing: {
id: existing.id,
url: `${origin}/boards/${existing.id}/`,
state: existing.state,
},
},
{ status: 409 },
);
}
if (nonDoneCount() >= MAX_BOARDS) {
return Response.json(
{
error: `Cannot publish: ${MAX_BOARDS} non-done boards already exist. Submit or close some first.`,
},
{ status: 503 },
);
}
const id = newBoardId();
const htmlContent = fs.readFileSync(resolvedHtml, "utf-8");
const now = Date.now();
const board: Board = {
id,
htmlContent,
sourceDir,
allowedDir: sourceDir,
state: "serving",
publishedAt: now,
lastTouched: now,
publisherPid: typeof body.publisherPid === "number" ? body.publisherPid : 0,
title: typeof body.title === "string" ? body.title : undefined,
};
boards.set(id, board);
evictUntilUnderCap();
markMeaningfulActivity();
dlog(`published board ${id} sourceDir=${sourceDir} pid=${board.publisherPid}`);
return Response.json({
id,
url: `${origin}/boards/${id}/`,
sourceDir,
});
}
function handleBoardGet(board: Board): Response {
board.lastTouched = Date.now();
// No __GSTACK_SERVER_URL injection — board JS uses relative URLs that
// resolve against /boards/<id>/ (the trailing slash is load-bearing here;
// the 301 from the bare /boards/<id> form ensures it).
return new Response(board.htmlContent, {
headers: { "Content-Type": "text/html; charset=utf-8" },
});
}
function handleBoardProgress(board: Board): Response {
// NOT meaningful activity — bare progress polling shouldn't keep the
// daemon alive forever (Codex finding on idle-immortality).
board.lastTouched = Date.now();
return Response.json({ status: board.state });
}
async function handleBoardFeedback(board: Board, req: Request): Promise<Response> {
let body: any;
try {
body = await req.json();
} catch {
return Response.json({ error: "Invalid JSON" }, { status: 400 });
}
if (!body || typeof body !== "object") {
return Response.json({ error: "Expected JSON object" }, { status: 400 });
}
const isSubmit = body.regenerated === false;
const isRegen = body.regenerated === true;
// Augment with boardId + publishedAt so multi-board agents can disambiguate
// which board produced a given feedback.json.
const augmented = {
...body,
boardId: board.id,
publishedAt: new Date(board.publishedAt).toISOString(),
};
const feedbackFile = isSubmit ? "feedback.json" : "feedback-pending.json";
const feedbackPath = path.join(board.sourceDir, feedbackFile);
try {
fs.writeFileSync(feedbackPath, JSON.stringify(augmented, null, 2));
} catch (e: any) {
dlog(`feedback write failed for ${board.id}: ${e.message}`);
return Response.json(
{ error: `Cannot write ${feedbackFile}: ${e.message}` },
{ status: 500 },
);
}
board.lastTouched = Date.now();
markMeaningfulActivity();
if (isSubmit) {
board.state = "done";
dlog(`board ${board.id} submitted → ${feedbackPath}`);
return Response.json({ received: true, action: "submitted" });
}
if (isRegen) {
board.state = "regenerating";
dlog(`board ${board.id} regenerate → ${feedbackPath}`);
return Response.json({ received: true, action: "regenerate" });
}
return Response.json({ received: true, action: "unknown" });
}
async function handleBoardReload(board: Board, req: Request): Promise<Response> {
let body: any;
try {
body = await req.json();
} catch {
return Response.json({ error: "Invalid JSON" }, { status: 400 });
}
const newHtmlPath = typeof body?.html === "string" ? body.html : "";
if (!newHtmlPath || !fs.existsSync(newHtmlPath)) {
return Response.json({ error: `HTML file not found: ${newHtmlPath}` }, { status: 400 });
}
const resolvedReload = fs.realpathSync(path.resolve(newHtmlPath));
if (!resolvedReload.startsWith(board.allowedDir + path.sep)) {
return Response.json(
{ error: `Path must be within: ${board.allowedDir}` },
{ status: 403 },
);
}
if (!fs.statSync(resolvedReload).isFile()) {
return Response.json(
{ error: `Path must be a file, not a directory: ${newHtmlPath}` },
{ status: 400 },
);
}
board.htmlContent = fs.readFileSync(resolvedReload, "utf-8");
board.state = "serving";
board.lastTouched = Date.now();
markMeaningfulActivity();
dlog(`board ${board.id} reloaded from ${resolvedReload}`);
return Response.json({ reloaded: true });
}
function boardExpiredHtml(id: string): string {
return `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><title>Board expired — gstack</title>
<style>body{font:14px/1.5 -apple-system,system-ui,sans-serif;max-width:600px;margin:80px auto;padding:0 20px;color:#1a1a1a;text-align:center}
h1{font-size:20px}.id{font-family:ui-monospace,monospace;color:#888;font-size:13px}
a{color:#0070f3;text-decoration:none}a:hover{text-decoration:underline}</style></head><body>
<h1>Board expired</h1>
<p>Board <span class="id">${escapeHtml(id)}</span> is no longer hosted by this daemon (evicted or the daemon restarted).</p>
<p><a href="/">← see active boards</a></p>
</body></html>`;
}
// ─── Router ──────────────────────────────────────────────────────
const BOARD_RE = /^\/boards\/([A-Za-z0-9_-]+)(\/.*)?$/;
export async function fetchHandler(req: Request): Promise<Response> {
const url = new URL(req.url);
const origin = url.origin;
if (req.method === "GET" && url.pathname === "/health") return handleHealth();
if (req.method === "GET" && url.pathname === "/") return handleIndex();
if (req.method === "POST" && url.pathname === "/api/boards") return handlePublish(req, origin);
if (req.method === "POST" && url.pathname === "/shutdown") {
if (hasActiveBoards()) {
return Response.json(
{
error: "Refusing /shutdown: daemon has active boards. Submit or close them first.",
activeBoards: nonDoneCount(),
},
{ status: 409 },
);
}
setTimeout(() => gracefulShutdown(0), 50);
return Response.json({ shuttingDown: true });
}
const m = url.pathname.match(BOARD_RE);
if (m) {
const id = m[1]!;
const subpath = m[2] || "";
const board = boards.get(id);
if (!board) {
return new Response(boardExpiredHtml(id), {
status: 404,
headers: { "Content-Type": "text/html; charset=utf-8" },
});
}
// Bare /boards/<id> → 301 to /boards/<id>/ so relative URLs in board JS
// resolve against the right base (./api/feedback → /boards/<id>/api/feedback).
if (req.method === "GET" && subpath === "") {
return new Response(null, {
status: 301,
headers: { Location: `/boards/${id}/` },
});
}
if (req.method === "GET" && subpath === "/") return handleBoardGet(board);
if (req.method === "GET" && subpath === "/api/progress") return handleBoardProgress(board);
if (req.method === "POST" && subpath === "/api/feedback") {
return withBoardMutex(id, () => handleBoardFeedback(board, req));
}
if (req.method === "POST" && subpath === "/api/reload") {
return withBoardMutex(id, () => handleBoardReload(board, req));
}
}
return new Response("Not found", { status: 404 });
}
// ─── Startup ─────────────────────────────────────────────────────
export function start(): { port: number } {
const portArg = process.env.DESIGN_DAEMON_PORT;
const port = portArg ? parseInt(portArg, 10) : 0;
serverRef = Bun.serve({
port,
hostname: "127.0.0.1",
fetch: fetchHandler,
});
const actualPort = serverRef.port;
const state: DaemonState = {
pid: process.pid,
port: actualPort,
startedAt: new Date().toISOString(),
version: VERSION,
serverPath: process.argv[1] || "",
cmdlineMarker: CMDLINE_MARKER,
};
writeStateFile(state);
dlog(`DAEMON_STARTED port=${actualPort} pid=${process.pid} version=${VERSION}`);
// Stdout line the spawning CLI parses to learn the port quickly.
console.log(`DAEMON_STARTED port=${actualPort}`);
idleInterval = setInterval(idleCheckTick, IDLE_CHECK_INTERVAL_MS);
process.on("SIGTERM", () => {
void gracefulShutdown(0);
});
process.on("SIGINT", () => {
void gracefulShutdown(0);
});
process.on("uncaughtException", (e) => {
dlog(`uncaughtException: ${(e as Error).stack || (e as Error).message}`);
void gracefulShutdown(1);
});
return { port: actualPort };
}
if (import.meta.main) {
start();
}
// Exported for tests. Keep this small and stable.
export const __testInternals__ = {
boards,
fetchHandler,
idleCheckTick,
markMeaningfulActivity,
resetForTest: (): void => {
boards.clear();
boardMutex.clear();
lastMeaningfulActivity = Date.now();
idleExtensions = 0;
shuttingDown = false;
},
};
+29 -20
View File
@@ -1,12 +1,18 @@
/**
* HTTP server for the design comparison board feedback loop.
*
* Replaces the broken file:// + DOM polling approach. The server:
* 1. Serves the comparison board HTML over HTTP
* 2. Injects __GSTACK_SERVER_URL so the board POSTs feedback here
* 3. Prints feedback JSON to stdout (agent reads it)
* 4. Stays alive across regeneration rounds (stateful)
* 5. Auto-opens in the user's default browser
* Legacy single-process path: spawned by `$D compare --serve --no-daemon`.
* The daemon (`design/src/daemon.ts`) handles default invocations and hosts
* multiple boards under `/boards/<id>/`; this file stays as the escape hatch
* for tests and debugging. Board JS uses relative URLs and a
* location.protocol feature-detect, so the same generated HTML works at
* both `/` (here) and `/boards/<id>/` (daemon).
*
* The server:
* 1. Serves the comparison board HTML over HTTP at `/`
* 2. Prints feedback JSON to stdout (agent reads it)
* 3. Stays alive across regeneration rounds (stateful)
* 4. Auto-opens in the user's default browser
*
* State machine:
*
@@ -69,17 +75,14 @@ export async function serve(options: ServeOptions): Promise<void> {
fetch(req) {
const url = new URL(req.url);
// Serve the comparison board HTML
// Serve the comparison board HTML. The board JS uses relative paths
// (./api/feedback, ./api/progress) and a location.protocol
// feature-detect, so no per-request injection is needed.
if (
req.method === "GET" &&
(url.pathname === "/" || url.pathname === "/index.html")
) {
// Inject the server URL so the board can POST feedback
const injected = htmlContent.replace(
"</head>",
`<script>window.__GSTACK_SERVER_URL = ${JSON.stringify(url.origin)};</script>\n</head>`,
);
return new Response(injected, {
return new Response(htmlContent, {
headers: { "Content-Type": "text/html; charset=utf-8" },
});
}
@@ -194,19 +197,25 @@ export async function serve(options: ServeOptions): Promise<void> {
);
}
// Security: resolve symlinks and validate the reload path is within the
// allowed directory (anchored to the initial HTML file's parent).
// Prevents path traversal via /api/reload reading arbitrary files.
// Security: resolve symlinks and validate the reload path is a FILE
// inside the allowed directory (anchored to the initial HTML file's
// parent). Prevents path traversal via /api/reload reading arbitrary
// files. A path resolving to the allowedDir itself (a directory) used
// to pass the guard and then crash readFileSync with EISDIR — reject
// it explicitly with a clear 400 instead.
const resolvedReload = fs.realpathSync(path.resolve(newHtmlPath));
if (
!resolvedReload.startsWith(allowedDir + path.sep) &&
resolvedReload !== allowedDir
) {
if (!resolvedReload.startsWith(allowedDir + path.sep)) {
return Response.json(
{ error: `Path must be within: ${allowedDir}` },
{ status: 403 },
);
}
if (!fs.statSync(resolvedReload).isFile()) {
return Response.json(
{ error: `Path must be a file, not a directory: ${newHtmlPath}` },
{ status: 400 },
);
}
// Swap the HTML content
htmlContent = fs.readFileSync(resolvedReload, "utf-8");
+580
View File
@@ -0,0 +1,580 @@
/**
* Out-of-process tests for daemon-client.ts.
*
* Spawns real daemon subprocesses (via the fixtures helper) so we can
* exercise: state-file discovery, /health attach vs spawn, the lock +
* re-read-under-lock race, identity-verified SIGTERM, version mismatch
* with and without active boards, startup-error log surfacing, and the
* concurrent-CLIs race (two real subprocesses, one wins the lock).
*
* These tests are slower than daemon.test.ts (each spawn is ~200ms) so
* they're kept in a separate file to keep the in-process suite fast.
*/
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { spawn } from "child_process";
import fs from "fs";
import os from "os";
import path from "path";
import {
daemonStatus,
ensureDaemon,
publishBoard,
shutdownDaemon,
} from "../src/daemon-client";
import {
acquireLock,
CMDLINE_MARKER,
isProcessAlive,
readStateFile,
resolveLockFilePath,
verifyIdentity,
} from "../src/daemon-state";
import {
DAEMON_SCRIPT,
makeBoardHtml,
makeTmpDir,
spawnDaemonForTest,
type SpawnedDaemon,
} from "./daemon-tests-fixtures";
let workDir: string;
let stateFile: string;
let activeDaemons: SpawnedDaemon[] = [];
beforeEach(() => {
workDir = makeTmpDir("discovery");
stateFile = path.join(workDir, "design.json");
// Each test gets a private state-file path; env var ensures both the
// client's resolver and any spawned daemons converge on the same file.
process.env.DESIGN_DAEMON_STATE_FILE = stateFile;
});
afterEach(async () => {
for (const d of activeDaemons.splice(0)) {
try { await d.stop(); } catch {}
}
// Tear down any state file left around so the next test starts clean.
try { fs.unlinkSync(stateFile); } catch {}
try { fs.unlinkSync(resolveLockFilePath(stateFile)); } catch {}
delete process.env.DESIGN_DAEMON_STATE_FILE;
try { fs.rmSync(workDir, { recursive: true, force: true }); } catch {}
});
async function spawn1(idleMs = 60_000): Promise<SpawnedDaemon> {
const d = await spawnDaemonForTest({ stateFile, idleMs });
activeDaemons.push(d);
return d;
}
// ─── healthCheck + readStateFile basics ──────────────────────────
describe("daemon-state helpers", () => {
test("readStateFile returns null when missing", () => {
expect(readStateFile(stateFile)).toBeNull();
});
test("spawned daemon writes a usable state file", async () => {
const d = await spawn1();
const state = readStateFile(stateFile);
expect(state).not.toBeNull();
expect(state!.pid).toBe(d.proc.pid);
expect(state!.port).toBe(d.port);
expect(state!.cmdlineMarker).toBe(CMDLINE_MARKER);
expect(state!.version).toBe("test-version");
});
test("verifyIdentity matches a real spawned daemon's cmdline", async () => {
const d = await spawn1();
expect(verifyIdentity(d.proc.pid!, CMDLINE_MARKER)).toBe(true);
// wrong marker → false
expect(verifyIdentity(d.proc.pid!, "some-other-marker-xyz")).toBe(false);
});
test("verifyIdentity returns false for dead pids", async () => {
expect(verifyIdentity(999_999_999, CMDLINE_MARKER)).toBe(false);
});
});
// ─── ensureDaemon ────────────────────────────────────────────────
describe("ensureDaemon", () => {
test("with no state file: spawns a fresh daemon", async () => {
const result = await ensureDaemon({
version: "test-version",
stateFile,
verbose: false,
});
expect(result.spawned).toBe(true);
expect(result.port).toBeGreaterThan(0);
expect(result.version).toBe("test-version");
const state = readStateFile(stateFile);
expect(state).not.toBeNull();
expect(isProcessAlive(state!.pid)).toBe(true);
// Track for cleanup
activeDaemons.push({
proc: { pid: state!.pid } as any,
port: state!.port,
stateFile,
stop: async () => {
try { process.kill(state!.pid, "SIGTERM"); } catch {}
},
});
});
test("with a healthy daemon already running: attaches without spawning", async () => {
const existing = await spawn1();
const result = await ensureDaemon({
version: "test-version",
stateFile,
verbose: false,
});
expect(result.spawned).toBe(false);
expect(result.port).toBe(existing.port);
});
test("with a stale state file (PID dead): spawns fresh, overwrites state", async () => {
// Synthesize a stale state file pointing at a definitely-dead pid.
fs.mkdirSync(path.dirname(stateFile), { recursive: true });
fs.writeFileSync(stateFile, JSON.stringify({
pid: 999_999_998,
port: 1, // bogus port — /health will fail fast
startedAt: "2020-01-01T00:00:00Z",
version: "ancient",
serverPath: "/nope",
cmdlineMarker: CMDLINE_MARKER,
}));
const result = await ensureDaemon({
version: "test-version",
stateFile,
verbose: false,
});
expect(result.spawned).toBe(true);
// State file should now point at the live daemon.
const fresh = readStateFile(stateFile);
expect(fresh!.pid).not.toBe(999_999_998);
expect(isProcessAlive(fresh!.pid)).toBe(true);
activeDaemons.push({
proc: { pid: fresh!.pid } as any,
port: fresh!.port,
stateFile,
stop: async () => { try { process.kill(fresh!.pid, "SIGTERM"); } catch {} },
});
});
test("PID-reuse safety: stale state with an unrelated alive PID → identity-verify blocks signal, daemon spawned", async () => {
// Use the current test process's PID — definitely alive, definitely
// does NOT have CMDLINE_MARKER in its cmdline (it's the Bun test runner).
fs.mkdirSync(path.dirname(stateFile), { recursive: true });
fs.writeFileSync(stateFile, JSON.stringify({
pid: process.pid, // alive but NOT a daemon
port: 1,
startedAt: "2020-01-01T00:00:00Z",
version: "ancient",
serverPath: "/nope",
cmdlineMarker: CMDLINE_MARKER,
}));
// ensureDaemon should NOT signal process.pid (we'd kill ourselves);
// verifyIdentity catches the cmdline mismatch and skips the kill.
const result = await ensureDaemon({
version: "test-version",
stateFile,
verbose: false,
});
// We're still alive (didn't get killed)
expect(isProcessAlive(process.pid)).toBe(true);
expect(result.spawned).toBe(true);
const fresh = readStateFile(stateFile);
expect(fresh!.pid).not.toBe(process.pid);
activeDaemons.push({
proc: { pid: fresh!.pid } as any,
port: fresh!.port,
stateFile,
stop: async () => { try { process.kill(fresh!.pid, "SIGTERM"); } catch {} },
});
});
test("version mismatch with NO active boards: gracefully shuts existing down and respawns", async () => {
const existing = await spawn1();
// The existing daemon's version is "test-version" (set by fixture env).
// ensureDaemon with a DIFFERENT version → should /shutdown the existing
// (no active boards) and spawn fresh.
const result = await ensureDaemon({
version: "different-version",
stateFile,
verbose: false,
});
expect(result.spawned).toBe(true);
expect(result.version).toBe("different-version");
// existing.proc.pid should be gone by now (or soon)
// Give it a moment for the /shutdown + SIGTERM to take effect
await new Promise((r) => setTimeout(r, 200));
expect(isProcessAlive(existing.proc.pid!)).toBe(false);
// New daemon recorded
const fresh = readStateFile(stateFile);
expect(fresh!.pid).not.toBe(existing.proc.pid);
activeDaemons.push({
proc: { pid: fresh!.pid } as any,
port: fresh!.port,
stateFile,
stop: async () => { try { process.kill(fresh!.pid, "SIGTERM"); } catch {} },
});
});
test("version mismatch WITH active boards: refuses to kill, exits 1 with user-actionable error", async () => {
// Run the ensureDaemon-that-would-exit-1 in a subprocess so we can
// observe the exit code and stderr without killing the test runner.
const existing = await spawn1();
// Publish a board so activeBoards > 0
const html = makeBoardHtml(workDir);
await publishBoard({ port: existing.port, html });
// Sanity: status should reflect the active board
const statusResp = await fetch(`http://127.0.0.1:${existing.port}/health`);
const status = (await statusResp.json()) as any;
expect(status.activeBoards).toBe(1);
// Now run a tiny script that calls ensureDaemon with a mismatched
// version. It should print the WARNING + exit 1.
const scriptPath = path.join(workDir, "ensure-mismatch.ts");
fs.writeFileSync(scriptPath, `
import { ensureDaemon } from "${path.resolve(import.meta.dir, "..", "src", "daemon-client.ts").replace(/\\\\/g, "/")}";
await ensureDaemon({
version: "totally-different-version",
stateFile: ${JSON.stringify(stateFile)},
verbose: true,
});
console.log("REACHED_AFTER_ENSURE — should not happen");
`);
const child = spawn("bun", ["run", scriptPath], {
env: { ...process.env, DESIGN_DAEMON_STATE_FILE: stateFile },
stdio: ["ignore", "pipe", "pipe"],
});
const stderrChunks: Buffer[] = [];
const stdoutChunks: Buffer[] = [];
child.stderr.on("data", (c) => stderrChunks.push(c));
child.stdout.on("data", (c) => stdoutChunks.push(c));
const exitCode = await new Promise<number>((resolve) => {
child.on("exit", (code) => resolve(code ?? -1));
});
const stderr = Buffer.concat(stderrChunks).toString();
const stdout = Buffer.concat(stdoutChunks).toString();
expect(exitCode).toBe(1);
expect(stderr).toContain("active board");
expect(stderr).toContain("Refusing to auto-kill");
// We must NOT have reached the post-ensure line
expect(stdout).not.toContain("REACHED_AFTER_ENSURE");
// And the existing daemon should still be alive
expect(isProcessAlive(existing.proc.pid!)).toBe(true);
}, 15_000);
});
// ─── publishBoard ────────────────────────────────────────────────
describe("publishBoard", () => {
test("publishes a board through the real HTTP path and returns id+url+sourceDir", async () => {
const d = await spawn1();
const htmlPath = makeBoardHtml(workDir, "<p>via-client</p>");
const result = await publishBoard({ port: d.port, html: htmlPath });
expect(result.id).toMatch(/^b-/);
expect(result.url).toBe(`http://127.0.0.1:${d.port}/boards/${result.id}/`);
expect(result.sourceDir).toBe(fs.realpathSync(workDir));
// Confirm the board is actually fetchable at the returned URL
const r = await fetch(result.url);
expect(r.status).toBe(200);
const html = await r.text();
expect(html).toContain("via-client");
});
test("409 surfaces existing board's id+url (returned object, no throw)", async () => {
const d = await spawn1();
const htmlPath = makeBoardHtml(workDir);
const first = await publishBoard({ port: d.port, html: htmlPath });
const htmlPath2 = makeBoardHtml(workDir, "<p>second</p>");
const second = await publishBoard({ port: d.port, html: htmlPath2 });
// Same sourceDir → 409 with `existing` field; publishBoard returns it
// so the caller can attach to the existing board.
expect(second.id).toBe(first.id);
expect(second.url).toBe(first.url);
});
});
// ─── shutdownDaemon / daemonStatus ───────────────────────────────
describe("shutdownDaemon + daemonStatus", () => {
test("status reports not-running when no state file", async () => {
const s = await daemonStatus();
expect(s.running).toBe(false);
});
test("status reports running with port + version + counts when daemon alive", async () => {
const d = await spawn1();
const s = await daemonStatus();
expect(s.running).toBe(true);
if (s.running) {
expect(s.port).toBe(d.port);
expect(s.pid).toBe(d.proc.pid);
expect(s.version).toBe("test-version");
expect(s.boards).toBe(0);
expect(s.activeBoards).toBe(0);
}
});
test("shutdownDaemon succeeds when no active boards", async () => {
const d = await spawn1();
const r = await shutdownDaemon();
expect(r.stopped).toBe(true);
// Give it a moment to die
await new Promise((res) => setTimeout(res, 300));
expect(isProcessAlive(d.proc.pid!)).toBe(false);
});
test("shutdownDaemon refuses (without force) when active boards present", async () => {
const d = await spawn1();
await publishBoard({ port: d.port, html: makeBoardHtml(workDir) });
const r = await shutdownDaemon();
expect(r.stopped).toBe(false);
expect(r.reason).toContain("active");
expect(r.activeBoards).toBe(1);
// Daemon still running
expect(isProcessAlive(d.proc.pid!)).toBe(true);
});
test("shutdownDaemon with force=true ignores active boards", async () => {
const d = await spawn1();
await publishBoard({ port: d.port, html: makeBoardHtml(workDir) });
const r = await shutdownDaemon({ force: true });
expect(r.stopped).toBe(true);
});
});
// ─── Real idle-shutdown behavior (spawned daemon, fast clock) ───
//
// The lastMeaningfulActivity timestamp is not observable from outside the
// daemon process, so the only way to prove "bare GETs do not reset the
// idle timer" is to spawn a real daemon with a short idle window, hit
// progress polls in a loop, and watch the process exit anyway.
//
// These tests aim for ~3-5s real time per test by setting IDLE_MS=2000
// and CHECK_MS=200. The idle-with-active-boards extension path needs a
// board in `serving` state to exercise.
describe("daemon idle-shutdown behavior (real process)", () => {
// Wait for a child process to exit, with a deadline. Resolves true on
// observed exit, false on timeout. Doesn't kill on timeout — caller does.
async function waitForExit(pid: number, timeoutMs: number): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (!isProcessAlive(pid)) return true;
await new Promise((r) => setTimeout(r, 100));
}
return false;
}
test("idle daemon (no boards) shuts itself down after IDLE_MS + CHECK_MS", async () => {
const d = await spawnDaemonForTest({
stateFile,
idleMs: 2_000,
checkMs: 200,
});
// Don't push to activeDaemons; the daemon should self-exit and the
// afterEach SIGTERM would race with that. Track manually.
try {
// No boards published. lastMeaningfulActivity is the startup time.
// Wait IDLE_MS + a couple CHECK_MS intervals for the timer to fire.
const exited = await waitForExit(d.proc.pid!, 5_000);
expect(exited).toBe(true);
// State file removed by gracefulShutdown
expect(readStateFile(stateFile)).toBeNull();
} finally {
if (isProcessAlive(d.proc.pid!)) {
try { d.proc.kill("SIGKILL"); } catch {}
}
}
}, 10_000);
test("bare GET polling does NOT prevent idle shutdown (progress polls don't reset idle)", async () => {
const d = await spawnDaemonForTest({
stateFile,
idleMs: 2_000,
checkMs: 200,
});
let polling = true;
let pollCount = 0;
const boardDir = makeTmpDir("idle-poll");
try {
const board = await publishBoard({
port: d.port,
html: makeBoardHtml(boardDir),
});
// Submit so the board becomes `done` — non-done would trigger the
// 1h extension path and keep the daemon alive past IDLE_MS.
await fetch(`${board.url}api/feedback`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ regenerated: false, preferred: "A" }),
});
// Hammer /api/progress every 200ms in the background. If bare GETs
// reset meaningful activity, the daemon would never idle out.
const pollLoop = (async () => {
while (polling) {
try {
await fetch(`${board.url}api/progress`);
pollCount += 1;
} catch {
// daemon went away
break;
}
await new Promise((r) => setTimeout(r, 200));
}
})();
const exited = await waitForExit(d.proc.pid!, 6_000);
polling = false;
await pollLoop;
expect(exited).toBe(true);
// We polled at least a few times before the daemon idled out
expect(pollCount).toBeGreaterThan(3);
expect(readStateFile(stateFile)).toBeNull();
} finally {
polling = false;
if (isProcessAlive(d.proc.pid!)) {
try { d.proc.kill("SIGKILL"); } catch {}
}
try { fs.rmSync(boardDir, { recursive: true, force: true }); } catch {}
}
}, 15_000);
test("idle with active (non-done) boards triggers extension instead of shutdown", async () => {
// With non-done boards, the daemon should NOT shut down on the first
// idle check after IDLE_MS — it extends. Verify it's still alive past
// the would-be-shutdown deadline. The MAX_EXTENSIONS=4 hard ceiling
// would take 4 * 1h = 4h to exercise with default extension window,
// so we shrink both IDLE and EXTENSION via env to test it in seconds.
const d = await spawnDaemonForTest({
stateFile,
idleMs: 1_500,
checkMs: 200,
env: {
DESIGN_DAEMON_EXTENSION_MS: "1500",
DESIGN_DAEMON_MAX_EXTENSIONS: "2",
},
});
const boardDir = makeTmpDir("idle-active");
try {
await publishBoard({ port: d.port, html: makeBoardHtml(boardDir) });
// Daemon has 1 non-done board. After IDLE_MS, idleCheckTick should
// extend rather than shut down. So at IDLE_MS + small margin, it's
// still alive.
await new Promise((r) => setTimeout(r, 2_500));
expect(isProcessAlive(d.proc.pid!)).toBe(true);
expect(readStateFile(stateFile)).not.toBeNull();
// After MAX_EXTENSIONS extension windows (2 * 1500ms = 3000ms more),
// the hard ceiling kicks in and force-shutdown fires. Total wait:
// IDLE_MS(1500) + EXT*MAX(3000) + slack(1000) = ~5500ms. We've already
// waited 2500ms, so 4000ms more.
const exited = await waitForExit(d.proc.pid!, 5_500);
expect(exited).toBe(true);
expect(readStateFile(stateFile)).toBeNull();
} finally {
if (isProcessAlive(d.proc.pid!)) {
try { d.proc.kill("SIGKILL"); } catch {}
}
try { fs.rmSync(boardDir, { recursive: true, force: true }); } catch {}
}
}, 15_000);
});
// ─── Concurrent ensureDaemon race (one wins the lock) ───────────
describe("concurrent ensureDaemon race", () => {
test("two parallel ensureDaemon() calls converge on one daemon (one spawned, one attached)", async () => {
// Fire two ensureDaemon calls in parallel against the same empty
// stateFile. The fs.openSync('wx') lock should make exactly one win
// the spawn race; the loser waits for the first to write the state
// file, then attaches.
const [a, b] = await Promise.all([
ensureDaemon({ version: "test-version", stateFile, verbose: false }),
ensureDaemon({ version: "test-version", stateFile, verbose: false }),
]);
// Both got the same port (same daemon)
expect(a.port).toBe(b.port);
// Exactly one spawned, one attached
const spawnedCount = [a.spawned, b.spawned].filter(Boolean).length;
expect(spawnedCount).toBe(1);
// Exactly one daemon process is alive at that port
const state = readStateFile(stateFile);
expect(state).not.toBeNull();
expect(isProcessAlive(state!.pid)).toBe(true);
// Lock file cleaned up (the winner released it on exit from the try block)
expect(fs.existsSync(resolveLockFilePath(stateFile))).toBe(false);
// Track for cleanup
activeDaemons.push({
proc: { pid: state!.pid } as any,
port: state!.port,
stateFile,
stop: async () => {
try { process.kill(state!.pid, "SIGTERM"); } catch {}
},
});
}, 15_000);
});
// ─── Stale-lock reclaim ──────────────────────────────────────────
describe("acquireLock stale-lock reclaim", () => {
test("reclaims a lockfile owned by a dead PID and writes our PID", () => {
const lockPath = resolveLockFilePath(stateFile);
// Plant a lockfile owned by a definitely-dead PID
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
fs.writeFileSync(lockPath, "999999998\n");
const release = acquireLock(lockPath);
expect(release).not.toBeNull();
// Lock file now contains our PID
expect(fs.readFileSync(lockPath, "utf-8").trim()).toBe(String(process.pid));
release!();
// Released = lock file gone
expect(fs.existsSync(lockPath)).toBe(false);
});
test("refuses to reclaim a lockfile owned by an alive (unrelated) PID", () => {
const lockPath = resolveLockFilePath(stateFile);
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
// Use this test process's own PID — it's alive AND unrelated to a daemon.
// acquireLock should refuse and return null without unlinking the lock.
fs.writeFileSync(lockPath, `${process.pid}\n`);
const release = acquireLock(lockPath);
expect(release).toBeNull();
// Lock file is untouched
expect(fs.readFileSync(lockPath, "utf-8").trim()).toBe(String(process.pid));
// Cleanup
try { fs.unlinkSync(lockPath); } catch {}
});
});
+135
View File
@@ -0,0 +1,135 @@
/**
* Shared helpers for daemon + daemon-client tests.
*
* Two test styles live here:
* - In-process: import fetchHandler from daemon.ts and call it with a
* synthesized Request. Fast, no spawn, no HTTP. Covers routing +
* handler semantics. Used by most of daemon.test.ts.
* - Out-of-process: spawn `bun run design/src/daemon.ts` with a tmp
* state file + env overrides, then HTTP against the bound port.
* Slow but only path that proves real spawn + state file + signal
* handling work. Used by daemon-discovery.test.ts.
*/
import { spawn, type ChildProcess } from "child_process";
import fs from "fs";
import os from "os";
import path from "path";
import { __testInternals__ } from "../src/daemon";
export const DAEMON_SCRIPT = path.join(import.meta.dir, "..", "src", "daemon.ts");
export function makeTmpDir(prefix = "design-daemon-test"): string {
return fs.mkdtempSync(path.join(os.tmpdir(), `${prefix}-`));
}
export function makeBoardHtml(tmpDir: string, body = "<p>Test board</p>"): string {
const p = path.join(tmpDir, "design-board.html");
fs.writeFileSync(
p,
`<!DOCTYPE html><html><head></head><body>${body}</body></html>`,
);
return p;
}
/** Reset the in-process daemon state between tests. */
export function resetDaemon(): void {
__testInternals__.resetForTest();
}
/** Build a Request for the in-process fetchHandler tests. */
export function req(method: string, urlPath: string, body?: unknown): Request {
const init: RequestInit = { method };
if (body !== undefined) {
init.body = typeof body === "string" ? body : JSON.stringify(body);
init.headers = { "Content-Type": "application/json" };
}
return new Request(`http://127.0.0.1:1234${urlPath}`, init);
}
export interface SpawnedDaemon {
proc: ChildProcess;
port: number;
stateFile: string;
stop: () => Promise<void>;
}
/**
* Spawn a real daemon process pointed at a per-test state file, with an
* aggressive idle window so idle-shutdown tests don't take 24h. Resolves
* when stdout emits `DAEMON_STARTED port=<N>`.
*/
export async function spawnDaemonForTest(
opts: { stateFile?: string; idleMs?: number; checkMs?: number; env?: Record<string, string> } = {},
): Promise<SpawnedDaemon> {
const stateFile = opts.stateFile ?? path.join(makeTmpDir("daemon-state"), "design.json");
const env: Record<string, string> = {
...(process.env as Record<string, string>),
// DESIGN_DAEMON_STATE_FILE points both daemon and any same-process
// discovery at this test's state file (overrides resolveStateFilePath).
DESIGN_DAEMON_STATE_FILE: stateFile,
DESIGN_DAEMON_IDLE_MS: String(opts.idleMs ?? 60_000),
DESIGN_DAEMON_CHECK_MS: String(opts.checkMs ?? 1000),
DESIGN_DAEMON_VERSION: "test-version",
...(opts.env ?? {}),
};
// Spawn with a marker in argv so cmdline-based identity verification
// exercises the real CMDLINE_MARKER ("gstack-design-daemon").
const proc = spawn(
"bun",
["run", DAEMON_SCRIPT, "--marker", "gstack-design-daemon"],
{
env,
stdio: ["ignore", "pipe", "pipe"],
cwd: path.dirname(stateFile),
},
);
const port = await new Promise<number>((resolve, reject) => {
const onTimeout = setTimeout(() => {
proc.kill("SIGKILL");
reject(new Error("Daemon failed to emit DAEMON_STARTED within 5s"));
}, 5000);
proc.stdout!.on("data", (chunk: Buffer) => {
const line = chunk.toString();
const m = line.match(/DAEMON_STARTED port=(\d+)/);
if (m) {
clearTimeout(onTimeout);
resolve(parseInt(m[1]!, 10));
}
});
proc.on("error", (e) => {
clearTimeout(onTimeout);
reject(e);
});
proc.on("exit", (code) => {
clearTimeout(onTimeout);
reject(new Error(`Daemon exited early with code ${code}`));
});
});
return {
proc,
port,
stateFile,
stop: async () => {
proc.kill("SIGTERM");
await new Promise<void>((r) => {
const t = setTimeout(() => {
try {
proc.kill("SIGKILL");
} catch {
// gone
}
r();
}, 2000);
proc.on("exit", () => {
clearTimeout(t);
r();
});
});
},
};
}
+534
View File
@@ -0,0 +1,534 @@
/**
* In-process tests for design daemon endpoints + lifecycle helpers.
*
* Uses the exported fetchHandler directly (no Bun.serve spawn) so the suite
* is fast and deterministic. Spawn-based tests live in
* daemon-discovery.test.ts.
*/
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import fs from "fs";
import path from "path";
import { __testInternals__, fetchHandler, idleCheckTick } from "../src/daemon";
const { markMeaningfulActivity } = __testInternals__;
import { makeBoardHtml, makeTmpDir, req, resetDaemon } from "./daemon-tests-fixtures";
let tmpDir: string;
beforeEach(() => {
resetDaemon();
tmpDir = makeTmpDir();
});
afterEach(() => {
try {
fs.rmSync(tmpDir, { recursive: true, force: true });
} catch {
// already gone
}
});
async function publishTestBoard(opts: { dir?: string; body?: string; title?: string } = {}) {
const dir = opts.dir ?? tmpDir;
const htmlPath = makeBoardHtml(dir, opts.body ?? "<p>Test</p>");
const r = await fetchHandler(
req("POST", "/api/boards", { html: htmlPath, title: opts.title }),
);
expect(r.status).toBe(200);
const body = (await r.json()) as { id: string; url: string; sourceDir: string };
return { ...body, htmlPath, dir };
}
// ─── /health ─────────────────────────────────────────────────────
describe("daemon /health", () => {
test("returns ok=true with version + boards counts", async () => {
const r = await fetchHandler(req("GET", "/health"));
expect(r.status).toBe(200);
const body = (await r.json()) as any;
expect(body.ok).toBe(true);
expect(typeof body.version).toBe("string");
expect(body.boards).toBe(0);
expect(body.activeBoards).toBe(0);
expect(typeof body.uptime).toBe("number");
});
test("activeBoards counts non-done after publish", async () => {
await publishTestBoard();
const r = await fetchHandler(req("GET", "/health"));
const body = (await r.json()) as any;
expect(body.boards).toBe(1);
expect(body.activeBoards).toBe(1);
});
});
// ─── POST /api/boards (publish) ─────────────────────────────────
describe("daemon /api/boards (publish)", () => {
test("publishes a board and returns id + url + derived sourceDir", async () => {
const htmlPath = makeBoardHtml(tmpDir);
const r = await fetchHandler(req("POST", "/api/boards", { html: htmlPath }));
expect(r.status).toBe(200);
const body = (await r.json()) as any;
expect(body.id).toMatch(/^b-\d{8}-\d{6}-[a-z0-9]{6}$/);
expect(body.url).toMatch(/\/boards\/b-\d{8}-\d{6}-[a-z0-9]{6}\/$/); // trailing slash
expect(body.sourceDir).toBe(fs.realpathSync(tmpDir));
});
test("rejects when html field missing", async () => {
const r = await fetchHandler(req("POST", "/api/boards", { title: "noop" }));
expect(r.status).toBe(400);
const body = (await r.json()) as any;
expect(body.error).toContain("Missing 'html'");
});
test("rejects when html file does not exist", async () => {
const r = await fetchHandler(
req("POST", "/api/boards", { html: "/tmp/does-not-exist.html" }),
);
expect(r.status).toBe(400);
const body = (await r.json()) as any;
expect(body.error).toContain("not found");
});
test("rejects when html points at a directory", async () => {
const r = await fetchHandler(req("POST", "/api/boards", { html: tmpDir }));
expect(r.status).toBe(400);
const body = (await r.json()) as any;
expect(body.error).toContain("must be a file");
});
test("ignores body-supplied sourceDir; derives from realpath(html) instead", async () => {
const htmlPath = makeBoardHtml(tmpDir);
const otherDir = makeTmpDir("sneaky");
try {
const r = await fetchHandler(
req("POST", "/api/boards", { html: htmlPath, sourceDir: otherDir }),
);
expect(r.status).toBe(200);
const body = (await r.json()) as any;
// The daemon used the realpath of the HTML's dir, NOT the body field.
expect(body.sourceDir).toBe(fs.realpathSync(tmpDir));
expect(body.sourceDir).not.toBe(fs.realpathSync(otherDir));
} finally {
try {
fs.rmSync(otherDir, { recursive: true, force: true });
} catch {
// already gone
}
}
});
test("409 when a non-done board already claims the same sourceDir", async () => {
const first = await publishTestBoard();
const htmlPath = makeBoardHtml(tmpDir, "<p>Second attempt</p>");
const r = await fetchHandler(req("POST", "/api/boards", { html: htmlPath }));
expect(r.status).toBe(409);
const body = (await r.json()) as any;
expect(body.error).toContain("already in use");
expect(body.existing.id).toBe(first.id);
expect(body.existing.url).toContain(`/boards/${first.id}/`);
});
test("allows publish to same sourceDir after the prior board is done", async () => {
const first = await publishTestBoard();
// Submit the first board so it becomes done
await fetchHandler(
req("POST", `/boards/${first.id}/api/feedback`, { regenerated: false }),
);
const htmlPath = makeBoardHtml(tmpDir, "<p>Round two</p>");
const r = await fetchHandler(req("POST", "/api/boards", { html: htmlPath }));
expect(r.status).toBe(200);
});
});
// ─── GET /boards/<id> trailing-slash redirect ────────────────────
describe("daemon /boards/<id> trailing-slash redirect", () => {
test("GET /boards/<id> returns 301 with Location /boards/<id>/", async () => {
const board = await publishTestBoard();
const r = await fetchHandler(req("GET", `/boards/${board.id}`));
expect(r.status).toBe(301);
expect(r.headers.get("Location")).toBe(`/boards/${board.id}/`);
});
test("GET /boards/<id>/ renders the board's HTML", async () => {
const board = await publishTestBoard({ body: "<p>Hello from board</p>" });
const r = await fetchHandler(req("GET", `/boards/${board.id}/`));
expect(r.status).toBe(200);
expect(r.headers.get("Content-Type") || "").toContain("text/html");
const html = await r.text();
expect(html).toContain("Hello from board");
// No __GSTACK_SERVER_URL injection (board JS uses relative paths)
expect(html).not.toContain("__GSTACK_SERVER_URL");
});
test("404 on unknown board id (shows expired page)", async () => {
const r = await fetchHandler(req("GET", "/boards/b-nonexistent/"));
expect(r.status).toBe(404);
const html = await r.text();
expect(html).toContain("Board expired");
});
});
// ─── POST /boards/<id>/api/feedback ──────────────────────────────
describe("daemon /boards/<id>/api/feedback", () => {
test("submit writes feedback.json to derived sourceDir with boardId + publishedAt", async () => {
const board = await publishTestBoard();
const feedback = { preferred: "A", ratings: { A: 5 }, regenerated: false };
const r = await fetchHandler(
req("POST", `/boards/${board.id}/api/feedback`, feedback),
);
expect(r.status).toBe(200);
expect(((await r.json()) as any).action).toBe("submitted");
const written = JSON.parse(
fs.readFileSync(path.join(board.sourceDir, "feedback.json"), "utf-8"),
);
expect(written.preferred).toBe("A");
expect(written.regenerated).toBe(false);
expect(written.boardId).toBe(board.id);
expect(typeof written.publishedAt).toBe("string");
expect(written.publishedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
});
test("regenerate writes feedback-pending.json and flips state to regenerating", async () => {
const board = await publishTestBoard();
const r = await fetchHandler(
req("POST", `/boards/${board.id}/api/feedback`, {
regenerated: true,
regenerateAction: "more_like_A",
}),
);
expect(r.status).toBe(200);
expect(((await r.json()) as any).action).toBe("regenerate");
expect(fs.existsSync(path.join(board.sourceDir, "feedback-pending.json"))).toBe(true);
expect(fs.existsSync(path.join(board.sourceDir, "feedback.json"))).toBe(false);
const progress = await fetchHandler(
req("GET", `/boards/${board.id}/api/progress`),
);
expect(((await progress.json()) as any).status).toBe("regenerating");
});
test("cross-board isolation: feedback writes only into that board's sourceDir", async () => {
const dirA = makeTmpDir("board-a");
const dirB = makeTmpDir("board-b");
try {
const htmlA = makeBoardHtml(dirA);
const htmlB = makeBoardHtml(dirB);
const a = (await (await fetchHandler(
req("POST", "/api/boards", { html: htmlA }),
)).json()) as any;
const b = (await (await fetchHandler(
req("POST", "/api/boards", { html: htmlB }),
)).json()) as any;
expect(a.id).not.toBe(b.id);
await fetchHandler(
req("POST", `/boards/${a.id}/api/feedback`, { preferred: "A", regenerated: false }),
);
expect(fs.existsSync(path.join(a.sourceDir, "feedback.json"))).toBe(true);
// Board B's directory must not have been touched
expect(fs.existsSync(path.join(b.sourceDir, "feedback.json"))).toBe(false);
expect(fs.existsSync(path.join(b.sourceDir, "feedback-pending.json"))).toBe(false);
} finally {
try { fs.rmSync(dirA, { recursive: true, force: true }); } catch {}
try { fs.rmSync(dirB, { recursive: true, force: true }); } catch {}
}
});
test("rejects malformed JSON body", async () => {
const board = await publishTestBoard();
const bad = new Request(`http://127.0.0.1/boards/${board.id}/api/feedback`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "{not json",
});
const r = await fetchHandler(bad);
expect(r.status).toBe(400);
});
});
// ─── POST /boards/<id>/api/reload ────────────────────────────────
describe("daemon /boards/<id>/api/reload", () => {
test("swaps HTML in place; subsequent GET returns new content", async () => {
const board = await publishTestBoard({ body: "<p>round 1</p>" });
const newHtml = makeBoardHtml(tmpDir, "<p>round 2</p>");
// The reload helper writes to design-board.html; make a distinct path
fs.writeFileSync(path.join(tmpDir, "round2.html"), "<html><body><p>round 2</p></body></html>");
const reloadPath = path.join(tmpDir, "round2.html");
const r = await fetchHandler(
req("POST", `/boards/${board.id}/api/reload`, { html: reloadPath }),
);
expect(r.status).toBe(200);
const page = await fetchHandler(req("GET", `/boards/${board.id}/`));
expect(await page.text()).toContain("round 2");
});
test("rejects path traversal outside allowedDir", async () => {
const board = await publishTestBoard();
const r = await fetchHandler(
req("POST", `/boards/${board.id}/api/reload`, { html: "/etc/passwd" }),
);
expect(r.status).toBe(403);
});
test("rejects directory path (Codex finding regression guard)", async () => {
const board = await publishTestBoard();
const sub = path.join(tmpDir, "subdir");
fs.mkdirSync(sub, { recursive: true });
const r = await fetchHandler(
req("POST", `/boards/${board.id}/api/reload`, { html: sub }),
);
expect(r.status).toBe(400);
const body = (await r.json()) as any;
expect(body.error).toContain("must be a file");
});
test("rejects symlink pointing out of allowedDir", async () => {
const board = await publishTestBoard();
const linkPath = path.join(tmpDir, "evil.html");
try {
fs.symlinkSync("/etc/passwd", linkPath);
const r = await fetchHandler(
req("POST", `/boards/${board.id}/api/reload`, { html: linkPath }),
);
expect(r.status).toBe(403);
} finally {
try { fs.unlinkSync(linkPath); } catch {}
}
});
});
// ─── GET / (index) ───────────────────────────────────────────────
describe("daemon / (index)", () => {
test("empty state shows the no-boards message", async () => {
const r = await fetchHandler(req("GET", "/"));
expect(r.status).toBe(200);
const html = await r.text();
expect(html).toContain("No boards yet");
});
test("lists boards newest first with state badges", async () => {
const a = await publishTestBoard({ title: "first" });
// Small wait so publishedAt differs
await new Promise((r) => setTimeout(r, 5));
const dirB = makeTmpDir("index-b");
try {
const htmlB = makeBoardHtml(dirB);
const b = (await (await fetchHandler(
req("POST", "/api/boards", { html: htmlB, title: "second" }),
)).json()) as any;
const html = await (await fetchHandler(req("GET", "/"))).text();
const idxA = html.indexOf(a.id);
const idxB = html.indexOf(b.id);
// Newest first: b appears before a
expect(idxB).toBeGreaterThanOrEqual(0);
expect(idxA).toBeGreaterThan(idxB);
// State badge present
expect(html).toMatch(/state-serving/);
} finally {
try { fs.rmSync(dirB, { recursive: true, force: true }); } catch {}
}
});
});
// ─── /shutdown ───────────────────────────────────────────────────
describe("daemon /shutdown", () => {
test("refuses /shutdown when boards are non-done", async () => {
await publishTestBoard();
const r = await fetchHandler(req("POST", "/shutdown"));
expect(r.status).toBe(409);
const body = (await r.json()) as any;
expect(body.error).toContain("active boards");
expect(body.activeBoards).toBe(1);
});
test("accepts /shutdown when no active boards (graceful path)", async () => {
// Publish then submit so state=done
const board = await publishTestBoard();
await fetchHandler(
req("POST", `/boards/${board.id}/api/feedback`, { regenerated: false }),
);
// Now non-done count is 0 — handler should return shuttingDown:true.
// We DON'T let the real gracefulShutdown timer fire (it calls process.exit
// after 50ms which would tear down the test runner); instead we just
// observe the immediate response.
const r = await fetchHandler(req("POST", "/shutdown"));
expect(r.status).toBe(200);
const body = (await r.json()) as any;
expect(body.shuttingDown).toBe(true);
// Reset state for subsequent tests; the shutdown timer will be a no-op
// because the next resetForTest flips shuttingDown back to false.
resetDaemon();
});
});
// ─── LRU + non-done protection ───────────────────────────────────
describe("daemon LRU eviction", () => {
test("evicts done boards in preference to non-done", async () => {
// Seed the map directly so we don't have to publish 50 real boards.
// Setup: 10 done (oldest) + 40 serving (newer) = 50 total, 40 non-done.
// Publishing a 51st board: nonDoneCount(40) < MAX(50) → accepts, inserts,
// size=51, then evictUntilUnderCap kicks out the LRU done.
const boards = __testInternals__.boards;
const mk = (id: string, state: "serving" | "done", lastTouched: number) => {
boards.set(id, {
id,
htmlContent: "<p>seeded</p>",
sourceDir: `/tmp/seeded-${id}`,
allowedDir: `/tmp/seeded-${id}`,
state,
publishedAt: lastTouched,
lastTouched,
publisherPid: 0,
});
};
for (let i = 0; i < 10; i++) mk(`b-done-${i}`, "done", 1000 + i);
for (let i = 0; i < 40; i++) mk(`b-active-${i}`, "serving", 2000 + i);
expect(boards.size).toBe(50);
const htmlPath = makeBoardHtml(tmpDir);
const r = await fetchHandler(req("POST", "/api/boards", { html: htmlPath }));
expect(r.status).toBe(200);
expect(boards.size).toBeLessThanOrEqual(50);
// At least one of the (oldest) done boards is gone; non-done untouched.
let doneGoneCount = 0;
for (let i = 0; i < 10; i++) if (!boards.has(`b-done-${i}`)) doneGoneCount += 1;
expect(doneGoneCount).toBeGreaterThanOrEqual(1);
// All non-done preserved
for (let i = 0; i < 40; i++) {
expect(boards.has(`b-active-${i}`)).toBe(true);
}
});
test("503 when 50 non-done boards already exist", async () => {
const boards = __testInternals__.boards;
for (let i = 0; i < 50; i++) {
boards.set(`b-busy-${i}`, {
id: `b-busy-${i}`,
htmlContent: "<p>busy</p>",
sourceDir: `/tmp/busy-${i}`,
allowedDir: `/tmp/busy-${i}`,
state: "serving",
publishedAt: i,
lastTouched: i,
publisherPid: 0,
});
}
const htmlPath = makeBoardHtml(tmpDir);
const r = await fetchHandler(req("POST", "/api/boards", { html: htmlPath }));
expect(r.status).toBe(503);
});
});
// ─── Idle + meaningful activity ──────────────────────────────────
//
// The behavioral tests for idle shutdown — actual process exit, bare-GET-
// doesn't-reset-idle, MAX_EXTENSIONS hard ceiling — live in
// daemon-discovery.test.ts because they require a real spawned daemon
// (lastMeaningfulActivity isn't observable in-process). The in-process
// version of these tests previously was a smoke that the testing specialist
// correctly flagged as misleading; it was removed.
describe("daemon idle + activity tracking (smoke)", () => {
test("idleCheckTick on a freshly-touched daemon does not throw or shut down", () => {
markMeaningfulActivity();
expect(() => idleCheckTick()).not.toThrow();
// boards map shouldn't have been wiped (no graceful shutdown happened)
expect(typeof __testInternals__.boards.size).toBe("number");
});
});
// ─── Malformed body negatives ────────────────────────────────────
describe("daemon malformed body handling", () => {
test("POST /api/boards rejects invalid JSON body with 400", async () => {
const bad = new Request("http://127.0.0.1:1234/api/boards", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "{not json",
});
const r = await fetchHandler(bad);
expect(r.status).toBe(400);
const body = (await r.json()) as any;
expect(body.error).toContain("Invalid JSON");
});
test("POST /api/boards rejects non-object body (e.g. JSON null) with 400", async () => {
// JS quirk: `typeof [] === "object"`, so arrays slip past the
// !body || typeof body !== "object" guard and fail at the missing-html
// check below. The "Expected JSON object" path only fires for genuinely
// non-object values like null, numbers, strings.
const bad = new Request("http://127.0.0.1:1234/api/boards", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "null",
});
const r = await fetchHandler(bad);
expect(r.status).toBe(400);
const body = (await r.json()) as any;
expect(body.error).toContain("Expected JSON object");
});
test("POST /api/boards: array body falls through to missing-html 400", async () => {
// Documents the actual behavior — arrays bypass the type guard but get
// caught by the html-field check. If we ever tighten the type check to
// reject arrays explicitly, this test will surface the change.
const r = await fetchHandler(req("POST", "/api/boards", [1, 2, 3] as any));
expect(r.status).toBe(400);
const body = (await r.json()) as any;
expect(body.error).toContain("Missing 'html'");
});
test("POST /boards/<id>/api/reload rejects invalid JSON body with 400", async () => {
const board = await publishTestBoard();
const bad = new Request(
`http://127.0.0.1:1234/boards/${board.id}/api/reload`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: "{nope",
},
);
const r = await fetchHandler(bad);
expect(r.status).toBe(400);
});
test("POST /boards/<id>/api/reload rejects body missing html field with 400", async () => {
const board = await publishTestBoard();
const r = await fetchHandler(
req("POST", `/boards/${board.id}/api/reload`, { somethingElse: true }),
);
expect(r.status).toBe(400);
const body = (await r.json()) as any;
expect(body.error).toContain("HTML file not found");
});
});
// ─── Unknown routes ──────────────────────────────────────────────
describe("daemon unknown routes", () => {
test("404 on unknown path", async () => {
const r = await fetchHandler(req("GET", "/some/unknown/path"));
expect(r.status).toBe(404);
});
test("GET /api/boards (wrong method on publish endpoint) returns 404", async () => {
const r = await fetchHandler(req("GET", "/api/boards"));
expect(r.status).toBe(404);
});
});
@@ -0,0 +1,257 @@
/**
* End-to-end daemon round-trip test.
*
* Spawns a real design daemon and walks the full publish → submit /
* regenerate / reload cycle via HTTP fetch (the same calls the board JS
* makes). Proves what design-shotgun and the rest of the design skills
* depend on:
*
* - $D compare --serve attaches to OR spawns a single shared daemon.
* - Two boards published into the same daemon get independent paths
* under /boards/<id>/ — no port churn, no second process.
* - Submit writes feedback.json into the board's sourceDir with
* boardId + publishedAt fields the skill can poll for.
* - Regenerate writes feedback-pending.json, flips state to
* regenerating, /api/progress reflects it.
* - /api/reload swaps HTML in place — second GET returns new content.
* - Even with two concurrent boards in flight, feedback for one does
* not contaminate the other's sourceDir.
*
* Browser-driven round-trip (feedback-roundtrip.test.ts) covers the same
* flow at the click level for the legacy --no-daemon path; this file is
* the daemon-path equivalent.
*/
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import fs from "fs";
import path from "path";
import { publishBoard } from "../src/daemon-client";
import { readStateFile } from "../src/daemon-state";
import {
makeBoardHtml,
makeTmpDir,
spawnDaemonForTest,
type SpawnedDaemon,
} from "./daemon-tests-fixtures";
let workDir: string;
let stateFile: string;
let daemons: SpawnedDaemon[] = [];
beforeEach(() => {
workDir = makeTmpDir("roundtrip-daemon");
stateFile = path.join(workDir, "design.json");
process.env.DESIGN_DAEMON_STATE_FILE = stateFile;
});
afterEach(async () => {
for (const d of daemons.splice(0)) {
try { await d.stop(); } catch {}
}
try { fs.unlinkSync(stateFile); } catch {}
delete process.env.DESIGN_DAEMON_STATE_FILE;
try { fs.rmSync(workDir, { recursive: true, force: true }); } catch {}
});
async function spawn1(): Promise<SpawnedDaemon> {
const d = await spawnDaemonForTest({ stateFile, idleMs: 60_000 });
daemons.push(d);
return d;
}
// ─── Submit round-trip ───────────────────────────────────────────
describe("daemon round-trip: publish → submit → feedback.json", () => {
test("Submit feedback lands at sourceDir with boardId + publishedAt", async () => {
const d = await spawn1();
const boardDir = makeTmpDir("board-submit");
try {
const htmlPath = makeBoardHtml(boardDir, "<p>round-trip board</p>");
const board = await publishBoard({ port: d.port, html: htmlPath });
expect(board.url).toBe(`http://127.0.0.1:${d.port}/boards/${board.id}/`);
expect(board.sourceDir).toBe(fs.realpathSync(boardDir));
// GET the board URL — same path the browser would hit
const page = await fetch(board.url);
expect(page.status).toBe(200);
const pageHtml = await page.text();
expect(pageHtml).toContain("round-trip board");
// POST submit (mirrors what the board JS does on Submit click)
const submit = await fetch(`${board.url}api/feedback`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
preferred: "A",
ratings: { A: 5, B: 3 },
comments: { A: "love it" },
overall: "ship A",
regenerated: false,
}),
});
expect(submit.status).toBe(200);
const submitBody = (await submit.json()) as any;
expect(submitBody.action).toBe("submitted");
// The skill side polls for feedback.json in the source directory
const feedbackPath = path.join(board.sourceDir, "feedback.json");
expect(fs.existsSync(feedbackPath)).toBe(true);
const written = JSON.parse(fs.readFileSync(feedbackPath, "utf-8"));
expect(written.preferred).toBe("A");
expect(written.ratings).toEqual({ A: 5, B: 3 });
expect(written.regenerated).toBe(false);
// Augmented fields the daemon adds
expect(written.boardId).toBe(board.id);
expect(typeof written.publishedAt).toBe("string");
expect(written.publishedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
// The board's URL stays accessible after submit (history view)
const after = await fetch(board.url);
expect(after.status).toBe(200);
// Progress endpoint reflects done state
const progress = await fetch(`${board.url}api/progress`);
expect(((await progress.json()) as any).status).toBe("done");
} finally {
try { fs.rmSync(boardDir, { recursive: true, force: true }); } catch {}
}
});
test("GET /boards/<id> (no trailing slash) returns 301 to /boards/<id>/", async () => {
const d = await spawn1();
const boardDir = makeTmpDir("board-redir");
try {
const board = await publishBoard({
port: d.port,
html: makeBoardHtml(boardDir),
});
// Use redirect: 'manual' so we observe the 301 response itself
const res = await fetch(`http://127.0.0.1:${d.port}/boards/${board.id}`, {
redirect: "manual",
});
expect(res.status).toBe(301);
expect(res.headers.get("Location")).toBe(`/boards/${board.id}/`);
} finally {
try { fs.rmSync(boardDir, { recursive: true, force: true }); } catch {}
}
});
});
// ─── Regenerate + reload round-trip ──────────────────────────────
describe("daemon round-trip: publish → regenerate → reload → submit round 2", () => {
test("Full regen cycle: feedback-pending.json then reload swaps HTML", async () => {
const d = await spawn1();
const boardDir = makeTmpDir("board-regen");
try {
const r1Path = makeBoardHtml(boardDir, "<p>round 1 variants</p>");
const board = await publishBoard({ port: d.port, html: r1Path });
// Skill issues a regenerate via the board JS path
const regen = await fetch(`${board.url}api/feedback`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
preferred: "A",
ratings: { A: 4 },
regenerated: true,
regenerateAction: "more_like_A",
}),
});
expect(regen.status).toBe(200);
expect(((await regen.json()) as any).action).toBe("regenerate");
// Pending file exists, final feedback file does not
expect(fs.existsSync(path.join(board.sourceDir, "feedback-pending.json"))).toBe(true);
expect(fs.existsSync(path.join(board.sourceDir, "feedback.json"))).toBe(false);
// Progress reflects regenerating state
const prog1 = await fetch(`${board.url}api/progress`);
expect(((await prog1.json()) as any).status).toBe("regenerating");
// Agent generates round 2, writes a new HTML file, calls /api/reload
const r2Path = path.join(boardDir, "round2.html");
fs.writeFileSync(r2Path, "<!DOCTYPE html><html><body><p>round 2 variants</p></body></html>");
const reload = await fetch(`${board.url}api/reload`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ html: r2Path }),
});
expect(reload.status).toBe(200);
// Same URL now serves the round-2 content (no port change, no
// new browser tab — the user's existing tab can reload in place)
const r2Page = await fetch(board.url);
expect(await r2Page.text()).toContain("round 2 variants");
expect(((await (await fetch(`${board.url}api/progress`)).json()) as any).status).toBe(
"serving",
);
// User submits round 2
const finalSubmit = await fetch(`${board.url}api/feedback`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
preferred: "B",
ratings: { B: 5 },
regenerated: false,
}),
});
expect(finalSubmit.status).toBe(200);
const written = JSON.parse(
fs.readFileSync(path.join(board.sourceDir, "feedback.json"), "utf-8"),
);
expect(written.preferred).toBe("B");
expect(written.boardId).toBe(board.id);
} finally {
try { fs.rmSync(boardDir, { recursive: true, force: true }); } catch {}
}
});
});
// ─── Two-board, one-daemon attach behavior ───────────────────────
describe("daemon round-trip: two concurrent publishes share one daemon", () => {
test("Second publish attaches to the same daemon (no new spawn)", async () => {
const d = await spawn1();
const dirA = makeTmpDir("two-a");
const dirB = makeTmpDir("two-b");
try {
const a = await publishBoard({ port: d.port, html: makeBoardHtml(dirA) });
const b = await publishBoard({ port: d.port, html: makeBoardHtml(dirB) });
// Same daemon process — state file pid is stable
const state = readStateFile(stateFile);
expect(state!.pid).toBe(d.proc.pid);
// Two distinct board ids
expect(a.id).not.toBe(b.id);
// Both URLs serve their own content
const pageA = await fetch(a.url);
const pageB = await fetch(b.url);
expect(pageA.status).toBe(200);
expect(pageB.status).toBe(200);
// Feedback isolation: submit to A only affects A's sourceDir
await fetch(`${a.url}api/feedback`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ regenerated: false, preferred: "A" }),
});
expect(fs.existsSync(path.join(a.sourceDir, "feedback.json"))).toBe(true);
expect(fs.existsSync(path.join(b.sourceDir, "feedback.json"))).toBe(false);
// Index page lists both
const idx = await fetch(`http://127.0.0.1:${d.port}/`);
const idxHtml = await idx.text();
expect(idxHtml).toContain(a.id);
expect(idxHtml).toContain(b.id);
} finally {
try { fs.rmSync(dirA, { recursive: true, force: true }); } catch {}
try { fs.rmSync(dirB, { recursive: true, force: true }); } catch {}
}
});
});
+8 -11
View File
@@ -55,7 +55,7 @@ beforeAll(async () => {
serverState = 'serving';
// This server mirrors the real serve.ts behavior:
// - Injects __GSTACK_SERVER_URL into the HTML
// - Serves board HTML at / (board JS uses relative URLs)
// - Handles POST /api/feedback with file writes
// - Handles GET /api/progress for regeneration polling
// - Handles POST /api/reload for board swapping
@@ -67,11 +67,7 @@ beforeAll(async () => {
const url = new URL(req.url);
if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/index.html')) {
const injected = currentHtml.replace(
'</head>',
`<script>window.__GSTACK_SERVER_URL = '${url.origin}';</script>\n</head>`
);
return new Response(injected, {
return new Response(currentHtml, {
headers: { 'Content-Type': 'text/html; charset=utf-8' },
});
}
@@ -140,14 +136,15 @@ describe('Submit: browser click → feedback.json on disk', () => {
if (fs.existsSync(feedbackPath)) fs.unlinkSync(feedbackPath);
serverState = 'serving';
// Navigate to the board (served with __GSTACK_SERVER_URL injected)
// Navigate to the board (board JS uses relative URLs + location.protocol detect)
await handleWriteCommand('goto', [baseUrl], bm);
// Verify __GSTACK_SERVER_URL was injected
const hasServerUrl = await handleReadCommand('js', [
'!!window.__GSTACK_SERVER_URL'
// Verify the board detects HTTP mode (so postFeedback will actually fetch
// instead of falling into the file:// DOM-only path)
const httpDetected = await handleReadCommand('js', [
"location.protocol === 'http:' || location.protocol === 'https:'"
], bm);
expect(hasServerUrl).toBe('true');
expect(httpDetected).toBe('true');
// User picks variant A, rates it 5 stars
await handleReadCommand('js', [
+48 -9
View File
@@ -65,11 +65,9 @@ describe('Serve HTTP endpoints', () => {
const url = new URL(req.url);
if (req.method === 'GET' && url.pathname === '/') {
const injected = htmlContent.replace(
'</head>',
`<script>window.__GSTACK_SERVER_URL = '${url.origin}';</script>\n</head>`
);
return new Response(injected, {
// Board JS uses relative URLs (./api/feedback, ./api/progress)
// and a location.protocol feature-detect; no injection needed.
return new Response(htmlContent, {
headers: { 'Content-Type': 'text/html; charset=utf-8' },
});
}
@@ -118,12 +116,17 @@ describe('Serve HTTP endpoints', () => {
server.stop();
});
test('GET / serves HTML with injected __GSTACK_SERVER_URL', async () => {
test('GET / serves HTML with relative-path board JS (no injection)', async () => {
const res = await fetch(baseUrl);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain('__GSTACK_SERVER_URL');
expect(html).toContain(baseUrl);
// No more per-origin URL injection; board JS uses relative paths.
expect(html).not.toContain('__GSTACK_SERVER_URL');
expect(html).not.toContain(baseUrl);
// Board JS calls relative endpoints so the same HTML works at / and at
// /boards/<id>/ (daemon mode).
expect(html).toContain("fetch('./api/feedback'");
expect(html).toContain("fetch('./api/progress')");
expect(html).toContain('Design Exploration');
});
@@ -308,9 +311,12 @@ describe('Serve /api/reload — path traversal protection', () => {
}
// Production path validation — same as design/src/serve.ts
const resolvedReload = fs.realpathSync(path.resolve(body.html));
if (!resolvedReload.startsWith(allowedDir + path.sep) && resolvedReload !== allowedDir) {
if (!resolvedReload.startsWith(allowedDir + path.sep)) {
return Response.json({ error: `Path must be within: ${allowedDir}` }, { status: 403 });
}
if (!fs.statSync(resolvedReload).isFile()) {
return Response.json({ error: `Path must be a file, not a directory: ${body.html}` }, { status: 400 });
}
htmlContent = fs.readFileSync(resolvedReload, 'utf-8');
return Response.json({ reloaded: true });
})();
@@ -369,6 +375,39 @@ describe('Serve /api/reload — path traversal protection', () => {
const page = await fetch(baseUrl);
expect(await page.text()).toContain('Safe reload');
});
// Regression for the directory-instead-of-file guard (Codex finding).
// Before: resolvedReload === allowedDir passed the guard and then
// readFileSync threw EISDIR with no helpful message.
test('blocks reload when path resolves to the allowed directory itself', async () => {
const res = await fetch(`${baseUrl}/api/reload`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ html: tmpDir }),
});
// tmpDir does not satisfy startsWith(allowedDir + sep), so the within-dir
// check rejects with 403 — but importantly, no EISDIR crash.
expect(res.status).toBe(403);
});
test('blocks reload when path is a subdirectory (not a file)', async () => {
const subdir = path.join(tmpDir, 'subdir-not-a-file');
fs.mkdirSync(subdir, { recursive: true });
try {
const res = await fetch(`${baseUrl}/api/reload`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ html: subdir }),
});
// Inside allowedDir but a directory — must fail before readFileSync,
// with a clear "must be a file" error instead of EISDIR.
expect(res.status).toBe(400);
const data = await res.json();
expect(data.error).toContain('must be a file');
} finally {
try { fs.rmSync(subdir, { recursive: true, force: true }); } catch {}
}
});
});
// ─── Full lifecycle: regeneration round-trip ──────────────────────
+5 -2
View File
@@ -1414,8 +1414,11 @@ If the JSON contains `"regenerated": true`:
1. Read `regenerateAction` (or `remixSpec` for remix requests)
2. Generate new variants with `$D iterate` or `$D variants` using updated brief
3. Create new board with `$D compare`
4. POST the new HTML to the running server via `curl -X POST http://localhost:PORT/api/reload -H 'Content-Type: application/json' -d '{"html":"$_DESIGN_DIR/design-board.html"}'`
(parse the port from stderr: look for `SERVE_STARTED: port=XXXXX`)
4. POST the new HTML to the running board. Parse the board URL from stderr
(`BOARD_URL: http://127.0.0.1:N/boards/<id>/` — the daemon path) or fall
back to the legacy port (`SERVE_STARTED: port=N` — only emitted under
`--no-daemon`, hits `/api/reload` root). Daemon path:
`curl -X POST "${BOARD_URL}api/reload" -H 'Content-Type: application/json' -d '{"html":"$_DESIGN_DIR/design-board.html"}'`
5. Board auto-refreshes in the same tab
If `"regenerated": false`: proceed with the approved variant.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gstack",
"version": "1.44.1.0",
"version": "1.45.0.0",
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT",
"type": "module",
+17 -5
View File
@@ -1131,8 +1131,12 @@ This command generates the board HTML, starts an HTTP server on a random port,
and opens it in the user's default browser. **Run it in the background** with `&`
because the server needs to stay running while the user interacts with the board.
Parse the port from stderr output: `SERVE_STARTED: port=XXXXX`. You need this
for the board URL and for reloading during regeneration cycles.
Parse the board URL from stderr output. Default daemon path:
`BOARD_URL: http://127.0.0.1:N/boards/<id>/` (already includes the per-board
path; use this for the AskUserQuestion URL AND as the base for the reload
endpoint). Legacy `--no-daemon` path emits `SERVE_STARTED: port=XXXXX` and
serves a single board at `/`, with reload at `/api/reload` — only relevant
when an external caller explicitly passes `--no-daemon`.
**PRIMARY WAIT: AskUserQuestion with board URL**
@@ -1140,11 +1144,14 @@ After the board is serving, use AskUserQuestion to wait for the user. Include th
board URL so they can click it if they lost the browser tab:
"I've opened a comparison board with the design variants:
http://127.0.0.1:<PORT>/ — Rate them, leave comments, remix
<BOARD_URL> — Rate them, leave comments, remix
elements you like, and click Submit when you're done. Let me know when you've
submitted your feedback (or paste your preferences here). If you clicked
Regenerate or Remix on the board, tell me and I'll generate new variants."
Substitute `<BOARD_URL>` with the URL parsed from stderr (the daemon path
emits `BOARD_URL: http://127.0.0.1:N/boards/<id>/`).
**Do NOT use AskUserQuestion to ask which variant the user prefers.** The comparison
board IS the chooser. AskUserQuestion is just the blocking wait mechanism.
@@ -1188,8 +1195,13 @@ the approved variant.
2. If `regenerateAction` is `"remix"`, read `remixSpec` (e.g. `{"layout":"A","colors":"B"}`)
3. Generate new variants with `$D iterate` or `$D variants` using updated brief
4. Create new board: `$D compare --images "..." --output "$_DESIGN_DIR/design-board.html"`
5. Reload the board in the user's browser (same tab):
`curl -s -X POST http://127.0.0.1:PORT/api/reload -H 'Content-Type: application/json' -d '{"html":"$_DESIGN_DIR/design-board.html"}'`
5. Reload the board in the user's browser (same tab) — the URL is per-board
under daemon mode, so use `<BOARD_URL>` (from the `BOARD_URL:` stderr
line) as the base:
`curl -s -X POST "${BOARD_URL}api/reload" -H 'Content-Type: application/json' -d '{"html":"$_DESIGN_DIR/design-board.html"}'`
Under `--no-daemon` the reload endpoint is `/api/reload` at the legacy
port; this path only matters if the caller explicitly opted out of the
daemon.
6. The board auto-refreshes. **AskUserQuestion again** with the same board URL to
wait for the next round of feedback. Repeat until `feedback.json` appears.
+22 -7
View File
@@ -891,8 +891,11 @@ If the JSON contains \`"regenerated": true\`:
1. Read \`regenerateAction\` (or \`remixSpec\` for remix requests)
2. Generate new variants with \`$D iterate\` or \`$D variants\` using updated brief
3. Create new board with \`$D compare\`
4. POST the new HTML to the running server via \`curl -X POST http://localhost:PORT/api/reload -H 'Content-Type: application/json' -d '{"html":"$_DESIGN_DIR/design-board.html"}'\`
(parse the port from stderr: look for \`SERVE_STARTED: port=XXXXX\`)
4. POST the new HTML to the running board. Parse the board URL from stderr
(\`BOARD_URL: http://127.0.0.1:N/boards/<id>/\` — the daemon path) or fall
back to the legacy port (\`SERVE_STARTED: port=N\` — only emitted under
\`--no-daemon\`, hits \`/api/reload\` root). Daemon path:
\`curl -X POST "\${BOARD_URL}api/reload" -H 'Content-Type: application/json' -d '{"html":"$_DESIGN_DIR/design-board.html"}'\`
5. Board auto-refreshes in the same tab
If \`"regenerated": false\`: proceed with the approved variant.
@@ -919,8 +922,12 @@ This command generates the board HTML, starts an HTTP server on a random port,
and opens it in the user's default browser. **Run it in the background** with \`&\`
because the server needs to stay running while the user interacts with the board.
Parse the port from stderr output: \`SERVE_STARTED: port=XXXXX\`. You need this
for the board URL and for reloading during regeneration cycles.
Parse the board URL from stderr output. Default daemon path:
\`BOARD_URL: http://127.0.0.1:N/boards/<id>/\` (already includes the per-board
path; use this for the AskUserQuestion URL AND as the base for the reload
endpoint). Legacy \`--no-daemon\` path emits \`SERVE_STARTED: port=XXXXX\` and
serves a single board at \`/\`, with reload at \`/api/reload\` — only relevant
when an external caller explicitly passes \`--no-daemon\`.
**PRIMARY WAIT: AskUserQuestion with board URL**
@@ -928,11 +935,14 @@ After the board is serving, use AskUserQuestion to wait for the user. Include th
board URL so they can click it if they lost the browser tab:
"I've opened a comparison board with the design variants:
http://127.0.0.1:<PORT>/ — Rate them, leave comments, remix
<BOARD_URL> Rate them, leave comments, remix
elements you like, and click Submit when you're done. Let me know when you've
submitted your feedback (or paste your preferences here). If you clicked
Regenerate or Remix on the board, tell me and I'll generate new variants."
Substitute \`<BOARD_URL>\` with the URL parsed from stderr (the daemon path
emits \`BOARD_URL: http://127.0.0.1:N/boards/<id>/\`).
**Do NOT use AskUserQuestion to ask which variant the user prefers.** The comparison
board IS the chooser. AskUserQuestion is just the blocking wait mechanism.
@@ -976,8 +986,13 @@ the approved variant.
2. If \`regenerateAction\` is \`"remix"\`, read \`remixSpec\` (e.g. \`{"layout":"A","colors":"B"}\`)
3. Generate new variants with \`$D iterate\` or \`$D variants\` using updated brief
4. Create new board: \`$D compare --images "..." --output "$_DESIGN_DIR/design-board.html"\`
5. Reload the board in the user's browser (same tab):
\`curl -s -X POST http://127.0.0.1:PORT/api/reload -H 'Content-Type: application/json' -d '{"html":"$_DESIGN_DIR/design-board.html"}'\`
5. Reload the board in the user's browser (same tab) the URL is per-board
under daemon mode, so use \`<BOARD_URL>\` (from the \`BOARD_URL:\` stderr
line) as the base:
\`curl -s -X POST "\${BOARD_URL}api/reload" -H 'Content-Type: application/json' -d '{"html":"$_DESIGN_DIR/design-board.html"}'\`
Under \`--no-daemon\` the reload endpoint is \`/api/reload\` at the legacy
port; this path only matters if the caller explicitly opted out of the
daemon.
6. The board auto-refreshes. **AskUserQuestion again** with the same board URL to
wait for the next round of feedback. Repeat until \`feedback.json\` appears.