Skip to content

Commit ecd4322

Browse files
authored
Agent evidence workflow: principles docs, e2e dev CLI, typed emulator client (#977)
* Document the service emulators: AGENTS.md note + emulate skill When tests or demos need an upstream API, OAuth provider, or webhook source, agents should reach for the @executor-js/emulate emulators instead of hand-writing stubs. The new skill captures the control-plane reference (/_emulate/openapi, /credentials, /ledger, /seed), the hosted-instance addressing, the connect-handoff ledger-assertion pattern, and the hard-won gotchas (Secure cookies need HTTPS off-localhost; per-boot id counters need a fresh app DB). .gitignore: carve .claude/skills/ out of the .claude/ ignore so skills are shareable repo state while worktrees/settings stay local. * Restructure agent docs: principles in AGENTS.md, mechanics in RUNNING.md Root AGENTS.md becomes the stable contract layer and gains the missing piece both recent agent sessions fumbled: a definition of done for user-visible work. Done is something the user can open — (1) an e2e scenario with direct links to the runs that prove the change, (2) the session's dev server left up and reachable over the tailnet so the user can take over, (3) an honest list of what to try by hand. The same machinery runs in reverse: seed an environment into a state (live bug repro, staged data) and hand across the link. Mechanics move to a new RUNNING.md that opens by declaring itself possibly stale — trust it as a starting point, verify against the code, update on drift. It carries setup, the boot recipes (pointing at the e2e globalsetup files as source of truth), run/viewer/sharing operations, the leave-an-instance-up recipe with the Secure-cookie/ HTTPS constraint, and the hard-won environment gotchas. e2e/AGENTS.md points at both. Docs deliberately avoid pinning URL formats and flags that churn — agents discover those. * Add the e2e dev CLI: the scenario primitives, interactive cd e2e && bun run cli — boot a target and keep it up (up [--share]), mint identities, make typed API calls, drive MCP sessions, read the emulator request ledger, and tear down (down). The point: develop against a live instance with the exact machinery the tests use, then crystallize the journey into a scenario — and the booted instance IS the handoff demo (AGENTS.md evidence contract) and the seeding vehicle (drive a bug repro or staged state, hand across the URL). The boot recipes move out of the vitest globalsetups into shared setup/<target>.boot.ts modules — one boot path for suites (ephemeral) and the CLI (persistent, detached runner owning cloud's in-process WorkOS/Autumn emulators). up --share binds the tailnet: plain http for selfhost; for cloud it fronts both the app and the WorkOS emulator with tailscale-serve HTTPS (Secure cookies) and threads the public origins through WORKOS_API_URL / emulator baseUrl / Vite allowed hosts. Instances are tracked in e2e/.dev/<target>.json so a deliberate long-lived instance is distinguishable from a leak; logs land next to the state file (boot.ts gains logFile + pids for that). Verified: both targets up/down cleanly through the CLI, identity/api/ mcp/ledger all answer, a scenario attaches to a CLI-booted instance via E2E_SELFHOST_URL, both refactored globalsetups boot standalone (api-tools green on cloud + selfhost), full suite run — the two cloud failures (sources-api envelope, auth-methods mixed-source) reproduce identically on the clean baseline, so they predate this change. * Migrate to @executor-js/emulate 0.7.0's typed control-plane client 0.7.0 ships EmulatorClient + connectEmulator: one typed handle over the /_emulate control plane (credentials.mint, ledger.list/clear, seed, reset, manifest, ...) wherever the emulator runs — locally spawned or hosted. Every hand-rolled fetch + response cast goes away: - connect-handoff: mints via credentials.mint and asserts against LedgerEntry[] (match on request.body) instead of substring-searching the raw ledger text. - targets/cloud setAccessTokenTtl: client.seed() instead of a raw POST. - CLI ledger command: typed entries, JSON output. - emulate skill: the client table replaces the raw-endpoint table as the primary interface; raw /_emulate stays documented for curl use. Also folds the CLI onto src/ports.ts claimPorts (from the worktree-env PR this branch now stacks on) — the CLI's ad-hoc probe-and-walk is gone; a held block lock marks the instance and releases on down. Verified: connect-handoff green on selfhost + cloud (the ledger assertion exercises the hosted resend emulator's typed client), the token-expiry mcp-execute scenario green on cloud (exercises seed).
1 parent 2ae9f50 commit ecd4322

16 files changed

Lines changed: 1110 additions & 184 deletions

File tree

.claude/skills/emulate/SKILL.md

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
---
2+
name: emulate
3+
description: Use the @executor-js/emulate service emulators (GitHub, Google, Stripe, Resend, WorkOS, …) to test integrations for real — full OpenAPI specs, working OAuth flows, mintable credentials, and a request ledger for assertions. Use when a test or demo needs a real-shaped upstream API, an OAuth/OIDC provider, a spec to feed addSpec, or proof that a request actually landed.
4+
---
5+
6+
# Emulate: production-fidelity service emulators
7+
8+
`@executor-js/emulate` (our fork of Vercel Labs' emulate, developed in
9+
`vendor/emulate` but ALWAYS consumed as the published npm package — never
10+
import from `vendor/`) provides stateful, wire-level emulators for 16
11+
services: `github vercel google okta microsoft spotify slack apple aws
12+
resend stripe mongoatlas clerk x workos autumn`. These are not mocks: real
13+
SDKs and real product code run against them unmodified — the cloud e2e
14+
target points the actual WorkOS SDK (sealed sessions, JWKS, hosted AuthKit
15+
login) and Autumn billing at emulators and exercises the product's real
16+
auth code.
17+
18+
## Two ways to get one
19+
20+
**Local, programmatic** (what `e2e/setup/cloud.boot.ts` does):
21+
22+
```ts
23+
import { createEmulator } from "@executor-js/emulate";
24+
const github = await createEmulator({ service: "github", port: 4501 });
25+
// github.url, await github.close() — plus the full typed client below
26+
```
27+
28+
`baseUrl` sets the _advertised_ origin (redirects, form actions, spec
29+
`servers`) when a proxy fronts the emulator — the bind stays on `port`.
30+
31+
**Attach to a running one** (another process, or hosted on Cloudflare with
32+
Durable Object state at `https://<service>.emulators.dev` /
33+
`https://<service>.<instance>.emulators.dev` — catalog at
34+
`GET https://emulators.dev/_emulate/services`):
35+
36+
```ts
37+
import { connectEmulator } from "@executor-js/emulate";
38+
const resend = await connectEmulator({ baseUrl: "https://resend.emulators.dev" });
39+
// optional: { service: "resend" } verifies the manifest on connect
40+
```
41+
42+
Create a private hosted instance with `POST /_emulate/instances` on the
43+
service host.
44+
45+
## The typed control-plane client
46+
47+
Both `createEmulator` and `connectEmulator` return the same `EmulatorClient`
48+
surface (since 0.7.0) — use it instead of hand-rolling `/_emulate` fetches:
49+
50+
| Call | Use |
51+
| ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ |
52+
| `client.openapiUrl` | The spec URL — feed it straight to Executor's addSpec to register the emulator as an integration |
53+
| `client.credentials.mint({type:"api-key"})` | `IssuedCredential` in the service's real shape: API keys, bearer tokens, OAuth/OIDC clients, client-credentials apps |
54+
| `client.ledger.list()` / `.clear()` | `LedgerEntry[]`: matched operationId, sanitized headers/body, auth identity, response status, webhook deliveries |
55+
| `client.seed({...})` | Add state via the service's seed schema (e.g. WorkOS `{oauth:{default_access_token_ttl_seconds:60}}` to compress token expiry) |
56+
| `client.reset()` | Reset state + logs, replay seed — works remotely, unlike the old local-only reset |
57+
| `client.manifest()` / `.quickstart()` / `.specs()` / `.coverage()` | What the service is, which operations are real vs partial |
58+
| `client.state()` / `.logs()` / `.connections()` | Store snapshot, webhook deliveries, copyable SDK/env/curl snippets |
59+
60+
The same routes exist as raw HTTP under `/_emulate/*` (start at
61+
`GET /_emulate/quickstart`, written for agents) for curl/browser use — but in
62+
TypeScript, reach for the client; the types are the point.
63+
64+
## Recipes
65+
66+
**Test an integration end-to-end for real** (the `connect-handoff` pattern):
67+
`client.credentials.mint(...)` → register `client.openapiUrl` with the
68+
product → invoke a tool through the product → find the call in
69+
`client.ledger.list()` (match on `entry.request.body` / `operationId`). The
70+
ledger is the proof — "the product made this exact upstream call with this
71+
auth" — which beats asserting on the product's own response.
72+
73+
**Real OAuth/OIDC flows**: google/okta/microsoft/apple/clerk/workos mint
74+
OAuth clients and run real authorize/token endpoints. The WorkOS emulator
75+
additionally serves hosted AuthKit login pages (any email signs in — users
76+
are minted on the fly, no password), an OAuth authorization server for MCP
77+
clients, and Vault KV. Real SDK + `WORKOS_API_URL` override = the product's
78+
untouched auth code against it. Set `EMULATE_WORKOS_AUDIENCE=<client_id>`
79+
before `createEmulator` so minted MCP access tokens carry the right audience.
80+
81+
**A live, human-pokeable cloud instance with zero .env**:
82+
`cd e2e && bun run cli up cloud --share` — WorkOS + Autumn emulators + the
83+
app's real dev stack (recipe in `e2e/setup/cloud.boot.ts`), fronted with
84+
tailscale HTTPS.
85+
86+
## Gotchas
87+
88+
- **Secure cookies need HTTPS off-localhost.** Browser-driven flows work on
89+
`127.0.0.1`, but from another device (tailnet) the app's `secure: true`
90+
auth cookies are dropped over http → "Invalid login state". Front BOTH the
91+
app and the emulator with HTTPS (`tailscale serve`), and give the emulator
92+
its public origin via `baseUrl` AND the app's `WORKOS_API_URL` — the
93+
authorize URL the browser follows is derived from the latter.
94+
- **State is per-process and id counters restart.** The WorkOS emulator
95+
mints org ids from a per-boot counter — a persisted app DB from a previous
96+
boot collides with new ids. Wipe the app's data dir when you restart the
97+
emulator (the e2e globalsetup and cloud-demo both do).
98+
- **Don't hand-write fake upstreams.** If a scenario needs an upstream API,
99+
OAuth provider, or webhook source, reach for an emulator before writing a
100+
bespoke stub server — you get specs, auth, and the ledger for free, and
101+
the e2e AGENTS.md "never modify product code or stubs" rule stays intact.

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,9 @@ apps/host-selfhost/.e2e-data*/
7070
test-results/
7171
playwright-report/
7272
.last-run.json
73-
.claude/
73+
# .claude is local agent state (worktrees, settings), EXCEPT checked-in skills
74+
.claude/*
75+
!.claude/skills/
7476
.nitro/
7577
.output/
7678
.tanstack/

AGENTS.md

Lines changed: 52 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,11 @@
11
# AGENTS.md
22

3-
## Fresh Checkout / Worktree Setup
4-
5-
Run `bun run bootstrap` first in any fresh checkout or worktree. It is
6-
idempotent: runs `bun install` (whose prepare hook builds the internal
7-
packages dev servers fail without) and installs Playwright chromium.
8-
Skipping it is why fresh worktrees die with "Failed to resolve entry for
9-
package '@executor-js/vite-plugin'". The `vendor/` submodules are NOT
10-
needed — nothing imports from `vendor/` at runtime; those forks are consumed
11-
from npm (see `vendor/README.md`). `bun run bootstrap --forks` inits them
12-
only when you're deliberately developing a fork.
13-
14-
## Environment Gotchas (learned the hard way)
15-
16-
- The shell is fish, and the working directory resets between Bash calls. Use
17-
absolute paths rooted at THIS worktree (check `pwd`), never
18-
`/Users/rhys/src/executor` from memory, and don't rely on a prior `cd`.
19-
- Don't write probe scripts to `/tmp` — they can't resolve workspace packages
20-
(`effect`, `playwright`, ...). Put scratch scripts under the repo root
21-
(`scratch/` is gitignored) so bun resolves the workspace.
22-
- `bun.lock` conflicts on rebase/merge: take either side, then re-run
23-
`bun install` to regenerate it — never hand-merge the lockfile.
24-
- e2e dev-server ports are derived per checkout (`cd e2e && bun run ports`).
25-
If a boot reports a squatted port, an old dev server leaked — kill it by
26-
PID from the error message; don't move your own ports to dodge it.
3+
This file is principles — the contracts that stay true while implementations
4+
churn. For how to actually run, boot, share, or navigate things today
5+
(fresh-worktree setup, dev servers, ports, environment gotchas), see
6+
[RUNNING.md](RUNNING.md) (which may lag reality; it says so itself). For
7+
writing e2e scenarios, see [e2e/AGENTS.md](e2e/AGENTS.md). Run
8+
`bun run bootstrap` first in any fresh checkout or worktree.
279

2810
## Task Completion Requirements
2911

@@ -36,6 +18,49 @@ only when you're deliberately developing a fork.
3618
- For broad or merge-ready changes, the full gates are `bun run format:check`,
3719
`bun run lint`, `bun run typecheck`, and `bun run test`.
3820

21+
## Handing Back Work: Evidence, Not Assertions
22+
23+
"Done" is something the user can open, not a claim. When work changes what a
24+
user sees or touches, the handoff has three parts, delivered unprompted:
25+
26+
1. **Watch it** — an e2e scenario covers the change, and the handoff links
27+
directly to the specific run(s) that prove it, with one line each on what
28+
to look at. Never hand back a bare wall of green results: the user's
29+
question is "show me the new thing working," not "is everything healthy?"
30+
2. **Touch it** — leave the session's dev server running and reachable over
31+
the user's tailnet, with credentials, so they can take over and poke at
32+
it. The instance you already booted for e2e IS this — leave it up rather
33+
than standing up something separate.
34+
3. **What to try** — name the paths worth exercising by hand, especially
35+
ones no scenario pins yet. Honesty about coverage gaps is part of the
36+
handoff. A human driving a real browser from another device reaches
37+
states the test harness structurally cannot; invite that.
38+
39+
The same machinery runs in reverse: you can seed an environment INTO a
40+
state — reproduce a bug live, stage data for the user to take over, set up
41+
a walkthrough — and hand across the link. "Here's the broken state, live"
42+
beats a paragraph describing it.
43+
44+
If no scenario covers the change yet, that is the cue to write one. When a
45+
change is user-visible, embed the run's recording in the PR description —
46+
reviewers should see the change, not just read about it.
47+
48+
Don't memorize the mechanics (ports, viewer, sharing commands) — discover
49+
them from RUNNING.md and the code; they change.
50+
51+
## Service Emulators
52+
53+
When a test or demo needs an upstream API, OAuth/OIDC provider, or webhook
54+
source, use the `@executor-js/emulate` emulators (GitHub, Google, Stripe,
55+
Resend, WorkOS, and a dozen more) instead of writing a stub. They are
56+
wire-level and stateful — real SDKs run against them unmodified — and each
57+
serves a full OpenAPI spec ready for addSpec, mints real-shaped credentials,
58+
runs working OAuth flows, and records every call in a request ledger you can
59+
assert against. Hosted instances exist at `https://<service>.emulators.dev`
60+
with zero setup. See the `emulate` skill
61+
(`.claude/skills/emulate/SKILL.md`) for the control-plane reference and
62+
recipes.
63+
3964
## Attribution
4065

4166
Do not add any AI assistant, Claude, Anthropic, or Co-Authored-By
@@ -44,21 +69,6 @@ attribution/trailers to commits, commit messages, PRs, or generated files.
4469
Pull request titles and descriptions are going to a public GitHub repo, so
4570
avoid using specific names or internal info unless explicitly stated to.
4671

47-
## Show Changes in PR Descriptions
48-
49-
When a change is user-visible and an e2e scenario covers it, embed the run's
50-
recording in the PR description — reviewers should see the change, not just
51-
read about it.
52-
53-
```
54-
bun e2e/scripts/pr-media.ts e2e/runs/<target>/<scenario-slug>
55-
```
56-
57-
converts the run's recording (browser `session.mp4` or `terminal.cast`) to a
58-
gif, uploads it to the `e2e-media` branch, and prints PR-ready markdown to
59-
paste into the body. Run screenshots (`*.png`) can be passed directly too. If
60-
no scenario covers the change yet, that is usually the cue to write one.
61-
6272
## Collaboration Notes
6373

6474
The user uses speech to text occasionally, so if sentences are weird or words
@@ -102,4 +112,6 @@ before using it.
102112

103113
## Other
104114

105-
Please make note of mistakes you make in MISTAKES.md. If you find you wish you had more context or tools, write that down in DESIRES.md. If you learn anything about your env write that down in LEARNINGS.md.
115+
Please make note of mistakes you make in MISTAKES.md. If you find you wish you
116+
had more context or tools, write that down in DESIRES.md. If you learn anything
117+
about your env write that down in LEARNINGS.md.

RUNNING.md

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
# RUNNING.md — how things run today
2+
3+
> **This document may be out of date.** It describes how things run today,
4+
> not how they must run. Trust it as a starting point; if you hit weirdness,
5+
> the implementation has probably moved and this file is why. Verify against
6+
> the code, then update this file when you notice drift. The principles in
7+
> [AGENTS.md](AGENTS.md) are the stable contract; everything below is
8+
> implementation detail that churns.
9+
10+
## Fresh checkout / worktree setup
11+
12+
`bun run bootstrap` from the repo root — idempotent: `bun install` (whose
13+
prepare hook builds `@executor-js/vite-plugin` and `packages/react`, the
14+
artifacts dev servers fail without) plus Playwright chromium. A fresh
15+
worktree that skips it dies with "Failed to resolve entry for package
16+
'@executor-js/vite-plugin'".
17+
18+
The `vendor/` submodules (emulate, mcporter) are NOT required — nothing
19+
imports from `vendor/` at runtime; those packages come from npm
20+
(`@executor-js/emulate`, `@executor-js/mcporter`). `bun run bootstrap
21+
--forks` inits them only when deliberately developing a fork (see
22+
`vendor/README.md`).
23+
24+
## Dev servers
25+
26+
- Everything except desktop/cloud: `bun run dev` (turbo, from root)
27+
- One app: `bun run dev` from its `apps/<name>` directory
28+
- Self-host boots standalone with just env vars — see
29+
`e2e/setup/selfhost.globalsetup.ts` for the canonical recipe (data dir,
30+
bootstrap admin email/password, base URL, `EXECUTOR_ALLOW_LOCAL_NETWORK`)
31+
- Cloud needs WorkOS + Autumn; for a no-.env boot, point it at emulators —
32+
see `e2e/setup/cloud.globalsetup.ts` for the canonical recipe (the real
33+
SDKs against emulated services, PGlite dev DB)
34+
35+
The e2e globalsetup files are the source of truth for "how do I boot a
36+
working instance of X" — read them before inventing a boot path.
37+
38+
## E2E: running, viewing, sharing
39+
40+
`e2e/AGENTS.md` covers writing scenarios. Operationally:
41+
42+
- `cd e2e && bun run test` boots dev servers and runs everything;
43+
`--project cloud|selfhost` narrows. `E2E_CLOUD_URL`/`E2E_SELFHOST_URL`
44+
attach to an already-running server instead of booting.
45+
- Runs land in `e2e/runs/<target>/<scenario-slug>/``result.json`, step
46+
screenshots, `session.mp4` + `trace.zip` for browser scenarios, and the
47+
scenario source as `test.ts`.
48+
- `cd e2e && bun run serve` builds the viewer and serves the scenario ×
49+
target matrix over HTTP, bound to all interfaces (reachable over the
50+
tailnet). Individual runs are at `#/<target>/<slug>` hash routes — when
51+
handing results to the user, link those directly, not the bare matrix.
52+
- `bun e2e/scripts/pr-media.ts e2e/runs/<target>/<slug>` converts a run's
53+
recording to a gif, uploads it to the `e2e-media` branch, and prints
54+
PR-ready markdown.
55+
56+
E2E dev-server ports are derived and CLAIMED per checkout (`cd e2e && bun
57+
run ports` prints this checkout's block; see `e2e/src/ports.ts`) — each
58+
checkout hashes its repo root to a preferred block, atomically locks it,
59+
and walks to the next free block if squatted, so concurrent worktrees never
60+
collide or attach to each other's servers. `E2E_*_PORT` env vars pin ports
61+
explicitly. If a boot reports a squatted port, an old dev server leaked —
62+
`bun run reap` (repo root) lists and kills orphaned stacks.
63+
64+
## The dev CLI: live instances, interactively
65+
66+
`cd e2e && bun run cli` — the same primitives scenarios use, as commands.
67+
Boot a target, mint identities, make typed API calls, drive MCP, read the
68+
emulator ledger — develop interactively, then crystallize the journey into
69+
a scenario.
70+
71+
```sh
72+
bun run cli up selfhost --share # boot, reachable over the tailnet, stays up
73+
bun run cli up cloud --share # emulated WorkOS+Autumn, tailscale-HTTPS fronted
74+
bun run cli status # what's running, URLs, creds
75+
bun run cli identity selfhost # fresh identity (headers / cookies / creds)
76+
bun run cli api selfhost tools.list
77+
bun run cli mcp selfhost call execute '{"code":"return 1+1;"}'
78+
bun run cli ledger cloud workos # what hit the emulator
79+
bun run cli down selfhost # tear down (also removes tailscale serves)
80+
```
81+
82+
Instances persist until `down``up --share` IS the "touch it" handoff
83+
artifact, and the seeding direction too: boot, drive the product into a
84+
state (API/MCP/UI), hand across the URL. State files in `e2e/.dev/` mark
85+
deliberate long-lived instances (vs leaks); attach scenarios to a running
86+
instance with `E2E_<TARGET>_URL`.
87+
88+
Why cloud `--share` is more involved (encoded in the CLI, kept here for
89+
when you hit it manually): the cloud app sets `secure: true` auth cookies,
90+
so login breaks over plain http from any non-localhost origin ("Invalid
91+
login state"). Both the app AND the WorkOS emulator get fronted with
92+
`tailscale serve` HTTPS, the emulator advertises its public URL on both
93+
sides (its `baseUrl` and the app's `WORKOS_API_URL` — the browser-facing
94+
authorize URL derives from the latter), and Vite must allow the public
95+
hostname (`__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS`).
96+
97+
## Environment gotchas (learned the hard way)
98+
99+
- The shell is fish, and the working directory resets between Bash calls.
100+
Use absolute paths rooted at THIS worktree; don't rely on a prior `cd`.
101+
- Don't write probe scripts to `/tmp` — they can't resolve workspace
102+
packages (`effect`, `playwright`, …). Put scratch scripts under the repo
103+
root (`scratch/` is gitignored) so bun resolves the workspace.
104+
- A fresh worktree's Vite dep-optimizer cache can serve PRE-REBASE code
105+
(symptom: behavior matching old code only in dev servers, while unit
106+
tests pass). Kill the server, clear `node_modules/.vite` /
107+
`.tanstack`-adjacent caches, reboot.
108+
- The real Tailscale CLI on this machine is
109+
`/opt/homebrew/opt/tailscale/bin/tailscale`; `/usr/local/bin/tailscale`
110+
is a broken shim pointing at a deleted app. The tailnet IP is on the
111+
`utun` interface (100.x.y.z) if the CLI fails.
112+
- `bun.lock` conflicts on rebase: take either side, re-run `bun install`,
113+
never hand-merge.
114+
- Long-lived demo servers you left up for the user look like leaks to
115+
cleanup tooling — `e2e/.dev/<target>.json` marks deliberate instances;
116+
check it before reaping, and `bun run cli down <target>` is the clean
117+
teardown.

bun.lock

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

e2e/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
.dev/

0 commit comments

Comments
 (0)