mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-30 06:49:30 +02:00
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:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) =>
|
||||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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
@@ -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");
|
||||
|
||||
@@ -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 {}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 {}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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', [
|
||||
|
||||
@@ -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 ──────────────────────
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user