mirror of https://github.com/garrytan/gstack.git
Merge origin/main: rebump v1.45→v1.46 (queue collision with design-daemon)
Main shipped v1.45.0.0 (persistent design daemon) at cf50443b while this
branch was at v1.45.0.0 (gstack v2 foundation). Same-MINOR-bump-level
queue collision per CLAUDE.md versioning invariant — advance this branch
to v1.46.0.0 to claim the next slot.
Resolved CHANGELOG.md conflict: kept both v1.45.0.0 (design daemon, main)
and v1.46.0.0 (gstack v2 foundation, this branch) entries in
reverse-chronological order. Updated VERSION, package.json, and renamed
test/fixtures/parity-baseline-v1.45.0.0.json → -v1.46.0.0.json with the
internal tag field synced.
Updated CHANGELOG entry numbers-table column header from v1.45.0.0 to
v1.46.0.0 + the source-reproduction line + the "v1.45 absorbs..." prose
and the eval target reference in skill-size-budget.test.ts comment.
Auto-merged main's design-daemon SKILL.md changes for design-consultation,
design-shotgun, office-hours, plan-design-review. Regenerated all SKILL.md
files via gen-skill-docs --host all to ensure clean state. Refreshed golden
ship snapshots (claude/codex/factory).
Test plan:
- bun test test/skill-validation.test.ts test/writing-style-resolver.test.ts
test/host-config.test.ts test/skill-size-budget.test.ts
test/parity-suite.test.ts test/skill-coverage-matrix.test.ts
test/skill-coverage-floor.test.ts test/cso-preserved.test.ts
test/resolver-entry.test.ts test/helpers/capture-parity-baseline.test.ts
test/gen-skill-docs.test.ts: 1134 pass, 0 fail
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
24742f9dac
69
CHANGELOG.md
69
CHANGELOG.md
|
|
@ -1,18 +1,18 @@
|
|||
# Changelog
|
||||
|
||||
## [1.45.0.0] - 2026-05-26
|
||||
## [1.46.0.0] - 2026-05-26
|
||||
|
||||
## **gstack v2 foundation lands. Catalog tokens drop 56%, eval-first floor covers all 51 skills, hard token + dollar caps gate every PR.**
|
||||
|
||||
The always-loaded skill catalog — what every Claude Code session pays for at startup before any real work begins — went from ~9,319 tokens to ~4,045 tokens. That's a 56.6% cut to the surface gstack has been criticized for (third-party review, May 2026: "10K+ tokens before any real code is written"). Heavyweight skills like `/ship`, `/plan-ceo-review`, `/office-hours` still ship their full content, but their frontmatter descriptions trim to one sentence each; the routing prose lives in a new "## When to invoke" body section, and a per-run `scripts/proactive-suggestions.json` registry holds the voice-trigger + proactive-suggest text so agents can pull guidance on demand instead of always-loaded.
|
||||
|
||||
This is the v2 foundation release. The architectural break — `sections/*.md.tmpl` pattern, mechanical Read enforcement, eval-coverage annotations — lands in v2.0.0.0 as a coordinated launch. v1.45 absorbs every low-risk win, ships the eval-first floor every future skill must pass, and locks in the v1.44.1 reference baseline so reviewers can audit v1→v2 numbers against a real file (`test/fixtures/parity-baseline-v1.44.1.json`).
|
||||
This is the v2 foundation release. The architectural break — `sections/*.md.tmpl` pattern, mechanical Read enforcement, eval-coverage annotations — lands in v2.0.0.0 as a coordinated launch. v1.46 absorbs every low-risk win, ships the eval-first floor every future skill must pass, and locks in the v1.44.1 reference baseline so reviewers can audit v1→v2 numbers against a real file (`test/fixtures/parity-baseline-v1.44.1.json`).
|
||||
|
||||
### The numbers that matter
|
||||
|
||||
Source: `bun run scripts/capture-baseline.ts --tag v1.45.0.0` vs the locked v1.44.1 baseline at `test/fixtures/parity-baseline-v1.44.1.json`. Reproduce locally with `bun test test/skill-size-budget.test.ts`.
|
||||
Source: `bun run scripts/capture-baseline.ts --tag v1.46.0.0` vs the locked v1.44.1 baseline at `test/fixtures/parity-baseline-v1.44.1.json`. Reproduce locally with `bun test test/skill-size-budget.test.ts`.
|
||||
|
||||
| Metric | v1.44.1 | v1.45.0.0 | Δ |
|
||||
| Metric | v1.44.1 | v1.46.0.0 | Δ |
|
||||
|---|---|---|---|
|
||||
| Catalog tokens (always-loaded system prompt) | ~9,319 | ~4,045 | **−56.6%** |
|
||||
| Total SKILL.md corpus | 2,847 KB | 2,813 KB | −1.2% |
|
||||
|
|
@ -40,7 +40,7 @@ If you run gstack in CI, the new `EVALS_BUDGET_HARD_CAP=$30` cap (per-suite: gat
|
|||
- `test/helpers/parity-harness.ts` + `test/parity-suite.test.ts` — cathedral parity-eval suite floor. `PARITY_INVARIANTS` registry pins must-preserve phrases per skill family (cso: OWASP/STRIDE; plan-ceo: SCOPE EXPANSION / HOLD SCOPE; ship: VERSION/CHANGELOG/PR) so future compression can't silently strip load-bearing prose.
|
||||
- `test/skill-coverage-matrix.ts` + `test/skill-coverage-matrix.test.ts` — single source of truth mapping each skill to gate + periodic tests; CI gate asserts every skill has at least one gate-tier entry. 51 skills, 51 entries.
|
||||
- `test/skill-coverage-floor.test.ts` — per-skill structural-compliance smoke test (file-IO, free). Verifies frontmatter shape, generated header, body non-trivial, no leaked `{{TEMPLATE}}` placeholders, catalog-trim contract on description. 309 assertions across 51 skills.
|
||||
- `test/skill-size-budget.test.ts` — per-skill SKILL.md byte budget (×1.05 default ratio), total corpus budget, catalog token budget (≤7000 for v1.45). Caught regressions get a per-skill breakdown + override path.
|
||||
- `test/skill-size-budget.test.ts` — per-skill SKILL.md byte budget (×1.05 default ratio), total corpus budget, catalog token budget (≤7000 for v1.46). Caught regressions get a per-skill breakdown + override path.
|
||||
- `test/cso-preserved.test.ts` — pins cso's must-not-strip security guidance phrases (OWASP, STRIDE, daily/comprehensive mode discipline, confidence scoring, active verification). Future compression that hits cso fails CI here.
|
||||
- `test/helpers/budget-override.ts` — audit-trail logger for `GSTACK_SIZE_BUDGET_OVERRIDE_REASON` and `EVALS_BUDGET_OVERRIDE_REASON`. Append-only JSONL at `~/.gstack/analytics/spend-overrides.jsonl` with timestamp + scope + reason + CI provenance.
|
||||
- `scripts/proactive-suggestions.json` — per-run registry of routing prose + voice triggers extracted from skill frontmatter during catalog trim. Agents pull on demand instead of paying for it always-loaded.
|
||||
|
|
@ -63,6 +63,65 @@ If you run gstack in CI, the new `EVALS_BUDGET_HARD_CAP=$30` cap (per-suite: gat
|
|||
- The `scripts/jargon-list.json` is the canonical glossary. Add terms there; gen-skill-docs picks them up automatically on next regen.
|
||||
- `test/fixtures/parity-baseline-v1.44.1.json` is the locked v1→v2 reference. Do not modify; capture new snapshots at later tags via `bun run scripts/capture-baseline.ts --tag <name>`.
|
||||
|
||||
## [1.45.0.0] - 2026-05-25
|
||||
|
||||
## **Design boards now live 24 hours, not 10 minutes. One daemon hosts every board, one tab survives the whole day.**
|
||||
|
||||
Run `$D compare --serve` and you get a persistent design daemon at `.gstack/design.json` instead of a fresh process per call. Open three design sessions across an afternoon and they all land at `/boards/<id>/` on the same port. The browser tab you opened first still works for the board you published an hour later. The idle timeout went from 10 minutes (the old per-process server) to 24 hours of inactivity (the daemon's lifetime). Submit a board, the URL stays accessible until the daemon idles out, so you can scroll back through the day's design history at `http://127.0.0.1:N/`.
|
||||
|
||||
Skill invocations (`/design-shotgun`, `/design-consultation`, `/plan-design-review`, `/design-review`, `/office-hours`) keep calling `$D compare --serve` exactly the same way. The CLI shape is unchanged. What's different is the binary now self-execs into daemon mode under the hood, attaches to a running daemon if one is there, spawns a fresh one if not, and prints `BOARD_PUBLISHED: http://127.0.0.1:N/boards/<id>/` to stderr so the skill can echo the URL. The legacy `--no-daemon` flag preserves the old single-process behavior for tests and debugging.
|
||||
|
||||
### The numbers that matter
|
||||
|
||||
Source: `bun test design/test/` and `git diff origin/main...HEAD --stat`.
|
||||
|
||||
| Metric | Before | After | Δ |
|
||||
|-----------------------------------------|---------------|---------------|----------------|
|
||||
| Idle timeout per board | 10 minutes | 24 hours | 144× |
|
||||
| Server processes for N boards | N | 1 | N× |
|
||||
| Browser tabs to keep open | one per board | one total | N× |
|
||||
| Design tests in repo | 16 | 77 | +61 |
|
||||
| Test paths covered (failure modes) | not enumerated| 38 / 100% | full coverage |
|
||||
| Plan-review findings absorbed pre-impl | 2 | 19 | 17× from Codex |
|
||||
|
||||
| Component | New lines | Test lines |
|
||||
|----------------------------|-----------|------------|
|
||||
| design/src/daemon.ts | ~580 | 34 tests |
|
||||
| design/src/daemon-client.ts| ~340 | 23 tests |
|
||||
| design/src/daemon-state.ts | ~180 | (via client + daemon tests; direct stale-lock reclaim coverage) |
|
||||
| Browser round-trip via HTTP| (existed) | 4 tests |
|
||||
|
||||
The compression: 61 new tests cover every endpoint, lifecycle path, LRU eviction, real idle-shutdown behavior (spawn-based, daemon process observed exiting after `IDLE_MS`), the bare-GET-doesn't-reset-idle invariant (poll loop in background, daemon still idles out), the idle-with-active-boards extension path with `MAX_EXTENSIONS` hard ceiling, concurrent-CLIs lock race (two parallel `ensureDaemon` calls converge on one daemon), identity-verified spawn, version mismatch with and without active boards, PID-reuse safety, path traversal rejection, malformed-body negatives on every POST, and cross-board feedback isolation. The plan-review pass caught 2 architectural issues in-house; an outside Codex pass caught 17 more, all absorbed into the implementation before any code was written; the /ship review army caught 1 backwards-compat break in skill resolvers (fixed) + 5 deferred test gaps (filled). The version-mismatch path now refuses to silently kill a daemon with active boards (it prints a warning and exits 1), so upgrading gstack mid-design-session doesn't drop your in-memory board history.
|
||||
|
||||
### What this means for the builder
|
||||
|
||||
Open `/design-shotgun` Monday morning, work through three rounds of variants, walk away for lunch, come back, click Submit. The board is still there. Open a second `/design-shotgun` for a different feature in the afternoon, get a new URL at `/boards/<another-id>/`, no port churn, your morning board still works. The whole day's worth of design exploration accumulates as a browsable history at the daemon's root. Stop worrying about the 10-minute death clock.
|
||||
|
||||
### Itemized changes
|
||||
|
||||
#### Added
|
||||
- **Persistent design daemon** (`design/src/daemon.ts`). Bun HTTP server on `127.0.0.1` hosting many boards under `/boards/<id>/`. Per-board state machine (`serving | regenerating | done`), LRU cap of 50 boards (evicts `done` first, returns 503 when 50 non-done coexist), 24h idle timeout with 1h extensions up to a 28h ceiling when boards are still active, per-board async mutex serializing feedback POST vs reload POST. Index page at `/` lists recent boards newest first.
|
||||
- **`$D daemon status`** and **`$D daemon stop [--force]`**. The stop sub-command refuses without `--force` when active boards exist, so a casual stop doesn't drop in-flight history.
|
||||
- **Daemon client** (`design/src/daemon-client.ts`). `ensureDaemon()` handles spawn-or-attach with file-lock-protected spawn (re-reads state inside the lock to close the two-CLIs-race window) and identity-verified SIGTERM (reads `/proc/PID/cmdline` on Linux, `ps -p PID -o command=` on macOS, only signals if `gstack-design-daemon` is in the cmdline). PID-reuse safety: if the state file points at a PID belonging to an unrelated process, no signal is sent and a fresh daemon spawns. Version-mismatch refusal: if a CLI from a newer gstack version arrives while boards are still open in an older daemon, the CLI prints a user-actionable warning and exits 1 instead of silently restarting and losing history.
|
||||
- **Shared daemon state utilities** (`design/src/daemon-state.ts`). Atomic state-file write (`<tmp>` + `renameSync` at mode `0o600`), `fs.openSync('wx')` exclusive lock, cross-platform cmdline reader, version lookup that falls back through `DESIGN_DAEMON_VERSION` env → `design/dist/.version` baked at build time → source-tree `VERSION` → `"unknown"`.
|
||||
- **End-to-end round-trip tests against a real spawned daemon** (`design/test/feedback-roundtrip-daemon.test.ts`). HTTP fetch drives publish → submit → regenerate → reload → round-2 submit, asserting `feedback.json` lands at the daemon-derived `sourceDir` with `boardId` and `publishedAt` augmented fields.
|
||||
|
||||
#### Changed
|
||||
- **Board JS uses relative URLs** instead of an injected `__GSTACK_SERVER_URL` global. The same generated HTML works at `/` (legacy `--no-daemon`) and `/boards/<id>/` (daemon). `location.protocol` feature-detect keeps the `file://` DOM-only fallback path working.
|
||||
- **Bare `GET /boards/<id>` returns 301** to `/boards/<id>/`. The trailing slash is load-bearing for relative-URL resolution in the board JS; without it, `fetch('./api/feedback')` would resolve to the wrong scope.
|
||||
- **Reload guard rejects directory paths**. `design/src/serve.ts:200-212` previously let `resolvedReload === allowedDir` through, which then crashed `readFileSync` with `EISDIR`. Now requires `statSync(resolvedReload).isFile()` with a clear 400 instead.
|
||||
- **Feedback files carry `boardId` and `publishedAt`** so agents polling `feedback.json` / `feedback-pending.json` in a multi-board world can verify which board produced what.
|
||||
- **`sourceDir` is derived from `realpath(html)` server-side**, never trusted from the publish POST body.
|
||||
- **Skill resolvers and templates** (`scripts/resolvers/design.ts`, `design-shotgun/SKILL.md`, `design-consultation/SKILL.md`, `plan-design-review/SKILL.md`, `office-hours/SKILL.md`) updated to parse `BOARD_URL:` from stderr and POST reloads to `${BOARD_URL}api/reload` instead of the legacy port-only `/api/reload`. Legacy `SERVE_STARTED: port=N html=...` line still emitted for back-compat.
|
||||
|
||||
#### Fixed
|
||||
- **Compiled design binary self-execs as the daemon** via a `--daemon-mode` flag, so the daemon lifecycle works for users installing from `design/dist/design` (not just `bun run` against the source tree).
|
||||
- **Version lookup** is consistent between client and daemon. Both go through `readVersionString()`, so the version-mismatch refusal path works on the compiled binary instead of always reading `"unknown"` and matching itself.
|
||||
|
||||
#### For contributors
|
||||
- **Test infrastructure split**: `design/test/daemon.test.ts` (30 in-process tests against the exported `fetchHandler`, ~70ms) for fast iteration; `design/test/daemon-discovery.test.ts` (17 real-spawn tests, ~8s) for lifecycle + lock + identity guarantees. Shared helpers in `design/test/daemon-tests-fixtures.ts`.
|
||||
- **Plan-review process**: this branch ran `/plan-eng-review` twice. Round 1 caught 2 architecture findings. An outside-voice Codex pass after round 1 found 17 more (URL contract self-contradiction, false test-green claim, lock semantics, identity verification, version-mismatch silent data loss, several others). Round 2 absorbed all 17 before implementation started. The full review trail is preserved in the plan file's `## GSTACK REVIEW REPORT` section.
|
||||
|
||||
## [1.44.1.0] - 2026-05-24
|
||||
|
||||
## **Nine community fixes ship in one bundle.** Office-hours session counter works again, iOS QA tunnels survive macOS 26.x, Windows brain-sync stops dropping artifacts, browse server tells you whether the bind failure was a port collision or a sandbox block.
|
||||
|
|
|
|||
48
TODOS.md
48
TODOS.md
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -1232,8 +1232,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**
|
||||
|
||||
|
|
@ -1241,11 +1245,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.
|
||||
|
||||
|
|
@ -1289,8 +1296,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.
|
||||
|
||||
|
|
|
|||
|
|
@ -1119,8 +1119,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**
|
||||
|
||||
|
|
@ -1128,11 +1132,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.
|
||||
|
||||
|
|
@ -1176,8 +1183,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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
|
|
@ -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 ──────────────────────
|
||||
|
|
|
|||
|
|
@ -1341,8 +1341,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,6 +1,6 @@
|
|||
{
|
||||
"name": "gstack",
|
||||
"version": "1.45.0.0",
|
||||
"version": "1.46.0.0",
|
||||
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -1057,8 +1057,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**
|
||||
|
||||
|
|
@ -1066,11 +1070,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.
|
||||
|
||||
|
|
@ -1114,8 +1121,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.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
{
|
||||
"tag": "v1.45.0.0",
|
||||
"capturedAt": "2026-05-26T03:38:39.714Z",
|
||||
"capturedFromCommit": "296937d4",
|
||||
"tag": "v1.46.0.0",
|
||||
"capturedAt": "2026-05-26T04:17:57.247Z",
|
||||
"capturedFromCommit": "2aff29e9",
|
||||
"capturedFromBranch": "garrytan/slim-skill-tokens",
|
||||
"totalSkills": 51,
|
||||
"totalCorpusBytes": 2880003,
|
||||
"totalCorpusBytes": 2882468,
|
||||
"estTotalCatalogTokens": 4045,
|
||||
"topHeaviest": [
|
||||
{
|
||||
|
|
@ -29,9 +29,9 @@
|
|||
},
|
||||
{
|
||||
"skill": "office-hours",
|
||||
"skillMdBytes": 110225,
|
||||
"skillMdLines": 2017,
|
||||
"estTokens": 27556,
|
||||
"skillMdBytes": 110388,
|
||||
"skillMdLines": 2020,
|
||||
"estTokens": 27597,
|
||||
"tmplBytes": 55466,
|
||||
"descriptionLen": 860,
|
||||
"hasGateEval": true,
|
||||
|
|
@ -39,9 +39,9 @@
|
|||
},
|
||||
{
|
||||
"skill": "plan-design-review",
|
||||
"skillMdBytes": 104737,
|
||||
"skillMdLines": 1870,
|
||||
"estTokens": 26184,
|
||||
"skillMdBytes": 105401,
|
||||
"skillMdLines": 1882,
|
||||
"estTokens": 26350,
|
||||
"tmplBytes": 28624,
|
||||
"descriptionLen": 218,
|
||||
"hasGateEval": true,
|
||||
|
|
@ -211,9 +211,9 @@
|
|||
},
|
||||
"design-consultation": {
|
||||
"skill": "design-consultation",
|
||||
"skillMdBytes": 75794,
|
||||
"skillMdLines": 1497,
|
||||
"estTokens": 18949,
|
||||
"skillMdBytes": 76768,
|
||||
"skillMdLines": 1515,
|
||||
"estTokens": 19192,
|
||||
"tmplBytes": 25899,
|
||||
"descriptionLen": 888,
|
||||
"hasGateEval": true,
|
||||
|
|
@ -241,9 +241,9 @@
|
|||
},
|
||||
"design-shotgun": {
|
||||
"skill": "design-shotgun",
|
||||
"skillMdBytes": 59718,
|
||||
"skillMdLines": 1253,
|
||||
"estTokens": 14930,
|
||||
"skillMdBytes": 60382,
|
||||
"skillMdLines": 1265,
|
||||
"estTokens": 15096,
|
||||
"tmplBytes": 13331,
|
||||
"descriptionLen": 786,
|
||||
"hasGateEval": false,
|
||||
|
|
@ -421,9 +421,9 @@
|
|||
},
|
||||
"office-hours": {
|
||||
"skill": "office-hours",
|
||||
"skillMdBytes": 110225,
|
||||
"skillMdLines": 2017,
|
||||
"estTokens": 27556,
|
||||
"skillMdBytes": 110388,
|
||||
"skillMdLines": 2020,
|
||||
"estTokens": 27597,
|
||||
"tmplBytes": 55466,
|
||||
"descriptionLen": 860,
|
||||
"hasGateEval": true,
|
||||
|
|
@ -461,9 +461,9 @@
|
|||
},
|
||||
"plan-design-review": {
|
||||
"skill": "plan-design-review",
|
||||
"skillMdBytes": 104737,
|
||||
"skillMdLines": 1870,
|
||||
"estTokens": 26184,
|
||||
"skillMdBytes": 105401,
|
||||
"skillMdLines": 1882,
|
||||
"estTokens": 26350,
|
||||
"tmplBytes": 28624,
|
||||
"descriptionLen": 218,
|
||||
"hasGateEval": true,
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
/**
|
||||
* Per-skill SKILL.md size budget regression (v1.45.0.0 T5).
|
||||
* Per-skill SKILL.md size budget regression (v1.46.0.0 T5).
|
||||
*
|
||||
* Asserts that no skill's generated SKILL.md grew beyond the v1.44.1
|
||||
* baseline. Catches preamble/resolver changes that bloat skills back to
|
||||
|
|
|
|||
Loading…
Reference in New Issue