diff --git a/.bazelignore b/.bazelignore new file mode 100644 index 00000000..a20fd054 --- /dev/null +++ b/.bazelignore @@ -0,0 +1,56 @@ +# Bazel must not treat pnpm's node_modules trees as packages. rules_js +# verifies every workspace importer's node_modules is listed here +# (see verify_node_modules_ignored in MODULE.bazel). Keep this list in +# sync with the package globs in pnpm-workspace.yaml. +node_modules +packages/artifacts/node_modules +packages/build/node_modules +packages/build-cli/node_modules +packages/canonical/node_modules +packages/capability/node_modules +packages/chain/node_modules +packages/cli/node_modules +packages/control/node_modules +packages/core/node_modules +packages/crypto/node_modules +packages/database/node_modules +packages/engine/node_modules +packages/engine-harness/node_modules +packages/engine-store/node_modules +packages/evals/node_modules +packages/flow/node_modules +packages/flows/node_modules +packages/fs/node_modules +packages/gateway/node_modules +packages/harness/node_modules +packages/jj/node_modules +packages/journal/node_modules +packages/kernel/node_modules +packages/keys/node_modules +packages/memory/node_modules +packages/model/node_modules +packages/notifications/node_modules +packages/observability/node_modules +packages/patterns/node_modules +packages/plan/node_modules +packages/platform-browser/node_modules +packages/platform-bun/node_modules +packages/platform-node/node_modules +packages/plugin/node_modules +packages/registry/node_modules +packages/run-store/node_modules +packages/sandbox/node_modules +packages/scorers/node_modules +packages/std/node_modules +packages/step-cache/node_modules +packages/sync/node_modules +packages/targets/node_modules +packages/testing/node_modules +packages/time-travel/node_modules +packages/triggers/node_modules +packages/build/infra/node_modules +examples/node_modules +apps/server/node_modules +apps/shared/node_modules +apps/tui/node_modules +apps/ui/node_modules diff --git a/.bazelrc b/.bazelrc new file mode 100644 index 00000000..7353c4cd --- /dev/null +++ b/.bazelrc @@ -0,0 +1,110 @@ +# Bazel configuration for the flows workspace. +# +# Bazelisk reads .bazelversion and runs Bazel 8.7.0. 9.2.0 is the current +# release line; 8.7.0 is the newest 8.x LTS, which is the line the Aspect +# rules (rules_js, rules_ts, rules_lint) and rules_rust test against. Upgrade +# deliberately, not by accident. + +# This repository is bzlmod-only. There is no WORKSPACE file; disabling the +# workspace mechanism makes a stray WORKSPACE file an error instead of a +# silent second dependency graph. +common --enable_bzlmod +common --enable_workspace=false + +# Print which rc files and options took effect. Cheap insurance when a flag +# "isn't applying". +common --announce_rc + +# --------------------------------------------------------------------------- +# Caching +# --------------------------------------------------------------------------- + +# On-disk action cache, shared across `bazel clean` and output-base resets. +# --disk_cache takes a path; a valueless spelling does NOT mean "default +# location". rc files are one token stream, so a bare `--disk_cache` +# consumes the next flag as its path (here it created a cache directory +# literally named `--spawn_strategy=sandboxed` in the workspace and silently +# dropped the spawn-strategy flag). `%workspace%` is not substituted in flag +# values either (only in import paths); a relative path resolves against the +# client's working directory, so run Bazel from the workspace root. The cache +# directory is gitignored at any depth. +build --disk_cache=.bazel-cache + +# Bazel's default repository cache (under the output user root) already +# persists external fetches (npm tarballs, crate archives, toolchains) +# across cleans and invocations; nothing to configure. + +# --------------------------------------------------------------------------- +# Hermeticity and sandboxing +# --------------------------------------------------------------------------- + +# Actions run in a sandbox with only their declared inputs. These are the +# defaults on Bazel 8 for Linux and macOS; they are stated explicitly because +# this repository's reproducibility claims rest on them. +build --spawn_strategy=sandboxed +build --incompatible_strict_action_env + +# Sandbox failures should be loud, not silently retried without a sandbox. +build --sandbox_debug + +# The cargo bridge actions (tools/cargo) invoke the host rustup shims, which +# live under $HOME/.cargo/bin. The toolchain itself is pinned by +# rust-toolchain.toml; HOME passthrough only locates the shims. +build --action_env=HOME +test --action_env=HOME + +# --------------------------------------------------------------------------- +# Remote cache +# --------------------------------------------------------------------------- +# +# `--config=remote` layers the remote cache on top of the local disk cache. +# Bazel does not expand environment variables in rc files, so the endpoint and +# credentials are passed by CI (see .github/workflows/bazel.yml) or written to +# a gitignored .bazelrc.remote: +# +# build:remote --remote_cache=https://build.example.com +# build:remote --remote_header=Authorization=Bearer +# +# try-import does not fail when the file is absent. +try-import %workspace%/.bazelrc.remote + +# Build without the Bytes: with a remote cache, download only the outputs the +# final targets actually need, not every intermediate. Correct for CI, where +# most actions are cache hits. toplevel and minimal are two spellings of the +# same knob (--remote_download_outputs); naming both sets it twice, and the +# last one silently wins. +build:remote --remote_download_toplevel + +# --------------------------------------------------------------------------- +# CI +# --------------------------------------------------------------------------- +# +# `bazel test --config=ci //...` is the CI entry point. +build:ci --config=remote +build:ci --color=yes +build:ci --show_timestamps +# Never flake a gate on a hung download. +build:ci --remote_timeout=60 +# Test output: print failures in full, keep passes quiet. +test:ci --test_output=errors +test:ci --test_summary=detailed + +# --------------------------------------------------------------------------- +# Lint +# --------------------------------------------------------------------------- +# +# `bazel build --config=lint //...` runs every registered linter as an aspect +# over the normal build: lint actions are cached and remotely executed like +# any other action, and a lint failure fails the build. +build:lint --aspects=//tools/lint:linters.bzl%eslint +build:lint --output_groups=+rules_lint_human + +# --------------------------------------------------------------------------- +# Gazelle +# --------------------------------------------------------------------------- +# +# `bazel run //:gazelle` regenerates BUILD.bazel files from imports. +# `bazel run //:gazelle.check` fails when the committed files are stale. + +# The JS gazelle plugin is prebuilt (aspect_gazelle_prebuilt); no Go, Rust, +# or LLVM toolchain is needed to run it. diff --git a/.bazelversion b/.bazelversion new file mode 100644 index 00000000..df5119ec --- /dev/null +++ b/.bazelversion @@ -0,0 +1 @@ +8.7.0 diff --git a/.github/workflows/apps-deploy.yml b/.github/workflows/apps-deploy.yml index 3163317f..b6e8ecf0 100644 --- a/.github/workflows/apps-deploy.yml +++ b/.github/workflows/apps-deploy.yml @@ -13,18 +13,8 @@ jobs: deploy: name: build + wrangler deploy runs-on: ubuntu-latest - # The e2e suites below boot wrangler dev and a real Chrome, so the job is - # minutes long even when everything passes. - timeout-minutes: 45 steps: - # Full history, not the default shallow one. The CN-1 probe measures how - # far the deployed commit sits behind origin/main with - # `git rev-list --count ..origin/main`, and a shallow clone has - # neither origin/main nor the commits between. Without this the probe - # reports that it could not measure, which is honest and useless. - uses: actions/checkout@v4 - with: - fetch-depth: 0 - uses: pnpm/action-setup@v6 - uses: actions/setup-node@v4 with: @@ -34,68 +24,6 @@ jobs: with: bun-version: 1.3.14 - run: pnpm install --frozen-lockfile --ignore-scripts - # A deploy is the one moment this code reaches a user, so it does not - # ship what it has not checked. These are the apps gates only: the - # workspace gates (packages, rust, wasm, browser contract) are CI's job - # on the same commit, and duplicating them here would put 20 minutes - # between a green tag and a live fix. - # - # Every step below fails the job, so a red tag never reaches the - # deploy step — including the dry-run one, which is the whole proof - # this pipeline offers when no credential is configured. - - name: Typecheck apps - run: | - for app in smithers-ui smithers-server smithers-shared smithers-tui; do - pnpm --filter "$app" run typecheck - done - - name: Test apps - run: | - for app in smithers-ui smithers-server smithers-shared smithers-tui; do - pnpm --filter "$app" run test - done - # The launch checklist is what grades the deployed origin afterwards. - # Its dry run touches no network and proves the row catalog, the CLI - # contract, and the report writer still work before the deploy, not - # after — a broken runner would otherwise be discovered at the worst - # possible moment. - - name: Launch-checklist dry run - run: pnpm run checklist -- --dry-run - # Issue I-4: the deploy is gated on end-to-end behaviour, not only on - # unit suites. Both suites are hermetic — the Worker runs on local - # workerd against scripts/stub-backends.ts and the browser is the - # runner's own Chrome — so this needs no Cloudflare credential and - # spends nothing on a model. - # - # These run as steps of the deploy job rather than as a separate job on - # purpose: the deploy has to wait for them either way, and a second job - # would only pay for a second `pnpm install`. - - name: Assert the runner ships a browser findBrowser can discover - run: | - if [ ! -x /usr/bin/google-chrome ]; then - echo "/usr/bin/google-chrome is missing from this runner image." >&2 - echo "findBrowser only probes BROWSER_CANDIDATES" >&2 - echo "(apps/ui/src/launch-checklist/BrowserLaunch.ts). Install Chrome" >&2 - echo "at one of those paths, or set CHECKLIST_BROWSER on this job." >&2 - exit 1 - fi - /usr/bin/google-chrome --version - - name: Worker e2e - run: pnpm --filter smithers-ui run test:e2e:worker - - name: Browser e2e - run: pnpm --filter smithers-ui run test:e2e - - name: Collect e2e artifacts - if: failure() - run: | - mkdir -p "$RUNNER_TEMP/e2e-artifacts" - cp /tmp/smithers-*.png "$RUNNER_TEMP/e2e-artifacts/" 2>/dev/null || true - cp -R apps/reports "$RUNNER_TEMP/e2e-artifacts/reports" 2>/dev/null || true - - name: Upload e2e artifacts - if: failure() - uses: actions/upload-artifact@v4 - with: - name: apps-deploy-e2e-artifacts - path: ${{ runner.temp }}/e2e-artifacts - if-no-files-found: ignore # Real deploy runs only on a tag push AND only when the Cloudflare # credential is configured; every other trigger (workflow_dispatch, or # a tag push without the secret) proves the pipeline in --dry-run mode @@ -114,157 +42,14 @@ jobs: echo "dry_run=true" >> "$GITHUB_OUTPUT" fi - name: Deploy (dry-run) - id: deploy_dry if: steps.mode.outputs.dry_run == 'true' run: pnpm --filter smithers-server run deploy:dry - name: Deploy (real) - id: deploy_real if: steps.mode.outputs.dry_run == 'false' env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} run: pnpm --filter smithers-server run deploy - # --- Post-deploy verification --------------------------------------- - # - # E2E-CANARY-CHECKLIST CN-1, CN-18, CN-23 and CN-24. Every one of these - # probes shipped with unit tests and no caller, so no verdict any of them - # produced could move with the deployment it grades. - # apps/server/scripts/canary/workflow-wiring.test.ts fails if a probe - # loses its caller again. - # - # WHY HERE AND NOT ON THE CANARY SCHEDULE. Each of these grades a - # property of this job's outcome: the sha just published, the upstreams - # the new Worker proxies, the entry path the release opens, and the - # rollback target the release leaves behind. canary.yml is the scheduled - # home for continuous liveness (CN-19/20/21) and it turns a failure into - # one GitHub issue, so a third-party blip there would file and re-file - # that issue every fifteen minutes. The same blip here reds one attended - # deploy run, which is the audience that can act on it. - # - # WHY AFTER THE DEPLOY AND NOT BEFORE. These verify what shipped. A red - # here reports a bad or unverifiable deployment; it cannot prevent one, - # and pretending otherwise would just move the deploy behind checks that - # have nothing to read yet. - # - # WHY EVERY PROBE CARRIES `!cancelled()`. GitHub's default step condition - # is "every previous step succeeded", which would let one red probe skip - # the other three. The operator would then fix CN-1, cut another tag, and - # only then learn CN-18 was also broken — one verdict per production - # deploy. Each probe instead runs whenever the deploy it grades succeeded, - # so a single run reports all four. A red probe still fails the job: a - # failed step does that regardless of what later steps are conditioned on. - # - # The conditions key off the deploy steps' `outcome`, not off dry_run - # alone, so a probe never runs against a deploy that failed or was - # skipped. A skipped step's outcome is 'skipped', which matches neither - # comparison. - # - # The steps below carry `if:` conditions. That is allowed in this file: - # the issue-#176 pin in packages/flows/test/vitestCoverageIsolation.test.ts - # forbids `if:` in ci.yml only. - - name: CN-1 — the deployment serves the sha it just published - if: ${{ !cancelled() && steps.deploy_real.outcome == 'success' }} - working-directory: apps/server - env: - DEPLOYED_SHA: ${{ github.sha }} - REPORT: ${{ runner.temp }}/canary-build.json - run: | - set -uo pipefail - # --sha is what makes the comparison real. Without an expected sha the - # probe skips both comparison checks and still prints PASS, having - # verified only that the deployment can state what it is. --max-drift 0 - # adds the other half: a tag whose commit main has already moved past - # deploys a canary that is stale the moment it goes live. - # - # Cloudflare returns from the deploy call before every edge serves the - # new asset, so a single read can lose a race the deployment did not. - # Three reads over 40 seconds; a real regression still fails, 40 - # seconds later. - for attempt in 1 2 3; do - if bun scripts/canary/build-probe.ts https://canary.smithers.sh \ - --sha "$DEPLOYED_SHA" \ - --max-drift 0 \ - --json "$REPORT"; then - exit 0 - fi - if [ "$attempt" != 3 ]; then - echo "CN-1 attempt $attempt did not match; waiting for the rollout." >&2 - sleep 20 - fi - done - echo "CN-1: canary.smithers.sh is not serving $DEPLOYED_SHA." >&2 - exit 1 - # A skipped check has to say so out loud. A dry-run job deploys nothing, - # so the live sha is not this run's to assert — but a silently absent step - # and a passing one look identical in the Actions list. - - name: CN-1 not run (dry-run deploy) - if: ${{ !cancelled() && steps.deploy_dry.outcome == 'success' }} - run: | - set -euo pipefail - echo "::warning title=CN-1 not run::This job deployed nothing, so canary.smithers.sh still serves an earlier commit and its sha is not this run's to assert." - echo "- CN-1 build-stamp probe: NOT RUN — dry-run deploy (not a tag push, or CLOUDFLARE_API_TOKEN is unset)." >> "$GITHUB_STEP_SUMMARY" - # No credential. The nine origins are committed in workers-manifest.ts on - # purpose: an origin hidden in a secret cannot be diffed, so a wrong one - # would probe nothing and report PASS. The probe exits 1 when a Worker is - # unhealthy and also when nothing was configured, so an emptied manifest - # cannot read as green. It runs on the dry-run path too, because upstream - # health does not depend on whether this job deployed. - - name: CN-18 — the backing Workers answer - if: ${{ !cancelled() && (steps.deploy_real.outcome == 'success' || steps.deploy_dry.outcome == 'success') }} - working-directory: apps/server - run: bun scripts/canary/workers-health.ts - # Read-only half only: the roster reads back as allowlisted. The write - # half (--admit-probe-login, IDENTITY_ADMIN_TOKEN) mutates the production - # allowlist and stays a human drill in apps/server/INVITES.md, so the - # admin token is deliberately not passed here. - # - # This step goes red until IDENTITY_SERVICE_TOKEN and - # CANARY_ALLOWLIST_LOGINS are configured. That is the probe's own ruling, - # not this file's: inviteRunSummary in scripts/canary/invite-verdict.ts - # exits 1 on a run that asserted nothing and refuses --allow-inconclusive - # under CI. Configure the secret and the variable; do not add - # continue-on-error. - # - # IDENTITY_UPSTREAM_URL is not set here. An unset repository variable - # expands to the empty string, which would override the probe's default - # identity origin with a URL base that cannot resolve. - - name: CN-23 — the closed-alpha entry path - if: ${{ !cancelled() && (steps.deploy_real.outcome == 'success' || steps.deploy_dry.outcome == 'success') }} - working-directory: apps/server - env: - IDENTITY_SERVICE_TOKEN: ${{ secrets.IDENTITY_SERVICE_TOKEN }} - CANARY_ALLOWLIST_LOGINS: ${{ vars.CANARY_ALLOWLIST_LOGINS }} - run: bun scripts/canary/invite-probe.ts - # Rollback readiness reads the receipt the deploy step just wrote, so it - # only means anything on the real-deploy path: dry runs write their - # receipts to deploy-receipts/dry-run/ and the probe reads - # deploy-receipts/latest.json. - # - # The probe exits 0 when it verified nothing, so this step turns that into - # a warning rather than letting an uncredentialed run read as a pass. - - name: CN-24 — a rollback target still exists - if: ${{ !cancelled() && steps.deploy_real.outcome == 'success' }} - working-directory: apps/server - env: - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} - CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - LOG: ${{ runner.temp }}/canary-rollback.log - run: | - set -euo pipefail - bun scripts/canary/rollback-probe.ts | tee "$LOG" - if ! grep -q "ROLLBACK PROBE PASS" "$LOG"; then - echo "::warning title=CN-24 verified nothing::The rollback probe found no receipt or no Cloudflare credential. Nothing here proves a rollback target exists." - echo "- CN-24 rollback readiness: NOT VERIFIED — see the step log." >> "$GITHUB_STEP_SUMMARY" - fi - - name: Upload the canary probe reports - if: always() - uses: actions/upload-artifact@v4 - with: - name: canary-probe-reports - path: | - ${{ runner.temp }}/canary-build.json - ${{ runner.temp }}/canary-rollback.log - if-no-files-found: ignore - name: Upload deploy receipt if: always() uses: actions/upload-artifact@v4 diff --git a/.github/workflows/bazel.yml b/.github/workflows/bazel.yml new file mode 100644 index 00000000..f65e6042 --- /dev/null +++ b/.github/workflows/bazel.yml @@ -0,0 +1,97 @@ +name: Bazel + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + name: bazel build + test + runs-on: ubuntu-latest + steps: + # submodules: vendor/jj holds the pinned jj fork that + # //crates/flows-jj builds against. + - uses: actions/checkout@v4 + with: + submodules: true + + # Bazelisk reads .bazelversion (Bazel 8.7.0) and downloads that exact + # Bazel. No Node or pnpm setup follows: rules_js fetches the pinned + # Node toolchain and every npm package itself. + - name: Install bazelisk + run: | + curl -fsSL -o /usr/local/bin/bazel https://github.com/bazelbuild/bazelisk/releases/download/v1.26.0/bazelisk-linux-amd64 + chmod +x /usr/local/bin/bazel + + # rust-toolchain.toml pins the toolchain for the cargo actions under + # //crates/flows-jj; a bare `rustup toolchain install` reads it. + - name: Install pinned Rust toolchain + run: rustup toolchain install + + # Warm the two caches Bazel populates: the repository cache (external + # fetches: npm tarballs, crate archives, toolchains) and the disk cache + # (action outputs). The action graph itself is recomputed each run. + - name: Cache Bazel repositories and actions + uses: actions/cache@v4 + with: + path: | + ~/.cache/bazel/repository_cache + ~/.cache/bazel-disk-cache + key: bazel-${{ runner.os }}-${{ hashFiles('MODULE.bazel.lock', 'pnpm-lock.yaml', 'Cargo.lock') }} + restore-keys: | + bazel-${{ runner.os }}- + + # Optional remote cache. When the SMITHERS_BAZEL_CACHE_URL variable is + # set, layer a Bazel Remote Cache protocol endpoint (gRPC or HTTP) on + # top of the local caches. The smithers cache worker in + # packages/build/infra does not speak that protocol today; see + # docs/build-systems/bazel.md. + - name: Configure remote cache + if: vars.SMITHERS_BAZEL_CACHE_URL != '' + run: | + { + echo "build:remote --remote_cache=${SMITHERS_BAZEL_CACHE_URL}" + echo "build:remote --remote_header=Authorization=Bearer ${SMITHERS_BAZEL_CACHE_TOKEN}" + } > .bazelrc.remote + env: + SMITHERS_BAZEL_CACHE_URL: ${{ vars.SMITHERS_BAZEL_CACHE_URL }} + SMITHERS_BAZEL_CACHE_TOKEN: ${{ secrets.SMITHERS_BAZEL_CACHE_TOKEN }} + + # The target set is explicit, not //...: the kernel <-> platform-browser + # packages declare a runtime dependency cycle, which pnpm tolerates and + # Bazel cannot analyze, and the generated npm store farm that mirrors it + # lives in the root package, so no wildcard that includes the root + # package analyzes. Per-package link targets are tagged manual by + # rules_js, so the per-directory wildcards below are clean. Details and + # the fix are in docs/build-systems/bazel.md. + # --disk_cache is a command option, not a startup option: it must follow + # the subcommand (`bazel --disk_cache=... build` is a fatal "unknown + # startup option"). $HOME, not ~: bash does not expand a tilde after `=`. + - name: Build + run: bazel build --disk_cache=$HOME/.cache/bazel-disk-cache --config=ci //packages/... //apps/... //examples/... //crates/... //tools/... + + - name: Test + run: bazel test --disk_cache=$HOME/.cache/bazel-disk-cache --config=ci //packages/... //apps/... //examples/... //crates/... //tools/... + + # Lint aspects over the same graph; lint actions are cached like any + # other. + - name: Lint + run: bazel build --disk_cache=$HOME/.cache/bazel-disk-cache --config=ci --config=lint //packages/... --output_groups=+rules_lint_human + + # Formatting gate (dprint check). Runs in the workspace, not a sandbox: + # dprint downloads its wasm plugins on first use. + - name: Format check + run: bazel run //tools/format:format.check + + # Generated-file drift gate: fails when BUILD.bazel files are stale + # relative to imports and package.json files. + - name: Gazelle drift check + run: bazel run //:gazelle.check + + # The wasm reproducibility gate is meaningful only on the canonical + # artifact host (x86_64-unknown-linux-gnu), which this job is. It + # compares the sandboxed Bazel build against the committed + # packages/jj/wasm/flows_jj.wasm byte for byte. + - name: Wasm reproducibility gate + run: bazel test --disk_cache=$HOME/.cache/bazel-disk-cache --config=ci //crates/flows-jj:wasm_repro_test diff --git a/.github/workflows/canary.yml b/.github/workflows/canary.yml deleted file mode 100644 index 63c4d061..00000000 --- a/.github/workflows/canary.yml +++ /dev/null @@ -1,171 +0,0 @@ -# Synthetic uptime, latency and error-rate probe against the live deployment -# (E2E-CANARY-CHECKLIST CN-19, CN-20, CN-21). -# -# This workflow never runs on a push or a pull request. It cannot fail a -# contributor's build, and it cannot run in a fork: the job is guarded on the -# repository name so a fork's scheduled runs are skipped rather than probing -# someone else's deployment with someone else's issue tracker. -# -# THE ALERT. There is no paging infrastructure in this project and this file -# invents none. The alert is one GitHub issue under a fixed title: a failing -# run opens it, later failing runs comment on it, and the first passing run -# comments and closes it. The decision is made by -# apps/server/scripts/canary/uptime-report.ts, which is unit-tested; this file -# only runs `gh`. The accepted failure mode is that notification depends on the -# maintainer watching this repository's issues — a repository with issue -# notifications muted learns nothing. That is still strictly better than a red -# tab in the Actions list, which notifies nobody on a scheduled run. -# -# COST. The unmetered probes are free: a static asset read, an unauthenticated -# scopes read, and a signed-out turn refusal that never reaches an upstream. -# The metered CN-19 sample — one short model turn — runs only on the hourly -# tick, and only when $CANARY_SESSION_COOKIE is configured. That is 24 short -# turns a day, and zero if the secret is absent. -# -# LINTING. ci.yml names this file in its actionlint step, and -# apps/server/scripts/canary/workflow-wiring.test.ts fails when a workflow is -# missing from that list. -name: Canary - -on: - schedule: - # Hourly: the full probe, including the one metered turn-seam sample. - - cron: '0 * * * *' - # The other three quarters: the free probes only. GitHub queues scheduled - # runs under load, so treat 15 minutes as a floor, not a guarantee. - - cron: '15,30,45 * * * *' - workflow_run: - workflows: ["Deploy apps"] - types: [completed] - workflow_dispatch: - inputs: - metered: - description: Also take the metered turn-seam sample (spends model credit) - type: boolean - default: false - -permissions: - contents: read - issues: write - -concurrency: - group: canary - cancel-in-progress: false - -jobs: - probe: - name: probe the live deployment - if: github.repository == 'smithersai/flows' - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v6 - - uses: actions/setup-node@v4 - with: - node-version: 22.19.0 - cache: pnpm - - uses: oven-sh/setup-bun@v2 - with: - bun-version: 1.3.14 - - run: pnpm install --frozen-lockfile --ignore-scripts - # A broken probe must never grade a deployment, so its own decision logic - # is checked before it is trusted. This is the same idea as the - # launch-checklist dry run in apps-deploy.yml: prove the runner, then run - # it. These tests touch no network. - - name: Test the probe's decision logic - working-directory: apps/server - run: bun test scripts/canary/uptime-checks.test.ts scripts/canary/uptime-report.test.ts - # continue-on-error, because the probe exits 1 on a failing canary and - # the alert steps below must still run. A probe that crashes without - # writing its report is handled too: uptime-report.ts turns a missing - # report into a failing report rather than a silent pass. - - name: Probe - id: probe - continue-on-error: true - working-directory: apps/server - env: - CANARY_URL: ${{ vars.CANARY_URL || 'https://canary.smithers.sh' }} - # Absent on the quarter-hour ticks and on an ordinary manual run, so - # those runs spend nothing and say out loud that they measured the - # signed-out refusal gate only. - CANARY_SESSION_COOKIE: ${{ (github.event.schedule == '0 * * * *' || github.event.inputs.metered == 'true') && secrets.CANARY_SESSION_COOKIE || '' }} - # The origin is passed POSITIONALLY and first. The probe reads argv[0] - # only, so this is belt and braces with $CANARY_URL rather than the - # only thing standing between the run and probing a temp-file path. - run: bun scripts/canary/uptime-probe.ts "$CANARY_URL" --json "$RUNNER_TEMP/canary-uptime.json" - - name: Find the open alert issue - id: existing - env: - GH_TOKEN: ${{ github.token }} - TITLE: 'Canary: canary.smithers.sh is failing' - run: | - set -euo pipefail - number="$(gh issue list --state open --limit 100 --json number,title \ - --jq '.[] | select(.title == env.TITLE) | .number' | head -n 1)" - echo "number=$number" >> "$GITHUB_OUTPUT" - # The verdict is captured rather than raised, so the alert lands before - # the job goes red. An exit code other than 0 or 1 means the alert - # machinery itself broke, which the last step reports separately. - - name: Decide the alert - id: decide - working-directory: apps/server - env: - RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - OPEN_ISSUE: ${{ steps.existing.outputs.number }} - run: | - set -uo pipefail - bun scripts/canary/uptime-report.ts \ - --report "$RUNNER_TEMP/canary-uptime.json" \ - --run-url "$RUN_URL" \ - --open-issue "$OPEN_ISSUE" \ - --body-out "$RUNNER_TEMP/canary-alert.md" - echo "verdict=$?" >> "$GITHUB_OUTPUT" - - name: Raise or clear the alert - env: - GH_TOKEN: ${{ github.token }} - ALERT_ACTION: ${{ steps.decide.outputs.action }} - ISSUE: ${{ steps.decide.outputs.issue }} - TITLE: ${{ steps.decide.outputs.title }} - run: | - set -euo pipefail - case "$ALERT_ACTION" in - create) - gh issue create --title "$TITLE" --body-file "$RUNNER_TEMP/canary-alert.md" - ;; - comment) - gh issue comment "$ISSUE" --body-file "$RUNNER_TEMP/canary-alert.md" - ;; - close) - gh issue comment "$ISSUE" --body-file "$RUNNER_TEMP/canary-alert.md" - gh issue close "$ISSUE" - ;; - none) - echo "The canary passed and no alert issue is open." - ;; - *) - echo "The alert decision produced no action: the canary machinery itself is broken." >&2 - exit 1 - ;; - esac - - name: Upload the probe report - if: always() - uses: actions/upload-artifact@v4 - with: - name: canary-uptime-report - path: ${{ runner.temp }}/canary-uptime.json - if-no-files-found: warn - - name: Fail the job when the canary failed - env: - VERDICT: ${{ steps.decide.outputs.verdict }} - run: | - set -euo pipefail - if [ "$VERDICT" = "0" ]; then - exit 0 - fi - if [ "$VERDICT" = "1" ]; then - echo "The canary failed. The alert issue carries the failing checks." >&2 - exit 1 - fi - echo "uptime-report.ts exited $VERDICT: the alert machinery is broken, not the deployment." >&2 - exit 1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4fe08421..27ea6bfb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,21 +1,22 @@ name: CI + on: push: branches: [main] pull_request: -concurrency: - group: ci-${{ github.ref }} - cancel-in-progress: true + jobs: test: - name: "workspace graph (coverage gates enforced)" + name: check + test (coverage gates enforced) runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + # Keep this check in lockstep with release.yml: actionlint catches + # GitHub-only expression-context errors before either workflow runs. - name: Validate GitHub Actions workflows uses: docker://rhysd/actionlint:1.7.11 with: - args: ".github/workflows/ci.yml .github/workflows/release.yml .github/workflows/apps-deploy.yml .github/workflows/canary.yml" + args: .github/workflows/ci.yml .github/workflows/release.yml - uses: pnpm/action-setup@v6 - uses: actions/setup-node@v4 with: @@ -25,127 +26,167 @@ jobs: with: bun-version: 1.3.14 - run: pnpm install --frozen-lockfile --ignore-scripts + # Issue #163: the real-binary NodeJj suite requires jj on PATH; without + # it the suite fails loudly on CI (it no longer skips silently). + # install-action fetches the prebuilt jj-cli release binary — no cargo + # build. Keep the version pinned. - name: Install jj uses: taiki-e/install-action@v2 with: tool: jj-cli@0.39.0 + # The Node/Bun host contracts exercise a successful `jj status` when + # the binary is present. A GitHub checkout is not itself a jj repo, so + # initialize the colocated metadata before those contracts run. - name: Initialize colocated jj repository run: jj git init --colocate - - name: Workspace targets - run: pnpm exec smthrs ci '//packages/...' --jobs 2 - env: - SMITHERS_CACHE_URL: "${{ secrets.SMITHERS_CACHE_URL }}" - SMITHERS_CACHE_TOKEN: "${{ secrets.SMITHERS_CACHE_TOKEN }}" - - name: Script gates - run: pnpm exec smthrs test '//scripts/...' - env: - SMITHERS_CACHE_URL: "${{ secrets.SMITHERS_CACHE_URL }}" - SMITHERS_CACHE_TOKEN: "${{ secrets.SMITHERS_CACHE_TOKEN }}" - - name: "Agent eval suite (offline, baseline-gated)" - run: pnpm exec smthrs test '//evals/agent:suite' - env: - SMITHERS_CACHE_URL: "${{ secrets.SMITHERS_CACHE_URL }}" - SMITHERS_CACHE_TOKEN: "${{ secrets.SMITHERS_CACHE_TOKEN }}" - - name: Agent eval typecheck - run: pnpm exec smthrs build '//evals/agent:types' - env: - SMITHERS_CACHE_URL: "${{ secrets.SMITHERS_CACHE_URL }}" - SMITHERS_CACHE_TOKEN: "${{ secrets.SMITHERS_CACHE_TOKEN }}" - - name: Generated workflow drift - run: pnpm exec smthrs lint '//:ci' - env: - SMITHERS_CACHE_URL: "${{ secrets.SMITHERS_CACHE_URL }}" - SMITHERS_CACHE_TOKEN: "${{ secrets.SMITHERS_CACHE_TOKEN }}" - apps-e2e: - name: "apps e2e (worker + browser)" - runs-on: ubuntu-latest - timeout-minutes: 30 - steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v6 - - uses: actions/setup-node@v4 - with: - node-version: 22.19.0 - cache: pnpm - - uses: oven-sh/setup-bun@v2 - with: - bun-version: 1.3.14 - - run: pnpm install --frozen-lockfile --ignore-scripts - - name: Assert the runner ships the declared browser + - name: Typecheck all workspaces + run: pnpm run check + # Issue #38: lint (eslint --max-warnings=0 + dprint) and the + # circular-dependency guard gate every workspace — the issue-#8 class + # of lint-only regression can no longer merge green. + - name: Lint all workspaces + run: pnpm run lint + - name: Documentation parity + run: pnpm exec smthrs docs '//...' + - name: Circular-dependency guard + run: pnpm run circular + # Browser support is a hard requirement met through layers: the contract + # entry points must bundle for the browser, and the documented Node-only + # ones must still be Node-only. scripts/browser-check.mjs gates both. + - name: Browser bundle guard + run: pnpm run browser + - name: Release manifest unit test + run: node --test scripts/pack-release.test.mjs + # The dry-run path of release.yml is a promise about publication: a + # dispatched rehearsal must skip the publish step and a tag push must not. + # These assertions read release.yml itself, so an edit that breaks either + # half fails here instead of at the next release. + - name: Release rehearsal unit test + run: node --test scripts/release-rehearsal.test.mjs + # Publishing at a new version means retargeting the exact internal ranges + # too, or the published set depends on a version nobody published. The + # last case here asserts the tree is coherent at whatever version it + # currently carries. + - name: Release version coherence + run: node --test scripts/set-release-version.test.mjs + # The operator's backup/verify/restore entry point, driven the way an + # operator drives it: spawned invocations against a real migrated store. + - name: Disaster-recovery script test + run: node --test scripts/flows-backup.test.mjs + # A test the default gate never runs to a pass is only acceptable when it + # is written down. This fails on any pin in the engine or tooling groups + # that docs/alpha-notes.md does not explain. + - name: Test-pin register guard + run: node --test scripts/check-test-pins.test.mjs + # Package vitest configs with `coverage.enabled: true` compute and + # enforce their coverage thresholds on every run — a red gate fails CI. + - name: Test all workspaces + run: pnpm test + - name: Build all workspaces from clean artifacts run: | - if [ ! -x /usr/bin/google-chrome ]; then - echo '/usr/bin/google-chrome is missing from this runner image.' >&2 - echo 'findBrowser only probes BROWSER_CANDIDATES in apps/ui/src/launch-checklist/BrowserLaunch.ts' >&2 - exit 1 - fi - /usr/bin/google-chrome --version - - name: UI end-to-end suites - run: pnpm exec smthrs test '//apps/ui' + find packages -type d -name dist -prune -exec rm -rf {} + + pnpm --recursive --if-present run build + - name: Pack and smoke-test release artifacts env: - SMITHERS_CACHE_URL: "${{ secrets.SMITHERS_CACHE_URL }}" - SMITHERS_CACHE_TOKEN: "${{ secrets.SMITHERS_CACHE_TOKEN }}" - - name: Collect apps-e2e-artifacts + PACK_DIR: ${{ runner.temp }}/release-packs run: | - mkdir -p "$RUNNER_TEMP/apps-e2e-artifacts" - cp -R /tmp/smithers-*.png "$RUNNER_TEMP/apps-e2e-artifacts" 2>/dev/null || true - cp -R apps/reports "$RUNNER_TEMP/apps-e2e-artifacts/reports" 2>/dev/null || true - - name: Upload apps-e2e-artifacts - uses: actions/upload-artifact@v4 - with: - name: apps-e2e-artifacts - path: "${{ runner.temp }}/apps-e2e-artifacts" - if-no-files-found: ignore + node scripts/pack-release.mjs "$PACK_DIR" + node scripts/smoke-release.mjs "$PACK_DIR" + + # rust-toolchain.toml pins the toolchain for the two Rust jobs; a bare + # `rustup toolchain install` reads it, so the pin cannot drift from what CI + # runs. It also pins the wasm32-wasip1 target and the rustfmt and clippy + # components. rust: name: rust fmt + clippy + test runs-on: ubuntu-latest timeout-minutes: 30 steps: + # jj-lib is a git submodule at vendor/jj; the crates in crates/ build + # against it, so the checkout has to bring it down or the job dies on a + # missing vendor/jj/lib/Cargo.toml. - uses: actions/checkout@v4 with: submodules: recursive - - uses: pnpm/action-setup@v6 - - uses: actions/setup-node@v4 - with: - node-version: 22.19.0 - - run: pnpm install --frozen-lockfile --ignore-scripts - name: Install pinned Rust toolchain run: rustup toolchain install + # Registry state and compiled dependencies, keyed on Cargo.lock. The + # native jj-lib build dominates this job's time without it. - uses: Swatinem/rust-cache@v2 - - name: Cargo lint gates - run: pnpm exec smthrs lint '//crates/flows-jj' - env: - SMITHERS_CACHE_URL: "${{ secrets.SMITHERS_CACHE_URL }}" - SMITHERS_CACHE_TOKEN: "${{ secrets.SMITHERS_CACHE_TOKEN }}" - - name: Cargo test suite - run: pnpm exec smthrs test '//crates/flows-jj:cargoTest' - env: - SMITHERS_CACHE_URL: "${{ secrets.SMITHERS_CACHE_URL }}" - SMITHERS_CACHE_TOKEN: "${{ secrets.SMITHERS_CACHE_TOKEN }}" + - name: Format + run: cargo fmt --check + - name: Clippy + run: cargo clippy --all-targets --locked -- -D warnings + - name: Test + run: cargo test --locked + + # The committed packages/jj/wasm/flows_jj.wasm is a reproducibility + # contract: rebuilding it from source with the pinned toolchain must give + # the same bytes. build-wasm.mjs remaps every machine-specific source + # prefix (checkout path, CARGO_HOME, toolchain sysroot) to fixed tokens, + # which is what makes the comparison meaningful across machines. No build + # cache here on purpose — the rebuild is the point. + # + # This runner's host triple is part of the contract. Cargo builds build + # scripts for the host, so their metadata hash — and every symbol hash + # above them — carries the host triple, and the same sources produce a + # different module on macOS or on arm64 Linux. The committed bytes are the + # x86_64-unknown-linux-gnu build; build-wasm.mjs refuses to run on any + # other host, so a runner change fails here with that message rather than + # a byte diff. wasm-repro: name: wasm reproducibility runs-on: ubuntu-latest timeout-minutes: 30 steps: + # Same reason as the rust job: the wasm rebuild compiles jj-lib from the + # vendor/jj submodule. - uses: actions/checkout@v4 with: submodules: recursive - - uses: pnpm/action-setup@v6 - uses: actions/setup-node@v4 with: node-version: 22.19.0 - - run: pnpm install --frozen-lockfile --ignore-scripts - name: Install pinned Rust toolchain run: rustup toolchain install - name: Build-script unit tests - run: pnpm exec smthrs test '//crates/flows-jj:buildScript' + run: node --test crates/flows-jj/build-wasm.test.mjs + - name: Rebuild flows_jj.wasm from source env: - SMITHERS_CACHE_URL: "${{ secrets.SMITHERS_CACHE_URL }}" - SMITHERS_CACHE_TOKEN: "${{ secrets.SMITHERS_CACHE_TOKEN }}" - - name: Rebuild and byte-compare flows_jj.wasm - run: pnpm exec smthrs test '//crates/flows-jj:wasmReproducibility' - env: - SMITHERS_CACHE_URL: "${{ secrets.SMITHERS_CACHE_URL }}" - SMITHERS_CACHE_TOKEN: "${{ secrets.SMITHERS_CACHE_TOKEN }}" + # A scratch target dir keeps the rebuild clean-room and exercises + # the script's CARGO_TARGET_DIR handling. + CARGO_TARGET_DIR: ${{ runner.temp }}/wasm-target + run: | + cp packages/jj/wasm/flows_jj.wasm "$RUNNER_TEMP/flows_jj.committed.wasm" + node crates/flows-jj/build-wasm.mjs + - name: Byte-compare against the committed artifact + run: | + if ! cmp "$RUNNER_TEMP/flows_jj.committed.wasm" packages/jj/wasm/flows_jj.wasm; then + echo "packages/jj/wasm/flows_jj.wasm does not reproduce from source." >&2 + echo "Rebuild it with the pinned toolchain on x86_64-unknown-linux-gnu" >&2 + echo "and commit the result. On that host:" >&2 + echo " node crates/flows-jj/build-wasm.mjs" >&2 + echo "On any other host the same script prints the container command." >&2 + exit 1 + fi + + # Runtime-compatibility check: the suites below pass under bun today + # (verified locally on bun 1.3.14, 2026-08-15). The pin has to name a + # published oven-sh/bun release: setup-bun downloads the release asset, so a + # version that exists only as a local build 404s. Coverage is disabled because + # @vitest/coverage-v8 needs V8's inspector and bun runs JavaScriptCore; the + # Node `test` job stays the coverage gate. + # + # Excluded suites, with the failure that keeps each one out: + # - database, engine-store, flows, journal, kernel, plan, run-store, + # step-cache, sync, time-travel (and examples): bun's node:sqlite binds + # the host SQLite, built with SQLITE_OMIT_LOAD_EXTENSION, which the + # @effect/sql-sqlite-node layer requires. + # - jj: NodeJjClassification expects spawn failures to classify as + # 'unknown'; bun's child_process error shape classifies as + # 'not_installed'. + # - platform-node: the Node host contract suite asserts Node-host + # behavior and is not expected to pass on bun. bun: name: test on bun runs-on: ubuntu-latest @@ -161,17 +202,25 @@ jobs: with: bun-version: 1.3.14 - run: pnpm install --frozen-lockfile --ignore-scripts + # Same jj setup as the Node job: the Bun host contract exercises a + # successful `jj status` when the binary is present. - name: Install jj uses: taiki-e/install-action@v2 with: tool: jj-cli@0.39.0 - name: Initialize colocated jj repository run: jj git init --colocate - - name: Bun-compatible suites - run: pnpm exec smthrs test '//ci/...' - env: - SMITHERS_CACHE_URL: "${{ secrets.SMITHERS_CACHE_URL }}" - SMITHERS_CACHE_TOKEN: "${{ secrets.SMITHERS_CACHE_TOKEN }}" + - name: Test bun-compatible suites + run: | + for pkg in artifacts canonical capability crypto engine flow keys platform-browser platform-bun sandbox; do + (cd "packages/$pkg" && bun node_modules/vitest/vitest.mjs run --coverage.enabled=false) + done + + # The browser contract as its own gate. scripts/browser-check.mjs bundles + # every browser entry point and pins the documented Node-only ones. No real + # browser-runner suite exists in this repo yet (no playwright, webdriverio, + # or @vitest/browser tests); when one lands, run it here instead of + # inventing a new harness. browser: name: browser bundle gate runs-on: ubuntu-latest @@ -185,15 +234,16 @@ jobs: cache: pnpm - run: pnpm install --frozen-lockfile --ignore-scripts - name: Browser bundle guard - run: pnpm exec smthrs test '//scripts:browserContract' - env: - SMITHERS_CACHE_URL: "${{ secrets.SMITHERS_CACHE_URL }}" - SMITHERS_CACHE_TOKEN: "${{ secrets.SMITHERS_CACHE_TOKEN }}" + run: pnpm run browser + + # Advisory OS coverage for the Node suite. Both jobs become required (drop + # continue-on-error) once they prove a stable green streak. Windows path + # pitfalls are expected and are not chased in this lane. node-macos: - name: "package suites (macOS, advisory)" + name: node suite (macOS, advisory) runs-on: macos-latest - timeout-minutes: 60 continue-on-error: true + timeout-minutes: 60 steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v6 @@ -211,16 +261,46 @@ jobs: tool: jj-cli@0.39.0 - name: Initialize colocated jj repository run: jj git init --colocate - - name: Package test targets - run: pnpm exec smthrs test '//packages/...' - env: - SMITHERS_CACHE_URL: "${{ secrets.SMITHERS_CACHE_URL }}" - SMITHERS_CACHE_TOKEN: "${{ secrets.SMITHERS_CACHE_TOKEN }}" + - name: Test all workspaces + run: pnpm test + + # Shadow lane for the dogfooded build system: the same gate surface as the + # `test` job's check/lint/test steps, planned and executed as smithers build + # targets (lib+check, lint+fmt, test — 130 targets over 26 packages). + # Advisory until it holds a green streak with verdicts matching the pnpm + # gates; then it becomes required and the recursive scripts retire. + smthrs-shadow: + name: smthrs ci (shadow, advisory) + runs-on: ubuntu-latest + continue-on-error: true + timeout-minutes: 90 + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v4 + with: + node-version: 22.19.0 + cache: pnpm + - run: pnpm install --frozen-lockfile --ignore-scripts + # Same jj setup as the Node job: the jj and time-travel suites exercise + # a real jj binary and refuse to skip silently. + - name: Install jj + uses: taiki-e/install-action@v2 + with: + tool: jj-cli@0.39.0 + - name: Initialize colocated jj repository + run: jj git init --colocate + # Two concurrent targets: the heavy vitest suites carry finite 30s + # per-test budgets that host parallelism on a 4-core runner starves + # when several run at once. + - name: Execute the merged ci graph + run: node packages/build-cli/src/main.js ci "//packages/..." --jobs 2 + node-windows: - name: "package suites (Windows, advisory)" + name: node suite (Windows, advisory) runs-on: windows-latest - timeout-minutes: 60 continue-on-error: true + timeout-minutes: 60 steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v6 @@ -238,8 +318,5 @@ jobs: tool: jj-cli@0.39.0 - name: Initialize colocated jj repository run: jj git init --colocate - - name: Package test targets - run: pnpm exec smthrs test '//packages/...' - env: - SMITHERS_CACHE_URL: "${{ secrets.SMITHERS_CACHE_URL }}" - SMITHERS_CACHE_TOKEN: "${{ secrets.SMITHERS_CACHE_TOKEN }}" + - name: Test all workspaces + run: pnpm test diff --git a/.github/workflows/gen.issue-intake.yml b/.github/workflows/gen.issue-intake.yml deleted file mode 100644 index 050e3841..00000000 --- a/.github/workflows/gen.issue-intake.yml +++ /dev/null @@ -1,40 +0,0 @@ -# GENERATED by //:issueIntake. Do not edit. Edit BUILD.ts and run smthrs run //:issueIntake. -name: Issue intake -on: - issues: - types: [opened, edited] - workflow_dispatch: - inputs: - issue: - description: The issue number this run is about - required: true -permissions: - contents: read -concurrency: - group: "gen-issue-intake-${{ github.event.issue.number || inputs.issue }}" - cancel-in-progress: false -jobs: - intake: - name: "decode, dedupe, and comment" - runs-on: ubuntu-latest - if: ${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.issue.author_association) || contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association) || contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.pull_request.author_association) || contains(github.event.issue.labels.*.name, 'agent:approved') || contains(github.event.pull_request.labels.*.name, 'agent:approved') }} - timeout-minutes: 20 - permissions: - contents: write - issues: write - steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v6 - - uses: actions/setup-node@v4 - with: - node-version: 22.19.0 - cache: pnpm - - run: pnpm install --frozen-lockfile --ignore-scripts - - name: Install the agent CLI - run: npm install --global @anthropic-ai/claude-code - - name: Run intake - run: node factory/automation/intake.ts - env: - GH_TOKEN: "${{ github.token }}" - ISSUE_NUMBER: "${{ inputs.issue }}" - ANTHROPIC_API_KEY: "${{ secrets.ANTHROPIC_API_KEY }}" diff --git a/.github/workflows/gen.issue-reply.yml b/.github/workflows/gen.issue-reply.yml deleted file mode 100644 index bcf9fff8..00000000 --- a/.github/workflows/gen.issue-reply.yml +++ /dev/null @@ -1,34 +0,0 @@ -# GENERATED by //:issueReply. Do not edit. Edit BUILD.ts and run smthrs run //:issueReply. -name: Repro state machine -on: - issue_comment: - types: [created] -permissions: - contents: read -concurrency: - group: "gen-issue-reply-${{ github.event.issue.number }}" - cancel-in-progress: false -jobs: - advance: - name: advance the repro state - runs-on: ubuntu-latest - if: ${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.issue.author_association) || contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association) || contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.pull_request.author_association) || contains(github.event.issue.labels.*.name, 'agent:approved') || contains(github.event.pull_request.labels.*.name, 'agent:approved') }} - timeout-minutes: 15 - permissions: - contents: read - issues: write - steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v6 - - uses: actions/setup-node@v4 - with: - node-version: 22.19.0 - cache: pnpm - - run: pnpm install --frozen-lockfile --ignore-scripts - - name: Install the agent CLI - run: npm install --global @anthropic-ai/claude-code - - name: Run advance - run: node factory/automation/advance.ts - env: - GH_TOKEN: "${{ github.token }}" - ANTHROPIC_API_KEY: "${{ secrets.ANTHROPIC_API_KEY }}" diff --git a/.github/workflows/gen.poc-loop.yml b/.github/workflows/gen.poc-loop.yml deleted file mode 100644 index 87d1643a..00000000 --- a/.github/workflows/gen.poc-loop.yml +++ /dev/null @@ -1,111 +0,0 @@ -# GENERATED by //:pocLoop. Do not edit. Edit BUILD.ts and run smthrs run //:pocLoop. -name: Repro PoC loop -on: - issues: - types: [labeled] - workflow_dispatch: - inputs: - issue: - description: The issue number this run is about - required: true -permissions: - contents: read -concurrency: - group: "gen-poc-loop-${{ github.event.issue.number || inputs.issue }}" - cancel-in-progress: false -jobs: - author: - name: write the repro pair - runs-on: ubuntu-latest - if: ${{ (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.issue.author_association) || contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association) || contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.pull_request.author_association) || contains(github.event.issue.labels.*.name, 'agent:approved') || contains(github.event.pull_request.labels.*.name, 'agent:approved')) && (github.event_name == 'workflow_dispatch' || github.event.label.name == 'agent:approved') }} - timeout-minutes: 30 - permissions: - contents: write - issues: read - steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v6 - - uses: actions/setup-node@v4 - with: - node-version: 22.19.0 - cache: pnpm - - run: pnpm install --frozen-lockfile --ignore-scripts - - name: Install the agent CLI - run: npm install --global @anthropic-ai/claude-code - - name: Run author - run: node factory/automation/poc.ts - env: - GH_TOKEN: "${{ github.token }}" - ISSUE_NUMBER: "${{ inputs.issue }}" - ANTHROPIC_API_KEY: "${{ secrets.ANTHROPIC_API_KEY }}" - - name: Upload poc - uses: actions/upload-artifact@v4 - with: - name: poc - path: factory/repros - if-no-files-found: error - execute: - name: run the repro in a no-secrets sandbox - runs-on: ubuntu-latest - needs: [author] - if: ${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.issue.author_association) || contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association) || contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.pull_request.author_association) || contains(github.event.issue.labels.*.name, 'agent:approved') || contains(github.event.pull_request.labels.*.name, 'agent:approved') }} - timeout-minutes: 30 - permissions: - contents: read - steps: - - uses: actions/checkout@v4 - with: - persist-credentials: "false" - - uses: pnpm/action-setup@v6 - - uses: actions/setup-node@v4 - with: - node-version: 22.19.0 - cache: pnpm - - run: pnpm install --frozen-lockfile --ignore-scripts - - name: Download poc - uses: actions/download-artifact@v4 - with: - name: poc - path: factory/repros - - name: Run execute - run: node factory/automation/poc-run.ts - env: - ISSUE_NUMBER: "${{ inputs.issue }}" - - name: Upload poc-result - uses: actions/upload-artifact@v4 - with: - name: poc-result - path: factory/repros - if-no-files-found: error - publish: - name: post the PoC and ask the reporter - runs-on: ubuntu-latest - needs: [execute] - if: ${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.issue.author_association) || contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association) || contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.pull_request.author_association) || contains(github.event.issue.labels.*.name, 'agent:approved') || contains(github.event.pull_request.labels.*.name, 'agent:approved') }} - timeout-minutes: 15 - permissions: - contents: write - issues: write - steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v6 - - uses: actions/setup-node@v4 - with: - node-version: 22.19.0 - cache: pnpm - - run: pnpm install --frozen-lockfile --ignore-scripts - - name: Download poc - uses: actions/download-artifact@v4 - with: - name: poc - path: factory/repros - - name: Download poc-result - uses: actions/download-artifact@v4 - with: - name: poc-result - path: factory/repros - - name: Run publish - run: node factory/automation/poc-publish.ts - env: - GH_TOKEN: "${{ github.token }}" - ISSUE_NUMBER: "${{ inputs.issue }}" diff --git a/.github/workflows/gen.pr-review.yml b/.github/workflows/gen.pr-review.yml deleted file mode 100644 index ee32ad3b..00000000 --- a/.github/workflows/gen.pr-review.yml +++ /dev/null @@ -1,40 +0,0 @@ -# GENERATED by //:prReview. Do not edit. Edit BUILD.ts and run smthrs run //:prReview. -name: Pull request review -on: - pull_request_target: - types: [opened, synchronize, reopened, ready_for_review] -permissions: - contents: read -concurrency: - group: "gen-pr-review-${{ github.event.pull_request.number }}" - cancel-in-progress: false -jobs: - review: - name: rubric review over the diff - runs-on: ubuntu-latest - if: ${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.issue.author_association) || contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association) || contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.pull_request.author_association) || contains(github.event.issue.labels.*.name, 'agent:approved') || contains(github.event.pull_request.labels.*.name, 'agent:approved') }} - timeout-minutes: 45 - permissions: - contents: read - pull-requests: write - steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v6 - - uses: actions/setup-node@v4 - with: - node-version: 22.19.0 - cache: pnpm - - run: pnpm install --frozen-lockfile --ignore-scripts - - name: Install the agent CLI - run: npm install --global @anthropic-ai/claude-code - - name: Cache .flows/pr-review - uses: actions/cache@v4 - with: - path: ".flows/pr-review" - key: "pr-review-${{ github.event.pull_request.number }}-${{ github.sha }}" - restore-keys: "pr-review-${{ github.event.pull_request.number }}-" - - name: Run review - run: node factory/automation/review.ts - env: - GH_TOKEN: "${{ github.token }}" - ANTHROPIC_API_KEY: "${{ secrets.ANTHROPIC_API_KEY }}" diff --git a/.github/workflows/gen.repro-proof.yml b/.github/workflows/gen.repro-proof.yml deleted file mode 100644 index 767ce520..00000000 --- a/.github/workflows/gen.repro-proof.yml +++ /dev/null @@ -1,34 +0,0 @@ -# GENERATED by //:reproProof. Do not edit. Edit BUILD.ts and run smthrs run //:reproProof. -name: Repro proof gate -on: - pull_request: - types: [opened, synchronize, reopened, labeled] -permissions: - contents: read -concurrency: - group: "gen-repro-proof-${{ github.event.pull_request.number }}" - cancel-in-progress: false -jobs: - proof: - name: "fail at the merge base, pass at the head" - runs-on: ubuntu-latest - if: ${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.issue.author_association) || contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association) || contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.pull_request.author_association) || contains(github.event.issue.labels.*.name, 'agent:approved') || contains(github.event.pull_request.labels.*.name, 'agent:approved') }} - timeout-minutes: 45 - permissions: - contents: read - steps: - - uses: actions/checkout@v4 - with: - persist-credentials: "false" - fetch-depth: "0" - - uses: pnpm/action-setup@v6 - - uses: actions/setup-node@v4 - with: - node-version: 22.19.0 - cache: pnpm - - run: pnpm install --frozen-lockfile --ignore-scripts - - name: Run proof - run: node factory/automation/proof.ts - env: - PR_BASE_SHA: "${{ github.event.pull_request.base.sha }}" - PR_BODY: "${{ github.event.pull_request.body }}" diff --git a/.github/workflows/gen.repro-reverify.yml b/.github/workflows/gen.repro-reverify.yml deleted file mode 100644 index 0cc29f0b..00000000 --- a/.github/workflows/gen.repro-reverify.yml +++ /dev/null @@ -1,31 +0,0 @@ -# GENERATED by //:reproReverify. Do not edit. Edit BUILD.ts and run smthrs run //:reproReverify. -name: Repro re-verification sweep -on: - schedule: - - cron: "17 4 * * *" - workflow_dispatch: -permissions: - contents: read -concurrency: - group: gen-repro-reverify - cancel-in-progress: false -jobs: - reverify: - name: re-run parked and verified repros against main - runs-on: ubuntu-latest - timeout-minutes: 120 - permissions: - contents: write - issues: write - steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v6 - - uses: actions/setup-node@v4 - with: - node-version: 22.19.0 - cache: pnpm - - run: pnpm install --frozen-lockfile --ignore-scripts - - name: Run reverify - run: node factory/automation/reverify.ts - env: - GH_TOKEN: "${{ github.token }}" diff --git a/.github/workflows/gen.verified-fix.yml b/.github/workflows/gen.verified-fix.yml deleted file mode 100644 index 3e15a9b0..00000000 --- a/.github/workflows/gen.verified-fix.yml +++ /dev/null @@ -1,41 +0,0 @@ -# GENERATED by //:verifiedFix. Do not edit. Edit BUILD.ts and run smthrs run //:verifiedFix. -name: Verified repro to pull request -on: - issues: - types: [labeled] - workflow_dispatch: - inputs: - issue: - description: The issue number this run is about - required: true -permissions: - contents: read -concurrency: - group: "gen-verified-fix-${{ github.event.issue.number || inputs.issue }}" - cancel-in-progress: false -jobs: - fix: - name: "queue the item, run the lane, open the pull request" - runs-on: ubuntu-latest - if: ${{ (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.issue.author_association) || contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association) || contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.pull_request.author_association) || contains(github.event.issue.labels.*.name, 'agent:approved') || contains(github.event.pull_request.labels.*.name, 'agent:approved')) && (github.event_name == 'workflow_dispatch' || github.event.label.name == 'repro:verified' || github.event.label.name == 'agent:approved') }} - timeout-minutes: 120 - permissions: - contents: write - issues: write - pull-requests: write - steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v6 - - uses: actions/setup-node@v4 - with: - node-version: 22.19.0 - cache: pnpm - - run: pnpm install --frozen-lockfile --ignore-scripts - - name: Install the agent CLI - run: npm install --global @anthropic-ai/claude-code - - name: Run fix - run: node factory/automation/fix.ts - env: - GH_TOKEN: "${{ secrets.FACTORY_PR_TOKEN || github.token }}" - ISSUE_NUMBER: "${{ inputs.issue }}" - ANTHROPIC_API_KEY: "${{ secrets.ANTHROPIC_API_KEY }}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b73c4875..bcbe83ec 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -109,10 +109,6 @@ jobs: run: node --test scripts/flows-backup.test.mjs - name: Test-pin register guard run: node --test scripts/check-test-pins.test.mjs - - name: Factory automation typecheck - run: pnpm exec tsc -p factory/automation/tsconfig.json - - name: Factory automation tests - run: node --test 'factory/automation/*.test.ts' - name: Build all workspaces from clean artifacts run: | find packages -type d -name dist -prune -exec rm -rf {} + diff --git a/.gitignore b/.gitignore index e5c9a1ab..3e2e4204 100644 --- a/.gitignore +++ b/.gitignore @@ -78,8 +78,8 @@ packages/*/.smithers/ # Wrangler local dev-server state (miniflare sqlite, DO storage, traces) .wrangler/ -# Launch-checklist dry runs prove the runner, not the product: they touch no -# origin, so they are never evidence and never belong in the tracked report -# trail under apps/reports/launch-checklist/. -apps/reports/launch-checklist/*-dry-run/ -.money-scratch/ +# Bazel +bazel-* + +# Local Bazel disk cache (configured in .bazelrc) +.bazel-cache/ diff --git a/.npmrc b/.npmrc index 23bcb8cb..7aa6c206 100644 --- a/.npmrc +++ b/.npmrc @@ -1,5 +1,5 @@ # pnpm 11 reads only auth and registry settings from .npmrc; every other -# setting lives in the hand-written pnpm-workspace.yaml planner input. +# setting lives in pnpm-workspace.yaml, which the root BUILD.ts generates. # `verifyDepsBeforeRun: false` is declared there: a gate run must never mutate # the install it is measuring, and installation here is an explicit step (the # workflow's install step locally, the tsflows Install target under the build diff --git a/.smithers/package.json b/.smithers/package.json index c02afb9f..95ec3b53 100644 --- a/.smithers/package.json +++ b/.smithers/package.json @@ -9,7 +9,7 @@ "workflow:run": "smithers workflow run", "workflow:inspect": "smithers workflow inspect", "workflow:skills": "smithers workflow skills", - "test": "bun test ./tests/alpha-ui.test.tsx ./tests/alpha-ui-evals.test.tsx ./tests/alpha-agent.test.tsx ./tests/alpha-agent-eval-judge.test.tsx ./tests/upstream-drift.test.tsx ./tests/canonical-history.test.tsx" + "test": "bun test ./tests/alpha-ui.test.tsx ./tests/alpha-ui-evals.test.tsx ./tests/alpha-agent.test.tsx ./tests/alpha-agent-eval-judge.test.tsx ./tests/upstream-drift.test.tsx" }, "dependencies": { "@milkdown/crepe": "7.21.2", diff --git a/.smithers/tests/alpha-agent.test.tsx b/.smithers/tests/alpha-agent.test.tsx index 6d47af66..9d505852 100644 --- a/.smithers/tests/alpha-agent.test.tsx +++ b/.smithers/tests/alpha-agent.test.tsx @@ -94,7 +94,7 @@ describe("alpha-agent workflow graph", () => { expect(human.needsApproval).toBe(true) }) - test("a FIX review mounts correction and cannot mount the lane's land task", async () => { + test("a review row mounts that lane's land task in the bounded merge queue", async () => { const g = await render({ alphaReview: [ { @@ -108,21 +108,14 @@ describe("alpha-agent workflow graph", () => { ], }) const nodeIds = ids(g) - expect(nodeIds).not.toContain("a1Land") + expect(nodeIds).toContain("a1Land") expect(nodeIds).not.toContain("a2Land") - expect(nodeIds).toContain("a1Correction") - expect(String(pick(g, "a1Correction").prompt)).toContain("1. add the steer round-trip assertion") - }) - - test("only APPROVE mounts a lane's land task", async () => { - const g = await render({ - alphaReview: [ - { nodeId: "a1Review", iteration: 0, laneKey: "a1", verdict: "APPROVE", findings: "none", dodAssessment: "all requirements verified" }, - ], - }) const land = pick(g, "a1Land") expect(land.parallelGroupId).toBe("alphaMergeQueue") + // Bounded, not serialized: each land rebases and retries on rejection, so + // three at a time trades no safety for real wall clock. expect(land.parallelMaxConcurrency).toBe(3) + expect(String(land.prompt)).toContain("1. add the steer round-trip assertion") }) test("a landed lane mounts its polish loop; FIX verdicts mount the fix task", async () => { diff --git a/.smithers/tests/alpha-ui.test.tsx b/.smithers/tests/alpha-ui.test.tsx index 2e725ed0..d34594d2 100644 --- a/.smithers/tests/alpha-ui.test.tsx +++ b/.smithers/tests/alpha-ui.test.tsx @@ -42,9 +42,8 @@ test("frame 0 renders every lane pipeline, the land queue, the panel, and the ga expect(ids).toContain("panelCodex") expect(ids).toContain("panelFable") expect(ids).toContain("panelFix") - expect(ids).not.toContain("humanTasksDoc") - expect(ids).not.toContain("humanGate") - expect(ids).toContain("panelFailed") + expect(ids).toContain("humanTasksDoc") + expect(ids).toContain("humanGate") }) test("lane pipeline order: impl before review before fix, land after the lane", async () => { @@ -194,9 +193,4 @@ test("panel failures open the panel fix step; a passing panel keeps it skipped", ], }) expect(taskOf(passing, "panelFix").skipIf).toBe(true) - expect(idsOf(passing)).toContain("humanTasksDoc") - expect(idsOf(passing)).toContain("humanGate") - expect(idsOf(passing)).not.toContain("panelFailed") - expect(idsOf(failing)).not.toContain("humanTasksDoc") - expect(idsOf(failing)).not.toContain("humanGate") }) diff --git a/.smithers/workflows/alpha-agent.tsx b/.smithers/workflows/alpha-agent.tsx index df5a0621..2bceb7f7 100644 --- a/.smithers/workflows/alpha-agent.tsx +++ b/.smithers/workflows/alpha-agent.tsx @@ -29,12 +29,6 @@ const { Workflow, smithers, outputs } = createSmithers({ findings: z.string().min(2), dodAssessment: z.string().min(20), }), - alphaCorrection: z.object({ - laneKey: laneKeyEnum, - summary: z.string().min(20), - addressed: z.string().min(2), - commitTip: z.string().min(7), - }), alphaLand: z.object({ laneKey: laneKeyEnum, landed: z.boolean(), @@ -415,8 +409,7 @@ export default smithers((ctx) => { // lost a push race. Keep it queued until it reports a verified landing, or // the lane strands silently and the run proceeds as if it shipped. const landVerified = (k: string) => truthy(col(landRow(k), "landed")) - const reviewApproved = (k: string) => col(reviewRow(k), "verdict") === "APPROVE" - const laneReadyToLand = (k: string) => reviewApproved(k) && !landVerified(k) + const laneReadyToLand = (k: string) => reviewRow(k) !== undefined && !landVerified(k) // Polish mounts on any land ATTEMPT, not on the landed flag, so a lane whose // reporting row is wrong still gets its post-land review. const landAttempted = (k: string) => landRow(k) !== undefined @@ -510,31 +503,18 @@ never batch, never wait for other lanes. LANE: ${lane.title} WORKTREE: ${wtPath(lane.key)} (branch alpha/${lane.key}) REVIEW VERDICT: ${col(reviewRow(lane.key), "verdict") ?? "(pending)"} -APPROVED REVIEW FINDINGS: +REVIEW FINDINGS TO APPLY FIRST: ${col(reviewRow(lane.key), "findings") ?? "(pending)"} ${RULES} WHAT TO DO: 1. cd ${wtPath(lane.key)}. Preflight per the rules. -2. Refuse to continue unless the review verdict is APPROVE. A FIX verdict is - handled by the bounded correction/re-review loop before this node mounts. +2. If the review verdict is FIX, apply every finding as additional commits on + the branch (explicit pathspecs, emoji conventional commits). If APPROVE with + findings "none", proceed directly. ${LAND_PROTOCOL} Report: laneKey exactly "${lane.key}"; landed; landedShas (exact shas now on origin/main, or "none"); gatesRun (exact commands); gatesGreen; notes (what you applied from review, conflicts resolved, or why landing failed). -` - - const correctionPrompt = (lane: LaneSpec) => ` -You are the PRE-LAND correction step for lane ${lane.key} (${lane.title}). The -review verdict was FIX, so this lane is not authorized to land. Apply every -numbered finding in the lane worktree, commit the corrections, and do not push. -A fresh review runs after you finish. - -${RULES} - -REVIEW FINDINGS: -${String(col(reviewRow(lane.key), "findings") ?? "(missing review findings)")} - -Report laneKey exactly "${lane.key}", summary, addressed, and commitTip. ` const polishReviewPrompt = (k: string, title: string) => ` @@ -775,32 +755,16 @@ Landed work remains on main (fix-forward track). Please direct the next step. Re > {implPrompt(lane)} - - - - {reviewPrompt(lane)} - - {col(reviewRow(lane.key), "verdict") === "FIX" ? ( - - {correctionPrompt(lane)} - - ) : null} - - + + {reviewPrompt(lane)} + ))} @@ -819,7 +783,7 @@ Landed work remains on main (fix-forward track). Please direct the next step. Re - {/* Phase 3: merge queue. A lane lands only after APPROVE. */} + {/* Phase 3: merge queue. A lane lands the moment its review exists. */} {LANES.filter((lane) => laneReadyToLand(lane.key)).map((lane) => ( { - {panelPassed() ? ( - - - {humanDocPrompt} - - - - ) : ( - - {() => ({ - passed: false as const, - attemptsExhausted: true as const, - failures: panelFailuresText() || "The panel did not produce two PRODUCTION-READY verdicts.", - summary: "The bounded production-readiness panel exhausted without passing; no readiness handoff or approval was mounted.", - })} - - )} + + {humanDocPrompt} + + ) diff --git a/BUILD.bazel b/BUILD.bazel new file mode 100644 index 00000000..bcde26d8 --- /dev/null +++ b/BUILD.bazel @@ -0,0 +1,92 @@ +# Root Bazel package for the flows workspace. +# +# NOTE: the `BUILD.ts` files in this repository belong to the in-repo smithers +# build system, not to Bazel. Bazel loads only files named `BUILD` or +# `BUILD.bazel`; this repository standardizes on `BUILD.bazel` so the two +# systems can never be confused, and no `BUILD.ts` file is read, modified, or +# deleted by anything in the Bazel setup. +load("@aspect_gazelle_prebuilt//:def.bzl", "aspect_gazelle") +load("@aspect_rules_js//js:defs.bzl", "js_library") +load("@aspect_rules_ts//ts:defs.bzl", "ts_config") +load("@npm//:defs.bzl", "npm_link_all_packages") + +# --------------------------------------------------------------------------- +# Gazelle +# +# `bazel run //:gazelle` walks the workspace and generates BUILD.bazel files +# from package.json files and import statements. `bazel run //:gazelle.check` +# (wired with_check below) fails in CI when the committed files are stale. +# +# Directives for the JS plugin. Most packages keep their tsconfig in +# tsconfig.json and the test tsconfig in tsconfig.test.json; the second +# js_tsconfig_file line applies the test tsconfig to the generated *_tests +# target group. +# --------------------------------------------------------------------------- +# gazelle:js_pnpm_lockfile pnpm-lock.yaml +# gazelle:js_files .bazel-no-root-sources +# gazelle:js_test_files .bazel-no-root-test-sources +# gazelle:exclude lint +# gazelle:exclude apps/ui/.smithers +# gazelle:js_tsconfig_file tsconfig.json +# gazelle:js_tsconfig_file {dirname}_tests tsconfig.test.json +# gazelle:js_validate_import_statements warn +# gazelle:js_tsconfig_package_deps enabled +# gazelle:js_npm_package_target_name pkg +# gazelle:js_visibility //:__subpackages__ +# gazelle:exclude vendor +# gazelle:exclude docs +# gazelle:exclude evals +aspect_gazelle( + name = "gazelle", + languages = ["js"], + with_check = True, +) + +exports_files( + [ + ".npmrc", + "Cargo.lock", + "Cargo.toml", + "eslint.jsdoc.js", + "rust-toolchain.toml", + "pnpm-lock.yaml", + "pnpm-workspace.yaml", + "tsconfig.base.json", + ], + visibility = ["//visibility:public"], +) + +# The shared jsdoc convention, imported by every package's eslint.config.js as +# ../../eslint.jsdoc.js. Wrapped in a js_library so package-level configs can +# declare it as a dependency. +js_library( + name = "eslint_jsdoc", + srcs = ["eslint.jsdoc.js"], + visibility = ["//visibility:public"], + deps = [":node_modules/eslint-plugin-jsdoc"], +) + +# The aggregator flat config the lint aspect discovers from the bin root. +# Deps carry each package's config and plugin closure into the action. +js_library( + name = "eslint_config", + srcs = ["eslint.config.js"], + visibility = ["//visibility:public"], + deps = [ + "//packages/canonical:eslint_config", + "//packages/crypto:eslint_config", + "//packages/keys:eslint_config", + ], +) + +ts_config( + name = "tsconfig", + src = "tsconfig.json", + visibility = [":__subpackages__"], + deps = [ + ":package.json", + ":tsconfig.base.json", + ], +) + +npm_link_all_packages(name = "node_modules") diff --git a/BUILD.ts b/BUILD.ts index f0781884..ff1d8ab2 100644 --- a/BUILD.ts +++ b/BUILD.ts @@ -1,24 +1,83 @@ +/** + * Root smithers build targets for the flows workspace. + * + * Every TypeScript-specific root file is declared here and generated from this + * file: the workspace definition, the workspace tsconfig, and the lockfile. + * `nodeModules` is a target produced by the `Install` rule, keyed on the + * declared toolchain and the generated lockfile. + * + * The runtime and the package manager are declared once and passed to every + * target that runs a tool. Nothing in the rule catalog spells `pnpm` or `node` + * into an argv any more, so switching either is an edit to this file. + */ import { Smithers } from "@smthrs/targets" -export const cacheToken = Smithers.Secret("SMITHERS_CACHE_TOKEN") -export const cacheUrl = Smithers.Secret("SMITHERS_CACHE_URL") - -export const rootPackageJson = Smithers.file("//package.json") -export const rootTsconfig = Smithers.file("//tsconfig.base.json") -export const workspaceTsconfig = Smithers.file("//tsconfig.json") -export const rootJSDocConfig = Smithers.file("//eslint.jsdoc.js") -const workspace = Smithers.pnpmWorkspace("//pnpm-workspace.yaml") +// --------------------------------------------------------------------------- +// Toolchain +// --------------------------------------------------------------------------- +/** + * The interpreter every tool runs under. The declaration is a requirement: the + * Runtime service measures the host and refuses to execute when it does not + * satisfy this. + */ export const runtime = Smithers.Runtime.Node({ version: ">=22.19.0" }) + +/** + * The package manager. It takes the runtime as a dependency because pnpm is + * itself a program the runtime executes. + */ export const packageManager = Smithers.PackageManager.Pnpm({ version: "11.21.0", runtime }) -export const bunRuntime = Smithers.Runtime.Bun({ version: ">=1.3.0" }) -export const bunPackageManager = Smithers.PackageManager.BunPackages({ runtime: bunRuntime }) +// --------------------------------------------------------------------------- +// Secrets +// --------------------------------------------------------------------------- + +/** + * The remote-cache bearer token. `Secret` names the environment variable the + * value is read from at execution time. A target that declares this secret is + * given an unguessable placeholder in its environment; the substituting proxy + * swaps the placeholder for the real value on outbound requests. Key material + * records the variable name, never the value. + */ +export const cacheToken = Smithers.Secret("SMITHERS_CACHE_TOKEN") + +/** The remote-cache endpoint override, resolved the same way. */ +export const cacheUrl = Smithers.Secret("SMITHERS_CACHE_URL") -export const rustToolchain = Smithers.RustToolchain.Pinned({}) +// --------------------------------------------------------------------------- +// Generated root files +// --------------------------------------------------------------------------- +/** Generates and drift-checks `pnpm-workspace.yaml`. */ +export const workspace = Smithers.PnpmWorkspace({ + packageManager, + packages: ["packages/*", "packages/build/infra", "examples", "apps/*"], + allowBuilds: { + "@journeyapps/wa-sqlite": false, + dprint: false, + "es5-ext": false, + esbuild: false, + "msgpackr-extract": false, + sharp: false, + "unrs-resolver": false, + "vue-demi": false, + workerd: false + }, + linkWorkspacePackages: true, + settings: { + // pnpm 11 reads settings from pnpm-workspace.yaml alone, so the gate run + // must never reinstall what it is measuring: installation is an explicit + // step (the workflow's install step locally, the Install target under the + // build system), and a `--ignore-scripts` install hashes differently than + // a default one, making a spurious "mismatch" routine. + verifyDepsBeforeRun: false + } +}) + +/** Generates and drift-checks the workspace `tsconfig.json`. */ export const tsconfig = Smithers.Tsconfig({ - extends: rootTsconfig, + extends: Smithers.file("tsconfig.base.json"), compilerOptions: { noEmit: true, module: "NodeNext", @@ -26,14 +85,6 @@ export const tsconfig = Smithers.Tsconfig({ paths: { "*": ["./*"] } }, include: [ - "BUILD.ts", - "apps/*/BUILD.ts", - "ci/BUILD.ts", - "crates/*/BUILD.ts", - "evals/*/BUILD.ts", - "lint/BUILD.ts", - "scripts/BUILD.ts", - "packages/*/BUILD.ts", "packages/*/src/**/*", "packages/*/test/**/*", "packages/storage/*/src/**/*", @@ -43,155 +94,46 @@ export const tsconfig = Smithers.Tsconfig({ exclude: ["**/dist/**", "packages/coding-agent/examples/extensions/gondolin/**"] }) +/** + * Generates `pnpm-lock.yaml` from the workspace definition and every package + * manifest. The lockfile is this target's output and the install target's + * input, which is why the two are separate: a rule cannot be keyed on a file it + * produces. + */ export const lockfile = Smithers.Lockfile({ packageManager, - manifests: [workspace] + workspace }) +// --------------------------------------------------------------------------- +// node_modules +// --------------------------------------------------------------------------- + export const nodeModules = Smithers.Install({ packageManager, lockfile, - workspaceManifest: workspace + workspace }) -const ubuntu = "ubuntu-latest" - -const node = Smithers.CiToolchain.Node({ runtime, release: "22.19.0" }) - -const bareNode = Smithers.CiToolchain.Node({ runtime, release: "22.19.0", cachePackageStore: false }) +// --------------------------------------------------------------------------- +// Shared declarations and workspace policy +// --------------------------------------------------------------------------- -const bun = Smithers.CiToolchain.Bun({ runtime: bunRuntime, release: "1.3.14" }) - -const jj = Smithers.CiToolchain.Jj({ release: "0.39.0" }) +export const rootPackageJson = Smithers.file("//package.json") +export const rootTsconfig = Smithers.file("//tsconfig.base.json") +export const workspaceTsconfig = Smithers.file("//tsconfig.json") +export const rootJSDocConfig = Smithers.file("//eslint.jsdoc.js") +export const pnpmWorkspace = Smithers.file("//pnpm-workspace.yaml") +// .github/workflows/ci.yml is hand-written. This target's default `contract` +// mode only verifies the checked-in workflow still runs the declared gates; +// only an explicit `write` mode would regenerate the file. export const ci = Smithers.GithubCiGen({ packageManager, cacheUrlSecret: cacheUrl, cacheTokenSecret: cacheToken, - workflowDispatch: false, - mode: "check", - gates: [ - { name: "documentation parity", verb: Smithers.Verb.Docs, pattern: "//packages/...", job: "test" }, - { name: "browser contract", verb: Smithers.Verb.Test, pattern: "//scripts:browserContract" } - ], - requiredJobs: ["test", "apps-e2e", "rust", "wasm-repro", "bun", "browser", "node-macos", "node-windows"], - jobs: [ - { - id: "test", - name: "workspace graph (coverage gates enforced)", - runsOn: ubuntu, - toolchain: Smithers.CiToolchain.Needs({ - runtimes: [node, bun], - jj, - workflowLint: Smithers.CiToolchain.Actionlint({ - release: "1.7.11", - workflows: [ - ".github/workflows/ci.yml", - ".github/workflows/release.yml", - ".github/workflows/apps-deploy.yml", - ".github/workflows/canary.yml" - ] - }) - }), - steps: [ - { name: "Workspace targets", verb: Smithers.Verb.Ci, pattern: "//packages/...", parallelism: 2 }, - { name: "Script gates", verb: Smithers.Verb.Test, pattern: "//scripts/..." }, - { - name: "Agent eval suite (offline, baseline-gated)", - verb: Smithers.Verb.Test, - pattern: "//evals/agent:suite" - }, - { name: "Agent eval typecheck", verb: Smithers.Verb.Build, pattern: "//evals/agent:types" }, - { name: "Generated workflow drift", verb: Smithers.Verb.Lint, pattern: "//:ci" } - ] - }, - { - id: "apps-e2e", - name: "apps e2e (worker + browser)", - runsOn: ubuntu, - timeoutMinutes: 30, - toolchain: Smithers.CiToolchain.Needs({ - runtimes: [node, bun], - browser: Smithers.CiToolchain.Browser({ - executable: "/usr/bin/google-chrome", - reason: "findBrowser only probes BROWSER_CANDIDATES in apps/ui/src/launch-checklist/BrowserLaunch.ts" - }), - artifacts: Smithers.CiToolchain.Artifacts({ - artifact: "apps-e2e-artifacts", - sources: [{ from: "/tmp/smithers-*.png" }, { from: "apps/reports", as: "reports" }] - }) - }), - steps: [{ name: "UI end-to-end suites", verb: Smithers.Verb.Test, pattern: "//apps/ui" }] - }, - { - id: "rust", - name: "rust fmt + clippy + test", - runsOn: ubuntu, - timeoutMinutes: 30, - toolchain: Smithers.CiToolchain.Needs({ - submodules: true, - runtimes: [bareNode], - rust: Smithers.CiToolchain.Rust({ toolchain: rustToolchain }) - }), - steps: [ - { name: "Cargo lint gates", verb: Smithers.Verb.Lint, pattern: "//crates/flows-jj" }, - { name: "Cargo test suite", verb: Smithers.Verb.Test, pattern: "//crates/flows-jj:cargoTest" } - ] - }, - { - id: "wasm-repro", - name: "wasm reproducibility", - runsOn: ubuntu, - timeoutMinutes: 30, - toolchain: Smithers.CiToolchain.Needs({ - submodules: true, - runtimes: [bareNode], - rust: Smithers.CiToolchain.Rust({ toolchain: rustToolchain, cache: false }) - }), - steps: [ - { name: "Build-script unit tests", verb: Smithers.Verb.Test, pattern: "//crates/flows-jj:buildScript" }, - { - name: "Rebuild and byte-compare flows_jj.wasm", - verb: Smithers.Verb.Test, - pattern: "//crates/flows-jj:wasmReproducibility" - } - ] - }, - { - id: "bun", - name: "test on bun", - runsOn: ubuntu, - timeoutMinutes: 30, - toolchain: Smithers.CiToolchain.Needs({ runtimes: [node, bun], jj }), - steps: [{ name: "Bun-compatible suites", verb: Smithers.Verb.Test, pattern: "//ci/..." }] - }, - { - id: "browser", - name: "browser bundle gate", - runsOn: ubuntu, - timeoutMinutes: 10, - toolchain: Smithers.CiToolchain.Needs({ runtimes: [node] }), - steps: [{ name: "Browser bundle guard", verb: Smithers.Verb.Test, pattern: "//scripts:browserContract" }] - }, - { - id: "node-macos", - name: "package suites (macOS, advisory)", - runsOn: "macos-latest", - continueOnError: true, - timeoutMinutes: 60, - toolchain: Smithers.CiToolchain.Needs({ runtimes: [node, bun], jj }), - steps: [{ name: "Package test targets", verb: Smithers.Verb.Test, pattern: "//packages/..." }] - }, - { - id: "node-windows", - name: "package suites (Windows, advisory)", - runsOn: "windows-latest", - continueOnError: true, - timeoutMinutes: 60, - toolchain: Smithers.CiToolchain.Needs({ runtimes: [node, bun], jj }), - steps: [{ name: "Package test targets", verb: Smithers.Verb.Test, pattern: "//packages/..." }] - } - ] + kinds: ["build", "test", "lint", "docs"], + gates: [{ name: "documentation parity", command: "pnpm exec smthrs docs '//...'", job: "test" }] }) export const packageDefaults = Smithers.PackageDefaults({ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7fd44dc3..bf9c0701 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,127 +14,8 @@ pnpm run test:examples pnpm exec vocs build ``` -`pnpm test` is the one that catches the most, and it stops at the first -failing package — so a green partial run proves less than it looks like it -does. `pnpm --recursive --if-present --no-bail run test` reports every -package instead of the first casualty. - -## Changing a root file - -Some files at the repository root are generated from `BUILD.ts` and then -pinned by suites that deliberately re-declare rather than import them. -Importing `BUILD.ts` would be circular, since it imports the very packages -doing the pinning. `pnpm-workspace.yaml` is the exception: pnpm owns and may -update it, so it is hand-written and authoritative. The build graph parses its -`packages` list and keys lockfile resolution and installation on the file plus -the root and selected member manifests. - -The cost is that one edit lands in several places. If you change: - -| What | Also update | -| ---------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `pnpm-workspace.yaml` package membership | `packages/flows/test/vitestCoverageIsolation.test.ts` (the coverage-universe policy pin); lockfile inputs are derived automatically | -| root `package.json` scripts | `packages/flows/test/vitestCoverageIsolation.test.ts` (the aggregator roster) | -| root `BUILD.ts` CI jobs, steps, or triggers | the generated `.github/workflows/ci.yml` (`pnpm exec smthrs build '//:ci'` with `mode: "write"`), and `packages/flows/test/vitestCoverageIsolation.test.ts` (source-text pins) | -| `.github/workflows/release.yml` | the same suite, plus `scripts/release-rehearsal.test.mjs` | - -Miss one and CI reports a generated file as a hand edit, which is exactly -what it should do — it cannot tell your deliberate change from a stray one. - -## Root graph rationale - -The root `BUILD.ts` intentionally contains declarations only, with its -explanatory prose kept here. Nothing in that file is a command. Jobs declare -the toolchain a runner provides and the targets they invoke; `GithubCiGen` -derives checkout, installation, tool setup, and every -`pnpm exec smthrs ` argv. A gate must therefore become a target -in the package that owns it before CI can invoke it, matching Bazel's rule that -a BUILD file has no free-form command surface. - -The generated `tsconfig.json` is the root TypeScript project. The lockfile and -install are separate targets because a target cannot be keyed on a file it -also produces: `Lockfile` writes `pnpm-lock.yaml`, while `Install` consumes the -lockfile target. The hand-written `pnpm-workspace.yaml` is a planner input. Its -contents select the workspace manifests, and all of those files key both -targets, so a membership or dependency edit forces resolution before linking -`node_modules`. `PackageDefaults` applies `StandardPackage` to each -`packages/*` directory with a `package.json` and no BUILD file, synthesizing -the conventional `lib`, `check`, `test`, `lint`, `fmt`, and `docs` targets; -packages with a different layout carry their own BUILD file. - -The CI declaration has several deliberate operational constraints: - -- The `test` job aggregates build, test, lint, docs, format, and circular - targets in one graph plan. It uses `parallelism: 2` because the heavy Vitest - suites have finite 30-second per-test budgets that excessive concurrency on - a four-core runner can starve. -- `actionlint` checks every workflow named by the declaration so GitHub-only - expression-context failures surface in review, not in a scheduled run. - `apps/server/scripts/canary/workflow-wiring.test.ts` ensures no workflow is - omitted. The script targets include the browser contract and release - pack-and-smoke chain; the agent eval suite and typecheck are offline and - baseline-gated. CI also lint-checks its own generated workflow so the file - describing the pipeline is not exempt from drift enforcement. -- The `apps-e2e` lane is separate because it boots Wrangler and a real Chrome; - nothing under `apps/` needs jj. The Ubuntu runner's Chrome path is asserted - because `BrowserLaunch.ts` probes that fixed candidate list. Screenshots in - `/tmp` and launch-checklist reports under `apps/reports` are collected under - one artifact root. -- Issue #163 requires jj on `PATH` for the real-binary suites so a missing - binary fails loudly instead of skipping. GitHub checkout creates a Git - repository, not a jj repository, so CI initializes colocated metadata before - those contracts run. -- `rust-toolchain.toml` is the shared pin for the Rust jobs. The WebAssembly - lane rebuilds `packages/jj/wasm/flows_jj.wasm` without a build cache and - requires byte-for-byte equality with the committed artifact. Its Linux host - triple is part of that reproducibility contract; `build-wasm.mjs` refuses a - different host explicitly rather than producing a misleading byte diff. -- The Bun lane covers only the compatibility matrix documented in - `ci/BUILD.ts`. The browser lane remains a standalone contract until a real - browser-runner suite exists. macOS and Windows package suites are advisory - until they establish a stable green history; known Windows path failures are - not chased in that lane. - -The package policy in `pnpm-workspace.yaml` is equally deliberate. -`verifyDepsBeforeRun` stays disabled because installation is an explicit graph -step, and a gate must not reinstall what it is measuring with different script -settings. Playwright lifecycle builds stay denied: the live browser checks use -a system or previously installed browser, so dependency installation must not -download one. - Packages under `packages/` follow the structure and conventions in the Effect repository. Use `reference/effect` as the local reference when adding or changing package modules, public APIs, tests, build configuration, or package metadata. -## A BUILD.ts file declares targets, never commands - -`BUILD.ts` says what the workspace has. It never says how to run it. A raw argv -in a BUILD file — a `run:` string, a bare executable name, a shell fragment — is -a gate the build system does not know about: unplanned, unkeyed, uncached, not -addressable by label, and not runnable locally by the name CI uses. It also pins -the interpreter and the package manager at the call site, so the workspace can no -longer switch either by editing one declaration. - -Argv rendering belongs in target implementations. `PackageManager.install()` -renders `pnpm install --frozen-lockfile --ignore-scripts`; `Runtime.test()` -renders `node --test`; `RustToolchain.install()` renders -`rustup toolchain install`. A declaration passes the toolchain in and the -implementation asks it for the argv. - -Every CI gate is therefore a target, in the package that owns it: -`scripts/BUILD.ts` for the operator and release scripts, `crates/*/BUILD.ts` for -the cargo gates, `apps/*/BUILD.ts` for an app's end-to-end suites, `ci/BUILD.ts` -for the targets that belong to no single package. `.github/workflows/ci.yml` is -generated from those declarations: a job names what it requires and which targets -it runs, and `GithubCiGen` derives every step. Its attrs schema has no field that -would hold a command, so reintroducing one is a compile error rather than a -review conversation. - -Bazel is the prior art: a `BUILD` file has no way to write a command at all, -every check is a test target, and CI is one verb over the graph. If a gate does -not fit an existing target type, add a target type; `ToolBuild` is the -deliberate escape hatch and using it is something to justify in review. The full -rule, with examples, is in -[`packages/build/docs/workspace/writing-build-files.md`](packages/build/docs/workspace/writing-build-files.md). - ## Working with the vendored jj submodule The Rust crates under `crates/` build against `jj-lib` from the `vendor/jj` git submodule. A plain `git clone` leaves that directory empty and `cargo` then fails with a missing `vendor/jj/lib/Cargo.toml`. Populate it once after cloning: diff --git a/HUMAN-TASKS.md b/HUMAN-TASKS.md index f1f6efc5..b02d9a14 100644 --- a/HUMAN-TASKS.md +++ b/HUMAN-TASKS.md @@ -11,7 +11,7 @@ Verify that the publishing identity controls `@smthrs`: npm org ls smthrs ``` -Reserve or confirm every engine-group name below. All returned E404 at +Reserve or confirm every engine-group `-next` name below. All returned E404 at the audit; recheck each name before publishing: ```sh @@ -58,87 +58,3 @@ publish. Follow the [release runbook](docs/release-runbook.md) exactly. Confirm that the engine group is alpha-shippable, using the [alpha notes](docs/alpha-notes.md), [release rehearsal](docs/release-rehearsal.md), and the production-readiness panel's passing verdicts recorded in this run. - -# Human tasks: GitHub automation - -The automation layer (`factory/automation/`, `.github/workflows/gen.*.yml`) is -landed and inert until these are done. Every one is owner-only. - -## H5. Create the label set - -The state machine is the labels, so a missing label is a state the automation -cannot enter. Create all seven: - -```sh -gh label create 'agent:approved' --repo smithersai/flows --color 0E8A16 \ - --description 'Maintainer gate. Admits an automation job on this issue or PR.' -gh label create 'repro:verified' --repo smithersai/flows --color 5319E7 \ - --description 'The PoC fails on main and the reporter confirmed it.' -gh label create 'repro:needs-info' --repo smithersai/flows --color FBCA04 \ - --description 'Targeted questions are posted; awaiting the reporter.' -gh label create 'repro:blocked' --repo smithersai/flows --color B60205 \ - --description 'Parked on an infra blocker, for reasons unrelated to the report.' -gh label create 'poc:proposed' --repo smithersai/flows --color C5DEF5 \ - --description 'A proof of concept is posted; awaiting the reporter.' -gh label create 'poc:confirmed' --repo smithersai/flows --color C5DEF5 \ - --description 'The reporter confirmed the proof of concept captures their issue.' -gh label create 'poc:rejected' --repo smithersai/flows --color C5DEF5 \ - --description 'The reporter rejected the proof of concept; a revision follows.' -gh label create 'dupe:candidate' --repo smithersai/flows --color D4C5F9 \ - --description 'A strong duplicate candidate was found; awaiting confirmation.' -gh label create 'infra' --repo smithersai/flows --color BFD4F2 \ - --description 'An infrastructure blocker. Closing it unparks the reports on it.' -``` - -## H6. Set the ANTHROPIC_API_KEY Actions secret - -Every agent job reads it. It is placed only in trusted, gated jobs; the -renderer refuses to put it in a job marked `untrustedInput`, which is why the -PoC sandbox cannot hold it. - -```sh -gh secret set ANTHROPIC_API_KEY --repo smithersai/flows -``` - -## H7. Decide the environment protection rules - -The generated workflows carry no `environment:` binding today. Two decisions, -both reversible: - -1. Whether `gen.verified-fix.yml` needs a protected environment. It is the one - automation that opens a pull request. The `agent:approved` gate already - requires a maintainer action, so an environment would be a second approval - on the same decision. -2. Whether to require the `repro-proof` check on pull requests that claim to - close an issue. The check is advisory until it is added to the branch - protection rules: - - ```sh - gh api -X PUT repos/smithersai/flows/branches/main/protection/required_status_checks \ - -f 'checks[][context]=proof' - ``` - -Record whichever way you decide in -`docs/specs/Concepts/Github Automation.md` under Open. - -## H8. Set FACTORY_PR_TOKEN so the fix lane's pull requests get checks - -A pull request opened with the workflow token triggers no workflows, so the -proof gate and the rubric review would never run on the automation's own fix -pull requests. Create a fine-grained PAT (contents: write, pull-requests: -write) or a GitHub App token and store it: - -```sh -gh secret set FACTORY_PR_TOKEN --repo smithersai/flows -``` - -`gen.verified-fix.yml` falls back to the workflow token until the secret -exists; until then, run the checks on those pull requests manually. - -## H9. Confirm the `agent:approved` policy with the team - -The gate admits `OWNER`, `MEMBER`, and `COLLABORATOR` without a label. Anyone -else needs a maintainer to apply `agent:approved`. If the organization grants -`MEMBER` broadly, narrow the association list in -`packages/targets/src/GithubAutomation.ts` (`trustedAssociations`) and -regenerate every workflow with `smthrs run //:`. diff --git a/MIGRATION-NOTES.md b/MIGRATION-NOTES.md index 7cc421bc..fccc123b 100644 --- a/MIGRATION-NOTES.md +++ b/MIGRATION-NOTES.md @@ -1,11 +1,10 @@ # Agent package migration Nineteen `@smthrs/*` packages moved from the agent repository into this pnpm -workspace: cli, control, core, engine-harness (since renamed to agent), evals, -fs, gateway, harness, memory, model, notifications, observability, patterns, -plugin, registry, scorers, std, testing, and triggers. The workspace globs, -root TypeScript globs, and `StandardPackage` build rule already cover every -migrated package. +workspace: cli, control, core, engine-harness, evals, fs, gateway, harness, +memory, model, notifications, observability, patterns, plugin, registry, +scorers, std, testing, and triggers. The workspace globs, root TypeScript +globs, and `StandardPackage` build rule already cover every migrated package. The lockfile resolves them as ordinary workspace siblings. The existing `@smthrs/observability` package remained the package diff --git a/MODULE.bazel b/MODULE.bazel new file mode 100644 index 00000000..eedc9dad --- /dev/null +++ b/MODULE.bazel @@ -0,0 +1,107 @@ +# Bazel module for the flows workspace. +# +# Bzlmod only: there is no WORKSPACE file and .bazelrc sets +# --enable_workspace=false. Every dependency below is pinned and recorded in +# MODULE.bazel.lock, which is committed and verified by CI. +module( + name = "flows", + version = "0.0.0", +) + +bazel_dep(name = "aspect_bazel_lib", version = "2.22.5") +bazel_dep(name = "aspect_rules_js", version = "3.4.0") +bazel_dep(name = "aspect_rules_ts", version = "3.10.0") +bazel_dep(name = "aspect_rules_lint", version = "2.7.2") +bazel_dep(name = "aspect_gazelle_prebuilt", version = "0.0.24") +bazel_dep(name = "rules_multirun", version = "0.14.0") +bazel_dep(name = "rules_nodejs", version = "6.7.5") +bazel_dep(name = "rules_rust", version = "0.73.0") +bazel_dep(name = "bazel_skylib", version = "1.9.2") +bazel_dep(name = "platforms", version = "1.1.0") + +# ----------------------------------------------------------------------------- +# Hermetic Node.js toolchain. +# +# The interpreter is fetched by Bazel, pinned to the same floor the packages +# declare in package.json "engines". Nothing here resolves node from PATH. +# ----------------------------------------------------------------------------- +node = use_extension("@rules_nodejs//nodejs:extensions.bzl", "node") +node.toolchain(node_version = "22.19.0") +use_repo(node, "nodejs_toolchains") + +# Hermetic pnpm, pinned to the version in the root package.json +# "packageManager" field. Used for `bazel run @pnpm -- ...` and for lockfile +# maintenance targets. +pnpm = use_extension("@aspect_rules_js//npm:extensions.bzl", "pnpm") +pnpm.pnpm(pnpm_version = "11.21.0") +use_repo(pnpm, "pnpm") + +# ----------------------------------------------------------------------------- +# npm dependencies. +# +# npm_translate_lock reads pnpm-lock.yaml (lockfileVersion 9.0) and the +# pnpm-workspace.yaml next to it, and materializes one repository (@npm) with +# a content-addressed store of every package. No pnpm install runs; Bazel +# fetches each tarball itself and verifies the integrity hash recorded in the +# lockfile. +# +# Lifecycle hooks: pnpm-workspace.yaml carries an allowBuilds table that sets +# every package to false, and CI installs with --ignore-scripts, so this +# repository never executes dependency lifecycle scripts. npm_translate_lock +# runs no lifecycle hooks unless they are explicitly allowlisted in +# `lifecycle_hooks`, so the default already matches the repository policy. +# ----------------------------------------------------------------------------- +npm = use_extension("@aspect_rules_js//npm:extensions.bzl", "npm") +npm.npm_translate_lock( + name = "npm", + npmrc = "//:.npmrc", + pnpm_lock = "//:pnpm-lock.yaml", + verify_node_modules_ignored = "//:.bazelignore", +) +use_repo(npm, "npm") + +# ----------------------------------------------------------------------------- +# TypeScript toolchain. +# +# rules_ts fetches tsc from npm at the version the packages pin. The version +# is read from a package manifest so the Bazel toolchain and the pnpm +# toolchain cannot drift. +# ----------------------------------------------------------------------------- +ts = use_extension("@aspect_rules_ts//ts:extensions.bzl", "typescript") +ts.deps(version_from = "//packages/canonical:package.json") +use_repo(ts, "npm_typescript") + +# ----------------------------------------------------------------------------- +# Rust toolchain and crates. +# +# The toolchain version matches rust-toolchain.toml (1.89.0, minimal profile, +# clippy + rustfmt, wasm32-wasip1 target). rules_rust fetches rustc; rustup +# on the host is never consulted. +# +# crate_universe renders the root Cargo workspace (crates/flows-jj) plus its +# path dependency on the pinned jj fork in vendor/jj into Bazel targets under +# @crates, keyed on the committed Cargo.lock. +# ----------------------------------------------------------------------------- +rust = use_extension("@rules_rust//rust:extensions.bzl", "rust") +rust.toolchain( + edition = "2024", + extra_target_triples = ["wasm32-wasip1"], + versions = ["1.89.0"], +) +use_repo(rust, "rust_toolchains") + +register_toolchains("@rust_toolchains//:all") + +# crate_universe is NOT wired: cargo-bazel's splicer requires every manifest +# in the dependency graph as a Bazel label, and the pinned jj fork lives in a +# git submodule (vendor/jj) that cannot carry committed BUILD files, so +# jj-lib's manifest and its workspace root are not label-addressable. The +# Rust build instead runs the pinned cargo toolchain in a sandboxed action +# over an offline registry; see tools/cargo/ and docs/build-systems/bazel.md. +vendor_jj = use_repo_rule("//tools/cargo:vendor_jj.bzl", "vendor_jj_repository") + +vendor_jj(name = "vendor_jj") + +cargo_registry = use_repo_rule("//tools/cargo:registry.bzl", "cargo_registry_repository") + +cargo_registry(name = "cargo_registry") diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock new file mode 100644 index 00000000..bc6f257a --- /dev/null +++ b/MODULE.bazel.lock @@ -0,0 +1,858 @@ +{ + "lockFileVersion": 24, + "registryFileHashes": { + "https://bcr.bazel.build/bazel_registry.json": "8a28e4aff06ee60aed2a8c281907fb8bcbf3b753c91fb5a5c57da3215d5b3497", + "https://bcr.bazel.build/modules/abseil-cpp/20210324.2/MODULE.bazel": "7cd0312e064fde87c8d1cd79ba06c876bd23630c83466e9500321be55c96ace2", + "https://bcr.bazel.build/modules/abseil-cpp/20211102.0/MODULE.bazel": "70390338f7a5106231d20620712f7cccb659cd0e9d073d1991c038eb9fc57589", + "https://bcr.bazel.build/modules/abseil-cpp/20230125.1/MODULE.bazel": "89047429cb0207707b2dface14ba7f8df85273d484c2572755be4bab7ce9c3a0", + "https://bcr.bazel.build/modules/abseil-cpp/20230802.0.bcr.1/MODULE.bazel": "1c8cec495288dccd14fdae6e3f95f772c1c91857047a098fad772034264cc8cb", + "https://bcr.bazel.build/modules/abseil-cpp/20230802.0/MODULE.bazel": "d253ae36a8bd9ee3c5955384096ccb6baf16a1b1e93e858370da0a3b94f77c16", + "https://bcr.bazel.build/modules/abseil-cpp/20230802.1/MODULE.bazel": "fa92e2eb41a04df73cdabeec37107316f7e5272650f81d6cc096418fe647b915", + "https://bcr.bazel.build/modules/abseil-cpp/20240116.1/MODULE.bazel": "37bcdb4440fbb61df6a1c296ae01b327f19e9bb521f9b8e26ec854b6f97309ed", + "https://bcr.bazel.build/modules/abseil-cpp/20240116.1/source.json": "9be551b8d4e3ef76875c0d744b5d6a504a27e3ae67bc6b28f46415fd2d2957da", + "https://bcr.bazel.build/modules/apple_support/1.23.1/MODULE.bazel": "53763fed456a968cf919b3240427cf3a9d5481ec5466abc9d5dc51bc70087442", + "https://bcr.bazel.build/modules/apple_support/1.24.1/MODULE.bazel": "f46e8ddad60aef170ee92b2f3d00ef66c147ceafea68b6877cb45bd91737f5f8", + "https://bcr.bazel.build/modules/apple_support/1.24.1/source.json": "cf725267cbacc5f028ef13bb77e7f2c2e0066923a4dab1025e4a0511b1ed258a", + "https://bcr.bazel.build/modules/aspect_bazel_lib/1.31.2/MODULE.bazel": "7bee702b4862612f29333590f4b658a5832d433d6f8e4395f090e8f4e85d442f", + "https://bcr.bazel.build/modules/aspect_bazel_lib/1.38.0/MODULE.bazel": "6307fec451ba9962c1c969eb516ebfe1e46528f7fa92e1c9ac8646bef4cdaa3f", + "https://bcr.bazel.build/modules/aspect_bazel_lib/1.42.2/MODULE.bazel": "2e0d8ab25c57a14f56ace1c8e881b69050417ff91b2fb7718dc00d201f3c3478", + "https://bcr.bazel.build/modules/aspect_bazel_lib/2.14.0/MODULE.bazel": "2b31ffcc9bdc8295b2167e07a757dbbc9ac8906e7028e5170a3708cecaac119f", + "https://bcr.bazel.build/modules/aspect_bazel_lib/2.19.2/MODULE.bazel": "30dfabbfae0139b1f0036e01c201dd4c0167da3017f0b7ef3820d78e07622989", + "https://bcr.bazel.build/modules/aspect_bazel_lib/2.19.3/MODULE.bazel": "253d739ba126f62a5767d832765b12b59e9f8d2bc88cc1572f4a73e46eb298ca", + "https://bcr.bazel.build/modules/aspect_bazel_lib/2.22.0/MODULE.bazel": "7fe0191f047d4fe4a4a46c1107e2350cbb58a8fc2e10913aa4322d3190dec0bf", + "https://bcr.bazel.build/modules/aspect_bazel_lib/2.22.5/MODULE.bazel": "004ba890363d05372a97248c37205ae64b6fa31047629cd2c0895a9d0c7779e8", + "https://bcr.bazel.build/modules/aspect_bazel_lib/2.22.5/source.json": "ac2c3213df8f985785f1d0aeb7f0f73d5324e6e67d593d9b9470fb74a25d4a9b", + "https://bcr.bazel.build/modules/aspect_bazel_lib/2.7.7/MODULE.bazel": "491f8681205e31bb57892d67442ce448cda4f472a8e6b3dc062865e29a64f89c", + "https://bcr.bazel.build/modules/aspect_bazel_lib/2.8.1/MODULE.bazel": "812d2dd42f65dca362152101fbec418029cc8fd34cbad1a2fde905383d705838", + "https://bcr.bazel.build/modules/aspect_gazelle_prebuilt/0.0.24/MODULE.bazel": "54997aa993ee83b3f7993b441c3f6e71ee76b0528b68a7708ed4ebf2b6f24330", + "https://bcr.bazel.build/modules/aspect_gazelle_prebuilt/0.0.24/source.json": "1c9d8fe3a5a91aa70291dab68d5715cd2d170cefbf28bca686cf5621d44a6ae6", + "https://bcr.bazel.build/modules/aspect_rules_js/1.33.1/MODULE.bazel": "db3e7f16e471cf6827059d03af7c21859e7a0d2bc65429a3a11f005d46fc501b", + "https://bcr.bazel.build/modules/aspect_rules_js/1.40.0/MODULE.bazel": "01a1014e95e6816b68ecee2584ae929c7d6a1b72e4333ab1ff2d2c6c30babdf1", + "https://bcr.bazel.build/modules/aspect_rules_js/2.0.0/MODULE.bazel": "b45b507574aa60a92796e3e13c195cd5744b3b8aff516a9c0cb5ae6a048161c5", + "https://bcr.bazel.build/modules/aspect_rules_js/3.4.0/MODULE.bazel": "88844ac411e1961f4574a92f3c5be5b20d1c6997778c6b88316c5c3b4b60e284", + "https://bcr.bazel.build/modules/aspect_rules_js/3.4.0/source.json": "85e5822f00dcbe64a1eda1324119e289c8c03cacb5c3695dffee16397b529078", + "https://bcr.bazel.build/modules/aspect_rules_lint/0.12.0/MODULE.bazel": "e767c5dbfeb254ec03275a7701b5cfde2c4d2873676804bc7cb27ddff3728fed", + "https://bcr.bazel.build/modules/aspect_rules_lint/2.7.2/MODULE.bazel": "b67b635c398734e05a7dde60639813f1c96bdf681736216de561e3d62b08c918", + "https://bcr.bazel.build/modules/aspect_rules_lint/2.7.2/source.json": "1051f1c3e853c0a3e0505efca29afbbc5b036c3543e994f51b4048e372514079", + "https://bcr.bazel.build/modules/aspect_rules_ts/3.10.0/MODULE.bazel": "69d06f57f30f4a2b6e53471584a9559d3b7cd7f891e1699876991230c7cabb95", + "https://bcr.bazel.build/modules/aspect_rules_ts/3.10.0/source.json": "56f28a3ddb55ceaaf57a1ef8d7195136789ca1d72d0c8a6a9eeaad313be4099d", + "https://bcr.bazel.build/modules/aspect_tools_telemetry/0.3.3/MODULE.bazel": "37c764292861c2f70314efa9846bb6dbb44fc0308903b3285da6528305450183", + "https://bcr.bazel.build/modules/aspect_tools_telemetry/0.4.2/MODULE.bazel": "f31aa84151d31e98cffd43eb7217ccff5ec52bdd5f2d10db8f053aeb23342eca", + "https://bcr.bazel.build/modules/aspect_tools_telemetry/0.4.2/source.json": "d027d264e6b6e7fc421e38189f4374fcd14a67e0bd6e0e705de8c2185c3787e1", + "https://bcr.bazel.build/modules/bazel_features/0.1.0/MODULE.bazel": "47011d645b0f949f42ee67f2e8775188a9cf4a0a1528aa2fa4952f2fd00906fd", + "https://bcr.bazel.build/modules/bazel_features/1.0.0/MODULE.bazel": "d7f022dc887efb96e1ee51cec7b2e48d41e36ff59a6e4f216c40e4029e1585bf", + "https://bcr.bazel.build/modules/bazel_features/1.1.0/MODULE.bazel": "cfd42ff3b815a5f39554d97182657f8c4b9719568eb7fded2b9135f084bf760b", + "https://bcr.bazel.build/modules/bazel_features/1.1.1/MODULE.bazel": "27b8c79ef57efe08efccbd9dd6ef70d61b4798320b8d3c134fd571f78963dbcd", + "https://bcr.bazel.build/modules/bazel_features/1.11.0/MODULE.bazel": "f9382337dd5a474c3b7d334c2f83e50b6eaedc284253334cf823044a26de03e8", + "https://bcr.bazel.build/modules/bazel_features/1.15.0/MODULE.bazel": "d38ff6e517149dc509406aca0db3ad1efdd890a85e049585b7234d04238e2a4d", + "https://bcr.bazel.build/modules/bazel_features/1.17.0/MODULE.bazel": "039de32d21b816b47bd42c778e0454217e9c9caac4a3cf8e15c7231ee3ddee4d", + "https://bcr.bazel.build/modules/bazel_features/1.18.0/MODULE.bazel": "1be0ae2557ab3a72a57aeb31b29be347bcdc5d2b1eb1e70f39e3851a7e97041a", + "https://bcr.bazel.build/modules/bazel_features/1.19.0/MODULE.bazel": "59adcdf28230d220f0067b1f435b8537dd033bfff8db21335ef9217919c7fb58", + "https://bcr.bazel.build/modules/bazel_features/1.27.0/MODULE.bazel": "621eeee06c4458a9121d1f104efb80f39d34deff4984e778359c60eaf1a8cb65", + "https://bcr.bazel.build/modules/bazel_features/1.28.0/MODULE.bazel": "4b4200e6cbf8fa335b2c3f43e1d6ef3e240319c33d43d60cc0fbd4b87ece299d", + "https://bcr.bazel.build/modules/bazel_features/1.30.0/MODULE.bazel": "a14b62d05969a293b80257e72e597c2da7f717e1e69fa8b339703ed6731bec87", + "https://bcr.bazel.build/modules/bazel_features/1.34.0/MODULE.bazel": "e8475ad7c8965542e0c7aac8af68eb48c4af904be3d614b6aa6274c092c2ea1e", + "https://bcr.bazel.build/modules/bazel_features/1.39.0/MODULE.bazel": "28739425c1fc283c91931619749c832b555e60bcd1010b40d8441ce0a5cf726d", + "https://bcr.bazel.build/modules/bazel_features/1.4.1/MODULE.bazel": "e45b6bb2350aff3e442ae1111c555e27eac1d915e77775f6fdc4b351b758b5d7", + "https://bcr.bazel.build/modules/bazel_features/1.41.0/MODULE.bazel": "6e0f87fafed801273c371d41e22a15a6f8abf83fdd7f87d5e44ad317b94433d0", + "https://bcr.bazel.build/modules/bazel_features/1.50.0/MODULE.bazel": "2083ef9c7a469f520890483ccf8e0189d6e71e2117e7752e15e6554433d5ae3e", + "https://bcr.bazel.build/modules/bazel_features/1.50.0/source.json": "e0ee3debde2789ff56e4452e612d126925ba9ab64d4bde79c67f099d2902df9b", + "https://bcr.bazel.build/modules/bazel_features/1.9.0/MODULE.bazel": "885151d58d90d8d9c811eb75e3288c11f850e1d6b481a8c9f766adee4712358b", + "https://bcr.bazel.build/modules/bazel_features/1.9.1/MODULE.bazel": "8f679097876a9b609ad1f60249c49d68bfab783dd9be012faf9d82547b14815a", + "https://bcr.bazel.build/modules/bazel_lib/3.0.0-rc.0/MODULE.bazel": "d6e00979a98ac14ada5e31c8794708b41434d461e7e7ca39b59b765e6d233b18", + "https://bcr.bazel.build/modules/bazel_lib/3.0.0/MODULE.bazel": "22b70b80ac89ad3f3772526cd9feee2fa412c2b01933fea7ed13238a448d370d", + "https://bcr.bazel.build/modules/bazel_lib/3.7.0/MODULE.bazel": "d7c10ed67f0f7f1fda179db8f86c22642581bd614882e1a50545fbe069525173", + "https://bcr.bazel.build/modules/bazel_lib/3.7.0/source.json": "cdacdbe1a504593475889158a96acf3ec3fb6be5cac2fb7b201ba5c6beacd9d4", + "https://bcr.bazel.build/modules/bazel_skylib/1.0.3/MODULE.bazel": "bcb0fd896384802d1ad283b4e4eb4d718eebd8cb820b0a2c3a347fb971afd9d8", + "https://bcr.bazel.build/modules/bazel_skylib/1.1.1/MODULE.bazel": "1add3e7d93ff2e6998f9e118022c84d163917d912f5afafb3058e3d2f1545b5e", + "https://bcr.bazel.build/modules/bazel_skylib/1.2.0/MODULE.bazel": "44fe84260e454ed94ad326352a698422dbe372b21a1ac9f3eab76eb531223686", + "https://bcr.bazel.build/modules/bazel_skylib/1.2.1/MODULE.bazel": "f35baf9da0efe45fa3da1696ae906eea3d615ad41e2e3def4aeb4e8bc0ef9a7a", + "https://bcr.bazel.build/modules/bazel_skylib/1.3.0/MODULE.bazel": "20228b92868bf5cfc41bda7afc8a8ba2a543201851de39d990ec957b513579c5", + "https://bcr.bazel.build/modules/bazel_skylib/1.4.1/MODULE.bazel": "a0dcb779424be33100dcae821e9e27e4f2901d9dfd5333efe5ac6a8d7ab75e1d", + "https://bcr.bazel.build/modules/bazel_skylib/1.4.2/MODULE.bazel": "3bd40978e7a1fac911d5989e6b09d8f64921865a45822d8b09e815eaa726a651", + "https://bcr.bazel.build/modules/bazel_skylib/1.5.0/MODULE.bazel": "32880f5e2945ce6a03d1fbd588e9198c0a959bb42297b2cfaf1685b7bc32e138", + "https://bcr.bazel.build/modules/bazel_skylib/1.6.1/MODULE.bazel": "8fdee2dbaace6c252131c00e1de4b165dc65af02ea278476187765e1a617b917", + "https://bcr.bazel.build/modules/bazel_skylib/1.7.0/MODULE.bazel": "0db596f4563de7938de764cc8deeabec291f55e8ec15299718b93c4423e9796d", + "https://bcr.bazel.build/modules/bazel_skylib/1.7.1/MODULE.bazel": "3120d80c5861aa616222ec015332e5f8d3171e062e3e804a2a0253e1be26e59b", + "https://bcr.bazel.build/modules/bazel_skylib/1.8.1/MODULE.bazel": "88ade7293becda963e0e3ea33e7d54d3425127e0a326e0d17da085a5f1f03ff6", + "https://bcr.bazel.build/modules/bazel_skylib/1.8.2/MODULE.bazel": "69ad6927098316848b34a9142bcc975e018ba27f08c4ff403f50c1b6e646ca67", + "https://bcr.bazel.build/modules/bazel_skylib/1.9.2/MODULE.bazel": "8c51259b0f4481475586dbfede7591e57b75702e637f840f6138eec80c34b270", + "https://bcr.bazel.build/modules/bazel_skylib/1.9.2/source.json": "41cbde7546542dee2f26e3f10bf1c4ac57909943194b7ac48cc5238a01893aa8", + "https://bcr.bazel.build/modules/buildifier_prebuilt/6.4.0/MODULE.bazel": "37389c6b5a40c59410b4226d3bb54b08637f393d66e2fa57925c6fcf68e64bf4", + "https://bcr.bazel.build/modules/buildifier_prebuilt/6.4.0/source.json": "83eb01b197ed0b392f797860c9da5ed1bf95f4d0ded994d694a3d44731275916", + "https://bcr.bazel.build/modules/buildozer/7.1.2/MODULE.bazel": "2e8dd40ede9c454042645fd8d8d0cd1527966aa5c919de86661e62953cd73d84", + "https://bcr.bazel.build/modules/buildozer/7.1.2/source.json": "c9028a501d2db85793a6996205c8de120944f50a0d570438fcae0457a5f9d1f8", + "https://bcr.bazel.build/modules/diff.bzl/0.5.1/MODULE.bazel": "bad8dd444e512b6fcbcd969d6385cd6bc093d4c12e5640fb1f0ace2282f99ef9", + "https://bcr.bazel.build/modules/diff.bzl/0.5.1/source.json": "64571044143273ff8adb322c0f7acefca36f9589ff37a743e3cac9c35ce6b010", + "https://bcr.bazel.build/modules/gawk/5.3.2.bcr.1/MODULE.bazel": "cdf8cbe5ee750db04b78878c9633cc76e80dcf4416cbe982ac3a9222f80713c8", + "https://bcr.bazel.build/modules/gawk/5.3.2.bcr.3/MODULE.bazel": "f1b7bb2dd53e8f2ef984b39485ec8a44e9076dda5c4b8efd2fb4c6a6e856a31d", + "https://bcr.bazel.build/modules/gawk/5.3.2.bcr.3/source.json": "ebe931bfe362e4b41e59ee00a528db6074157ff2ced92eb9e970acab2e1089c9", + "https://bcr.bazel.build/modules/gazelle/0.27.0/MODULE.bazel": "3446abd608295de6d90b4a8a118ed64a9ce11dcb3dda2dc3290a22056bd20996", + "https://bcr.bazel.build/modules/gazelle/0.30.0/MODULE.bazel": "f888a1effe338491f35f0e0e85003b47bb9d8295ccba73c37e07702d8d31c65b", + "https://bcr.bazel.build/modules/gazelle/0.32.0/MODULE.bazel": "b499f58a5d0d3537f3cf5b76d8ada18242f64ec474d8391247438bf04f58c7b8", + "https://bcr.bazel.build/modules/gazelle/0.33.0/MODULE.bazel": "a13a0f279b462b784fb8dd52a4074526c4a2afe70e114c7d09066097a46b3350", + "https://bcr.bazel.build/modules/gazelle/0.34.0/MODULE.bazel": "abdd8ce4d70978933209db92e436deb3a8b737859e9354fb5fd11fb5c2004c8a", + "https://bcr.bazel.build/modules/gazelle/0.34.0/source.json": "cdf0182297e3adabbdea2da88d5b930b2ee5e56511c3e7d6512069db6315a1f7", + "https://bcr.bazel.build/modules/google_benchmark/1.8.2/MODULE.bazel": "a70cf1bba851000ba93b58ae2f6d76490a9feb74192e57ab8e8ff13c34ec50cb", + "https://bcr.bazel.build/modules/googletest/1.11.0/MODULE.bazel": "3a83f095183f66345ca86aa13c58b59f9f94a2f81999c093d4eeaa2d262d12f4", + "https://bcr.bazel.build/modules/googletest/1.14.0.bcr.1/MODULE.bazel": "22c31a561553727960057361aa33bf20fb2e98584bc4fec007906e27053f80c6", + "https://bcr.bazel.build/modules/googletest/1.14.0.bcr.1/source.json": "41e9e129f80d8c8bf103a7acc337b76e54fad1214ac0a7084bf24f4cd924b8b4", + "https://bcr.bazel.build/modules/googletest/1.14.0/MODULE.bazel": "cfbcbf3e6eac06ef9d85900f64424708cc08687d1b527f0ef65aa7517af8118f", + "https://bcr.bazel.build/modules/jq.bzl/0.1.0/MODULE.bazel": "2ce69b1af49952cd4121a9c3055faa679e748ce774c7f1fda9657f936cae902f", + "https://bcr.bazel.build/modules/jq.bzl/0.4.0/MODULE.bazel": "a7b39b37589f2b0dad53fd6c1ccaabbdb290330caa920d7ef3e6aad068cd4ab2", + "https://bcr.bazel.build/modules/jq.bzl/0.6.0/MODULE.bazel": "26ec5118e66a55fef36f8ea39d6415d55fc966671fe4d61a6b9ef0cd6bc7b6a1", + "https://bcr.bazel.build/modules/jq.bzl/0.6.0/source.json": "2ed4e35b9fa9505495784114f7637fbde5846ec14af917841b6c045d877f20eb", + "https://bcr.bazel.build/modules/jsoncpp/1.9.5/MODULE.bazel": "31271aedc59e815656f5736f282bb7509a97c7ecb43e927ac1a37966e0578075", + "https://bcr.bazel.build/modules/jsoncpp/1.9.5/source.json": "4108ee5085dd2885a341c7fab149429db457b3169b86eb081fa245eadf69169d", + "https://bcr.bazel.build/modules/libpfm/4.11.0/MODULE.bazel": "45061ff025b301940f1e30d2c16bea596c25b176c8b6b3087e92615adbd52902", + "https://bcr.bazel.build/modules/package_metadata/0.0.2/MODULE.bazel": "fb8d25550742674d63d7b250063d4580ca530499f045d70748b1b142081ebb92", + "https://bcr.bazel.build/modules/package_metadata/0.0.3/MODULE.bazel": "77890552ecea9e284b5424c9de827a58099348763a4359e975c359a83d4faa83", + "https://bcr.bazel.build/modules/package_metadata/0.0.6/MODULE.bazel": "341dab6f417197494517d54c8e557c0baee1de7aec83543a4fbefe57900acb7e", + "https://bcr.bazel.build/modules/package_metadata/0.0.6/source.json": "9581d8b22db43550ac75ecc314ee4fa0a33400bfdc77d1317d8af6b18dca7756", + "https://bcr.bazel.build/modules/platforms/0.0.10/MODULE.bazel": "8cb8efaf200bdeb2150d93e162c40f388529a25852b332cec879373771e48ed5", + "https://bcr.bazel.build/modules/platforms/0.0.11/MODULE.bazel": "0daefc49732e227caa8bfa834d65dc52e8cc18a2faf80df25e8caea151a9413f", + "https://bcr.bazel.build/modules/platforms/0.0.4/MODULE.bazel": "9b328e31ee156f53f3c416a64f8491f7eb731742655a47c9eec4703a71644aee", + "https://bcr.bazel.build/modules/platforms/0.0.5/MODULE.bazel": "5733b54ea419d5eaf7997054bb55f6a1d0b5ff8aedf0176fef9eea44f3acda37", + "https://bcr.bazel.build/modules/platforms/0.0.6/MODULE.bazel": "ad6eeef431dc52aefd2d77ed20a4b353f8ebf0f4ecdd26a807d2da5aa8cd0615", + "https://bcr.bazel.build/modules/platforms/0.0.7/MODULE.bazel": "72fd4a0ede9ee5c021f6a8dd92b503e089f46c227ba2813ff183b71616034814", + "https://bcr.bazel.build/modules/platforms/0.0.8/MODULE.bazel": "9f142c03e348f6d263719f5074b21ef3adf0b139ee4c5133e2aa35664da9eb2d", + "https://bcr.bazel.build/modules/platforms/0.0.9/MODULE.bazel": "4a87a60c927b56ddd67db50c89acaa62f4ce2a1d2149ccb63ffd871d5ce29ebc", + "https://bcr.bazel.build/modules/platforms/1.0.0/MODULE.bazel": "f05feb42b48f1b3c225e4ccf351f367be0371411a803198ec34a389fb22aa580", + "https://bcr.bazel.build/modules/platforms/1.1.0/MODULE.bazel": "1c0c09f5bdcf4b3f924720d2478a3711cb39f4977019ca5988685e5b7e18b3d2", + "https://bcr.bazel.build/modules/platforms/1.1.0/source.json": "fcf351c47596c939140ab0d333dfdd08ed1ea6ce33c2fe70c12493a301cf1344", + "https://bcr.bazel.build/modules/protobuf/21.7/MODULE.bazel": "a5a29bb89544f9b97edce05642fac225a808b5b7be74038ea3640fae2f8e66a7", + "https://bcr.bazel.build/modules/protobuf/23.1/MODULE.bazel": "88b393b3eb4101d18129e5db51847cd40a5517a53e81216144a8c32dfeeca52a", + "https://bcr.bazel.build/modules/protobuf/24.4/MODULE.bazel": "7bc7ce5f2abf36b3b7b7c8218d3acdebb9426aeb35c2257c96445756f970eb12", + "https://bcr.bazel.build/modules/protobuf/27.0/MODULE.bazel": "7873b60be88844a0a1d8f80b9d5d20cfbd8495a689b8763e76c6372998d3f64c", + "https://bcr.bazel.build/modules/protobuf/27.1/MODULE.bazel": "703a7b614728bb06647f965264967a8ef1c39e09e8f167b3ca0bb1fd80449c0d", + "https://bcr.bazel.build/modules/protobuf/29.0-rc2/MODULE.bazel": "6241d35983510143049943fc0d57937937122baf1b287862f9dc8590fc4c37df", + "https://bcr.bazel.build/modules/protobuf/29.0/MODULE.bazel": "319dc8bf4c679ff87e71b1ccfb5a6e90a6dbc4693501d471f48662ac46d04e4e", + "https://bcr.bazel.build/modules/protobuf/29.1/MODULE.bazel": "557c3457560ff49e122ed76c0bc3397a64af9574691cb8201b4e46d4ab2ecb95", + "https://bcr.bazel.build/modules/protobuf/29.1/source.json": "04cca85dce26b895ed037d98336d860367fe09919208f2ad383f0df1aff63199", + "https://bcr.bazel.build/modules/protobuf/3.19.0/MODULE.bazel": "6b5fbb433f760a99a22b18b6850ed5784ef0e9928a72668b66e4d7ccd47db9b0", + "https://bcr.bazel.build/modules/protobuf/3.19.2/MODULE.bazel": "532ffe5f2186b69fdde039efe6df13ba726ff338c6bc82275ad433013fa10573", + "https://bcr.bazel.build/modules/protobuf/3.19.6/MODULE.bazel": "9233edc5e1f2ee276a60de3eaa47ac4132302ef9643238f23128fea53ea12858", + "https://bcr.bazel.build/modules/pybind11_bazel/2.11.1/MODULE.bazel": "88af1c246226d87e65be78ed49ecd1e6f5e98648558c14ce99176da041dc378e", + "https://bcr.bazel.build/modules/pybind11_bazel/2.11.1/source.json": "be4789e951dd5301282729fe3d4938995dc4c1a81c2ff150afc9f1b0504c6022", + "https://bcr.bazel.build/modules/re2/2023-09-01/MODULE.bazel": "cb3d511531b16cfc78a225a9e2136007a48cf8a677e4264baeab57fe78a80206", + "https://bcr.bazel.build/modules/re2/2023-09-01/source.json": "e044ce89c2883cd957a2969a43e79f7752f9656f6b20050b62f90ede21ec6eb4", + "https://bcr.bazel.build/modules/rules_android/0.1.1/MODULE.bazel": "48809ab0091b07ad0182defb787c4c5328bd3a278938415c00a7b69b50c4d3a8", + "https://bcr.bazel.build/modules/rules_android/0.1.1/source.json": "e6986b41626ee10bdc864937ffb6d6bf275bb5b9c65120e6137d56e6331f089e", + "https://bcr.bazel.build/modules/rules_buf/0.1.1/MODULE.bazel": "6189aec18a4f7caff599ad41b851ab7645d4f1e114aa6431acf9b0666eb92162", + "https://bcr.bazel.build/modules/rules_buf/0.5.2/MODULE.bazel": "5f2492d284ab9bedf2668178303abf5f3cd7d8cdf85d768951008e88456e9c6a", + "https://bcr.bazel.build/modules/rules_buf/0.5.2/source.json": "41876d4834c0832de4b393de6e55dfd1cb3b25d3109e4ba90eb7fb57c560e0d9", + "https://bcr.bazel.build/modules/rules_cc/0.0.1/MODULE.bazel": "cb2aa0747f84c6c3a78dad4e2049c154f08ab9d166b1273835a8174940365647", + "https://bcr.bazel.build/modules/rules_cc/0.0.10/MODULE.bazel": "ec1705118f7eaedd6e118508d3d26deba2a4e76476ada7e0e3965211be012002", + "https://bcr.bazel.build/modules/rules_cc/0.0.13/MODULE.bazel": "0e8529ed7b323dad0775ff924d2ae5af7640b23553dfcd4d34344c7e7a867191", + "https://bcr.bazel.build/modules/rules_cc/0.0.14/MODULE.bazel": "5e343a3aac88b8d7af3b1b6d2093b55c347b8eefc2e7d1442f7a02dc8fea48ac", + "https://bcr.bazel.build/modules/rules_cc/0.0.15/MODULE.bazel": "6704c35f7b4a72502ee81f61bf88706b54f06b3cbe5558ac17e2e14666cd5dcc", + "https://bcr.bazel.build/modules/rules_cc/0.0.16/MODULE.bazel": "7661303b8fc1b4d7f532e54e9d6565771fea666fbdf839e0a86affcd02defe87", + "https://bcr.bazel.build/modules/rules_cc/0.0.2/MODULE.bazel": "6915987c90970493ab97393024c156ea8fb9f3bea953b2f3ec05c34f19b5695c", + "https://bcr.bazel.build/modules/rules_cc/0.0.6/MODULE.bazel": "abf360251023dfe3efcef65ab9d56beefa8394d4176dd29529750e1c57eaa33f", + "https://bcr.bazel.build/modules/rules_cc/0.0.8/MODULE.bazel": "964c85c82cfeb6f3855e6a07054fdb159aced38e99a5eecf7bce9d53990afa3e", + "https://bcr.bazel.build/modules/rules_cc/0.0.9/MODULE.bazel": "836e76439f354b89afe6a911a7adf59a6b2518fafb174483ad78a2a2fde7b1c5", + "https://bcr.bazel.build/modules/rules_cc/0.1.1/MODULE.bazel": "2f0222a6f229f0bf44cd711dc13c858dad98c62d52bd51d8fc3a764a83125513", + "https://bcr.bazel.build/modules/rules_cc/0.2.0/MODULE.bazel": "b5c17f90458caae90d2ccd114c81970062946f49f355610ed89bebf954f5783c", + "https://bcr.bazel.build/modules/rules_cc/0.2.16/MODULE.bazel": "9242fa89f950c6ef7702801ab53922e99c69b02310c39fb6e62b2bd30df2a1d4", + "https://bcr.bazel.build/modules/rules_cc/0.2.16/source.json": "d03d5cde49376d87e14ec14b666c56075e5e3926930327fd5d0484a1ff2ac1cc", + "https://bcr.bazel.build/modules/rules_cc/0.2.4/MODULE.bazel": "1ff1223dfd24f3ecf8f028446d4a27608aa43c3f41e346d22838a4223980b8cc", + "https://bcr.bazel.build/modules/rules_cc/0.2.8/MODULE.bazel": "f1df20f0bf22c28192a794f29b501ee2018fa37a3862a1a2132ae2940a23a642", + "https://bcr.bazel.build/modules/rules_foreign_cc/0.9.0/MODULE.bazel": "c9e8c682bf75b0e7c704166d79b599f93b72cfca5ad7477df596947891feeef6", + "https://bcr.bazel.build/modules/rules_fuzzing/0.5.2/MODULE.bazel": "40c97d1144356f52905566c55811f13b299453a14ac7769dfba2ac38192337a8", + "https://bcr.bazel.build/modules/rules_fuzzing/0.5.2/source.json": "c8b1e2c717646f1702290959a3302a178fb639d987ab61d548105019f11e527e", + "https://bcr.bazel.build/modules/rules_go/0.33.0/MODULE.bazel": "a2b11b64cd24bf94f57454f53288a5dacfe6cb86453eee7761b7637728c1910c", + "https://bcr.bazel.build/modules/rules_go/0.38.1/MODULE.bazel": "fb8e73dd3b6fc4ff9d260ceacd830114891d49904f5bda1c16bc147bcc254f71", + "https://bcr.bazel.build/modules/rules_go/0.39.1/MODULE.bazel": "d34fb2a249403a5f4339c754f1e63dc9e5ad70b47c5e97faee1441fc6636cd61", + "https://bcr.bazel.build/modules/rules_go/0.41.0/MODULE.bazel": "55861d8e8bb0e62cbd2896f60ff303f62ffcb0eddb74ecb0e5c0cbe36fc292c8", + "https://bcr.bazel.build/modules/rules_go/0.42.0/MODULE.bazel": "8cfa875b9aa8c6fce2b2e5925e73c1388173ea3c32a0db4d2b4804b453c14270", + "https://bcr.bazel.build/modules/rules_go/0.42.0/source.json": "33cd3d725806ad432753c4263ffd0459692010fdc940cce60b2c0e32282b45c5", + "https://bcr.bazel.build/modules/rules_java/4.0.0/MODULE.bazel": "5a78a7ae82cd1a33cef56dc578c7d2a46ed0dca12643ee45edbb8417899e6f74", + "https://bcr.bazel.build/modules/rules_java/5.3.5/MODULE.bazel": "a4ec4f2db570171e3e5eb753276ee4b389bae16b96207e9d3230895c99644b86", + "https://bcr.bazel.build/modules/rules_java/6.0.0/MODULE.bazel": "8a43b7df601a7ec1af61d79345c17b31ea1fedc6711fd4abfd013ea612978e39", + "https://bcr.bazel.build/modules/rules_java/6.3.0/MODULE.bazel": "a97c7678c19f236a956ad260d59c86e10a463badb7eb2eda787490f4c969b963", + "https://bcr.bazel.build/modules/rules_java/6.4.0/MODULE.bazel": "e986a9fe25aeaa84ac17ca093ef13a4637f6107375f64667a15999f77db6c8f6", + "https://bcr.bazel.build/modules/rules_java/6.5.2/MODULE.bazel": "1d440d262d0e08453fa0c4d8f699ba81609ed0e9a9a0f02cd10b3e7942e61e31", + "https://bcr.bazel.build/modules/rules_java/7.0.6/MODULE.bazel": "6ddb07d9857a1a3accc9f6d005f20c969c4659c7710e6269a51db3527e0ea969", + "https://bcr.bazel.build/modules/rules_java/7.1.0/MODULE.bazel": "30d9135a2b6561c761bd67bd4990da591e6bdc128790ce3e7afd6a3558b2fb64", + "https://bcr.bazel.build/modules/rules_java/7.10.0/MODULE.bazel": "530c3beb3067e870561739f1144329a21c851ff771cd752a49e06e3dc9c2e71a", + "https://bcr.bazel.build/modules/rules_java/7.12.2/MODULE.bazel": "579c505165ee757a4280ef83cda0150eea193eed3bef50b1004ba88b99da6de6", + "https://bcr.bazel.build/modules/rules_java/7.2.0/MODULE.bazel": "06c0334c9be61e6cef2c8c84a7800cef502063269a5af25ceb100b192453d4ab", + "https://bcr.bazel.build/modules/rules_java/7.3.2/MODULE.bazel": "50dece891cfdf1741ea230d001aa9c14398062f2b7c066470accace78e412bc2", + "https://bcr.bazel.build/modules/rules_java/7.6.1/MODULE.bazel": "2f14b7e8a1aa2f67ae92bc69d1ec0fa8d9f827c4e17ff5e5f02e91caa3b2d0fe", + "https://bcr.bazel.build/modules/rules_java/8.14.0/MODULE.bazel": "717717ed40cc69994596a45aec6ea78135ea434b8402fb91b009b9151dd65615", + "https://bcr.bazel.build/modules/rules_java/8.14.0/source.json": "8a88c4ca9e8759da53cddc88123880565c520503321e2566b4e33d0287a3d4bc", + "https://bcr.bazel.build/modules/rules_jvm_external/4.4.2/MODULE.bazel": "a56b85e418c83eb1839819f0b515c431010160383306d13ec21959ac412d2fe7", + "https://bcr.bazel.build/modules/rules_jvm_external/5.1/MODULE.bazel": "33f6f999e03183f7d088c9be518a63467dfd0be94a11d0055fe2d210f89aa909", + "https://bcr.bazel.build/modules/rules_jvm_external/5.2/MODULE.bazel": "d9351ba35217ad0de03816ef3ed63f89d411349353077348a45348b096615036", + "https://bcr.bazel.build/modules/rules_jvm_external/5.3/MODULE.bazel": "bf93870767689637164657731849fb887ad086739bd5d360d90007a581d5527d", + "https://bcr.bazel.build/modules/rules_jvm_external/6.1/MODULE.bazel": "75b5fec090dbd46cf9b7d8ea08cf84a0472d92ba3585b476f44c326eda8059c4", + "https://bcr.bazel.build/modules/rules_jvm_external/6.3/MODULE.bazel": "c998e060b85f71e00de5ec552019347c8bca255062c990ac02d051bb80a38df0", + "https://bcr.bazel.build/modules/rules_jvm_external/6.3/source.json": "6f5f5a5a4419ae4e37c35a5bb0a6ae657ed40b7abc5a5189111b47fcebe43197", + "https://bcr.bazel.build/modules/rules_kotlin/1.9.0/MODULE.bazel": "ef85697305025e5a61f395d4eaede272a5393cee479ace6686dba707de804d59", + "https://bcr.bazel.build/modules/rules_kotlin/1.9.6/MODULE.bazel": "d269a01a18ee74d0335450b10f62c9ed81f2321d7958a2934e44272fe82dcef3", + "https://bcr.bazel.build/modules/rules_kotlin/1.9.6/source.json": "2faa4794364282db7c06600b7e5e34867a564ae91bda7cae7c29c64e9466b7d5", + "https://bcr.bazel.build/modules/rules_license/0.0.3/MODULE.bazel": "627e9ab0247f7d1e05736b59dbb1b6871373de5ad31c3011880b4133cafd4bd0", + "https://bcr.bazel.build/modules/rules_license/0.0.7/MODULE.bazel": "088fbeb0b6a419005b89cf93fe62d9517c0a2b8bb56af3244af65ecfe37e7d5d", + "https://bcr.bazel.build/modules/rules_license/1.0.0/MODULE.bazel": "a7fda60eefdf3d8c827262ba499957e4df06f659330bbe6cdbdb975b768bb65c", + "https://bcr.bazel.build/modules/rules_license/1.0.0/source.json": "a52c89e54cc311196e478f8382df91c15f7a2bfdf4c6cd0e2675cc2ff0b56efb", + "https://bcr.bazel.build/modules/rules_multirun/0.14.0/MODULE.bazel": "7b5a01844515db132c256d415e9f4c27149bcbaf2c5ee40f5cb9355aa7c83302", + "https://bcr.bazel.build/modules/rules_multirun/0.14.0/source.json": "b80960b7da5a86dca4e699ef32ca5402024c76b55ed4c406e11f75b2c4065399", + "https://bcr.bazel.build/modules/rules_multirun/0.9.0/MODULE.bazel": "32d628ef586b5b23f67e55886b7bc38913ea4160420d66ae90521dda2ff37df0", + "https://bcr.bazel.build/modules/rules_multitool/0.11.0/MODULE.bazel": "8d9dda78d2398e136300d3ef4fbcc89ede7c32c158d8c016fa7d032df41c4aaf", + "https://bcr.bazel.build/modules/rules_multitool/0.11.0/source.json": "0b86574a1eaff37c33aafaff095ea16d6ac846beb94ffc74c4fcf626f8f80681", + "https://bcr.bazel.build/modules/rules_nodejs/5.8.2/MODULE.bazel": "6bc03c8f37f69401b888023bf511cb6ee4781433b0cb56236b2e55a21e3a026a", + "https://bcr.bazel.build/modules/rules_nodejs/6.2.0/MODULE.bazel": "ec27907f55eb34705adb4e8257952162a2d4c3ed0f0b3b4c3c1aad1fac7be35e", + "https://bcr.bazel.build/modules/rules_nodejs/6.7.3/MODULE.bazel": "c22a48b2a0dbf05a9dc5f83837bbc24c226c1f6e618de3c3a610044c9f336056", + "https://bcr.bazel.build/modules/rules_nodejs/6.7.5/MODULE.bazel": "97e6794043821d23c013baa4a50fd1c599f2e6ae92b06e2c5f1cd7074fd83e7c", + "https://bcr.bazel.build/modules/rules_nodejs/6.7.5/source.json": "d60ee5a76258b1c8f99545ed24172b44d43ba64ca1a2dfc04371ef203df19fdf", + "https://bcr.bazel.build/modules/rules_pkg/0.7.0/MODULE.bazel": "df99f03fc7934a4737122518bb87e667e62d780b610910f0447665a7e2be62dc", + "https://bcr.bazel.build/modules/rules_pkg/1.0.1/MODULE.bazel": "5b1df97dbc29623bccdf2b0dcd0f5cb08e2f2c9050aab1092fd39a41e82686ff", + "https://bcr.bazel.build/modules/rules_pkg/1.0.1/source.json": "bd82e5d7b9ce2d31e380dd9f50c111d678c3bdaca190cb76b0e1c71b05e1ba8a", + "https://bcr.bazel.build/modules/rules_proto/4.0.0/MODULE.bazel": "a7a7b6ce9bee418c1a760b3d84f83a299ad6952f9903c67f19e4edd964894e06", + "https://bcr.bazel.build/modules/rules_proto/5.3.0-21.7/MODULE.bazel": "e8dff86b0971688790ae75528fe1813f71809b5afd57facb44dad9e8eca631b7", + "https://bcr.bazel.build/modules/rules_proto/6.0.0-rc1/MODULE.bazel": "1e5b502e2e1a9e825eef74476a5a1ee524a92297085015a052510b09a1a09483", + "https://bcr.bazel.build/modules/rules_proto/6.0.0/MODULE.bazel": "b531d7f09f58dce456cd61b4579ce8c86b38544da75184eadaf0a7cb7966453f", + "https://bcr.bazel.build/modules/rules_proto/6.0.2/MODULE.bazel": "ce916b775a62b90b61888052a416ccdda405212b6aaeb39522f7dc53431a5e73", + "https://bcr.bazel.build/modules/rules_proto/7.0.2/MODULE.bazel": "bf81793bd6d2ad89a37a40693e56c61b0ee30f7a7fdbaf3eabbf5f39de47dea2", + "https://bcr.bazel.build/modules/rules_proto/7.1.0/MODULE.bazel": "002d62d9108f75bb807cd56245d45648f38275cb3a99dcd45dfb864c5d74cb96", + "https://bcr.bazel.build/modules/rules_proto/7.1.0/source.json": "39f89066c12c24097854e8f57ab8558929f9c8d474d34b2c00ac04630ad8940e", + "https://bcr.bazel.build/modules/rules_python/0.10.2/MODULE.bazel": "cc82bc96f2997baa545ab3ce73f196d040ffb8756fd2d66125a530031cd90e5f", + "https://bcr.bazel.build/modules/rules_python/0.23.1/MODULE.bazel": "49ffccf0511cb8414de28321f5fcf2a31312b47c40cc21577144b7447f2bf300", + "https://bcr.bazel.build/modules/rules_python/0.25.0/MODULE.bazel": "72f1506841c920a1afec76975b35312410eea3aa7b63267436bfb1dd91d2d382", + "https://bcr.bazel.build/modules/rules_python/0.26.0/MODULE.bazel": "42cb98cd15954e83b96b540dcc6d5a618eb061f056147ac4ea46e687a066a7c7", + "https://bcr.bazel.build/modules/rules_python/0.27.1/MODULE.bazel": "65dc875cc1a06c30d5bbdba7ab021fd9e551a6579e408a3943a61303e2228a53", + "https://bcr.bazel.build/modules/rules_python/0.28.0/MODULE.bazel": "cba2573d870babc976664a912539b320cbaa7114cd3e8f053c720171cde331ed", + "https://bcr.bazel.build/modules/rules_python/0.31.0/MODULE.bazel": "93a43dc47ee570e6ec9f5779b2e64c1476a6ce921c48cc9a1678a91dd5f8fd58", + "https://bcr.bazel.build/modules/rules_python/0.36.0/MODULE.bazel": "a4ce1ccea92b9106c7d16ab9ee51c6183107e78ba4a37aa65055227b80cd480c", + "https://bcr.bazel.build/modules/rules_python/0.4.0/MODULE.bazel": "9208ee05fd48bf09ac60ed269791cf17fb343db56c8226a720fbb1cdf467166c", + "https://bcr.bazel.build/modules/rules_python/0.40.0/MODULE.bazel": "9d1a3cd88ed7d8e39583d9ffe56ae8a244f67783ae89b60caafc9f5cf318ada7", + "https://bcr.bazel.build/modules/rules_python/0.40.0/source.json": "939d4bd2e3110f27bfb360292986bb79fd8dcefb874358ccd6cdaa7bda029320", + "https://bcr.bazel.build/modules/rules_rust/0.73.0/MODULE.bazel": "25e3b077128612754c4add1b4c90d20a6be06566b623dee6e32038d0e8f93062", + "https://bcr.bazel.build/modules/rules_rust/0.73.0/source.json": "8eeb3d9ba7c57916b63887a651e8f84c2f68b7243af9e712d728c2a0b7882255", + "https://bcr.bazel.build/modules/rules_shell/0.2.0/MODULE.bazel": "fda8a652ab3c7d8fee214de05e7a9916d8b28082234e8d2c0094505c5268ed3c", + "https://bcr.bazel.build/modules/rules_shell/0.4.1/MODULE.bazel": "00e501db01bbf4e3e1dd1595959092c2fadf2087b2852d3f553b5370f5633592", + "https://bcr.bazel.build/modules/rules_shell/0.5.0/MODULE.bazel": "8c8447370594d45539f66858b602b0bb2cb2d3401a4ebb9ad25830c59c0f366d", + "https://bcr.bazel.build/modules/rules_shell/0.6.1/MODULE.bazel": "72e76b0eea4e81611ef5452aa82b3da34caca0c8b7b5c0c9584338aa93bae26b", + "https://bcr.bazel.build/modules/rules_shell/0.6.1/source.json": "20ec05cd5e592055e214b2da8ccb283c7f2a421ea0dc2acbf1aa792e11c03d0c", + "https://bcr.bazel.build/modules/stardoc/0.5.0/MODULE.bazel": "f9f1f46ba8d9c3362648eea571c6f9100680efc44913618811b58cc9c02cd678", + "https://bcr.bazel.build/modules/stardoc/0.5.1/MODULE.bazel": "1a05d92974d0c122f5ccf09291442580317cdd859f07a8655f1db9a60374f9f8", + "https://bcr.bazel.build/modules/stardoc/0.5.3/MODULE.bazel": "c7f6948dae6999bf0db32c1858ae345f112cacf98f174c7a8bb707e41b974f1c", + "https://bcr.bazel.build/modules/stardoc/0.5.4/MODULE.bazel": "6569966df04610b8520957cb8e97cf2e9faac2c0309657c537ab51c16c18a2a4", + "https://bcr.bazel.build/modules/stardoc/0.5.6/MODULE.bazel": "c43dabc564990eeab55e25ed61c07a1aadafe9ece96a4efabb3f8bf9063b71ef", + "https://bcr.bazel.build/modules/stardoc/0.6.2/MODULE.bazel": "7060193196395f5dd668eda046ccbeacebfd98efc77fed418dbe2b82ffaa39fd", + "https://bcr.bazel.build/modules/stardoc/0.7.0/MODULE.bazel": "05e3d6d30c099b6770e97da986c53bd31844d7f13d41412480ea265ac9e8079c", + "https://bcr.bazel.build/modules/stardoc/0.7.1/MODULE.bazel": "3548faea4ee5dda5580f9af150e79d0f6aea934fc60c1cc50f4efdd9420759e7", + "https://bcr.bazel.build/modules/stardoc/0.7.1/source.json": "b6500ffcd7b48cd72c29bb67bcac781e12701cc0d6d55d266a652583cfcdab01", + "https://bcr.bazel.build/modules/tar.bzl/0.10.4/MODULE.bazel": "e8f9ff79199e8d9eaad7f1b0a77ad74b30bb82d794b87d8ca942bead5de83ae9", + "https://bcr.bazel.build/modules/tar.bzl/0.10.4/source.json": "20143442376c03426f6135292ba02d825cb75308aa47e6bf42dd4cc5a435c2ff", + "https://bcr.bazel.build/modules/tar.bzl/0.2.1/MODULE.bazel": "52d1c00a80a8cc67acbd01649e83d8dd6a9dc426a6c0b754a04fe8c219c76468", + "https://bcr.bazel.build/modules/tar.bzl/0.5.1/MODULE.bazel": "7c2eb3dcfc53b0f3d6f9acdfd911ca803eaf92aadf54f8ca6e4c1f3aee288351", + "https://bcr.bazel.build/modules/upb/0.0.0-20220923-a547704/MODULE.bazel": "7298990c00040a0e2f121f6c32544bab27d4452f80d9ce51349b1a28f3005c43", + "https://bcr.bazel.build/modules/upb/0.0.0-20230516-61a97ef/MODULE.bazel": "c0df5e35ad55e264160417fd0875932ee3c9dda63d9fccace35ac62f45e1b6f9", + "https://bcr.bazel.build/modules/yq.bzl/0.1.1/MODULE.bazel": "9039681f9bcb8958ee2c87ffc74bdafba9f4369096a2b5634b88abc0eaefa072", + "https://bcr.bazel.build/modules/yq.bzl/0.3.4/MODULE.bazel": "d3a270662f5d766cd7229732d65a5a5bc485240c3007343dd279edfb60c9ae27", + "https://bcr.bazel.build/modules/yq.bzl/0.3.4/source.json": "786dafdc2843722da3416e4343ee1a05237227f068590779a6e8496a2064c0f9", + "https://bcr.bazel.build/modules/zlib/1.2.11/MODULE.bazel": "07b389abc85fdbca459b69e2ec656ae5622873af3f845e1c9d80fe179f3effa0", + "https://bcr.bazel.build/modules/zlib/1.2.12/MODULE.bazel": "3b1a8834ada2a883674be8cbd36ede1b6ec481477ada359cd2d3ddc562340b27", + "https://bcr.bazel.build/modules/zlib/1.3.1.bcr.5/MODULE.bazel": "eec517b5bbe5492629466e11dae908d043364302283de25581e3eb944326c4ca", + "https://bcr.bazel.build/modules/zlib/1.3.1.bcr.5/source.json": "22bc55c47af97246cfc093d0acf683a7869377de362b5d1c552c2c2e16b7a806", + "https://bcr.bazel.build/modules/zlib/1.3.1/MODULE.bazel": "751c9940dcfe869f5f7274e1295422a34623555916eb98c174c1e945594bf198" + }, + "selectedYankedVersions": {}, + "moduleExtensions": { + "@@aspect_gazelle_prebuilt+//:extensions.bzl%prebuilt_extension": { + "general": { + "bzlTransitiveDigest": "4gLJYWBne/+kGjmt/fMRNlhRPekuQIiz48iA0svq5kk=", + "usagesDigest": "buhT4ml8E2AYzkV20drBJehT+kN+5y5YuX2nDKnwWuk=", + "recordedFileInputs": {}, + "recordedDirentsInputs": {}, + "envVariables": {}, + "generatedRepoSpecs": { + "aspect_gazelle_prebuilt_linux_amd64": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_file", + "attributes": { + "executable": true, + "sha256": "a6a1333b2c34055725b389b41cf07ff65db91a262aedf05347082e545305e5f2", + "urls": [ + "https://github.com/aspect-build/aspect-gazelle/releases/download/prebuilt-v0.0.24/aspect_gazelle-linux_amd64" + ] + } + }, + "aspect_gazelle_prebuilt_linux_arm64": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_file", + "attributes": { + "executable": true, + "sha256": "5fe7ea59c7521dbe628a8667c7d5c7347a0621ba1a6f42dcf34d9e4bdcf134ed", + "urls": [ + "https://github.com/aspect-build/aspect-gazelle/releases/download/prebuilt-v0.0.24/aspect_gazelle-linux_arm64" + ] + } + }, + "aspect_gazelle_prebuilt_darwin_amd64": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_file", + "attributes": { + "executable": true, + "sha256": "8d34895863274f9c5c0b2214540e89d9f52c5649c0c0afc98fb269de0445a7dc", + "urls": [ + "https://github.com/aspect-build/aspect-gazelle/releases/download/prebuilt-v0.0.24/aspect_gazelle-darwin_amd64" + ] + } + }, + "aspect_gazelle_prebuilt_darwin_arm64": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_file", + "attributes": { + "executable": true, + "sha256": "04d28decd516ccdd2e40f82fb24d5d1c44803b49e94f22217c24d263d53387c2", + "urls": [ + "https://github.com/aspect-build/aspect-gazelle/releases/download/prebuilt-v0.0.24/aspect_gazelle-darwin_arm64" + ] + } + } + }, + "recordedRepoMappingEntries": [ + [ + "aspect_gazelle_prebuilt+", + "bazel_tools", + "bazel_tools" + ] + ] + } + }, + "@@aspect_tools_telemetry+//:extension.bzl%telemetry": { + "general": { + "bzlTransitiveDigest": "4w9RM0xjdKo1crk5zL20a/TuhqO0P1z1LsuXDneBXD4=", + "usagesDigest": "ZQkRZFV8HR96e0wjekdSnlPwcJFV8quoRnqO/Jyw84Q=", + "recordedFileInputs": {}, + "recordedDirentsInputs": {}, + "envVariables": { + "ASPECT_TOOLS_TELEMETRY_TEST": null + }, + "generatedRepoSpecs": { + "aspect_tools_telemetry_report": { + "repoRuleId": "@@aspect_tools_telemetry+//:extension.bzl%tel_repository", + "attributes": { + "deps": { + "aspect_rules_js": "3.4.0", + "aspect_rules_ts": "3.10.0", + "aspect_rules_lint": "2.7.2", + "aspect_tools_telemetry": "0.4.2" + }, + "last_notice": 0 + } + } + }, + "moduleExtensionMetadata": { + "useAllRepos": "NO", + "reproducible": false + }, + "recordedRepoMappingEntries": [ + [ + "aspect_tools_telemetry+", + "bazel_lib", + "bazel_lib+" + ], + [ + "aspect_tools_telemetry+", + "bazel_skylib", + "bazel_skylib+" + ] + ] + } + }, + "@@buildifier_prebuilt+//:defs.bzl%buildifier_prebuilt_deps_extension": { + "general": { + "bzlTransitiveDigest": "1FTjQqujrd7WMJXHg/5wOcyPNiIYmpx0gHbnIfftRWI=", + "usagesDigest": "m+RORtK3MOrJs2auGj/7mY7N11R7swVsHYHg1jls5hs=", + "recordedFileInputs": {}, + "recordedDirentsInputs": {}, + "envVariables": {}, + "generatedRepoSpecs": { + "buildifier_darwin_amd64": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_file", + "attributes": { + "urls": [ + "https://github.com/bazelbuild/buildtools/releases/download/v6.4.0/buildifier-darwin-amd64" + ], + "downloaded_file_path": "buildifier", + "executable": true, + "sha256": "eeb47b2de27f60efe549348b183fac24eae80f1479e8b06cac0799c486df5bed" + } + }, + "buildifier_darwin_arm64": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_file", + "attributes": { + "urls": [ + "https://github.com/bazelbuild/buildtools/releases/download/v6.4.0/buildifier-darwin-arm64" + ], + "downloaded_file_path": "buildifier", + "executable": true, + "sha256": "fa07ba0d20165917ca4cc7609f9b19a8a4392898148b7babdf6bb2a7dd963f05" + } + }, + "buildifier_linux_amd64": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_file", + "attributes": { + "urls": [ + "https://github.com/bazelbuild/buildtools/releases/download/v6.4.0/buildifier-linux-amd64" + ], + "downloaded_file_path": "buildifier", + "executable": true, + "sha256": "be63db12899f48600bad94051123b1fd7b5251e7661b9168582ce52396132e92" + } + }, + "buildifier_linux_arm64": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_file", + "attributes": { + "urls": [ + "https://github.com/bazelbuild/buildtools/releases/download/v6.4.0/buildifier-linux-arm64" + ], + "downloaded_file_path": "buildifier", + "executable": true, + "sha256": "18540fc10f86190f87485eb86963e603e41fa022f88a2d1b0cf52ff252b5e1dd" + } + }, + "buildifier_windows_amd64": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_file", + "attributes": { + "urls": [ + "https://github.com/bazelbuild/buildtools/releases/download/v6.4.0/buildifier-windows-amd64.exe" + ], + "downloaded_file_path": "buildifier.exe", + "executable": true, + "sha256": "da8372f35e34b65fb6d997844d041013bb841e55f58b54d596d35e49680fe13c" + } + }, + "buildozer_darwin_amd64": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_file", + "attributes": { + "urls": [ + "https://github.com/bazelbuild/buildtools/releases/download/v6.4.0/buildozer-darwin-amd64" + ], + "downloaded_file_path": "buildozer", + "executable": true, + "sha256": "d29e347ecd6b5673d72cb1a8de05bf1b06178dd229ff5eb67fad5100c840cc8e" + } + }, + "buildozer_darwin_arm64": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_file", + "attributes": { + "urls": [ + "https://github.com/bazelbuild/buildtools/releases/download/v6.4.0/buildozer-darwin-arm64" + ], + "downloaded_file_path": "buildozer", + "executable": true, + "sha256": "9b9e71bdbec5e7223871e913b65d12f6d8fa026684daf991f00e52ed36a6978d" + } + }, + "buildozer_linux_amd64": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_file", + "attributes": { + "urls": [ + "https://github.com/bazelbuild/buildtools/releases/download/v6.4.0/buildozer-linux-amd64" + ], + "downloaded_file_path": "buildozer", + "executable": true, + "sha256": "8dfd6345da4e9042daa738d7fdf34f699c5dfce4632f7207956fceedd8494119" + } + }, + "buildozer_linux_arm64": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_file", + "attributes": { + "urls": [ + "https://github.com/bazelbuild/buildtools/releases/download/v6.4.0/buildozer-linux-arm64" + ], + "downloaded_file_path": "buildozer", + "executable": true, + "sha256": "6559558fded658c8fa7432a9d011f7c4dcbac6b738feae73d2d5c352e5f605fa" + } + }, + "buildozer_windows_amd64": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_file", + "attributes": { + "urls": [ + "https://github.com/bazelbuild/buildtools/releases/download/v6.4.0/buildozer-windows-amd64.exe" + ], + "downloaded_file_path": "buildozer.exe", + "executable": true, + "sha256": "e7f05bf847f7c3689dd28926460ce6e1097ae97380ac8e6ae7147b7b706ba19b" + } + }, + "buildifier_prebuilt_toolchains": { + "repoRuleId": "@@buildifier_prebuilt+//:defs.bzl%_buildifier_toolchain_setup", + "attributes": { + "assets_json": "[{\"arch\":\"amd64\",\"name\":\"buildifier\",\"platform\":\"darwin\",\"sha256\":\"eeb47b2de27f60efe549348b183fac24eae80f1479e8b06cac0799c486df5bed\",\"version\":\"v6.4.0\"},{\"arch\":\"arm64\",\"name\":\"buildifier\",\"platform\":\"darwin\",\"sha256\":\"fa07ba0d20165917ca4cc7609f9b19a8a4392898148b7babdf6bb2a7dd963f05\",\"version\":\"v6.4.0\"},{\"arch\":\"amd64\",\"name\":\"buildifier\",\"platform\":\"linux\",\"sha256\":\"be63db12899f48600bad94051123b1fd7b5251e7661b9168582ce52396132e92\",\"version\":\"v6.4.0\"},{\"arch\":\"arm64\",\"name\":\"buildifier\",\"platform\":\"linux\",\"sha256\":\"18540fc10f86190f87485eb86963e603e41fa022f88a2d1b0cf52ff252b5e1dd\",\"version\":\"v6.4.0\"},{\"arch\":\"amd64\",\"name\":\"buildifier\",\"platform\":\"windows\",\"sha256\":\"da8372f35e34b65fb6d997844d041013bb841e55f58b54d596d35e49680fe13c\",\"version\":\"v6.4.0\"},{\"arch\":\"amd64\",\"name\":\"buildozer\",\"platform\":\"darwin\",\"sha256\":\"d29e347ecd6b5673d72cb1a8de05bf1b06178dd229ff5eb67fad5100c840cc8e\",\"version\":\"v6.4.0\"},{\"arch\":\"arm64\",\"name\":\"buildozer\",\"platform\":\"darwin\",\"sha256\":\"9b9e71bdbec5e7223871e913b65d12f6d8fa026684daf991f00e52ed36a6978d\",\"version\":\"v6.4.0\"},{\"arch\":\"amd64\",\"name\":\"buildozer\",\"platform\":\"linux\",\"sha256\":\"8dfd6345da4e9042daa738d7fdf34f699c5dfce4632f7207956fceedd8494119\",\"version\":\"v6.4.0\"},{\"arch\":\"arm64\",\"name\":\"buildozer\",\"platform\":\"linux\",\"sha256\":\"6559558fded658c8fa7432a9d011f7c4dcbac6b738feae73d2d5c352e5f605fa\",\"version\":\"v6.4.0\"},{\"arch\":\"amd64\",\"name\":\"buildozer\",\"platform\":\"windows\",\"sha256\":\"e7f05bf847f7c3689dd28926460ce6e1097ae97380ac8e6ae7147b7b706ba19b\",\"version\":\"v6.4.0\"}]" + } + } + }, + "recordedRepoMappingEntries": [ + [ + "buildifier_prebuilt+", + "bazel_skylib", + "bazel_skylib+" + ], + [ + "buildifier_prebuilt+", + "bazel_tools", + "bazel_tools" + ] + ] + } + }, + "@@rules_buf+//buf:extensions.bzl%buf": { + "general": { + "bzlTransitiveDigest": "dSWqckK2ILN7aDIDHfv+Qrl1fb1hF7o7MDXY6T8C41s=", + "usagesDigest": "vxN6C2h72rUERbAmd1476FWpxdxo1NhYoY5JSFXJT3g=", + "recordedFileInputs": {}, + "recordedDirentsInputs": {}, + "envVariables": {}, + "generatedRepoSpecs": { + "rules_buf_toolchains": { + "repoRuleId": "@@rules_buf+//buf/internal:toolchain.bzl%buf_download_releases", + "attributes": { + "version": "v1.47.2", + "sha256": "1b37b75dc0a777a0cba17fa2604bc9906e55bb4c578823d8b7a8fe3fc9fe4439" + } + } + }, + "recordedRepoMappingEntries": [ + [ + "rules_buf+", + "bazel_tools", + "bazel_tools" + ] + ] + } + }, + "@@rules_go+//go:extensions.bzl%go_sdk": { + "os:osx,arch:aarch64": { + "bzlTransitiveDigest": "oup6J56aGr+wYhA3yPsutNkHUmMnFMFDEVdFL3oy95k=", + "usagesDigest": "igIBXyqNg9Be63Cuu6kZxOeoDRDMqxSv8BcoWiqSh3w=", + "recordedFileInputs": {}, + "recordedDirentsInputs": {}, + "envVariables": {}, + "generatedRepoSpecs": { + "go_default_sdk": { + "repoRuleId": "@@rules_go+//go/private:sdk.bzl%go_download_sdk_rule", + "attributes": { + "goos": "", + "goarch": "", + "sdks": {}, + "experiments": [], + "patches": [], + "patch_strip": 0, + "urls": [ + "https://dl.google.com/go/{}" + ], + "version": "1.21.1", + "strip_prefix": "go" + } + }, + "go_host_compatible_sdk_label": { + "repoRuleId": "@@rules_go+//go/private:extensions.bzl%host_compatible_toolchain", + "attributes": { + "toolchain": "@go_default_sdk//:ROOT" + } + }, + "go_toolchains": { + "repoRuleId": "@@rules_go+//go/private:sdk.bzl%go_multiple_toolchains", + "attributes": { + "prefixes": [ + "_0000_go_default_sdk_" + ], + "geese": [ + "" + ], + "goarchs": [ + "" + ], + "sdk_repos": [ + "go_default_sdk" + ], + "sdk_types": [ + "remote" + ], + "sdk_versions": [ + "1.21.1" + ] + } + } + }, + "recordedRepoMappingEntries": [ + [ + "bazel_features+", + "bazel_features_globals", + "bazel_features++version_extension+bazel_features_globals" + ], + [ + "bazel_features+", + "bazel_features_version", + "bazel_features++version_extension+bazel_features_version" + ], + [ + "rules_go+", + "bazel_features", + "bazel_features+" + ], + [ + "rules_go+", + "bazel_tools", + "bazel_tools" + ] + ] + } + }, + "@@rules_kotlin+//src/main/starlark/core/repositories:bzlmod_setup.bzl%rules_kotlin_extensions": { + "general": { + "bzlTransitiveDigest": "03Qju4tW0vE+0RBuZGuV2A4Hx6AiSkdNahYvworx2aM=", + "usagesDigest": "QI2z8ZUR+mqtbwsf2fLqYdJAkPOHdOV+tF2yVAUgRzw=", + "recordedFileInputs": {}, + "recordedDirentsInputs": {}, + "envVariables": {}, + "generatedRepoSpecs": { + "com_github_jetbrains_kotlin_git": { + "repoRuleId": "@@rules_kotlin+//src/main/starlark/core/repositories:compiler.bzl%kotlin_compiler_git_repository", + "attributes": { + "urls": [ + "https://github.com/JetBrains/kotlin/releases/download/v1.9.23/kotlin-compiler-1.9.23.zip" + ], + "sha256": "93137d3aab9afa9b27cb06a824c2324195c6b6f6179d8a8653f440f5bd58be88" + } + }, + "com_github_jetbrains_kotlin": { + "repoRuleId": "@@rules_kotlin+//src/main/starlark/core/repositories:compiler.bzl%kotlin_capabilities_repository", + "attributes": { + "git_repository_name": "com_github_jetbrains_kotlin_git", + "compiler_version": "1.9.23" + } + }, + "com_github_google_ksp": { + "repoRuleId": "@@rules_kotlin+//src/main/starlark/core/repositories:ksp.bzl%ksp_compiler_plugin_repository", + "attributes": { + "urls": [ + "https://github.com/google/ksp/releases/download/1.9.23-1.0.20/artifacts.zip" + ], + "sha256": "ee0618755913ef7fd6511288a232e8fad24838b9af6ea73972a76e81053c8c2d", + "strip_version": "1.9.23-1.0.20" + } + }, + "com_github_pinterest_ktlint": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_file", + "attributes": { + "sha256": "01b2e0ef893383a50dbeb13970fe7fa3be36ca3e83259e01649945b09d736985", + "urls": [ + "https://github.com/pinterest/ktlint/releases/download/1.3.0/ktlint" + ], + "executable": true + } + }, + "rules_android": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "sha256": "cd06d15dd8bb59926e4d65f9003bfc20f9da4b2519985c27e190cddc8b7a7806", + "strip_prefix": "rules_android-0.1.1", + "urls": [ + "https://github.com/bazelbuild/rules_android/archive/v0.1.1.zip" + ] + } + } + }, + "recordedRepoMappingEntries": [ + [ + "rules_kotlin+", + "bazel_tools", + "bazel_tools" + ] + ] + } + }, + "@@rules_multitool+//multitool:extension.bzl%multitool": { + "general": { + "bzlTransitiveDigest": "O7APJD7ee58kMf8lAX7fo06S2S6VWaegxP8YElhZJ84=", + "usagesDigest": "OxXTEbe+pxnGKai6ApFrmdjQOvsZ1xgjkDb718C0Kl0=", + "recordedFileInputs": {}, + "recordedDirentsInputs": {}, + "envVariables": {}, + "generatedRepoSpecs": { + "multitool.linux_arm64": { + "repoRuleId": "@@rules_multitool+//multitool/private:multitool.bzl%_env_specific_tools", + "attributes": { + "lockfiles": [ + "@@aspect_rules_lint+//format:multitool.lock.json", + "@@aspect_rules_lint+//lint:multitool.lock.json" + ], + "os": "linux", + "cpu": "arm64" + } + }, + "multitool.linux_x86_64": { + "repoRuleId": "@@rules_multitool+//multitool/private:multitool.bzl%_env_specific_tools", + "attributes": { + "lockfiles": [ + "@@aspect_rules_lint+//format:multitool.lock.json", + "@@aspect_rules_lint+//lint:multitool.lock.json" + ], + "os": "linux", + "cpu": "x86_64" + } + }, + "multitool.macos_arm64": { + "repoRuleId": "@@rules_multitool+//multitool/private:multitool.bzl%_env_specific_tools", + "attributes": { + "lockfiles": [ + "@@aspect_rules_lint+//format:multitool.lock.json", + "@@aspect_rules_lint+//lint:multitool.lock.json" + ], + "os": "macos", + "cpu": "arm64" + } + }, + "multitool.macos_x86_64": { + "repoRuleId": "@@rules_multitool+//multitool/private:multitool.bzl%_env_specific_tools", + "attributes": { + "lockfiles": [ + "@@aspect_rules_lint+//format:multitool.lock.json", + "@@aspect_rules_lint+//lint:multitool.lock.json" + ], + "os": "macos", + "cpu": "x86_64" + } + }, + "multitool.windows_arm64": { + "repoRuleId": "@@rules_multitool+//multitool/private:multitool.bzl%_env_specific_tools", + "attributes": { + "lockfiles": [ + "@@aspect_rules_lint+//format:multitool.lock.json", + "@@aspect_rules_lint+//lint:multitool.lock.json" + ], + "os": "windows", + "cpu": "arm64" + } + }, + "multitool.windows_x86_64": { + "repoRuleId": "@@rules_multitool+//multitool/private:multitool.bzl%_env_specific_tools", + "attributes": { + "lockfiles": [ + "@@aspect_rules_lint+//format:multitool.lock.json", + "@@aspect_rules_lint+//lint:multitool.lock.json" + ], + "os": "windows", + "cpu": "x86_64" + } + }, + "multitool": { + "repoRuleId": "@@rules_multitool+//multitool/private:multitool.bzl%_multitool_hub", + "attributes": { + "lockfiles": [ + "@@aspect_rules_lint+//format:multitool.lock.json", + "@@aspect_rules_lint+//lint:multitool.lock.json" + ] + } + } + }, + "recordedRepoMappingEntries": [ + [ + "bazel_features+", + "bazel_features_globals", + "bazel_features++version_extension+bazel_features_globals" + ], + [ + "bazel_features+", + "bazel_features_version", + "bazel_features++version_extension+bazel_features_version" + ], + [ + "rules_multitool+", + "bazel_features", + "bazel_features+" + ] + ] + } + }, + "@@yq.bzl+//yq:extensions.bzl%yq": { + "general": { + "bzlTransitiveDigest": "tDqk+ntWTdxNAWPDjRY1uITgHbti2jcXR5ZdinltBs0=", + "usagesDigest": "nAzVrl4+FVgfZnfAD+hzB5dFzozUf4mzIrzTGjk90/k=", + "recordedFileInputs": {}, + "recordedDirentsInputs": {}, + "envVariables": {}, + "generatedRepoSpecs": { + "yq_darwin_amd64": { + "repoRuleId": "@@yq.bzl+//yq/toolchain:platforms.bzl%yq_platform_repo", + "attributes": { + "platform": "darwin_amd64", + "version": "4.45.2" + } + }, + "yq_darwin_arm64": { + "repoRuleId": "@@yq.bzl+//yq/toolchain:platforms.bzl%yq_platform_repo", + "attributes": { + "platform": "darwin_arm64", + "version": "4.45.2" + } + }, + "yq_linux_amd64": { + "repoRuleId": "@@yq.bzl+//yq/toolchain:platforms.bzl%yq_platform_repo", + "attributes": { + "platform": "linux_amd64", + "version": "4.45.2" + } + }, + "yq_linux_arm64": { + "repoRuleId": "@@yq.bzl+//yq/toolchain:platforms.bzl%yq_platform_repo", + "attributes": { + "platform": "linux_arm64", + "version": "4.45.2" + } + }, + "yq_linux_s390x": { + "repoRuleId": "@@yq.bzl+//yq/toolchain:platforms.bzl%yq_platform_repo", + "attributes": { + "platform": "linux_s390x", + "version": "4.45.2" + } + }, + "yq_linux_riscv64": { + "repoRuleId": "@@yq.bzl+//yq/toolchain:platforms.bzl%yq_platform_repo", + "attributes": { + "platform": "linux_riscv64", + "version": "4.45.2" + } + }, + "yq_linux_ppc64le": { + "repoRuleId": "@@yq.bzl+//yq/toolchain:platforms.bzl%yq_platform_repo", + "attributes": { + "platform": "linux_ppc64le", + "version": "4.45.2" + } + }, + "yq_windows_amd64": { + "repoRuleId": "@@yq.bzl+//yq/toolchain:platforms.bzl%yq_platform_repo", + "attributes": { + "platform": "windows_amd64", + "version": "4.45.2" + } + }, + "yq_windows_arm64": { + "repoRuleId": "@@yq.bzl+//yq/toolchain:platforms.bzl%yq_platform_repo", + "attributes": { + "platform": "windows_arm64", + "version": "4.45.2" + } + }, + "yq_toolchains": { + "repoRuleId": "@@yq.bzl+//yq/toolchain:toolchain.bzl%yq_toolchains_repo", + "attributes": { + "user_repository_name": "yq" + } + } + }, + "recordedRepoMappingEntries": [] + } + } + }, + "facts": { + "@@aspect_tools_telemetry+//:extension.bzl%telemetry": { + "notice_version": "1" + } + } +} diff --git a/apps/DESIGN.md b/apps/DESIGN.md index 2b919299..9da238f1 100644 --- a/apps/DESIGN.md +++ b/apps/DESIGN.md @@ -36,7 +36,7 @@ Single source: `src/mainview/styles/tokens.css`. The `@smthrs/ui` library resolv - **The 300ms toast law (2026-08-09):** background work not settled within 300ms shows a toast on the one shared corner stack stating what is running, resolving into the result when done; work under 300ms never flashes. Toasts are notifications, not state mutations; a failure toast is honest and stays until dismissed. - **Chat is complimentary during the alpha (2026-08-09):** no per-turn dollar line on chat turns; a $0 balance never pauses the composer or the chat (the pause discipline applies only to non-complimentary paid work); the dollar balance chip stays. - **Watched repos are chosen, never defaulted (2026-08-09):** the first signed-in run asks ONE question — a repo-chooser card in the transcript (never a wizard page) — and the digest reads only the chosen set. Changing the set is "just ask": `repos.watch` is one command with three invocations (card confirm, slash, agent tool). `via` is recorded on every write. -- **Pills and buttons are command bindings (2026-08-09; amended 2026-08-19):** a suggestion pill never carries a prompt string for the model; it invokes its command directly. The state-derived row stays derived (the one grounded recommendation, or the genuinely-next step) — an empty row is correct, an app-fabricated one is a violation. **Amended (will, 2026-08-19):** the AGENT may propose the follow-ups it predicts after its answer, through one structured channel (`suggestions.propose`), and one of those may be a canned QUESTION — the words the user would type next ("What is a flow"). It stays a binding: a question pill invokes `send` with that text, so the click submits the user's own message through the composer's own path, and a flow pill invokes a registered flow. The set belongs to the latest answer only, is validated at the controller boundary (a question is never a slash invocation; a flow must be one the human can invoke), caps at three, and composes with the state-derived pills rather than replacing them. The composer shows NO standing status chrome; broken states speak at the moment they happen. +- **Pills and buttons are command bindings (2026-08-09):** a suggestion pill never carries a prompt string for the model; it invokes its command directly. The pill row is derived (the one grounded recommendation, or the genuinely-next step) — an empty row is correct, a fabricated one is a violation. The composer shows NO standing status chrome; broken states speak at the moment they happen. - **The trigger axis (2026-08-09):** browser-mechanics commands (`auth.*`, `reset`, `theme`, `chat.stop`, `send`, maximize/minimize) are `trigger: user` — absent from the agent's tool catalog and uncallable by it, with an honest tool-result error naming the visible alternative. The agent's invocation of a surface command renders the EMBEDDED card; maximizing is the user's explicit act alone. The bare reset is admin-only dev tooling; users get `/clear`, which sweeps the transcript into world notes before clearing and clears nothing on a failed sweep. Tool acts render as at most one compact Smithers-side line — raw payloads never enter the transcript; the full stream lives in the admin-only dev-tools panel. - **Sign-in IS the GitHub connector (2026-08-09):** a valid session means connected; the connect surface (extension-store rows: icon, name, one line, one action) and the agent context derive connection truth from the session + the watched set, never from the legacy local-connector store. @@ -287,7 +287,7 @@ Every visible capability still follows the same presentation law: its first outp Full plan with phase bars and rationale: `reports/agent-chain-ui-integration-plan-2026-08-11.html`. The vault (`../flows/docs/specs`) wins where they disagree. This section is the product-side contract each stacked PR lands against. -**The agent is the Agent Chain** (`@smthrs/chain`, a workspace package at `packages/chain` — promoted 2026-08-15 from the vendored copy, which was the last living source after the upstream agent repo deleted it): a bootstrap authors one flow script per link, the trampoline runs it, and the journal — `ChainStarted · LinkAuthored · CallSettled · GateRejected · LinkEnded · SteeringDrained` — is the only state. The concierge chain runs **in the webview** behind the existing `NativeAgent` seam, and since 2026-08-19 it is the ONLY chat backend: the flag and the second loop are gone, `/debug.backend` reports rather than switches, and every turn authors over `/api/model/stream`. Heavy or server-placed work is a sub-chain catalog call, not a second loop. +**The agent is the Agent Chain** (`@smthrs/chain`, a workspace package at `packages/chain` — promoted 2026-08-15 from the vendored copy, which was the last living source after the upstream agent repo deleted it): a bootstrap authors one flow script per link, the trampoline runs it, and the journal — `ChainStarted · LinkAuthored · CallSettled · GateRejected · LinkEnded · SteeringDrained` — is the only state. The concierge chain runs **in the webview** behind the existing `NativeAgent` seam, feature-flagged (`session.agentBackend: "proxy" | "chain"`) until parity. Heavy or server-placed work is a sub-chain catalog call, not a second loop. **Dependency law** (updated 2026-08-15; the vendored form below is superseded). Product code imports `@smthrs/chain`, `@smthrs/harness`, `@smthrs/model`, and `@smthrs/kernel` as pnpm workspace packages from this monorepo's `packages/` tree — the apps live at `apps/*` in `pnpm-workspace.yaml`, so the links come from the one workspace lockfile and no `file:` or sibling-checkout dependency exists. The property vendoring existed to buy — exactly one `effect` instance, because identity-keyed features (the `Redacted` registry, context references) fail across a pair — is now guaranteed by the workspace itself (single lockfile, `linkWorkspacePackages`) and asserted, not assumed: `apps/ui/src/mainview/chain/deps.test.ts` fails if any dependency reintroduces a path specifier or if `effect` resolved from the app and from any `@smthrs` package realpath to different directories. The historical vendored mechanism (`vendor/smthrs/*` as `file:` deps, `scripts/vendor-smthrs.mjs`, `MANIFEST.json` as pin of record) is deleted; its rationale is preserved in git history and `MIGRATION.md`. `src/shared/` (now the `smithers-shared` workspace package at `apps/shared`) stays runtime-free zod: it mirrors chain vocabulary and never imports it, which is what keeps the worker effect-free. @@ -303,4 +303,4 @@ Full plan with phase bars and rationale: `reports/agent-chain-ui-integration-pla **Decided defaults** (D1–D4 of the plan): new `/api/model/stream` worker route speaking full `ModelEvent`; worldview binds to `worldDocuments` now with `@smthrs/memory` underneath later; OPFS collection journal now; typed cards only. Chain gaps (live event tap, typed Control event, notifications-backed steering, park/wake) are fixed upstream in the agent repo, never as private mvp semantics. -**Deferred, deliberately.** The `/clear` sweep is a plain model call on `/api/model/stream`, not yet an authored flow (a background chain reading the transcript and writing worldview through `remember`); that conversion is mechanical and stays open. Closed 2026-08-19: the relay no longer holds a provider key at all. It forwards to the same managed-inference upstream `/api/agent/turn` uses, which owns the Cerebras key, authorizes the balance before the provider call, and meters the usage onto the vouched login — so per-user attribution came with the upstream rather than being rebuilt on the relay. Recorded here so the absences are decisions, not gaps. +**Deferred, deliberately.** The `/clear` sweep stays on its proxy call until the proxy retires — converting it to an authored flow (a background chain reading the transcript and writing worldview through `remember`) is mechanical once the chain backend is the default, and doing it behind the flag would run one conversation through two engines. The model relay does not yet meter per-user spend: it session-gates and injects the deployment key, and the Wave-13 attribution path must land before the chain backend becomes the default. Recorded here so the absences are decisions, not gaps. diff --git a/apps/E2E-CANARY-CHECKLIST.md b/apps/E2E-CANARY-CHECKLIST.md deleted file mode 100644 index d0ca6058..00000000 --- a/apps/E2E-CANARY-CHECKLIST.md +++ /dev/null @@ -1,358 +0,0 @@ -# apps — E2E and canary test checklist - -The complete set of end-to-end and canary tests the alpha needs, and what the -tree covers. Audited at `ceb784b6` on 2026-08-18; **remediated and re-audited at -`a6cab068`+ on 2026-08-19.** - -Two kinds of test are catalogued, and they are not interchangeable: - -- **E2E (`E*`)** — hermetic. Builds the SPA, boots `wrangler dev` against test - doubles, drives a real browser. No live deployment, no credential, no model - spend. Runs in CI on every push. -- **Canary (`CN*`)** — against a real deployment (`canary.smithers.sh`) and the - nine backing Workers. Runs after every deploy and on a schedule. Needs - credentials; may cost money. - -Status legend: **PASS** an automated test asserts it end to end · **PART** -asserted only at unit level, only against a live target, or only in a script -that cannot run unattended · **GAP** nothing asserts it. - -> **The row statuses below are the ORIGINAL 2026-08-18 audit and are now -> stale.** They are kept verbatim as the baseline this work was measured -> against. What was actually built, what it found, and what remains open is in -> `REMEDIATION.md` next to this file. Read that first. - -## Summary - -| | Required | PASS | PART | GAP | -| --- | ---: | ---: | ---: | ---: | -| E2E | 132 | 40 | 45 | 47 | -| Canary | 24 | 11 | 4 | 9 | - -Read that as: of 132 required e2e tests, 40 are genuinely asserted end to end -today — and none of those 40 run in CI. - -Unit tests are healthy and not the problem: 687 pass, 0 fail -(526 `apps/ui`, 108 `apps/server`, 33 `apps/shared`, 20 `apps/tui`). - -The problem is above unit level: - -1. **No e2e or canary test runs in CI or in the deploy pipeline.** `ci.yml` - runs `pnpm test`, which resolves to `bun test src` in each app — unit only. - `apps-deploy.yml` runs no tests before deploying and no probe after. -2. **The desktop app has zero coverage of any kind.** The Electrobun binary is - the shipped alpha artifact. -3. **The native sign-in handoff has zero server-side coverage.** - `/api/auth/native/start` and `/api/auth/native/claim` appear only in the - shared route table and in one client unit test against a mocked `fetch`. -4. **Three of the four e2e scripts cannot run unattended** — they need a - hand-started dev server and a hardcoded macOS Chrome path. -5. **All five canary scripts are machine-bound** — they `createRequire` a - Playwright install at the absolute path - `/Users/williamcory/flows/ui/package.json`. Playwright is not a dependency - of any package in this repo. - ---- - -# Part 1 — E2E (hermetic, CI-runnable) - -## E1. Auth and entry — the one-page law - -| | Test | Status | Where | -| --- | --- | --- | --- | -| E1.1 | Signed-out load renders the chat: transcript + composer, no landing view | PASS | `worker-e2e.ts` | -| E1.2 | First Tab stop is `auth.sign-in` | PART | checklist A-1, live only | -| E1.3 | Attempted send while signed out resolves to the calm sign-in reply; no turn POST | PASS | `worker-e2e.ts` | -| E1.4 | `/api/auth/github/start` redirects to the authorize URL | PASS | `worker-e2e.ts`, `index.test.ts` | -| E1.5 | Failed OAuth callback renders the honest chat message with retry | PASS | `worker-e2e.ts` | -| E1.6 | Failed OAuth keeps JSON + status for `Accept: application/json` | PASS | `worker-e2e.ts` | -| E1.7 | Non-allowlisted: request-access via the chat command; send states the waiting state | PASS | `worker-e2e.ts` | -| E1.8 | Allowlisted session reaches a working chat | PASS | `worker-e2e.ts` | -| E1.9 | **Sign-out** (`/api/auth/logout`) returns to the signed-out chat and clears the cookie | **GAP** | — | -| E1.10 | **Session expiry mid-session** surfaces in the chat, never a dead end or a silent 401 loop | **GAP** | — | -| E1.11 | **Native sign-in handoff** (`/api/auth/native/start` → poll `/api/auth/native/claim` → session) | **GAP** | client unit only, mocked fetch | -| E1.12 | Native handoff claim is single-use and expires | **GAP** | — | -| E1.13 | Cross-origin API request refused 403 before any credential is spent | PASS | `worker-e2e.ts` | -| E1.14 | Admin surface answers signed-out probes byte-identically to an unknown route (404, never 403) | PASS | `worker-e2e.ts` | -| E1.15 | SPA served with COOP/COEP isolation headers | PASS | `worker-e2e.ts` | -| E1.16 | Every seam 501s honestly when its upstream is unconfigured (gateway, identity, billing, approvals, reco) | PASS | `worker-e2e.ts` | - -## E2. First run and onboarding - -| | Test | Status | Where | -| --- | --- | --- | --- | -| E2.1 | `needsSelection` in one round trip; the real client opens the repo chooser | PASS | `worker-e2e.ts` | -| E2.2 | Chooser confirm PUTs `via: onboarding`; the scoped digest arrives | PASS | `worker-e2e.ts` | -| E2.3 | Agent-tool selection change: `via: agent`, embedded card, surface never changes | PASS | `worker-e2e.ts` | -| E2.4 | **Slash `/repos.watch` reaches the same one command** (third trigger of the three-trigger law) | **GAP** | — | -| E2.5 | Recommendation card carries proposes / why-now / what-happens | PART | `RecoCard.test.tsx` unit | -| E2.6 | Recommendation carries exactly one accept, one edit, one dismiss | PART | checklist A-8, live only | -| E2.7 | Dismiss is one keypress and the same recommendation does not return | PART | `RecoEscapeDismiss.test.tsx` unit; checklist A-9 self-poisons | -| E2.8 | **`reco.accept` runs the bound command** — the pill is a binding, not a prompt string | **GAP** | — | -| E2.9 | **`reco.edit` opens the edit path and the edited act is what runs** | **GAP** | — | -| E2.10 | Reco is scoped to the watched set; feedback round-trips | PASS | `worker-e2e.ts` | -| E2.11 | Degraded reco renders `honestMessage`, never a fake digest | PASS | `worker-e2e.ts` | -| E2.12 | **First message cites repo-specific data, not greeting boilerplate** | PART | checklist A-3, live only | -| E2.13 | **The first run asks ≤ 3 questions** | PART | checklist A-7, live only | -| E2.14 | **"$500 of usage on us" rendered exactly once while `introUsd` is unspent, zero times after** | PART | checklist A-5, live only | -| E2.15 | **No clone / install / configure copy anywhere on the signed-in surface** | PART | checklist A-4, live only | -| E2.16 | **No card-shaped input and no card-collection copy anywhere** | PART | checklist A-6, live only | -| E2.17 | **Sign-in to first useful message ≤ 90s** | PART | checklist A-2, live only | - -## E3. Turn lifecycle - -| | Test | Status | Where | -| --- | --- | --- | --- | -| E3.1 | One streamed turn completes delta → card → done | PASS | `worker-e2e.ts` | -| E3.2 | Cancel endpoint answers | PASS | `worker-e2e.ts` | -| E3.3 | Server-side kill mid-stream: `done:cancelled` never `done:stop`; late kill is not-found | PASS | `worker-e2e.ts` | -| E3.4 | The kill surfaces in the real client store as `interrupted` with the honest line; session returns to idle | PASS | `worker-e2e.ts` | -| E3.5 | Escape stops foreground work ≤ 1s with a statement of what stopped | PART | unit + checklist B-2 | -| E3.6 | **Close the browser mid-turn, reopen: conversation and in-flight work restored and correctly described** | PART | checklist B-1, live only | -| E3.7 | A never-finishing run goes honestly quiet with stop / retry | PASS | `worker-e2e.ts` | -| E3.8 | Tool loop end to end: model → tool → registry → final text → act line | PASS | `worker-e2e.ts` | -| E3.9 | **Turn failure renders the in-character bubble + `failed` status + system note** | **GAP** | — | -| E3.10 | **Retry on a failed turn resubmits the last user prompt** | **GAP** | — | -| E3.11 | **Interrupted partial message is retained, not discarded** | **GAP** | — | -| E3.12 | **Turn-seam rate limit / abuse guard refuses honestly** | **GAP** | not implemented | -| E3.13 | **`/api/model/stream` is session-gated and streams** | **GAP** | server unit only | -| E3.14 | **`/api/tools/browser-fetch` refuses unsafe targets and answers safe ones** | **GAP** | server unit only | - -## E4. Cards and approvals - -| | Test | Status | Where | -| --- | --- | --- | --- | -| E4.1 | `card` / `card.update` NDJSON frames validated; invalid dropped, unknown ignored | PART | `CardFrames.test.tsx` unit | -| E4.2 | Approval approve round trip with the Worker's identity injection | PASS | `worker-e2e.ts` | -| E4.3 | Approval deny round trip | PASS | `worker-e2e.ts` | -| E4.4 | Forced approval failure surfaces honestly | PASS | `worker-e2e.ts` | -| E4.5 | **Blocked-on-approval state agrees across every surface — no RUNNING-vs-Blocked contradiction** | PART | checklist F-6, live only | -| E4.6 | **Result cards lead with the result** | PART | checklist B-4, live only | -| E4.7 | **No score / grade / number is user-facing** | PART | checklist B-5, live only | -| E4.8 | **A correction never renders as an error state** | PART | checklist B-6, live only | -| E4.9 | **Zero rating prompts anywhere** | PART | checklist B-7, live only | -| E4.10 | **Decided card freezes with the decision stamp and cannot be re-decided** | **GAP** | — | -| E4.11 | **`card.maximize` is the user's act alone; an agent invocation renders the embedded card** | PART | `parity.test.ts` unit | - -## E5. Commands — 88 registered flows - -| | Test | Status | Where | -| --- | --- | --- | --- | -| E5.1 | Every visible interactive affordance resolves to a named command reachable by `/name` | PART | checklist C-1, live only | -| E5.2 | `/` opens with the recommended command first; bare `/` + Enter runs it | PART | checklist C-2, live only | -| E5.3 | **Exact-name precedence: `/flows` + Enter runs `flows`, never `flow.list`** | **GAP** | known defect U10, unfixed | -| E5.4 | The whole section-A journey is completable keyboard-only | PART | checklist C-3, live only | -| E5.5 | Trigger axis: `trigger: user` commands are absent from the agent tool catalog | PART | `parity.test.ts` unit | -| E5.6 | An agent calling a user-only command gets an honest tool error naming the visible alternative | PART | `requirements.test.ts` unit | -| E5.7 | **Every one of the 88 commands has a registry-driven smoke invocation** | **GAP** | ~6 exercised end to end | -| E5.8 | **`/clear` sweeps the transcript into world notes, then clears; clears nothing on a failed sweep** | **GAP** | — | -| E5.9 | Bare `reset` is an unknown command for a non-admin | PASS | `worker-e2e.ts` | -| E5.10 | Admin commands and chrome are undetectable to a non-admin | PASS | `worker-e2e.ts` | -| E5.11 | Admin journey: allowlist add with attribution, grant with fresh id, queue read, feedback log, health card | PASS | `worker-e2e.ts` | - -## E6. Billing - -| | Test | Status | Where | -| --- | --- | --- | --- | -| E6.1 | Balance reads in dollars, billed as the user through the trusted-caller path | PASS | `worker-e2e.ts` | -| E6.2 | Balance drains to $0 with `allowedToStartWork: false` | PASS | `worker-e2e.ts` | -| E6.3 | At $0, interactive chat keeps working (complimentary) | PART | `ZeroBalanceLaunch.test.ts` unit; checklist D-4 | -| E6.4 | At $0, `flow.run` / `flow.create` short-circuit before any seam call and post the notice naming `/billing.upgrade` | PART | unit only | -| E6.5 | **No top-up / checkout / card-collection flow is exposed** | PART | checklist D-3, live only | -| E6.6 | **`POST /api/admin/grant` rejects a call with no admin token (401)** | PART | checklist E-1, live only | -| E6.7 | **An untimestamped grant is refused (400 `timestamp_required`)** | PART | checklist E-2, live only | -| E6.8 | **A grant with requester + timestamp credits exactly once (201, audit record)** | PART | checklist E-3, live only | -| E6.9 | **Replaying the same grant does not double-credit** | **GAP** | — | -| E6.10 | **`/api/billing/usage` answers for a signed-in user and refuses signed out** | **GAP** | — | - -## E7. Workflows in the conversation - -| | Test | Status | Where | -| --- | --- | --- | --- | -| E7.1 | `POST /api/workflow/provision` provisions-or-resumes; idempotent on a second call | PASS | `worker-e2e.ts` | -| E7.2 | No gateway credential ever reaches the browser | PASS | `worker-e2e.ts` | -| E7.3 | `listWorkflows` through the relay | PASS | `worker-e2e.ts` | -| E7.4 | create-workflow launched with the user's own words | PASS | `worker-e2e.ts` | -| E7.5 | The embedded run card tracks the run live | PASS | `worker-e2e.ts` | -| E7.6 | Approval round trip through the relay | PASS | `worker-e2e.ts` | -| E7.7 | Auto-resume to a result stated in words | PASS | `worker-e2e.ts` | -| E7.8 | Honest `no_capacity` / no-cloud-identity taxonomy | PASS | `worker-e2e.ts` | -| E7.9 | Wave-12 truth: the replayed canary turn renders the deterministic line, never "has been created" | PASS | `worker-e2e.ts` | -| E7.10 | **`/api/workflow/events` and `/api/workflow/stream` reconnect after a dropped connection** | **GAP** | server unit only | -| E7.11 | **`/api/workflow/rpc` refuses non-replayable methods on replay** | **GAP** | server unit only | -| E7.12 | **`flow.run.stop` and `flow.run.retry` from the card** | **GAP** | — | - -## E8. Honesty — the F rows - -Every one of these needs a scripted model double so it can run hermetically. -Today all six exist only as live-target checklist rows. - -| | Test | Status | -| --- | --- | --- | -| E8.1 | Impossible ask, send an email: honest "can't yet + next step", never fake success | PART (F-1) | -| E8.2 | Impossible ask, read local files | PART (F-2) | -| E8.3 | Impossible ask, unconnected tool | PART (F-3) | -| E8.4 | Impossible ask, claim a push | PART (F-4) | -| E8.5 | Impossible ask, claim a PR | PART (F-5) | -| E8.6 | A launch turn's prose never claims run state the run does not have | PART (`Wave12.test.ts` unit) | - -## E9. Shell, panes, and layout - -| | Test | Status | Where | -| --- | --- | --- | --- | -| E9.1 | World and Connectors open as embedded panes; transcript and composer keep node identity | PART | `web-chat-shell-e2e.ts`, not CI-runnable | -| E9.2 | The sent message and the composer draft survive every transition | PART | same | -| E9.3 | Back-to-conversation returns without unmounting the chat | PART | same | -| E9.4 | **Pane sits beside the chat on a wide window and under it on a narrow one** | **GAP** | no viewport-size e2e | -| E9.5 | **A pane never overlays the conversation; chat chrome stays anchored to the chat column** | **GAP** | — | -| E9.6 | **The 300ms toast law: work over 300ms toasts, work under it never flashes** | PART | `Toasts.test.ts` unit | -| E9.7 | **A failure toast stays until dismissed** | PART | unit | - -## E10. World surface - -| | Test | Status | Where | -| --- | --- | --- | --- | -| E10.1 | Tool-driven note creation lands in the registry and renders the act line | PASS | `worker-e2e.ts` | -| E10.2 | **Editing a note reparses wikilinks with `user:world-editor` provenance** | **GAP** | — | -| E10.3 | **Delete note goes through the destructive ConfirmDialog** | **GAP** | — | -| E10.4 | **Empty state renders with its create action** | **GAP** | — | - -## E11. Connectors surface - -| | Test | Status | -| --- | --- | --- | -| E11.1 | **Local repository picker: read-only vs read-write states** | **GAP** | -| E11.2 | **Remove repo requires the destructive ConfirmDialog** | **GAP** | -| E11.3 | **Connected repo card states branch / head / worldview facts** | **GAP** | -| E11.4 | **Sign-in is the GitHub connector: connection truth derives from session + watched set** | **GAP** | - -## E12. Native desktop app — Electrobun - -Nothing in this group exists. The packaged binary is the shipped alpha artifact. - -| | Test | Status | -| --- | --- | --- | -| E12.1 | **The built app launches and renders the chat** | **GAP** | -| E12.2 | **`SMITHERS_APP_URL` loads the deployed origin instead of the local build** | **GAP** | -| E12.3 | **Native RPC seams bind to the window, not the URL, and answer** | **GAP** | -| E12.4 | **Local repository picker returns a real inspection** | **GAP** | -| E12.5 | **Updater channel resolution picks the right build** | **GAP** | -| E12.6 | **`build:canary` produces a launchable artifact** | **GAP** | -| E12.7 | **The artifact opens on a clean machine (signing / notarization)** | **GAP** | - -## E13. Accessibility, theming, and motion - -| | Test | Status | Where | -| --- | --- | --- | --- | -| E13.1 | Keyboard-only completion of the section-A journey | PART | checklist C-3, live only | -| E13.2 | **`role="log"` transcript, `aria-live` status, aria-labels on icon buttons** | **GAP** | — | -| E13.3 | **`:focus-visible` brand ring present on every interactive affordance** | **GAP** | — | -| E13.4 | Every consumed house token defined in both `:root` and `[data-theme="dark"]` — no violet/zinc leak | PART | `Palette.test.ts` unit | -| E13.5 | **`prefers-reduced-motion` honored** | **GAP** | — | - -## E14. Client resilience - -| | Test | Status | -| --- | --- | --- | -| E14.1 | **Network drop mid-turn resolves honestly and recovers** | **GAP** | -| E14.2 | **Persisted store (OPFS / wa-sqlite) survives a schema version bump** | **GAP** | -| E14.3 | **A stale cached bundle after a deploy does not wedge the app** | **GAP** | -| E14.4 | **Client errors reach a reporting sink, not only `console.error`** | **GAP** | -| E14.5 | **Zero console errors on the signed-out and signed-in loads** | PART (live only) | - -## E15. TUI - -| | Test | Status | -| --- | --- | --- | -| E15.1 | Fixture NDJSON stream folds into the transcript; composer submit works | PART (`scripts/smoke.ts`, not in `test`) | -| E15.2 | **A real turn against `wrangler dev` + stubs** | **GAP** | -| E15.3 | **Interrupt and retry from the TUI** | **GAP** | - ---- - -# Part 2 — Canary (live deployment) - -Run after every deploy, then on a schedule. - -| | Probe | Status | Where | -| --- | --- | --- | --- | -| CN-1 | **The deployed bundle is the git sha the deploy receipt claims** | **GAP** | the live build is 13 commits stale and nothing detects it | -| CN-2 | Signed-out chat renders with zero console errors | PASS | `live-check.ts` | -| CN-3 | Sign-in never dead-ends: authorize page, or the branded honest page | PASS | `live-check.ts` | -| CN-4 | A real failed callback renders the honest page with the way home, status preserved | PASS | `live-check.ts` | -| CN-5 | `Accept: application/json` still gets the machine-readable answer | PASS | `live-check.ts` | -| CN-6 | Every configured seam answers its configured shape; no accidental 501 | PASS | `canary-seam-probe.ts` | -| CN-7 | Deliberately-unset seams answer the honest 501 naming the unset var | PASS | `canary-seam-probe.ts` | -| CN-8 | The turn seam is session-gated — signed out is 401, never 200 | PASS | `canary-seam-probe.ts` | -| CN-9 | Real OAuth journey with the sanctioned profile reaches a signed-in chat | PASS | `live-signed-in-check.ts` | -| CN-10 | Chooser appears iff no watched selection; otherwise the scoped digest + gold pill | PASS | `live-signed-in-check.ts` | -| CN-11 | No standing composer status chrome; no admin chrome for a non-admin | PASS | `live-signed-in-check.ts` | -| CN-12 | Workflow provision + launch + approve on the real relay (honest `no_capacity` passes) | PASS | `live-workflow-check.ts` | -| CN-13 | Sign-in to first useful message ≤ 90s | PART | checklist A-2 | -| CN-14 | Balance reads the $500 design-partner grant | PART | checklist D-1/D-2 | -| CN-15 | $0 account: chat works, workflow launch refused into the transcript | PART | checklist D-4, needs a parked $0 account | -| CN-16 | Grants admin: 401 without token, 400 untimestamped, 201 credit-once | PART | checklist E-1..E-3 | -| CN-17 | **Reco dismissal is resettable between runs** | **GAP** | needs `DELETE /api/reco/admin/dismissals`; A-8/A-9 self-poison for 7 days without it | -| CN-18 | **All nine backing Workers answer a health probe** (identity, billing, chat, recommendations, connectors-catalog, cron, status, sync, webhooks) | **GAP** | none are in this repo | -| CN-19 | **Turn-seam latency budget** | **GAP** | — | -| CN-20 | **Error-rate threshold with an alert** | **GAP** | — | -| CN-21 | **Synthetic uptime probe on a schedule** | **GAP** | — | -| CN-22 | **Native app against the deployed origin (`start:canary`)** | **GAP** | — | -| CN-23 | **The allowlist seed is present and an invite actually admits a new user** | **GAP** | `invite-mechanics.test.ts` is unit only | -| CN-24 | **Rollback: the previous Worker version is reachable and the receipt names it** | **GAP** | receipts record `wranglerVersionId: null` on every dry run | - ---- - -# Part 3 — Infrastructure gaps - -These block the tests above from being worth anything, and should land first. - -**I-1. No e2e job in CI.** Add a job that runs `pnpm --filter smithers-ui run -test:e2e:worker`. It is already self-contained: it builds the SPA, boots -`wrangler dev` twice against `scripts/stub-backends.ts`, and asserts 26 named -outcomes. It is the single highest-value thing in the tree that never runs. - -**I-2. The three web e2e scripts cannot run unattended.** They default to -`http://localhost:5173` and never start it, and they hardcode -`/Applications/Google Chrome.app/Contents/MacOS/Google Chrome`. Give them the -same self-boot the worker script has, and the same browser resolution -`launch-checklist/BrowserLaunch.ts` already implements. - -**I-3. Playwright is not a dependency.** `live-check.ts`, -`live-signed-in-check.ts`, and `live-workflow-check.ts` all do -`createRequire("/Users/williamcory/flows/ui/package.json")`. They run on one -laptop and nowhere else. Add `@playwright/test` as a devDependency. - -**I-4. `apps-deploy.yml` runs no tests and no post-deploy probe.** It installs, -builds, deploys. A deploy that breaks the app is indistinguishable from one -that does not. - -**I-5. `apps/ui/scripts/` is not typechecked.** `tsconfig.json` covers `src` -only, so the e2e and canary scripts drift silently. - -**I-6. The nine backing Workers are not in this repo.** They live in a dirty -branch of `~/flows/ui/workers/`. Nothing in CI can build, test, or deploy them, -and CN-18 cannot be written until they move. - -**I-7. `web-chat-e2e.ts` asserts a genuine streamed reply**, which means a real -model credential and real spend. Split it: a hermetic variant against the stub -model for CI, and the live variant as a canary. - ---- - -# Part 4 — Order of work - -1. **I-1** — put `test:e2e:worker` in CI. One job, no new tests, immediate value. -2. **E12.1–E12.3** — smoke the packaged desktop app. It is what ships and it has - nothing. -3. **E1.9, E1.11, E1.12** — sign-out and the native sign-in handoff. The desktop - app cannot sign in any other way and no test covers the path. -4. **CN-1** — assert the deployed bundle matches the deploy receipt's sha. This - defect is live right now and silent. -5. **I-2, I-3** — make the browser scripts runnable off this laptop. -6. **E8.1–E8.6** with a scripted model double — move the six honesty rows off the - live target and into CI. -7. **E5.3** — fix and pin the exact-name slash precedence defect. -8. **CN-17** — add the dismissal reset door so A-8/A-9 stop self-poisoning. -9. **E2.4–E2.9, E3.9–E3.11, E4.10, E6.9** — the reco, retry, and grant paths. -10. **I-4** — gate the deploy on e2e and follow it with the canary set. diff --git a/apps/HUMAN-TASKS.md b/apps/HUMAN-TASKS.md index 9a41d226..d815fe6a 100644 --- a/apps/HUMAN-TASKS.md +++ b/apps/HUMAN-TASKS.md @@ -280,16 +280,6 @@ The run writes `launch-checklist-report.json` and `.md` under is `fail`. A `not-testable-yet` row always carries a named reason; read them rather than treating them as passes. -**A-8 and A-9 reset themselves now.** They used to poison the account: A-9 -dismisses a recommendation by design, reco suppresses a dismissed -recommendation for seven days, so the next run had nothing to grade and -reported a defect that was not one (`apps/WAVE14-RECEIPT.md`, "Honest gaps"). -Both rows now lift the account's dismissals first through the admin-gated -`DELETE /api/admin/reco-dismissals?login=`. That means **the checklist session -should be an admin account**; a non-admin session still runs, but the rows -record "not an admin" in their evidence and the old self-poisoning applies. -Set `CHECKLIST_LOGIN` if the session seam does not name the account. - **Final go/no-go.** Ship when: - every checklist row is `pass`, or its `not-testable-yet` reason is one you diff --git a/apps/README.md b/apps/README.md index 296739f0..1c831049 100644 --- a/apps/README.md +++ b/apps/README.md @@ -22,31 +22,4 @@ had no living source elsewhere and was promoted to `packages/chain` Product-level docs (`DESIGN.md`, `MIGRATION.md`, `WAVE*-RECEIPT.md`, `reports/`) live at this level because they cover UI and Worker waves -alike. `UPSTREAMS.md` names the sibling Cloudflare Workers this product -proxies — identity, billing, chat, recommendations — which live in a -different repository and are what a broken sign-in usually means. - -## Running it locally - -| Command | From | What runs | -| --- | --- | --- | -| `pnpm dev` | repository root | The UI on `http://localhost:5173`. Forwards to `pnpm --filter smithers-ui run web`, so the `--configLoader runner` flag lives in one place. | -| `pnpm --filter smithers-ui run serve:local` | anywhere | The UI built and served by `wrangler dev`, i.e. the UI **and** the product Worker together. Use this to exercise the `/api` seams. | -| `pnpm --filter smithers-ui run build` | anywhere | The production bundle into `apps/ui/dist`, which the Worker serves as static assets. | - -Dev rides the deployed seams. Everything the product Worker proxies in -production (`/api/auth`, `/api/identity`, `/api/reco`, `/api/billing`, -`/api/repos`, `/api/github`, `/api/user`, `/api/notifications`, -`/api/workflow`, `/api/client-errors`) forwards to -`https://canary.smithers.sh`, so the identity probe answers definitively -instead of "unavailable". Point that elsewhere with **`SMITHERS_DEV_UPSTREAM`**: - -```sh -SMITHERS_DEV_UPSTREAM=http://127.0.0.1:8787 pnpm dev -``` - -The chat seam (`/api/agent`) stays local — `apps/ui/src/dev/AgentApi.ts` serves -it, with `SMITHERS_CHAT_URL` and `SMITHERS_CHAT_ORIGIN` naming the upstream it -relays to. Signed-in state cannot exist on `localhost` whatever you point at: -the session cookie and the GitHub OAuth callback are bound to the canary -origin, so completing a sign-in continues there. +alike. diff --git a/apps/REMEDIATION.md b/apps/REMEDIATION.md deleted file mode 100644 index 351a9d6c..00000000 --- a/apps/REMEDIATION.md +++ /dev/null @@ -1,255 +0,0 @@ -# apps — e2e and canary remediation, 2026-08-19 - -What was built against `E2E-CANARY-CHECKLIST.md`, what it found, and what is -still open. The row statuses in that file are the 2026-08-18 baseline and are -deliberately left stale; this file is the current record. - -## What the tests found - -The point of the exercise was coverage. The more valuable outcome was three -product defects, each found by a new suite, each confirmed independently by two -adversarial auditors reading source rather than trusting the lane that reported -it. - -### D1 — a decided approval could be decided twice - -`AppStore.ts` — the `card.upsert` and `card.updated` reducers lacked the freeze -guard their sibling reducer has. A `card.update` NDJSON frame arriving after a -decision reopened a decided ApprovalCard, so it could be approved or denied a -second time. - -An approval is a human authorising an action. A frame from the model's own -stream must never be able to un-decide one. - -Fixed: a recorded decision, not the `acted` status, now freezes an approval -(`AppStore.ts:113-120`). Caught by `e2e/suites/cards-approvals.e2e.ts`. - -Adjacent hazard reported and deliberately not changed: `decideApproval` in -`AppController.ts` returns early on `status === "acted"`, so a streamed frame -that sets `acted` with no decision recorded can suppress the gate entirely — the -opposite failure. No test covers it and it predates this work. The two guards now -use different notions of "frozen" and should be reconciled. - -### D2 — reopening the app could lose the whole conversation - -`AppStore.ts` — the persistence backend could flip between launches. One launch -wrote OPFS, the next fell back to localStorage and read a fresh empty database, -silently. An auditor reproduced it in both directions. - -Fixed: the recorded backend is authoritative. When the recorded store will not -open, the launch runs on a memory store rather than presenting a stale store as -the current conversation or forking history by writing into it. The real store is -untouched and returns on the next launch. Caught by -`e2e/suites/turn-failure.e2e.ts` (row E3.6). - -Follow-up landed by the orchestrator: `persistenceDegraded` was set and read by -nothing, so the user saw an empty transcript with no explanation — the honest -recovery read as silent data loss. A `failed` toast now states it and stays until -dismissed. - -### D3 — the sign-in button was not the first tab stop - -The corner chrome rendered before the transcript, so on the signed-out chat a -keyboard user Tabbed into the theme toggle instead of the only action available -to them. The theme toggle also carried no `data-flow` despite running the -registered `dark-mode` command. - -Launch-checklist row A-1 grades exactly `tabbable.indexOf("auth.sign-in") === 0`, -**so the live checklist had been failing this row too.** It was recorded as -`PART — live only`, which is how it stayed invisible. That is the checklist's own -thesis: a row nobody runs is a row nobody knows is red. - -Fixed in `App.tsx` + `chat.css`. Caught by `e2e/suites/auth-session.e2e.ts`. - -Root cause of why the fix needed a focus shortcut rather than a DOM reorder: -`@smthrs/ui`'s `MessageScrollerViewport` hardcodes `tabIndex={0}` and nothing the -host passes reaches it. `apps/ui` pins `@smthrs/ui: 0.33.0` from npm with no -alias and no patch, so no change in this repo can reach it. Filed upstream. - -## The rot that started this - -`worker-e2e.ts` — the tree's only self-contained e2e suite — was RED at HEAD and -had been for three days. Nothing ran it, so nothing noticed. - -Across two sessions, one file was found to contain **19 dead string literals and -4 vacuous assertions**, all orphaned by the 2026-08-15 `command`→`flow` rename: - -- 9 assertions against the card kind `"workflow-run"`, which stopped existing at - the rename, so every comparison was always false; -- 8 `workflow.` command names against a registry that declares only - `flow.`. The worst was a stub emitting `workflow.create` while - `RunClaims.RUN_LAUNCH_COMMANDS` is `["flow.create", "flow.run"]` — the wave-12 - section had disarmed the exact substitution it exists to prove; -- 2 dead `workflow-run-` card-id prefixes; -- `"never a fake digest"` asserting nothing; -- a credential-leak probe that any refusal satisfied for free. - -Plus 17 stale `data-command` DOM selectors across four browser scripts, invisible -to `tsc` because a selector is a string inside a CDP expression. - -All fixed. `worker-e2e.ts` is green at 27 assertions. - -## Closing the class, not the instances - -A one-off sweep proves the suites are clean today and does nothing for the next -rename. `src/conformance/` pins every literal the e2e and canary suites assert -against to the vocabulary the app owns — card kinds from the type union, command -names from the `data-flows` manifest the shell publishes, collection keys, -card-id prefixes, DOM attributes. A literal that no longer resolves fails the -test and names what orphaned it. - -It runs in `bun test src`, the fast unit gate, not behind the browser job. - -Two things make it non-vacuous, both added after review: - -- **Non-empty floors on every derived vocabulary.** A conformance test that - derives its universe from the app passes trivially the moment the derivation - returns nothing — the defect class it exists to close, one level up. -- **A regression fixture** replaying the four dead-literal classes of the - 2026-08-15 rename, asserted to be caught. - -An auditor then found a hole in the pin *in the same shape*: card kinds were -checked inside `[data-kind="…"]` selectors and `.kind ===` comparisons but not -when passed as a function argument, which is how most are passed. Closed, with a -fixture in that exact shape. - -## The canary probes were audited before they shipped - -Four probes were written, then adversarially audited. All four were defective and -every defect was demonstrated with a real run: - -- **CN-19/20/21 never probed the deployment at all.** The origin was resolved as - "first non-flag token in argv", and the scheduled workflow passes - `--json ` with no positional origin — so the origin became a filesystem - path. Every scheduled run would report the canary fully down regardless of - production state, and open a GitHub issue every 15 minutes forever. A probe - whose verdict cannot move with the thing it grades. -- **CN-18 called a nonexistent Worker healthy.** A `workers.dev` host with - nothing behind it answers 404 — the same status the healthy chat Worker answers - at `/`. -- **CN-1 passed a half-published deploy.** A fresh `/__build.json` beside a - pre-stamp `index.html` went green while the deployment served the old app. -- **CN-23 exited 0 while asserting nothing** when uncredentialed, and the - proposed CI step referenced secrets that do not exist. - -All four fixed and mutation-tested: each fix was reverted in isolation to confirm -its regression test actually fails without it. - -CN-1's mechanism was verified sound by building the real bundle and grepping -`dist`: the stamp travels inside the artifact, so a stale deployment cannot serve -a fresh stamp. CN-24 was verified by unpacking wrangler 4.123.0 and tracing the -version id to stdout, then reading the live Cloudflare versions API. - -The live canary is confirmed stale from outside: Cloudflare reports the running -version was created 2026-08-13, and the probe reds against it today. - -## Infrastructure - -- **I-1** — `ci.yml` gained an `apps-e2e` job running both the worker suite and - the hermetic suites. It is a separate top-level job, not a step of `test`, so a - multi-minute browser run does not sit in front of every push. -- **F7** — none of the four apps declared a `check` script, so root - `pnpm run check` (`--recursive --if-present`) typechecked **zero app code**. - All four now declare it. -- **T7** — `apps/server`'s test script was scoped to `src`, so 172 canary probe - tests ran nowhere. Now `bun test src scripts`. -- The e2e runner had no CDP timeout and hung for 25 minutes with no output; a - hanging CI job is worse than a failing one. Bounded, with per-lane debug ports - and profiles so concurrent runs cannot collide. -- `deploy.ts` wrote `wranglerVersionId: null` when a real deploy printed no id, - handing the operator a rollback plan CN-24 cannot verify. It now fails loudly. - Dry runs stay exempt. - -## Where the numbers stand - -| Workspace | Before | After | -| --- | ---: | ---: | -| `apps/ui` | 526 | 628 | -| `apps/server` | 108 | 371 | -| `apps/shared` | 33 | 33 | -| `apps/tui` | 20 | 27 | - -17 hermetic e2e suites exist where there were none. `worker-e2e.ts` went from -red to green. No test was weakened, skipped, or deleted to reach any of it — -both auditors grep for `.skip(`, `.todo(`, `xit(` and `it.failing` and for new -tolerances. - -## What the whole-suite gate found afterwards - -Running all seventeen suites in one process — which no per-lane verification -does — surfaced four more, three of them real: - -- **A data-loss regression in the schema gate.** It cleared every persisted key - when the store carried no version stamp, and every store written before the - gate exists is unstamped, so the first boot after the upgrade wiped the - conversation of every existing user. An unstamped store is now adopted, not - cleared. Fixed in `9818bac2`. -- **A user-facing confidence score** (`80%`) on the world card, which row B-5 - forbids. Deleted. -- **A real brand leak.** `--muted-foreground` and `--popover` were consumed but - defined nowhere, so hardcoded fallbacks painted — the wrong colour entirely in - dark. They are now aliases of `--text-muted` and `--surface`, so all nine - palettes and both themes follow automatically. This had been hidden behind a - named waiver, which is why the audit flagged the waiver as suspicious. -- **A flaky-by-construction toast assertion.** E9.6 asserted that fast balance - work shows no toast, assuming the local double always answers well under - 300ms. On a loaded machine the round trip crosses it, the product correctly - toasts, and the suite called that a violation. It now reads the page's own - settle stamp and asserts the law in both directions, so it cannot flake. -- **A roaming mount flake, and the only one that was systemic.** - `browser.open()` returned at `document.readyState === "complete"`, which says - the document loaded, not that React rendered. Each suite then hand-rolled its - own mount wait with its own budget, and under load whichever suite happened to - be running failed on "the composer never mounted". Three different suites were - blamed across four runs; each passed in isolation, which is the signature of an - environmental wait rather than a defect. `open()` now waits for the shell's - `[data-flows]` manifest — proof React rendered and the registry is live — and - retries the whole navigation, because a page that lands mid-reload never mounts - however long it is given. It still throws after the last attempt: an app that - truly never mounts is a product failure and must stay one. - -Two of these — the score and the token leak — were latent product defects that -only a whole-set run reached, because an earlier failure in the same suite was -aborting before them. The a11y suite went from 4 of 16 sections reached to 20 of -20. - -## Where the whole-suite run ended - -Seventeen suites in one process, on a quiet machine, after every fix above: - -``` -PASS: apps/ui e2e — 17 suites, 184 checks, 68/68 checklist ids proven, 0 skipped. -``` - -Exit 0. Nothing skipped, nothing deferred, no hang. - -For comparison, the same command at the start of this remediation: 13 of 17 -suites and 56 of 68 ids — and before the runner was fixed it did not terminate -at all. - -Three of the four whole-set failures turned out to be defects in the tests -rather than the product, and each was fixed at its cause rather than by -widening a budget: - -| Symptom | Cause | Fix | -| --- | --- | --- | -| roaming "never mounted" | `open()` returned at `readyState`, before React mounted | wait for the `[data-flows]` manifest, retry the navigation | -| "surfaces menu never opened" | a synthetic click delivered before the handler attached is lost | click the trigger up to three times | -| "connector rows did not load" | the seed named no backend, so the app read OPFS while the rows sat in localStorage | stamp `persistenceBackend` in the seed | - -The first two moved between suites run to run, which is the signature of a lost -event rather than a slow one. None was fixed by raising a timeout. - -## Still open - -- **E3.5** now runs for the first time and fails by ~18ms against a 1000ms - budget. The measurement charges DevTools round trips to the product's budget; - the same Escape settles in 1-2ms at store altitude. Fix the measurement, not - the budget. -- **`@smthrs/ui` `MessageScrollerViewport`** hardcodes `tabIndex={0}`; upstream. -- **`worker-e2e.ts` seals 14 environment variables** where the harness seals 19. - Narrower is not wrong here, but it is worth closing. -- **E12.7** codesign / notarization — no signing configuration exists anywhere in - the repo. Correctly a human task, not a test. -- **Phase A** — the harness supports booting with every seam unconfigured to - prove honest 501s, and no suite declares it. diff --git a/apps/UPSTREAMS.md b/apps/UPSTREAMS.md deleted file mode 100644 index 32670e2b..00000000 --- a/apps/UPSTREAMS.md +++ /dev/null @@ -1,73 +0,0 @@ -# The seams this product runs on - -`smithers-mvp-web` (`apps/server`) is a proxy for most of what a user does. -Sign-in, balance, chat turns, and recommendations all resolve in **sibling -Cloudflare Workers that are not in this repository** — they live in -`~/flows/ui/workers/`, a separate checkout. Nothing in `apps/**` can deploy, -roll back, or even name a version of them. - -That is a real operational gap during an alpha: a user reports that sign-in -broke, and the first question — *what is deployed on identity right now?* — -had no answer here. This file is the answer, and the deploy script named below -is how you change one and leave a record. - -Verified 2026-08-18. - -## The inventory - -| Seam | Worker env var (`apps/server/wrangler.jsonc`) | Cloudflare Worker | Source | Custom domain | -| --- | --- | --- | --- | --- | -| Identity — GitHub OAuth, sessions, the allowlist, the jjhub cloud-token door | `IDENTITY_UPSTREAM_URL` | `smithers-cloud-identity` | `~/flows/ui/workers/identity` | `identity.smithers.sh` | -| Billing — balances, grants, the admin grant surface | `BILLING_UPSTREAM_URL` | `smithers-cloud-billing` | `~/flows/ui/workers/billing` | `billing.smithers.sh` | -| Recommendations — the first-run digest, the one ranked recommendation, dismissals | `RECO_UPSTREAM_URL` | `smithers-cloud-reco` | `~/flows/ui/workers/recommendations` | `reco.smithers.sh` | -| Chat — the metered turn upstream | `SMITHERS_CHAT_URL` | `smithers-cloud-chat` | `~/flows/ui/workers/chat` | `chat.smithers.sh` | -| Smithers Cloud (jjhub) — gateway provisioning and the relay | `SMITHERS_CLOUD_API_BASE_URL` | *(not a Worker)* | `~/plue` | `api.jjhub.tech` | - -Four more workers exist in that tree and this product does not call them -today: `connectors-catalog`, `cron`, `status`, `sync`, `webhooks`. - -## Deploying one - -```sh -cd ~/flows/ui -node workers/deploy.mjs --list # every deployable worker -node workers/deploy.mjs identity --dry-run # no credentials, nothing published -node workers/deploy.mjs identity # real deploy; writes a receipt -``` - -Receipts land in `workers//deploy-receipts/`, with `latest.json` naming -the git sha, the timestamp, and the Cloudflare version id — the same shape -`apps/server/deploy-receipts/` uses, so both halves of a deploy can be read the -same way. - -Each Worker's `name` and `routes` are its identity. Renaming one deploys a -fresh Worker with empty Durable Object storage and detaches its custom domain; -the deploy script never edits either. - -## Two things to know before you touch these - -**The `smithers.sh` hostnames are live, and this repo does not use them.** -`apps/server/wrangler.jsonc` still points identity and reco at -`smithers-cloud-identity.willcory10.workers.dev` and -`smithers-cloud-reco.willcory10.workers.dev`, because when wave 7 shipped, the -`smithers.sh` CNAMEs still pointed at dead Vercel records -(`apps/WAVE7-DEPLOY-RECEIPT.md` §1). That is no longer true: on 2026-08-18 -`identity.smithers.sh`, `reco.smithers.sh`, `billing.smithers.sh`, -`connectors.smithers.sh`, and `status.smithers.sh` all answer `/healthz` from -Cloudflare, and identity's and reco's custom domains return byte-identical -health payloads to their `workers.dev` twins — the same Worker, reached two -ways. - -So the alpha depends on a personal `workers.dev` subdomain for sign-in and -recommendations, and no longer has to. Repointing those two vars at the custom -domains is a two-line change to `apps/server/wrangler.jsonc` plus a deploy. It -is deliberately not made here: it changes production routing on the next -deploy, and that is the operator's call, not a side effect of writing this -file. GitHub OAuth callbacks are registered against the *product* origin, not -these, so they are unaffected. - -**The source tree is a working branch.** `~/flows/ui` was on -`wave5-billing-bridge` with uncommitted changes to the identity worker when -this was written. Commit or stash before deploying anything from it: a deploy -ships the working tree, and the receipt's git sha will not describe what -actually went out. diff --git a/apps/WAVE15-SINGLE-BACKEND-RECEIPT.md b/apps/WAVE15-SINGLE-BACKEND-RECEIPT.md deleted file mode 100644 index 74f01841..00000000 --- a/apps/WAVE15-SINGLE-BACKEND-RECEIPT.md +++ /dev/null @@ -1,125 +0,0 @@ -# Wave 15 — one backend: the browser chain on a metered Cerebras relay - -2026-08-19 · Worker `smithers-mvp-web` version `e84ad45e-0311-4eed-b326-dc0bc80aeec9` -· · deploy receipt -`apps/server/deploy-receipts/2026-08-19T22-45-46-498Z.json` - -**Bottom line:** the chat has one backend. The agent loop runs in the browser as -an Agent Chain, it spends its model on `POST /api/model/stream`, and that route -forwards to the same managed-inference upstream `/api/agent/turn` used — the -canary chat Worker, which owns the Cerebras key, authorizes the balance before -the provider call, and meters the usage durably onto the signed-in user's own -account. **No provider credential is bound on the product Worker.** The -`/debug.backend proxy | chain` switch is gone; the flow reports instead. - -## What was broken - -`/api/model/stream` forwarded to `api.anthropic.com` behind -`MODEL_RELAY_API_KEY`, a secret canary never had, so every chain turn answered -501 and the chat worked only on the server-side proxy backend -(`apps/ui/canary-repros/admin/26.1.md`, `26.6.md`). - -## The decision, and where it deviates from the brief - -The brief said to bind `CEREBRAS_API_KEY` on the product Worker. It also said to -reuse `/api/agent/turn`'s client, config, and metering rather than inventing a -second one. Those two pull apart, because the turn path does not talk to -Cerebras itself — it talks to the chat Worker, which does. - -Reusing that upstream was chosen, and no secret was bound. It is what the brief -asked for on the point that matters (a Cloudflare endpoint we own, proxying to -the Cerebras endpoint we use), and it is strictly better on the two things the -Wave 7 post-deploy correction was written about: - -- **Credential surface.** The browser-facing Worker holds no provider key. There - is no configuration of it that can leak one, because there is none to leak. -- **Metering.** The relay inherits the turn path's metering exactly: balance - authorized before the provider call, authoritative usage enqueued on the - durable queue, charge attributed to the vouched login. A relay that called - Cerebras directly would have needed billing's money-writing - `METERING_SERVICE_TOKEN` on the browser-facing Worker, and its own retry and - idempotency logic, to arrive somewhere worse. - -The relay mints the run id itself. Upstream derives the charge's idempotency key -from it, so a caller that could choose it could replay one receipt and take -every later call for free. - -## Gate order on the route (unchanged, now covered) - -1. Anonymous → `401`, signed-in-but-not-allowlisted → `403`, both decided before - any upstream byte is spent. -2. Per-login ceiling (`TurnRateLimiter`), same budget as the turn path. -3. Sealed-step law: a tool-bearing body is `400`, and nothing is forwarded. - -The ceiling's unit changed with the backend and was re-sized: it counts model -calls, not messages, because a chain turn authors a link per step. It was 120, -which a heavy hour of chat now reaches; it is 1000, which keeps the ten-times -headroom the guard has always claimed. A spent window is about a dollar at the -alpha's rate card. - -## Live verification - -Signed in as `codeplanesmithers` (allowlisted, admin) on -, sent "Reply with exactly: PONG-chain": - -| what | observed | -|---|---| -| the turn | streamed and completed; `PONG-chain` rendered in the transcript | -| transport | `["POST /api/model/stream"]` — and **zero** calls to `/api/agent/turn` | -| errors | no response >= 400, no console error | -| billing `chargeCount` | 1981 → **1983** (input + output token lines, rate card `2026-08-09.1`) | -| billing `totalUsd` | 543 → 543 | - -The balance does not move **by design**: interactive chat is complimentary -("metered at true supplier cost, on us" — the account's own -`freeAtZeroBalance`). The two new charge lines on `codeplanesmithers`'s own -account are the metering proof, and they are the trusted-caller attribution the -relay now carries. - -Screenshot: `/tmp/canary-chain-live.png`. Re-runnable: -`PROF=/tmp/canary-access-profile bun apps/ui/canary-repros/admin/26.1.ts`. - -## Gates - -- `apps/server`: `tsc --noEmit` clean, 386 tests pass. -- `apps/ui`: `tsc --noEmit` clean, 718 tests pass. -- `packages/model`: `tsc -b` clean, 104 tests pass. -- e2e `E3.13+E3.14` passes, including a whole browser chain turn driven through - the product's own wiring against the stub upstream. Four suites pass, eight - fail — see the gaps below for which failures this change caused and which it - inherited. - -## Honest gaps - -- **`/api/agent/turn` still exists.** The browser never calls it, but the - terminal client (`apps/tui`) and the native shell do, and removing the route - would break them. `apps/ui/src/mainview/native/WebAgent.ts` remains as that - seam's client — nothing under `src/mainview` composes it — and the e2e corpus - still drives it. Retiring the seam is a separate piece of work that has to - move the TUI first. -- **Five browser-driven e2e suites are red, and this change is why.** They drive - the real SPA, which now runs the chain, while their fixtures still script the - proxy's vocabulary: prose deltas the chain reads as a failed authoring - attempt, and `card` frames pushed from the upstream, which the chain has no - notion of at all. On the chain a card comes from a catalog call inside the - flow script — the model cannot push one — so this is a real change in what the - product does, not only in what the fixtures say. - - | suite | file | what it pins | - |---|---|---| - | E4.5-E4.9 | `cards-copy.e2e.ts` | settled/plan/blocked card copy | - | E4.1/E4.10/E4.11 | `cards-approvals.e2e.ts` | approval cards on a streamed turn | - | E13-E14 | `a11y-resilience.e2e.ts` | keyboard journey, dropped stream, retry | - | E2.4-E2.9 | `reco-actions.e2e.ts` | the agent's tool call opening the chooser | - | E3.9 | `turn-failure.e2e.ts` | retry re-POSTs a turn | - - Porting them means re-deciding what each fixture means on the chain's render - path, which is a piece of work in its own right. - `openClient({ backend: "chain" })` exists and E3.13 uses it; that is the - starting point, not the finish. - - Three other suites are red for reasons unrelated to this change (E9's World - close affordance, E11's `/keys.list`, E1's expired-session balance read) — - they were red on the concurrent lane's tree before this landed. -- **`/clear`'s memory sweep** moved onto `/api/model/stream` but is still a - plain model call rather than an authored flow (DESIGN.md §14). diff --git a/apps/server/BUILD.bazel b/apps/server/BUILD.bazel new file mode 100644 index 00000000..8634a485 --- /dev/null +++ b/apps/server/BUILD.bazel @@ -0,0 +1,11 @@ +load("@aspect_rules_ts//ts:defs.bzl", "ts_config") +load("@npm//:defs.bzl", "npm_link_all_packages") + +npm_link_all_packages(name = "node_modules") + +ts_config( + name = "tsconfig", + src = "tsconfig.json", + visibility = [":__subpackages__"], + deps = [":package.json"], +) diff --git a/apps/server/BUILD.ts b/apps/server/BUILD.ts deleted file mode 100644 index 95dac6d5..00000000 --- a/apps/server/BUILD.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * Targets for the Worker application: the typecheck and the unit suite. - * - * `pnpm run check` and `pnpm test` used to reach this package only through the - * recursive root scripts, which the target graph cannot plan. These targets are - * the same gates as declarations, so the pipeline runs them by label and a red - * suite re-keys on the sources it reads. - * - * The suite runs under Bun, which is what the app's own scripts use, so the - * runtime is the root Bun declaration and nothing here spells `bun` into an - * argv. - */ -import { Smithers } from "@smthrs/targets" -import { bunRuntime, packageManager } from "../../BUILD.ts" - -const cwd = "apps/server" - -/** The Worker sources and the operator scripts the suite covers. */ -const sources = [ - Smithers.glob("//apps/server/src/**/*.ts"), - Smithers.glob("//apps/server/scripts/**/*.ts") -] - -/** - * Checks the Worker against its own tsconfig. - * - * @since 0.1.0 - * @category build - */ -export const check = Smithers.Typecheck({ - packageManager, - srcs: sources, - deps: [], - tsconfig: Smithers.file("tsconfig.json"), - buildMode: false, - incremental: false, - cwd -}) - -/** - * The unit suite: everything under `src/` and `scripts/`, including the canary - * wiring checks. - * - * @since 0.1.0 - * @category test - */ -export const unitTests = Smithers.NodeTest({ - runtime: bunRuntime, - runner: Smithers.testSuite(["src", "scripts"]), - srcs: sources, - deps: [], - cwd -}) diff --git a/apps/server/DEPLOY.md b/apps/server/DEPLOY.md index 32b13939..c18ddf73 100644 --- a/apps/server/DEPLOY.md +++ b/apps/server/DEPLOY.md @@ -68,14 +68,6 @@ always for a manual `workflow_dispatch` run) it runs the dry-run path. Set the secret in the repo's Settings → Secrets and variables → Actions before cutting a tag that should actually publish. -## The seams this Worker proxies - -Sign-in, balance, chat turns, and recommendations resolve in sibling -Workers that live in a different repository (`~/flows/ui/workers/`). -Deploying this Worker does not deploy them, and a broken sign-in is more -often theirs than ours. `apps/UPSTREAMS.md` names each one, its source, its -hostname, and how to deploy it with a receipt. - ## Rollback Cloudflare Workers keep prior versions. To roll back to the version recorded @@ -92,76 +84,3 @@ targets the immediately-prior version; for a specific historical version, use Object state — `TURN_CANCELS` and `GATEWAY_SESSIONS` storage is unaffected either way, since it is keyed to the (unchanged) Worker identity, not to a version. - -### What the receipt records, and why every receipt on disk says `null` - -`scripts/deploy.ts` captures wrangler's stdout (`capture: true`) and pulls the -version id out of it with `/Current Version ID:\s*([0-9a-f-]{36})/i`. Wrangler -4.123.0 prints `Current Version ID: ` through `logger.log`, which is -`console.log`, which is stdout — so a **real** deploy does record the id. A -`--dry-run` returns at `--dry-run: exiting now.` before printing any id, which -is why every receipt in `deploy-receipts/dry-run/` carries -`"wranglerVersionId": null`. The mechanism is sound; it has simply never been -exercised by a credentialed run. - -Two residual risks remain, and `scripts/deploy.ts` does not guard either one -today: wrangler could move the id to stderr (only stdout is captured), or -rename the label between versions. Either turns a real deploy into a receipt -that says `null`, and rollback then has nothing to target. The rollback probe -below is what catches it — a real deploy whose receipt names no version fails -the probe. - -### Probe it: `scripts/canary/rollback-probe.ts` - -```sh -CLOUDFLARE_API_TOKEN= bun scripts/canary/rollback-probe.ts -``` - -It asserts three things about `smithers-mvp-web`: - -1. the newest receipt (`deploy-receipts/latest.json`, or `--receipt `) - names a wrangler version id, -2. that version is the one Cloudflare is actually serving - (`GET /accounts//workers/scripts/smithers-mvp-web/deployments`), -3. a prior version is still in Cloudflare's version list - (`GET .../versions`), so `wrangler rollback ` has a target. The probe - prints the exact rollback command for that version. - -Both response shapes were read back from the live account on 2026-08-18: -`/versions` answers `{ success, result: { items: [{ id, number, metadata: { -created_on }, annotations }] } }` newest first, and `/deployments` answers -`{ success, result: { deployments: [{ versions: [{ version_id, percentage }] }] } }` -newest first. Cloudflare lists 10 versions for `smithers-mvp-web`, and the -version serving 100% of traffic is `dffd4070-e5c6-4fd0-86b6-73ebedff5600` -(created 2026-08-13T06:21:59Z) — so a rollback target exists today even though -no receipt on disk names the deployed version. - -**"Reachable" means rollback-eligible, not fetchable.** A prior Worker version -has no public URL; nothing can HTTP it. The probe never claims otherwise. - -It skips (exit 0, `skip:` lines) when `CLOUDFLARE_API_TOKEN` is unset or no -receipt is on disk, and reports `INCONCLUSIVE` rather than `PASS` when it -verified nothing. It fails when a receipt exists but cannot support a -rollback. Receipts are gitignored and exist only on the machine that deployed, -so this belongs in the deploy workflow after a real deploy, not in a scheduled -canary that has no receipt to read. - -### The drill — do this once, by hand, and keep the receipt - -A rollback plan nobody has ever exercised is not a rollback plan. Rolling back -and forward swaps the live deployment, so it is a human drill and is -deliberately not automated. - -1. Deploy for real, so a receipt names a version: - `CLOUDFLARE_API_TOKEN=… CLOUDFLARE_ACCOUNT_ID=dd3525a4132493566aeb38de533c8827 pnpm --filter smithers-server run deploy`. - Record `deploy-receipts/latest.json` — call this version **N**. -2. Run `bun scripts/canary/rollback-probe.ts`. It must pass and must name the - prior version, **N-1**. -3. `bun x wrangler@4.123.0 rollback --message "CN-24 drill"` from - `apps/server`. -4. Confirm `https://canary.smithers.sh` serves the older build, and that - `bun x wrangler@4.123.0 deployments list` shows N-1 at 100%. -5. Roll forward: `bun x wrangler@4.123.0 rollback --message "CN-24 drill, forward"`. -6. Confirm the canary serves N again and re-run the probe. -7. Write the drill up in an `apps/WAVE*-RECEIPT.md` note with both version ids - and the timestamps, so the next person can see it was really done. diff --git a/apps/server/INVITES.md b/apps/server/INVITES.md index d6894f68..47f78cfa 100644 --- a/apps/server/INVITES.md +++ b/apps/server/INVITES.md @@ -49,98 +49,3 @@ Cloudflare secret on that deployment, not a var here — get it from wherever that secret is held, never from this repo. `--action remove` revokes instead of adds. `--requester ` sets the audit attribution (defaults to `seed-allowlist-script`). Full flag list: `node scripts/seed-allowlist.mjs --help`. - -## Verifying the seed against a real deployment (CN-23) - -The unit tests prove both doors work against fakes. They cannot prove the -alpha's allowlist is actually seeded, or that an invite issued to the live -identity worker admits anybody. `scripts/canary/invite-probe.ts` is the live -half: - -```sh -# Read-only. Safe to schedule: it writes nothing. -IDENTITY_SERVICE_TOKEN= \ -CANARY_ALLOWLIST_LOGINS=alice,bob,carol \ -bun scripts/canary/invite-probe.ts -``` - -It reads each roster login back from the identity worker and asserts it is -admitted. A missing credential or an empty roster skips that check, and a run -that verified nothing prints `CN-23 ASSERTED NOTHING` and exits 1 — never -`PASS`, and never a green exit code. A probe that asserted nothing must not -read as a probe that passed, which is the whole point of scheduling it. - -Pass `--allow-inconclusive` to exit 0 on a run that verified nothing. It is -there so a contributor without production credentials is not blocked by a -failure they cannot act on. CI refuses the flag: with `CI=true` the probe exits -1 regardless, so an unconfigured pipeline step can never report green. -`IDENTITY_UPSTREAM_URL` defaults to the canary value in `wrangler.jsonc`. Keep -the roster in a repository **variable**, not a secret — GitHub logins are -public and a variable is diffable. - -`IDENTITY_SERVICE_TOKEN` is identity's service token. It is the credential the -read side needs, and the sections above name only the admin token, so set both -when running the full probe. The probe sends `IDENTITY_ADMIN_TOKEN` on the -read as well when it is set, so it works whichever of the two the read-back -door gates on. - -### The read-back door - -The probe reads `GET /api/identity/allowlist/`, expecting -`{ login, allowlisted }`. That route is not implemented in this repository, so -what the canary identity worker answers, unauthenticated, was checked directly -on 2026-08-18: - -| Request | Answer | -| --- | --- | -| `GET /api/identity/allowlist/octocat` | `401 {"error":"Unauthorized service"}` | -| `GET /api/identity/admin/audit` | `404 {"error":"Not found"}` | - -The worker answers 401 for a route it implements but gates, and 404 for one it -does not. So the per-login read-back exists and gates on a **service** token — -`IDENTITY_SERVICE_TOKEN` — and this deployment exposes no admin audit -read-back. The probe treats that 404 as a skip, not a failure: the invite is -still attributed where it is written (the upstream refuses an unattributed -call), it simply cannot be read back here. - -If the door ever moves, pass `--read-path '/some/other/{login}'`. A 404 on the -read-back is reported as **unreadable**, never as "not allowlisted" — a missing -door and an absent login are different facts, and conflating them would let the -probe pass a completely unseeded allowlist. - -### The admission half writes, so it is opt-in - -Admitting a new user mutates production. The default run therefore does not do -it; it prints a `skip:` line saying the admission went unverified. Pass -`--admit-probe-login` to run the round trip: - -```sh -IDENTITY_SERVICE_TOKEN= IDENTITY_ADMIN_TOKEN= \ -bun scripts/canary/invite-probe.ts --admit-probe-login -``` - -It reads `canary-invite-probe` (absent), invites it, reads it back (**this is -the CN-23 assertion**), checks the audit log names the login and the requester -`canary-invite-probe`, then withdraws it and reads it back absent. The -withdrawal is in a `finally`, so it runs even after a failed check. - -Two properties make this safe to run after a deploy: - -- The probe identity is a **fixed** login, not a timestamped one. A run that - dies between the invite and the withdrawal leaves that one known row behind, - and the next run reports it (`an earlier run did not reach its cleanup`) and - removes it. Timestamped logins would accumulate silently. -- `canary-invite-probe` is not a real GitHub account, so admitting it grants - nobody a session: the OAuth callback can only ever mint a session for a login - GitHub issues. - -Run it after a deploy, not on a schedule. - -### What stays manual - -Proving that a real human, invited today, can sign in and reach a working chat -needs a real GitHub account and a browser. That is the sign-in journey -`apps/ui/scripts/live-signed-in-check.ts` drives (CN-9), and it is a human -drill, not an automated probe: seed the invitee with the one-command seed -above, have them sign in, and confirm they reach the chat rather than the -waiting-state reply. diff --git a/apps/server/package.json b/apps/server/package.json index 3f498a0b..79a99136 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -5,23 +5,16 @@ "type": "module", "description": "Smithers closed-alpha Cloudflare Worker: same-origin agent API, gateway relay, and host for the smithers-ui build", "scripts": { - "dev": "pnpm --dir ../ui run web", - "serve:local": "pnpm --dir ../ui run web", - "deploy:dry-run": "bun scripts/deploy.ts --dry-run", + "dev": "bun x wrangler dev", + "serve:local": "bun x wrangler dev", + "deploy:dry-run": "bun x wrangler deploy --dry-run", "deploy": "bun scripts/deploy.ts", "deploy:dry": "bun scripts/deploy.ts --dry-run", - "check": "tsc --noEmit", "typecheck": "tsc --noEmit", - "test": "bun test src scripts", - "seed:allowlist": "node scripts/seed-allowlist.mjs", - "canary:build": "bun scripts/canary/build-probe.ts", - "canary:workers": "bun scripts/canary/workers-health.ts", - "canary:uptime": "bun scripts/canary/uptime-probe.ts", - "canary:invite": "bun scripts/canary/invite-probe.ts", - "canary:rollback": "bun scripts/canary/rollback-probe.ts" + "test": "bun test src", + "seed:allowlist": "node scripts/seed-allowlist.mjs" }, "dependencies": { - "@tanstack/react-start": "1.168.48", "smithers-shared": "workspace:*" }, "devDependencies": { diff --git a/apps/server/scripts/canary/BuildStamp.test.ts b/apps/server/scripts/canary/BuildStamp.test.ts deleted file mode 100644 index 686499fe..00000000 --- a/apps/server/scripts/canary/BuildStamp.test.ts +++ /dev/null @@ -1,444 +0,0 @@ -import { readFileSync } from "node:fs"; -import { fileURLToPath } from "node:url"; -import { describe, expect, test } from "bun:test"; -import { - BUILD_STAMP_META, - BUILD_STAMP_PATH, - buildShaFromHtml, - buildShaVerdict, - expectedShaFromReceipt, - flagValue, - hasFlag, - HTML_AGREEMENT_COVERAGE, - htmlAgreementVerdict, - parseBuildStamp, - resolveOrigin, -} from "./BuildStamp.ts"; -import type { BuildStamp } from "./BuildStamp.ts"; - -/* - * CN-1's whole value is that it fails when the deployment is stale, so these - * tests spend most of their weight on the failure shapes. A probe that reports - * "pass" for a bundle it could not read would be worse than no probe: it would - * retire the checklist row that is currently the only thing telling the truth. - * - * The fetches live in build-probe.ts. Everything here is a fixture. - */ - -const FRESH = "1dd856f1a2b3c4d5e6f708192a3b4c5d6e7f8091"; -const STALE = "ceb784b6a2b3c4d5e6f708192a3b4c5d6e7f8091"; - -const stampOf = (gitSha: string): BuildStamp => ({ - worker: "smithers-mvp-web", - gitSha, - builtAt: "2026-08-18T00:00:00.000Z", -}); - -describe("parseBuildStamp", () => { - test("reads the stamp the vite build emits", () => { - const body = `${JSON.stringify({ worker: "smithers-mvp-web", gitSha: FRESH, builtAt: "2026-08-18T00:00:00.000Z" })}\n`; - expect(parseBuildStamp({ status: 200, body })).toEqual(stampOf(FRESH)); - }); - - test("a 404 names the real cause: the bundle predates the stamp", () => { - const verdict = parseBuildStamp({ status: 404, body: "Not found" }); - expect(typeof verdict).toBe("string"); - expect(verdict).toContain(BUILD_STAMP_PATH); - expect(verdict).toContain("404"); - expect(verdict).toContain("predates the stamp"); - }); - - test("any other HTTP status is reported, never treated as a stamp", () => { - expect(parseBuildStamp({ status: 503, body: "" })).toBe( - `GET ${BUILD_STAMP_PATH} answered HTTP 503`, - ); - }); - - /* - * The SPA fallback answers index.html for unknown paths on some asset - * configurations, so HTTP 200 alone proves nothing. - */ - test("a 200 that is HTML, not JSON, fails instead of passing on the status", () => { - const verdict = parseBuildStamp({ status: 200, body: "Smithers" }); - expect(verdict).toContain("not JSON"); - }); - - test("a 200 whose JSON is not a stamp fails", () => { - expect(parseBuildStamp({ status: 200, body: '{"nope":1}' })).toBe( - `GET ${BUILD_STAMP_PATH} answered 200 with a body that is not a build stamp`, - ); - }); - - test("a stamp whose sha is not a sha fails", () => { - const verdict = parseBuildStamp({ - status: 200, - body: JSON.stringify({ gitSha: "HEAD~3", builtAt: "2026-08-18T00:00:00.000Z" }), - }); - expect(verdict).toContain("is not a git sha"); - }); - - /* - * A build outside a git checkout stamps "unknown". Accepting it would make - * every later comparison vacuous, so it is a failure at the parse. - */ - test('a stamp of "unknown" fails rather than verifying nothing', () => { - const verdict = parseBuildStamp({ - status: 200, - body: JSON.stringify({ gitSha: "unknown", builtAt: "2026-08-18T00:00:00.000Z" }), - }); - expect(verdict).toContain("built outside a git checkout"); - }); - - test("a short sha is a sha", () => { - expect(parseBuildStamp({ status: 200, body: JSON.stringify({ gitSha: "ceb784b", builtAt: "x" }) })).toEqual({ - worker: "unknown", - gitSha: "ceb784b", - builtAt: "x", - }); - }); -}); - -describe("buildShaFromHtml", () => { - test("reads the meta tag vite injects", () => { - const html = ``; - expect(buildShaFromHtml(html)).toBe(FRESH); - }); - - test("reads it however it is quoted or self-closed", () => { - expect(buildShaFromHtml(``)).toBe(FRESH); - }); - - test("HTML with no stamp answers null, not an empty sha", () => { - expect(buildShaFromHtml("Smithers")).toBeNull(); - }); - - test("a different meta tag is not mistaken for the stamp", () => { - expect(buildShaFromHtml('')).toBeNull(); - }); -}); - -describe("buildShaVerdict", () => { - test("a matching sha passes and says what is deployed", () => { - const verdict = buildShaVerdict(stampOf(FRESH), FRESH, FRESH, 0, 0); - expect(verdict.ok).toBe(true); - expect(verdict.detail).toContain(FRESH); - expect(verdict.detail).toContain("2026-08-18T00:00:00.000Z"); - }); - - /* The live defect this row exists to catch. */ - test("a stale deployment fails, naming both shas and the distance", () => { - const verdict = buildShaVerdict(stampOf(STALE), FRESH, STALE, 13, 0); - expect(verdict.ok).toBe(false); - expect(verdict.detail).toContain(STALE); - expect(verdict.detail).toContain(FRESH); - expect(verdict.detail).toContain("13 commit(s) behind"); - }); - - /* - * A half-published deploy is checked first: when the HTML and the assets - * disagree there is no single sha to compare with anything. - */ - test("HTML and asset disagreeing is a partial deploy, reported before any other rule", () => { - const verdict = buildShaVerdict(stampOf(STALE), STALE, FRESH, 0, 0); - expect(verdict.ok).toBe(false); - expect(verdict.detail).toContain("different builds"); - }); - - test("no expected sha and no drift measurement still passes on a readable stamp", () => { - const verdict = buildShaVerdict(stampOf(FRESH), undefined, null, undefined, 0); - expect(verdict.ok).toBe(true); - expect(verdict.detail).not.toContain("behind origin/main"); - }); - - test("drift alone fails once it exceeds the budget", () => { - expect(buildShaVerdict(stampOf(STALE), undefined, null, 3, 2).ok).toBe(false); - expect(buildShaVerdict(stampOf(STALE), undefined, null, 2, 2).ok).toBe(true); - }); -}); - -/* - * The defect this suite now pins: a served index.html with no meta tag used to - * grade "ok", so a deployment serving pre-stamp HTML beside a fresh - * /__build.json — the old app, with fresh assets behind it — exited 0. The - * asset stamp and the meta tag come out of one vite build, so once the stamp - * parses, unstamped HTML is evidence, not an absent input. - */ -describe("htmlAgreementVerdict", () => { - test("HTML and asset naming the same sha is the only pass", () => { - const verdict = htmlAgreementVerdict(stampOf(FRESH), { status: 200, metaSha: FRESH }, false); - expect(verdict.status).toBe("ok"); - expect(verdict.detail).toContain(FRESH); - }); - - /* CN-1's half-published deploy, in the direction the old code passed. */ - test("readable HTML with no stamp beside a parsed asset stamp FAILS", () => { - const verdict = htmlAgreementVerdict(stampOf(FRESH), { status: 200, metaSha: null }, false); - expect(verdict.status).toBe("FAIL"); - expect(verdict.detail).toContain(BUILD_STAMP_META); - expect(verdict.detail).toContain("predates the stamp"); - expect(verdict.detail).toContain("half-published"); - }); - - test("unstamped HTML is never graded ok, whatever the flag says", () => { - for (const allow of [true, false]) { - expect(htmlAgreementVerdict(stampOf(FRESH), { status: 200, metaSha: null }, allow).status).not.toBe("ok"); - } - }); - - /* - * The one deploy that introduces the stamp is watched by a human, who can - * park the row. Parking it is a skip, so the run still reports that the - * comparison did not happen. - */ - test("--allow-unstamped-html downgrades that failure to a skip, not to a pass", () => { - const verdict = htmlAgreementVerdict(stampOf(FRESH), { status: 200, metaSha: null }, true); - expect(verdict.status).toBe("skip"); - expect(verdict.detail).toContain("--allow-unstamped-html"); - }); - - test("the other direction still fails: HTML and asset naming different shas", () => { - const verdict = htmlAgreementVerdict(stampOf(FRESH), { status: 200, metaSha: STALE }, false); - expect(verdict.status).toBe("FAIL"); - expect(verdict.detail).toContain("different builds"); - }); - - /* - * An unreadable root is a comparison that never ran. The uptime probe's spa - * check is what grades GET / for a 200. - */ - test("HTML that could not be read is a skip, not a pass and not a failure", () => { - const verdict = htmlAgreementVerdict(stampOf(FRESH), { status: 503, metaSha: null }, false); - expect(verdict.status).toBe("skip"); - expect(verdict.detail).toContain("503"); - }); - - test("a 2xx that is not 200 is still read", () => { - expect(htmlAgreementVerdict(stampOf(FRESH), { status: 203, metaSha: FRESH }, false).status).toBe("ok"); - }); - - /* - * The lane's original claim named one direction. The printed coverage note - * must keep naming both, and must keep admitting what it does not fetch. - */ - test("the coverage note states both directions, the no-stamp case, and the gap", () => { - expect(HTML_AGREEMENT_COVERAGE).toContain("either direction"); - expect(HTML_AGREEMENT_COVERAGE).toContain("no stamp at all"); - expect(HTML_AGREEMENT_COVERAGE).toContain("does not fetch the hashed chunks"); - }); -}); - -describe("expectedShaFromReceipt", () => { - test("a real deploy receipt states its sha", () => { - const claim = expectedShaFromReceipt( - JSON.stringify({ worker: "smithers-mvp-web", dryRun: false, gitSha: FRESH, gitDirty: false }), - ); - expect(claim).toEqual({ kind: "sha", gitSha: FRESH, gitDirty: false }); - }); - - test("a dirty-tree deploy is recorded, not hidden", () => { - expect(expectedShaFromReceipt(JSON.stringify({ dryRun: false, gitSha: FRESH, gitDirty: true }))).toEqual({ - kind: "sha", - gitSha: FRESH, - gitDirty: true, - }); - }); - - /* - * Every receipt in deploy-receipts/dry-run/ has dryRun: true and a null - * wranglerVersionId. Treating one as the expected sha would grade the - * deployment against a build that was never published. - */ - test("a dry-run receipt claims nothing about the deployment", () => { - const claim = expectedShaFromReceipt(JSON.stringify({ dryRun: true, gitSha: FRESH })); - expect(claim.kind).toBe("none"); - expect(claim.kind === "none" && claim.reason).toContain("dry run"); - }); - - test("a receipt with no sha claims nothing", () => { - expect(expectedShaFromReceipt(JSON.stringify({ dryRun: false })).kind).toBe("none"); - }); - - test("a receipt that is not JSON claims nothing", () => { - expect(expectedShaFromReceipt("").kind).toBe("none"); - }); -}); - -describe("argument resolution", () => { - test("the positional origin wins, then $CANARY_URL, then the canary", () => { - expect(resolveOrigin(["https://staging.test"], { CANARY_URL: "https://env.test" })).toBe("https://staging.test"); - expect(resolveOrigin([], { CANARY_URL: "https://env.test" })).toBe("https://env.test"); - expect(resolveOrigin([], {})).toBe("https://canary.smithers.sh"); - }); - - test("a leading flag is not mistaken for the origin", () => { - expect(resolveOrigin(["--sha", "abc1234"], {})).toBe("https://canary.smithers.sh"); - }); - - test("a trailing slash is dropped so paths concatenate cleanly", () => { - expect(resolveOrigin(["https://canary.smithers.sh/"], {})).toBe("https://canary.smithers.sh"); - }); - - test("a flag reads its value", () => { - expect(flagValue(["--sha", "abc1234", "--max-drift", "3"], "--max-drift")).toBe("3"); - }); - - /* - * The dangerous case: a flag with no value would otherwise swallow the next - * flag and grade the deployment against a string that is not a sha. - */ - test("a flag with no value reads as absent, never as the next flag", () => { - expect(flagValue(["--sha", "--max-drift", "3"], "--sha")).toBeUndefined(); - expect(flagValue(["--sha"], "--sha")).toBeUndefined(); - expect(flagValue([], "--sha")).toBeUndefined(); - }); - - test("a boolean flag is present only when it is passed", () => { - expect(hasFlag(["--allow-unstamped-html"], "--allow-unstamped-html")).toBe(true); - expect(hasFlag(["--sha", "abc1234"], "--allow-unstamped-html")).toBe(false); - }); - - /* - * --sha must not swallow the boolean flag that follows it, or the probe - * would grade the deployment against the string "--allow-unstamped-html". - */ - test("the boolean flag is not eaten by a preceding value flag", () => { - expect(flagValue(["--sha", "--allow-unstamped-html"], "--sha")).toBeUndefined(); - expect(hasFlag(["--sha", "--allow-unstamped-html"], "--allow-unstamped-html")).toBe(true); - }); -}); - -/* - * The verdicts are pure and tested above, but the exit code is decided in - * build-probe.ts. This holds the shell to the verdict: the old code inlined a - * `metaSha === null || metaSha === stamp.gitSha` pass, and that is the exact - * line that let a half-published deploy exit 0. - */ -describe("the probe grades through the verdict", () => { - const probe = readFileSync(fileURLToPath(new URL("./build-probe.ts", import.meta.url)), "utf8"); - - test("build-probe.ts calls htmlAgreementVerdict", () => { - expect(probe).toContain("htmlAgreementVerdict(stamp, { status: htmlResponse.status, metaSha }, allowUnstampedHtml)"); - }); - - test("build-probe.ts no longer passes a null metaSha as an agreement", () => { - expect(probe).not.toContain("metaSha === null ||"); - expect(probe).not.toContain("stands alone"); - }); - - test("build-probe.ts prints the coverage note", () => { - expect(probe).toContain("HTML_AGREEMENT_COVERAGE"); - }); -}); - -/* - * The end-to-end pin. Source greps above can be satisfied by a probe that still - * exits 0, and the exit code is the only thing the canary schedule reads. These - * tests serve a bundle over real HTTP and run the real probe against it, so the - * thing being asserted is the process exit code a half-published deploy earns. - * - * The stripped fixture is the auditor's reproduction in miniature: fresh - * /__build.json, index.html with no meta tag. Against the pre-fix probe it - * printed "the asset stamp stands alone" and exited 0. - */ -describe("the probe's exit code moves with the deployment", () => { - const SERVED_SHA = "a6cab0684a3f5d873dc23cc5b63784e93ffcdb50"; - const stampBody = `${JSON.stringify({ worker: "smithers-mvp-web", gitSha: SERVED_SHA, builtAt: "2026-08-19T03:16:28.120Z" })}\n`; - const probePath = fileURLToPath(new URL("./build-probe.ts", import.meta.url)); - - /** Serves one index.html and one /__build.json on an ephemeral port. */ - const serve = (html: string) => - Bun.serve({ - port: 0, - fetch: (request) => { - const path = new URL(request.url).pathname; - if (path === BUILD_STAMP_PATH) { - return new Response(stampBody, { headers: { "content-type": "application/json" } }); - } - if (path === "/") return new Response(html, { headers: { "content-type": "text/html" } }); - return new Response("Not found", { status: 404 }); - }, - }); - - const runProbe = async (html: string, extraArgs: ReadonlyArray = []) => { - const server = serve(html); - try { - const proc = Bun.spawn(["bun", probePath, server.url.origin, "--sha", SERVED_SHA, ...extraArgs], { - stdout: "pipe", - stderr: "pipe", - }); - const stdout = await new Response(proc.stdout).text(); - return { exitCode: await proc.exited, stdout }; - } finally { - server.stop(true); - } - }; - - const STAMPED_HTML = `Smithers
`; - const UNSTAMPED_HTML = `Smithers
`; - - test("a correctly stamped bundle exits 0", async () => { - const result = await runProbe(STAMPED_HTML); - expect(result.stdout).toContain("CN-1 PASS"); - expect(result.exitCode).toBe(0); - }); - - /* The defect: this exited 0 and printed "stands alone" before the fix. */ - test("pre-stamp HTML beside a fresh build stamp exits 1", async () => { - const result = await runProbe(UNSTAMPED_HTML); - expect(result.stdout).toContain("FAIL: the served HTML and the build stamp are from the same build"); - expect(result.stdout).toContain("CN-1 FAILED"); - expect(result.stdout).not.toContain("stands alone"); - expect(result.exitCode).toBe(1); - }); - - test("HTML naming a different sha exits 1", async () => { - const stale = ``; - const result = await runProbe(stale); - expect(result.stdout).toContain("different builds"); - expect(result.exitCode).toBe(1); - }); - - /* - * The escape hatch is for the operator watching the stamp's first deploy - * land. It parks the row; the pass line must then say the row was not - * graded, or the run reads as a verification that never happened. - */ - test("--allow-unstamped-html parks the row and the pass line says so", async () => { - const result = await runProbe(UNSTAMPED_HTML, ["--allow-unstamped-html"]); - expect(result.stdout).toContain("skip: the served HTML and the build stamp are from the same build"); - expect(result.stdout).toContain("CN-1 PASS"); - expect(result.stdout).toContain("check(s) not graded: the served HTML and the build stamp are from the same build"); - expect(result.exitCode).toBe(0); - }); - - test("the probe prints what the HTML-vs-asset row does not cover", async () => { - const result = await runProbe(STAMPED_HTML); - expect(result.stdout).toContain("does not fetch the hashed chunks"); - }); -}); - -/* - * The producer of the stamp is apps/ui/vite.config.ts and the reader is this - * directory; apps/ui does not depend on apps/server, so the two constants are - * spelled twice. This holds them equal: renaming the meta tag or the asset in - * the vite plugin reds here instead of silently retiring the probe. - */ -describe("the probe reads what the build writes", () => { - const viteConfig = readFileSync( - fileURLToPath(new URL("../../../ui/vite.config.ts", import.meta.url)), - "utf8", - ); - - test("the vite plugin emits the asset this probe fetches", () => { - expect(viteConfig).toContain(`const BUILD_STAMP_ASSET = "${BUILD_STAMP_PATH.slice(1)}";`); - }); - - test("the vite plugin injects the meta tag this probe reads", () => { - expect(viteConfig).toContain(`const BUILD_STAMP_META = "${BUILD_STAMP_META}";`); - }); - - test("the plugin is wired into the build", () => { - expect(viteConfig).toContain("buildStamp()"); - expect(viteConfig).toContain('apply: "build"'); - }); -}); diff --git a/apps/server/scripts/canary/BuildStamp.ts b/apps/server/scripts/canary/BuildStamp.ts deleted file mode 100644 index e67b7e85..00000000 --- a/apps/server/scripts/canary/BuildStamp.ts +++ /dev/null @@ -1,287 +0,0 @@ -/* - * CN-1: is the deployed bundle the git sha the deploy receipt claims? - * - * The live canary served a build 13 commits old and nothing detected it, - * because the deployment could not state what it was. `apps/ui/vite.config.ts` - * now stamps the sha into the SPA bundle at build time, twice: a - * `__build.json` asset and a `` tag on the - * HTML. Both travel inside the artifact, so a stale bundle serves a stale - * stamp. Nothing is computed at request time, so no deployment can report a - * sha it is not serving. - * - * This module is the pure half of the probe: it turns a fetched status and - * body into a verdict. `build-probe.ts` beside it does the fetching, the - * printing, and the exit code. Everything here is a total function of its - * arguments so the verdicts are tested without a deployment. - */ - -/** - * The asset `vite build` emits into the bundle. Served straight from the - * Cloudflare assets layer: `wrangler.jsonc` runs the Worker first only for - * `/api/*`, `/v1/*` and `/workflows/*`, so this path needs no Worker route and - * no credential to read. - */ -export const BUILD_STAMP_PATH = "/__build.json"; - -/** The corroborating meta tag on the served HTML. */ -export const BUILD_STAMP_META = "smithers-build-sha"; - -/** What a deployment says about itself. Mirrors the vite plugin's emit. */ -export interface BuildStamp { - readonly worker: string; - readonly gitSha: string; - readonly builtAt: string; -} - -/** A fetched response reduced to what the verdicts need. */ -export interface FetchedStamp { - readonly status: number; - readonly body: string; -} - -const SHA_PATTERN = /^(?:[0-9a-f]{7,40}|unknown)$/i; - -/** - * The stamp, or a sentence naming why there is none. A missing stamp is never - * silently tolerated: a deployment that cannot say what it is has failed CN-1 - * exactly as loudly as one that says the wrong thing. - */ -export const parseBuildStamp = (fetched: FetchedStamp): BuildStamp | string => { - if (fetched.status === 404) { - return `GET ${BUILD_STAMP_PATH} answered 404: this deployment's bundle carries no build stamp, so it predates the stamp and cannot state what it is`; - } - if (fetched.status !== 200) { - return `GET ${BUILD_STAMP_PATH} answered HTTP ${fetched.status}`; - } - let parsed: unknown; - try { - parsed = JSON.parse(fetched.body); - } catch { - /* - * The SPA fallback answers HTML for an unknown path on some asset - * configurations, so a 200 is not by itself evidence of a stamp. - */ - return `GET ${BUILD_STAMP_PATH} answered 200 with a body that is not JSON: ${fetched.body.replace(/\s+/g, " ").trim().slice(0, 80)}`; - } - const stamp = parsed as { gitSha?: unknown; builtAt?: unknown; worker?: unknown }; - if (typeof stamp.gitSha !== "string" || typeof stamp.builtAt !== "string") { - return `GET ${BUILD_STAMP_PATH} answered 200 with a body that is not a build stamp`; - } - if (!SHA_PATTERN.test(stamp.gitSha)) { - return `the deployment's build stamp names "${stamp.gitSha}", which is not a git sha`; - } - if (stamp.gitSha.toLowerCase() === "unknown") { - return 'the deployment\'s build stamp says "unknown": it was built outside a git checkout, so nothing can be verified against it'; - } - return { - worker: typeof stamp.worker === "string" ? stamp.worker : "unknown", - gitSha: stamp.gitSha, - builtAt: stamp.builtAt, - }; -}; - -/** - * The sha the served HTML claims, or null when the HTML carries no stamp. - * The tag is located by its name attribute and then read for its content, so - * attribute order and quoting style do not change the answer. - */ -export const buildShaFromHtml = (html: string): string | null => { - const tag = new RegExp(`]*name=["']${BUILD_STAMP_META}["'][^>]*>`, "i").exec(html)?.[0]; - if (tag === undefined) return null; - return /content=["']([^"']*)["']/i.exec(tag)?.[1] ?? null; -}; - -export interface Verdict { - readonly ok: boolean; - readonly detail: string; -} - -/** - * What the HTML-vs-asset check does and does not cover, stated once and - * printed by the probe so a green line cannot be read as a broader claim than - * it is. - * - * It compares two artifacts of one build: the served `index.html` and the - * served `/__build.json`. It fails in both directions of disagreement: HTML - * newer than the assets, and assets newer than the HTML, including the case - * where the HTML carries no stamp at all. It does not fetch the hashed - * chunks `index.html` names, so a deploy that published `index.html` and - * `/__build.json` but not the chunks they reference is out of scope. - */ -export const HTML_AGREEMENT_COVERAGE = `compares the served index.html with the served ${BUILD_STAMP_PATH} and fails either direction of disagreement, including HTML that carries no stamp at all; it does not fetch the hashed chunks index.html names, so a deploy that published index.html and ${BUILD_STAMP_PATH} but not the chunks they reference is out of scope`; - -/** A check that can also decline to grade, because its input was unreadable. */ -export interface Graded { - readonly status: "ok" | "FAIL" | "skip"; - readonly detail: string; -} - -/** The served HTML, reduced to what the agreement verdict needs. */ -export interface FetchedHtml { - readonly status: number; - readonly metaSha: string | null; -} - -/** - * Are the served HTML and the served build stamp from the same build? - * - * One vite build emits both halves: `transformIndexHtml` injects the meta tag - * and `generateBundle` emits the asset. So a parsed stamp proves the build - * that produced it also stamped its own `index.html`. Served HTML with no meta - * tag therefore came from a different, older, pre-stamp build. That is a - * half-published deploy in the direction that matters most, because - * `index.html` names the hashed chunks the browser actually runs, and it is - * reported as a failure. - * - * The transition is not a hole. Before the first stamped deploy the HTML - * legitimately has no meta tag, but that bundle also carries no - * `/__build.json`: the stamp fetch answers 404 and the probe fails earlier, - * naming the real cause. This verdict is only reached once a stamp parsed, and - * no ordering of a single atomic deploy leaves a fresh stamp beside pre-stamp - * HTML. `allowUnstampedHtml` exists for the one operator who is watching that - * deploy land and wants the row parked rather than red; it downgrades the - * failure to a skip and never to a pass. - * - * An unreadable root is a skip, not a pass and not a failure of this claim: - * the comparison was never made. The uptime probe's `spa` check is what grades - * `GET /` for a 200. - */ -export const htmlAgreementVerdict = ( - stamp: BuildStamp, - html: FetchedHtml, - allowUnstampedHtml: boolean, -): Graded => { - if (html.status < 200 || html.status > 299) { - return { - status: "skip", - detail: `GET / answered HTTP ${html.status}, so the served HTML could not be compared with ${BUILD_STAMP_PATH} (the uptime probe grades root availability)`, - }; - } - if (html.metaSha === null) { - const cause = `the served HTML carries no tag, but ${BUILD_STAMP_PATH} names ${stamp.gitSha}: one build emits both, so the HTML predates the stamp while the assets do not`; - if (allowUnstampedHtml) { - return { status: "skip", detail: `${cause}. Graded as unverified because --allow-unstamped-html was passed` }; - } - return { status: "FAIL", detail: `${cause}. That is a half-published deploy, and index.html is the half that names the chunks the browser runs` }; - } - if (html.metaSha !== stamp.gitSha) { - return { - status: "FAIL", - detail: `the served HTML claims ${html.metaSha} but ${BUILD_STAMP_PATH} claims ${stamp.gitSha}: the HTML and the assets are from different builds`, - }; - } - return { - status: "ok", - detail: `the HTML and ${BUILD_STAMP_PATH} both name ${stamp.gitSha}`, - }; -}; - -/** - * Does the deployment serve the sha it is supposed to? - * - * The checks run in this order because an earlier failure makes the later - * comparisons meaningless: - * - * 1. HTML and asset disagreeing means a half-published deploy. There is no - * single sha for the later checks to compare. `htmlAgreementVerdict` - * above grades that comparison on its own row, including the case this - * rule cannot see, where the HTML carries no stamp at all. - * 2. The receipt is the claim CN-1 exists to test. - * 3. Drift from origin/main catches the deploy nobody ran, which no receipt - * records because no receipt was written. - * - * `expectedSha` and `commitsBehind` are optional because a scheduled probe - * often has neither a receipt nor a git checkout. An absent input is reported - * as a skipped check by the caller, never as a pass. - */ -export const buildShaVerdict = ( - stamp: BuildStamp, - expectedSha: string | undefined, - metaSha: string | null, - commitsBehind: number | undefined, - maxDrift: number, -): Verdict => { - if (metaSha !== null && metaSha !== stamp.gitSha) { - return { - ok: false, - detail: `the served HTML claims ${metaSha} but ${BUILD_STAMP_PATH} claims ${stamp.gitSha}: the HTML and the assets are from different builds`, - }; - } - if (expectedSha !== undefined && expectedSha !== stamp.gitSha) { - return { - ok: false, - detail: `the deployment serves ${stamp.gitSha}, the receipt claims ${expectedSha}${ - commitsBehind === undefined ? "" : ` (${commitsBehind} commit(s) behind origin/main)` - }`, - }; - } - if (commitsBehind !== undefined && commitsBehind > maxDrift) { - return { - ok: false, - detail: `the deployment serves ${stamp.gitSha}, which is ${commitsBehind} commit(s) behind origin/main (drift budget ${maxDrift})`, - }; - } - return { - ok: true, - detail: `the deployment serves ${stamp.gitSha}, built ${stamp.builtAt}${ - commitsBehind === undefined ? "" : ` (${commitsBehind} commit(s) behind origin/main)` - }`, - }; -}; - -/** - * What a deploy receipt claims about the live deployment. A dry-run receipt is - * refused: it published nothing, so its sha is not a claim about anything that - * is serving traffic. - */ -export type ReceiptClaim = - | { readonly kind: "sha"; readonly gitSha: string; readonly gitDirty: boolean } - | { readonly kind: "none"; readonly reason: string }; - -export const expectedShaFromReceipt = (receiptJson: string): ReceiptClaim => { - let parsed: unknown; - try { - parsed = JSON.parse(receiptJson); - } catch { - return { kind: "none", reason: "the deploy receipt is not JSON" }; - } - const receipt = parsed as { gitSha?: unknown; dryRun?: unknown; gitDirty?: unknown }; - if (receipt.dryRun === true) { - return { - kind: "none", - reason: "the latest deploy receipt is a dry run: it published nothing, so it makes no claim about the deployment", - }; - } - if (typeof receipt.gitSha !== "string" || !SHA_PATTERN.test(receipt.gitSha)) { - return { kind: "none", reason: "the deploy receipt records no usable gitSha" }; - } - return { kind: "sha", gitSha: receipt.gitSha, gitDirty: receipt.gitDirty === true }; -}; - -/** Is the boolean flag `--name` present? */ -export const hasFlag = (argv: ReadonlyArray, name: string): boolean => argv.includes(name); - -/** - * The value of `--name`, or undefined. A flag whose value is missing or is - * itself a flag reads as absent, so `--sha --max-drift 3` cannot silently - * grade the deployment against the string "--max-drift". - */ -export const flagValue = (argv: ReadonlyArray, name: string): string | undefined => { - const at = argv.indexOf(name); - if (at === -1) return undefined; - const value = argv[at + 1]; - return value === undefined || value.startsWith("--") ? undefined : value; -}; - -/** - * The origin to probe: the first positional argument, then $CANARY_URL, then - * the canary itself. A trailing slash is dropped so paths concatenate cleanly. - */ -export const resolveOrigin = ( - argv: ReadonlyArray, - env: { readonly CANARY_URL?: string | undefined }, -): string => { - const positional = argv[0] !== undefined && !argv[0].startsWith("--") ? argv[0] : undefined; - const origin = positional ?? env.CANARY_URL ?? "https://canary.smithers.sh"; - return origin.endsWith("/") ? origin.slice(0, -1) : origin; -}; diff --git a/apps/server/scripts/canary/build-probe.ts b/apps/server/scripts/canary/build-probe.ts deleted file mode 100644 index 9023cf0e..00000000 --- a/apps/server/scripts/canary/build-probe.ts +++ /dev/null @@ -1,201 +0,0 @@ -/* - * CN-1: the deployed bundle is the git sha the deploy receipt claims. - * - * bun scripts/canary/build-probe.ts [origin] [--sha ] [--receipt ] - * [--max-drift ] [--json ] - * [--allow-unstamped-html] - * - * Reads the build stamp the SPA carries (apps/ui/vite.config.ts writes it) and - * compares it with the sha the caller expects. No credential is needed: the - * stamp is a static asset on a public deployment. - * - * Expected sha resolution, first hit wins: --sha, $CANARY_EXPECTED_SHA, - * --receipt , then ../../deploy-receipts/latest.json when it exists. - * Receipts are gitignored, so a scheduled run usually resolves none; that - * check then prints as skipped, never as a pass. - * - * The HTML-vs-asset row compares the served index.html with the served - * /__build.json and fails either direction of disagreement, including HTML - * that carries no stamp at all. It does not fetch the hashed chunks index.html - * names. Pass --allow-unstamped-html only while the deploy that introduces the - * stamp is landing; it downgrades that row to a skip and never to a pass. - * - * This file is the process shell only. Every verdict lives in BuildStamp.ts, - * which is unit-tested; the fetches below are the untested lines. - */ -import { existsSync, readFileSync, writeFileSync } from "node:fs"; -import { fileURLToPath } from "node:url"; -import { - BUILD_STAMP_PATH, - buildShaFromHtml, - buildShaVerdict, - expectedShaFromReceipt, - flagValue, - hasFlag, - HTML_AGREEMENT_COVERAGE, - htmlAgreementVerdict, - parseBuildStamp, - resolveOrigin, -} from "./BuildStamp.ts"; -import type { BuildStamp } from "./BuildStamp.ts"; - -const argv = process.argv.slice(2); -const flag = (name: string): string | undefined => flagValue(argv, name); -/* - * The escape hatch for the single deploy that introduces the stamp, and for - * nothing else. It downgrades unstamped HTML from a failure to a skip; it - * cannot turn any comparison into a pass. - */ -const allowUnstampedHtml = hasFlag(argv, "--allow-unstamped-html"); -const origin = resolveOrigin(argv, { CANARY_URL: process.env.CANARY_URL }); -const maxDriftArg = flag("--max-drift"); -if (maxDriftArg !== undefined && !/^\d+$/.test(maxDriftArg)) { - console.error(`--max-drift takes a commit count, not "${maxDriftArg}".`); - process.exit(2); -} -const maxDrift = maxDriftArg === undefined ? undefined : Number.parseInt(maxDriftArg, 10); -const jsonPath = flag("--json"); -const repoRoot = fileURLToPath(new URL("../../../..", import.meta.url)); - -let failures = 0; -const checks: Array<{ label: string; status: "ok" | "FAIL" | "skip"; detail: string }> = []; -const record = (label: string, status: "ok" | "FAIL" | "skip", detail: string): void => { - if (status === "FAIL") failures += 1; - console.log(`${status}: ${label} — ${detail}`); - checks.push({ label, status, detail }); -}; -const check = (label: string, ok: boolean, detail: string): void => record(label, ok ? "ok" : "FAIL", detail); -const skip = (label: string, detail: string): void => record(label, "skip", detail); - -/* - * A cache-buster and no-store together: the stamp is an unhashed asset, so - * Cloudflare's asset layer and any intermediary are both entitled to hold a - * copy, and a probe that reads a cache is measuring nothing. - */ -const noCache = { cache: "no-store" as const, headers: { "cache-control": "no-cache" } }; -const bust = `?t=${Date.now()}`; - -// 1. The deployment states what it is. -const stampResponse = await fetch(`${origin}${BUILD_STAMP_PATH}${bust}`, noCache); -const parsed = parseBuildStamp({ status: stampResponse.status, body: await stampResponse.text() }); -check( - "the deployment carries a build stamp", - typeof parsed !== "string", - typeof parsed === "string" ? parsed : `${BUILD_STAMP_PATH} names ${parsed.gitSha}`, -); - -if (typeof parsed === "string") { - console.log(`\nCN-1 FAILED: ${failures} check(s). The deployment cannot state which commit it is.`); - if (jsonPath !== undefined) { - writeFileSync(jsonPath, `${JSON.stringify({ origin, stamp: null, checks }, null, "\t")}\n`); - } - process.exit(1); -} -const stamp: BuildStamp = parsed; - -// 2. The HTML and the assets are the same build. -const htmlResponse = await fetch(`${origin}/${bust}`, noCache); -const metaSha = htmlResponse.ok ? buildShaFromHtml(await htmlResponse.text()) : null; -if (!htmlResponse.ok) { - await htmlResponse.body?.cancel(); -} - -// 3. The expected sha, if anything states one. -const receiptPath = - flag("--receipt") ?? - fileURLToPath(new URL("../../deploy-receipts/latest.json", import.meta.url)); -let expectedSha = flag("--sha") ?? process.env.CANARY_EXPECTED_SHA; -let receiptNote = expectedSha === undefined ? "" : "from --sha/$CANARY_EXPECTED_SHA"; -if (expectedSha === undefined) { - if (existsSync(receiptPath)) { - const claim = expectedShaFromReceipt(readFileSync(receiptPath, "utf8")); - if (claim.kind === "sha") { - expectedSha = claim.gitSha; - receiptNote = `from ${receiptPath}${claim.gitDirty ? " (built from a dirty tree)" : ""}`; - } else { - receiptNote = claim.reason; - } - } else { - receiptNote = `no deploy receipt at ${receiptPath}`; - } -} - -/* - * 4. Drift. `git rev-list --count ..origin/main` is the only honest - * measure of how far behind a deployment is, and it needs a checkout that has - * both commits. A shallow clone or an unfetched origin cannot answer, and that - * is reported rather than guessed. - */ -let commitsBehind: number | undefined; -let driftNote = "not requested (--max-drift)"; -if (maxDrift !== undefined) { - const proc = Bun.spawn(["git", "rev-list", "--count", `${stamp.gitSha}..origin/main`], { - cwd: repoRoot, - stdout: "pipe", - stderr: "pipe", - }); - const out = (await new Response(proc.stdout).text()).trim(); - const exitCode = await proc.exited; - if (exitCode === 0 && /^\d+$/.test(out)) { - commitsBehind = Number.parseInt(out, 10); - } else { - driftNote = `git could not measure drift from ${stamp.gitSha} to origin/main in ${repoRoot} (exit ${exitCode})`; - } -} - -/* - * The stamp parsed, so the build that emitted it also stamped its own - * index.html. Unstamped HTML from here on is evidence of a half-published - * deploy, not of an unverifiable input, and it is graded as one. - */ -const agreement = htmlAgreementVerdict(stamp, { status: htmlResponse.status, metaSha }, allowUnstampedHtml); -record("the served HTML and the build stamp are from the same build", agreement.status, agreement.detail); -console.log(`note: that check ${HTML_AGREEMENT_COVERAGE}.`); - -/* - * Each of the last two checks makes exactly one claim, so a failing line names - * the thing that is wrong. Both are skipped rather than passed when the input - * they need is absent: a probe that reports "ok" for a comparison it never made - * is the failure mode CN-1 exists to end. - */ -if (expectedSha === undefined) { - skip("the deployed sha matches the expected sha", receiptNote); -} else { - /* - * metaSha is not passed: the HTML/asset comparison has its own row above, and - * this line claims only that the served sha is the expected one. Reporting a - * disagreement twice under two labels hides which one is broken. - */ - const verdict = buildShaVerdict(stamp, expectedSha, null, undefined, 0); - check("the deployed sha matches the expected sha", verdict.ok, `${verdict.detail} (${receiptNote})`); -} - -if (maxDrift === undefined || commitsBehind === undefined) { - skip("the deployment is within the drift budget of origin/main", driftNote); -} else { - const verdict = buildShaVerdict(stamp, undefined, null, commitsBehind, maxDrift); - check("the deployment is within the drift budget of origin/main", verdict.ok, verdict.detail); -} - -if (jsonPath !== undefined) { - writeFileSync( - jsonPath, - `${JSON.stringify({ origin, stamp, expectedSha: expectedSha ?? null, metaSha, htmlAgreementCoverage: HTML_AGREEMENT_COVERAGE, commitsBehind: commitsBehind ?? null, checks }, null, "\t")}\n`, - ); -} - -if (failures > 0) { - console.log(`\nCN-1 FAILED: ${failures} check(s). ${origin} is not serving the commit it is supposed to.`); - process.exit(1); -} -/* - * A pass line names the rows that were never graded. A skipped row is not a - * verified one, and CN-1 exists because a green summary that hides an ungraded - * comparison is how a stale deployment stayed invisible for thirteen commits. - */ -const skipped = checks.filter((entry) => entry.status === "skip"); -console.log( - `\nCN-1 PASS: ${origin} serves ${stamp.gitSha}, built ${stamp.builtAt}${ - skipped.length === 0 ? "" : ` (${skipped.length} check(s) not graded: ${skipped.map((entry) => entry.label).join("; ")})` - }.`, -); diff --git a/apps/server/scripts/canary/invite-probe.test.ts b/apps/server/scripts/canary/invite-probe.test.ts deleted file mode 100644 index eeb3677c..00000000 --- a/apps/server/scripts/canary/invite-probe.test.ts +++ /dev/null @@ -1,412 +0,0 @@ -import { afterEach, describe, expect, test } from "bun:test"; -import { - allowlistStateVerdict, - auditOutcome, - invalidLogins, - inviteRunSummary, - inviteWriteVerdict, - isCi, - parseAllowlistRead, - parseLogins, - readPathFor, -} from "./invite-verdict.ts"; - -/** - * CN-23. The probe is exercised as a real subprocess against a loopback stand-in - * for the identity worker (a sibling deployment outside this repo, so nothing - * here can import it). The fake keeps a mutable allowlist set and an audit log, - * which is what makes the round trip provable: the add really has to land for - * the read-back to answer true, and the cleanup really has to run for the set - * to end empty. No live deployment and no credential is involved. - */ - -const SCRIPT = new URL("./invite-probe.ts", import.meta.url).pathname; - -interface Recorded { - readonly method: string; - readonly path: string; - readonly serviceToken: string | null; - readonly adminToken: string | null; - readonly body: unknown; -} - -interface FakeOptions { - readonly allowlisted?: ReadonlyArray; - /** Answer the read-back door with this status instead of 200. */ - readonly readStatus?: number; - /** Answer the read-back door with this raw body instead of the real shape. */ - readonly readBody?: string; - /** Refuse writes with this status. */ - readonly writeStatus?: number; - /** Accept the write but never actually admit the login. */ - readonly writeIsALie?: boolean; - /** Serve no admin audit read-back, as the canary identity worker does. */ - readonly noAuditDoor?: boolean; -} - -const identityDouble = (options: FakeOptions = {}) => { - const allowlist = new Set(options.allowlisted ?? []); - const audit: Array<{ login: string; action: string; requester: string }> = []; - const received: Array = []; - const server = Bun.serve({ - port: 0, - fetch: async (request) => { - const url = new URL(request.url); - const body = request.method === "POST" ? ((await request.json()) as unknown) : undefined; - received.push({ - method: request.method, - path: url.pathname, - serviceToken: request.headers.get("x-smithers-service-token"), - adminToken: request.headers.get("x-smithers-admin-token"), - body, - }); - - if (url.pathname.startsWith("/api/identity/allowlist/")) { - if (options.readStatus !== undefined) return new Response(options.readBody ?? "", { status: options.readStatus }); - if (options.readBody !== undefined) return new Response(options.readBody, { status: 200 }); - const login = decodeURIComponent(url.pathname.slice("/api/identity/allowlist/".length)); - return Response.json({ login, allowlisted: allowlist.has(login) }); - } - - if (url.pathname === "/api/identity/admin/allowlist" && request.method === "POST") { - if (options.writeStatus !== undefined) { - return new Response(JSON.stringify({ error: "requester_required" }), { status: options.writeStatus }); - } - const write = body as { login: string; action: string; requester: string }; - if (options.writeIsALie !== true) { - if (write.action === "add") allowlist.add(write.login); - else allowlist.delete(write.login); - } - audit.push({ login: write.login, action: write.action, requester: write.requester }); - return Response.json({ applied: true }, { status: 201 }); - } - - if (url.pathname === "/api/identity/admin/audit") { - if (options.noAuditDoor === true) return new Response(JSON.stringify({ error: "Not found" }), { status: 404 }); - return Response.json({ entries: audit }); - } - - return new Response("not found", { status: 404 }); - }, - }); - return { server, allowlist, audit, received, origin: `http://localhost:${server.port}` }; -}; - -let live: ReturnType | undefined; - -afterEach(() => { - live?.server.stop(true); - live = undefined; -}); - -const runProbe = async ( - args: ReadonlyArray, - env: Record = {}, -): Promise<{ exitCode: number; stdout: string }> => { - const proc = Bun.spawn(["bun", SCRIPT, ...args], { - env: { PATH: process.env.PATH ?? "", HOME: process.env.HOME ?? "", ...env }, - stdout: "pipe", - stderr: "pipe", - }); - const [stdout, stderr, exitCode] = await Promise.all([ - new Response(proc.stdout).text(), - new Response(proc.stderr).text(), - proc.exited, - ]); - return { exitCode, stdout: `${stdout}${stderr}` }; -}; - -describe("invite-verdict", () => { - test("a roster is a comma or newline list, minus blanks and comments", () => { - expect(parseLogins("alice, bob\ncarol\n# note\n")).toEqual(["alice", "bob", "carol"]); - expect(parseLogins(undefined)).toEqual([]); - expect(parseLogins("")).toEqual([]); - }); - - test("roster entries that cannot be GitHub logins are named", () => { - expect(invalidLogins(["alice", "not a login", "-bad", "ok-1"])).toEqual(["not a login", "-bad"]); - }); - - test("the read path template substitutes and encodes the login", () => { - expect(readPathFor("/api/identity/allowlist/{login}", "a b")).toBe("/api/identity/allowlist/a%20b"); - expect(readPathFor("/api/identity/allowlist/", "alice")).toBe("/api/identity/allowlist/alice"); - }); - - test("a 404 read is unreadable, never a false — a missing door is not an absent login", () => { - const read = parseAllowlistRead(404, "not found"); - expect(read.state).toBe("unreadable"); - expect(allowlistStateVerdict("alice", true, read).ok).toBe(false); - expect(allowlistStateVerdict("alice", false, read).ok).toBe(false); - if (read.state === "unreadable") expect(read.detail).toContain("--read-path"); - }); - - test("a refused credential is unreadable and names the credential", () => { - const read = parseAllowlistRead(403, "nope"); - expect(read.state).toBe("unreadable"); - if (read.state === "unreadable") expect(read.detail).toContain("IDENTITY_SERVICE_TOKEN"); - }); - - test("a 200 with no boolean allowlisted field is unreadable, not a pass", () => { - expect(parseAllowlistRead(200, '{"login":"alice"}').state).toBe("unreadable"); - expect(parseAllowlistRead(200, "").state).toBe("unreadable"); - expect(parseAllowlistRead(200, '{"login":"alice","allowlisted":"yes"}').state).toBe("unreadable"); - }); - - test("a well-formed read decides both directions", () => { - expect(parseAllowlistRead(200, '{"allowlisted":true}')).toEqual({ state: "known", allowlisted: true }); - expect(allowlistStateVerdict("alice", true, { state: "known", allowlisted: true }).ok).toBe(true); - expect(allowlistStateVerdict("alice", true, { state: "known", allowlisted: false }).ok).toBe(false); - expect(allowlistStateVerdict("alice", false, { state: "known", allowlisted: false }).ok).toBe(true); - }); - - test("an unattributed write is reported as such", () => { - expect(inviteWriteVerdict(201, '{"applied":true}').ok).toBe(true); - const refused = inviteWriteVerdict(400, '{"error":"requester_required"}'); - expect(refused.ok).toBe(false); - expect(refused.detail).toContain("unattributed"); - }); - - test("the audit outcome needs both the login and the requester", () => { - expect(auditOutcome(200, '[{"login":"p","requester":"canary-invite-probe"}]', "p", "canary-invite-probe").state).toBe("ok"); - expect(auditOutcome(200, '[{"login":"p","requester":"someone-else"}]', "p", "canary-invite-probe").state).toBe("fail"); - expect(auditOutcome(500, "boom", "p", "canary-invite-probe").state).toBe("fail"); - }); - - test("a run that asserted nothing is red, credentialed or not", () => { - const nothing = { passed: 0, failures: 0, skipped: 2 }; - expect(inviteRunSummary({ ...nothing, allowInconclusive: false, ci: false }).exitCode).toBe(1); - expect(inviteRunSummary({ ...nothing, allowInconclusive: false, ci: true }).exitCode).toBe(1); - expect(inviteRunSummary({ ...nothing, allowInconclusive: false, ci: false }).line).toContain("ASSERTED NOTHING"); - }); - - test("the inconclusive escape hatch is local-only", () => { - const nothing = { passed: 0, failures: 0, skipped: 2 }; - const local = inviteRunSummary({ ...nothing, allowInconclusive: true, ci: false }); - expect(local.exitCode).toBe(0); - expect(local.line).toContain("INCONCLUSIVE"); - const inCi = inviteRunSummary({ ...nothing, allowInconclusive: true, ci: true }); - expect(inCi.exitCode).toBe(1); - expect(inCi.line).toContain("refused under CI"); - }); - - test("a failure outranks the escape hatch, and a checked run passes", () => { - expect(inviteRunSummary({ passed: 1, failures: 1, skipped: 0, allowInconclusive: true, ci: false }).exitCode).toBe(1); - expect(inviteRunSummary({ passed: 2, failures: 0, skipped: 1, allowInconclusive: false, ci: false })).toEqual({ - exitCode: 0, - line: "CN-23 INVITE PROBE PASS: 2 check(s), 0 failures, 1 skipped.", - }); - }); - - test("CI is detected from $CI, and an explicit off value is not CI", () => { - expect(isCi({ CI: "true" })).toBe(true); - expect(isCi({ CI: "1" })).toBe(true); - expect(isCi({})).toBe(false); - expect(isCi({ CI: "" })).toBe(false); - expect(isCi({ CI: "false" })).toBe(false); - expect(isCi({ CI: "0" })).toBe(false); - }); - - test("a deployment with no audit door is unavailable, not a failure", () => { - // The canary identity worker answers 404 here; attribution is still - // enforced where it is written, so this must not red the run. - const outcome = auditOutcome(404, '{"error":"Not found"}', "p", "canary-invite-probe"); - expect(outcome.state).toBe("unavailable"); - expect(outcome.detail).toContain("no admin audit read-back"); - }); -}); - -describe("invite-probe.ts against a stateful identity double", () => { - test("read-only: a seeded roster passes, nothing is written, and the admission half is declared unverified", async () => { - live = identityDouble({ allowlisted: ["alice", "bob"] }); - const result = await runProbe(["--identity", live.origin, "--logins", "alice,bob"], { - IDENTITY_SERVICE_TOKEN: "service-123", - }); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("ok: allowlist seed: alice is admitted"); - expect(result.stdout).toContain("ok: allowlist seed: bob is admitted"); - expect(result.stdout).toContain("skip: an invite admits a new user"); - expect(result.stdout).toContain("--admit-probe-login"); - expect(result.stdout).toContain("CN-23 INVITE PROBE PASS"); - // The default run is safe to schedule: no write reached the deployment. - expect(live.received.every((entry) => entry.method === "GET")).toBe(true); - expect(live.received[0]?.serviceToken).toBe("service-123"); - }); - - test("a roster login that is not seeded fails the run", async () => { - live = identityDouble({ allowlisted: ["alice"] }); - const result = await runProbe(["--identity", live.origin, "--logins", "alice,bob"], { - IDENTITY_SERVICE_TOKEN: "service-123", - }); - expect(result.exitCode).toBe(1); - expect(result.stdout).toContain("ok: allowlist seed: alice is admitted"); - expect(result.stdout).toContain("FAIL: allowlist seed: bob is admitted — bob: allowlisted=false"); - expect(result.stdout).toContain("CN-23 INVITE PROBE FAILED"); - }); - - test("a read-back door that answers 404 fails loudly instead of reporting everyone unadmitted", async () => { - live = identityDouble({ allowlisted: ["alice"], readStatus: 404 }); - const result = await runProbe(["--identity", live.origin, "--logins", "alice"], { - IDENTITY_SERVICE_TOKEN: "service-123", - }); - expect(result.exitCode).toBe(1); - expect(result.stdout).toContain("NOT evidence the login is off the allowlist"); - }); - - test("an unrecognized read-back shape fails rather than passing vacuously", async () => { - live = identityDouble({ allowlisted: ["alice"], readBody: '{"login":"alice"}' }); - const result = await runProbe(["--identity", live.origin, "--logins", "alice"], { - IDENTITY_SERVICE_TOKEN: "service-123", - }); - expect(result.exitCode).toBe(1); - expect(result.stdout).toContain('carries no boolean "allowlisted" field'); - }); - - test("no credential skips the check and touches nothing, and the run is still red for asserting nothing", async () => { - live = identityDouble({ allowlisted: ["alice"] }); - const result = await runProbe(["--identity", live.origin, "--logins", "alice"]); - expect(result.stdout).toContain("skip: allowlist seed — neither IDENTITY_SERVICE_TOKEN nor IDENTITY_ADMIN_TOKEN"); - expect(result.stdout).not.toContain("FAIL: allowlist seed"); - expect(live.received).toEqual([]); - expect(result.exitCode).toBe(1); - expect(result.stdout).toContain("CN-23 ASSERTED NOTHING"); - }); - - test("an empty roster skips with the variable named, and never passes vacuously", async () => { - live = identityDouble(); - const result = await runProbe(["--identity", live.origin], { IDENTITY_SERVICE_TOKEN: "service-123" }); - expect(result.stdout).toContain("CANARY_ALLOWLIST_LOGINS"); - expect(result.stdout).toContain("An empty roster proves nothing"); - expect(result.stdout).not.toContain("ok: allowlist seed"); - // A credential with no roster verifies exactly as much as no credential. - expect(result.exitCode).toBe(1); - expect(result.stdout).toContain("CN-23 ASSERTED NOTHING"); - }); - - test("a roster entry that cannot be a GitHub login is refused before any network call", async () => { - live = identityDouble(); - const result = await runProbe(["--identity", live.origin, "--logins", "alice,not a login"], { - IDENTITY_SERVICE_TOKEN: "service-123", - }); - expect(result.exitCode).toBe(1); - expect(result.stdout).toContain("cannot be GitHub logins — not a login"); - expect(live.received).toEqual([]); - }); - - test("a malformed --probe-login is refused before any network call", async () => { - live = identityDouble(); - const result = await runProbe(["--identity", live.origin, "--admit-probe-login", "--probe-login", "-nope-"], { - IDENTITY_ADMIN_TOKEN: "admin-123", - }); - expect(result.exitCode).toBe(1); - expect(result.stdout).toContain("cannot be GitHub logins — -nope-"); - expect(live.received).toEqual([]); - }); - - test("--admit-probe-login runs the round trip and leaves the allowlist exactly as it found it", async () => { - live = identityDouble({ allowlisted: ["alice"] }); - const result = await runProbe(["--identity", live.origin, "--logins", "alice", "--admit-probe-login"], { - IDENTITY_SERVICE_TOKEN: "service-123", - IDENTITY_ADMIN_TOKEN: "admin-123", - }); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("ok: a fresh probe login starts off the allowlist"); - expect(result.stdout).toContain("ok: the admin allowlist door accepts the invite"); - expect(result.stdout).toContain("ok: the invited login is admitted"); - expect(result.stdout).toContain("ok: the invite is attributed in the audit log"); - expect(result.stdout).toContain("ok: the probe login is off the allowlist again"); - // The production-state promise: the seeded roster is untouched and the probe login is gone. - expect([...live.allowlist]).toEqual(["alice"]); - expect(live.audit.map((entry) => entry.action)).toEqual(["add", "remove"]); - expect(live.audit.every((entry) => entry.requester === "canary-invite-probe")).toBe(true); - const writes = live.received.filter((entry) => entry.method === "POST"); - expect(writes.every((entry) => entry.adminToken === "admin-123")).toBe(true); - }); - - test("a deployment with no audit door still passes: the CN-23 assertion does not depend on it", async () => { - live = identityDouble({ noAuditDoor: true }); - const result = await runProbe(["--identity", live.origin, "--admit-probe-login"], { - IDENTITY_SERVICE_TOKEN: "service-123", - IDENTITY_ADMIN_TOKEN: "admin-123", - }); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("ok: the invited login is admitted"); - expect(result.stdout).toContain("skip: the invite is attributed in the audit log"); - expect(result.stdout).toContain("no admin audit read-back"); - }); - - test("a write the deployment accepts but does not honor fails the CN-23 assertion, and still cleans up", async () => { - live = identityDouble({ writeIsALie: true }); - const result = await runProbe(["--identity", live.origin, "--admit-probe-login"], { - IDENTITY_SERVICE_TOKEN: "service-123", - IDENTITY_ADMIN_TOKEN: "admin-123", - }); - expect(result.exitCode).toBe(1); - expect(result.stdout).toContain("ok: the admin allowlist door accepts the invite"); - expect(result.stdout).toContain("FAIL: the invited login is admitted"); - // Cleanup is in a finally, so it runs after a failed check. - expect(live.audit.map((entry) => entry.action)).toEqual(["add", "remove"]); - }); - - test("a refused invite fails, and the cleanup write still runs", async () => { - live = identityDouble({ writeStatus: 400 }); - const result = await runProbe(["--identity", live.origin, "--admit-probe-login"], { - IDENTITY_SERVICE_TOKEN: "service-123", - IDENTITY_ADMIN_TOKEN: "admin-123", - }); - expect(result.exitCode).toBe(1); - expect(result.stdout).toContain("FAIL: the admin allowlist door accepts the invite"); - expect(result.stdout).toContain("unattributed"); - expect(live.received.filter((entry) => entry.method === "POST").length).toBe(2); - }); - - test("a probe login left behind by a crashed run is reported and removed, not silently re-added", async () => { - live = identityDouble({ allowlisted: ["canary-invite-probe"] }); - const result = await runProbe(["--identity", live.origin, "--admit-probe-login"], { - IDENTITY_SERVICE_TOKEN: "service-123", - IDENTITY_ADMIN_TOKEN: "admin-123", - }); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("an earlier run did not reach its cleanup"); - expect([...live.allowlist]).toEqual([]); - }); - - /* - * The regression this trio pins. The probe used to print INCONCLUSIVE and - * exit 0 when nothing was verified, so a CI step wired up before its secrets - * existed would have been green on every deploy while asserting nothing - * about the allowlist. - */ - test("a run that verified nothing exits 1, like CN-18's ASSERTED NOTHING", async () => { - live = identityDouble(); - const result = await runProbe(["--identity", live.origin]); - expect(result.exitCode).toBe(1); - expect(result.stdout).toContain("CN-23 ASSERTED NOTHING: 0 checks ran, 2 skipped."); - expect(result.stdout).toContain("IDENTITY_SERVICE_TOKEN"); - expect(result.stdout).toContain("--allow-inconclusive"); - expect(result.stdout).not.toContain("PROBE PASS"); - }); - - test("--allow-inconclusive lets an uncredentialed local run end green", async () => { - live = identityDouble(); - const result = await runProbe(["--identity", live.origin, "--allow-inconclusive"], { CI: "" }); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("CN-23 INVITE PROBE INCONCLUSIVE: nothing was verified, 2 skipped."); - expect(result.stdout).not.toContain("PROBE PASS"); - }); - - test("--allow-inconclusive is refused under CI, so the escape hatch cannot green a CI step", async () => { - live = identityDouble(); - const result = await runProbe(["--identity", live.origin, "--allow-inconclusive"], { CI: "true" }); - expect(result.exitCode).toBe(1); - expect(result.stdout).toContain("CN-23 ASSERTED NOTHING"); - expect(result.stdout).toContain("refused under CI"); - }); - - test("an unreachable identity worker fails with the target named, never a stack trace", async () => { - const result = await runProbe(["--identity", "http://127.0.0.1:1", "--logins", "alice"], { - IDENTITY_SERVICE_TOKEN: "service-123", - }); - expect(result.exitCode).toBe(1); - expect(result.stdout).toContain("the identity worker is unreachable at http://127.0.0.1:1/api/identity/allowlist/alice"); - }); -}); diff --git a/apps/server/scripts/canary/invite-probe.ts b/apps/server/scripts/canary/invite-probe.ts deleted file mode 100644 index 9f4f686d..00000000 --- a/apps/server/scripts/canary/invite-probe.ts +++ /dev/null @@ -1,222 +0,0 @@ -/* - * CN-23 — the closed-alpha entry path, probed against a real deployment. - * - * bun scripts/canary/invite-probe.ts [--logins a,b] [--admit-probe-login] - * [--allow-inconclusive] - * - * The alpha's whole entry path is: seed the allowlist, send an invite, the - * invited person gets in. src/invite-mechanics.test.ts and - * src/seed-allowlist.test.ts prove that against fakes. This script is the - * live half. - * - * WHY IT IS READ-ONLY BY DEFAULT - * - * Admitting a new user mutates production. A probe that ran the admission on - * every scheduled tick would write to the alpha allowlist forever, and a run - * that died mid-probe would leave litter behind. So the halves are split: - * - * Default (read-only, safe to schedule): every roster login named by - * --logins / $CANARY_ALLOWLIST_LOGINS reads back as allowlisted. This - * asserts the seed is PRESENT. It does not assert an invite ADMITS, and it - * says so — it prints a skip line naming --admit-probe-login rather than - * passing as if it had checked. - * - * --admit-probe-login (writes, opt-in): the full round trip on one - * designated probe identity — absent, invited, admitted, attributed, - * removed. The removal runs in a finally, so a failed check still cleans - * up. The identity is a FIXED login (invite-verdict.ts DEFAULT_PROBE_LOGIN), - * so the worst a crash can leave behind is that one known row, not a new - * row per run. It is not a real GitHub account, so admitting it grants - * nobody a session. - * - * Run the opt-in half after a deploy and in the manual drill in INVITES.md. - * Do not put it on a schedule. - * - * EXIT CODES - * - * 0 every check that ran passed, and at least one check ran. - * 1 a check failed, OR nothing was checked at all. - * - * A run that asserted nothing is red, matching CN-18's `ASSERTED NOTHING`. A - * probe wired into CI before its secrets exist must not report green for an - * assertion nobody made. --allow-inconclusive buys exit 0 for a contributor - * running this without production credentials; it is refused when $CI is set, - * so pasting it into a workflow file cannot restore the green. - * - * Credentials (all optional; a missing one skips its checks, and a run left - * with no checks at all exits 1 — see EXIT CODES): - * IDENTITY_UPSTREAM_URL the identity worker (default: the canary value in wrangler.jsonc) - * IDENTITY_SERVICE_TOKEN read-back credential (x-smithers-service-token) - * IDENTITY_ADMIN_TOKEN write + audit credential (x-smithers-admin-token) - * CANARY_ALLOWLIST_LOGINS the roster, a comma list; a repository variable, not a secret - * CANARY_PROBE_LOGIN overrides the designated probe identity - */ -import { - ALLOW_INCONCLUSIVE_FLAG, - allowlistStateVerdict, - auditOutcome, - DEFAULT_PROBE_LOGIN, - DEFAULT_READ_PATH, - invalidLogins, - inviteRunSummary, - inviteWriteVerdict, - isCi, - parseAllowlistRead, - parseLogins, - PROBE_REQUESTER, - readPathFor, -} from "./invite-verdict.ts"; - -const DEFAULT_IDENTITY_URL = "https://smithers-cloud-identity.willcory10.workers.dev"; - -const argOf = (name: string): string | undefined => { - const index = process.argv.indexOf(name); - return index === -1 ? undefined : process.argv[index + 1]; -}; - -const identity = argOf("--identity") ?? process.env.IDENTITY_UPSTREAM_URL ?? DEFAULT_IDENTITY_URL; -const readPath = argOf("--read-path") ?? DEFAULT_READ_PATH; -const probeLogin = argOf("--probe-login") ?? process.env.CANARY_PROBE_LOGIN ?? DEFAULT_PROBE_LOGIN; -const roster = parseLogins(argOf("--logins") ?? process.env.CANARY_ALLOWLIST_LOGINS); -const admitProbeLogin = process.argv.includes("--admit-probe-login"); -const allowInconclusive = process.argv.includes(ALLOW_INCONCLUSIVE_FLAG); -const serviceToken = process.env.IDENTITY_SERVICE_TOKEN?.trim() ?? ""; -const adminToken = process.env.IDENTITY_ADMIN_TOKEN?.trim() ?? ""; - -let failures = 0; -let passed = 0; -let skipped = 0; -const check = (label: string, ok: boolean, detail: string): void => { - if (ok) { - passed += 1; - console.log(`ok: ${label} — ${detail}`); - } else { - failures += 1; - console.log(`FAIL: ${label} — ${detail}`); - } -}; -/* A skip is neither a pass nor a failure: it states what went unverified. */ -const skip = (label: string, detail: string): void => { - skipped += 1; - console.log(`skip: ${label} — ${detail}`); -}; - -/* Refuse a malformed login before any network call: the identity worker - * validates writes against the same grammar, so sending one can only produce a - * confusing upstream refusal. */ -const badLogins = invalidLogins(admitProbeLogin ? [...roster, probeLogin] : roster); -if (badLogins.length > 0) { - console.log(`FAIL: these entries cannot be GitHub logins — ${badLogins.join(", ")}`); - process.exit(1); -} - -const readHeaders = (): Record => { - const headers: Record = {}; - if (serviceToken !== "") headers["x-smithers-service-token"] = serviceToken; - if (adminToken !== "") headers["x-smithers-admin-token"] = adminToken; - return headers; -}; - -const readAllowlist = async (login: string) => { - const target = new URL(readPathFor(readPath, login), identity).toString(); - try { - const response = await fetch(target, { headers: readHeaders() }); - return parseAllowlistRead(response.status, await response.text()); - } catch (error) { - return { - state: "unreadable" as const, - detail: `the identity worker is unreachable at ${target}: ${error instanceof Error ? error.message : String(error)}`, - }; - } -}; - -const writeAllowlist = async (login: string, action: "add" | "remove") => { - const target = new URL("/api/identity/admin/allowlist", identity).toString(); - try { - const response = await fetch(target, { - method: "POST", - headers: { "content-type": "application/json", "x-smithers-admin-token": adminToken }, - body: JSON.stringify({ login, action, requester: PROBE_REQUESTER, timestamp: new Date().toISOString() }), - }); - return { status: response.status, body: await response.text() }; - } catch (error) { - return { status: 0, body: error instanceof Error ? error.message : String(error) }; - } -}; - -/* Half one: the seed is present. */ -if (serviceToken === "" && adminToken === "") { - skip( - "allowlist seed", - "neither IDENTITY_SERVICE_TOKEN nor IDENTITY_ADMIN_TOKEN is set, so the allowlist cannot be read back", - ); -} else if (roster.length === 0) { - skip( - "allowlist seed", - "no roster to check: set $CANARY_ALLOWLIST_LOGINS (a repository variable) or pass --logins. An empty roster proves nothing", - ); -} else { - for (const login of roster) { - const verdict = allowlistStateVerdict(login, true, await readAllowlist(login)); - check(`allowlist seed: ${login} is admitted`, verdict.ok, verdict.detail); - } -} - -/* Half two: an invite actually admits. Opt-in, because it writes. */ -if (!admitProbeLogin) { - skip( - "an invite admits a new user", - `not exercised: this run made no write. Pass --admit-probe-login to run the round trip on ${probeLogin}, or run the manual drill in INVITES.md`, - ); -} else if (adminToken === "") { - skip("an invite admits a new user", "IDENTITY_ADMIN_TOKEN is unset, so no invite can be issued"); -} else { - try { - const before = await readAllowlist(probeLogin); - if (before.state === "known" && before.allowlisted) { - console.log( - `note: ${probeLogin} was already on the allowlist — an earlier run did not reach its cleanup. This run removes it.`, - ); - } else { - const freshVerdict = allowlistStateVerdict(probeLogin, false, before); - check("a fresh probe login starts off the allowlist", freshVerdict.ok, freshVerdict.detail); - } - - const written = await writeAllowlist(probeLogin, "add"); - const writeVerdict = inviteWriteVerdict(written.status, written.body); - check("the admin allowlist door accepts the invite", writeVerdict.ok, writeVerdict.detail); - - /* The CN-23 assertion: the invite ADMITTED the login, read back from the deployment. */ - const admitted = allowlistStateVerdict(probeLogin, true, await readAllowlist(probeLogin)); - check("the invited login is admitted", admitted.ok, admitted.detail); - - const auditTarget = new URL("/api/identity/admin/audit", identity).toString(); - try { - const audit = await fetch(auditTarget, { headers: { "x-smithers-admin-token": adminToken } }); - const outcome = auditOutcome(audit.status, await audit.text(), probeLogin, PROBE_REQUESTER); - if (outcome.state === "unavailable") skip("the invite is attributed in the audit log", outcome.detail); - else check("the invite is attributed in the audit log", outcome.state === "ok", outcome.detail); - } catch (error) { - check( - "the invite is attributed in the audit log", - false, - `the audit door is unreachable at ${auditTarget}: ${error instanceof Error ? error.message : String(error)}`, - ); - } - } finally { - /* - * Cleanup runs even when a check above failed. A probe that leaves a - * synthetic login on the production allowlist is worse than the gap it - * was closing. - */ - const removed = await writeAllowlist(probeLogin, "remove"); - const removeVerdict = inviteWriteVerdict(removed.status, removed.body); - check("the probe login is withdrawn again", removeVerdict.ok, removeVerdict.detail); - const after = allowlistStateVerdict(probeLogin, false, await readAllowlist(probeLogin)); - check("the probe login is off the allowlist again", after.ok, after.detail); - } -} - -const summary = inviteRunSummary({ passed, failures, skipped, allowInconclusive, ci: isCi(process.env) }); -console.log(`\n${summary.line}`); -process.exit(summary.exitCode); diff --git a/apps/server/scripts/canary/invite-verdict.ts b/apps/server/scripts/canary/invite-verdict.ts deleted file mode 100644 index bb9d17d4..00000000 --- a/apps/server/scripts/canary/invite-verdict.ts +++ /dev/null @@ -1,239 +0,0 @@ -/* - * CN-23 — "the allowlist seed is present and an invite actually admits a new - * user" — as pure verdicts over already-fetched values. invite-probe.ts is the - * process shell around this file: it does the fetching, the printing, and the - * exit code. Everything that can be wrong about a probe's *judgement* lives - * here, where a bun test can drive it without a deployment. - * - * The identity Worker is not in this repository (apps/UPSTREAMS.md); it lives - * in ~/flows/ui/workers/identity. Its write door is documented in - * apps/server/INVITES.md and is exercised by src/seed-allowlist.test.ts. - * - * Its read-back door is not documented anywhere in this tree. What the live - * deployment answers, unauthenticated, on 2026-08-18: - * - * GET /api/identity/allowlist/octocat -> 401 {"error":"Unauthorized service"} - * GET /api/identity/admin/audit -> 404 {"error":"Not found"} - * - * The worker answers 401 for a route it implements but gates, and 404 for one - * it does not implement. So the per-login read-back exists and gates on a - * service token, and there is no admin audit read-back. Every rule below - * therefore treats an unexpected read as unreadable — never as an answer. A - * 404 in particular is not evidence that a login is off the allowlist; it is - * evidence that nothing answered. - */ - -export interface Verdict { - readonly ok: boolean; - readonly detail: string; -} - -/** - * GitHub's login grammar. The identity Worker validates writes against it, so - * a roster entry that cannot be a GitHub login is a configuration mistake - * worth refusing before any network call. - */ -export const LOGIN_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/; - -/** - * The designated probe identity. It is deliberately a FIXED login, not a - * timestamped one: a crashed run then leaves at most this single, named, - * greppable row on the alpha allowlist instead of a new one every run. It is - * not a real GitHub account, so admitting it grants nobody access — the - * OAuth callback can never mint a session for a login GitHub will not issue. - */ -export const DEFAULT_PROBE_LOGIN = "canary-invite-probe"; - -/** The read-back door, as a path template. Overridable because it is unverified. */ -export const DEFAULT_READ_PATH = "/api/identity/allowlist/{login}"; - -/** The audit attribution written with every probe invite. */ -export const PROBE_REQUESTER = "canary-invite-probe"; - -/* - * Split a comma- or newline-separated roster, dropping blanks and `#` - * comments. Splitting on spaces too would quietly turn a malformed entry into - * several well-formed ones, which is how a bad roster passes validation. - * seed-allowlist.mjs reads its --file the same way. - */ -export const parseLogins = (raw: string | undefined): ReadonlyArray => { - if (raw === undefined) return []; - return raw - .split(/[,\n]/) - .map((entry) => entry.trim()) - .filter((entry) => entry.length > 0 && !entry.startsWith("#")); -}; - -/** The roster entries that cannot be GitHub logins. */ -export const invalidLogins = (logins: ReadonlyArray): ReadonlyArray => - logins.filter((login) => !LOGIN_PATTERN.test(login)); - -export const readPathFor = (template: string, login: string): string => - template.includes("{login}") ? template.replaceAll("{login}", encodeURIComponent(login)) : `${template}${encodeURIComponent(login)}`; - -/** - * What one read-back answered. `known` is the only state that says anything - * about the allowlist; everything else is the probe admitting it does not - * know, which is a failed check rather than a `false`. - */ -export type AllowlistRead = - | { readonly state: "known"; readonly allowlisted: boolean } - | { readonly state: "unreadable"; readonly detail: string }; - -const preview = (body: string): string => body.trim().replace(/\s+/g, " ").slice(0, 160); - -export const parseAllowlistRead = (status: number, body: string): AllowlistRead => { - if (status === 404 || status === 405) { - return { - state: "unreadable", - detail: `HTTP ${status} — nothing answered the read-back door at this path, so this is NOT evidence the login is off the allowlist. Point --read-path at the identity worker's per-login allowlist route.`, - }; - } - if (status === 401 || status === 403) { - return { - state: "unreadable", - detail: `HTTP ${status} — the read credential was refused. Set IDENTITY_SERVICE_TOKEN (or IDENTITY_ADMIN_TOKEN) to a token the identity worker accepts.`, - }; - } - if (status !== 200) { - return { state: "unreadable", detail: `HTTP ${status} ${preview(body)}` }; - } - let parsed: unknown; - try { - parsed = JSON.parse(body) as unknown; - } catch { - return { state: "unreadable", detail: `HTTP 200 but the body is not JSON: ${preview(body)}` }; - } - const allowlisted = (parsed as { allowlisted?: unknown } | null)?.allowlisted; - if (typeof allowlisted !== "boolean") { - return { - state: "unreadable", - detail: `HTTP 200 but the body carries no boolean "allowlisted" field: ${preview(body)}`, - }; - } - return { state: "known", allowlisted }; -}; - -/** Assert one login's allowlist state. An unreadable answer always fails. */ -export const allowlistStateVerdict = (login: string, expected: boolean, read: AllowlistRead): Verdict => { - if (read.state === "unreadable") return { ok: false, detail: `${login}: ${read.detail}` }; - return { - ok: read.allowlisted === expected, - detail: `${login}: allowlisted=${read.allowlisted}, expected ${expected}`, - }; -}; - -/** The identity admin write door's answer to one invite. */ -export const inviteWriteVerdict = (status: number, body: string): Verdict => { - if (status >= 200 && status < 300) return { ok: true, detail: `HTTP ${status} ${preview(body)}` }; - if (body.includes("requester_required") || body.includes("timestamp_required")) { - return { - ok: false, - detail: `HTTP ${status} ${preview(body)} — the write was refused as unattributed; the probe must send requester and timestamp.`, - }; - } - return { ok: false, detail: `HTTP ${status} ${preview(body)}` }; -}; - -/** - * The invite is attributed in the identity worker's audit log — where one is - * exposed. The canary identity worker answers 404 for - * /api/identity/admin/audit, so this reports `unavailable` (a skip) rather - * than a failure: attribution is still enforced on the WRITE side, where the - * upstream refuses an unattributed call, it simply cannot be read back. - */ -export type AuditOutcome = - | { readonly state: "ok"; readonly detail: string } - | { readonly state: "fail"; readonly detail: string } - | { readonly state: "unavailable"; readonly detail: string }; - -export const auditOutcome = (status: number, body: string, login: string, requester: string): AuditOutcome => { - if (status === 404 || status === 405) { - return { - state: "unavailable", - detail: `HTTP ${status} — this deployment exposes no admin audit read-back, so the attribution written with the invite cannot be read back. The write door still refuses unattributed calls.`, - }; - } - if (status !== 200) return { state: "fail", detail: `HTTP ${status} ${preview(body)}` }; - const named = body.includes(login) && body.includes(requester); - return { - state: named ? "ok" : "fail", - detail: named - ? `the audit log names ${login} and requester ${requester}` - : `the audit log does not name both ${login} and requester ${requester}: ${preview(body)}`, - }; -}; - -/** - * The flag that lets a local run without production credentials end green. - * It is spelled as a flag, not an env var, so a CI step that ever grew one - * would carry the word "inconclusive" in the workflow file where a reviewer - * reads it. `inviteRunSummary` refuses it under CI regardless. - */ -export const ALLOW_INCONCLUSIVE_FLAG = "--allow-inconclusive"; - -/** - * Is this a CI run? GitHub Actions sets CI=true on every step, as does every - * other runner this repo could move to. The probe reads it for one purpose: - * to refuse the local escape hatch there. - */ -export const isCi = (env: { readonly [key: string]: string | undefined }): boolean => { - const raw = env.CI?.trim().toLowerCase(); - if (raw === undefined || raw === "") return false; - return raw !== "0" && raw !== "false"; -}; - -export interface RunSummary { - readonly exitCode: number; - readonly line: string; -} - -/** - * Turn the tallies into a verdict and an exit code. - * - * A run that asserted nothing exits 1, the same way CN-18's `summarizeHealth` - * treats zero healthy targets. The two constraints pull apart here: a - * contributor without production secrets must not face a red they cannot act - * on, and CI must never report green for an assertion nobody made. They are - * resolved by making the green-on-nothing case explicit and local-only — - * ALLOW_INCONCLUSIVE_FLAG buys exit 0 on a developer's machine, and is refused - * under CI, where the whole point of the step is the assertion. So the default - * everywhere, including a CI step wired up before its secrets exist, is red. - */ -export const inviteRunSummary = (counts: { - readonly passed: number; - readonly failures: number; - readonly skipped: number; - readonly allowInconclusive: boolean; - readonly ci: boolean; -}): RunSummary => { - const { passed, failures, skipped, allowInconclusive, ci } = counts; - if (failures > 0) { - return { - exitCode: 1, - line: `CN-23 INVITE PROBE FAILED: ${failures} check(s), ${passed} passed, ${skipped} skipped.`, - }; - } - if (passed === 0) { - if (allowInconclusive && !ci) { - return { - exitCode: 0, - line: - `CN-23 INVITE PROBE INCONCLUSIVE: nothing was verified, ${skipped} skipped. ` + - `${ALLOW_INCONCLUSIVE_FLAG} accepted that for this local run; under CI it is refused.`, - }; - } - const refusal = - allowInconclusive && ci - ? ` ${ALLOW_INCONCLUSIVE_FLAG} is refused under CI: a CI step must never report success for an assertion it did not make.` - : ""; - return { - exitCode: 1, - line: - `CN-23 ASSERTED NOTHING: 0 checks ran, ${skipped} skipped.${refusal}` + - " Set IDENTITY_SERVICE_TOKEN and CANARY_ALLOWLIST_LOGINS (add IDENTITY_ADMIN_TOKEN and --admit-probe-login for the write half)" + - `, or pass ${ALLOW_INCONCLUSIVE_FLAG} to accept a local run that verifies nothing.`, - }; - } - return { exitCode: 0, line: `CN-23 INVITE PROBE PASS: ${passed} check(s), 0 failures, ${skipped} skipped.` }; -}; diff --git a/apps/server/scripts/canary/rollback-probe.test.ts b/apps/server/scripts/canary/rollback-probe.test.ts deleted file mode 100644 index 3961a969..00000000 --- a/apps/server/scripts/canary/rollback-probe.test.ts +++ /dev/null @@ -1,362 +0,0 @@ -import { afterEach, describe, expect, test } from "bun:test"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { - deployedVerdict, - parseDeployedVersions, - parseReceipt, - parseVersionList, - receiptVerdict, - rollbackVerdict, -} from "./rollback-verdict.ts"; - -/** - * CN-24. The probe is exercised as a real subprocess against a loopback - * stand-in for the Cloudflare API, with real receipt files on disk. The fake's - * response shapes are the ones wrangler 4.123.0's own client reads: - * `{ success, result: { items } }` for versions and - * `{ success, result: { deployments: [{ versions: [{ version_id, percentage }] }] } }` - * for deployments. No Cloudflare credential is involved. - */ - -const SCRIPT = new URL("./rollback-probe.ts", import.meta.url).pathname; - -const VERSION_A = "11111111-1111-4111-8111-111111111111"; -const VERSION_B = "22222222-2222-4222-8222-222222222222"; - -interface FakeOptions { - readonly versions?: ReadonlyArray<{ id: string; created_on?: string }>; - readonly deployed?: ReadonlyArray<{ version_id: string; percentage: number }>; - readonly status?: number; - readonly rawBody?: string; -} - -const cloudflareDouble = (options: FakeOptions = {}) => { - const paths: Array = []; - const authorizations: Array = []; - const server = Bun.serve({ - port: 0, - fetch: (request) => { - const url = new URL(request.url); - paths.push(url.pathname); - authorizations.push(request.headers.get("authorization")); - if (options.status !== undefined) return new Response(options.rawBody ?? "", { status: options.status }); - if (options.rawBody !== undefined) return new Response(options.rawBody, { status: 200 }); - if (url.pathname.endsWith("/deployments")) { - return Response.json({ - success: true, - result: { - deployments: [{ versions: options.deployed ?? [{ version_id: VERSION_B, percentage: 100 }] }], - }, - }); - } - return Response.json({ - success: true, - result: { - items: (options.versions ?? [{ id: VERSION_B, created_on: "2026-08-18T00:00:00Z" }, { id: VERSION_A }]).map( - (version) => ({ - id: version.id, - metadata: { created_on: version.created_on }, - annotations: { "workers/message": "deploy" }, - }), - ), - }, - }); - }, - }); - return { server, paths, authorizations, base: `http://localhost:${server.port}` }; -}; - -let live: ReturnType | undefined; -let workdir: string | undefined; - -afterEach(() => { - live?.server.stop(true); - live = undefined; - if (workdir !== undefined) rmSync(workdir, { recursive: true, force: true }); - workdir = undefined; -}); - -const receiptFile = (receipt: Record | string): string => { - workdir = mkdtempSync(join(tmpdir(), "cn24-")); - const path = join(workdir, "latest.json"); - writeFileSync(path, typeof receipt === "string" ? receipt : JSON.stringify(receipt, null, "\t")); - return path; -}; - -const realReceipt = (versionId: string | null): Record => ({ - worker: "smithers-mvp-web", - dryRun: false, - gitSha: "a6cab068a6cab068a6cab068a6cab068a6cab068", - timestamp: "2026-08-18T12:00:00.000Z", - wranglerVersionId: versionId, -}); - -const runProbe = async ( - args: ReadonlyArray, - env: Record = {}, -): Promise<{ exitCode: number; stdout: string }> => { - const proc = Bun.spawn(["bun", SCRIPT, ...args], { - env: { PATH: process.env.PATH ?? "", HOME: process.env.HOME ?? "", ...env }, - stdout: "pipe", - stderr: "pipe", - }); - const [stdout, stderr, exitCode] = await Promise.all([ - new Response(proc.stdout).text(), - new Response(proc.stderr).text(), - proc.exited, - ]); - return { exitCode, stdout: `${stdout}${stderr}` }; -}; - -describe("rollback-verdict", () => { - test("a dry-run receipt is refused with the reason, not read as a deployed version", () => { - const parsed = parseReceipt(JSON.stringify({ ...realReceipt(null), dryRun: true })); - expect(parsed.ok).toBe(true); - if (!parsed.ok) return; - const verdict = receiptVerdict(parsed.value); - expect(verdict.ok).toBe(false); - expect(verdict.detail).toContain("--dry-run"); - }); - - test("a real receipt with no version id is the CN-24 gap, stated in full", () => { - const parsed = parseReceipt(JSON.stringify(realReceipt(null))); - if (!parsed.ok) throw new Error(parsed.detail); - expect(receiptVerdict(parsed.value)).toEqual({ - ok: false, - detail: "the deploy receipt names no wranglerVersionId, so no version can be confirmed deployed or rolled back to.", - }); - }); - - test("a real receipt naming a version passes and quotes the sha", () => { - const parsed = parseReceipt(JSON.stringify(realReceipt(VERSION_B))); - if (!parsed.ok) throw new Error(parsed.detail); - const verdict = receiptVerdict(parsed.value); - expect(verdict.ok).toBe(true); - expect(verdict.detail).toContain(VERSION_B); - expect(verdict.detail).toContain("a6cab068a6ca"); - }); - - test("malformed receipts fail with a reason instead of throwing", () => { - expect(parseReceipt("not json")).toEqual({ ok: false, detail: "the receipt is not JSON: not json" }); - expect(parseReceipt("[]").ok).toBe(false); - expect(parseReceipt(JSON.stringify({ worker: "w" })).ok).toBe(false); - expect(parseReceipt(JSON.stringify({ ...realReceipt(null), wranglerVersionId: 7 })).ok).toBe(false); - }); - - test("the version list is read out of result.items, newest first", () => { - const parsed = parseVersionList({ - success: true, - result: { - items: [ - { id: VERSION_B, metadata: { created_on: "2026-08-18T00:00:00Z" }, annotations: { "workers/message": "m" } }, - { id: VERSION_A }, - ], - }, - }); - expect(parsed).toEqual({ - ok: true, - value: [ - { id: VERSION_B, createdOn: "2026-08-18T00:00:00Z", message: "m" }, - { id: VERSION_A, createdOn: undefined, message: undefined }, - ], - }); - }); - - test("an unexpected versions shape is a failure detail, never a throw", () => { - expect(parseVersionList(null).ok).toBe(false); - expect(parseVersionList({ success: false, errors: [{ message: "bad token" }] }).ok).toBe(false); - expect(parseVersionList({ success: true, result: {} }).ok).toBe(false); - expect(parseVersionList({ success: true, result: { items: [{ noId: true }] } }).ok).toBe(false); - }); - - test("the deployed set is read out of the newest deployment", () => { - expect( - parseDeployedVersions({ - success: true, - result: { deployments: [{ versions: [{ version_id: VERSION_B, percentage: 100 }] }] }, - }), - ).toEqual({ ok: true, value: [{ id: VERSION_B, percentage: 100 }] }); - expect(parseDeployedVersions({ success: true, result: { deployments: [] } }).ok).toBe(false); - expect(parseDeployedVersions({ success: true, result: { deployments: [{}] } }).ok).toBe(false); - expect(parseDeployedVersions({ success: true, result: { deployments: [{ versions: [{}] }] } }).ok).toBe(false); - }); - - test("a deployment that drifted from the receipt fails and names both sides", () => { - const verdict = deployedVerdict([{ id: VERSION_A, percentage: 100 }], VERSION_B); - expect(verdict.ok).toBe(false); - expect(verdict.detail).toContain(VERSION_A); - expect(verdict.detail).toContain(VERSION_B); - }); - - test("rollback eligibility needs at least two versions, one of them the deployed one", () => { - const versions = [ - { id: VERSION_B, createdOn: "2026-08-18T00:00:00Z", message: undefined }, - { id: VERSION_A, createdOn: "2026-08-01T00:00:00Z", message: undefined }, - ]; - const pass = rollbackVerdict(versions, VERSION_B); - expect(pass.ok).toBe(true); - expect(pass.detail).toContain(`rollback ${VERSION_A}`); - - expect(rollbackVerdict(versions.slice(0, 1), VERSION_B)).toEqual({ - ok: false, - detail: "only 1 version(s) exist for this Worker: there is nothing to roll back to.", - }); - expect(rollbackVerdict(versions, null).ok).toBe(false); - const unknown = rollbackVerdict(versions, "33333333-3333-4333-8333-333333333333"); - expect(unknown.ok).toBe(false); - expect(unknown.detail).toContain("not in Cloudflare's version list"); - }); - - test("the prior version is the next-OLDER entry, never a newer one", () => { - // Newest first, exactly as Cloudflare returns them. A receipt naming the - // middle version must not be told to roll "back" to the newest. - const versions = [ - { id: "c", createdOn: undefined, message: undefined }, - { id: "b", createdOn: undefined, message: undefined }, - { id: "a", createdOn: undefined, message: undefined }, - ]; - expect(rollbackVerdict(versions, "b").detail).toContain("rollback a"); - expect(rollbackVerdict(versions, "b").detail).not.toContain("rollback c"); - const oldest = rollbackVerdict(versions, "a"); - expect(oldest.ok).toBe(false); - expect(oldest.detail).toContain("oldest version Cloudflare still lists"); - }); -}); - -describe("rollback-probe.ts against a Cloudflare API double", () => { - test("a real deploy receipt whose version is live and has a predecessor passes", async () => { - live = cloudflareDouble(); - const result = await runProbe(["--receipt", receiptFile(realReceipt(VERSION_B)), "--api-base", live.base], { - CLOUDFLARE_API_TOKEN: "cf-token-123", - }); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("ok: the deploy receipt names a version"); - expect(result.stdout).toContain("ok: the receipt names the version serving traffic"); - expect(result.stdout).toContain(`ok: a previous version is still rollback-eligible`); - expect(result.stdout).toContain(`bun x wrangler@4.123.0 rollback ${VERSION_A}`); - expect(result.stdout).toContain("CN-24 ROLLBACK PROBE PASS"); - expect(live.authorizations.every((value) => value === "Bearer cf-token-123")).toBe(true); - expect(live.paths).toEqual([ - "/accounts/dd3525a4132493566aeb38de533c8827/workers/scripts/smithers-mvp-web/deployments", - "/accounts/dd3525a4132493566aeb38de533c8827/workers/scripts/smithers-mvp-web/versions", - ]); - }); - - test("today's state — a dry-run receipt with a null version id — fails without calling Cloudflare", async () => { - live = cloudflareDouble(); - const dryRun = { ...realReceipt(null), dryRun: true }; - const result = await runProbe(["--receipt", receiptFile(dryRun), "--api-base", live.base], { - CLOUDFLARE_API_TOKEN: "cf-token-123", - }); - expect(result.exitCode).toBe(1); - expect(result.stdout).toContain("FAIL: the deploy receipt names a version"); - expect(result.stdout).toContain("it published nothing"); - }); - - test("a real deploy that recorded no version id fails on all three checks", async () => { - live = cloudflareDouble(); - const result = await runProbe(["--receipt", receiptFile(realReceipt(null)), "--api-base", live.base], { - CLOUDFLARE_API_TOKEN: "cf-token-123", - }); - expect(result.exitCode).toBe(1); - expect(result.stdout).toContain("names no wranglerVersionId"); - expect(result.stdout).toContain("FAIL: the receipt names the version serving traffic"); - expect(result.stdout).toContain("FAIL: a previous version is still rollback-eligible"); - }); - - test("a Worker with only one version has nothing to roll back to", async () => { - live = cloudflareDouble({ versions: [{ id: VERSION_B }] }); - const result = await runProbe(["--receipt", receiptFile(realReceipt(VERSION_B)), "--api-base", live.base], { - CLOUDFLARE_API_TOKEN: "cf-token-123", - }); - expect(result.exitCode).toBe(1); - expect(result.stdout).toContain("only 1 version(s) exist"); - }); - - test("a live deployment that drifted from the receipt fails", async () => { - live = cloudflareDouble({ deployed: [{ version_id: VERSION_A, percentage: 100 }] }); - const result = await runProbe(["--receipt", receiptFile(realReceipt(VERSION_B)), "--api-base", live.base], { - CLOUDFLARE_API_TOKEN: "cf-token-123", - }); - expect(result.exitCode).toBe(1); - expect(result.stdout).toContain("the deployment drifted from the receipt"); - }); - - test("a refused Cloudflare token fails with the status, naming the credential", async () => { - live = cloudflareDouble({ status: 403, rawBody: "forbidden" }); - const result = await runProbe(["--receipt", receiptFile(realReceipt(VERSION_B)), "--api-base", live.base], { - CLOUDFLARE_API_TOKEN: "cf-token-123", - }); - expect(result.exitCode).toBe(1); - expect(result.stdout).toContain("HTTP 403 — CLOUDFLARE_API_TOKEN cannot read smithers-mvp-web's versions"); - }); - - test("an unrecognized API body is one legible FAIL line, not a stack trace", async () => { - live = cloudflareDouble({ rawBody: '{"success":true,"result":{}}' }); - const result = await runProbe(["--receipt", receiptFile(realReceipt(VERSION_B)), "--api-base", live.base], { - CLOUDFLARE_API_TOKEN: "cf-token-123", - }); - expect(result.exitCode).toBe(1); - expect(result.stdout).toContain("carries no result.deployments array"); - expect(result.stdout).toContain("carries no result.items array"); - expect(result.stdout).not.toContain("at "); - }); - - test("HTML from a proxy is reported as such, not parsed", async () => { - live = cloudflareDouble({ rawBody: "gateway" }); - const result = await runProbe(["--receipt", receiptFile(realReceipt(VERSION_B)), "--api-base", live.base], { - CLOUDFLARE_API_TOKEN: "cf-token-123", - }); - expect(result.exitCode).toBe(1); - expect(result.stdout).toContain("the body is not JSON"); - }); - - test("an unreachable API is reported with the target, never a stack trace", async () => { - const result = await runProbe(["--receipt", receiptFile(realReceipt(VERSION_B)), "--api-base", "http://127.0.0.1:1"], { - CLOUDFLARE_API_TOKEN: "cf-token-123", - }); - expect(result.exitCode).toBe(1); - expect(result.stdout).toContain("http://127.0.0.1:1/accounts/"); - expect(result.stdout).toContain("is unreachable"); - }); - - test("no Cloudflare token skips the live checks and exits 0", async () => { - live = cloudflareDouble(); - const result = await runProbe(["--receipt", receiptFile(realReceipt(VERSION_B)), "--api-base", live.base]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("ok: the deploy receipt names a version"); - expect(result.stdout).toContain("skip: the receipt names the version serving traffic — CLOUDFLARE_API_TOKEN is unset"); - expect(result.stdout).toContain("skip: a previous version is still rollback-eligible — CLOUDFLARE_API_TOKEN is unset"); - expect(live.paths).toEqual([]); - }); - - test("a missing receipt skips with the path named, because receipts are gitignored", async () => { - live = cloudflareDouble(); - const result = await runProbe(["--receipt", "/nonexistent/latest.json", "--api-base", live.base], { - CLOUDFLARE_API_TOKEN: "cf-token-123", - }); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("no receipt at /nonexistent/latest.json"); - expect(result.stdout).toContain("CANARY_RECEIPT_PATH"); - expect(live.paths).toEqual([]); - }); - - test("a run that verified nothing reports INCONCLUSIVE, never PASS", async () => { - live = cloudflareDouble(); - const result = await runProbe(["--receipt", "/nonexistent/latest.json", "--api-base", live.base]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("CN-24 ROLLBACK PROBE INCONCLUSIVE: nothing was verified, 3 skipped."); - expect(result.stdout).not.toContain("PROBE PASS"); - }); - - test("a corrupt receipt fails instead of skipping", async () => { - live = cloudflareDouble(); - const result = await runProbe(["--receipt", receiptFile("{ not json"), "--api-base", live.base], { - CLOUDFLARE_API_TOKEN: "cf-token-123", - }); - expect(result.exitCode).toBe(1); - expect(result.stdout).toContain("the receipt is not JSON"); - }); -}); diff --git a/apps/server/scripts/canary/rollback-probe.ts b/apps/server/scripts/canary/rollback-probe.ts deleted file mode 100644 index 46a6a906..00000000 --- a/apps/server/scripts/canary/rollback-probe.ts +++ /dev/null @@ -1,172 +0,0 @@ -/* - * CN-24 — rollback readiness, probed against the real Cloudflare account. - * - * bun scripts/canary/rollback-probe.ts [--receipt ] - * - * Asserts three things about smithers-mvp-web: - * - * 1. the newest deploy receipt names a wrangler version id, - * 2. that version is the one Cloudflare is actually serving, - * 3. a prior version is still in Cloudflare's version list, so - * `wrangler rollback ` has something to target. - * - * "Reachable" means rollback-ELIGIBLE, not fetchable. A prior Worker version - * has no URL of its own; nothing can HTTP it. The probe says exactly what it - * checked and never implies more. - * - * Deliberately NOT automated: performing the rollback and rolling forward - * again. That swaps the live deployment, so it belongs in a human drill — - * DEPLOY.md carries the procedure and the receipt it must leave behind. - * - * Where it runs: deploy receipts are gitignored and exist only on the machine - * that deployed, so this belongs in the deploy workflow after a real deploy, - * not in a scheduled canary that has no receipt to read. - * - * Environment: - * CLOUDFLARE_API_TOKEN required; unset skips every check and exits 0 - * CLOUDFLARE_ACCOUNT_ID defaults to the account named in DEPLOY.md - * CANARY_RECEIPT_PATH defaults to ../../deploy-receipts/latest.json - */ -import { readFileSync } from "node:fs"; -import { - deployedVerdict, - parseDeployedVersions, - parseReceipt, - parseVersionList, - receiptVerdict, - rollbackVerdict, -} from "./rollback-verdict.ts"; - -const DEFAULT_ACCOUNT_ID = "dd3525a4132493566aeb38de533c8827"; -const DEFAULT_API_BASE = "https://api.cloudflare.com/client/v4"; - -const argOf = (name: string): string | undefined => { - const index = process.argv.indexOf(name); - return index === -1 ? undefined : process.argv[index + 1]; -}; - -const receiptPath = - argOf("--receipt") ?? process.env.CANARY_RECEIPT_PATH ?? new URL("../../deploy-receipts/latest.json", import.meta.url).pathname; -const worker = argOf("--worker") ?? "smithers-mvp-web"; -const accountId = argOf("--account") ?? process.env.CLOUDFLARE_ACCOUNT_ID ?? DEFAULT_ACCOUNT_ID; -const apiBase = argOf("--api-base") ?? process.env.CLOUDFLARE_API_BASE ?? DEFAULT_API_BASE; -const apiToken = process.env.CLOUDFLARE_API_TOKEN?.trim() ?? ""; - -let failures = 0; -let passed = 0; -let skipped = 0; -const check = (label: string, ok: boolean, detail: string): void => { - if (ok) { - passed += 1; - console.log(`ok: ${label} — ${detail}`); - } else { - failures += 1; - console.log(`FAIL: ${label} — ${detail}`); - } -}; -/* A skip is neither a pass nor a failure: it states what went unverified. */ -const skip = (label: string, detail: string): void => { - skipped += 1; - console.log(`skip: ${label} — ${detail}`); -}; - -/* Explicitly typed so TypeScript treats every call as terminating control flow. */ -const finish: () => never = () => { - if (failures > 0) { - console.log(`\nCN-24 ROLLBACK PROBE FAILED: ${failures} check(s), ${passed} passed, ${skipped} skipped.`); - process.exit(1); - } - /* A run that checked nothing is inconclusive, not a pass. It still exits 0: - * a receipt-less or uncredentialed run should not go red, but it must not - * report success. */ - if (passed === 0) { - console.log(`\nCN-24 ROLLBACK PROBE INCONCLUSIVE: nothing was verified, ${skipped} skipped.`); - } else { - console.log(`\nCN-24 ROLLBACK PROBE PASS: ${passed} check(s), 0 failures, ${skipped} skipped.`); - } - process.exit(0); -}; - -const cloudflareGet = async (path: string): Promise<{ ok: true; body: unknown } | { ok: false; detail: string }> => { - const target = `${apiBase.replace(/\/$/, "")}${path}`; - let response: Response; - try { - response = await fetch(target, { headers: { authorization: `Bearer ${apiToken}` } }); - } catch (error) { - return { ok: false, detail: `${target} is unreachable: ${error instanceof Error ? error.message : String(error)}` }; - } - const text = await response.text(); - if (response.status === 401 || response.status === 403) { - return { ok: false, detail: `HTTP ${response.status} — CLOUDFLARE_API_TOKEN cannot read ${worker}'s versions.` }; - } - try { - return { ok: true, body: JSON.parse(text) as unknown }; - } catch { - return { ok: false, detail: `HTTP ${response.status} and the body is not JSON: ${text.trim().slice(0, 200)}` }; - } -}; - -/* 1. The receipt. */ -let receiptText: string; -try { - receiptText = readFileSync(receiptPath, "utf8"); -} catch { - skip( - "the deploy receipt names a version", - `no receipt at ${receiptPath}. Receipts are gitignored and only exist on the machine that deployed; pass --receipt or set $CANARY_RECEIPT_PATH`, - ); - skip("the receipt names the version serving traffic", "no receipt to read"); - skip("a previous version is still rollback-eligible", "no receipt to read"); - finish(); -} - -const receipt = parseReceipt(receiptText); -if (!receipt.ok) { - check("the deploy receipt names a version", false, `${receiptPath}: ${receipt.detail}`); - finish(); -} - -const named = receiptVerdict(receipt.value); -check("the deploy receipt names a version", named.ok, named.detail); -const deployedVersionId = receipt.value.wranglerVersionId; - -/* 2 and 3 need Cloudflare. */ -if (apiToken === "") { - skip("the receipt names the version serving traffic", "CLOUDFLARE_API_TOKEN is unset"); - skip("a previous version is still rollback-eligible", "CLOUDFLARE_API_TOKEN is unset"); - finish(); -} - -const deployments = await cloudflareGet(`/accounts/${accountId}/workers/scripts/${worker}/deployments`); -if (!deployments.ok) { - check("the receipt names the version serving traffic", false, deployments.detail); -} else { - const parsed = parseDeployedVersions(deployments.body); - if (!parsed.ok) { - check("the receipt names the version serving traffic", false, parsed.detail); - } else if (deployedVersionId === null) { - check( - "the receipt names the version serving traffic", - false, - `the receipt names no version; Cloudflare is serving ${parsed.value.map((v) => v.id).join(", ")}`, - ); - } else { - const verdict = deployedVerdict(parsed.value, deployedVersionId); - check("the receipt names the version serving traffic", verdict.ok, verdict.detail); - } -} - -const versions = await cloudflareGet(`/accounts/${accountId}/workers/scripts/${worker}/versions`); -if (!versions.ok) { - check("a previous version is still rollback-eligible", false, versions.detail); -} else { - const parsed = parseVersionList(versions.body); - if (!parsed.ok) { - check("a previous version is still rollback-eligible", false, parsed.detail); - } else { - const verdict = rollbackVerdict(parsed.value, deployedVersionId); - check("a previous version is still rollback-eligible", verdict.ok, verdict.detail); - } -} - -finish(); diff --git a/apps/server/scripts/canary/rollback-verdict.ts b/apps/server/scripts/canary/rollback-verdict.ts deleted file mode 100644 index eebd81e2..00000000 --- a/apps/server/scripts/canary/rollback-verdict.ts +++ /dev/null @@ -1,224 +0,0 @@ -/* - * CN-24 — "the previous Worker version is reachable and the receipt names it" - * — as pure verdicts over already-fetched values. rollback-probe.ts is the - * process shell around this file. - * - * What "reachable" can honestly mean: a prior Worker version has no public - * URL, so nothing can fetch it. The assertable property is that it is - * ROLLBACK-ELIGIBLE — still in Cloudflare's version list for the Worker, with - * an id `wrangler rollback ` can target — and that the receipt names the - * version actually serving traffic. The verdicts below never claim more. - * - * Response shapes are read off wrangler 4.123.0's own client rather than - * guessed: `fetchDeployableVersions` destructures `{ items }` out of - * `GET /accounts/{account}/workers/scripts/{name}/versions`, and - * `fetchLatestDeployments` destructures `{ deployments }` out of - * `GET .../deployments`, both through `fetchResult`, which returns the - * envelope's `result`. Every parser here still returns a failure detail - * instead of throwing, so a shape change reads as one legible FAIL line. - */ - -export interface Verdict { - readonly ok: boolean; - readonly detail: string; -} - -export type Parsed = { readonly ok: true; readonly value: T } | { readonly ok: false; readonly detail: string }; - -export interface WorkerVersion { - readonly id: string; - readonly createdOn: string | undefined; - readonly message: string | undefined; -} - -export interface DeployedVersion { - readonly id: string; - readonly percentage: number; -} - -export interface Receipt { - readonly worker: string; - readonly dryRun: boolean; - readonly gitSha: string; - readonly timestamp: string; - readonly wranglerVersionId: string | null; -} - -const asRecord = (value: unknown): Record | undefined => - typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record) : undefined; - -const optionalString = (value: unknown): string | undefined => (typeof value === "string" ? value : undefined); - -const preview = (body: string): string => body.trim().replace(/\s+/g, " ").slice(0, 200); - -/** Read a deploy receipt written by scripts/deploy.ts. */ -export const parseReceipt = (text: string): Parsed => { - let raw: unknown; - try { - raw = JSON.parse(text) as unknown; - } catch { - return { ok: false, detail: `the receipt is not JSON: ${preview(text)}` }; - } - const record = asRecord(raw); - if (record === undefined) return { ok: false, detail: `the receipt is not an object: ${preview(text)}` }; - const worker = optionalString(record.worker); - const gitSha = optionalString(record.gitSha); - const timestamp = optionalString(record.timestamp); - if (worker === undefined || gitSha === undefined || timestamp === undefined) { - return { ok: false, detail: `the receipt is missing worker, gitSha, or timestamp: ${preview(text)}` }; - } - const versionId = record.wranglerVersionId; - if (versionId !== null && typeof versionId !== "string") { - return { ok: false, detail: `the receipt's wranglerVersionId is neither a string nor null: ${preview(text)}` }; - } - return { - ok: true, - value: { worker, dryRun: record.dryRun === true, gitSha, timestamp, wranglerVersionId: versionId }, - }; -}; - -/** Cloudflare's version list, newest first. */ -export const parseVersionList = (body: unknown): Parsed> => { - const envelope = asRecord(body); - if (envelope === undefined) return { ok: false, detail: "the versions response is not an object" }; - if (envelope.success === false) { - return { ok: false, detail: `the versions API refused the call: ${preview(JSON.stringify(envelope.errors ?? envelope))}` }; - } - const result = asRecord(envelope.result); - const items = result?.items; - if (!Array.isArray(items)) { - return { ok: false, detail: `the versions response carries no result.items array: ${preview(JSON.stringify(body))}` }; - } - const versions: Array = []; - for (const item of items) { - const record = asRecord(item); - const id = optionalString(record?.id); - if (id === undefined) { - return { ok: false, detail: `a version entry carries no id: ${preview(JSON.stringify(item))}` }; - } - versions.push({ - id, - createdOn: optionalString(asRecord(record?.metadata)?.created_on), - message: optionalString(asRecord(record?.annotations)?.["workers/message"]), - }); - } - return { ok: true, value: versions }; -}; - -/** The versions the newest deployment splits traffic across. */ -export const parseDeployedVersions = (body: unknown): Parsed> => { - const envelope = asRecord(body); - if (envelope === undefined) return { ok: false, detail: "the deployments response is not an object" }; - if (envelope.success === false) { - return { - ok: false, - detail: `the deployments API refused the call: ${preview(JSON.stringify(envelope.errors ?? envelope))}`, - }; - } - const deployments = asRecord(envelope.result)?.deployments; - if (!Array.isArray(deployments)) { - return { - ok: false, - detail: `the deployments response carries no result.deployments array: ${preview(JSON.stringify(body))}`, - }; - } - if (deployments.length === 0) return { ok: false, detail: "the Worker has no deployments" }; - const versions = asRecord(deployments[0])?.versions; - if (!Array.isArray(versions)) { - return { ok: false, detail: `the newest deployment carries no versions array: ${preview(JSON.stringify(deployments[0]))}` }; - } - const parsed: Array = []; - for (const entry of versions) { - const record = asRecord(entry); - const id = optionalString(record?.version_id); - const percentage = record?.percentage; - if (id === undefined || typeof percentage !== "number") { - return { ok: false, detail: `a deployed version entry is malformed: ${preview(JSON.stringify(entry))}` }; - } - parsed.push({ id, percentage }); - } - return { ok: true, value: parsed }; -}; - -/** - * The receipt names a version at all. A `null` here is the CN-24 gap itself: - * every receipt on disk records null because every recorded run was a dry run, - * and `wrangler deploy --dry-run` returns before it prints a version id. - */ -export const receiptVerdict = (receipt: Receipt): Verdict => { - if (receipt.dryRun) { - return { - ok: false, - detail: `the receipt from ${receipt.timestamp} is a --dry-run: it published nothing, so it names no deployed version. Roll a real deploy before reading rollback readiness from it.`, - }; - } - if (receipt.wranglerVersionId === null) { - return { - ok: false, - detail: "the deploy receipt names no wranglerVersionId, so no version can be confirmed deployed or rolled back to.", - }; - } - return { - ok: true, - detail: `the receipt from ${receipt.timestamp} (git ${receipt.gitSha.slice(0, 12)}) names version ${receipt.wranglerVersionId}`, - }; -}; - -/** The version the receipt names is the one actually serving traffic. */ -export const deployedVerdict = (deployed: ReadonlyArray, receiptVersionId: string): Verdict => { - const match = deployed.find((version) => version.id === receiptVersionId); - if (match === undefined) { - return { - ok: false, - detail: `the receipt names ${receiptVersionId} but the live deployment serves ${ - deployed.map((version) => `${version.id} (${version.percentage}%)`).join(", ") || "nothing" - } — the deployment drifted from the receipt.`, - }; - } - return { ok: true, detail: `${receiptVersionId} is serving ${match.percentage}% of traffic` }; -}; - -/** - * A prior version is still rollback-eligible. Cloudflare returns the version - * list newest first, so "prior" is the entry AFTER the deployed one — not - * merely the first entry that differs from it. The distinction matters when - * the receipt has fallen behind the live deployment: picking the first - * different entry would name a NEWER version as the rollback target. - */ -export const rollbackVerdict = ( - versions: ReadonlyArray, - deployedVersionId: string | null, -): Verdict => { - if (deployedVersionId === null) { - return { - ok: false, - detail: "the deploy receipt names no wranglerVersionId, so no version can be confirmed deployed or rolled back to.", - }; - } - if (versions.length < 2) { - return { - ok: false, - detail: `only ${versions.length} version(s) exist for this Worker: there is nothing to roll back to.`, - }; - } - const index = versions.findIndex((version) => version.id === deployedVersionId); - if (index === -1) { - return { - ok: false, - detail: `the receipt names version ${deployedVersionId}, which is not in Cloudflare's version list.`, - }; - } - const prior = versions[index + 1]; - if (prior === undefined) { - return { - ok: false, - detail: `${deployedVersionId} is the oldest version Cloudflare still lists: there is nothing older to roll back to.`, - }; - } - return { - ok: true, - detail: `deployed ${deployedVersionId}; the previous version ${prior.id} (created ${ - prior.createdOn ?? "unknown" - }) is still rollback-eligible — roll back with: bun x wrangler@4.123.0 rollback ${prior.id}`, - }; -}; diff --git a/apps/server/scripts/canary/uptime-checks.test.ts b/apps/server/scripts/canary/uptime-checks.test.ts deleted file mode 100644 index accf93ad..00000000 --- a/apps/server/scripts/canary/uptime-checks.test.ts +++ /dev/null @@ -1,743 +0,0 @@ -/* - * Every decision the canary uptime probe makes, exercised against fixtures. - * - * The lane that wrote these has no credential and no live deployment, so the - * network call is the only line here that cannot be covered. `runUptimeProbe` - * takes its fetch as a dependency for exactly that reason: the tests below - * drive whole probe runs — a healthy deployment, a dead endpoint, a slow turn - * seam, a turn that streams nothing — without touching a network. - */ -import { describe, expect, test } from "bun:test"; -import { readFileSync } from "node:fs"; -import { fileURLToPath } from "node:url"; -import { - ALERT_TITLE, - alertAction, - type Check, - coerceReport, - endpointPlan, - ERROR_RATE_SCOPE_NOTE, - ERROR_RATE_THRESHOLD, - errorRateVerdict, - isErrorSample, - LATENCY_BUDGETS_MS, - latencyVerdict, - meteredTurnSample, - median, - type ProbeDeps, - type ProbeOptions, - type ProbeReport, - probeFailed, - renderAlertBody, - resolveProbeOrigin, - runUptimeProbe, - type Sample, - tallyChecks, - TURN_FIRST_FRAME_NOTE, - TURN_FIRST_FRAME_SAMPLES, - uptimeVerdict, -} from "./uptime-checks.ts"; - -const sample = (over: Partial = {}): Sample => ({ - label: "spa", - status: 200, - expectedStatus: 200, - elapsedMs: 100, - transportError: undefined, - ...over, -}); - -const byId = (checks: ReadonlyArray, id: string): Check => { - const found = checks.find((check) => check.id === id); - if (found === undefined) throw new Error(`no check with id ${id} in ${checks.map((c) => c.id).join(", ")}`); - return found; -}; - -/* - * The regression this file exists to keep dead. - * - * The probe used to resolve its origin as the first non-flag token anywhere in - * argv. The scheduled workflow passes `--json ` and no positional, so - * the origin became the temp-file path, every sample recorded a transport - * error, and the run reported the deployment fully down no matter what the - * deployment was doing. The first test below is that exact invocation. - */ -describe("resolveProbeOrigin", () => { - const WORKFLOW_ARGV = ["--json", "/home/runner/work/_temp/canary-uptime.json"]; - - test("the scheduled workflow's argument form probes $CANARY_URL, never the --json path", () => { - expect(resolveProbeOrigin(WORKFLOW_ARGV, { CANARY_URL: "https://canary.smithers.sh" })).toEqual({ - origin: "https://canary.smithers.sh", - }); - }); - - test("the workflow's argument form with the origin passed first still probes the origin", () => { - expect( - resolveProbeOrigin(["https://canary.smithers.sh", ...WORKFLOW_ARGV], { CANARY_URL: "https://canary.smithers.sh" }), - ).toEqual({ origin: "https://canary.smithers.sh" }); - }); - - test("a flag's value is never the origin, even with no CANARY_URL to fall back to", () => { - expect(resolveProbeOrigin(WORKFLOW_ARGV, {})).toEqual({ origin: "https://canary.smithers.sh" }); - expect(resolveProbeOrigin(["--samples", "1", "--gap-ms", "1"], {})).toEqual({ - origin: "https://canary.smithers.sh", - }); - }); - - test("a positional origin beats $CANARY_URL", () => { - expect(resolveProbeOrigin(["https://staging.test"], { CANARY_URL: "https://env.test" })).toEqual({ - origin: "https://staging.test", - }); - }); - - test("trailing slashes are dropped so origin + path never doubles one", () => { - expect(resolveProbeOrigin(["https://staging.test//"], {})).toEqual({ origin: "https://staging.test" }); - }); - - test("a filesystem path is refused by name instead of being probed", () => { - const resolution = resolveProbeOrigin([], { CANARY_URL: "/home/runner/work/_temp/canary-uptime.json" }); - expect(resolution).not.toHaveProperty("origin"); - expect("error" in resolution ? resolution.error : "").toContain('refusing to probe "/home/runner/work/_temp/canary-uptime.json"'); - expect("error" in resolution ? resolution.error : "").toContain("that is not a URL"); - }); - - test("a bare hostname with no scheme is refused, because it is not a URL", () => { - const resolution = resolveProbeOrigin(["canary.smithers.sh"], {}); - expect("error" in resolution ? resolution.error : "").toContain("is not a URL"); - }); - - test("a URL that is not http or https is refused and names the scheme", () => { - const resolution = resolveProbeOrigin(["file:///tmp/canary-uptime.json"], {}); - expect("error" in resolution ? resolution.error : "").toContain("file:// is not http or https"); - }); - - test("an empty $CANARY_URL is refused rather than silently becoming a relative fetch", () => { - const resolution = resolveProbeOrigin([], { CANARY_URL: "" }); - expect("error" in resolution ? resolution.error : "").toContain("no origin to probe"); - }); - - test("http is accepted, so a local deployment can be probed", () => { - expect(resolveProbeOrigin(["http://127.0.0.1:8787"], {})).toEqual({ origin: "http://127.0.0.1:8787" }); - }); -}); - -describe("median", () => { - test("an empty list has no median, so it answers undefined instead of zero", () => { - expect(median([])).toBeUndefined(); - }); - - test("an odd count takes the middle value", () => { - expect(median([300, 100, 200])).toBe(200); - }); - - test("an even count averages the two middle values", () => { - expect(median([100, 200, 300, 500])).toBe(250); - }); - - test("the input is not reordered in place", () => { - const values = [300, 100, 200]; - median(values); - expect(values).toEqual([300, 100, 200]); - }); -}); - -describe("isErrorSample", () => { - test("the expected status is not an error, even when it is a refusal", () => { - expect(isErrorSample(sample({ label: "turn-gate", status: 401, expectedStatus: 401 }))).toBe(false); - }); - - test("a transport failure is an error", () => { - expect(isErrorSample(sample({ status: undefined, transportError: "TimeoutError: timed out" }))).toBe(true); - }); - - test("a 5xx is an error", () => { - expect(isErrorSample(sample({ status: 503 }))).toBe(true); - }); - - test("an unexpected 4xx is an error: a gate that starts refusing valid traffic is an outage", () => { - expect(isErrorSample(sample({ status: 401, expectedStatus: 200 }))).toBe(true); - }); -}); - -describe("latencyVerdict (CN-19)", () => { - test("under budget passes and reports median, max and budget", () => { - const check = latencyVerdict("latency:spa", "spa latency", [sample({ elapsedMs: 100 }), sample({ elapsedMs: 300 })], 2_000); - expect(check.status).toBe("pass"); - expect(check.detail).toBe("spa latency: median 200ms, max 300ms (budget 2000ms) over 2 answered sample(s) of 2"); - }); - - test("over budget fails", () => { - const check = latencyVerdict( - "latency:spa", - "spa latency", - [sample({ elapsedMs: 2_400 }), sample({ elapsedMs: 2_600 }), sample({ elapsedMs: 2_500 })], - 2_000, - ); - expect(check.status).toBe("fail"); - expect(check.detail).toContain("median 2500ms"); - expect(check.detail).toContain("budget 2000ms"); - }); - - test("exactly at the budget passes: a budget is a ceiling, not an exclusive bound", () => { - expect(latencyVerdict("latency:spa", "spa latency", [sample({ elapsedMs: 2_000 })], 2_000).status).toBe("pass"); - }); - - test("one slow sample cannot fail the budget, because the verdict reads the median", () => { - const check = latencyVerdict( - "latency:spa", - "spa latency", - [sample({ elapsedMs: 120 }), sample({ elapsedMs: 9_000 }), sample({ elapsedMs: 140 })], - 2_000, - ); - expect(check.status).toBe("pass"); - expect(check.detail).toContain("max 9000ms"); - }); - - test("zero samples is skipped, never passed", () => { - const check = latencyVerdict("latency:spa", "spa latency", [], 2_000); - expect(check.status).toBe("skip"); - expect(check.detail).toContain("no samples were taken"); - }); - - test("all requests failing is a latency failure, not an unmeasured skip", () => { - const check = latencyVerdict( - "latency:spa", - "spa latency", - [ - sample({ status: undefined, transportError: "ConnectionRefused", elapsedMs: 12 }), - sample({ status: undefined, transportError: "ConnectionRefused", elapsedMs: 9 }), - ], - 2_000, - ); - expect(check.status).toBe("fail"); - expect(check.detail).toContain("none of 2 request(s) answered"); - }); - - test("a 5xx still carries a duration, so it is measured rather than discarded", () => { - const check = latencyVerdict("latency:spa", "spa latency", [sample({ status: 500, elapsedMs: 30 })], 2_000); - expect(check.status).toBe("pass"); - expect(check.detail).toContain("over 1 answered sample(s) of 1"); - }); -}); - -describe("errorRateVerdict (CN-20)", () => { - const many = (count: number, over: Partial = {}): Array => - Array.from({ length: count }, () => sample(over)); - - test("zero samples is skipped with no rate", () => { - const { check, rate } = errorRateVerdict([], ERROR_RATE_THRESHOLD); - expect(check.status).toBe("skip"); - expect(rate).toBeUndefined(); - expect(check.detail).toContain("no requests were made"); - }); - - test("too few samples is skipped rather than dressed up as a percentage", () => { - const { check, rate } = errorRateVerdict(many(4), ERROR_RATE_THRESHOLD); - expect(check.status).toBe("skip"); - expect(rate).toBeUndefined(); - expect(check.detail).toContain("only 4 sample(s)"); - }); - - test("a clean run passes with a zero rate", () => { - const { check, rate } = errorRateVerdict(many(15), ERROR_RATE_THRESHOLD); - expect(check.status).toBe("pass"); - expect(rate).toBe(0); - expect(check.detail).toBe(`0/15 probe request(s) failed (rate 0.0%, threshold 5.0%) — ${ERROR_RATE_SCOPE_NOTE}`); - }); - - test("exactly at the threshold passes", () => { - const samples = [...many(19), sample({ status: 502 })]; - const { check, rate } = errorRateVerdict(samples, ERROR_RATE_THRESHOLD); - expect(rate).toBe(0.05); - expect(check.status).toBe("pass"); - }); - - test("one failure in fifteen exceeds the threshold and names the failure", () => { - const samples = [...many(14), sample({ label: "scopes", status: 502 })]; - const { check, rate } = errorRateVerdict(samples, ERROR_RATE_THRESHOLD); - expect(rate).toBeCloseTo(1 / 15, 10); - expect(check.status).toBe("fail"); - expect(check.detail).toContain("1/15 probe request(s) failed"); - expect(check.detail).toContain("scopes (HTTP 502, expected 200)"); - }); - - test("identical failures collapse to one reason, so the issue body stays readable", () => { - const { check } = errorRateVerdict(many(10, { status: 503 }), ERROR_RATE_THRESHOLD); - expect(check.detail).toBe( - `10/10 probe request(s) failed (rate 100.0%, threshold 5.0%): spa (HTTP 503, expected 200) — ${ERROR_RATE_SCOPE_NOTE}`, - ); - }); - - test("more distinct reasons than fit are summarized rather than dumped", () => { - const samples = Array.from({ length: 8 }, (_unused, index) => - sample({ label: `endpoint-${String(index)}`, status: 500 + index }), - ); - const { check } = errorRateVerdict(samples, ERROR_RATE_THRESHOLD); - expect(check.detail).toContain("and 2 other distinct failure(s)"); - expect(check.detail).toContain("endpoint-0 (HTTP 500, expected 200)"); - expect(check.detail).not.toContain("endpoint-7"); - }); - - test("every request failing is a 100% rate that names the transport error", () => { - const { check, rate } = errorRateVerdict( - many(10, { status: undefined, transportError: "TimeoutError: The operation timed out." }), - ERROR_RATE_THRESHOLD, - ); - expect(rate).toBe(1); - expect(check.status).toBe("fail"); - expect(check.detail).toContain("10/10 probe request(s) failed (rate 100.0%, threshold 5.0%)"); - expect(check.detail).toContain("TimeoutError"); - }); -}); - -describe("uptimeVerdict (CN-21)", () => { - test("no endpoints probed is skipped", () => { - expect(uptimeVerdict([]).status).toBe("skip"); - }); - - test("every endpoint answering passes and reports the per-endpoint tally", () => { - const check = uptimeVerdict([ - sample({ label: "spa" }), - sample({ label: "spa" }), - sample({ label: "turn-gate", status: 401, expectedStatus: 401 }), - ]); - expect(check.status).toBe("pass"); - expect(check.detail).toBe("2/2 endpoint(s) answered — spa 2/2, turn-gate 1/1"); - }); - - test("an endpoint that answered once is up: unreliability is the error rate's finding, not this one's", () => { - const check = uptimeVerdict([sample({ label: "spa" }), sample({ label: "spa", status: 503 })]); - expect(check.status).toBe("pass"); - expect(check.detail).toContain("spa 1/2"); - }); - - test("an endpoint with no good sample is down and is named", () => { - const check = uptimeVerdict([ - sample({ label: "spa" }), - sample({ label: "scopes", status: undefined, transportError: "ConnectionRefused" }), - sample({ label: "scopes", status: undefined, transportError: "ConnectionRefused" }), - ]); - expect(check.status).toBe("fail"); - expect(check.detail).toContain("1/2 endpoint(s) answered"); - expect(check.detail).toContain("fully down: scopes"); - }); -}); - -describe("probeFailed and tallyChecks", () => { - const checks: Array = [ - { id: "a", label: "a", status: "pass", detail: "" }, - { id: "b", label: "b", status: "skip", detail: "" }, - ]; - - test("a skipped check never fails a run", () => { - expect(probeFailed(checks)).toBe(false); - }); - - test("one failed check fails the run", () => { - expect(probeFailed([...checks, { id: "c", label: "c", status: "fail", detail: "" }])).toBe(true); - }); - - test("the tally counts each status", () => { - expect(tallyChecks([...checks, { id: "c", label: "c", status: "fail", detail: "" }])).toEqual({ - passed: 1, - failed: 1, - skipped: 1, - }); - }); -}); - -describe("coerceReport", () => { - const good: ProbeReport = { - origin: "https://canary.smithers.sh", - generatedAt: "2026-08-18T00:00:00.000Z", - samples: [sample()], - checks: [{ id: "uptime", label: "every probed endpoint answered", status: "pass", detail: "d" }], - errorRate: 0, - meteredTurns: 1, - failed: false, - }; - - test("a round-tripped report survives JSON unchanged", () => { - expect(coerceReport(JSON.parse(JSON.stringify(good)), "report.json")).toEqual(good); - }); - - test("a missing report is a FAILING report that names the file", () => { - const report = coerceReport(undefined, "/tmp/uptime.json"); - expect(report.failed).toBe(true); - expect(report.checks[0]!.status).toBe("fail"); - expect(report.checks[0]!.detail).toContain("/tmp/uptime.json"); - expect(report.checks[0]!.detail).toContain("proved nothing about the deployment"); - }); - - test("a report whose checks are not checks is treated as no report at all", () => { - expect(coerceReport({ ...good, checks: [{ id: 1 }] }, "report.json").failed).toBe(true); - }); - - test("a report missing its verdict is treated as no report at all", () => { - const { failed: _dropped, ...withoutVerdict } = good; - expect(coerceReport(withoutVerdict, "report.json").checks[0]!.id).toBe("probe-report"); - }); -}); - -describe("alertAction", () => { - const report = (failed: boolean): ProbeReport => ({ - origin: "https://canary.smithers.sh", - generatedAt: "2026-08-18T00:00:00.000Z", - samples: [], - checks: [{ id: "uptime", label: "every probed endpoint answered", status: failed ? "fail" : "pass", detail: "d" }], - errorRate: 0, - meteredTurns: 0, - failed, - }); - const runUrl = "https://github.com/smithersai/flows/actions/runs/1"; - - test("a first failure opens the one issue, under the fixed title", () => { - const action = alertAction({ report: report(true), openIssue: undefined, runUrl }); - expect(action.kind).toBe("create"); - if (action.kind !== "create") throw new Error("unreachable"); - expect(action.title).toBe(ALERT_TITLE); - expect(action.body).toContain("https://canary.smithers.sh"); - }); - - test("a repeat failure comments on the open issue instead of opening a second", () => { - const action = alertAction({ report: report(true), openIssue: 42, runUrl }); - expect(action.kind).toBe("comment"); - if (action.kind !== "comment") throw new Error("unreachable"); - expect(action.issue).toBe(42); - }); - - test("recovery closes the open issue, so the alert cannot become a stale banner", () => { - const action = alertAction({ report: report(false), openIssue: 42, runUrl }); - expect(action.kind).toBe("close"); - if (action.kind !== "close") throw new Error("unreachable"); - expect(action.issue).toBe(42); - expect(action.body).toStartWith("The canary recovered."); - }); - - test("a passing run with nothing open does nothing at all", () => { - const action = alertAction({ report: report(false), openIssue: undefined, runUrl }); - expect(action.kind).toBe("none"); - }); - - test("the body carries the run link, the metered-turn count and every check", () => { - const body = renderAlertBody(report(true), runUrl); - expect(body).toContain(`Run: ${runUrl}`); - expect(body).toContain("Metered turns spent by this run: 0"); - expect(body).toContain("| FAIL | every probed endpoint answered | d |"); - }); -}); - -describe("endpointPlan", () => { - test("it probes the SPA, the scopes read and the signed-out turn gate", () => { - expect(endpointPlan("run-1").map((endpoint) => endpoint.label)).toEqual(["spa", "scopes", "turn-gate"]); - }); - - test("the turn gate expects the 401 refusal, so a 200 there is an error", () => { - const gate = endpointPlan("run-1").find((endpoint) => endpoint.label === "turn-gate")!; - expect(gate.expectedStatus).toBe(401); - expect(gate.method).toBe("POST"); - expect(JSON.parse(gate.body!)).toMatchObject({ runId: "run-1" }); - }); -}); - -/* - * Whole probe runs against a fake transport. The clock advances a fixed step - * per reading, so every sample's elapsedMs is exactly `step` and the budgets - * are the only thing under test. - */ -const makeDeps = ( - step: number, - handler: (url: string, init: RequestInit) => Response | Promise, -): { deps: ProbeDeps; calls: Array<{ url: string; init: RequestInit }> } => { - let clock = 0; - const calls: Array<{ url: string; init: RequestInit }> = []; - return { - calls, - deps: { - now: () => { - const value = clock; - clock += step; - return value; - }, - sleep: async () => {}, - fetch: async (url, init) => { - calls.push({ url, init }); - return await handler(url, init); - }, - }, - }; -}; - -const options = (over: Partial = {}): ProbeOptions => ({ - origin: "https://canary.test", - samplesPerEndpoint: 5, - gapMs: 0, - requestTimeoutMs: 20_000, - sessionCookie: undefined, - runId: "run-1", - ...over, -}); - -const healthy = (url: string): Response => { - if (url.endsWith("/api/agent/turn")) return new Response("Unauthorized", { status: 401 }); - return new Response("ok", { status: 200 }); -}; - -const ndjson = (frames: ReadonlyArray): ReadableStream => - new ReadableStream({ - start(controller) { - for (const frame of frames) controller.enqueue(new TextEncoder().encode(`${frame}\n`)); - controller.close(); - }, - }); - -const chunked = (chunks: ReadonlyArray): ReadableStream => - new ReadableStream({ - start(controller) { - for (const chunk of chunks) controller.enqueue(new TextEncoder().encode(chunk)); - controller.close(); - }, - }); - -describe("runUptimeProbe", () => { - test("a healthy deployment passes uptime, every latency budget and the error rate", async () => { - const { deps, calls } = makeDeps(50, (url) => healthy(url)); - const report = await runUptimeProbe(deps, options()); - - expect(report.failed).toBe(false); - expect(report.samples).toHaveLength(15); - expect(calls).toHaveLength(15); - expect(report.errorRate).toBe(0); - expect(report.meteredTurns).toBe(0); - expect(byId(report.checks, "uptime").status).toBe("pass"); - expect(byId(report.checks, "latency:spa").status).toBe("pass"); - expect(byId(report.checks, "latency:turn-gate").status).toBe("pass"); - expect(byId(report.checks, "error-rate").status).toBe("pass"); - }); - - test("with no session cookie the turn-seam latency check is skipped and says why", async () => { - const { deps } = makeDeps(50, (url) => healthy(url)); - const report = await runUptimeProbe(deps, options()); - const check = byId(report.checks, "latency:turn-first-frame"); - - expect(check.status).toBe("skip"); - expect(check.detail).toContain("$CANARY_SESSION_COOKIE is unset"); - expect(report.failed).toBe(false); - }); - - test("a slow deployment fails only the latency checks it actually blew", async () => { - const { deps } = makeDeps(1_600, (url) => healthy(url)); - const report = await runUptimeProbe(deps, options()); - - expect(byId(report.checks, "uptime").status).toBe("pass"); - expect(byId(report.checks, "error-rate").status).toBe("pass"); - // 1600ms is inside the 2000ms SPA budget and outside the 1500ms - // budgets for the two API reads. - expect(byId(report.checks, "latency:spa").status).toBe("pass"); - expect(byId(report.checks, "latency:scopes").status).toBe("fail"); - expect(byId(report.checks, "latency:turn-gate").status).toBe("fail"); - expect(report.failed).toBe(true); - }); - - test("a dead endpoint fails uptime, its latency and the error rate together", async () => { - const { deps } = makeDeps(20, (url) => { - if (url.endsWith("/api/auth/scopes")) throw new Error("connect ECONNREFUSED"); - return healthy(url); - }); - const report = await runUptimeProbe(deps, options()); - - expect(byId(report.checks, "uptime").detail).toContain("fully down: scopes"); - expect(byId(report.checks, "latency:scopes").status).toBe("fail"); - expect(byId(report.checks, "error-rate").status).toBe("fail"); - expect(report.errorRate).toBeCloseTo(5 / 15, 10); - expect(report.samples.filter((s) => s.transportError !== undefined)).toHaveLength(5); - expect(report.failed).toBe(true); - }); - - test("a turn seam that stops refusing anonymous callers is an error, not a pass", async () => { - const { deps } = makeDeps(20, (url) => - url.endsWith("/api/agent/turn") ? new Response("streaming", { status: 200 }) : healthy(url), - ); - const report = await runUptimeProbe(deps, options()); - - expect(byId(report.checks, "error-rate").detail).toContain("turn-gate (HTTP 200, expected 401)"); - expect(report.failed).toBe(true); - }); - - test("with a session cookie the probe takes exactly one metered turn and times its first frame", async () => { - const { deps, calls } = makeDeps(3_000, (url, init) => { - if (url.endsWith("/api/agent/turn") && (init.headers as Record).cookie !== undefined) { - return new Response(ndjson(['{"runId":"run-1-metered","type":"delta","kind":"text","text":"ok"}']), { status: 200 }); - } - return healthy(url); - }); - const report = await runUptimeProbe(deps, options({ sessionCookie: "smithers_session=abc" })); - - expect(report.meteredTurns).toBe(1); - expect(calls.filter((call) => (call.init.headers as Record).cookie !== undefined)).toHaveLength(1); - const check = byId(report.checks, "latency:turn-first-frame"); - expect(check.status).toBe("pass"); - expect(check.detail).toContain(`budget ${LATENCY_BUDGETS_MS.turnFirstFrame}ms`); - }); - - test("the metered sample decodes one complete split NDJSON frame", async () => { - const frame = '{"runId":"run-1-metered","type":"delta","kind":"text","text":"ok"}\n'; - const { deps } = makeDeps(1, () => new Response(chunked([frame.slice(0, 12), frame.slice(12)]), { status: 200 })); - const result = await meteredTurnSample(deps, options(), "s=1"); - expect(result.transportError).toBeUndefined(); - }); - - test("the metered sample rejects corrupt, foreign-run, and HTML first frames", async () => { - for (const body of [ - "{broken}\n", - '{"runId":"other","type":"delta","kind":"text","text":"ok"}\n', - "upstream error\n", - ]) { - const { deps } = makeDeps(1, () => new Response(body, { status: 200 })); - const result = await meteredTurnSample(deps, options(), "s=1"); - expect(result.transportError).toBeDefined(); - } - }); - - test("a metered turn slower than the first-frame budget fails", async () => { - const { deps } = makeDeps(LATENCY_BUDGETS_MS.turnFirstFrame + 1_000, (url, init) => { - if (url.endsWith("/api/agent/turn") && (init.headers as Record).cookie !== undefined) { - return new Response(ndjson(['{"runId":"run-1-metered","type":"delta","kind":"text","text":"ok"}']), { status: 200 }); - } - return healthy(url); - }); - const report = await runUptimeProbe(deps, options({ samplesPerEndpoint: 1, sessionCookie: "s=1" })); - - expect(byId(report.checks, "latency:turn-first-frame").status).toBe("fail"); - expect(report.failed).toBe(true); - }); - - test("a 200 that streams no frame is a failed turn, never a very fast one", async () => { - const { deps } = makeDeps(10, (url, init) => { - if (url.endsWith("/api/agent/turn") && (init.headers as Record).cookie !== undefined) { - return new Response(ndjson([]), { status: 200 }); - } - return healthy(url); - }); - const report = await runUptimeProbe(deps, options({ samplesPerEndpoint: 5, sessionCookie: "s=1" })); - - const turn = report.samples.find((s) => s.label === "turn-first-frame")!; - expect(turn.transportError).toBe("the turn seam answered 200 but streamed no frame"); - expect(byId(report.checks, "latency:turn-first-frame").status).toBe("fail"); - expect(byId(report.checks, "uptime").detail).toContain("turn-first-frame 0/1"); - expect(report.failed).toBe(true); - }); - - test("a signed-in turn refused with a 401 is recorded as the failure it is", async () => { - const { deps } = makeDeps(10, (url, init) => - url.endsWith("/api/agent/turn") && (init.headers as Record).cookie !== undefined - ? new Response("Unauthorized", { status: 401 }) - : healthy(url), - ); - const report = await runUptimeProbe(deps, options({ sessionCookie: "expired" })); - - const turn = report.samples.find((s) => s.label === "turn-first-frame")!; - expect(turn.status).toBe(401); - expect(turn.expectedStatus).toBe(200); - expect(report.failed).toBe(true); - }); - - test("the probe sleeps between samples so consecutive samples are independent", async () => { - let sleeps = 0; - const { deps } = makeDeps(10, (url) => healthy(url)); - const counting: ProbeDeps = { - ...deps, - sleep: async () => { - sleeps += 1; - }, - }; - await runUptimeProbe(counting, options({ samplesPerEndpoint: 2 })); - // Six samples, five gaps: nothing is slept before the first request. - expect(sleeps).toBe(5); - }); - - /* - * CN-19's honesty gate. The turn seam costs model credit, so it is sampled - * once (TURN_FIRST_FRAME_SAMPLES). A verdict built on one observation must - * not describe itself with a word that implies many, so the detail carries - * the note and the alert issue carries the detail. - */ - test("the turn-seam verdict declares that it is a single sample, in the line a human reads", async () => { - const { deps, calls } = makeDeps(3_000, (url, init) => { - if (url.endsWith("/api/agent/turn") && (init.headers as Record).cookie !== undefined) { - return new Response(ndjson(['{"runId":"run-1-metered","type":"delta","kind":"text","text":"ok"}']), { status: 200 }); - } - return healthy(url); - }); - const report = await runUptimeProbe(deps, options({ sessionCookie: "s=1" })); - - expect(TURN_FIRST_FRAME_SAMPLES).toBe(1); - expect(report.meteredTurns).toBe(TURN_FIRST_FRAME_SAMPLES); - expect(calls.filter((call) => (call.init.headers as Record).cookie !== undefined)).toHaveLength( - TURN_FIRST_FRAME_SAMPLES, - ); - const check = byId(report.checks, "latency:turn-first-frame"); - expect(check.detail).toContain(TURN_FIRST_FRAME_NOTE); - expect(renderAlertBody(report, "https://runs.test/1")).toContain(TURN_FIRST_FRAME_NOTE); - }); - - /* - * CN-20's caveat used to live only in a doc comment. The number it qualifies - * is read in a GitHub issue, so the caveat has to be there too. - */ - test("the error-rate verdict carries its scope caveat into the alert issue body", async () => { - const { deps } = makeDeps(20, (url) => { - if (url.endsWith("/api/auth/scopes")) throw new Error("connect ECONNREFUSED"); - return healthy(url); - }); - const report = await runUptimeProbe(deps, options()); - - const check = byId(report.checks, "error-rate"); - expect(check.status).toBe("fail"); - expect(check.detail).toContain(ERROR_RATE_SCOPE_NOTE); - expect(renderAlertBody(report, "https://runs.test/1")).toContain(ERROR_RATE_SCOPE_NOTE); - }); - - test("the report names the origin it probed and stamps when it ran", async () => { - const { deps } = makeDeps(1, (url) => healthy(url)); - const report = await runUptimeProbe(deps, options({ origin: "https://other.test", samplesPerEndpoint: 5 })); - - expect(report.origin).toBe("https://other.test"); - expect(report.generatedAt).toBe(new Date(0).toISOString()); - expect(report.samples.every((s) => s.label !== "turn-first-frame")).toBe(true); - }); -}); - -/* - * The belt-and-braces half of the same regression, checked against the file - * that actually invokes the probe. - * - * `resolveProbeOrigin` above makes the workflow's flag-only form safe. This - * block keeps the workflow from drifting back to a form that relies on that - * safety net alone: the origin is passed first and positionally, and the - * environment the probe falls back to is exported on the same step. A test - * that only checked the pure function would stay green while the workflow - * once again handed the probe a temp-file path. - */ -describe("the scheduled workflow invokes the probe with an origin", () => { - const canaryYml = readFileSync(fileURLToPath(new URL("../../../../.github/workflows/canary.yml", import.meta.url)), "utf8"); - const invocation = canaryYml - .split("\n") - .map((line) => line.trim()) - .find((line) => line.includes("scripts/canary/uptime-probe.ts")); - - test("the workflow invokes the probe at all", () => { - // A guard on the guard: an invocation this block cannot find would make - // every assertion below vacuous. - expect(invocation).toBeDefined(); - }); - - test("the origin is the first argument, before any flag", () => { - const args = (invocation as string).split("scripts/canary/uptime-probe.ts")[1]!.trim().split(/\s+/); - expect(args[0]).toBe('"$CANARY_URL"'); - }); - - test("the step exports the CANARY_URL the invocation and the fallback both read", () => { - expect(canaryYml).toMatch(/^\s*CANARY_URL: /m); - }); -}); diff --git a/apps/server/scripts/canary/uptime-checks.ts b/apps/server/scripts/canary/uptime-checks.ts deleted file mode 100644 index e1adf9ce..00000000 --- a/apps/server/scripts/canary/uptime-checks.ts +++ /dev/null @@ -1,763 +0,0 @@ -/* - * CN-19, CN-20 and CN-21 — the pure half of the canary uptime probe. - * - * Everything here is a total function over recorded observations. The only - * untested line in this lane is the network call itself: `uptime-probe.ts` - * supplies a real `fetch`, `runUptimeProbe` takes it as a dependency, and the - * tests beside this file supply a fake. That split exists because the lane has - * no credential and no live deployment, so a probe whose decision logic could - * only be exercised against production would never have been demonstrated at - * all. - * - * Three separate questions, deliberately not collapsed into one number: - * - * CN-21 uptime — did each endpoint answer at all? - * CN-20 error rate — of the requests we made, what fraction failed? - * CN-19 latency — how long did the ones that answered take? - * - * An endpoint that is down fails all three. An endpoint that is up but flaky - * fails only the error rate. An endpoint that is up and reliable but slow fails - * only latency. Collapsing them would lose which of the three is true. - */ -import { AUTH_SCOPES_PATH, TURN_PATH } from "smithers-shared/AgentApiRoutes"; -import { AgentTurnFrameSchema } from "smithers-shared/NativeAgent"; -import { resolveOrigin } from "./BuildStamp.ts"; - -const FIRST_FRAME_MAX_BYTES = 64 * 1024; - -/** - * The end-to-end product bar this repo already states: row A-2's - * `FIRST_MESSAGE_BUDGET_MS` in apps/ui/src/launch-checklist/Probes.ts. It is - * the only latency contract written down anywhere in the tree, so every budget - * below is derived from it rather than invented. - */ -export const FIRST_MESSAGE_BUDGET_MS = 90_000; - -/* - * How the 90s end-to-end bar is divided. - * - * A-2's 90s covers the whole journey: OAuth round trip, SPA download and - * hydrate, session read, first-run recommendation, then the turn seam - * streaming its first useful token. The turn seam is one segment of that - * journey, so its budget must be a fraction of 90s with room left for the - * others. - * - * turnFirstFrame 30_000 — one third of the bar, leaving 60s for every other - * segment, which the segments below show is ample. It is deliberately - * twice the 15s a median-backed budget would use, because this endpoint is - * sampled ONCE (see TURN_FIRST_FRAME_SAMPLES): a single sample has no - * median to absorb a Cloudflare Worker cold start, so the budget absorbs - * it instead. At 30s a single sample is a broken turn seam, not a cold one. - * spa 2_000 — the SPA is static assets from Cloudflare's edge. This is a - * transfer, not a computation. - * scopes 1_500 and turnGate 1_500 — both refuse or answer without reaching - * an upstream. `/api/agent/turn` signed out is rejected by - * requireTurnSession before the rate limiter and before any model call - * (apps/server/src/index.ts), so this measures the gate, not a turn. - * - * The three unmetered budgets are budgets for the MEDIAN of SAMPLES_PER_ENDPOINT - * samples, so one slow cold start cannot open an issue. turnFirstFrame is the - * documented exception and is handled by its size, not by a median. - */ -export const LATENCY_BUDGETS_MS = { - spa: 2_000, - scopes: 1_500, - turnGate: 1_500, - turnFirstFrame: 30_000, -} as const; - -/* - * How many times a metered run samples the turn seam, and what that costs. - * - * One. Every sample here spends real model credit — a nine-word prompt and a - * cancelled stream, but a whole short turn as far as the account is concerned. - * The scheduled workflow takes the metered sample on the hourly tick only, so - * one sample is 24 short turns a day. Three samples, the smallest count for - * which a median means anything, would be 72 a day for a probe whose job is to - * notice an outage that the free turn-gate sample already notices. - * - * The cost of that choice, stated rather than hidden: this one endpoint is - * judged on a single observation, so a cold start reads exactly like a - * regression. The budget above is doubled to absorb that, and every verdict - * this endpoint produces carries TURN_FIRST_FRAME_NOTE so the human reading - * the GitHub issue is told which kind of number they are looking at. Raise - * this to 3 and the median claim becomes true again; the bill triples. - */ -export const TURN_FIRST_FRAME_SAMPLES = 1; - -/** - * Travels in the check detail, and therefore into the alert issue body. Change - * it in the same edit that changes TURN_FIRST_FRAME_SAMPLES: its whole job is - * to state the sampling the number above actually does. - */ -export const TURN_FIRST_FRAME_NOTE = - "single sample: the turn seam spends model credit, so this endpoint is measured once per metered run and its budget absorbs a cold start instead of a median"; - -/** - * CN-20's threshold. Read the honest scope note on `errorRateVerdict` before - * changing it: this is the failure rate of THIS probe's own requests, not the - * fleet's. - */ -export const ERROR_RATE_THRESHOLD = 0.05; - -/** - * Below this many samples a rate is a coin flip dressed as a percentage, so - * `errorRateVerdict` reports "not measured" rather than a number. - */ -export const MIN_SAMPLES_FOR_RATE = 5; - -/** Samples per unmetered endpoint per run. */ -export const SAMPLES_PER_ENDPOINT = 5; - -/** Distinct failure reasons named in the error-rate line before it summarizes. */ -export const MAX_REASONS_SHOWN = 6; - -/** Milliseconds between samples, so consecutive samples are independent. */ -export const SAMPLE_GAP_MS = 250; - -/** Per-request ceiling. A request that exceeds it is recorded as a failure. */ -export const REQUEST_TIMEOUT_MS = 20_000; - -export type CheckStatus = "pass" | "fail" | "skip"; - -export interface Check { - readonly id: string; - readonly label: string; - readonly status: CheckStatus; - readonly detail: string; -} - -export const pass = (id: string, label: string, detail: string): Check => ({ id, label, status: "pass", detail }); -export const fail = (id: string, label: string, detail: string): Check => ({ id, label, status: "fail", detail }); -/** Not measured this run, and saying so out loud. Never fails the probe. */ -export const skip = (id: string, label: string, detail: string): Check => ({ id, label, status: "skip", detail }); - -export interface Sample { - readonly label: string; - /** undefined when the request never produced a response. */ - readonly status: number | undefined; - /** The status this endpoint answers when the deployment is healthy. */ - readonly expectedStatus: number; - readonly elapsedMs: number; - /** Set when the request threw: DNS, TLS, connection reset, timeout. */ - readonly transportError: string | undefined; -} - -/** - * A sample is an error when it did not answer, or did not answer the status - * this endpoint answers when healthy. `expectedStatus` is always below 500, so - * every 5xx is an error by construction; an unexpected 4xx counts too, because - * a gate that starts refusing valid traffic is as much an outage as a crash. - */ -export const isErrorSample = (sample: Sample): boolean => - sample.transportError !== undefined || sample.status === undefined || sample.status !== sample.expectedStatus; - -/** A sample that produced a response, and therefore carries a real duration. */ -const answered = (sample: Sample): boolean => sample.transportError === undefined && sample.status !== undefined; - -/** undefined for an empty list: zero samples have no median, and no caller may pretend otherwise. */ -export const median = (values: ReadonlyArray): number | undefined => { - if (values.length === 0) return undefined; - const sorted = [...values].sort((a, b) => a - b); - const middle = Math.floor(sorted.length / 2); - return sorted.length % 2 === 1 ? sorted[middle]! : (sorted[middle - 1]! + sorted[middle]!) / 2; -}; - -/** - * CN-19. Fails when the MEDIAN of the samples that answered exceeds the budget. - * Equal to the budget passes: a budget is a ceiling, not an exclusive bound. - * - * `note` is appended to every detail this verdict produces. It exists so an - * endpoint whose sampling differs from the rule — today only `turn-first-frame`, - * which is sampled once — says so in the line the human reads in the alert - * issue, instead of letting the word "median" imply a protection it does not - * have. - */ -export const latencyVerdict = ( - id: string, - label: string, - samples: ReadonlyArray, - budgetMs: number, - note?: string, -): Check => { - const suffix = note === undefined ? "" : ` — ${note}`; - if (samples.length === 0) { - return skip(id, label, `${label}: no samples were taken, so latency was not measured${suffix}`); - } - const durations = samples.filter(answered).map((sample) => sample.elapsedMs); - if (durations.length === 0) { - // Not a skip. An endpoint that never answers has no latency to measure - // because it failed, and reporting that as "not measured" would hide an - // outage behind a word that means the opposite. - return fail( - id, - label, - `${label}: none of ${samples.length} request(s) answered, so latency is unbounded (budget ${budgetMs}ms)${suffix}`, - ); - } - const middle = median(durations)!; - const max = Math.max(...durations); - const detail = `${label}: median ${middle}ms, max ${max}ms (budget ${budgetMs}ms) over ${durations.length} answered sample(s) of ${samples.length}${suffix}`; - return middle <= budgetMs ? pass(id, label, detail) : fail(id, label, detail); -}; - -/** - * CN-20's scope in one sentence, carried by every measured error-rate detail - * so it reaches the human reading the alert issue rather than only the - * maintainer reading this file. - */ -export const ERROR_RATE_SCOPE_NOTE = - "scope: one vantage point, and only the requests this probe itself made — it cannot see errors that only signed-in users hit, nor errors on routes it does not call"; - -/** - * CN-20, and the honest scope of it. - * - * This measures the failure rate of THIS probe's own requests, from one - * vantage point, over one run. It is a sample, not the fleet's error rate. - * Without log access it cannot see errors that only signed-in users hit, - * cannot see errors on routes it does not call, and cannot see an error that - * the deployment answers correctly with a 4xx. Read a passing verdict as "the - * deployment answered this probe's requests", never as "the deployment is - * error-free". - * - * It is still worth having: the sample is uniform, it runs on a schedule, and - * a rate that moves is a real signal even when the absolute number is not the - * fleet's. - * - * A caveat only a maintainer reading this file can see is a caveat nobody - * reads, so the one-sentence form below travels in the check detail and lands - * in the GitHub issue body next to the number it qualifies. - */ -export const errorRateVerdict = ( - samples: ReadonlyArray, - threshold: number, -): { readonly check: Check; readonly rate: number | undefined } => { - const id = "error-rate"; - const label = "probe-request error rate"; - if (samples.length === 0) { - return { - check: skip(id, label, `${label}: no requests were made, so there is no rate to compare against the threshold`), - rate: undefined, - }; - } - if (samples.length < MIN_SAMPLES_FOR_RATE) { - return { - check: skip( - id, - label, - `${label}: only ${samples.length} sample(s); ${MIN_SAMPLES_FOR_RATE} are required before a rate means anything`, - ), - rate: undefined, - }; - } - const failed = samples.filter(isErrorSample); - const rate = failed.length / samples.length; - // Distinct reasons, capped: fifteen identical connection refusals in one - // line is noise, and this string is read inside a GitHub issue. - const distinct = [ - ...new Set( - failed.map( - (sample) => - `${sample.label} (${sample.transportError ?? `HTTP ${String(sample.status)}, expected ${sample.expectedStatus}`})`, - ), - ), - ]; - const shown = distinct.slice(0, MAX_REASONS_SHOWN); - const reasons = - distinct.length > shown.length - ? `${shown.join(", ")}, and ${distinct.length - shown.length} other distinct failure(s)` - : shown.join(", "); - const detail = `${failed.length}/${samples.length} probe request(s) failed (rate ${(rate * 100).toFixed(1)}%, threshold ${(threshold * 100).toFixed(1)}%)${ - reasons === "" ? "" : `: ${reasons}` - } — ${ERROR_RATE_SCOPE_NOTE}`; - return { check: rate > threshold ? fail(id, label, detail) : pass(id, label, detail), rate }; -}; - -/** - * CN-21. The narrowest, loudest question: did each endpoint answer at all? - * An endpoint with one good sample out of five is up and unreliable, which is - * the error rate's finding. An endpoint with none is down, which is this one's. - */ -export const uptimeVerdict = (samples: ReadonlyArray): Check => { - const id = "uptime"; - const label = "every probed endpoint answered"; - if (samples.length === 0) { - return skip(id, label, `${label}: no endpoints were probed`); - } - const byLabel = new Map(); - for (const sample of samples) { - const tally = byLabel.get(sample.label) ?? { good: 0, total: 0 }; - tally.total += 1; - if (!isErrorSample(sample)) tally.good += 1; - byLabel.set(sample.label, tally); - } - const rendered = [...byLabel.entries()].map(([name, tally]) => `${name} ${tally.good}/${tally.total}`); - const down = [...byLabel.entries()].filter(([, tally]) => tally.good === 0).map(([name]) => name); - const detail = `${byLabel.size - down.length}/${byLabel.size} endpoint(s) answered — ${rendered.join(", ")}`; - return down.length === 0 - ? pass(id, label, detail) - : fail(id, label, `${detail}; fully down: ${down.join(", ")}`); -}; - -export interface ProbeReport { - readonly origin: string; - readonly generatedAt: string; - readonly samples: ReadonlyArray; - readonly checks: ReadonlyArray; - readonly errorRate: number | undefined; - /** The turn seam costs money, so a run states plainly what it spent. */ - readonly meteredTurns: number; - readonly failed: boolean; -} - -/** A run fails when any check failed. A skipped check never fails a run. */ -export const probeFailed = (checks: ReadonlyArray): boolean => checks.some((check) => check.status === "fail"); - -export const tallyChecks = ( - checks: ReadonlyArray, -): { readonly passed: number; readonly failed: number; readonly skipped: number } => ({ - passed: checks.filter((check) => check.status === "pass").length, - failed: checks.filter((check) => check.status === "fail").length, - skipped: checks.filter((check) => check.status === "skip").length, -}); - -const isCheck = (value: unknown): value is Check => { - if (typeof value !== "object" || value === null) return false; - const candidate = value as Record; - return ( - typeof candidate.id === "string" && - typeof candidate.label === "string" && - typeof candidate.detail === "string" && - (candidate.status === "pass" || candidate.status === "fail" || candidate.status === "skip") - ); -}; - -/** - * A probe that crashed before writing its report must still alert. Anything - * that is not a report becomes a failing report that says so, so the alert - * path never depends on the probe having survived its own run — a silent - * green from a probe that never ran is the one outcome this whole lane exists - * to prevent. - */ -export const coerceReport = (parsed: unknown, source: string): ProbeReport => { - const candidate = typeof parsed === "object" && parsed !== null ? (parsed as Record) : undefined; - if ( - candidate !== undefined && - typeof candidate.origin === "string" && - typeof candidate.generatedAt === "string" && - typeof candidate.failed === "boolean" && - Array.isArray(candidate.checks) && - candidate.checks.every(isCheck) - ) { - return { - origin: candidate.origin, - generatedAt: candidate.generatedAt, - samples: Array.isArray(candidate.samples) ? (candidate.samples as ReadonlyArray) : [], - checks: candidate.checks, - errorRate: typeof candidate.errorRate === "number" ? candidate.errorRate : undefined, - meteredTurns: typeof candidate.meteredTurns === "number" ? candidate.meteredTurns : 0, - failed: candidate.failed, - }; - } - return { - origin: "unknown", - generatedAt: new Date(0).toISOString(), - samples: [], - checks: [ - fail( - "probe-report", - "the probe produced a report", - `${source} is missing or is not a canary uptime report, so this run proved nothing about the deployment`, - ), - ], - errorRate: undefined, - meteredTurns: 0, - failed: true, - }; -}; - -/* - * Alerting. - * - * There is no paging infrastructure in this project and none is invented here. - * The alert is a GitHub issue with a fixed title: a failing scheduled run - * creates it, later failing runs comment on it, and the first passing run - * comments and closes it. That gives a durable record, a timestamp, a - * notification path the maintainer already reads, and — because of the close — - * no stale banner. - * - * The failure mode this design accepts: notification depends on the maintainer - * watching the repository's issues. A repository with issue notifications - * muted learns nothing. That is stated rather than hidden, and it is strictly - * better than a red tab in the Actions list, which notifies no one on a - * schedule-triggered run. - * - * The second accepted failure mode: a single transient blip opens an issue - * that the next run closes fifteen minutes later. The three unmetered latency - * checks are judged on a median of SAMPLES_PER_ENDPOINT samples, so ordinary - * slowness cannot do this to them. `turn-first-frame` is the exception, and it - * is an exception on purpose: it is sampled once because samples cost model - * credit, so a cold start there can open a self-closing issue. Its budget is - * doubled to make that unlikely and its detail says "single sample" so the - * reader of the issue knows which number they have. A self-closing issue is - * the cost of not suppressing real outages. - */ -export const ALERT_TITLE = "Canary: canary.smithers.sh is failing"; - -export type AlertAction = - | { readonly kind: "create"; readonly title: string; readonly body: string } - | { readonly kind: "comment"; readonly issue: number; readonly body: string } - | { readonly kind: "close"; readonly issue: number; readonly body: string } - | { readonly kind: "none"; readonly reason: string }; - -export interface AlertInputs { - readonly report: ProbeReport; - /** The number of the open alert issue, or undefined when none is open. */ - readonly openIssue: number | undefined; - readonly runUrl: string; - readonly title?: string; -} - -const checkLine = (check: Check): string => { - const marker = check.status === "pass" ? "ok" : check.status === "fail" ? "FAIL" : "skip"; - return `| ${marker} | ${check.label} | ${check.detail} |`; -}; - -export const renderAlertBody = (report: ProbeReport, runUrl: string): string => { - const tally = tallyChecks(report.checks); - const lines = [ - `Origin: ${report.origin}`, - `Probed at: ${report.generatedAt}`, - `Run: ${runUrl}`, - `Checks: ${tally.passed} passed, ${tally.failed} failed, ${tally.skipped} not measured`, - `Metered turns spent by this run: ${report.meteredTurns}`, - "", - "| | check | detail |", - "| --- | --- | --- |", - ...report.checks.map(checkLine), - ]; - return lines.join("\n"); -}; - -export const alertAction = (inputs: AlertInputs): AlertAction => { - const title = inputs.title ?? ALERT_TITLE; - const body = renderAlertBody(inputs.report, inputs.runUrl); - if (inputs.report.failed) { - return inputs.openIssue === undefined - ? { kind: "create", title, body } - : { kind: "comment", issue: inputs.openIssue, body }; - } - if (inputs.openIssue === undefined) { - return { kind: "none", reason: "the canary passed and no alert issue is open" }; - } - return { kind: "close", issue: inputs.openIssue, body: `The canary recovered.\n\n${body}` }; -}; - -/* - * The endpoint plan. - * - * Paths come from smithers-shared/AgentApiRoutes, the same module the Worker - * dispatches on, so renaming a route breaks this probe loudly instead of - * leaving it probing a 404 forever. - */ -export interface Endpoint { - readonly label: string; - readonly method: "GET" | "POST"; - readonly path: string; - readonly expectedStatus: number; - readonly budgetMs: number; - readonly body: string | undefined; -} - -/** The body shape apps/ui/scripts/canary-seam-probe.ts sends to the turn seam. */ -export const turnRequestBody = (runId: string): string => - JSON.stringify({ - runId, - messages: [{ role: "user", content: "Say the word ok and nothing else." }], - instructions: "Answer briefly.", - }); - -export const endpointPlan = (runId: string): ReadonlyArray => [ - { label: "spa", method: "GET", path: "/", expectedStatus: 200, budgetMs: LATENCY_BUDGETS_MS.spa, body: undefined }, - { - label: "scopes", - method: "GET", - path: AUTH_SCOPES_PATH, - expectedStatus: 200, - budgetMs: LATENCY_BUDGETS_MS.scopes, - body: undefined, - }, - { - // Signed out the turn seam answers 401 (asserted by - // apps/ui/scripts/canary-seam-probe.ts). This measures the GATE, not a - // model turn: requireTurnSession refuses before the rate limiter and - // before any upstream call, so these samples cost nothing and cannot - // consume the login's turn ceiling. - label: "turn-gate", - method: "POST", - path: TURN_PATH, - expectedStatus: 401, - budgetMs: LATENCY_BUDGETS_MS.turnGate, - body: turnRequestBody(runId), - }, -]; - -/* - * Which origin gets probed, and the refusal that keeps a non-URL out. - * - * The bug this replaces: the origin was `args.find((arg) => !arg.startsWith("--"))`, - * the first non-flag token anywhere in argv. The scheduled workflow invokes - * the probe as `uptime-probe.ts --json "$RUNNER_TEMP/canary-uptime.json"`, so - * the first non-flag token was the VALUE of --json. Every scheduled run - * fetched `/home/runner/work/_temp/canary-uptime.json/` and friends, recorded - * a transport error on all fifteen samples, and reported the deployment fully - * down whether it was up or down — a verdict that could not move with the - * thing it graded, opening an alert issue every fifteen minutes forever. - * - * Two changes stop it. Source selection is delegated to BuildStamp.ts's - * `resolveOrigin`, which reads argv[0] only and then $CANARY_URL, so a flag's - * value is never mistaken for a positional. And the result must parse as an - * http or https URL, because the root cause was that a filesystem path was - * accepted as an origin at all. A probe that silently accepts nonsense as its - * target is the defect, not the symptom. - */ -export type OriginResolution = { readonly origin: string } | { readonly error: string }; - -const ORIGIN_SOURCES = "the first positional argument, then $CANARY_URL, then the built-in canary default"; - -export const resolveProbeOrigin = ( - argv: ReadonlyArray, - env: { readonly CANARY_URL?: string | undefined }, -): OriginResolution => { - // Trailing slashes go before parsing so `origin + path` never doubles one. - const candidate = resolveOrigin(argv, env).trim().replace(/\/+$/, ""); - if (candidate === "") { - return { error: `no origin to probe: ${ORIGIN_SOURCES} all resolved to an empty string` }; - } - let parsed: URL; - try { - parsed = new URL(candidate); - } catch { - return { - error: `refusing to probe "${candidate}": that is not a URL. The origin is read from ${ORIGIN_SOURCES}; a flag's value is never the origin`, - }; - } - if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { - return { - error: `refusing to probe "${candidate}": ${parsed.protocol}// is not http or https, so this is not a deployment`, - }; - } - return { origin: candidate }; -}; - -export interface ProbeDeps { - readonly fetch: (url: string, init: RequestInit) => Promise; - readonly now: () => number; - readonly sleep: (ms: number) => Promise; -} - -export interface ProbeOptions { - readonly origin: string; - readonly samplesPerEndpoint: number; - readonly gapMs: number; - readonly requestTimeoutMs: number; - /* - * CN-19's metered half. When set, the run takes exactly ONE signed-in - * sample of the turn seam. When unset, the run says out loud that it - * measured the refusal gate only. It never silently claims to have - * measured a turn. - */ - readonly sessionCookie: string | undefined; - readonly runId: string; -} - -const errorText = (error: unknown): string => - error instanceof Error ? `${error.name}: ${error.message}` : String(error); - -const takeSample = async (deps: ProbeDeps, options: ProbeOptions, endpoint: Endpoint): Promise => { - const started = deps.now(); - try { - const response = await deps.fetch(`${options.origin}${endpoint.path}`, { - method: endpoint.method, - headers: endpoint.body === undefined ? {} : { "content-type": "application/json" }, - body: endpoint.body, - signal: AbortSignal.timeout(options.requestTimeoutMs), - }); - const elapsedMs = deps.now() - started; - // Drain rather than read: nothing here inspects the body, and an - // undrained response holds a connection open for the whole run. - await response.body?.cancel(); - return { - label: endpoint.label, - status: response.status, - expectedStatus: endpoint.expectedStatus, - elapsedMs, - transportError: undefined, - }; - } catch (error) { - return { - label: endpoint.label, - status: undefined, - expectedStatus: endpoint.expectedStatus, - elapsedMs: deps.now() - started, - transportError: errorText(error), - }; - } -}; - -/** - * CN-19's metered half: one signed-in turn, timed to the first NDJSON byte. - * - * Cost, stated plainly: one model turn per call. The prompt is nine words and - * the instruction is two, and the stream is cancelled the moment the first - * frame arrives, so this run reads no more than it must. Cancelling stops this - * probe's read; whether the deployment finishes generating the turn is the - * deployment's business, so budget the cost as one full short turn, not as a - * partial one. `uptime-probe.ts` takes exactly one sample and only when - * $CANARY_SESSION_COOKIE is set, and the scheduled workflow supplies that - * cookie on the hourly tick only — 24 short turns a day, not 96. - */ -export const meteredTurnSample = async (deps: ProbeDeps, options: ProbeOptions, cookie: string): Promise => { - const started = deps.now(); - try { - const response = await deps.fetch(`${options.origin}${TURN_PATH}`, { - method: "POST", - headers: { "content-type": "application/json", cookie }, - body: turnRequestBody(`${options.runId}-metered`), - signal: AbortSignal.timeout(options.requestTimeoutMs), - }); - if (response.status !== 200 || response.body === null) { - await response.body?.cancel(); - return { - label: "turn-first-frame", - status: response.status, - expectedStatus: 200, - elapsedMs: deps.now() - started, - transportError: undefined, - }; - } - const reader = response.body.getReader(); - const chunks: Array = []; - let received = 0; - let newline = -1; - while (received < FIRST_FRAME_MAX_BYTES && newline === -1) { - const next = await reader.read(); - if (next.done) break; - const remaining = FIRST_FRAME_MAX_BYTES - received; - const value = next.value.subarray(0, remaining); - chunks.push(value); - const localNewline = value.indexOf(0x0a); - if (localNewline !== -1) newline = received + localNewline; - received += value.byteLength; - } - const elapsedMs = deps.now() - started; - await reader.cancel(); - let frameError: string | undefined; - if (newline === -1) { - frameError = received === 0 - ? "the turn seam answered 200 but streamed no frame" - : `the turn seam did not stream a complete frame within ${FIRST_FRAME_MAX_BYTES} bytes`; - } else { - const bytes = new Uint8Array(newline); - let offset = 0; - for (const chunk of chunks) { - const slice = chunk.subarray(0, Math.min(chunk.byteLength, newline - offset)); - bytes.set(slice, offset); - offset += slice.byteLength; - if (offset >= newline) break; - } - try { - const decoded = AgentTurnFrameSchema.safeParse(JSON.parse(new TextDecoder().decode(bytes))); - const expectedRunId = `${options.runId}-metered`; - if (!decoded.success) frameError = "the turn seam streamed a malformed AgentTurnFrame"; - else if (decoded.data.runId !== expectedRunId) frameError = "the turn seam streamed a frame for another run"; - else if (decoded.data.type !== "delta") frameError = "the turn seam streamed a terminal or non-delta first frame"; - } catch { - frameError = "the turn seam streamed malformed NDJSON"; - } - } - return { - label: "turn-first-frame", - status: response.status, - expectedStatus: 200, - elapsedMs, - // A 200 that closes without a single frame is a failed turn, not a - // fast one, and the elapsed time above would otherwise read as a - // very good latency. - transportError: frameError, - }; - } catch (error) { - return { - label: "turn-first-frame", - status: undefined, - expectedStatus: 200, - elapsedMs: deps.now() - started, - transportError: errorText(error), - }; - } -}; - -export const runUptimeProbe = async (deps: ProbeDeps, options: ProbeOptions): Promise => { - const generatedAt = new Date(deps.now()).toISOString(); - const plan = endpointPlan(options.runId); - const samples: Array = []; - for (const endpoint of plan) { - for (let index = 0; index < options.samplesPerEndpoint; index += 1) { - if (samples.length > 0) await deps.sleep(options.gapMs); - samples.push(await takeSample(deps, options, endpoint)); - } - } - - // TURN_FIRST_FRAME_SAMPLES is 1 and is a cost decision, not an oversight. - // Read its comment before raising it: each extra sample is another short - // model turn on every hourly tick. - const turnSamples: Array = []; - if (options.sessionCookie !== undefined && options.sessionCookie !== "") { - for (let index = 0; index < TURN_FIRST_FRAME_SAMPLES; index += 1) { - if (index > 0) await deps.sleep(options.gapMs); - const taken = await meteredTurnSample(deps, options, options.sessionCookie); - turnSamples.push(taken); - samples.push(taken); - } - } - const meteredTurns = turnSamples.length; - - const checks: Array = [uptimeVerdict(samples)]; - for (const endpoint of plan) { - checks.push( - latencyVerdict( - `latency:${endpoint.label}`, - `${endpoint.label} latency`, - samples.filter((sample) => sample.label === endpoint.label), - endpoint.budgetMs, - ), - ); - } - checks.push( - turnSamples.length === 0 - ? skip( - "latency:turn-first-frame", - "turn-seam first-frame latency", - "turn-seam first-frame latency: not measured — $CANARY_SESSION_COOKIE is unset, so this run measured the signed-out refusal gate only", - ) - : latencyVerdict( - "latency:turn-first-frame", - "turn-seam first-frame latency", - turnSamples, - LATENCY_BUDGETS_MS.turnFirstFrame, - TURN_FIRST_FRAME_NOTE, - ), - ); - const rate = errorRateVerdict(samples, ERROR_RATE_THRESHOLD); - checks.push(rate.check); - - return { - origin: options.origin, - generatedAt, - samples, - checks, - errorRate: rate.rate, - meteredTurns, - failed: probeFailed(checks), - }; -}; diff --git a/apps/server/scripts/canary/uptime-probe.ts b/apps/server/scripts/canary/uptime-probe.ts deleted file mode 100644 index 8b11cbd8..00000000 --- a/apps/server/scripts/canary/uptime-probe.ts +++ /dev/null @@ -1,108 +0,0 @@ -/* - * The canary uptime probe (CN-19, CN-20, CN-21). - * - * bun scripts/canary/uptime-probe.ts [origin] [options] - * - * --json write the machine-readable report (uptime-report.ts reads it) - * --samples samples per unmetered endpoint (default 5) - * --gap-ms milliseconds between samples (default 250) - * --timeout-ms per-request ceiling (default 20000) - * --no-turn never take the metered turn, even with a cookie set - * - * $CANARY_URL the origin, when no POSITIONAL origin is given. - * The origin is argv[0] and nothing else, so a - * flag's value can never become the target. - * $CANARY_SESSION_COOKIE a signed-in cookie header. Present: the run takes - * ONE metered turn and measures CN-19's real bar. - * Absent: the run measures the signed-out refusal - * gate and says so, and never claims otherwise. - * - * WHAT THIS RUN COSTS. Without the cookie: nothing. Every request is a static - * asset read, an unauthenticated scopes read, or a refusal that never reaches - * an upstream. With the cookie: exactly one short model turn — a nine-word - * prompt, a two-word instruction, and the stream cancelled at the first frame. - * The scheduled workflow supplies the cookie on the hourly tick only, so the - * standing cost is 24 short turns a day. - * - * This file is the process shell only: argument parsing, the real fetch, - * printing and the exit code. Every decision it prints comes from - * uptime-checks.ts, which is covered by uptime-checks.test.ts. The fetch is - * the one line in this lane that no test can reach. - */ -import { writeFileSync } from "node:fs"; -import { - type Check, - REQUEST_TIMEOUT_MS, - resolveProbeOrigin, - runUptimeProbe, - SAMPLE_GAP_MS, - SAMPLES_PER_ENDPOINT, - tallyChecks, -} from "./uptime-checks.ts"; - -const args = process.argv.slice(2); -const flagValue = (name: string): string | undefined => { - const index = args.indexOf(name); - return index === -1 ? undefined : args[index + 1]; -}; -const positive = (name: string, fallback: number): number => { - const raw = flagValue(name); - if (raw === undefined) return fallback; - const parsed = Number(raw); - if (!Number.isFinite(parsed) || parsed <= 0) { - console.error(`FAIL: ${name} must be a positive number, got ${raw}`); - process.exit(2); - } - return parsed; -}; - -/* - * Exit 2, not 1. Exit 1 means "the canary failed", which a caller may read as - * a statement about the deployment. A misconfigured target is a statement - * about this invocation, and it must never be mistaken for a verdict. - */ -const resolved = resolveProbeOrigin(args, { CANARY_URL: process.env.CANARY_URL }); -if ("error" in resolved) { - console.error(`FAIL: ${resolved.error}`); - process.exit(2); -} -const origin = resolved.origin; -const cookie = args.includes("--no-turn") ? undefined : process.env.CANARY_SESSION_COOKIE; - -const report = await runUptimeProbe( - { - fetch: (url, init) => fetch(url, init), - now: () => Date.now(), - sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)), - }, - { - origin, - samplesPerEndpoint: positive("--samples", SAMPLES_PER_ENDPOINT), - gapMs: positive("--gap-ms", SAMPLE_GAP_MS), - requestTimeoutMs: positive("--timeout-ms", REQUEST_TIMEOUT_MS), - sessionCookie: cookie === "" ? undefined : cookie, - runId: `canary-uptime-probe-${Date.now()}`, - }, -); - -// The same three prefixes apps/ui/scripts/canary-seam-probe.ts prints, so a -// human reading two canary logs side by side reads one format. -const print = (check: Check): void => { - const prefix = check.status === "pass" ? "ok" : check.status === "fail" ? "FAIL" : "skip"; - console.log(`${prefix}: ${check.label} — ${check.detail}`); -}; -for (const check of report.checks) print(check); - -const jsonPath = flagValue("--json"); -if (jsonPath !== undefined) { - writeFileSync(jsonPath, `${JSON.stringify(report, null, "\t")}\n`); - console.log(`report: ${jsonPath}`); -} - -const tally = tallyChecks(report.checks); -const summary = `${tally.passed} passed, ${tally.failed} failed, ${tally.skipped} not measured; ${report.meteredTurns} metered turn(s) spent`; -if (report.failed) { - console.log(`\nCANARY UPTIME PROBE FAILED against ${origin}: ${summary}.`); - process.exit(1); -} -console.log(`\nCANARY UPTIME PROBE PASS against ${origin}: ${summary}.`); diff --git a/apps/server/scripts/canary/uptime-report.test.ts b/apps/server/scripts/canary/uptime-report.test.ts deleted file mode 100644 index 5d2200bc..00000000 --- a/apps/server/scripts/canary/uptime-report.test.ts +++ /dev/null @@ -1,289 +0,0 @@ -/* - * The two process shells, run for real. - * - * uptime-checks.test.ts covers every decision. This file covers the parts a - * pure test cannot reach: argument parsing, the real `fetch`, the JSON report - * on disk, the exit codes, and the GITHUB_OUTPUT lines the scheduled workflow - * reads. The deployment is replaced by a local Bun.serve that answers the same - * shapes canary.smithers.sh answers — including the signed-out 401 from the - * turn seam and the NDJSON stream a signed-in turn produces — so the network - * path is exercised without a credential and without spending model credit. - */ -import { afterAll, beforeAll, describe, expect, test } from "bun:test"; -import { mkdtempSync, readFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import type { Server } from "bun"; -import { ALERT_TITLE, type ProbeReport } from "./uptime-checks.ts"; - -const scriptsDir = import.meta.dir; -const serverDir = join(scriptsDir, "..", ".."); -const workDir = mkdtempSync(join(tmpdir(), "canary-uptime-")); - -/** How the fake deployment behaves for the run in progress. */ -let mode: "healthy" | "spa-down" | "turn-open" = "healthy"; - -let server: Server; - -beforeAll(() => { - server = Bun.serve({ - port: 0, - async fetch(request) { - const url = new URL(request.url); - if (url.pathname === "/") { - return mode === "spa-down" - ? new Response("upstream failure", { status: 503 }) - : new Response("smithers", { status: 200 }); - } - if (url.pathname === "/api/auth/scopes") return new Response('{"scopes":[]}', { status: 200 }); - if (url.pathname === "/api/agent/turn") { - if (mode === "turn-open") return new Response("streaming to anyone", { status: 200 }); - if (request.headers.get("cookie") === null) return new Response("Unauthorized", { status: 401 }); - const body = await request.json() as { runId: string }; - return new Response( - new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode(`${JSON.stringify({ runId: body.runId, type: "delta", kind: "text", text: "ok" })}\n`)); - controller.close(); - }, - }), - { status: 200, headers: { "content-type": "application/x-ndjson" } }, - ); - } - return new Response("Not found", { status: 404 }); - }, - }); -}); - -afterAll(() => { - server.stop(true); -}); - -const origin = (): string => `http://localhost:${String(server.port)}`; - -const runProbe = async ( - extraArgs: ReadonlyArray, - env: Record = {}, -): Promise<{ exitCode: number; stdout: string }> => { - const child = Bun.spawn( - ["bun", join(scriptsDir, "uptime-probe.ts"), origin(), "--gap-ms", "1", ...extraArgs], - { - cwd: serverDir, - stdout: "pipe", - stderr: "pipe", - // A cookie leaking in from the developer's shell would spend real - // money from a unit test, so the environment is stated, not inherited. - env: { ...process.env, CANARY_URL: "", CANARY_SESSION_COOKIE: "", ...env }, - }, - ); - const stdout = await new Response(child.stdout).text(); - const exitCode = await child.exited; - return { exitCode, stdout }; -}; - -const runReport = async ( - extraArgs: ReadonlyArray, -): Promise<{ exitCode: number; stdout: string }> => { - const child = Bun.spawn(["bun", join(scriptsDir, "uptime-report.ts"), ...extraArgs], { - cwd: serverDir, - stdout: "pipe", - stderr: "pipe", - env: { ...process.env, GITHUB_OUTPUT: "" }, - }); - const stdout = await new Response(child.stdout).text(); - const exitCode = await child.exited; - return { exitCode, stdout }; -}; - -describe("uptime-probe.ts against a live HTTP origin", () => { - test("a healthy origin exits 0 and writes a report naming every check", async () => { - mode = "healthy"; - const jsonPath = join(workDir, "healthy.json"); - const { exitCode, stdout } = await runProbe(["--samples", "5", "--json", jsonPath]); - - expect(stdout).toContain("ok: every probed endpoint answered"); - expect(stdout).toContain("ok: probe-request error rate — 0/15 probe request(s) failed"); - expect(stdout).toContain("CANARY UPTIME PROBE PASS"); - expect(exitCode).toBe(0); - - const report = JSON.parse(readFileSync(jsonPath, "utf8")) as ProbeReport; - expect(report.origin).toBe(origin()); - expect(report.failed).toBe(false); - expect(report.samples).toHaveLength(15); - expect(report.meteredTurns).toBe(0); - }); - - test("without $CANARY_SESSION_COOKIE it says the turn seam was not measured, and spends nothing", async () => { - mode = "healthy"; - const { stdout } = await runProbe(["--samples", "1"]); - expect(stdout).toContain("skip: turn-seam first-frame latency"); - expect(stdout).toContain("$CANARY_SESSION_COOKIE is unset"); - expect(stdout).toContain("0 metered turn(s) spent"); - }); - - test("with a session cookie it takes exactly one metered turn and times the first frame", async () => { - mode = "healthy"; - const jsonPath = join(workDir, "metered.json"); - const { exitCode, stdout } = await runProbe(["--samples", "1", "--json", jsonPath], { - CANARY_SESSION_COOKIE: "smithers_session=probe", - }); - - expect(stdout).toContain("ok: turn-seam first-frame latency"); - expect(stdout).toContain("1 metered turn(s) spent"); - expect(exitCode).toBe(0); - - const report = JSON.parse(readFileSync(jsonPath, "utf8")) as ProbeReport; - expect(report.meteredTurns).toBe(1); - expect(report.samples.filter((s) => s.label === "turn-first-frame")).toHaveLength(1); - }); - - test("--no-turn suppresses the metered turn even when the cookie is set", async () => { - mode = "healthy"; - const { stdout } = await runProbe(["--samples", "1", "--no-turn"], { - CANARY_SESSION_COOKIE: "smithers_session=probe", - }); - expect(stdout).toContain("0 metered turn(s) spent"); - expect(stdout).toContain("skip: turn-seam first-frame latency"); - }); - - test("a 5xx from the SPA fails uptime and the error rate, and exits 1", async () => { - mode = "spa-down"; - const jsonPath = join(workDir, "down.json"); - const { exitCode, stdout } = await runProbe(["--samples", "5", "--json", jsonPath]); - - expect(stdout).toContain("FAIL: every probed endpoint answered"); - expect(stdout).toContain("fully down: spa"); - expect(stdout).toContain("FAIL: probe-request error rate"); - expect(stdout).toContain("CANARY UPTIME PROBE FAILED"); - expect(exitCode).toBe(1); - - const report = JSON.parse(readFileSync(jsonPath, "utf8")) as ProbeReport; - expect(report.failed).toBe(true); - expect(report.errorRate).toBeCloseTo(5 / 15, 10); - }); - - test("a turn seam that answers 200 to an anonymous caller fails the probe", async () => { - mode = "turn-open"; - const { exitCode, stdout } = await runProbe(["--samples", "5"]); - expect(stdout).toContain("turn-gate (HTTP 200, expected 401)"); - expect(exitCode).toBe(1); - }); - - test("a nonsense --samples value is refused rather than silently defaulted", async () => { - mode = "healthy"; - const { exitCode } = await runProbe(["--samples", "zero"]); - expect(exitCode).toBe(2); - }); -}); - -describe("uptime-report.ts", () => { - const runUrl = "https://github.com/smithersai/flows/actions/runs/7"; - - test("a failing report with nothing open asks for the issue to be created", async () => { - mode = "spa-down"; - const jsonPath = join(workDir, "alert-fail.json"); - await runProbe(["--samples", "5", "--json", jsonPath]); - - const outputPath = join(workDir, "output-create.txt"); - const bodyPath = join(workDir, "body-create.md"); - const { exitCode, stdout } = await runReport([ - "--report", - jsonPath, - "--run-url", - runUrl, - "--body-out", - bodyPath, - "--github-output", - outputPath, - ]); - - expect(stdout).toContain("alert: create"); - expect(exitCode).toBe(1); - const output = readFileSync(outputPath, "utf8"); - expect(output).toContain("action=create"); - expect(output).toContain("issue=\n"); - expect(output).toContain(`title=${ALERT_TITLE}`); - const body = readFileSync(bodyPath, "utf8"); - expect(body).toContain(`Run: ${runUrl}`); - expect(body).toContain("| FAIL | every probed endpoint answered |"); - }); - - test("a failing report with an issue open comments on it instead of opening a second", async () => { - mode = "spa-down"; - const jsonPath = join(workDir, "alert-fail-2.json"); - await runProbe(["--samples", "5", "--json", jsonPath]); - - const outputPath = join(workDir, "output-comment.txt"); - const { exitCode, stdout } = await runReport([ - "--report", - jsonPath, - "--open-issue", - "31", - "--github-output", - outputPath, - ]); - - expect(stdout).toContain("alert: comment on issue #31"); - expect(exitCode).toBe(1); - expect(readFileSync(outputPath, "utf8")).toContain("action=comment\nissue=31\n"); - }); - - test("a passing report closes the open issue and exits 0", async () => { - mode = "healthy"; - const jsonPath = join(workDir, "alert-pass.json"); - await runProbe(["--samples", "5", "--json", jsonPath]); - - const outputPath = join(workDir, "output-close.txt"); - const bodyPath = join(workDir, "body-close.md"); - const { exitCode, stdout } = await runReport([ - "--report", - jsonPath, - "--open-issue", - "31", - "--body-out", - bodyPath, - "--github-output", - outputPath, - ]); - - expect(stdout).toContain("alert: close on issue #31"); - expect(exitCode).toBe(0); - expect(readFileSync(outputPath, "utf8")).toContain("action=close\nissue=31\n"); - expect(readFileSync(bodyPath, "utf8")).toStartWith("The canary recovered."); - }); - - test("a passing report with nothing open does nothing and exits 0", async () => { - mode = "healthy"; - const jsonPath = join(workDir, "alert-pass-2.json"); - await runProbe(["--samples", "5", "--json", jsonPath]); - - const outputPath = join(workDir, "output-none.txt"); - const { exitCode, stdout } = await runReport(["--report", jsonPath, "--github-output", outputPath]); - - expect(stdout).toContain("alert: none"); - expect(exitCode).toBe(0); - expect(readFileSync(outputPath, "utf8")).toContain("action=none"); - }); - - test("a probe that never wrote a report still alerts, and never reports success", async () => { - const outputPath = join(workDir, "output-missing.txt"); - const bodyPath = join(workDir, "body-missing.md"); - const { exitCode, stdout } = await runReport([ - "--report", - join(workDir, "does-not-exist.json"), - "--body-out", - bodyPath, - "--github-output", - outputPath, - ]); - - expect(stdout).toContain("alert: create"); - expect(exitCode).toBe(1); - expect(readFileSync(bodyPath, "utf8")).toContain("proved nothing about the deployment"); - }); - - test("--report is required", async () => { - const { exitCode } = await runReport([]); - expect(exitCode).toBe(2); - }); -}); diff --git a/apps/server/scripts/canary/uptime-report.ts b/apps/server/scripts/canary/uptime-report.ts deleted file mode 100644 index a368aea2..00000000 --- a/apps/server/scripts/canary/uptime-report.ts +++ /dev/null @@ -1,78 +0,0 @@ -/* - * The canary alert decision (CN-20's "with an alert", CN-21's schedule). - * - * bun scripts/canary/uptime-report.ts --report [options] - * - * --open-issue the number of the alert issue that is already open - * --run-url the Actions run to link from the issue - * --body-out write the issue body here (gh --body-file reads it) - * --github-output write action/issue/title (defaults to $GITHUB_OUTPUT) - * - * There is no paging infrastructure in this project and this file invents - * none. The alert is one GitHub issue under a fixed title: a failing run opens - * it, later failing runs comment on it, and the first passing run comments and - * closes it. `gh` is left to the workflow; the decision is made here, where - * uptime-checks.test.ts covers it. - * - * A missing or unreadable report is itself an alert. `coerceReport` turns it - * into a failing report, so a probe that crashed before writing anything still - * opens an issue rather than passing silently. - */ -import { readFileSync, writeFileSync } from "node:fs"; -import { ALERT_TITLE, alertAction, coerceReport, renderAlertBody } from "./uptime-checks.ts"; - -const args = process.argv.slice(2); -const flagValue = (name: string): string | undefined => { - const index = args.indexOf(name); - return index === -1 ? undefined : args[index + 1]; -}; - -const reportPath = flagValue("--report"); -if (reportPath === undefined) { - console.error("uptime-report.ts: --report is required"); - process.exit(2); -} - -const parsed = ((): unknown => { - try { - return JSON.parse(readFileSync(reportPath, "utf8")); - } catch { - return undefined; - } -})(); -const report = coerceReport(parsed, reportPath); - -const rawIssue = flagValue("--open-issue"); -const openIssue = rawIssue === undefined || rawIssue.trim() === "" ? undefined : Number(rawIssue); -if (openIssue !== undefined && !Number.isInteger(openIssue)) { - console.error(`uptime-report.ts: --open-issue must be an integer, got ${rawIssue}`); - process.exit(2); -} - -const runUrl = flagValue("--run-url") ?? "(no run url given)"; -const action = alertAction({ report, openIssue, runUrl }); - -const bodyOut = flagValue("--body-out"); -if (bodyOut !== undefined) { - writeFileSync(bodyOut, `${action.kind === "none" ? renderAlertBody(report, runUrl) : action.body}\n`); -} - -const outputPath = flagValue("--github-output") ?? process.env.GITHUB_OUTPUT; -if (outputPath !== undefined && outputPath !== "") { - const issue = action.kind === "comment" || action.kind === "close" ? String(action.issue) : ""; - writeFileSync(outputPath, `action=${action.kind}\nissue=${issue}\ntitle=${ALERT_TITLE}\n`, { flag: "a" }); -} - -console.log( - action.kind === "none" - ? `alert: none — ${action.reason}` - : `alert: ${action.kind}${action.kind === "create" ? "" : ` on issue #${String(action.issue)}`}`, -); - -/* - * The exit code carries the canary's verdict, and this step is the last one in - * the job. A failing canary therefore leaves the issue behind BEFORE the job - * goes red, which is the whole point of deciding the alert here rather than - * letting the probe's own exit code fail the job first. - */ -process.exit(report.failed ? 1 : 0); diff --git a/apps/server/scripts/canary/workers-health.test.ts b/apps/server/scripts/canary/workers-health.test.ts deleted file mode 100644 index 5b6c175c..00000000 --- a/apps/server/scripts/canary/workers-health.test.ts +++ /dev/null @@ -1,507 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { - cloudflareEdgeError, - formatVerdictLine, - probeWorker, - runWorkersHealth, - summarizeHealth, - workerHealthVerdict, -} from "./workers-health.ts"; -import type { HealthVerdict, ProbeFetch, ProbeResponse } from "./workers-health.ts"; -import { BACKING_WORKERS, expandTargets, healthUrl, withOriginOverrides } from "./workers-manifest.ts"; -import type { BackingWorker } from "./workers-manifest.ts"; - -/* - * CN-18. The nine Workers this probe watches are not in this repository and - * there is no credential here to reach a deployment with, so the probe's logic - * is held to fakes: the network call is the only line these tests do not cover. - * - * What they hold: the three states never collapse into two (a Worker an - * operator deliberately left unset must not read as broken, and a broken one - * must never read as unset), and a run that asserted nothing never reports - * PASS. - */ - -const worker = (over: Partial = {}): BackingWorker => ({ - name: "identity", - origin: "https://identity.test", - alternateOrigins: [], - path: "/healthz", - contract: "ok-json", - note: "test worker", - ...over, -}); - -const jsonResponse = (status: number, body: unknown): ProbeResponse => ({ - status, - text: async () => JSON.stringify(body), -}); - -const textResponse = (status: number, text: string): ProbeResponse => ({ status, text: async () => text }); - -/** A response whose body cannot be read: the probe must not guess what it said. */ -const unreadableResponse = (status: number): ProbeResponse => ({ - status, - text: async () => { - throw new Error("body stream already read"); - }, -}); - -/* - * Cloudflare edge error bodies, captured live on 2026-08-19 from - * https://this-worker-does-not-exist-xyz123.willcory10.workers.dev/ — a - * *.workers.dev hostname with no Worker deployed behind it. All three came from - * that one host; only the request headers differed, because the edge - * content-negotiates its error page. - * - * Every one of them arrives with HTTP 404, the same status the live chat Worker - * answers at / with the body "Not found". That collision is CN-18. - */ -const EDGE_JSON_404 = JSON.stringify({ - type: "https://developers.cloudflare.com/support/troubleshooting/http-status-codes/cloudflare-1xxx-errors/", - title: "Error 1042: Cloudflare Error", - status: 404, - detail: "No Workers script was found for this host on workers.dev.", - instance: "a2d6011749dfdf9a", - error_code: 1042, - error_name: "workers_dev_script_not_found", - error_category: "worker", - ray_id: "a2d6011749dfdf9a", - zone: "this-worker-does-not-exist-xyz123.willcory10.workers.dev", - cloudflare_error: true, - retryable: false, -}); - -/** What the same host returns to an uncompressed non-browser request. */ -const EDGE_PLAIN_404 = "error code: 1042\n"; - -/** The workers.dev placeholder page, returned when the request accepts HTML. */ -const EDGE_HTML_404 = [ - "", - '', - " ", - " Page not found", - ' ', - " ", - "

Page not found

", - "", -].join("\n"); - -/** The classic branded page, still served for WAF and rate-limit blocks. */ -const EDGE_CLASSIC_HTML_403 = [ - "", - "example.com | 1020: Access denied", - 'Error 1020', - '
Access denied
', - "", -].join("\n"); - -/** A fake fetch keyed by URL. A function value throws instead of answering. */ -const fakeFetch = (routes: Record never)>): ProbeFetch => { - const seen: Array = []; - const impl: ProbeFetch = async (url) => { - seen.push(url); - const route = routes[url]; - if (route === undefined) throw new Error(`unexpected probe request: ${url}`); - if (typeof route === "function") route(); - return route as ProbeResponse; - }; - return Object.assign(impl, { seen }); -}; - -const timeoutError = (): never => { - throw Object.assign(new Error("The operation timed out."), { name: "TimeoutError" }); -}; - -const dnsError = (): never => { - throw Object.assign(new Error("Unable to connect. Is the computer able to access the url?"), { - cause: { code: "ENOTFOUND" }, - }); -}; - -/** A pinned clock: elapsed times appear in output, so they must not be wall time. */ -const steppingClock = (): (() => number) => { - let value = 1_000; - return () => { - value += 25; - return value; - }; -}; - -describe("the manifest", () => { - test("names the nine backing Workers with a public origin each", () => { - expect(BACKING_WORKERS.map((entry) => entry.name).sort()).toEqual([ - "billing", - "chat", - "connectors-catalog", - "cron", - "identity", - "recommendations", - "status", - "sync", - "webhooks", - ]); - for (const entry of BACKING_WORKERS) { - expect(entry.origin).toStartWith("https://"); - expect(entry.note).not.toBe(""); - } - }); - - test("carries no credential: no origin embeds userinfo or a query token", () => { - for (const entry of BACKING_WORKERS) { - const url = new URL(entry.origin ?? "https://unset.invalid"); - expect(url.username).toBe(""); - expect(url.password).toBe(""); - expect(url.search).toBe(""); - } - }); - - test("covers every upstream origin apps/server/wrangler.jsonc configures", async () => { - // The drift guard that makes a committed manifest worth trusting: if an - // operator repoints a seam at a host this file does not know, CN-18 would - // otherwise stay green while probing a stack the product no longer calls. - const wrangler = await Bun.file(new URL("../../wrangler.jsonc", import.meta.url)).text(); - const configured = [...wrangler.matchAll(/"([A-Z_]*(?:UPSTREAM_URL|CHAT_URL))"\s*:\s*"([^"]+)"/g)].map( - (match) => ({ name: match[1] as string, origin: new URL(match[2] as string).origin }), - ); - expect(configured.length).toBeGreaterThan(0); - const known = new Set( - BACKING_WORKERS.flatMap((entry) => [entry.origin, ...entry.alternateOrigins]).filter( - (origin): origin is string => origin !== undefined, - ), - ); - for (const upstream of configured) { - expect({ ...upstream, known: known.has(upstream.origin) }).toEqual({ ...upstream, known: true }); - } - }); - - test("the three Workers with no health route are `responds`, and only those", () => { - const responds = BACKING_WORKERS.filter((entry) => entry.contract === "responds").map((entry) => entry.name); - expect(responds.sort()).toEqual(["chat", "cron", "webhooks"]); - }); - - test("sync's health path is /health — /healthz is a 404 on that Worker", () => { - expect(healthUrl(worker({ name: "sync", origin: "https://sync.test", path: "/health" }))).toBe( - "https://sync.test/health", - ); - }); -}); - -describe("$CANARY_WORKER_ORIGINS", () => { - test("an unset variable leaves the manifest alone", () => { - expect(withOriginOverrides(BACKING_WORKERS, undefined)).toEqual(BACKING_WORKERS); - expect(withOriginOverrides(BACKING_WORKERS, " ")).toEqual(BACKING_WORKERS); - }); - - test("overriding one origin leaves the other eight at their defaults", () => { - const overridden = withOriginOverrides(BACKING_WORKERS, JSON.stringify({ identity: "https://identity.staging" })); - expect(overridden.find((entry) => entry.name === "identity")?.origin).toBe("https://identity.staging"); - expect(overridden.find((entry) => entry.name === "billing")?.origin).toBe( - BACKING_WORKERS.find((entry) => entry.name === "billing")?.origin, - ); - }); - - test('"" and null declare a Worker unset on this deployment', () => { - const overridden = withOriginOverrides(BACKING_WORKERS, '{"cron":"","webhooks":null}'); - expect(overridden.find((entry) => entry.name === "cron")?.origin).toBeUndefined(); - expect(overridden.find((entry) => entry.name === "webhooks")?.origin).toBeUndefined(); - }); - - test("malformed values throw instead of silently probing the default deployment", () => { - expect(() => withOriginOverrides(BACKING_WORKERS, "not json")).toThrow(/not a JSON object/); - expect(() => withOriginOverrides(BACKING_WORKERS, "[1,2]")).toThrow(/not a JSON object/); - expect(() => withOriginOverrides(BACKING_WORKERS, '{"identity":7}')).toThrow(/must be an origin string/); - expect(() => withOriginOverrides(BACKING_WORKERS, '{"identity":"identity.example"}')).toThrow( - /not an absolute URL/, - ); - expect(() => withOriginOverrides(BACKING_WORKERS, '{"identity":"ftp://identity.example"}')).toThrow( - /must be http or https/, - ); - }); - - test("a typo'd Worker name throws and lists the known names", () => { - expect(() => withOriginOverrides(BACKING_WORKERS, '{"identiy":"https://identity.test"}')).toThrow( - /unknown Worker "identiy"/, - ); - }); -}); - -describe("Cloudflare's edge error page", () => { - /* - * The regression suite for CN-18. Before the body check, every one of these - * bodies read as a healthy Worker, because the probe kept only the status and - * the edge answers 404 for a host with nothing deployed on it. - */ - test("names the error code in each shape the edge serves", () => { - expect(cloudflareEdgeError(EDGE_JSON_404)).toBe("1042"); - expect(cloudflareEdgeError(EDGE_PLAIN_404)).toBe("1042"); - expect(cloudflareEdgeError(EDGE_CLASSIC_HTML_403)).toBe("1020"); - expect(cloudflareEdgeError(EDGE_HTML_404)).toBe("unnumbered"); - }); - - test("a live Worker's own body is never mistaken for the edge", () => { - // The three responds Workers, as measured on 2026-08-19. - expect(cloudflareEdgeError("Not found")).toBeUndefined(); - expect(cloudflareEdgeError(JSON.stringify({ error: "Forbidden origin" }))).toBeUndefined(); - expect(cloudflareEdgeError("")).toBeUndefined(); - // A Worker that quotes the phrase is still a Worker: the plain shape has - // to be the whole body, and the JSON shape has to be edge-authored. - expect(cloudflareEdgeError("upstream said: error code: 1042, retrying")).toBeUndefined(); - expect(cloudflareEdgeError(JSON.stringify({ error: "error code: 1042" }))).toBeUndefined(); - expect(cloudflareEdgeError(JSON.stringify({ error_name: "rate_limited", error_code: "slow_down" }))).toBeUndefined(); - expect(cloudflareEdgeError("Page not found

this Worker has no such route

")).toBeUndefined(); - }); -}); - -describe("one Worker's verdict", () => { - test("ok-json: HTTP 200 with ok:true is healthy", async () => { - const observation = await probeWorker(worker(), { - fetch: fakeFetch({ "https://identity.test/healthz": jsonResponse(200, { ok: true, oauth: true }) }), - now: steppingClock(), - }); - const verdict = workerHealthVerdict(observation); - expect(verdict.state).toBe("healthy"); - expect(verdict.detail).toContain("ok in 25ms"); - expect(formatVerdictLine(verdict)).toStartWith("ok: "); - }); - - test("ok-json: HTTP 500 is unhealthy and names the status", async () => { - const observation = await probeWorker(worker({ name: "billing", origin: "https://billing.test" }), { - fetch: fakeFetch({ "https://billing.test/healthz": textResponse(500, "Internal Error") }), - }); - const verdict = workerHealthVerdict(observation); - expect(verdict.state).toBe("unhealthy"); - expect(verdict.detail).toContain("answered HTTP 500"); - expect(formatVerdictLine(verdict)).toStartWith("FAIL: "); - }); - - test("ok-json: HTTP 200 with a malformed body is unhealthy, and the excerpt is bounded", async () => { - const observation = await probeWorker(worker(), { - fetch: fakeFetch({ "https://identity.test/healthz": textResponse(200, `${"x".repeat(500)}`) }), - }); - const verdict = workerHealthVerdict(observation); - expect(verdict.state).toBe("unhealthy"); - expect(verdict.detail).toContain("the body is not JSON"); - expect(verdict.detail.length).toBeLessThan(260); - }); - - test("ok-json: HTTP 200 reporting ok:false is unhealthy, not healthy-because-200", async () => { - const observation = await probeWorker(worker(), { - fetch: fakeFetch({ "https://identity.test/healthz": jsonResponse(200, { ok: false, oauth: false }) }), - }); - const verdict = workerHealthVerdict(observation); - expect(verdict.state).toBe("unhealthy"); - expect(verdict.detail).toContain("without ok:true"); - }); - - test("responds: a 403 from an origin-gated Worker is healthy — routability is the assertion", async () => { - const cron = worker({ name: "cron", origin: "https://cron.test", path: "/", contract: "responds" }); - const observation = await probeWorker(cron, { - fetch: fakeFetch({ "https://cron.test/": jsonResponse(403, { error: "Forbidden origin" }) }), - }); - const verdict = workerHealthVerdict(observation); - expect(verdict.state).toBe("healthy"); - expect(verdict.detail).toContain("HTTP 403"); - }); - - test("responds: HTTP 404 from Cloudflare's edge is unhealthy — the Worker is gone (CN-18)", async () => { - // The exact collision: this is byte-for-byte what an undeployed - // *.workers.dev host returned on 2026-08-19, and the status is the same - // 404 the live chat Worker answers with at /. - const chat = worker({ name: "chat", origin: "https://chat.test", path: "/", contract: "responds" }); - const observation = await probeWorker(chat, { - fetch: fakeFetch({ "https://chat.test/": textResponse(404, EDGE_JSON_404) }), - }); - const verdict = workerHealthVerdict(observation); - expect(verdict.state).toBe("unhealthy"); - expect(verdict.detail).toContain("error code 1042"); - expect(verdict.detail).toContain("Nothing is deployed on this route"); - }); - - test("responds: the same 404 with the Worker's own body stays healthy — the fix is not an inversion", async () => { - const chat = worker({ name: "chat", origin: "https://chat.test", path: "/", contract: "responds" }); - const observation = await probeWorker(chat, { - fetch: fakeFetch({ "https://chat.test/": textResponse(404, "Not found") }), - }); - expect(workerHealthVerdict(observation).state).toBe("healthy"); - }); - - test("responds: an unreadable body is unhealthy — an unchecked body cannot be called healthy", async () => { - const chat = worker({ name: "chat", origin: "https://chat.test", path: "/", contract: "responds" }); - const observation = await probeWorker(chat, { - fetch: fakeFetch({ "https://chat.test/": unreadableResponse(404) }), - }); - const verdict = workerHealthVerdict(observation); - expect(verdict.state).toBe("unhealthy"); - expect(verdict.detail).toContain("cannot be ruled out"); - }); - - test("ok-json: a non-200 from the edge says so, so an operator is sent to the right place", async () => { - const observation = await probeWorker(worker(), { - fetch: fakeFetch({ "https://identity.test/healthz": textResponse(404, EDGE_PLAIN_404) }), - }); - const verdict = workerHealthVerdict(observation); - expect(verdict.state).toBe("unhealthy"); - expect(verdict.detail).toContain("from Cloudflare's edge (error code 1042)"); - }); - - test("responds: a 5xx is unhealthy — a routable Worker is not the same as a working one", async () => { - const cron = worker({ name: "cron", origin: "https://cron.test", path: "/", contract: "responds" }); - const observation = await probeWorker(cron, { - fetch: fakeFetch({ "https://cron.test/": textResponse(522, "connection timed out") }), - }); - expect(workerHealthVerdict(observation).state).toBe("unhealthy"); - }); - - test("a timeout is unhealthy and says so — not a DNS failure, not a bad status", async () => { - const observation = await probeWorker(worker({ name: "sync", origin: "https://sync.test", path: "/health" }), { - fetch: fakeFetch({ "https://sync.test/health": timeoutError }), - timeoutMs: 8_000, - }); - const verdict = workerHealthVerdict(observation); - expect(verdict.state).toBe("unhealthy"); - expect(verdict.detail).toContain("timed out after 8000ms"); - }); - - test("a DNS failure is unhealthy and carries the error code", async () => { - const observation = await probeWorker(worker({ name: "status", origin: "https://status.test", path: "/healthz" }), { - fetch: fakeFetch({ "https://status.test/healthz": dnsError }), - }); - const verdict = workerHealthVerdict(observation); - expect(verdict.state).toBe("unhealthy"); - expect(verdict.detail).toContain("ENOTFOUND"); - }); - - test("an unset Worker is not-configured, and no request is made for it", async () => { - const fetchImpl = fakeFetch({}); - const observation = await probeWorker(worker({ origin: undefined }), { fetch: fetchImpl }); - const verdict = workerHealthVerdict(observation); - expect(verdict.state).toBe("not-configured"); - expect(verdict.detail).toContain("CANARY_WORKER_ORIGINS"); - expect(formatVerdictLine(verdict)).toStartWith("skip: "); - expect((fetchImpl as unknown as { seen: Array }).seen).toEqual([]); - }); -}); - -describe("the run", () => { - const allHealthy = (): Record never)> => - Object.fromEntries( - expandTargets(BACKING_WORKERS).map((entry) => [ - healthUrl(entry) as string, - entry.contract === "ok-json" ? jsonResponse(200, { ok: true }) : textResponse(404, "Not found"), - ]), - ); - - const runWith = async ( - routes: Record never)>, - env: Record = {}, - ): Promise<{ summary: Awaited>; lines: Array }> => { - const lines: Array = []; - const summary = await runWorkersHealth({ - fetch: fakeFetch(routes), - env, - now: steppingClock(), - log: (line) => lines.push(line), - }); - return { summary, lines }; - }; - - const TARGET_COUNT = expandTargets(BACKING_WORKERS).length; - - test("every route healthy exits 0 and reports every route", async () => { - const { summary, lines } = await runWith(allHealthy()); - expect(summary).toMatchObject({ healthy: TARGET_COUNT, unhealthy: 0, notConfigured: 0, exitCode: 0 }); - expect(summary.line).toContain("CN-18 PASS"); - expect(lines.filter((line) => line.startsWith("ok: "))).toHaveLength(TARGET_COUNT); - }); - - test("the workers.dev route apps/server actually configures is probed, not just the custom domain", async () => { - const routes = allHealthy(); - // The route wrangler.jsonc points IDENTITY_UPSTREAM_URL at. If only the - // custom domain were probed, sign-in could be dead with CN-18 green. - routes["https://smithers-cloud-identity.willcory10.workers.dev/healthz"] = jsonResponse(200, { ok: false }); - const { summary, lines } = await runWith(routes); - expect(summary.exitCode).toBe(1); - expect(lines).toContainEqual(expect.stringContaining("FAIL: identity via smithers-cloud-identity.willcory10.workers.dev")); - expect(lines).toContainEqual(expect.stringContaining("ok: identity https://identity.smithers.sh/healthz")); - }); - - test("pointing chat at an undeployed workers.dev host fails the run (the CN-18 reproduction)", async () => { - // The auditor's command, in fake form: - // CANARY_WORKER_ORIGINS='{"chat":"https://this-worker-does-not-exist…"}' - // The run used to report CN-18 PASS with exit 0. - const routes = allHealthy(); - routes["https://this-worker-does-not-exist-xyz123.willcory10.workers.dev/"] = textResponse(404, EDGE_JSON_404); - const { summary, lines } = await runWith(routes, { - CANARY_WORKER_ORIGINS: '{"chat":"https://this-worker-does-not-exist-xyz123.willcory10.workers.dev"}', - }); - expect(summary.exitCode).toBe(1); - expect(summary.unhealthy).toBe(1); - expect(summary.line).toContain("CN-18 FAILED"); - expect(lines).toContainEqual(expect.stringContaining("FAIL: chat")); - }); - - test("an override replaces a Worker's routes rather than adding to them", async () => { - const routes = allHealthy(); - routes["https://identity.staging/healthz"] = jsonResponse(200, { ok: true }); - // No route is registered for the canary's workers.dev twin here: the fake - // fetch throws on an unexpected URL, so probing it would fail this test. - const { summary, lines } = await runWith(routes, { - CANARY_WORKER_ORIGINS: '{"identity":"https://identity.staging"}', - }); - expect(summary.exitCode).toBe(0); - expect(lines.filter((line) => line.includes("identity"))).toHaveLength(1); - }); - - test("reports in manifest order so a diff of two runs is readable", async () => { - const { lines } = await runWith(allHealthy()); - const names = lines.filter((line) => line.startsWith("ok: ")).map((line) => line.slice(4).split(" ")[0]); - expect(names).toEqual(expandTargets(BACKING_WORKERS).map((entry) => entry.name.split(" ")[0])); - }); - - test("one 500, one timeout, one unconfigured: two failures, one skip, and exit 1", async () => { - const routes = allHealthy(); - routes["https://billing.smithers.sh/healthz"] = textResponse(500, "boom"); - routes["https://sync.smithers.sh/health"] = timeoutError; - const { summary, lines } = await runWith(routes, { CANARY_WORKER_ORIGINS: '{"cron":""}' }); - expect(summary).toMatchObject({ healthy: TARGET_COUNT - 3, unhealthy: 2, notConfigured: 1, exitCode: 1 }); - expect(summary.line).toContain("billing, sync"); - expect(lines.some((line) => line.startsWith("skip: cron"))).toBe(true); - // The skip must not be counted as a failure, and the failures must not - // be counted as skips: three states, three tallies. - expect(lines.filter((line) => line.startsWith("FAIL: "))).toHaveLength(2); - }); - - test("a healthy Worker an operator left unset never reads as broken", async () => { - const routes = allHealthy(); - const { summary, lines } = await runWith(routes, { CANARY_WORKER_ORIGINS: '{"webhooks":null}' }); - expect(summary.exitCode).toBe(0); - expect(summary.notConfigured).toBe(1); - expect(lines.some((line) => line.startsWith("FAIL"))).toBe(false); - }); - - test("a run that probed nothing fails instead of reporting a green CN-18", async () => { - const blanked = JSON.stringify(Object.fromEntries(BACKING_WORKERS.map((entry) => [entry.name, ""]))); - const { summary } = await runWith({}, { CANARY_WORKER_ORIGINS: blanked }); - expect(summary).toMatchObject({ healthy: 0, unhealthy: 0, notConfigured: 9, exitCode: 1 }); - // Nine, not twelve: an unset Worker has no routes to expand. - expect(summary.line).toContain("ASSERTED NOTHING"); - }); - - test("a bad override aborts the run rather than probing the wrong deployment", async () => { - await expect(runWith({}, { CANARY_WORKER_ORIGINS: "{" })).rejects.toThrow(/not a JSON object/); - }); -}); - -describe("the summary", () => { - const verdicts = (...states: ReadonlyArray): ReadonlyArray => - states.map((state, index) => ({ name: `w${index}`, state, detail: "" })); - - test("any unhealthy Worker fails the run", () => { - expect(summarizeHealth(verdicts("healthy", "unhealthy", "not-configured")).exitCode).toBe(1); - }); - - test("not-configured alone never fails the run", () => { - expect(summarizeHealth(verdicts("healthy", "not-configured")).exitCode).toBe(0); - }); -}); diff --git a/apps/server/scripts/canary/workers-health.ts b/apps/server/scripts/canary/workers-health.ts deleted file mode 100644 index ee3bb475..00000000 --- a/apps/server/scripts/canary/workers-health.ts +++ /dev/null @@ -1,423 +0,0 @@ -/* - * CN-18: the nine backing Workers answer a health probe. - * - * bun scripts/canary/workers-health.ts [--timeout ] - * - * apps/ui/scripts/canary-seam-probe.ts already probes the seams THROUGH the - * product Worker, which is the right test for "is the product honest about its - * upstreams". It cannot tell a healthy upstream from a proxy that never called - * one, and it says nothing at all about the five Workers apps/server does not - * proxy (connectors-catalog, cron, status, sync, webhooks). This probe is the - * other half: it calls each Worker directly, at its own origin, with no - * credential. When both pass, the seam and the service behind it are both good; - * when this one fails and the seam probe passes, the product is masking an - * outage. - * - * Three states per Worker, never conflated: - * - * healthy it answered its contract (see workers-manifest.ts). - * unhealthy it answered wrongly, answered 5xx, or did not answer. - * not-configured this deployment declares it unset ($CANARY_WORKER_ORIGINS). - * Not a failure — canary-seam-probe.ts models the same - * honesty for the deliberately-unset gateway seam. - * - * Exit 1 when any Worker is unhealthy, and also when NOTHING was configured — a - * run that asserted nothing must not report PASS. Exit 2 when the probe could - * not run at all, which is a different fact from a Worker being down. - */ -import { BACKING_WORKERS, expandTargets, healthUrl, ORIGIN_OVERRIDE_ENV, withOriginOverrides } from "./workers-manifest.ts"; -import type { BackingWorker } from "./workers-manifest.ts"; - -/** The slice of Response this probe uses, so a test can hand it a fake. */ -export interface ProbeResponse { - readonly status: number; - text(): Promise; -} - -export type ProbeFetch = ( - url: string, - init: { readonly signal: AbortSignal; readonly headers: Record }, -) => Promise; - -export type HealthState = "healthy" | "unhealthy" | "not-configured"; - -/** - * What came back in the body. - * - * none Nothing was read: the Worker is unset, or reading the body failed. - * Every contract fails closed on it. - * text The raw body, kept for the responds contract, which reads it to - * rule out Cloudflare's edge error page (see cloudflareEdgeError). - * json Parsed JSON, for the ok-json contract. - * invalid A body that was supposed to be JSON and is not. - */ -export type HealthBody = - | { readonly kind: "none" } - | { readonly kind: "text"; readonly text: string } - | { readonly kind: "json"; readonly value: unknown } - | { readonly kind: "invalid"; readonly text: string }; - -/** What the probe read back. */ -export interface HealthObservation { - readonly worker: BackingWorker; - /** undefined when the request never completed. */ - readonly status: number | undefined; - readonly body: HealthBody; - readonly transportError: string | undefined; - readonly elapsedMs: number; -} - -export interface HealthVerdict { - readonly name: string; - readonly state: HealthState; - readonly detail: string; -} - -const DEFAULT_TIMEOUT_MS = 8_000; - -/** Cap what a stranger's response body can print into CI logs. */ -const excerpt = (text: string, limit = 120): string => { - const flat = text.replace(/\s+/g, " ").trim(); - return flat.length <= limit ? flat : `${flat.slice(0, limit)}…`; -}; - -/** - * How much of a response body the probe keeps. Cloudflare's edge error bodies - * are a few hundred bytes of JSON or plain text and its HTML pages run to - * roughly 20 KiB with their markers in the first 2 KiB, so this holds every - * shape cloudflareEdgeError looks for while bounding what a stranger's Worker - * can make this process allocate. - */ -const MAX_BODY_CHARS = 16_384; - -const isRecord = (value: unknown): value is Record => - typeof value === "object" && value !== null && !Array.isArray(value); - -/** - * Report the error code when a body came from Cloudflare's edge rather than - * from a Worker. - * - * This is the whole of CN-18. The edge answers for a hostname with nothing - * deployed behind it, and it answers with the same status a live Worker uses. - * Measured 2026-08-19: an undeployed *.workers.dev host answered HTTP 404, and - * so did the live chat Worker at /. Status cannot separate them. Body can. - * - * The edge content-negotiates its error, so there is no single body to match. - * All three shapes were captured from the same undeployed host on 2026-08-19 by - * varying only the request headers: - * - * json What THIS probe receives, because it sends `accept: application/json` - * and a compressed encoding. An RFC 9457 problem document that names - * itself: `"cloudflare_error": true`, a `type` under - * developers.cloudflare.com, and the triple `"error_code": 1042`, - * `"error_name": "workers_dev_script_not_found"`, `"ray_id"`. Any one - * of those three signals is conclusive on its own. - * plain `error code: NNNN` and nothing else. What an uncompressed non-browser - * request gets, and the historical shape most Cloudflare documentation - * still shows. - * page HTML. Two generations are in service: the classic branded error page, - * keyed by the `cf-error-details` and `cf-error-code` hooks Cloudflare's - * own stylesheet targets, and the newer workers.dev "Page not found" - * page, which carries no error code and is keyed by its title together - * with the workers.cloudflare.com favicon it loads. The classic page is - * what the edge serves for WAF and rate-limit blocks, which arrive as - * 403 and 429 — statuses a responds Worker is otherwise allowed to - * answer with. - * - * Keyed on the shape and on Cloudflare's self-identification, never on a - * particular error number: Cloudflare assigns a code per failure mode and adds - * new ones, but every code is rendered through these templates. Every match is - * anchored — the plain form must be the entire body, and the JSON form must be - * a top-level object with an edge-authored field — so a Worker that quotes the - * phrase inside a larger message is not mistaken for the edge. - */ -export const cloudflareEdgeError = (text: string): string | undefined => { - const flat = text.trim(); - - const plain = /^error code:\s*(\d{3,5})$/i.exec(flat); - if (plain !== null) return plain[1]; - - if (flat.startsWith("{")) { - let parsed: unknown; - try { - parsed = JSON.parse(flat) as unknown; - } catch { - parsed = undefined; - } - if (isRecord(parsed)) { - const type = typeof parsed.type === "string" ? parsed.type : ""; - // Each disjunct is something only the edge writes. `error_name` is - // not one of them on its own — a Worker could ship that key — so it - // counts only alongside the numeric code and the Ray ID. - const authored = - parsed.cloudflare_error === true || - type.startsWith("https://developers.cloudflare.com/") || - (typeof parsed.error_code === "number" && - typeof parsed.error_name === "string" && - typeof parsed.ray_id === "string"); - if (authored) { - const code = parsed.error_code; - return typeof code === "number" || typeof code === "string" ? String(code) : "unnumbered"; - } - } - return undefined; - } - - if (/cf-error-details|cf-error-code/i.test(flat)) { - const numbered = /class="cf-error-code"[^>]*>\s*(\d{3,5})/i.exec(flat) ?? /error code:?\s*(\d{3,5})/i.exec(flat); - // The page without a readable number is still the edge, so it still fails. - return numbered?.[1] ?? "unnumbered"; - } - - // The newer workers.dev placeholder. Both markers are required: the title - // alone is a phrase any Worker could return, and the favicon host alone - // could appear in a Worker's own page. - if (/\s*Page not found\s*<\/title>/i.test(flat) && flat.includes("workers.cloudflare.com/favicon.ico")) { - return "unnumbered"; - } - - return undefined; -}; - -/** - * Name the failure the way an operator would triage it. A timeout, a DNS - * failure, and a refused connection have different owners, so they must not - * collapse into one "fetch failed". - */ -export const describeTransportError = (error: unknown, timeoutMs: number): string => { - if (error instanceof Error) { - if (error.name === "TimeoutError" || error.name === "AbortError") return `timed out after ${timeoutMs}ms`; - const cause = (error as { cause?: unknown }).cause; - const code = typeof cause === "object" && cause !== null ? (cause as { code?: unknown }).code : undefined; - if (typeof code === "string") return `${error.message} (${code})`; - return error.message; - } - return typeof error === "string" ? error : "unknown transport error"; -}; - -/** Read one Worker. An unset Worker is not fetched at all. */ -export const probeWorker = async ( - worker: BackingWorker, - options: { readonly fetch: ProbeFetch; readonly timeoutMs?: number; readonly now?: () => number }, -): Promise<HealthObservation> => { - const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; - const now = options.now ?? Date.now; - const url = healthUrl(worker); - if (url === undefined) { - return { worker, status: undefined, body: { kind: "none" }, transportError: undefined, elapsedMs: 0 }; - } - - const started = now(); - let response: ProbeResponse; - try { - response = await options.fetch(url, { - signal: AbortSignal.timeout(timeoutMs), - // No credential, no cookie: health is a public fact about these - // Workers, and a probe that needed a secret could not run in CI. - headers: { accept: "application/json", "user-agent": "smithers-canary-workers-health" }, - }); - } catch (error) { - return { - worker, - status: undefined, - body: { kind: "none" }, - transportError: describeTransportError(error, timeoutMs), - elapsedMs: now() - started, - }; - } - - // The `responds` contract KEEPS the body. It used to drain and drop it, on - // the theory that routability was the whole assertion; a *.workers.dev - // hostname with no Worker behind it answers HTTP 404, byte-identical in - // status to the healthy chat Worker's own 404, so the status alone reported - // a deleted Worker as healthy (CN-18, proved live 2026-08-19). The body is - // what tells the edge apart from the Worker. - if (worker.contract === "responds") { - const read = await response.text().then( - (value) => ({ ok: true, text: value.slice(0, MAX_BODY_CHARS) }), - () => ({ ok: false, text: "" }), - ); - return { - worker, - status: response.status, - // A body that could not be read leaves the edge-error check unable to - // run, so it stays `none` and the verdict fails closed. Guessing here - // would restore the exact hole this contract was widened to close. - body: read.ok ? { kind: "text", text: read.text } : { kind: "none" }, - transportError: undefined, - elapsedMs: now() - started, - }; - } - - const text = (await response.text().catch(() => "")).slice(0, MAX_BODY_CHARS); - let body: HealthBody; - try { - body = { kind: "json", value: JSON.parse(text) as unknown }; - } catch { - body = { kind: "invalid", text }; - } - return { worker, status: response.status, body, transportError: undefined, elapsedMs: now() - started }; -}; - -export const workerHealthVerdict = (observation: HealthObservation): HealthVerdict => { - const { worker, status, elapsedMs } = observation; - const name = worker.name; - if (worker.origin === undefined) { - return { - name, - state: "not-configured", - detail: `${name} is not configured on this deployment (no origin in workers-manifest.ts, or $${ORIGIN_OVERRIDE_ENV} declares it unset); nothing probed.`, - }; - } - if (observation.transportError !== undefined) { - return { name, state: "unhealthy", detail: `${name} ${worker.origin} did not answer: ${observation.transportError}` }; - } - if (status === undefined) { - return { name, state: "unhealthy", detail: `${name} ${worker.origin} did not answer.` }; - } - - const target = healthUrl(worker) ?? worker.origin; - if (worker.contract === "responds") { - if (status >= 500) return { name, state: "unhealthy", detail: `${name} ${target} answered HTTP ${status}.` }; - if (observation.body.kind !== "text") { - return { - name, - state: "unhealthy", - detail: `${name} ${target} answered HTTP ${status} but its body was not read, so Cloudflare's edge error page cannot be ruled out.`, - }; - } - const edgeCode = cloudflareEdgeError(observation.body.text); - if (edgeCode !== undefined) { - return { - name, - state: "unhealthy", - detail: `${name} ${target} answered HTTP ${status}, but the body is Cloudflare's edge error page (error code ${edgeCode}), not the Worker's: ${excerpt(observation.body.text)}. Nothing is deployed on this route.`, - }; - } - return { - name, - state: "healthy", - detail: `${name} ${target} answered HTTP ${status} in ${elapsedMs}ms — ${worker.note}`, - }; - } - - if (status !== 200) { - // Same edge/Worker distinction, reported for triage only: an ok-json - // Worker already fails on any non-200, but "answered HTTP 404" and - // "nothing is deployed there" send an operator to different places. - const raw = observation.body.kind === "invalid" ? cloudflareEdgeError(observation.body.text) : undefined; - if (raw !== undefined) { - return { - name, - state: "unhealthy", - detail: `${name} ${target} answered HTTP ${status} from Cloudflare's edge (error code ${raw}), not from the Worker. Nothing is deployed on this route.`, - }; - } - return { name, state: "unhealthy", detail: `${name} ${target} answered HTTP ${status}.` }; - } - if (observation.body.kind === "invalid") { - return { - name, - state: "unhealthy", - detail: `${name} ${target} answered HTTP 200 but the body is not JSON: ${excerpt(observation.body.text)}`, - }; - } - if (observation.body.kind !== "json") { - return { name, state: "unhealthy", detail: `${name} ${target} answered HTTP 200 but no JSON body was read.` }; - } - if (!isRecord(observation.body.value) || observation.body.value.ok !== true) { - return { - name, - state: "unhealthy", - detail: `${name} ${target} answered HTTP 200 without ok:true: ${excerpt(JSON.stringify(observation.body.value))}`, - }; - } - return { - name, - state: "healthy", - detail: `${name} ${target} ok in ${elapsedMs}ms — ${excerpt(JSON.stringify(observation.body.value))}`, - }; -}; - -export interface HealthSummary { - readonly healthy: number; - readonly unhealthy: number; - readonly notConfigured: number; - readonly exitCode: number; - readonly line: string; -} - -export const summarizeHealth = (verdicts: ReadonlyArray<HealthVerdict>): HealthSummary => { - const healthy = verdicts.filter((verdict) => verdict.state === "healthy").length; - const unhealthy = verdicts.filter((verdict) => verdict.state === "unhealthy").length; - const notConfigured = verdicts.filter((verdict) => verdict.state === "not-configured").length; - // Targets, not Workers: identity, reco and chat each answer on more than one - // route, and every route the product configures is probed. - const counts = `${healthy} healthy, ${unhealthy} unhealthy, ${notConfigured} not configured, of ${verdicts.length} targets`; - if (unhealthy > 0) { - const names = verdicts.filter((verdict) => verdict.state === "unhealthy").map((verdict) => verdict.name); - return { healthy, unhealthy, notConfigured, exitCode: 1, line: `CN-18 FAILED: ${counts} — ${names.join(", ")}` }; - } - if (healthy === 0) { - return { - healthy, - unhealthy, - notConfigured, - exitCode: 1, - // A run that probed nothing is not a passing run. Without this, an - // override that blanks the manifest reports a green CN-18 forever. - line: `CN-18 ASSERTED NOTHING: ${counts}. Check $${ORIGIN_OVERRIDE_ENV}.`, - }; - } - return { healthy, unhealthy, notConfigured, exitCode: 0, line: `CN-18 PASS: ${counts}` }; -}; - -export const formatVerdictLine = (verdict: HealthVerdict): string => { - if (verdict.state === "healthy") return `ok: ${verdict.detail}`; - if (verdict.state === "not-configured") return `skip: ${verdict.detail}`; - return `FAIL: ${verdict.detail}`; -}; - -/** Probe every Worker concurrently and report, in manifest order. */ -export const runWorkersHealth = async (options: { - readonly fetch: ProbeFetch; - readonly env: Record<string, string | undefined>; - readonly timeoutMs?: number; - readonly now?: () => number; - readonly log: (line: string) => void; -}): Promise<HealthSummary> => { - const targets = expandTargets(withOriginOverrides(BACKING_WORKERS, options.env[ORIGIN_OVERRIDE_ENV])); - const observations = await Promise.all( - targets.map((worker) => probeWorker(worker, { fetch: options.fetch, timeoutMs: options.timeoutMs, now: options.now })), - ); - const verdicts = observations.map(workerHealthVerdict); - for (const verdict of verdicts) options.log(formatVerdictLine(verdict)); - const summary = summarizeHealth(verdicts); - options.log(""); - options.log(summary.line); - return summary; -}; - -if (import.meta.main) { - const timeoutFlag = process.argv.indexOf("--timeout"); - const timeoutMs = timeoutFlag === -1 ? DEFAULT_TIMEOUT_MS : Number(process.argv[timeoutFlag + 1]); - if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { - console.error(`--timeout wants a positive number of milliseconds, got ${process.argv[timeoutFlag + 1]}`); - process.exit(2); - } - try { - const summary = await runWorkersHealth({ - fetch: (url, init) => fetch(url, init), - env: process.env, - timeoutMs, - log: (line) => console.log(line), - }); - process.exit(summary.exitCode); - } catch (error) { - // A rejected run means the probe never ran, which is a different thing - // from a Worker being down. Exit 2, and print the reason rather than a - // stack trace an operator has to read past. - console.error(`CN-18 DID NOT RUN: ${error instanceof Error ? error.message : String(error)}`); - process.exit(2); - } -} diff --git a/apps/server/scripts/canary/workers-manifest.ts b/apps/server/scripts/canary/workers-manifest.ts deleted file mode 100644 index 2ce06bbd..00000000 --- a/apps/server/scripts/canary/workers-manifest.ts +++ /dev/null @@ -1,243 +0,0 @@ -/* - * The nine Cloudflare Workers the product runs on (E2E-CANARY-CHECKLIST CN-18). - * - * None of them is in this repository. They are deployed from ~/flows/ui/workers/ - * (see apps/UPSTREAMS.md), so nothing here can build, test, or roll one back — - * which is exactly why their health has to be assertable from outside. This - * file is the only place the deployment's shape is written down where CI can - * read it. - * - * These origins are addresses, not credentials. Four of them are already - * committed in apps/server/wrangler.jsonc, all nine resolve in public DNS, and - * this probe sends no token. The list is committed on purpose: a GitHub secret - * cannot be diffed, so a wrong origin hidden in one would probe nothing and - * report PASS. $CANARY_WORKER_ORIGINS overrides it per deployment. - * - * Nine Workers, twelve routes: identity, reco and chat each answer on both a - * custom domain and the workers.dev hostname apps/server/wrangler.jsonc - * actually points at, and both routes are probed (see alternateOrigins). - * - * Two contracts, because the Workers do not all offer the same surface: - * - * ok-json HTTP 200 with a JSON body whose `ok` is true. This is the same - * contract apps/server/src/index.ts's readServiceHealth already - * applies when it composes the admin health card. - * responds A response below HTTP 500 whose body came from the Worker rather - * than from Cloudflare's edge. Three Workers expose no health route - * at all, so the only honest assertion left is that the Worker is - * deployed and routable: a transport failure, a 5xx, or an edge - * error page is the failure. The body check is load-bearing, not - * decoration — a *.workers.dev hostname with nothing deployed - * behind it answers HTTP 404, exactly like the live chat Worker - * does at /, so a status-only assertion reported a deleted Worker - * as healthy (CN-18, proved live 2026-08-19). See - * cloudflareEdgeError in workers-health.ts. - * - * This is still weaker than ok-json: it proves a Worker answered, - * not that its dependencies are up. Promote a Worker to ok-json as - * soon as it exposes a health route, and not before — asserting a - * route that does not exist would fail forever. Landing one is - * work for whoever owns ~/flows/ui/workers/: the chat Worker needs - * an unauthenticated GET /healthz returning HTTP 200 and - * {"ok":true,...}, reachable without an allowed Origin header, the - * same shape identity, billing, reco, connectors, status and sync - * already serve. - * - * Contracts measured live on 2026-08-18, and the responds bodies again on - * 2026-08-19, against every origin below. - */ - -/** @see BACKING_WORKERS for which Worker carries which contract. */ -export type HealthContract = "ok-json" | "responds"; - -export interface BackingWorker { - readonly name: string; - /** - * undefined means this deployment declares the Worker unset. That is a - * state, not a failure — the same honesty canary-seam-probe.ts applies to - * the deliberately-unset gateway seam, where a 501 is the PASS shape. - */ - readonly origin: string | undefined; - /** - * The other routes this product configures for the same seam. identity and - * reco answer on both a custom domain and a workers.dev subdomain, and - * apps/server/wrangler.jsonc points at the workers.dev one; the canary's - * chat upstream is a separate deployment of the chat Worker entirely. Probing - * only the custom domain would report a green CN-18 while the route the - * product actually calls was dead. Each alternate carries its Worker's - * contract. - */ - readonly alternateOrigins: ReadonlyArray<string>; - /** Health path, joined onto the origin. "/" for a Worker with no health route. */ - readonly path: string; - readonly contract: HealthContract; - /** Why this Worker carries this contract. Printed with its result. */ - readonly note: string; -} - -export const BACKING_WORKERS: ReadonlyArray<BackingWorker> = [ - { - name: "identity", - origin: "https://identity.smithers.sh", - alternateOrigins: ["https://smithers-cloud-identity.willcory10.workers.dev"], - path: "/healthz", - contract: "ok-json", - note: "GitHub OAuth, sessions, the allowlist; IDENTITY_UPSTREAM_URL", - }, - { - name: "billing", - origin: "https://billing.smithers.sh", - alternateOrigins: [], - path: "/healthz", - contract: "ok-json", - note: "balances, grants, the admin grant surface; BILLING_UPSTREAM_URL", - }, - { - name: "chat", - origin: "https://chat.smithers.sh", - alternateOrigins: ["https://smithers-cloud-chat-canary.willcory10.workers.dev"], - path: "/", - contract: "responds", - note: "the metered turn upstream (SMITHERS_CHAT_URL); no health route — / answers 404 with the Worker's own \"Not found\", and /chat is origin-gated, so a Worker-authored body is the whole assertion", - }, - { - name: "recommendations", - origin: "https://reco.smithers.sh", - alternateOrigins: ["https://smithers-cloud-reco.willcory10.workers.dev"], - path: "/healthz", - contract: "ok-json", - note: "first-run digest, the ranked recommendation, dismissals; RECO_UPSTREAM_URL", - }, - { - name: "connectors-catalog", - origin: "https://connectors.smithers.sh", - alternateOrigins: [], - path: "/healthz", - contract: "ok-json", - note: "the connector catalog; not called by apps/server today", - }, - { - name: "cron", - origin: "https://cron-schedules.smithers.sh", - alternateOrigins: [], - path: "/", - contract: "responds", - note: "scheduled triggers; no health route and every path is origin-gated (403 without an allowed Origin), so routability is the whole assertion", - }, - { - name: "status", - origin: "https://status.smithers.sh", - alternateOrigins: [], - path: "/healthz", - contract: "ok-json", - note: "the public status site", - }, - { - name: "sync", - origin: "https://sync.smithers.sh", - alternateOrigins: [], - path: "/health", - contract: "ok-json", - note: "durable-object backed sync; note /health, not /healthz — /healthz is a 404 here", - }, - { - name: "webhooks", - origin: "https://webhooks.smithers.sh", - alternateOrigins: [], - path: "/", - contract: "responds", - note: "inbound webhooks; no health route and every path is origin-gated (403 without an allowed Origin), so routability is the whole assertion", - }, -]; - -export const ORIGIN_OVERRIDE_ENV = "CANARY_WORKER_ORIGINS"; - -/** - * Apply $CANARY_WORKER_ORIGINS: a JSON object of { name: origin } naming any - * subset of the manifest. An empty string or null declares the Worker unset on - * this deployment, which the probe reports as not-configured rather than as a - * failure. - * - * Every malformed value throws. A silent fallback to the defaults would probe - * the canary deployment while the operator believed they were probing theirs, - * and report PASS for a stack nobody looked at. - */ -export const withOriginOverrides = ( - workers: ReadonlyArray<BackingWorker>, - raw: string | undefined, -): ReadonlyArray<BackingWorker> => { - const text = raw?.trim(); - if (text === undefined || text === "") return workers; - - let parsed: unknown; - try { - parsed = JSON.parse(text); - } catch (error) { - throw new Error( - `$${ORIGIN_OVERRIDE_ENV} is not a JSON object of { name: origin }: ${error instanceof Error ? error.message : "unparseable"}`, - ); - } - if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { - throw new Error(`$${ORIGIN_OVERRIDE_ENV} is not a JSON object of { name: origin }: got ${JSON.stringify(parsed)}`); - } - - const known = new Set(workers.map((worker) => worker.name)); - const overrides = new Map<string, string | undefined>(); - for (const [name, value] of Object.entries(parsed as Record<string, unknown>)) { - if (!known.has(name)) { - throw new Error( - `$${ORIGIN_OVERRIDE_ENV} names an unknown Worker "${name}". Known Workers: ${[...known].join(", ")}`, - ); - } - if (value === null || value === "") { - overrides.set(name, undefined); - continue; - } - if (typeof value !== "string") { - throw new Error( - `$${ORIGIN_OVERRIDE_ENV}.${name} must be an origin string, "" or null (unset), not ${JSON.stringify(value)}`, - ); - } - let url: URL; - try { - url = new URL(value); - } catch { - throw new Error(`$${ORIGIN_OVERRIDE_ENV}.${name} is not an absolute URL: ${JSON.stringify(value)}`); - } - if (url.protocol !== "http:" && url.protocol !== "https:") { - throw new Error(`$${ORIGIN_OVERRIDE_ENV}.${name} must be http or https, not ${JSON.stringify(value)}`); - } - overrides.set(name, value); - } - - // An override names the ONE origin that deployment uses, so it also drops the - // canary's alternate routes: probing another deployment's identity Worker - // plus this one's workers.dev twin would report on two stacks at once. - return workers.map((worker) => - overrides.has(worker.name) ? { ...worker, origin: overrides.get(worker.name), alternateOrigins: [] } : worker, - ); -}; - -/** The URL this probe will request, or undefined when the Worker is unset. */ -export const healthUrl = (worker: BackingWorker): string | undefined => - worker.origin === undefined ? undefined : new URL(worker.path, worker.origin).toString(); - -/** - * One probe target per route: each Worker, then each alternate route to it. - * An unset Worker expands to itself alone — there is nothing to probe, and - * saying so once is the honest report. - */ -export const expandTargets = (workers: ReadonlyArray<BackingWorker>): ReadonlyArray<BackingWorker> => - workers.flatMap((worker) => - worker.origin === undefined - ? [worker] - : [ - worker, - ...worker.alternateOrigins.map((origin) => ({ - ...worker, - name: `${worker.name} via ${new URL(origin).hostname}`, - origin, - alternateOrigins: [], - })), - ], - ); diff --git a/apps/server/scripts/canary/workflow-wiring.test.ts b/apps/server/scripts/canary/workflow-wiring.test.ts deleted file mode 100644 index 342b2f92..00000000 --- a/apps/server/scripts/canary/workflow-wiring.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -/* - * The wiring gate: every canary probe is invoked by a workflow, and every - * workflow is linted. - * - * The probes in this directory shipped with unit tests and with nothing in - * .github/workflows/ that ran any of them. A probe nobody invokes cannot grade - * a deployment, and its unit tests stay green while it does so. These - * assertions fail the moment a probe is added without a caller, a caller is - * deleted, or a workflow file is added outside the actionlint argument list. - * - * The probe list is derived from the directory, never restated here. A - * hardcoded list is the defect this file exists to prevent: it would keep - * passing after someone adds probe six. - */ -import { describe, expect, it } from "bun:test"; -import { readdirSync, readFileSync } from "node:fs"; -import { fileURLToPath } from "node:url"; - -const canaryDir = fileURLToPath(new URL(".", import.meta.url)); -const workflowsDir = fileURLToPath(new URL("../../../../.github/workflows/", import.meta.url)); - -const readWorkflow = (name: string): string => readFileSync(`${workflowsDir}${name}`, "utf8"); - -const workflowNames = readdirSync(workflowsDir).filter((name) => name.endsWith(".yml")).sort(); - -/* - * An entry point reads process.argv; a library does not. That is the same - * split the files themselves document — BuildStamp.ts, workers-manifest.ts, - * invite-verdict.ts and rollback-verdict.ts hold verdicts, and the *-probe.ts - * shells hold the process. - */ -const entryPoints = readdirSync(canaryDir) - .filter((name) => name.endsWith(".ts") && !name.endsWith(".test.ts")) - .filter((name) => readFileSync(`${canaryDir}${name}`, "utf8").includes("process.argv")) - .sort(); - -describe("canary probes are wired into a gate", () => { - it("finds the probe entry points", () => { - // A guard on the guard: an import rename that empties this list would - // make every assertion below vacuous. - expect(entryPoints.length).toBeGreaterThanOrEqual(5); - expect(entryPoints).toContain("build-probe.ts"); - expect(entryPoints).toContain("workers-health.ts"); - expect(entryPoints).toContain("uptime-probe.ts"); - expect(entryPoints).toContain("invite-probe.ts"); - expect(entryPoints).toContain("rollback-probe.ts"); - }); - - it("invokes every probe entry point from at least one workflow", () => { - const workflows = workflowNames.map((name) => ({ name, text: readWorkflow(name) })); - const unwired = entryPoints.filter( - (probe) => !workflows.some((workflow) => workflow.text.includes(`scripts/canary/${probe}`)), - ); - expect(unwired).toEqual([]); - }); - - it("runs CN-1 against the sha the deploy just published", () => { - // Without an expected sha the probe skips its comparison checks and - // still prints PASS, having verified only that the deployment can state - // what it is. The sha has to reach the probe for the verdict to move. - const deploy = readWorkflow("apps-deploy.yml"); - expect(deploy).toContain("scripts/canary/build-probe.ts"); - expect(deploy).toMatch(/--sha\s/); - expect(deploy).toContain("github.sha"); - // Drift needs both halves: the flag, and a checkout deep enough for - // `git rev-list <sha>..origin/main` to resolve origin/main. - expect(deploy).toMatch(/--max-drift\s+\d/); - expect(deploy).toMatch(/^\s*fetch-depth: 0$/m); - }); - - it("reports every post-deploy probe in one run", () => { - /* - * GitHub's default step condition is "every previous step succeeded", - * so without `!cancelled()` a red CN-1 skips CN-18, CN-23 and CN-24 and - * the operator learns one verdict per production deploy. The step list - * is derived from the file, so a probe step added without the condition - * fails here rather than being silently masked in the next incident. - */ - const steps = readWorkflow("apps-deploy.yml") - .split(/\n(?=\t{0,0} {6}- )/) - .filter((block) => block.includes("scripts/canary/") && block.includes("bun scripts/canary/")); - expect(steps.length).toBeGreaterThanOrEqual(4); - const masked = steps - .filter((block) => !block.includes("!cancelled()")) - .map((block) => (/- name: (.*)/.exec(block) ?? [, block.slice(0, 40)])[1]); - expect(masked).toEqual([]); - }); - - it("lints every workflow file in ci.yml's actionlint step", () => { - const ci = readWorkflow("ci.yml"); - const args = ci.split("\n").find((line) => line.trim().startsWith("args:")); - expect(args).toBeDefined(); - const unlinted = workflowNames.filter((name) => !(args as string).includes(`.github/workflows/${name}`)); - expect(unlinted).toEqual([]); - }); - - it("keeps ci.yml free of step conditions (issue #176)", () => { - // packages/flows/test/vitestCoverageIsolation.test.ts owns this pin. - // It is restated here because the apps workspaces run `bun test` and - // never load that suite, and this file edits ci.yml. - expect(readWorkflow("ci.yml")).not.toMatch(/^\s*if:/m); - }); -}); diff --git a/apps/server/scripts/deploy.ts b/apps/server/scripts/deploy.ts index 29efde14..b40f7aa3 100644 --- a/apps/server/scripts/deploy.ts +++ b/apps/server/scripts/deploy.ts @@ -1,7 +1,7 @@ /** - * Scripted deploy: build the TanStack Start client and Worker (apps/ui), then - * deploy that generated Wrangler bundle, recording a receipt (git sha + - * timestamp + wrangler version id) either way. + * Scripted deploy: `vite build` for the SPA (apps/ui), then `wrangler deploy` + * for the Worker (apps/server), recording a receipt (git sha + timestamp + + * wrangler version id) either way. * * bun scripts/deploy.ts --dry-run * Runs the real vite build, then `wrangler deploy --dry-run` — no @@ -15,10 +15,6 @@ * The Worker identity (name `smithers-mvp-web`, the canary.smithers.sh route) * is frozen — see apps/server/DEPLOY.md and wrangler.jsonc:1-9. This script * never changes wrangler.jsonc; it only builds and deploys what's there. - * - * CN-1: the receipt records the sha the SPA bundle was stamped with, not a sha - * read afterwards, so `scripts/canary/build-probe.ts` can hold the deployment - * to the claim. */ import { mkdirSync, writeFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; @@ -30,11 +26,10 @@ const uiDir = fileURLToPath(new URL("../../ui", import.meta.url)); const run = async ( cmd: ReadonlyArray<string>, - options: { cwd: string; capture?: boolean; env?: Record<string, string> }, + options: { cwd: string; capture?: boolean }, ): Promise<{ exitCode: number; output: string }> => { const proc = Bun.spawn([...cmd], { cwd: options.cwd, - env: { ...process.env, ...(options.env ?? {}) }, stdout: options.capture === true ? "pipe" : "inherit", stderr: "inherit", }); @@ -44,68 +39,28 @@ const run = async ( return { exitCode, output }; }; -/* - * The sha is read BEFORE the build, not after, because the build consumes it: - * apps/ui/vite.config.ts's buildStamp plugin writes SMITHERS_BUILD_SHA into - * the bundle as /__build.json and as a meta tag on the HTML. The receipt below - * records the same value, so the deployment and the receipt can be compared - * byte for byte — that comparison is CN-1, and - * scripts/canary/build-probe.ts runs it. - * - * A dirty tree is recorded rather than hidden. The sha alone would claim the - * artifact is that commit, which is not true when uncommitted work went into - * the build. - */ -const gitSha = (await run(["git", "rev-parse", "HEAD"], { cwd: serverDir, capture: true })).output.trim(); -const gitDirty = - (await run(["git", "status", "--porcelain"], { cwd: serverDir, capture: true })).output.trim() !== ""; - -console.log(`[deploy] building TanStack Start in ${uiDir}, stamped ${gitSha}${gitDirty ? " (dirty tree)" : ""}...`); -const build = await run(["bun", "run", "build"], { cwd: uiDir, env: { SMITHERS_BUILD_SHA: gitSha } }); +console.log(`[deploy] building the SPA (vite build) in ${uiDir}...`); +const build = await run(["bun", "run", "build"], { cwd: uiDir }); if (build.exitCode !== 0) { console.error("[deploy] vite build failed."); process.exit(build.exitCode); } -console.log(`[deploy] ${dryRun ? "dry-run " : ""}wrangler deploy of the Start/Worker build...`); -const startConfig = `${uiDir}/dist/server/wrangler.json`; -const deployArgs = [ - "bun", - "x", - "wrangler@4.124.0", - "deploy", - "--config", - startConfig, - ...(dryRun ? ["--dry-run"] : []), -]; +console.log(`[deploy] ${dryRun ? "dry-run " : ""}wrangler deploy in ${serverDir}...`); +const deployArgs = ["bun", "x", "wrangler@4.123.0", "deploy", ...(dryRun ? ["--dry-run"] : [])]; const deploy = await run(deployArgs, { cwd: serverDir, capture: true }); if (deploy.exitCode !== 0) { console.error("[deploy] wrangler deploy failed."); process.exit(deploy.exitCode); } +const gitSha = await run(["git", "rev-parse", "HEAD"], { cwd: serverDir, capture: true }); const versionIdMatch = /Current Version ID:\s*([0-9a-f-]{36})/i.exec(deploy.output); -/* - * A real deploy that exits 0 without printing a version id leaves CN-24 a - * receipt it cannot use: rollback-probe.ts needs the id to assert that the - * previous version is reachable and that the deployment matches the receipt. - * Writing `null` and carrying on would hand the operator a rollback plan that - * silently verifies nothing. Fail here instead, while the deploy output is - * still on screen. A dry run legitimately prints no id, so it is exempt. - */ -if (!dryRun && versionIdMatch === null) { - console.error("[deploy] wrangler deployed but printed no 'Current Version ID'."); - console.error("[deploy] The receipt would carry wranglerVersionId: null, which CN-24 cannot verify."); - console.error("[deploy] Check the wrangler output above, then re-run, or record the id by hand."); - process.exit(1); -} - const receipt = { worker: "smithers-mvp-web", dryRun, - gitSha, - gitDirty, + gitSha: gitSha.output.trim(), timestamp: new Date().toISOString(), wranglerVersionId: versionIdMatch?.[1] ?? null, }; @@ -117,9 +72,3 @@ writeFileSync(receiptPath, `${JSON.stringify(receipt, null, "\t")}\n`); writeFileSync(`${receiptDir}/latest.json`, `${JSON.stringify(receipt, null, "\t")}\n`); console.log(`[deploy] receipt written to ${receiptPath}`); -if (!dryRun) { - console.log( - `[deploy] verify the deployment serves what this receipt claims:\n` + - ` bun scripts/canary/build-probe.ts https://canary.smithers.sh --sha ${gitSha}`, - ); -} diff --git a/apps/server/src/ModelStream.test.ts b/apps/server/src/ModelStream.test.ts index 22cc258c..5e713655 100644 --- a/apps/server/src/ModelStream.test.ts +++ b/apps/server/src/ModelStream.test.ts @@ -5,160 +5,107 @@ import type { WorkerEnv } from "./index"; /* * The relay boundary, driven through the Worker's real fetch handler with the - * upstream fetch patched — no network. The contract under test: the relay - * forwards to the SAME managed-inference upstream the turn path uses (which is - * what makes it metered), it mints the run id itself, it refuses anonymous and - * non-allowlisted callers BEFORE any upstream call, and the sealed-step law - * rejects tool-bearing bodies. + * upstream fetch patched — no network. The contract under test: session-free + * dev mode passes through, the provider key is injected and the client's + * placeholder never forwarded, the sealed-step law rejects tool-bearing + * bodies, and an unconfigured relay answers 501 instead of forwarding a + * request that can only come back 401. */ const env = (overrides: Partial<WorkerEnv> = {}): WorkerEnv => ({ ASSETS: { fetch: async () => new Response("not-found", { status: 404 }) }, - SMITHERS_CHAT_URL: "https://upstream.test/chat", ...overrides, }) as WorkerEnv; -/** The deployed shape: an identity seam is set, so the route is gated. */ -const gatedEnv = (overrides: Partial<WorkerEnv> = {}): WorkerEnv => - env({ IDENTITY_UPSTREAM_URL: "https://identity.test", ...overrides }); - -const relayBody = { instructions: "You are Smithers.", messages: [{ role: "user", content: "hi" }] }; - -const relayRequest = (body: unknown = relayBody, headers: Record<string, string> = {}): Request => +const relayRequest = (body: unknown, headers: Record<string, string> = {}): Request => new Request(`https://app.test${MODEL_STREAM_PATH}`, { method: "POST", - headers: { "content-type": "application/json", ...headers }, + headers: { + "content-type": "application/json", + "anthropic-version": "2023-06-01", + "x-api-key": "browser-relay-placeholder", + ...headers, + }, body: JSON.stringify(body), }); -const ndjson = (frames: ReadonlyArray<Record<string, unknown>>): Response => - new Response(`${frames.map((frame) => JSON.stringify(frame)).join("\n")}\n`, { - status: 200, - headers: { "content-type": "application/x-ndjson" }, - }); - const realFetch = globalThis.fetch; afterEach(() => { globalThis.fetch = realFetch; }); -/** Patches fetch, routing identity validation separately from the model upstream. */ -const withFetch = ( - handler: (request: Request) => Response | Promise<Response>, -): Array<Request> => { - const captured: Array<Request> = []; - globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { - const request = new Request(input as Request | string, init); - captured.push(request); - return handler(request); - }) as typeof fetch; - return captured; -}; - -const identityAnswer = (login: string, allowlisted: boolean): Response => - new Response(JSON.stringify({ login, allowlisted }), { - status: 200, - headers: { "content-type": "application/json" }, - }); - describe("the model relay route", () => { - test("forwards the sealed call to the managed-inference upstream and streams its frames back", async () => { - const captured = withFetch(() => ndjson([{ type: "delta", kind: "text", text: "ok" }, { type: "done" }])); + test("injects the provider key and streams the provider body back verbatim", async () => { + const captured: Array<Request> = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + captured.push(new Request(input as Request | string, init)); + return new Response("event: message_stop\ndata: {}\n\n", { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); + }) as typeof fetch; - const response = await worker.fetch(relayRequest(), env()); + const response = await worker.fetch( + relayRequest({ model: "claude-sonnet-5", stream: true, messages: [] }), + env({ MODEL_RELAY_API_KEY: "sk-real-provider-key" }), + ); expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe("application/x-ndjson"); - expect(await response.text()).toContain('"type":"done"'); + expect(response.headers.get("content-type")).toBe("text/event-stream"); + expect(await response.text()).toContain("message_stop"); expect(captured).toHaveLength(1); const sent = captured[0]!; - // The SAME upstream /api/agent/turn calls: the one that owns the provider - // key, authorizes the balance, and meters the usage durably. - expect(sent.url).toBe("https://upstream.test/chat"); - expect(await sent.json()).toEqual(relayBody); - }); - - test("mints its own run id — a caller can never choose the charge's idempotency key", async () => { - const captured = withFetch(() => ndjson([{ type: "done" }])); - await worker.fetch(relayRequest(relayBody, { "x-smithers-run-id": "attacker-chosen" }), env()); - const runId = captured[0]!.headers.get("x-smithers-run-id"); - expect(runId).not.toBe("attacker-chosen"); - expect(runId).toMatch(/^[0-9a-f-]{36}$/); + expect(sent.url).toBe("https://api.anthropic.com/v1/messages"); + expect(sent.headers.get("x-api-key")).toBe("sk-real-provider-key"); + expect(sent.headers.get("anthropic-version")).toBe("2023-06-01"); + // The browser's placeholder credential dies at the boundary. + expect(sent.headers.get("x-api-key")).not.toBe("browser-relay-placeholder"); }); - test("vouches a validated login so the charge lands on the user's own account", async () => { - const captured = withFetch((request) => - new URL(request.url).hostname === "identity.test" - ? identityAnswer("will", true) - : ndjson([{ type: "done" }]), + test("honors MODEL_RELAY_URL as the upstream override", async () => { + const urls: Array<string> = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + urls.push(new Request(input as Request | string, init).url); + return new Response("ok", { status: 200, headers: { "content-type": "text/event-stream" } }); + }) as typeof fetch; + await worker.fetch( + relayRequest({ model: "claude-sonnet-5" }), + env({ MODEL_RELAY_API_KEY: "k", MODEL_RELAY_URL: "https://relay.test/v1/messages" }), ); - const response = await worker.fetch( - relayRequest(relayBody, { cookie: "smithers_session=abc" }), - gatedEnv({ CHAT_PRODUCT_SERVICE_TOKEN: "product-token", SMITHERS_CHAT_AUTH_TOKEN: "bearer-token" }), - ); - expect(response.status).toBe(200); - const upstream = captured.find((request) => new URL(request.url).hostname === "upstream.test")!; - expect(upstream.headers.get("x-smithers-service-token")).toBe("product-token"); - expect(upstream.headers.get("x-user-login")).toBe("will"); - expect(upstream.headers.get("authorization")).toBe("Bearer bearer-token"); - }); - - test("refuses an anonymous call with 401 before any credential is spent", async () => { - let upstreamCalls = 0; - withFetch((request) => { - if (new URL(request.url).hostname === "identity.test") return new Response("{}", { status: 401 }); - upstreamCalls += 1; - return ndjson([{ type: "done" }]); - }); - const response = await worker.fetch(relayRequest(), gatedEnv()); - expect(response.status).toBe(401); - expect(upstreamCalls).toBe(0); - }); - - test("refuses a signed-in but non-allowlisted account with 403 before any credential is spent", async () => { - let upstreamCalls = 0; - withFetch((request) => { - if (new URL(request.url).hostname === "identity.test") return identityAnswer("stranger", false); - upstreamCalls += 1; - return ndjson([{ type: "done" }]); - }); - const response = await worker.fetch(relayRequest(relayBody, { cookie: "smithers_session=abc" }), gatedEnv()); - expect(response.status).toBe(403); - expect(upstreamCalls).toBe(0); + expect(urls).toEqual(["https://relay.test/v1/messages"]); }); test("rejects a tool-bearing body — the relay serves sealed author calls only", async () => { - let upstreamCalls = 0; - withFetch(() => { - upstreamCalls += 1; - return ndjson([{ type: "done" }]); - }); const response = await worker.fetch( - relayRequest({ ...relayBody, tools: [{ type: "function", name: "bash" }] }), - env(), + relayRequest({ model: "claude-sonnet-5", tools: [{ name: "bash" }] }), + env({ MODEL_RELAY_API_KEY: "k" }), ); expect(response.status).toBe(400); expect(await response.text()).toContain("sealed author calls only"); - expect(upstreamCalls).toBe(0); }); - test("rejects a body with no messages", async () => { - const response = await worker.fetch(relayRequest({ messages: [] }), env()); - expect(response.status).toBe(400); - expect(await response.text()).toContain("messages"); + test("answers 501 when the relay key is not configured", async () => { + const response = await worker.fetch(relayRequest({ model: "claude-sonnet-5" }), env()); + expect(response.status).toBe(501); + expect(await response.text()).toContain("MODEL_RELAY_API_KEY"); }); test("surfaces an upstream failure with its status and detail", async () => { - withFetch(() => new Response(JSON.stringify({ error: "overloaded" }), { status: 529 })); - const response = await worker.fetch(relayRequest(), env()); + globalThis.fetch = (async (_input: RequestInfo | URL, _init?: RequestInit) => + new Response(JSON.stringify({ error: { message: "overloaded" } }), { status: 529 })) as typeof fetch; + const response = await worker.fetch( + relayRequest({ model: "claude-sonnet-5" }), + env({ MODEL_RELAY_API_KEY: "k" }), + ); expect(response.status).toBe(529); + expect(await response.text()).toContain("overloaded"); }); test("only POST is allowed", async () => { const response = await worker.fetch( new Request(`https://app.test${MODEL_STREAM_PATH}`, { method: "GET" }), - env(), + env({ MODEL_RELAY_API_KEY: "k" }), ); expect(response.status).toBe(405); }); diff --git a/apps/server/src/clientErrorLog.test.ts b/apps/server/src/clientErrorLog.test.ts deleted file mode 100644 index e9491d06..00000000 --- a/apps/server/src/clientErrorLog.test.ts +++ /dev/null @@ -1,315 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import worker from "./index"; -import type { WorkerEnv } from "./index"; -import { - appendClientError, - bounded, - capRecord, - CLIENT_ERROR_LOG_LIMIT, - CLIENT_ERROR_LOG_MAX_BYTES, - CLIENT_ERROR_RECORD_MAX_BYTES, - ClientErrorLog, - readClientErrors, -} from "./clientErrorLog"; -import type { ClientErrorNamespace, ClientErrorRecord, ClientErrorStorage } from "./clientErrorLog"; - -/* - * What broke in a user's browser has to survive longer than a `wrangler tail`. - * These tests hold the log to being readable afterwards, bounded, and never - * able to fail the report it is recording. - */ - -const memoryStorage = (): ClientErrorStorage => { - const data = new Map<string, unknown>(); - return { - get: async (key) => data.get(key) as never, - put: async (key, value) => void data.set(key, value), - }; -}; - -const memoryLog = (): ClientErrorNamespace & { readonly names: () => Array<string> } => { - const logs = new Map<string, ClientErrorLog>(); - return { - names: () => [...logs.keys()], - idFromName: (name) => name, - get: (id) => { - const name = String(id); - let log = logs.get(name); - if (log === undefined) { - log = new ClientErrorLog({ storage: memoryStorage() }); - logs.set(name, log); - } - return { fetch: (request) => log.fetch(request) }; - }, - }; -}; - -const adminEnv = (logs?: ClientErrorNamespace): WorkerEnv => ({ - ASSETS: { fetch: async () => new Response("<html></html>", { status: 200 }) }, - IDENTITY_UPSTREAM_URL: "https://identity.test", - ...(logs === undefined ? {} : { CLIENT_ERRORS: logs }), -}); - -const withIdentity = async ( - session: { readonly login: string; readonly admin: boolean } | undefined, - run: () => Promise<void>, -): Promise<void> => { - const original = globalThis.fetch; - globalThis.fetch = (async (input: unknown, init?: RequestInit) => { - const request = typeof input === "string" ? new Request(input, init) : (input as Request); - if (new URL(request.url).hostname === "identity.test") { - return session === undefined - ? new Response("{}", { status: 401 }) - : new Response(JSON.stringify({ ...session, allowlisted: true }), { - status: 200, - headers: { "content-type": "application/json" }, - }); - } - return new Response("{}", { status: 200 }); - }) as typeof fetch; - try { - await run(); - } finally { - globalThis.fetch = original; - } -}; - -const report = (path: string, body: unknown, headers: Record<string, string> = {}): Request => - new Request(`https://mvp.test${path}`, { - method: "POST", - headers: { "content-type": "application/json", ...headers }, - body: JSON.stringify(body), - }); - -describe("the client-error log (Durable Object state)", () => { - test("keeps reports newest first", async () => { - const logs = memoryLog(); - await appendClientError(logs, { at: "2026-08-18T00:00:00.000Z", report: { message: "first" } }); - await appendClientError(logs, { at: "2026-08-18T00:00:01.000Z", report: { message: "second" } }); - const read = await readClientErrors(logs); - expect(read.total).toBe(2); - expect(read.reports.map((row) => (row.report as { message: string }).message)).toEqual(["second", "first"]); - }); - - test("is bounded: an error storm evicts the oldest, never the newest", async () => { - const logs = memoryLog(); - for (let index = 0; index < CLIENT_ERROR_LOG_LIMIT + 25; index += 1) { - await appendClientError(logs, { at: new Date(index).toISOString(), report: { index } }); - } - const read = await readClientErrors(logs); - expect(read.total).toBe(CLIENT_ERROR_LOG_LIMIT); - expect((read.reports[0]?.report as { index: number }).index).toBe(CLIENT_ERROR_LOG_LIMIT + 24); - }); - - test("a limit trims the read and never exceeds what is kept", async () => { - const logs = memoryLog(); - for (let index = 0; index < 10; index += 1) { - await appendClientError(logs, { at: new Date(index).toISOString(), report: { index } }); - } - expect((await readClientErrors(logs, 3)).reports).toHaveLength(3); - expect((await readClientErrors(logs, 10_000)).reports).toHaveLength(10); - }); - - test("with no namespace bound, appending is a no-op and the read is honestly empty", async () => { - await appendClientError(undefined, { at: "2026-08-18T00:00:00.000Z", report: {} }); - expect(await readClientErrors(undefined)).toEqual({ total: 0, reports: [] }); - }); - - test("a failing log never fails the report", async () => { - const broken: ClientErrorNamespace = { - idFromName: (name) => name, - get: () => ({ - fetch: async () => { - throw new Error("durable object unavailable"); - }, - }), - }; - await appendClientError(broken, { at: "2026-08-18T00:00:00.000Z", report: {} }); - }); -}); - -describe("the client-error route and its admin read", () => { - test("a posted error is stored with when it arrived, the page, and the agent", async () => { - const logs = memoryLog(); - const env = adminEnv(logs); - const response = await worker.fetch( - report( - "/api/client-errors", - { message: "Cannot read properties of undefined", stack: "at App" }, - { referer: "https://canary.smithers.sh/", "user-agent": "TestBrowser/1.0" }, - ), - env, - ); - expect(response.status).toBe(202); - const stored = await readClientErrors(logs); - expect(stored.total).toBe(1); - expect(stored.reports[0]?.page).toBe("https://canary.smithers.sh/"); - expect(stored.reports[0]?.userAgent).toBe("TestBrowser/1.0"); - expect((stored.reports[0]?.report as { message: string }).message).toBe( - "Cannot read properties of undefined", - ); - expect(Date.parse(stored.reports[0]?.at ?? "")).toBeGreaterThan(0); - }); - - test("a report that is not JSON is kept verbatim rather than dropped", async () => { - const logs = memoryLog(); - const response = await worker.fetch( - new Request("https://mvp.test/api/client-errors", { method: "POST", body: "boom, not json" }), - adminEnv(logs), - ); - expect(response.status).toBe(202); - expect((await readClientErrors(logs)).reports[0]?.report).toBe("boom, not json"); - }); - - test("every deployment writes to one log, so any request finds every report", async () => { - const logs = memoryLog(); - await worker.fetch(report("/api/client-errors", { message: "a" }), adminEnv(logs)); - await worker.fetch(report("/api/client-errors", { message: "b" }), adminEnv(logs)); - expect(logs.names()).toEqual(["client-errors"]); - }); - - test("the admin read answers the log, newest first", async () => { - const logs = memoryLog(); - const env = adminEnv(logs); - await withIdentity({ login: "will", admin: true }, async () => { - await worker.fetch(report("/api/client-errors", { message: "older" }), env); - await worker.fetch(report("/api/client-errors", { message: "newer" }), env); - const response = await worker.fetch( - new Request("https://mvp.test/api/admin/errors", { headers: { cookie: "smithers_session=abc" } }), - env, - ); - expect(response.status).toBe(200); - const body = (await response.json()) as { - total: number; - reports: Array<{ report: { message: string } }>; - }; - expect(body.total).toBe(2); - expect(body.reports.map((row) => row.report.message)).toEqual(["newer", "older"]); - }); - }); - - test("a non-admin gets the canonical unknown-route 404, never a 403", async () => { - const env = adminEnv(memoryLog()); - await withIdentity({ login: "someone", admin: false }, async () => { - const response = await worker.fetch( - new Request("https://mvp.test/api/admin/errors", { headers: { cookie: "smithers_session=abc" } }), - env, - ); - expect(response.status).toBe(404); - const unknown = await worker.fetch( - new Request("https://mvp.test/api/definitely-not-a-route", { - headers: { cookie: "smithers_session=abc" }, - }), - env, - ); - expect(await response.text()).toBe(await unknown.text()); - }); - }); - - test("an anonymous read is the same 404", async () => { - const env = adminEnv(memoryLog()); - await withIdentity(undefined, async () => { - const response = await worker.fetch(new Request("https://mvp.test/api/admin/errors"), env); - expect(response.status).toBe(404); - }); - }); - - test("with no log bound the admin read says so instead of implying nothing broke", async () => { - const env = adminEnv(); - await withIdentity({ login: "will", admin: true }, async () => { - const response = await worker.fetch( - new Request("https://mvp.test/api/admin/errors", { headers: { cookie: "smithers_session=abc" } }), - env, - ); - const body = (await response.json()) as { total: number; note?: string }; - expect(body.total).toBe(0); - expect(body.note).toContain("nothing is stored"); - }); - }); -}); - -/* - * The log lives under one storage key with a 128 KiB ceiling, and the route - * accepts reports of up to 16 KiB. A count-only bound would let the value grow - * past the limit, the put would throw, and — since appending must never fail - * the report — the throw would be swallowed and the log would quietly stop - * recording. These hold the byte bound that prevents exactly that. - */ -describe("the log stays inside one storage value", () => { - const bigReport = (chars: number, at: string): ClientErrorRecord => ({ - at, - report: { message: "x".repeat(chars) }, - }); - - test("a single oversized report is truncated, and says so", () => { - const capped = capRecord(bigReport(20_000, "2026-08-18T00:00:00.000Z")); - expect(JSON.stringify(capped).length).toBeLessThanOrEqual(CLIENT_ERROR_RECORD_MAX_BYTES); - expect(String(capped.report)).toContain("truncated from"); - // The head of the report survives — what broke is usually in the first lines. - expect(String(capped.report)).toContain("xxxxx"); - expect(capped.at).toBe("2026-08-18T00:00:00.000Z"); - }); - - test("a small report is left exactly as it was", () => { - const small: ClientErrorRecord = { at: "2026-08-18T00:00:00.000Z", report: { message: "boom" } }; - expect(capRecord(small)).toEqual(small); - }); - - test("the log never exceeds its byte budget, whatever it is fed", async () => { - const logs = memoryLog(); - for (let index = 0; index < CLIENT_ERROR_LOG_LIMIT + 20; index += 1) { - await appendClientError(logs, bigReport(16_000, new Date(index).toISOString())); - } - const read = await readClientErrors(logs); - expect(JSON.stringify(read.reports).length).toBeLessThanOrEqual(CLIENT_ERROR_LOG_MAX_BYTES); - // Still a useful log, not one record. - expect(read.reports.length).toBeGreaterThan(10); - // And the newest survived: eviction takes from the old end. - expect(read.reports[0]?.at).toBe(new Date(CLIENT_ERROR_LOG_LIMIT + 19).toISOString()); - }); - - test("both bounds hold together: small reports are capped by count, large ones by bytes", () => { - const small = Array.from({ length: 400 }, (_, index) => ({ - at: new Date(index).toISOString(), - report: { i: index }, - })); - expect(bounded(small)).toHaveLength(CLIENT_ERROR_LOG_LIMIT); - const large = Array.from({ length: 400 }, (_, index) => capRecord(bigReport(16_000, new Date(index).toISOString()))); - const boundedLarge = bounded(large); - expect(boundedLarge.length).toBeLessThan(CLIENT_ERROR_LOG_LIMIT); - expect(JSON.stringify(boundedLarge).length).toBeLessThanOrEqual(CLIENT_ERROR_LOG_MAX_BYTES); - }); - - test("one report that alone exceeds the budget is still kept, not dropped into silence", () => { - const huge: ClientErrorRecord = { at: "2026-08-18T00:00:00.000Z", report: "y".repeat(200_000) }; - expect(bounded([huge])).toHaveLength(1); - }); -}); - -/* - * The store's limit is in bytes and JSON.stringify leaves non-ASCII literal, - * so counting characters would under-measure exactly the reports written by - * the users hardest to support. - */ -describe("the byte bound counts bytes, not characters", () => { - test("a report in a non-ASCII language is measured at its real size", async () => { - const logs = memoryLog(); - // Three bytes per character in UTF-8: 20k characters is ~60 KB. - for (let index = 0; index < 20; index += 1) { - await appendClientError(logs, { - at: new Date(index).toISOString(), - report: { message: "文".repeat(20_000) }, - }); - } - const read = await readClientErrors(logs); - const bytes = new TextEncoder().encode(JSON.stringify(read.reports)).length; - expect(bytes).toBeLessThanOrEqual(CLIENT_ERROR_LOG_MAX_BYTES); - }); - - test("a single non-ASCII report is truncated to its byte budget", () => { - const capped = capRecord({ at: "2026-08-18T00:00:00.000Z", report: "文".repeat(20_000) }); - expect(new TextEncoder().encode(JSON.stringify(capped)).length).toBeLessThanOrEqual( - CLIENT_ERROR_RECORD_MAX_BYTES, - ); - }); -}); diff --git a/apps/server/src/clientErrorLog.ts b/apps/server/src/clientErrorLog.ts deleted file mode 100644 index 1678f987..00000000 --- a/apps/server/src/clientErrorLog.ts +++ /dev/null @@ -1,189 +0,0 @@ -/** - * A readable record of what broke in a user's browser. - * - * The client already posts its errors to `/api/client-errors`. Until now the - * handler ran `console.error` and stopped, which means the report survived only - * as long as someone happened to be running `wrangler tail`. During a private - * alpha that is the same as having no report at all: the first anyone learns of - * a broken flow is the user mentioning it, if they bother. - * - * So the last reports are kept in one Durable Object — a ring buffer, newest - * first — and read back through `GET /api/admin/errors`, behind the same admin - * validation as every other admin route. Deliberately not a log service: no new - * vendor, no new secret, no egress, and it is bounded, so it cannot grow into a - * cost of its own. - * - * What is stored is what the page sent plus when it arrived, the URL it came - * from, and the user agent. No session lookup: identifying the reporter would - * mean an identity round-trip on a route that must stay cheap enough to absorb - * an error storm, and the report itself is what needs reading. - */ - -/** Reports kept. At the route's own ceiling of 120/minute this is a couple of minutes of a storm. */ -export const CLIENT_ERROR_LOG_LIMIT = 200; - -/** - * The whole log lives under one Durable Object storage key, and a stored value - * may not exceed 128 KiB. The route accepts a report of up to 16 KiB, so a - * count alone is not a bound: two hundred large ones would be megabytes, the - * `put` would throw, and — because appending must never fail the report — the - * throw would be swallowed and the log would silently stop recording. Which is - * the exact failure this module exists to end. - * - * So the real constraint is bytes. The budget is set well under the limit to - * leave room for the key and the store's own framing. - */ -export const CLIENT_ERROR_LOG_MAX_BYTES = 96 * 1024; - -/** - * The most one report may occupy. A stack trace is worth keeping and a 16 KiB - * blob is not worth evicting fifty other reports for, so an oversized one is - * truncated rather than dropped: what broke is usually in the first lines. - */ -export const CLIENT_ERROR_RECORD_MAX_BYTES = 4 * 1024; - -export interface ClientErrorStorage { - readonly get: <T>(key: string) => Promise<T | undefined>; - readonly put: (key: string, value: unknown) => Promise<void>; -} - -export interface ClientErrorStub { - readonly fetch: (request: Request) => Promise<Response>; -} - -export interface ClientErrorNamespace { - readonly idFromName: (name: string) => unknown; - readonly get: (id: unknown) => ClientErrorStub; -} - -export interface ClientErrorRecord { - /** When the Worker received it, ISO 8601. */ - readonly at: string; - /** The page that reported, when the request carried a referer. */ - readonly page?: string; - readonly userAgent?: string; - /** Exactly what the client posted, parsed when it was JSON and raw text when it was not. */ - readonly report: unknown; -} - -const LOG_KEY = "reports"; - -/* - * Real UTF-8 bytes, not JSON characters. The store measures bytes and - * JSON.stringify leaves non-ASCII literal, so a message in a language that - * is not English costs up to three bytes a character — counting characters - * would under-measure exactly the reports written by the users hardest to - * support. - */ -const encoder = new TextEncoder(); -const sizeOf = (value: unknown): number => encoder.encode(JSON.stringify(value) ?? "").length; - -/** One report, cut to its byte budget. The truncation is stated, never silent. */ -export const capRecord = (record: ClientErrorRecord): ClientErrorRecord => { - if (sizeOf(record) <= CLIENT_ERROR_RECORD_MAX_BYTES) return record; - const text = typeof record.report === "string" ? record.report : (JSON.stringify(record.report) ?? ""); - const withHead = (head: string): ClientErrorRecord => ({ - ...record, - report: `${head}… [truncated from ${text.length} characters]` - }); - /* - * String.slice counts characters and the budget counts bytes, so a first - * guess in characters overshoots by up to 3x on non-ASCII text. Shrink - * geometrically until it actually fits — a handful of iterations, and - * correct for any alphabet rather than for English only. - */ - let head = text.slice(0, CLIENT_ERROR_RECORD_MAX_BYTES); - while (head.length > 0 && sizeOf(withHead(head)) > CLIENT_ERROR_RECORD_MAX_BYTES) { - head = head.slice(0, Math.floor(head.length * 0.75)); - } - return withHead(head); -}; - -/** - * The newest reports that fit, both bounds enforced: count and bytes. - * - * Each record is measured once and the budget accumulated, rather than - * re-serializing the whole log per eviction — during a storm this runs on - * every append. - */ -export const bounded = (records: ReadonlyArray<ClientErrorRecord>): Array<ClientErrorRecord> => { - const kept: Array<ClientErrorRecord> = []; - // Two bytes of array framing per record ("[", "]", and the commas between). - let used = 2; - for (const record of records.slice(0, CLIENT_ERROR_LOG_LIMIT)) { - const cost = sizeOf(record) + 1; - // The newest report is kept whatever it costs: a log that answers - // nothing because one report was too big has failed at its only job. - if (kept.length > 0 && used + cost > CLIENT_ERROR_LOG_MAX_BYTES) break; - kept.push(record); - used += cost; - } - return kept; -}; - -/** Every deployment shares one log; the name is fixed so any request finds it. */ -export const CLIENT_ERROR_LOG_NAME = "client-errors"; - -export class ClientErrorLog { - constructor(private readonly ctx: { readonly storage: ClientErrorStorage }) {} - - async fetch(request: Request): Promise<Response> { - const url = new URL(request.url); - const stored = (await this.ctx.storage.get<ReadonlyArray<ClientErrorRecord>>(LOG_KEY)) ?? []; - switch (url.pathname) { - case "/append": { - const record = (await request.json().catch(() => undefined)) as ClientErrorRecord | undefined; - if (record === undefined) return new Response("bad record", { status: 400 }); - // Newest first, oldest evicted: a storm never buries the report - // that is being read right now. - const next = bounded([capRecord(record), ...stored]); - await this.ctx.storage.put(LOG_KEY, next); - return new Response(JSON.stringify({ status: "ok", kept: next.length }), { - headers: { "content-type": "application/json" }, - }); - } - case "/read": { - const asked = Number(url.searchParams.get("limit") ?? CLIENT_ERROR_LOG_LIMIT); - const limit = Number.isInteger(asked) && asked > 0 ? Math.min(asked, CLIENT_ERROR_LOG_LIMIT) : CLIENT_ERROR_LOG_LIMIT; - return new Response( - JSON.stringify({ status: "ok", total: stored.length, reports: stored.slice(0, limit) }), - { headers: { "content-type": "application/json" } }, - ); - } - default: - return new Response("not found", { status: 404 }); - } - } -} - -/** - * Record one report. Never throws and never blocks the answer to the client: - * a browser that just hit an error is not helped by the report failing too. - * With no namespace bound (local dev, the stub stack) this is a no-op and the - * handler's `console.error` remains the only trace, as it always was. - */ -export const appendClientError = async ( - logs: ClientErrorNamespace | undefined, - record: ClientErrorRecord, -): Promise<void> => { - if (logs === undefined) return; - const stub = logs.get(logs.idFromName(CLIENT_ERROR_LOG_NAME)); - await stub - .fetch(new Request("https://client-errors.internal/append", { method: "POST", body: JSON.stringify(record) })) - .catch(() => undefined); -}; - -/** The stored reports, newest first. */ -export const readClientErrors = async ( - logs: ClientErrorNamespace | undefined, - limit?: number, -): Promise<{ readonly total: number; readonly reports: ReadonlyArray<ClientErrorRecord> }> => { - if (logs === undefined) return { total: 0, reports: [] }; - const stub = logs.get(logs.idFromName(CLIENT_ERROR_LOG_NAME)); - const query = limit === undefined ? "" : `?limit=${limit}`; - const response = await stub.fetch(new Request(`https://client-errors.internal/read${query}`)); - const body = (await response.json().catch(() => undefined)) as - | { readonly total: number; readonly reports: ReadonlyArray<ClientErrorRecord> } - | undefined; - return body ?? { total: 0, reports: [] }; -}; diff --git a/apps/server/src/gateway.test.ts b/apps/server/src/gateway.test.ts index 154101d6..b48f8dd4 100644 --- a/apps/server/src/gateway.test.ts +++ b/apps/server/src/gateway.test.ts @@ -219,75 +219,6 @@ describe("wave 11 — provision-or-resume (§5)", () => { ); }); - /* - * Repro apps/ui/canary-repros/honesty/22.6: Smithers Cloud accepted the - * provision POST and never answered, so the route hung past 70s and the - * product left "Preparing your <repo> workspace…" standing with no run - * card, no timeout and no error. A deadline turns silence into one of the - * seam's own honest states — the request always ANSWERS. - */ - test("a provision upstream that never answers becomes an honest state, not a hang", async () => { - const originalFetch = globalThis.fetch; - globalThis.fetch = (async (input: unknown, init?: RequestInit) => { - const url = new URL(typeof input === "string" ? input : (input as Request).url); - if (url.pathname === "/api/identity/cloud-token") { - return json(200, { found: true, token: CLOUD_TOKEN }); - } - // The exact canary shape: the connection is accepted and nothing - // ever comes back. Only the seam's own deadline ends this. - return await new Promise<Response>((_resolve, reject) => { - init?.signal?.addEventListener("abort", () => reject(init.signal?.reason ?? new Error("aborted"))); - }); - }) as typeof fetch; - try { - const started = Date.now(); - const outcome = await ensureGateway( - env({ UPSTREAM_TIMEOUT_MS: "150" }), - "codeplanesmithers", - "codeplanesmithers/canary-sandbox", - ); - expect(Date.now() - started).toBeLessThan(5_000); - expect(outcome.status).toBe("provisioning"); - if (outcome.status === "provisioning") { - expect(outcome.detail).toContain("codeplanesmithers/canary-sandbox"); - expect(outcome.detail).toContain("longer than"); - } - } finally { - globalThis.fetch = originalFetch; - } - }); - - test("the provision ROUTE answers a state a client can act on when Cloud stays silent", async () => { - const originalFetch = globalThis.fetch; - globalThis.fetch = (async (input: unknown, init?: RequestInit) => { - const url = new URL(typeof input === "string" ? input : (input as Request).url); - if (url.pathname === "/api/identity/validate") { - return json(200, { login: "codeplanesmithers", allowlisted: true, admin: false }); - } - if (url.pathname === "/api/identity/cloud-token") { - return json(200, { found: true, token: CLOUD_TOKEN }); - } - return await new Promise<Response>((_resolve, reject) => { - init?.signal?.addEventListener("abort", () => reject(init.signal?.reason ?? new Error("aborted"))); - }); - }) as typeof fetch; - try { - const response = await worker.fetch( - signedIn("/api/workflow/provision", { - method: "POST", - body: JSON.stringify({ repo: "codeplanesmithers/canary-sandbox" }), - }), - env({ UPSTREAM_TIMEOUT_MS: "150" }), - ); - expect(response.status).toBe(200); - const body = (await response.json()) as { status: string; message: string }; - expect(body.status).toBe("provisioning"); - expect(body.message).toContain("codeplanesmithers/canary-sandbox"); - } finally { - globalThis.fetch = originalFetch; - } - }); - test("500 no_capacity is surfaced honestly and never retried", async () => { await withRelay( { provision: () => json(500, { error: "no_capacity", message: "no worker has capacity" }) }, diff --git a/apps/server/src/gateway.ts b/apps/server/src/gateway.ts index 930a7b94..eed77b8a 100644 --- a/apps/server/src/gateway.ts +++ b/apps/server/src/gateway.ts @@ -20,43 +20,6 @@ * holds the token and sets the Authorization header the relay requires. */ -/** - * Every call out of this seam is bounded. Smithers Cloud accepts the provision - * POST and can then take an unbounded time to build a sandbox: on canary the - * route never answered at all, so `POST /api/workflow/provision` hung past 70s - * and the product left "Preparing your <repo> workspace…" standing with no - * timeout, no run card and no error (repro - * apps/ui/canary-repros/honesty/22.6). A deadline turns that into one of the - * seam's own honest states. - */ -export const GATEWAY_UPSTREAM_TIMEOUT_MS = 20_000; - -/** A deadline expiring, told apart from a connection that failed outright. */ -export class GatewayTimeoutError extends Error { - constructor(seam: string) { - super(`${seam} did not answer within ${Math.round(GATEWAY_UPSTREAM_TIMEOUT_MS / 1000)}s.`); - } -} - -/** - * `fetch` under a deadline. The timer is disarmed once the headers land, so a - * streaming relay answer is never cut off mid-body. - */ -const fetchWithDeadline = async ( - seam: string, - url: string, - init: RequestInit, - timeoutMs: number = GATEWAY_UPSTREAM_TIMEOUT_MS, -): Promise<Response> => { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(new GatewayTimeoutError(seam)), timeoutMs); - try { - return await fetch(url, { ...init, signal: controller.signal }); - } finally { - clearTimeout(timer); - } -}; - export interface GatewayRecord { readonly gatewayId: string; readonly baseUrl: string; @@ -143,16 +106,8 @@ export interface GatewayEnv { readonly IDENTITY_SERVICE_TOKEN?: string; readonly SMITHERS_CLOUD_API_BASE_URL?: string; readonly GATEWAY_SESSIONS?: GatewaySessionNamespace; - /** Override for GATEWAY_UPSTREAM_TIMEOUT_MS, in milliseconds. */ - readonly UPSTREAM_TIMEOUT_MS?: string; } -/** The deadline this deployment uses, defaulted when unset or unparseable. */ -export const upstreamTimeoutMs = (env: { readonly UPSTREAM_TIMEOUT_MS?: string }): number => { - const configured = Number(env.UPSTREAM_TIMEOUT_MS ?? ""); - return Number.isFinite(configured) && configured > 0 ? configured : GATEWAY_UPSTREAM_TIMEOUT_MS; -}; - export const DEFAULT_CLOUD_API_BASE_URL = "https://api.jjhub.tech"; /* @@ -247,16 +202,11 @@ export const fetchCloudToken = async (env: GatewayEnv, login: string): Promise<C } let response: Response; try { - response = await fetchWithDeadline( - "The Cloud token door", - new URL("/api/identity/cloud-token", upstream).toString(), - { - method: "POST", - headers: { "content-type": "application/json", "x-smithers-service-token": serviceToken }, - body: JSON.stringify({ login }), - }, - upstreamTimeoutMs(env), - ); + response = await fetch(new URL("/api/identity/cloud-token", upstream).toString(), { + method: "POST", + headers: { "content-type": "application/json", "x-smithers-service-token": serviceToken }, + body: JSON.stringify({ login }), + }); } catch (error) { return { status: "unavailable", @@ -318,27 +268,11 @@ const provisionGateway = async ( const base = env.SMITHERS_CLOUD_API_BASE_URL?.trim() || DEFAULT_CLOUD_API_BASE_URL; let response: Response; try { - response = await fetchWithDeadline( - "Smithers Cloud", - new URL(`/api/repos/${repo}/gateway`, base).toString(), - { method: "POST", headers: { authorization: `Bearer ${cloudToken}` } }, - upstreamTimeoutMs(env), - ); + response = await fetch(new URL(`/api/repos/${repo}/gateway`, base).toString(), { + method: "POST", + headers: { authorization: `Bearer ${cloudToken}` }, + }); } catch (error) { - /* - * A provision POST that never answers is not a dead end: the route is - * idempotent, and Cloud may well still be building the sandbox behind - * the silence. So a deadline lands in the seam's `provisioning` state — - * the caller polls to its own bounded deadline and then says so — and - * only a real connection failure is reported as unreachable. Either way - * the request ANSWERS, which is the whole point. - */ - if (error instanceof GatewayTimeoutError) { - return { - status: "provisioning", - detail: `Smithers Cloud hasn't finished preparing the workspace for ${repo} yet — it took longer than ${Math.round(upstreamTimeoutMs(env) / 1000)}s to answer.`, - }; - } return { status: "unavailable", detail: `Smithers Cloud is unreachable: ${error instanceof Error ? error.message : "unknown error"}`, @@ -540,20 +474,15 @@ export const callGateway = async ( try { // The relay base_url is a PATH base (…/api/gateways/<id>): URL-joining // an absolute path would drop it, so concatenate instead. - return await fetchWithDeadline( - "The workspace gateway", - `${record.baseUrl.replace(/\/+$/, "")}${path}`, - { - method: init.method, - headers: { - authorization: `Bearer ${record.token}`, - ...(init.body === undefined ? {} : { "content-type": "application/json" }), - ...init.headers, - }, - ...(init.body === undefined ? {} : { body: JSON.stringify(init.body) }), + return await fetch(`${record.baseUrl.replace(/\/+$/, "")}${path}`, { + method: init.method, + headers: { + authorization: `Bearer ${record.token}`, + ...(init.body === undefined ? {} : { "content-type": "application/json" }), + ...init.headers, }, - upstreamTimeoutMs(env), - ); + ...(init.body === undefined ? {} : { body: JSON.stringify(init.body) }), + }); } catch (error) { reason = error instanceof Error ? error.message : String(error); return undefined; diff --git a/apps/server/src/index.test.ts b/apps/server/src/index.test.ts index 5e0111c4..117a7053 100644 --- a/apps/server/src/index.test.ts +++ b/apps/server/src/index.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import worker, { TurnCancelRegistry, withStartSessionHandoff } from "./index"; +import worker, { TurnCancelRegistry } from "./index"; import type { TurnCancelNamespace, TurnCancelStorage, WorkerEnv } from "./index"; const assetsEnv = (html = "<html><body>smithers</body></html>"): WorkerEnv => ({ @@ -32,24 +32,6 @@ const post = (path: string, body: unknown): Request => }); describe("smithers mvp worker", () => { - test("the trusted Start session handoff overwrites a forged client value", async () => { - const forged = encodeURIComponent(JSON.stringify({ status: 200, body: JSON.stringify({ login: "forged" }) })); - const request = new Request("https://mvp.test/", { - headers: { "x-smithers-start-session": forged }, - }); - const handedOff = await withStartSessionHandoff( - request, - new Response(JSON.stringify({ status: "signed-out" }), { status: 200 }), - ); - const encoded = handedOff.headers.get("x-smithers-start-session"); - expect(encoded).not.toBeNull(); - expect(JSON.parse(decodeURIComponent(encoded ?? ""))).toEqual({ - status: 200, - body: JSON.stringify({ status: "signed-out" }), - }); - expect(encoded).not.toBe(forged); - }); - test("serves the SPA with the cross-origin isolation headers OPFS needs", async () => { const response = await worker.fetch(new Request("https://mvp.test/"), assetsEnv()); expect(response.status).toBe(200); @@ -58,9 +40,9 @@ describe("smithers mvp worker", () => { expect(await response.text()).toContain("smithers"); }); - test("rejects a turn body over the 1 MB cap with 413", async () => { + test("rejects a turn body over the 64 KB cap with 413", async () => { const response = await worker.fetch( - post("/api/agent/turn", { ...turnBody, instructions: "x".repeat(1100 * 1024) }), + post("/api/agent/turn", { ...turnBody, instructions: "x".repeat(70 * 1024) }), assetsEnv(), ); expect(response.status).toBe(413); @@ -68,35 +50,13 @@ describe("smithers mvp worker", () => { /** * The cap is a byte cap: a body of multi-byte characters encodes to up to 4x its - * string length, so a UTF-16 `.length` check would wave a 2 MB body through. + * string length, so a UTF-16 `.length` check would wave a 96 KB body through. */ - /* - * Repro apps/ui/canary-repros/chat/4.13: every model call replays the whole - * transcript, so an over-cap body is a fact about the CONVERSATION. The turn - * seam said so; the relay — which carries every turn now that the browser - * chain is the only backend — answered the bare "Request body is too large." - * Both doors say the same sentence, and it names the way out. - */ - test("both model doors answer an over-cap transcript with the same actionable 413", async () => { - const oversize = { role: "user", content: "x".repeat(1100 * 1024) }; - for (const request of [ - post("/api/agent/turn", { ...turnBody, messages: [oversize] }), - post("/api/model/stream", { messages: [oversize] }), - ]) { - const response = await worker.fetch(request, assetsEnv()); - expect(response.status).toBe(413); - const body = (await response.json()) as { message: string }; - expect(body.message).toContain("This conversation has grown too long"); - expect(body.message).toContain("Start a new conversation"); - expect(body.message).not.toBe("Request body is too large."); - } - }); - - test("measures the 1 MB cap in bytes, not UTF-16 code units", async () => { - // 768k x U+00E9 = 768k code units but 1.5 MB of UTF-8. - const instructions = "é".repeat(768 * 1024); - expect(instructions.length).toBeLessThan(1024 * 1024); - expect(new TextEncoder().encode(instructions).byteLength).toBeGreaterThan(1024 * 1024); + test("measures the 64 KB cap in bytes, not UTF-16 code units", async () => { + // 48k x U+00E9 = 48k code units but 96 KB of UTF-8. + const instructions = "é".repeat(48 * 1024); + expect(instructions.length).toBeLessThan(64 * 1024); + expect(new TextEncoder().encode(instructions).byteLength).toBeGreaterThan(64 * 1024); const response = await worker.fetch( post("/api/agent/turn", { ...turnBody, instructions }), assetsEnv(), @@ -104,152 +64,6 @@ describe("smithers mvp worker", () => { expect(response.status).toBe(413); }); - /* - * Repro apps/ui/canary-repros/chat/4.13: every turn replays the whole - * transcript, so at the old 64 KB cap seven long answers wedged the seam - * permanently — and `/clear`, which runs a model turn of its own to decide - * what to keep, hit the same refusal, so the conversation had no in-app - * escape. The measured wedge was ~64 KB of rendered transcript. - */ - test("accepts a transcript the size that wedged the seam at the old cap", async () => { - const upstream: Array<string> = []; - const env: WorkerEnv = { ...assetsEnv(), SMITHERS_CHAT_URL: "https://upstream.test/chat" }; - const originalFetch = globalThis.fetch; - globalThis.fetch = (async (input: unknown) => { - upstream.push(String(input)); - return new Response('{"type":"done"}\n', { headers: { "content-type": "application/x-ndjson" } }); - }) as typeof fetch; - try { - const messages = Array.from({ length: 14 }, (_, index) => ({ - role: index % 2 === 0 ? "user" : "assistant", - content: "x".repeat(6 * 1024), - })); - const response = await worker.fetch( - post("/api/agent/turn", { ...turnBody, runId: "run-4-13-wedge", messages }), - env, - ); - expect(response.status).toBe(200); - // Drain so the per-isolate active-turn entry settles for later tests. - await response.text(); - expect(upstream).toEqual(["https://upstream.test/chat"]); - } finally { - globalThis.fetch = originalFetch; - } - }); - - /* A refusal a reader can act on: which thing is too long, and the way out. */ - test("the oversize refusal names the conversation and the way out", async () => { - const response = await worker.fetch( - post("/api/agent/turn", { ...turnBody, instructions: "x".repeat(1100 * 1024) }), - assetsEnv(), - ); - const body = (await response.json()) as { message: string }; - expect(body.message).toContain("conversation"); - expect(body.message).toContain("Start a new conversation"); - expect(body.message).not.toBe("Request body is too large."); - }); - - /* - * Repro apps/ui/canary-repros/honesty/24.3: the seam pasted the upstream's - * body onto a fixed prefix, so a provider's rate-limit envelope arrived in - * the transcript as raw JSON. The status is classified here rather than - * trusting every upstream to write prose for a human. - */ - test("a rate-limited upstream becomes a rate-limit sentence, not raw provider JSON", async () => { - const env: WorkerEnv = { ...assetsEnv(), SMITHERS_CHAT_URL: "https://upstream.test/chat" }; - const original = globalThis.fetch; - globalThis.fetch = (async () => - new Response( - JSON.stringify({ - type: "error", - error: { type: "rate_limit_error", message: "Number of request tokens has exceeded your per-minute rate limit" }, - }), - { status: 429, headers: { "content-type": "application/json", "retry-after": "45" } }, - )) as unknown as typeof fetch; - try { - const response = await worker.fetch(post("/api/agent/turn", { ...turnBody, runId: "run-429" }), env); - expect(response.status).toBe(429); - const body = (await response.json()) as { message: string }; - expect(body.message).toContain("rate-limiting"); - expect(body.message).toContain("Nothing was charged"); - expect(body.message).toContain("45 seconds"); - expect(body.message).not.toContain("rate_limit_error"); - expect(body.message).not.toContain("{"); - } finally { - globalThis.fetch = original; - } - }); - - /* - * The §24.4 shape from the same repro: a Worker 500 whose body is a - * Cloudflare HTML page rendered as markup in the transcript. - */ - test("an upstream HTML error page never reaches the message", async () => { - const env: WorkerEnv = { ...assetsEnv(), SMITHERS_CHAT_URL: "https://upstream.test/chat" }; - const original = globalThis.fetch; - globalThis.fetch = (async () => - new Response("<!DOCTYPE html><html><body>Error 1101 Worker threw exception</body></html>", { - status: 500, - headers: { "content-type": "text/html" }, - })) as unknown as typeof fetch; - try { - const response = await worker.fetch(post("/api/agent/turn", { ...turnBody, runId: "run-500" }), env); - expect(response.status).toBe(500); - const body = (await response.json()) as { message: string }; - expect(body.message).not.toContain("<"); - expect(body.message).toContain("having trouble"); - } finally { - globalThis.fetch = original; - } - }); - - /* An upstream that DOES write prose keeps it — our own limiter is the case. */ - test("an upstream message written for a reader survives", async () => { - const env: WorkerEnv = { ...assetsEnv(), SMITHERS_CHAT_URL: "https://upstream.test/chat" }; - const original = globalThis.fetch; - globalThis.fetch = (async () => - new Response(JSON.stringify({ status: "error", message: "The canary chat queue is draining; try again shortly." }), { - status: 503, - headers: { "content-type": "application/json" }, - })) as unknown as typeof fetch; - try { - const response = await worker.fetch(post("/api/agent/turn", { ...turnBody, runId: "run-503" }), env); - const body = (await response.json()) as { message: string }; - expect(body.message).toContain("The canary chat queue is draining"); - } finally { - globalThis.fetch = original; - } - }); - - /* - * An unreachable sibling used to end the fetch handler with an uncaught - * rejection, and workerd answers that with its own HTML error page — which - * the product then renders to the user. - */ - test("an unreachable proxy upstream answers honest JSON, never a thrown exception", async () => { - const env: WorkerEnv = { - ...assetsEnv(), - IDENTITY_UPSTREAM_URL: "https://identity.test", - BILLING_UPSTREAM_URL: "https://billing.test", - RECO_UPSTREAM_URL: "https://reco.test", - }; - const original = globalThis.fetch; - globalThis.fetch = (async () => { - throw new TypeError("Network connection lost."); - }) as unknown as typeof fetch; - try { - for (const path of ["/api/identity/whoami", "/api/reco/first-run"]) { - const response = await worker.fetch(new Request(`https://mvp.test${path}`), env); - expect(`${path} → ${response.status}`).toBe(`${path} → 502`); - const body = (await response.json()) as { status: string; message: string }; - expect(body.status).toBe("error"); - expect(body.message).toContain("unreachable"); - } - } finally { - globalThis.fetch = original; - } - }); - test("streams one upstream turn through /api/agent/turn as NDJSON", async () => { let upstreamCall: { origin: string | null; runId: string | null; body: unknown } | undefined; const env: WorkerEnv = { @@ -533,81 +347,6 @@ describe("auth navigation seam (wave 8)", () => { ); }); - /* - * Repro apps/ui/canary-repros/access/2.3: pressing Cancel on GitHub's - * consent screen returns `?error=access_denied` with no `code`. That was - * forwarded to identity, which read it as a malformed callback, and the page - * told the user "the sign-in service answered HTTP 400" — blaming a service - * for a button they pressed. The cause is in the query string, so it is read - * here, named here, and never spends an upstream call. - */ - test("a cancelled consent screen is named as a cancellation, not an upstream failure", async () => { - let upstreamCalls = 0; - await withIdentity( - () => { - upstreamCalls += 1; - return new Response(JSON.stringify({ message: "code and state are required" }), { status: 400 }); - }, - async () => { - const response = await worker.fetch( - new Request( - "https://mvp.test/api/auth/github/callback?error=access_denied&error_description=The+user+has+denied+your+application+access.&state=zzz", - { headers: { accept: BROWSER_ACCEPT } }, - ), - env, - ); - // Nothing failed: the user declined and the app did as it was told. - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toContain("text/html"); - const html = await response.text(); - expect(html).toContain("You cancelled the GitHub sign-in."); - expect(html).toContain("Nothing was signed in"); - expect(html).not.toContain("sign-in service answered"); - expect(html).not.toContain("HTTP 400"); - expect(html).toContain('href="/"'); - expect(upstreamCalls).toBe(0); - }, - ); - }); - - test("any other OAuth error names what GitHub called it, and keeps a 400", async () => { - await withIdentity( - () => new Response("{}", { status: 400 }), - async () => { - const response = await worker.fetch( - new Request( - "https://mvp.test/api/auth/github/callback?error=redirect_uri_mismatch&error_description=The+redirect_uri+is+not+associated.&state=zzz", - { headers: { accept: BROWSER_ACCEPT } }, - ), - env, - ); - expect(response.status).toBe(400); - const html = await response.text(); - expect(html).toContain("redirect_uri_mismatch"); - expect(html).toContain("The redirect_uri is not associated."); - expect(html).toContain('href="/"'); - }, - ); - }); - - test("a cancelled callback answers JSON callers a cancellation too", async () => { - await withIdentity( - () => new Response("{}", { status: 400 }), - async () => { - const response = await worker.fetch( - new Request("https://mvp.test/api/auth/github/callback?error=access_denied&state=zzz", { - headers: { accept: "application/json" }, - }), - env, - ); - expect(response.status).toBe(200); - const body = (await response.json()) as { status: string; message: string }; - expect(body.status).toBe("cancelled"); - expect(body.message).toContain("Nothing was signed in"); - }, - ); - }); - test("the redirect happy path passes through untouched", async () => { await withIdentity( () => new Response(null, { status: 302, headers: { location: "https://github.com/login/oauth" } }), @@ -1298,42 +1037,6 @@ describe("the admin surface (non-enumerable)", () => { }); }); - /* - * Repro apps/ui/canary-repros/access/1.5: `admin` comes from identity's - * ADMIN_LOGINS var, so removing a login from the closed-alpha allowlist left - * the whole admin surface open to it — including POST /api/admin/allowlist, - * the door that edits the allowlist itself. Identity now withholds the claim - * from a non-allowlisted login; this Worker refuses on its own evidence too, - * so one upstream field cannot re-open the surface on its own. - */ - test("a de-allowlisted admin is as undetectable as a stranger", async () => { - const deAllowlistedAdmin = new Response( - JSON.stringify({ login: "will", allowlisted: false, admin: true, scopes: [] }), - { status: 200, headers: { "content-type": "application/json" } }, - ); - await withMockedFetch(identityDouble(deAllowlistedAdmin), async () => { - const unknown = await worker.fetch(new Request("https://mvp.test/api/nope"), adminEnv()); - const unknownBody = await unknown.text(); - for (const path of [ - "/api/admin/requests", - "/api/admin/health", - "/api/admin/feedback", - "/api/admin/errors", - ]) { - const probe = await worker.fetch(new Request(`https://mvp.test${path}`), adminEnv()); - expect(probe.status).toBe(404); - expect(await probe.text()).toBe(unknownBody); - } - // The write door too: a revoked admin cannot re-add itself. - const write = await worker.fetch( - post("/api/admin/allowlist", { login: "will", action: "add" }), - adminEnv(), - ); - expect(write.status).toBe(404); - expect(await write.text()).toBe(unknownBody); - }); - }); - test("admin allowlist writes carry the admin's login as requester and a fresh timestamp", async () => { let seen: { headers: Headers; body: unknown } | undefined; const originalFetch = globalThis.fetch; @@ -1959,178 +1662,6 @@ describe("the browser tool route (§2d)", () => { } }); - /* - * Repro apps/ui/canary-repros/admin/28.5 and cards/8.21: the proxy forwarded - * every allowlisted path, and the jjhub Go router's plain-text - * `404 page not found` came back through it and was rendered verbatim into - * the user's toast. A body written for a router is never a message for a - * reader. - */ - test("a platform failure is restated in the seam's envelope, never forwarded raw", async () => { - const env: WorkerEnv = { - ...assetsEnv(), - IDENTITY_UPSTREAM_URL: "https://identity.test", - IDENTITY_SERVICE_TOKEN: "svc", - SMITHERS_CLOUD_API_BASE_URL: "https://cloud.test", - }; - const original = globalThis.fetch; - globalThis.fetch = (async (input: RequestInfo | URL) => { - const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; - if (url.includes("/api/identity/validate")) { - return new Response(JSON.stringify({ login: "will", allowlisted: true, admin: false, scopes: [] }), { - status: 200, - headers: { "content-type": "application/json" }, - }); - } - if (url.includes("/api/identity/cloud-token")) { - return new Response(JSON.stringify({ found: true, token: "cloud-token-1" }), { - status: 200, - headers: { "content-type": "application/json" }, - }); - } - return new Response("404 page not found\n", { - status: 404, - headers: { "content-type": "text/plain; charset=utf-8" }, - }); - }) as unknown as typeof fetch; - try { - const response = await worker.fetch( - new Request("https://mvp.test/api/repos/will/flows/issues?state=open"), - env, - ); - expect(response.status).toBe(404); - expect(response.headers.get("content-type")).toContain("application/json"); - const body = (await response.json()) as { status: string; message: string }; - expect(body.status).toBe("error"); - expect(body.message).not.toContain("404 page not found"); - expect(body.message).toContain("Smithers Cloud"); - } finally { - globalThis.fetch = original; - } - }); - - /* - * Repro apps/ui/canary-repros/money/18.1 and flow-sweep/A.59: the platform - * ships no BYOK key store, so the forward could only ever come back a 404. - * The honest answer is the seam's own 501 naming the state, and NO forward - * at all — a doomed request is also a 4xx on every ordinary session - * (repro admin/28.12). - */ - test("a platform family the upstream does not implement answers an honest 501 and never forwards", async () => { - const env: WorkerEnv = { - ...assetsEnv(), - IDENTITY_UPSTREAM_URL: "https://identity.test", - IDENTITY_SERVICE_TOKEN: "svc", - SMITHERS_CLOUD_API_BASE_URL: "https://cloud.test", - }; - const seen: Array<string> = []; - const original = globalThis.fetch; - globalThis.fetch = (async (input: RequestInfo | URL) => { - const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; - seen.push(url); - if (url.includes("/api/identity/validate")) { - return new Response(JSON.stringify({ login: "will", allowlisted: true, admin: false, scopes: [] }), { - status: 200, - headers: { "content-type": "application/json" }, - }); - } - return new Response("404 page not found\n", { status: 404 }); - }) as unknown as typeof fetch; - try { - for (const request of [ - new Request("https://mvp.test/api/user/byok-keys"), - new Request("https://mvp.test/api/user/byok-keys/anthropic", { method: "DELETE" }), - ]) { - const response = await worker.fetch(request, env); - expect(response.status).toBe(501); - const body = (await response.json()) as { message: string }; - expect(body.message).toContain("provider keys"); - expect(body.message).not.toContain("404"); - } - expect(seen.every((url) => url.includes("identity.test"))).toBe(true); - } finally { - globalThis.fetch = original; - } - }); - - /* - * Repro apps/ui/canary-repros/money/17.4: `/billing.upgrade` on an MVP - * account fired a live POST /api/billing/checkout and came back the - * platform's `stripe billing is not configured`. The alpha comps every - * balance, so the honest answer is that there is nothing to buy — and the - * request never reaches Stripe. - */ - test("checkout and the billing portal are refused while the alpha comps every balance", async () => { - const env: WorkerEnv = { - ...assetsEnv(), - IDENTITY_UPSTREAM_URL: "https://identity.test", - IDENTITY_SERVICE_TOKEN: "svc", - SMITHERS_CLOUD_API_BASE_URL: "https://cloud.test", - }; - const seen: Array<string> = []; - const original = globalThis.fetch; - globalThis.fetch = (async (input: RequestInfo | URL) => { - const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; - seen.push(url); - if (url.includes("/api/identity/validate")) { - return new Response(JSON.stringify({ login: "will", allowlisted: true, admin: false, scopes: [] }), { - status: 200, - headers: { "content-type": "application/json" }, - }); - } - return new Response(JSON.stringify({ message: "stripe billing is not configured" }), { status: 400 }); - }) as unknown as typeof fetch; - try { - for (const path of ["/api/billing/checkout", "/api/billing/portal"]) { - const response = await worker.fetch(new Request(`https://mvp.test${path}`, { method: "POST" }), env); - expect(`${path} → ${response.status}`).toBe(`${path} → 501`); - const body = (await response.json()) as { message: string }; - expect(body.message).toContain("nothing to buy"); - expect(body.message).not.toContain("stripe"); - } - expect(seen.some((url) => url.includes("cloud.test"))).toBe(false); - } finally { - globalThis.fetch = original; - } - }); - - test("a deployment that has shipped paid plans forwards checkout unchanged", async () => { - const env: WorkerEnv = { - ...assetsEnv(), - IDENTITY_UPSTREAM_URL: "https://identity.test", - IDENTITY_SERVICE_TOKEN: "svc", - SMITHERS_CLOUD_API_BASE_URL: "https://cloud.test", - BILLING_CHECKOUT_ENABLED: "1", - }; - const original = globalThis.fetch; - globalThis.fetch = (async (input: RequestInfo | URL) => { - const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; - if (url.includes("/api/identity/validate")) { - return new Response(JSON.stringify({ login: "will", allowlisted: true, admin: false, scopes: [] }), { - status: 200, - headers: { "content-type": "application/json" }, - }); - } - if (url.includes("/api/identity/cloud-token")) { - return new Response(JSON.stringify({ found: true, token: "cloud-token-1" }), { status: 200 }); - } - return new Response(JSON.stringify({ url: "https://checkout.stripe.test/session" }), { - status: 200, - headers: { "content-type": "application/json" }, - }); - }) as unknown as typeof fetch; - try { - const response = await worker.fetch( - new Request("https://mvp.test/api/billing/checkout", { method: "POST" }), - env, - ); - expect(response.status).toBe(200); - expect(await response.json()).toEqual({ url: "https://checkout.stripe.test/session" }); - } finally { - globalThis.fetch = original; - } - }); - test("the platform proxy forwards with the user's cloud bearer and passes the platform answer through", async () => { const env: WorkerEnv = { ...assetsEnv(), @@ -2177,127 +1708,3 @@ describe("the browser tool route (§2d)", () => { } }); }); - -/* - * The reco dismissal reset. A dismissal suppresses its recommendation for - * seven days, and the launch checklist dismisses a card by design (row A-9), - * so one run left A-8 and A-9 ungradeable for a week. This door is what makes - * the suite repeatable; it is admin-gated like every other one, and the reco - * admin token never leaves the server. - */ -describe("the reco dismissal reset", () => { - const recoEnv: WorkerEnv = { - ASSETS: { fetch: async () => new Response("<html></html>", { status: 200 }) }, - IDENTITY_UPSTREAM_URL: "https://identity.test", - RECO_UPSTREAM_URL: "https://reco.test", - RECO_ADMIN_TOKEN: "reco-admin-token-0123456789", - }; - - const reset = (query: string, env: WorkerEnv = recoEnv): Promise<Response> => - worker.fetch( - new Request(`https://mvp.test/api/admin/reco-dismissals${query}`, { - method: "DELETE", - headers: { cookie: "smithers_session=abc" }, - }), - env, - ); - - const asAdmin = (admin: boolean) => (request: Request): Response | undefined => { - const host = new URL(request.url).hostname; - if (host === "identity.test") { - return new Response(JSON.stringify({ login: "will", allowlisted: true, admin }), { - status: 200, - headers: { "content-type": "application/json" }, - }); - } - return undefined; - }; - - test("an admin reset reaches reco with the admin token and the login", async () => { - const seen: Array<{ url: string; method: string; token: string | null }> = []; - await withMockedFetch( - (request) => { - const stubbed = asAdmin(true)(request); - if (stubbed !== undefined) return stubbed; - seen.push({ - url: request.url, - method: request.method, - token: request.headers.get("x-smithers-admin-token"), - }); - return new Response(JSON.stringify({ login: "will", cleared: 2 }), { - status: 200, - headers: { "content-type": "application/json" }, - }); - }, - async () => { - const response = await reset("?login=will"); - expect(response.status).toBe(200); - expect(await response.json()).toEqual({ login: "will", cleared: 2 }); - }, - ); - expect(seen).toHaveLength(1); - expect(seen[0]?.url).toBe("https://reco.test/api/reco/admin/dismissals?login=will"); - expect(seen[0]?.method).toBe("DELETE"); - expect(seen[0]?.token).toBe("reco-admin-token-0123456789"); - }); - - test("a login with a slash or space is encoded, never smuggled into the path", async () => { - let called = ""; - await withMockedFetch( - (request) => { - const stubbed = asAdmin(true)(request); - if (stubbed !== undefined) return stubbed; - called = request.url; - return new Response(JSON.stringify({ cleared: 0 }), { - status: 200, - headers: { "content-type": "application/json" }, - }); - }, - async () => { - await reset("?login=" + encodeURIComponent("a b/../admin")); - }, - ); - expect(called).toBe("https://reco.test/api/reco/admin/dismissals?login=a%20b%2F..%2Fadmin"); - }); - - test("a missing login is a 400, not a reset of everyone", async () => { - let upstreamCalls = 0; - await withMockedFetch( - (request) => { - const stubbed = asAdmin(true)(request); - if (stubbed !== undefined) return stubbed; - upstreamCalls += 1; - return new Response("{}", { status: 200 }); - }, - async () => { - expect((await reset("")).status).toBe(400); - expect((await reset("?login=%20%20")).status).toBe(400); - }, - ); - expect(upstreamCalls).toBe(0); - }); - - test("a non-admin gets the canonical 404 and reco is never called", async () => { - let upstreamCalls = 0; - await withMockedFetch( - (request) => { - const stubbed = asAdmin(false)(request); - if (stubbed !== undefined) return stubbed; - upstreamCalls += 1; - return new Response("{}", { status: 200 }); - }, - async () => { - expect((await reset("?login=will")).status).toBe(404); - }, - ); - expect(upstreamCalls).toBe(0); - }); - - test("an unconfigured admin token says so rather than forwarding without one", async () => { - await withMockedFetch(asAdmin(true), async () => { - const response = await reset("?login=will", { ...recoEnv, RECO_ADMIN_TOKEN: undefined }); - expect(response.status).toBe(501); - expect(JSON.stringify(await response.json())).toContain("RECO_ADMIN_TOKEN"); - }); - }); -}); diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 66221bd9..4cb6295d 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -1,10 +1,8 @@ import { ADMIN_ALLOWLIST_PATH, - ADMIN_ERRORS_PATH, ADMIN_FEEDBACK_PATH, ADMIN_GRANT_PATH, ADMIN_HEALTH_PATH, - ADMIN_RECO_DISMISSALS_PATH, ADMIN_REQUESTS_PATH, ADMIN_ROUTE_PREFIX, APPROVAL_DECISION_PATH, @@ -36,20 +34,11 @@ import { GatewaySessionRegistry, isRelayRepoName, NON_REPLAYABLE_GATEWAY_METHODS, - upstreamTimeoutMs, } from "./gateway"; import type { GatewaySessionNamespace } from "./gateway"; -import { appendClientError, ClientErrorLog, readClientErrors } from "./clientErrorLog"; -import type { ClientErrorNamespace } from "./clientErrorLog"; -import { spendTurn, TurnRateLimiter, turnLimitResponse } from "./turnLimit"; -import type { TurnLimitNamespace } from "./turnLimit"; - -declare const __SMITHERS_START__: boolean | undefined; /* The per-user gateway session registry (Wave 11) — wrangler binds this DO. */ export { GatewaySessionRegistry }; -/* The per-login turn ceiling and the client-error log — wrangler binds both. */ -export { TurnRateLimiter, ClientErrorLog }; /* * The deployable Smithers MVP server: a Cloudflare Worker that serves the built @@ -63,112 +52,11 @@ const DEFAULT_CHAT_URL = "https://chat.smithers.sh/chat"; const DEFAULT_APP_ORIGIN = "https://smithers.sh"; /** - * Cap for a single turn request body. Every turn replays the whole transcript, - * so this is a conversation-length ceiling, not a per-message one. At 64 KB - * seven long answers wedged the seam permanently on canary and `/clear` could - * not recover it, because `/clear` runs a model turn of its own and hit the - * same refusal (repro apps/ui/canary-repros/chat/4.13). The Vite dev boundary - * (`src/server/AgentApi.ts`) allows 1 MB, so the two boundaries now agree and a - * conversation that passes in dev passes here. - */ -const MAX_BODY_BYTES = 1024 * 1024; - -/** - * Every upstream this Worker calls is bounded. Without a deadline a sibling - * that accepts the connection and never answers hangs the browser request for - * as long as the tab is open: `POST /api/workflow/provision` stood past 70s on - * canary with no answer, no timeout and no error (repro - * apps/ui/canary-repros/honesty/22.6), which is a spinner that never ends — - * the silent-failure family in its worst shape. The deadline bounds the wait - * for the upstream's HEADERS; a response that has begun streaming is not cut - * off by it. - */ -const UPSTREAM_TIMEOUT_MS = 20_000; - -/** A deadline expiring, distinguishable from the client hanging up. */ -class UpstreamTimeoutError extends Error { - constructor(readonly seam: string) { - super(`${seam} did not answer within ${Math.round(UPSTREAM_TIMEOUT_MS / 1000)}s.`); - } -} - -/** - * Run one upstream call under a deadline. The timer is disarmed as soon as the - * headers land, so a streaming body (billing, reco, identity, the gateway) - * keeps flowing for as long as it needs. - */ -const withDeadline = async ( - seam: string, - run: (signal: AbortSignal) => Promise<Response>, - timeoutMs: number = UPSTREAM_TIMEOUT_MS, -): Promise<Response> => { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(new UpstreamTimeoutError(seam)), timeoutMs); - try { - return await run(controller.signal); - } finally { - clearTimeout(timer); - } -}; - -/** - * The prose inside an upstream error body, or undefined when the body was - * written for a machine. This is the rule the seam keeps: a Cloudflare HTML - * page, a Go router's `404 page not found`, and a provider's error envelope - * are never handed to a reader. Only a `message`/`error` string — a field an - * upstream fills with a sentence — survives. - */ -const upstreamProse = (body: string): string | undefined => { - const text = body.trim(); - if (text === "" || text.startsWith("<")) return undefined; - let parsed: unknown; - try { - parsed = JSON.parse(text); - } catch { - return undefined; - } - if (typeof parsed !== "object" || parsed === null) return undefined; - const record = parsed as { message?: unknown; error?: unknown }; - const nested = - typeof record.error === "object" && record.error !== null - ? (record.error as { message?: unknown }).message - : record.error; - const prose = [record.message, nested].find( - (value): value is string => typeof value === "string" && value.trim() !== "", - ); - return prose === undefined ? undefined : prose.trim().slice(0, 200); -}; - -/** - * One sentence a reader can act on for an upstream that refused. The status is - * classified here rather than trusting every upstream to write user-facing - * prose: a provider's raw rate-limit JSON was pasted straight into the - * transcript on canary (repro apps/ui/canary-repros/honesty/24.3), and a - * Worker 500 arrived as a Cloudflare HTML page. + * Cap for a single turn request body. The Vite dev boundary + * (`src/server/AgentApi.ts`) allows 1 MB, so a conversation long enough to exceed + * 64 KB — every turn replays the whole transcript — passes in dev and 413s here. */ -const upstreamFailureMessage = (status: number, body: string, retryAfter: string | null): string => { - if (status === 429) { - const seconds = Number(retryAfter ?? ""); - const when = - Number.isFinite(seconds) && seconds > 0 - ? `Try again in about ${seconds < 90 ? `${Math.ceil(seconds)} seconds` : `${Math.ceil(seconds / 60)} minutes`}.` - : "Try again in a minute."; - return `The model service is rate-limiting this deployment right now, so the turn did not run. Nothing was charged. ${when}`; - } - if (status === 401 || status === 403) { - return "The model service refused this deployment's credentials, so the turn did not run. Nothing was charged, and this is a deployment configuration problem rather than anything to fix from here."; - } - if (status === 413) { - return "This conversation has grown too long for the model service to accept. Start a new conversation to keep going — nothing was charged."; - } - const prose = upstreamProse(body); - if (status >= 500) { - return `The model service is having trouble right now (HTTP ${status}), so the turn did not run. Nothing was charged.${prose === undefined ? "" : ` It said: ${prose}`}`; - } - return prose === undefined - ? `The model service refused this turn (HTTP ${status}).` - : `The model service refused this turn: ${prose}`; -}; +const MAX_BODY_BYTES = 64 * 1024; /** The OPFS SQLite persistence in the SPA needs cross-origin isolation. */ const ISOLATION_HEADERS = { @@ -309,21 +197,10 @@ export interface WorkerEnv { readonly GATEWAY_SESSION_USER_ID?: string; readonly GATEWAY_SESSION_USER_ROLE?: string; readonly GATEWAY_SESSION_USER_SCOPES?: string; - /** - * How long any one upstream gets to answer, in milliseconds. Unset uses - * UPSTREAM_TIMEOUT_MS; a deployment behind a slow sibling can raise it - * without a code change, and the tests shorten it to stay fast. - */ - readonly UPSTREAM_TIMEOUT_MS?: string; /** Identity worker (GitHub OAuth + allowlist) upstream. Unset = 501. */ readonly IDENTITY_UPSTREAM_URL?: string; /** Service token for the product-Worker → identity /api/identity/validate call. */ readonly IDENTITY_SERVICE_TOKEN?: string; - /** - * "1" opens the Stripe checkout and portal routes. Unset — the closed-alpha - * state — makes both answer an honest refusal instead of reaching Stripe. - */ - readonly BILLING_CHECKOUT_ENABLED?: string; /** Billing worker upstream. Unset = 501. */ readonly BILLING_UPSTREAM_URL?: string; /** @@ -351,6 +228,14 @@ export interface WorkerEnv { readonly IDENTITY_ADMIN_TOKEN?: string; readonly BILLING_ADMIN_TOKEN?: string; readonly RECO_ADMIN_TOKEN?: string; + /** + * The chain backend's model relay (DESIGN.md §14, D1): the provider key the + * relay injects, and an optional upstream override (tests, alternate + * deployments). Unset key = the relay answers 501 rather than forwarding a + * request that can only come back 401. + */ + readonly MODEL_RELAY_API_KEY?: string; + readonly MODEL_RELAY_URL?: string; /** * The per-runId cancellation registry (Durable Object). Present on every * real deployment — wrangler.jsonc binds it; only unit tests that exercise @@ -368,18 +253,6 @@ export interface WorkerEnv { * reach a browser. Unset only in unit tests (in-isolate fallback). */ readonly GATEWAY_SESSIONS?: GatewaySessionNamespace; - /** - * The per-login turn ceiling (Durable Object keyed by the validated login). - * An abuse guard on a comped seam, not a billing pause — see turnLimit.ts. - * Unset in unit tests and the stub stack, where it fails open. - */ - readonly TURN_LIMITS?: TurnLimitNamespace; - /** - * The bounded client-error log (one Durable Object for the deployment), - * read back through GET /api/admin/errors. Unset in unit tests, where the - * handler keeps its console.error and nothing is stored. - */ - readonly CLIENT_ERRORS?: ClientErrorNamespace; } const withIsolationHeaders = (response: Response): Response => { @@ -434,17 +307,6 @@ class BodyTooLargeError extends Error { } } -/* - * Every model call replays the whole transcript, so "too large" is a fact about - * the CONVERSATION, not about the message that tripped it. The turn seam said - * so; the model relay — which since the browser chain became the only backend - * carries every turn — still answered the bare `Request body is too large.`, - * which names nothing the reader can act on (repro - * apps/ui/canary-repros/chat/4.13). One sentence, both doors. - */ -const TRANSCRIPT_TOO_LARGE = - "This conversation has grown too long to send in one turn. Start a new conversation to keep going — nothing was charged, and the transcript above stays where it is."; - /* * Live turns keyed by runId so /cancel can abort one. Per-isolate best effort, * used only when no TURN_CANCELS binding exists (unit tests): a disconnect of @@ -596,49 +458,6 @@ const tagRunId = ( }); }; -/** - * Where managed inference lives, and how this Worker authenticates to it. - * - * Both model-spending routes — the turn path and the browser chain's relay — - * call the SAME upstream with the SAME credentials, so there is one place that - * decides what a Smithers-authenticated inference request looks like. The - * upstream owns the provider key, prices the turn against the rate card, and - * meters it durably; nothing downstream of here has to reproduce any of that. - */ -const chatUpstreamUrl = (env: WorkerEnv): string => env.SMITHERS_CHAT_URL?.trim() || DEFAULT_CHAT_URL; - -/* - * Wave 13 (D-2): a session-validated call is metered onto the USER's own - * billing account — the chat worker attributes the charge to the vouched login - * (complimentary: cost recorded, $0 debited), so the user's receipt shows the - * usage and their balance never moves. The token pair is the chat worker's - * trusted-caller door; a client can never inject it, because this header set is - * BUILT here and the caller's own headers are never forwarded. Without the - * configured token the call still runs — metering then attributes to the - * deployment account, exactly as before that path existed. - */ -const chatUpstreamHeaders = ( - env: WorkerEnv, - runId: string, - session: ValidatedIdentity | undefined, -): Record<string, string> => { - const headers: Record<string, string> = { - "content-type": "application/json", - origin: env.SMITHERS_CHAT_ORIGIN?.trim() || DEFAULT_APP_ORIGIN, - "x-smithers-run-id": runId, - }; - const chatToken = env.SMITHERS_CHAT_AUTH_TOKEN?.trim(); - if (chatToken !== undefined && chatToken !== "") { - headers.authorization = `Bearer ${chatToken}`; - } - const chatProductToken = env.CHAT_PRODUCT_SERVICE_TOKEN?.trim(); - if (session !== undefined && chatProductToken !== undefined && chatProductToken !== "") { - headers["x-smithers-service-token"] = chatProductToken; - headers["x-user-login"] = session.login; - } - return headers; -}; - const handleTurn = async ( request: Request, env: WorkerEnv, @@ -648,12 +467,7 @@ const handleTurn = async ( try { body = await readTurnBody(request); } catch (error) { - // The transcript rides every turn, so "too large" is a fact about the - // conversation, not about this one message. Say which, and say the way out. - if (error instanceof BodyTooLargeError) { - return json(413, { status: "error", message: TRANSCRIPT_TOO_LARGE }); - } - return json(400, { + return json(error instanceof BodyTooLargeError ? 413 : 400, { status: "error", message: error instanceof Error ? error.message : "Invalid request.", }); @@ -695,10 +509,34 @@ const handleTurn = async ( let response: Response; try { - response = await fetch(chatUpstreamUrl(env), { + const headers: Record<string, string> = { + "content-type": "application/json", + origin: env.SMITHERS_CHAT_ORIGIN?.trim() || DEFAULT_APP_ORIGIN, + "x-smithers-run-id": body.runId, + }; + const chatToken = env.SMITHERS_CHAT_AUTH_TOKEN?.trim(); + if (chatToken !== undefined && chatToken !== "") { + headers.authorization = `Bearer ${chatToken}`; + } + /* + * Wave 13 (D-2): a session-validated turn is metered onto the USER's own + * billing account — the chat worker attributes the charge to the vouched + * login (complimentary: cost recorded, $0 debited), so the user's receipt + * shows the turn and their balance never moves. The token pair is the + * chat worker's trusted-caller door; a client can never inject it (these + * headers are built here, never forwarded). Without the configured token + * the turn still runs — metering attributes to the deployment account, + * exactly as before this path existed. + */ + const chatProductToken = env.CHAT_PRODUCT_SERVICE_TOKEN?.trim(); + if (turnSession !== undefined && chatProductToken !== undefined && chatProductToken !== "") { + headers["x-smithers-service-token"] = chatProductToken; + headers["x-user-login"] = turnSession.login; + } + response = await fetch(env.SMITHERS_CHAT_URL?.trim() || DEFAULT_CHAT_URL, { method: "POST", signal: upstream.signal, - headers: chatUpstreamHeaders(env, body.runId, turnSession), + headers, body: JSON.stringify({ messages: body.messages, // The hidden runtime context renders server-side into the @@ -722,12 +560,10 @@ const handleTurn = async ( } if (!response.ok || response.body === null) { await settle(); - const detail = await response.text().catch(() => ""); + const detail = (await response.text().catch(() => "")).trim().slice(0, 320); return json(response.ok ? 502 : response.status, { status: "error", - message: response.ok - ? "The model service accepted the turn and then sent no answer at all. Nothing was charged." - : upstreamFailureMessage(response.status, detail, response.headers.get("retry-after")), + message: `Smithers Cloud chat failed (HTTP ${response.status})${detail === "" ? "." : `: ${detail}`}`, }); } @@ -760,73 +596,65 @@ const handleTurn = async ( }; /* - * The chain backend's model relay (DESIGN.md §14, D1) — and, since the browser - * chain became the only backend, the one route a chat turn spends a model on. - * - * The browser runs the real @smthrs/model request/stream machinery against this - * path and the relay forwards it, unchanged, to the SAME managed-inference - * upstream `/api/agent/turn` uses (`chatUpstreamHeaders` above). That upstream - * owns the Cerebras key, authorizes the balance BEFORE calling the provider, - * and enqueues the turn's authoritative usage onto the durable metering queue — - * so the relay inherits per-user metering rather than reproducing it, and no - * provider credential exists on this Worker at all. + * The chain backend's model relay (DESIGN.md §14, D1). The browser runs the + * real @smthrs/model provider wire against this path; the relay session-gates + * the call (router), injects the provider key, and streams the provider's SSE + * back verbatim — the Worker never speaks effect or ModelEvent. Client + * credentials are never forwarded: the header set below is built here, so the + * browser's placeholder x-api-key dies at this boundary. * - * The router gates the route before any of this runs: anonymous callers get - * 401, non-allowlisted ones 403, and the per-login turn ceiling applies — all - * of it decided before a single upstream byte is spent. + * Known gap, recorded in §14: the relay spends the deployment key without + * per-user metering. The Wave-13 attribution path must land here before the + * chain backend becomes the default. */ +const MODEL_RELAY_DEFAULT_URL = "https://api.anthropic.com/v1/messages"; -const isModelStreamBody = (value: unknown): value is { readonly messages: ReadonlyArray<unknown> } => +const isModelStreamBody = (value: unknown): value is { readonly model: string } => typeof value === "object" && value !== null && - "messages" in value && - Array.isArray((value as { readonly messages?: unknown }).messages) && - (value as { readonly messages: ReadonlyArray<unknown> }).messages.length > 0; + "model" in value && + typeof (value as { readonly model?: unknown }).model === "string" && + (value as { readonly model: string }).model !== ""; const hasTools = (value: object): boolean => "tools" in value && Array.isArray((value as { readonly tools?: unknown }).tools) && ((value as { readonly tools: ReadonlyArray<unknown> }).tools.length > 0); -const handleModelStream = async ( - request: Request, - env: WorkerEnv, - session: ValidatedIdentity | undefined, -): Promise<Response> => { +const handleModelStream = async (request: Request, env: WorkerEnv): Promise<Response> => { let body: unknown; try { body = await readTurnBody(request); } catch (error) { - if (error instanceof BodyTooLargeError) { - return json(413, { status: "error", message: TRANSCRIPT_TOO_LARGE }); - } - return json(400, { + return json(error instanceof BodyTooLargeError ? 413 : 400, { status: "error", message: error instanceof Error ? error.message : "Invalid request.", }); } if (!isModelStreamBody(body)) { - return json(400, { status: "error", message: "Body must carry a non-empty messages array." }); + return json(400, { status: "error", message: "Body must be a provider request with a model." }); } // The sealed-step law, enforced at the boundary: the author call carries no // tools, so a tool-bearing request has no business on this relay. if (hasTools(body)) { return json(400, { status: "error", message: "The model relay serves sealed author calls only — no tools." }); } - /* - * The run id is minted HERE, never read from the caller. Upstream derives - * the charge's idempotency key from it, so a client that could choose it - * could replay one receipt and take every later call for free. - */ - const runId = crypto.randomUUID(); + const apiKey = env.MODEL_RELAY_API_KEY?.trim(); + if (apiKey === undefined || apiKey === "") { + return json(501, { status: "error", message: "The model relay is not configured (MODEL_RELAY_API_KEY)." }); + } const upstream = new AbortController(); request.signal.addEventListener("abort", () => upstream.abort()); let response: Response; try { - response = await fetch(chatUpstreamUrl(env), { + response = await fetch(env.MODEL_RELAY_URL?.trim() || MODEL_RELAY_DEFAULT_URL, { method: "POST", signal: upstream.signal, - headers: chatUpstreamHeaders(env, runId, session), + headers: { + "content-type": "application/json", + "anthropic-version": request.headers.get("anthropic-version") ?? "2023-06-01", + "x-api-key": apiKey, + }, body: JSON.stringify(body), }); } catch (error) { @@ -835,23 +663,21 @@ const handleModelStream = async ( } return json(502, { status: "error", - message: `The model service is unreachable: ${error instanceof Error ? error.message : "unknown error"}`, + message: `The model provider is unreachable: ${error instanceof Error ? error.message : "unknown error"}`, }); } if (!response.ok || response.body === null) { - const detail = await response.text().catch(() => ""); + const detail = (await response.text().catch(() => "")).trim().slice(0, 320); return json(response.ok ? 502 : response.status, { status: "error", - message: response.ok - ? "The model service accepted the request and then sent no answer at all." - : upstreamFailureMessage(response.status, detail, response.headers.get("retry-after")), + message: `The model provider failed (HTTP ${response.status})${detail === "" ? "." : `: ${detail}`}`, }); } return withIsolationHeaders( new Response(response.body, { status: 200, headers: { - "content-type": response.headers.get("content-type") ?? "application/x-ndjson", + "content-type": response.headers.get("content-type") ?? "text/event-stream", "cache-control": "no-store", }, }), @@ -944,38 +770,12 @@ const proxyToGateway = (request: Request, env: WorkerEnv): Promise<Response> => const headers = new Headers(request.headers); for (const name of STRIPPED_IDENTITY_HEADERS) headers.delete(name); for (const [name, value] of Object.entries(identity)) headers.set(name, value); - return forwardUnderDeadline( - "The engine gateway", - new Request(target.toString(), new Request(request, { headers })), - upstreamTimeoutMs(env), - ); + return fetch(new Request(target.toString(), new Request(request, { headers }))); }; const isGatewayRoute = (pathname: string): boolean => GATEWAY_ROUTE_PREFIXES.some((prefix) => pathname.startsWith(prefix)); -/** - * A proxy whose upstream never answered. Returning the raw rejection would end - * the fetch handler with an uncaught exception, and workerd answers that with - * its own `Error 1101 Worker threw exception` HTML page — which the transcript - * then renders verbatim to the user (repro apps/ui/canary-repros/honesty/24.3, - * the §24.4 note). A named JSON refusal is the honest answer instead. - */ -const upstreamUnreachable = (seam: string, error: unknown): Response => - json(error instanceof UpstreamTimeoutError ? 504 : 502, { - status: "error", - message: - error instanceof UpstreamTimeoutError - ? `${seam} did not answer in time, so nothing was read. Try again in a moment.` - : `${seam} is unreachable right now: ${error instanceof Error ? error.message : "unknown error"}`, - }); - -/** Forward one already-built request under the seam's deadline, never throwing. */ -const forwardUnderDeadline = (seam: string, target: Request, timeoutMs: number): Promise<Response> => - withDeadline(seam, (signal) => fetch(target, { signal }), timeoutMs).catch((error: unknown) => - upstreamUnreachable(seam, error), - ); - /** * The identity worker is the identity authority: it sets and reads its own * session cookie, so the proxy forwards cookies untouched but still strips @@ -1002,11 +802,7 @@ const proxyToIdentity = (request: Request, env: WorkerEnv): Promise<Response> => const headers = new Headers(request.headers); for (const name of STRIPPED_IDENTITY_HEADERS) headers.delete(name); withProxyOrigin(headers, url); - return forwardUnderDeadline( - "The identity service", - new Request(target.toString(), new Request(request, { headers })), - upstreamTimeoutMs(env), - ); + return fetch(new Request(target.toString(), new Request(request, { headers }))); }; /* @@ -1124,59 +920,11 @@ const authErrorResponse = (status: number, heading: string, detail: string): Res const OAUTH_OFF_HEADING = "GitHub sign-in isn't switched on yet for this preview."; -/* - * GitHub reports a refused authorization on the callback as `?error=…` with no - * `code`. Forwarded to identity, that reads as a malformed callback and the - * page told the user "the sign-in service answered HTTP 400" — blaming a - * service for a button the user pressed (repro - * apps/ui/canary-repros/access/2.3). The cause is knowable from the query - * string, so it is read here and named. - * - * `access_denied` is not a failure: the user declined, the app did exactly what - * it was told, and nothing was signed in. It answers 200 with that sentence. - * Every other documented OAuth error IS a failure of the exchange and keeps a - * 400 with the error GitHub named. - */ -const OAUTH_DENIED_HEADING = "You cancelled the GitHub sign-in."; - -const oauthCallbackRefusal = (url: URL): { status: number; heading: string; detail: string } | undefined => { - const error = url.searchParams.get("error")?.trim(); - if (error === undefined || error === "") return undefined; - if (error === "access_denied") { - return { - status: 200, - heading: OAUTH_DENIED_HEADING, - detail: - "You chose not to give Smithers access on GitHub, so the sign-in stopped there. Nothing was signed in and nothing was shared — head back whenever you want to try again.", - }; - } - const described = url.searchParams.get("error_description")?.trim(); - return { - status: 400, - heading: "GitHub sign-in didn't finish.", - detail: `GitHub stopped the sign-in and called it "${error}"${ - described === undefined || described === "" ? "" : ` — ${described}` - }. Nothing was signed in — head back and try again.`, - }; -}; - const handleAuthNavigation = async ( request: Request, env: WorkerEnv, route: "start" | "callback", ): Promise<Response> => { - if (route === "callback") { - const refusal = oauthCallbackRefusal(new URL(request.url)); - if (refusal !== undefined) { - if (prefersJson(request)) { - return json(refusal.status, { - status: refusal.status === 200 ? "cancelled" : "error", - message: refusal.detail, - }); - } - return authErrorResponse(refusal.status, refusal.heading, refusal.detail); - } - } const upstream = env.IDENTITY_UPSTREAM_URL?.trim(); if (upstream === undefined || upstream === "") { if (prefersJson(request)) return proxyToIdentity(request, env); @@ -1417,11 +1165,7 @@ const proxyToBilling = async (request: Request, env: WorkerEnv): Promise<Respons headers.set("authorization", `Bearer ${bearer}`); } withProxyOrigin(headers, url); - return forwardUnderDeadline( - "The billing service", - new Request(target.toString(), new Request(request, { headers })), - upstreamTimeoutMs(env), - ); + return fetch(new Request(target.toString(), new Request(request, { headers }))); }; /** @@ -1444,11 +1188,7 @@ const proxyToReco = (request: Request, env: WorkerEnv): Promise<Response> => { const headers = new Headers(request.headers); for (const name of STRIPPED_IDENTITY_HEADERS) headers.delete(name); withProxyOrigin(headers, url); - return forwardUnderDeadline( - "The recommendations service", - new Request(target.toString(), new Request(request, { headers })), - upstreamTimeoutMs(env), - ); + return fetch(new Request(target.toString(), new Request(request, { headers }))); }; /* @@ -1470,16 +1210,16 @@ const forwardAdminCall = async ( if (init.body !== undefined) headers["content-type"] = "application/json"; let response: Response; try { - response = await withDeadline("The admin upstream", (signal) => - fetch(new URL(path, upstream).toString(), { - method: init.method, - headers, - signal, - ...(init.body === undefined ? {} : { body: JSON.stringify(init.body) }), - }), - ); + response = await fetch(new URL(path, upstream).toString(), { + method: init.method, + headers, + ...(init.body === undefined ? {} : { body: JSON.stringify(init.body) }), + }); } catch (error) { - return upstreamUnreachable("The admin upstream", error); + return json(502, { + status: "error", + message: `The admin upstream is unreachable: ${error instanceof Error ? error.message : "unknown error"}`, + }); } const text = await response.text(); return new Response(text, { @@ -1510,15 +1250,12 @@ const readServiceHealth = async ( } let response: Response; try { - response = await withDeadline(name, (signal) => fetch(new URL("/healthz", base).toString(), { signal })); + response = await fetch(new URL("/healthz", base).toString()); } catch (error) { return { name, status: "failed", - detail: - error instanceof UpstreamTimeoutError - ? `healthz did not answer within ${Math.round(UPSTREAM_TIMEOUT_MS / 1000)}s.` - : `unreachable: ${error instanceof Error ? error.message : "unknown error"}`, + detail: `unreachable: ${error instanceof Error ? error.message : "unknown error"}`, }; } if (!response.ok) { @@ -1552,34 +1289,17 @@ const handleAdminHealth = async (env: WorkerEnv, proxyOrigin: string): Promise<R readServiceHealth("reco", env.RECO_UPSTREAM_URL, "RECO_UPSTREAM_URL", summarize("identity", "prewarm", "admin", "testMode")), ]); - /* - * Recent charges: the billing ledger's own totals, read with the account - * bearer — which authenticates the DEPLOYMENT's billing account, not the - * fleet. Since wave 13 a signed-in user's turn is metered onto that user's - * own account, so this figure stopped moving and is smaller than a single - * active user's (repro apps/ui/canary-repros/admin/25.7). Billing keeps one - * Durable Object per login with no enumeration, so no fleet total can be - * read from here at all; the answer therefore STATES its scope instead of - * presenting a deployment figure as a fleet one. - */ - let charges: { - chargeCount: number; - lifetimeChargedUsd: string; - scope: string; - scopeDetail: string; - } | null = null; + // Recent charges: the billing ledger's own totals, read with the account bearer. + let charges: { chargeCount: number; lifetimeChargedUsd: string } | null = null; const billingBase = env.BILLING_UPSTREAM_URL?.trim(); const bearer = env.BILLING_AUTH_TOKEN?.trim(); if (billingBase !== undefined && billingBase !== "" && bearer !== undefined && bearer !== "") { try { // Billing refuses a request that carries no Origin, so the read states // this Worker's own — the same seam discipline as the billing proxy. - const balance = await withDeadline("billing", (signal) => - fetch(new URL("/api/billing/balance", billingBase).toString(), { - headers: { authorization: `Bearer ${bearer}`, origin: proxyOrigin }, - signal, - }), - ); + const balance = await fetch(new URL("/api/billing/balance", billingBase).toString(), { + headers: { authorization: `Bearer ${bearer}`, origin: proxyOrigin }, + }); if (balance.ok) { const body = (await balance.json()) as { balance?: { chargeCount?: unknown; lifetimeChargedUsd?: unknown }; @@ -1591,9 +1311,6 @@ const handleAdminHealth = async (env: WorkerEnv, proxyOrigin: string): Promise<R charges = { chargeCount: body.balance.chargeCount, lifetimeChargedUsd: body.balance.lifetimeChargedUsd, - scope: "deployment-account", - scopeDetail: - "charge rows on the deployment's own billing account. Signed-in users' turns meter onto their own accounts, so this is not a fleet total and it is not a turn count.", }; } } else { @@ -1650,24 +1367,15 @@ const parseAdminBody = async (request: Request): Promise<Record<string, unknown> /** * The admin plugin's server half (Launch Checklist §E). Every /api/admin/* - * route FIRST validates the session through identity and requires BOTH - * admin:true and allowlisted:true; anything else gets the canonical 404, - * byte-identical to an unknown route. Admin writes carry their audit - * attribution at write time: requester is the admin's own validated login and - * the timestamp is fresh — the siblings refuse unattributed writes by contract. - * - * Allowlisted is part of the gate because removing a login from the - * closed-alpha allowlist has to revoke something. It did not: `admin` comes - * from identity's ADMIN_LOGINS var, so a de-allowlisted admin kept the whole - * surface — including POST /api/admin/allowlist, the door that edits the - * allowlist itself (repro apps/ui/canary-repros/access/1.5). Identity now - * withholds the claim from a non-allowlisted login too; this check is the - * second half of that fix, so the product Worker refuses on its own evidence - * rather than trusting one upstream field. + * route FIRST validates the session through identity and requires admin:true; + * anything else gets the canonical 404, byte-identical to an unknown route. + * Admin writes carry their audit attribution at write time: requester is the + * admin's own validated login and the timestamp is fresh — the siblings + * refuse unattributed writes by contract. */ const handleAdmin = async (request: Request, env: WorkerEnv, url: URL): Promise<Response> => { const session = await validateSession(request, env); - if (session === undefined || !session.admin || !session.allowlisted) return notFound(); + if (session === undefined || !session.admin) return notFound(); if (url.pathname === ADMIN_ALLOWLIST_PATH && request.method === "POST") { const upstream = env.IDENTITY_UPSTREAM_URL?.trim(); @@ -1685,22 +1393,6 @@ const handleAdmin = async (request: Request, env: WorkerEnv, url: URL): Promise< if (login === "" || (action !== "add" && action !== "remove")) { return json(400, { status: "error", message: "Body must be { login, action: \"add\" | \"remove\" }." }); } - /* - * An admin cannot remove its own login. Now that being allowlisted is - * what carries admin, a self-removal is a one-way door: it revokes the - * session's admin claim, and the only door that could undo it is this - * one. The first caller to try it would lock the closed alpha's admin - * surface out of the product with no in-app way back — the operator's - * ADMIN_SERVICE_TOKEN would be the only remaining route. Refuse, and - * name the route that does work. - */ - if (action === "remove" && login.toLowerCase() === session.login.toLowerCase()) { - return json(409, { - status: "error", - message: - "You can't remove your own login from the allowlist: it would revoke your admin access through the only door that could restore it. Ask another admin to remove you, or use the identity worker's admin token.", - }); - } return forwardAdminCall(upstream, "/api/identity/admin/allowlist", token, { method: "POST", body: { login, action, requester: session.login, timestamp: new Date().toISOString() }, @@ -1762,55 +1454,10 @@ const handleAdmin = async (request: Request, env: WorkerEnv, url: URL): Promise< return forwardAdminCall(upstream, "/api/reco/admin/feedback", token, { method: "GET" }); } - /* - * Lift a login's recommendation dismissals (reco D5 suppresses a dismissed - * recommendation for seven days). The launch checklist dismisses a card by - * design in row A-9, so without this door one run leaves A-8 and A-9 - * ungradeable for a week — the suite poisons itself. Admin-gated like every - * other door here, and the reco admin token never leaves the server. - */ - if (url.pathname === ADMIN_RECO_DISMISSALS_PATH && request.method === "DELETE") { - const upstream = env.RECO_UPSTREAM_URL?.trim(); - if (upstream === undefined || upstream === "") { - return notConfigured("The recommendations seam", "RECO_UPSTREAM_URL is unset. Dismissals cannot be reset"); - } - const token = env.RECO_ADMIN_TOKEN?.trim(); - if (token === undefined || token === "") { - return adminTokenNotConfigured("The recommendations admin surface", "RECO_ADMIN_TOKEN"); - } - const login = (url.searchParams.get("login") ?? "").trim(); - if (login === "") { - return json(400, { status: "error", message: "Query must carry ?login=<github login>." }); - } - return forwardAdminCall( - upstream, - `/api/reco/admin/dismissals?login=${encodeURIComponent(login)}`, - token, - { method: "DELETE" }, - ); - } - if (url.pathname === ADMIN_HEALTH_PATH && request.method === "GET") { return handleAdminHealth(env, url.origin); } - // The client-error log, newest first. No upstream and no admin token: the - // reports are this Worker's own state, so this is a local read, and it - // answers an empty log honestly rather than 404ing when nothing has broken. - if (url.pathname === ADMIN_ERRORS_PATH && request.method === "GET") { - const asked = Number(url.searchParams.get("limit") ?? ""); - const limit = Number.isInteger(asked) && asked > 0 ? asked : undefined; - const log = await readClientErrors(env.CLIENT_ERRORS, limit); - return json(200, { - status: "ok", - total: log.total, - reports: log.reports, - ...(env.CLIENT_ERRORS === undefined - ? { note: "No CLIENT_ERRORS binding on this deployment: nothing is stored, so this log is always empty." } - : {}), - }); - } - // An admin-only path this Worker does not implement is still just not found. return notFound(); }; @@ -2244,62 +1891,8 @@ const PLATFORM_PROXY_RULES: ReadonlyArray<{ { exact: "/api/billing/portal", methods: ["POST"] }, ]; -/* - * The closed alpha exposes no top-up, checkout, or card-collection flow: every - * account's balance is comped. Both Stripe routes stayed live anyway, so - * `/billing.upgrade` on an MVP account fired a real POST and came back the - * platform's `stripe billing is not configured` (repro - * apps/ui/canary-repros/money/17.4). A configuration string is not an answer to - * "upgrade my plan", and a live checkout call is not something an MVP account - * should be able to make at all. - * - * Set BILLING_CHECKOUT_ENABLED=1 on the deployment where paid plans ship; the - * routes then forward exactly as before. - */ -const CHECKOUT_PATHS: ReadonlyArray<string> = ["/api/billing/checkout", "/api/billing/portal"]; - -const checkoutEnabled = (env: WorkerEnv): boolean => env.BILLING_CHECKOUT_ENABLED?.trim() === "1"; - const PLATFORM_PROXY_MAX_BODY = 256 * 1024; -/* - * Families the Smithers Cloud platform does not implement. The proxy used to - * forward them anyway and hand the browser the Go router's own plain-text - * `404 page not found`, which the product rendered verbatim into a user's - * toast (repro apps/ui/canary-repros/admin/28.5) and to the console as a 404 - * on every ordinary session (repro admin/28.12). Neither told the user - * anything. An honest 501 that names the state is the contract the rest of - * this Worker already keeps for a seam it cannot serve. - * - * A row here is a statement about the PLATFORM, not about this Worker: delete - * the row the day the upstream route ships and the forward resumes unchanged. - */ -const PLATFORM_UNIMPLEMENTED: ReadonlyArray<{ readonly prefix: string; readonly message: string }> = [ - { - prefix: "/api/user/byok-keys", - message: - "Bring-your-own provider keys aren't part of this preview. Smithers Cloud has no key store yet, so there is nothing to list, add, or remove — turns run on the included allowance instead.", - }, -]; - -const platformUnimplemented = (pathname: string): string | undefined => - PLATFORM_UNIMPLEMENTED.find((rule) => pathname.startsWith(rule.prefix))?.message; - -/** - * What to tell a reader when Smithers Cloud refuses. The upstream's own body is - * used only when it carries prose; a router's plain-text 404 or an HTML error - * page is replaced by a sentence, never forwarded. - */ -const platformFailureMessage = (status: number, body: string): string => { - const prose = upstreamProse(body); - if (prose !== undefined) return prose; - if (status === 404) return "Smithers Cloud doesn't serve that request on this deployment."; - if (status === 401 || status === 403) return "Smithers Cloud refused that request for your account."; - if (status === 429) return "Smithers Cloud is rate-limiting this account right now. Try again in a minute."; - if (status >= 500) return `Smithers Cloud is having trouble right now (HTTP ${status}).`; - return `Smithers Cloud refused that request (HTTP ${status}).`; -}; - /* * Frontend error ingest (multi's /api/client-errors, minimal form): bounded * body, per-isolate rate limit, logged to the worker tail — enough to stop @@ -2311,7 +1904,7 @@ const CLIENT_ERROR_WINDOW_MS = 60_000; const CLIENT_ERROR_WINDOW_MAX = 120; let clientErrorWindow = { start: 0, count: 0 }; -const handleClientError = async (request: Request, env: WorkerEnv): Promise<Response> => { +const handleClientError = async (request: Request): Promise<Response> => { const now = Date.now(); if (now - clientErrorWindow.start > CLIENT_ERROR_WINDOW_MS) { clientErrorWindow = { start: now, count: 0 }; @@ -2324,25 +1917,7 @@ const handleClientError = async (request: Request, env: WorkerEnv): Promise<Resp if (body.byteLength > CLIENT_ERROR_MAX_BODY) { return json(413, { status: "error", message: "Error report too large." }); } - const text = new TextDecoder().decode(body); - console.error("client-error:", text); - // console.error alone lives exactly as long as someone is tailing. The log - // is what makes an alpha user's crash readable afterwards, through - // GET /api/admin/errors; it is bounded and it never fails the report. - const referer = request.headers.get("referer"); - const userAgent = request.headers.get("user-agent"); - await appendClientError(env.CLIENT_ERRORS, { - at: new Date(now).toISOString(), - ...(referer === null ? {} : { page: referer }), - ...(userAgent === null ? {} : { userAgent }), - report: ((): unknown => { - try { - return JSON.parse(text); - } catch { - return text; - } - })(), - }); + console.error("client-error:", new TextDecoder().decode(body)); return json(202, { status: "accepted" }); }; @@ -2364,17 +1939,6 @@ const handlePlatformProxy = async (request: Request, env: WorkerEnv, url: URL): message: "Repository actions need the identity seam, which this deployment does not have.", }); } - if (CHECKOUT_PATHS.includes(url.pathname) && !checkoutEnabled(env)) { - return json(501, { - status: "error", - message: - "There is nothing to buy during the closed alpha: your balance is comped, so there is no checkout and no billing portal. You'll be told before that changes.", - }); - } - // A doomed forward is not more honest than a refusal, and it costs the user - // a raw upstream body they cannot read. Refuse before spending the token. - const unimplemented = platformUnimplemented(url.pathname); - if (unimplemented !== undefined) return json(501, { status: "error", message: unimplemented }); const token = await fetchCloudToken(env, gate.login); if (token.status !== "ok") { return json(503, { @@ -2397,29 +1961,16 @@ const handlePlatformProxy = async (request: Request, env: WorkerEnv, url: URL): if (accept !== null) headers.set("accept", accept); let upstream: Response; try { - upstream = await withDeadline( - "Smithers Cloud", - (signal) => - fetch(new URL(url.pathname + url.search, base).toString(), { - method: request.method, - headers, - signal, - ...(body === undefined ? {} : { body }), - }), - upstreamTimeoutMs(env), - ); + upstream = await fetch(new URL(url.pathname + url.search, base).toString(), { + method: request.method, + headers, + ...(body === undefined ? {} : { body }), + }); } catch (error) { - return upstreamUnreachable("Smithers Cloud", error); - } - /* - * A failure never passes through: the upstream's body is written for its - * own callers, and the product renders whatever comes back straight to the - * user. Restate it in the seam's own envelope so a reader always gets a - * sentence, and the shape matches every other refusal this Worker makes. - */ - if (upstream.status >= 400) { - const detail = await upstream.text().catch(() => ""); - return json(upstream.status, { status: "error", message: platformFailureMessage(upstream.status, detail) }); + return json(502, { + status: "error", + message: `Smithers Cloud is unreachable: ${error instanceof Error ? error.message : "unknown error"}`, + }); } // Status and body pass through; upstream headers do not (no set-cookie, no // upstream CORS) — only the content type survives. @@ -2429,18 +1980,6 @@ const handlePlatformProxy = async (request: Request, env: WorkerEnv, url: URL): return new Response(upstream.body, { status: upstream.status, headers: out }); }; -const START_SESSION_HEADER = "x-smithers-start-session"; - -/** Replace any client-supplied value with the session resolved for the Start branch. */ -export const withStartSessionHandoff = async (request: Request, sessionResponse: Response): Promise<Request> => { - const sessionEnvelope = encodeURIComponent( - JSON.stringify({ status: sessionResponse.status, body: await sessionResponse.text() }), - ); - const startHeaders = new Headers(request.headers); - startHeaders.set(START_SESSION_HEADER, sessionEnvelope); - return new Request(request, { headers: startHeaders }); -}; - export default { async fetch(request: Request, env: WorkerEnv): Promise<Response> { const url = new URL(request.url); @@ -2459,21 +1998,12 @@ export default { if (refusal instanceof Response) return refusal; return handleCancel(request, env); } - // The two routes that spend a model credential. Both gate on the - // session first and then on the login's turn ceiling, so a refusal - // costs one Durable Object read and never reaches an upstream. The - // cancel route above is deliberately unlimited: killing a turn must - // always work, and it spends nothing. if (url.pathname === TURN_PATH) { if (request.method !== "POST") { return json(405, { status: "error", message: "Method not allowed." }); } const gate = await requireTurnSession(request, env); if (gate instanceof Response) return gate; - if (gate !== undefined) { - const budget = await spendTurn(env.TURN_LIMITS, gate.login); - if (!budget.allowed) return turnLimitResponse(budget, ISOLATION_HEADERS); - } return handleTurn(request, env, gate); } if (url.pathname === MODEL_STREAM_PATH) { @@ -2482,11 +2012,7 @@ export default { } const gate = await requireTurnSession(request, env); if (gate instanceof Response) return gate; - if (gate !== undefined) { - const budget = await spendTurn(env.TURN_LIMITS, gate.login); - if (!budget.allowed) return turnLimitResponse(budget, ISOLATION_HEADERS); - } - return handleModelStream(request, env, gate); + return handleModelStream(request, env); } if (url.pathname === APPROVAL_DECISION_PATH) { if (request.method !== "POST") { @@ -2539,7 +2065,7 @@ export default { return proxyToIdentity(request, env); } if (url.pathname === CLIENT_ERRORS_PATH && request.method === "POST") { - return handleClientError(request, env); + return handleClientError(request); } if (platformProxyMatch(url.pathname, request.method)) { return handlePlatformProxy(request, env, url); @@ -2557,20 +2083,6 @@ export default { // Any other /api/* path is an unknown route: the same canonical 404 the // admin surface answers non-admins with, so nothing is enumerable. if (url.pathname.startsWith("/api/")) return notFound(); - /* - * Vite replaces this guarded import in the Cloudflare Start build. Bun's - * unit tests and a plain Wrangler invocation retain the asset fallback, - * so the API Worker remains importable without Start's virtual manifest. - */ - if (typeof __SMITHERS_START__ !== "undefined" && __SMITHERS_START__) { - const { default: start } = await import("@tanstack/react-start/server-entry"); - // Resolve once at the trusted boundary; the server function reads this - // request header, and a client-supplied value is always overwritten here. - const sessionRequest = new Request(new URL(AUTH_SESSION_PATH, request.url), request); - const sessionResponse = await probeAuthSession(sessionRequest, env); - const response = await start.fetch(await withStartSessionHandoff(request, sessionResponse)); - return withIsolationHeaders(response); - } return withIsolationHeaders(await env.ASSETS.fetch(request)); }, }; diff --git a/apps/server/src/invite-mechanics.test.ts b/apps/server/src/invite-mechanics.test.ts index 06d4e02d..909fb3c1 100644 --- a/apps/server/src/invite-mechanics.test.ts +++ b/apps/server/src/invite-mechanics.test.ts @@ -145,15 +145,10 @@ describe("invite mechanics: request-access -> admin approval -> allowlist gate", }; test("the full path: request-access queues the login, admin approval allowlists it, and the same session then passes the gate", async () => { - // The admin is allowlisted, as every real admin is: the surface requires - // BOTH claims now, so de-allowlisting one revokes it (repro access/1.5). - const double = identityDouble( - { - "admin-session": { login: "will", admin: true }, - "stranger-session": { login: "octocat", admin: false }, - }, - ["will"], - ); + const double = identityDouble({ + "admin-session": { login: "will", admin: true }, + "stranger-session": { login: "octocat", admin: false }, + }); await withDouble(double, async () => { // 1. octocat is signed in but not allowlisted: a gated route refuses them. @@ -212,76 +207,10 @@ describe("invite mechanics: request-access -> admin approval -> allowlist gate", }); }); - /* - * Repro apps/ui/canary-repros/access/1.5: removing a login from the - * allowlist has to revoke the ADMIN surface too. It did not — `admin` rides - * ADMIN_LOGINS, so a revoked admin kept every /api/admin/* route, the - * allowlist editor included, and could simply put itself back. - */ - /* - * The other half of that rule: because the allowlist is what carries admin, - * a self-removal is a ONE-WAY door — it revokes the caller's own claim, and - * this route is the only door that could restore it. The first admin to try - * would lock the alpha out of its own product. - */ - test("an admin cannot remove its own login, and the refusal names the route that works", async () => { - const double = identityDouble({ "admin-session": { login: "will", admin: true } }, ["will"]); - - await withDouble(double, async () => { - for (const login of ["will", "WILL"]) { - const selfRemove = await worker.fetch( - post("/api/admin/allowlist", { login, action: "remove" }, { cookie: "smithers_session=admin-session" }), - env, - ); - expect(selfRemove.status).toBe(409); - const body = (await selfRemove.json()) as { message: string }; - expect(body.message).toContain("your own login"); - expect(body.message).toContain("admin token"); - expect(double.allowlist.has("will")).toBe(true); - } - // Removing SOMEONE ELSE is still the ordinary admin write. - double.allowlist.add("octocat"); - const other = await worker.fetch( - post("/api/admin/allowlist", { login: "octocat", action: "remove" }, { cookie: "smithers_session=admin-session" }), - env, - ); - expect(other.status).toBe(201); - expect(double.allowlist.has("octocat")).toBe(false); - }); - }); - - test("removing an admin from the allowlist revokes the admin surface, editor first", async () => { - const double = identityDouble({ "admin-session": { login: "will", admin: true } }, ["will"]); - - await withDouble(double, async () => { - expect( - (await worker.fetch(get("/api/admin/requests", { cookie: "smithers_session=admin-session" }), env)).status, - ).toBe(200); - - double.allowlist.delete("will"); - - const unknown = await worker.fetch(get("/api/definitely-not-a-route"), env); - const canonical = await unknown.text(); - const afterRead = await worker.fetch( - get("/api/admin/requests", { cookie: "smithers_session=admin-session" }), - env, - ); - expect(afterRead.status).toBe(404); - expect(await afterRead.text()).toBe(canonical); - - const selfRestore = await worker.fetch( - post("/api/admin/allowlist", { login: "will", action: "add" }, { cookie: "smithers_session=admin-session" }), - env, - ); - expect(selfRestore.status).toBe(404); - expect(double.allowlist.has("will")).toBe(false); - }); - }); - test("removing a login from the allowlist revokes the gate immediately", async () => { const double = identityDouble( { "admin-session": { login: "will", admin: true }, "member-session": { login: "octocat", admin: false } }, - ["octocat", "will"], + ["octocat"], ); await withDouble(double, async () => { diff --git a/apps/server/src/turnLimit.test.ts b/apps/server/src/turnLimit.test.ts deleted file mode 100644 index e7eb72f4..00000000 --- a/apps/server/src/turnLimit.test.ts +++ /dev/null @@ -1,270 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import worker from "./index"; -import type { WorkerEnv } from "./index"; -import { - spendTurn, - TURN_WINDOW_MAX, - TURN_WINDOW_MS, - TurnRateLimiter, - turnLimitResponse, -} from "./turnLimit"; -import type { TurnBudget, TurnLimitNamespace, TurnLimitStorage } from "./turnLimit"; - -/* - * The per-login turn ceiling. Chat is comped during the alpha, so the balance - * is not a spend limit and nothing else bounds what one session can cost. These - * tests hold the ceiling to being an ABUSE guard: it must be invisible to a - * person, it must not read like a paywall when it does fire, and it must never - * lock someone out because our own infrastructure hiccuped. - */ - -const memoryStorage = (seed?: Record<string, unknown>): TurnLimitStorage => { - const data = new Map<string, unknown>(Object.entries(seed ?? {})); - return { - get: async (key) => data.get(key) as never, - put: async (key, value) => void data.set(key, value), - }; -}; - -/** - * A namespace of in-memory buckets. `spent` names logins whose budget is - * already exhausted — seeding the window is how a route test reaches the - * refusal without driving `TURN_WINDOW_MAX` real turns through the seam, - * which would make the suite slower every time the ceiling rises. - */ -const memoryLimits = ( - spent: ReadonlyArray<string> = [], -): TurnLimitNamespace & { readonly logins: () => Array<string> } => { - const buckets = new Map<string, TurnRateLimiter>(); - const bucketFor = (name: string): TurnRateLimiter => { - let bucket = buckets.get(name); - if (bucket === undefined) { - bucket = new TurnRateLimiter({ - storage: spent.includes(name) - ? memoryStorage({ window: { start: Date.now(), count: TURN_WINDOW_MAX } }) - : memoryStorage(), - }); - buckets.set(name, bucket); - } - return bucket; - }; - return { - logins: () => [...buckets.keys()], - idFromName: (name) => name, - get: (id) => ({ fetch: (request) => bucketFor(String(id)).fetch(request) }), - }; -}; - -const spend = async (limiter: TurnRateLimiter): Promise<TurnBudget> => { - const response = await limiter.fetch(new Request("https://turn-limit.internal/spend", { method: "POST" })); - return (await response.json()) as TurnBudget; -}; - -describe("the per-login turn ceiling (Durable Object state)", () => { - test("admits every turn up to the ceiling and counts down honestly", async () => { - const limiter = new TurnRateLimiter({ storage: memoryStorage() }); - const first = await spend(limiter); - expect(first.allowed).toBe(true); - expect(first.remaining).toBe(TURN_WINDOW_MAX - 1); - - for (let turn = 2; turn <= TURN_WINDOW_MAX; turn += 1) { - const budget = await spend(limiter); - expect(budget.allowed).toBe(true); - expect(budget.remaining).toBe(TURN_WINDOW_MAX - turn); - } - const over = await spend(limiter); - expect(over.allowed).toBe(false); - expect(over.remaining).toBe(0); - expect(typeof over.retryAt).toBe("number"); - }); - - test("a refused turn does not push its own reset further away", async () => { - const opened = Date.now() - 30 * 60 * 1000; - const storage = memoryStorage({ window: { start: opened, count: TURN_WINDOW_MAX } }); - const limiter = new TurnRateLimiter({ storage }); - const first = await spend(limiter); - const second = await spend(limiter); - expect(first.allowed).toBe(false); - expect(second.retryAt).toBe(first.retryAt); - expect(first.retryAt).toBe(opened + TURN_WINDOW_MS); - }); - - test("a window older than the budget period starts a fresh one", async () => { - const storage = memoryStorage({ - window: { start: Date.now() - TURN_WINDOW_MS - 1, count: TURN_WINDOW_MAX }, - }); - const budget = await spend(new TurnRateLimiter({ storage })); - expect(budget.allowed).toBe(true); - expect(budget.remaining).toBe(TURN_WINDOW_MAX - 1); - }); - - test("peek reports the state without spending anything", async () => { - const limiter = new TurnRateLimiter({ storage: memoryStorage() }); - await spend(limiter); - const peek = async (): Promise<TurnBudget> => - (await (await limiter.fetch(new Request("https://turn-limit.internal/peek"))).json()) as TurnBudget; - expect((await peek()).remaining).toBe(TURN_WINDOW_MAX - 1); - expect((await peek()).remaining).toBe(TURN_WINDOW_MAX - 1); - }); - - test("with no namespace bound the ceiling fails open", async () => { - const budget = await spendTurn(undefined, "will"); - expect(budget.allowed).toBe(true); - }); - - test("an unreadable answer from our own Durable Object admits the turn", async () => { - const broken: TurnLimitNamespace = { - idFromName: (name) => name, - get: () => ({ fetch: async () => new Response("not json at all", { status: 500 }) }), - }; - expect((await spendTurn(broken, "will")).allowed).toBe(true); - }); - - test("each login has its own budget", async () => { - const limits = memoryLimits(); - for (let turn = 0; turn < TURN_WINDOW_MAX; turn += 1) await spendTurn(limits, "will"); - expect((await spendTurn(limits, "will")).allowed).toBe(false); - expect((await spendTurn(limits, "someone-else")).allowed).toBe(true); - }); - - test("the refusal reads as a bug report, not a bill", () => { - const response = turnLimitResponse({ allowed: false, remaining: 0, retryAt: Date.now() + 600_000 }, {}); - expect(response.status).toBe(429); - expect(Number(response.headers.get("retry-after"))).toBeGreaterThan(0); - }); - - test("the refusal never sends the user to billing", async () => { - const response = turnLimitResponse({ allowed: false, remaining: 0, retryAt: Date.now() + 600_000 }, {}); - const body = (await response.json()) as { message: string; code: string }; - expect(body.code).toBe("turn_rate_limited"); - expect(body.message).toContain("looping"); - expect(body.message).toContain("balance is untouched"); - for (const word of ["upgrade", "billing", "pay", "plan", "$"]) { - expect(body.message.toLowerCase()).not.toContain(word); - } - }); -}); - -/* - * The routes. A ceiling that let the upstream call happen first would not save - * a dollar, so what matters is that a refusal costs nothing beyond one - * Durable Object read. - */ -describe("the turn routes under the ceiling", () => { - const identityEnv = (limits: TurnLimitNamespace): WorkerEnv => ({ - ASSETS: { fetch: async () => new Response("<html></html>", { status: 200 }) }, - IDENTITY_UPSTREAM_URL: "https://identity.test", - SMITHERS_CHAT_URL: "https://upstream.test/chat", - TURN_LIMITS: limits, - }); - - const signedIn = (path: string, runId: string): Request => - new Request(`https://mvp.test${path}`, { - method: "POST", - headers: { "content-type": "application/json", cookie: "smithers_session=abc" }, - body: JSON.stringify({ runId, messages: [{ role: "user", content: "hi" }], instructions: "Be brief." }), - }); - - const withStubbedSeams = async (run: (upstreamCalls: () => number) => Promise<void>): Promise<void> => { - const original = globalThis.fetch; - let calls = 0; - globalThis.fetch = (async (input: unknown, init?: RequestInit) => { - const request = typeof input === "string" ? new Request(input, init) : (input as Request); - if (new URL(request.url).hostname === "identity.test") { - return new Response(JSON.stringify({ login: "will", allowlisted: true }), { - status: 200, - headers: { "content-type": "application/json" }, - }); - } - calls += 1; - return new Response('{"type":"done"}\n', { status: 200, headers: { "content-type": "application/x-ndjson" } }); - }) as typeof fetch; - try { - await run(() => calls); - } finally { - globalThis.fetch = original; - } - }; - - test("a spent budget refuses the turn with 429 before any credential is spent", async () => { - // A run id may be registered only once; the in-isolate cancel registry is - // module state, so each test gets its own namespace. - const lane = "spent-budget"; - const env = identityEnv(memoryLimits(["will"])); - await withStubbedSeams(async (upstreamCalls) => { - const refused = await worker.fetch(signedIn("/api/agent/turn", `${lane}-over`), env); - expect(refused.status).toBe(429); - // The whole point: nothing reached the upstream, so nothing was spent. - expect(upstreamCalls()).toBe(0); - expect(refused.headers.get("retry-after")).not.toBeNull(); - }); - }); - - test("a budget with room admits the turn", async () => { - const env = identityEnv(memoryLimits()); - await withStubbedSeams(async (upstreamCalls) => { - const ok = await worker.fetch(signedIn("/api/agent/turn", "with-room-1"), env); - expect(ok.status).toBe(200); - expect(upstreamCalls()).toBe(1); - }); - }); - - test("the model-stream route shares the same budget", async () => { - // A run id may be registered only once; the in-isolate cancel registry is - // module state, so each test gets its own namespace. - const lane = "model-stream"; - const env = identityEnv(memoryLimits(["will"])); - await withStubbedSeams(async () => { - const refused = await worker.fetch(signedIn("/api/model/stream", `${lane}-stream`), env); - expect(refused.status).toBe(429); - }); - }); - - test("the budget is keyed by the validated login, never by anything a client sends", async () => { - // A run id may be registered only once; the in-isolate cancel registry is - // module state, so each test gets its own namespace. - const lane = "keyed-by-login"; - const limits = memoryLimits(); - const env = identityEnv(limits); - await withStubbedSeams(async () => { - await worker.fetch(signedIn("/api/agent/turn", `${lane}-1`), env); - }); - expect(limits.logins()).toEqual(["will"]); - }); - - test("killing a turn is never rate limited", async () => { - // A run id may be registered only once; the in-isolate cancel registry is - // module state, so each test gets its own namespace. - const lane = "cancel-unlimited"; - const env = identityEnv(memoryLimits(["will"])); - await withStubbedSeams(async () => { - // The budget is already spent, so a turn here would be refused. - expect((await worker.fetch(signedIn("/api/agent/turn", `${lane}-turn`), env)).status).toBe(429); - const cancel = await worker.fetch(signedIn("/api/agent/turn/cancel", `${lane}-1`), env); - expect(cancel.status).not.toBe(429); - }); - }); - - test("an ordinary hour of chat never reaches the ceiling", async () => { - // The guard is worthless if it fires on a real person. Sixty messages is - // a heavy hour of conversation, and the browser chain authors several - // links for each, so the ceiling has to clear sixty times a handful — it - // sits at a thousand. - // A run id may be registered only once; the in-isolate cancel registry is - // module state, so each test gets its own namespace. - const lane = "ordinary-hour"; - const limits = memoryLimits(); - const env = identityEnv(limits); - await withStubbedSeams(async () => { - for (let turn = 0; turn < 60; turn += 1) { - const response = await worker.fetch(signedIn("/api/agent/turn", `${lane}-${turn}`), env); - expect(response.status).toBe(200); - // The chain's links for that message spend from the same budget. - for (let link = 0; link < 8; link += 1) { - const authored = await worker.fetch(signedIn("/api/model/stream", `${lane}-${turn}-${link}`), env); - expect(authored.status).toBe(200); - } - } - }); - }); -}); diff --git a/apps/server/src/turnLimit.ts b/apps/server/src/turnLimit.ts deleted file mode 100644 index 0def2ad1..00000000 --- a/apps/server/src/turnLimit.ts +++ /dev/null @@ -1,153 +0,0 @@ -/** - * A per-login ceiling on model calls, because every one of them spends model - * dollars. - * - * The unit is ONE CALL TO A MODEL-SPENDING ROUTE, not one thing the user typed. - * That distinction became load-bearing when the browser Agent Chain became the - * only chat backend: the loop runs in the page and authors a fresh link over - * `/api/model/stream` for each step of a turn, bounded at 32 links, so one - * message can spend many units where the old server-side turn spent exactly - * one. The ceiling below is sized in those units. - * - * Chat is complimentary during the alpha (DESIGN.md §1): a $0 balance never - * pauses the composer, and the zero-balance guard in the client covers workflow - * launch, not chat. That is a deliberate product decision and this module does - * not touch it. What it adds is the thing a comped seam has no other defence - * against: a runaway client, a stuck retry loop, or a lifted session cookie can - * post turns as fast as the network allows, and the first anyone would know is - * the invoice. - * - * So this is an ABUSE ceiling, not a billing pause. It sits far above what a - * person chatting hard reaches in an hour, and its refusal says so — an alpha - * user who trips it has hit a bug, not a paywall, and must never be told to go - * buy something. - * - * The state is one Durable Object per login, keyed by the validated login only: - * a client cannot name its own bucket. Fixed windows, not a rolling log — the - * ceiling is loose enough that the boundary effect (up to 2x across a window - * edge) does not matter, and one counter is far cheaper than a timestamp list. - */ - -/** - * Model calls one login may start per window. - * - * A heavy hour of conversation is about sixty messages, and a chain turn - * authors a handful of links for each — so sixteen calls a message is already a - * pessimistic reading of a hard hour. A thousand keeps the same ten-times - * headroom the ceiling has always had, and still stops a lifted cookie posting - * as fast as the network allows: at the alpha's rate card a spent window is - * about a dollar, which is a bug someone notices rather than an invoice nobody - * saw coming. - */ -export const TURN_WINDOW_MAX = 1000; - -/** The window the ceiling applies over. */ -export const TURN_WINDOW_MS = 60 * 60 * 1000; - -export interface TurnLimitStorage { - readonly get: <T>(key: string) => Promise<T | undefined>; - readonly put: (key: string, value: unknown) => Promise<void>; -} - -export interface TurnLimitStub { - readonly fetch: (request: Request) => Promise<Response>; -} - -export interface TurnLimitNamespace { - readonly idFromName: (name: string) => unknown; - readonly get: (id: unknown) => TurnLimitStub; -} - -interface TurnLimitWindow { - /** When the current window opened. */ - readonly start: number; - /** Turns admitted since it opened. */ - readonly count: number; -} - -const WINDOW_KEY = "window"; - -/** What a spend check answered. `retryAt` is set only when refused. */ -export interface TurnBudget { - readonly allowed: boolean; - readonly remaining: number; - readonly retryAt?: number; -} - -export class TurnRateLimiter { - constructor(private readonly ctx: { readonly storage: TurnLimitStorage }) {} - - async fetch(request: Request): Promise<Response> { - const now = Date.now(); - const stored = await this.ctx.storage.get<TurnLimitWindow>(WINDOW_KEY); - const open = stored !== undefined && now - stored.start < TURN_WINDOW_MS ? stored : { start: now, count: 0 }; - const answer = (body: TurnBudget): Response => - new Response(JSON.stringify(body), { headers: { "content-type": "application/json" } }); - - switch (new URL(request.url).pathname) { - case "/spend": { - if (open.count >= TURN_WINDOW_MAX) { - // Refused turns do not extend the window: a client that keeps - // hammering cannot push its own reset further away. - return answer({ allowed: false, remaining: 0, retryAt: open.start + TURN_WINDOW_MS }); - } - const next = { start: open.start, count: open.count + 1 }; - await this.ctx.storage.put(WINDOW_KEY, next); - return answer({ allowed: true, remaining: TURN_WINDOW_MAX - next.count }); - } - case "/peek": - return answer({ - allowed: open.count < TURN_WINDOW_MAX, - remaining: Math.max(0, TURN_WINDOW_MAX - open.count), - ...(open.count >= TURN_WINDOW_MAX ? { retryAt: open.start + TURN_WINDOW_MS } : {}), - }); - default: - return new Response("not found", { status: 404 }); - } - } -} - -/** - * Spend one turn from `login`'s budget. - * - * Fails OPEN when no namespace is bound. A deployment without the binding is - * local dev or a stub stack, where there is no real model credential to - * protect; refusing every turn there would break the e2e suites to guard - * nothing. The binding is declared in `wrangler.jsonc`, so the deployed Worker - * always has it. - */ -export const spendTurn = async ( - limits: TurnLimitNamespace | undefined, - login: string, -): Promise<TurnBudget> => { - if (limits === undefined) return { allowed: true, remaining: TURN_WINDOW_MAX }; - const stub = limits.get(limits.idFromName(login)); - const response = await stub.fetch(new Request("https://turn-limit.internal/spend", { method: "POST" })); - const budget = (await response.json().catch(() => undefined)) as TurnBudget | undefined; - // An unreadable answer from our own Durable Object is an infrastructure - // fault, not a signal about this user: admit the turn and let it be seen in - // the logs rather than locking a real person out of the alpha. - return budget ?? { allowed: true, remaining: TURN_WINDOW_MAX }; -}; - -/** The refusal a spent budget answers with: a bug report, never a sales pitch. */ -export const turnLimitResponse = (budget: TurnBudget, isolationHeaders: Record<string, string>): Response => { - const retryAt = budget.retryAt ?? Date.now() + TURN_WINDOW_MS; - const seconds = Math.max(1, Math.ceil((retryAt - Date.now()) / 1000)); - return new Response( - JSON.stringify({ - status: "error", - code: "turn_rate_limited", - message: `That is more than ${TURN_WINDOW_MAX} model calls in an hour, which no conversation reaches by hand — something is looping. Chat resumes on its own in about ${Math.ceil(seconds / 60)} minutes. Nothing was charged and your balance is untouched.`, - retryAt: new Date(retryAt).toISOString(), - }), - { - status: 429, - headers: { - "content-type": "application/json", - "retry-after": String(seconds), - ...isolationHeaders, - }, - }, - ); -}; diff --git a/apps/server/tsconfig.json b/apps/server/tsconfig.json index 03bfcc45..fd21306d 100644 --- a/apps/server/tsconfig.json +++ b/apps/server/tsconfig.json @@ -15,9 +15,6 @@ "noUnusedParameters": true, "noFallthroughCasesInSwitch": true }, - // scripts/ is typechecked too: the deploy script and the canary probes are - // the only things that verify a deployment, and an untypechecked verifier - // is not one (scripts/seed-allowlist.mjs is JavaScript and is not matched). - "include": ["src", "scripts"], + "include": ["src"], "exclude": ["node_modules", "dist"] } diff --git a/apps/server/wrangler.jsonc b/apps/server/wrangler.jsonc index e3f92bcb..e9564bf0 100644 --- a/apps/server/wrangler.jsonc +++ b/apps/server/wrangler.jsonc @@ -2,7 +2,6 @@ "name": "smithers-mvp-web", "main": "src/index.ts", "compatibility_date": "2026-08-01", - "compatibility_flags": ["nodejs_compat"], // Wave 7: this Worker IS the canary. The custom domain was repointed from // the flows/ui POC worker `smithers-ui-canary` (rollback: redeploy that // worker from ~/flows/ui — `bun x wrangler deploy` — and the domain @@ -24,21 +23,12 @@ "bindings": [ { "name": "TURN_CANCELS", "class_name": "TurnCancelRegistry" }, // Wave 11: per-user gateway session records (relay tokens, server-side only), keyed by login. - { "name": "GATEWAY_SESSIONS", "class_name": "GatewaySessionRegistry" }, - // A per-login ceiling on turns: an abuse guard on a seam that is - // comped by design, so a runaway client or a lifted session cookie - // cannot spend the alpha budget unobserved (src/turnLimit.ts). - { "name": "TURN_LIMITS", "class_name": "TurnRateLimiter" }, - // The bounded client-error log, read back at GET /api/admin/errors. - // Without it a browser crash lives only in a wrangler tail nobody - // is watching (src/clientErrorLog.ts). - { "name": "CLIENT_ERRORS", "class_name": "ClientErrorLog" } + { "name": "GATEWAY_SESSIONS", "class_name": "GatewaySessionRegistry" } ] }, "migrations": [ { "tag": "v1", "new_sqlite_classes": ["TurnCancelRegistry"] }, - { "tag": "v2", "new_sqlite_classes": ["GatewaySessionRegistry"] }, - { "tag": "v3", "new_sqlite_classes": ["TurnRateLimiter", "ClientErrorLog"] } + { "tag": "v2", "new_sqlite_classes": ["GatewaySessionRegistry"] } ], "vars": { // Wave 7 canary upstreams. identity/reco live on workers.dev until will @@ -55,14 +45,6 @@ // touching its config, which wave 7 is not authorized to do. "SMITHERS_CHAT_URL": "https://smithers-cloud-chat-canary.willcory10.workers.dev/chat", "SMITHERS_CHAT_ORIGIN": "https://canary.smithers.sh" - // Optional knobs, both unset on canary (the defaults are the alpha's): - // UPSTREAM_TIMEOUT_MS — how long any one upstream gets to answer, in ms - // (default 20000). Bounds the wait for HEADERS only, so a - // streaming answer is never cut off. Raise it behind a slow - // sibling rather than letting a route hang. - // BILLING_CHECKOUT_ENABLED — "1" opens POST /api/billing/checkout and /portal. Unset is - // the closed-alpha state: every balance is comped, so both - // answer an honest refusal instead of reaching Stripe. // Backend seams. The three upstream URLs above are SET on canary; the // rest are Cloudflare secrets (`wrangler secret put`), not vars, and an // unset one makes its route answer an honest 501: diff --git a/apps/shared/BUILD.bazel b/apps/shared/BUILD.bazel new file mode 100644 index 00000000..fd51eae0 --- /dev/null +++ b/apps/shared/BUILD.bazel @@ -0,0 +1,18 @@ +load("@aspect_rules_js//npm:defs.bzl", "npm_package") +load("@aspect_rules_ts//ts:defs.bzl", "ts_config") +load("@npm//:defs.bzl", "npm_link_all_packages") + +npm_link_all_packages(name = "node_modules") + +npm_package( + name = "pkg", + srcs = ["package.json"], + visibility = ["//:__pkg__"], +) + +ts_config( + name = "tsconfig", + src = "tsconfig.json", + visibility = [":__subpackages__"], + deps = [":package.json"], +) diff --git a/apps/shared/BUILD.ts b/apps/shared/BUILD.ts deleted file mode 100644 index f8551bb2..00000000 --- a/apps/shared/BUILD.ts +++ /dev/null @@ -1,45 +0,0 @@ -/** - * Targets for the shared agent contract: the typecheck and the unit suite. - * - * Both apps import this package, so its gates run in the same pipeline job as - * theirs. The suite runs under Bun, which is what the apps' own scripts use, so - * the runtime is the root Bun declaration and nothing here spells `bun` into an - * argv. - */ -import { Smithers } from "@smthrs/targets" -import { bunRuntime, packageManager } from "../../BUILD.ts" - -const cwd = "apps/shared" - -/** The contract sources both apps import. */ -const sources = Smithers.glob("//apps/shared/src/**/*.ts") - -/** - * Checks the contract against its own tsconfig. - * - * @since 0.1.0 - * @category build - */ -export const check = Smithers.Typecheck({ - packageManager, - srcs: [sources], - deps: [], - tsconfig: Smithers.file("tsconfig.json"), - buildMode: false, - incremental: false, - cwd -}) - -/** - * The unit suite: everything under `src/`. - * - * @since 0.1.0 - * @category test - */ -export const unitTests = Smithers.NodeTest({ - runtime: bunRuntime, - runner: Smithers.testSuite(["src"]), - srcs: [sources], - deps: [], - cwd -}) diff --git a/apps/shared/package.json b/apps/shared/package.json index cb998639..fbc1ad7e 100644 --- a/apps/shared/package.json +++ b/apps/shared/package.json @@ -12,7 +12,6 @@ } }, "scripts": { - "check": "tsc --noEmit", "typecheck": "tsc --noEmit", "test": "bun test src" }, diff --git a/apps/shared/src/AgentApiRoutes.ts b/apps/shared/src/AgentApiRoutes.ts index 8d155c39..40ff3fee 100644 --- a/apps/shared/src/AgentApiRoutes.ts +++ b/apps/shared/src/AgentApiRoutes.ts @@ -75,11 +75,3 @@ export const ADMIN_GRANT_PATH = "/api/admin/grant"; export const ADMIN_REQUESTS_PATH = "/api/admin/requests"; export const ADMIN_FEEDBACK_PATH = "/api/admin/feedback"; export const ADMIN_HEALTH_PATH = "/api/admin/health"; -/** The bounded client-error log: what actually broke in an alpha user's browser. */ -export const ADMIN_ERRORS_PATH = "/api/admin/errors"; -/** - * Lift one login's recommendation dismissals. A dismissal suppresses its - * recommendation for seven days, which makes the launch checklist poison - * itself: its A-9 row dismisses a card by design. This is the reset. - */ -export const ADMIN_RECO_DISMISSALS_PATH = "/api/admin/reco-dismissals"; diff --git a/apps/shared/src/AgentContext.test.ts b/apps/shared/src/AgentContext.test.ts index 932ccc68..b24b6611 100644 --- a/apps/shared/src/AgentContext.test.ts +++ b/apps/shared/src/AgentContext.test.ts @@ -73,56 +73,11 @@ describe("renderAgentRuntimeContext", () => { }), ); expect(populated).toContain('local-repository "smithers" (connected, read-write access) at /Users/will/smithers, branch main'); - expect(populated).toContain("World state: 2 document(s)"); + expect(populated).toContain("World state: 2 document(s):"); expect(populated).toContain('Roadmap.md — "Roadmap" (confidence 0.6)'); expect(populated).toContain('world document open: "Roadmap.md"'); }); - /* - * §10.8: a note holding a fact recorded nowhere else was invisible to the - * model — the block carried paths, titles and confidences and never a word - * the user wrote. The World pane calls itself "what Smithers currently - * understands", so the notes' own text is the substance of that claim. - */ - test("a world note's own words are in the block, marked when the budget cut them", () => { - const rendered = renderAgentRuntimeContext( - contextFixture({ - worldState: { - documentCount: 3, - documents: [ - { - path: "Glossary.md", - title: "Glossary", - confidence: 1, - body: "The canary codeword for this workspace is zarquon-mimsy-7741.", - }, - { path: "Long.md", title: "Long", confidence: 1, body: "the head of it", bodyTruncated: true }, - { path: "Dropped.md", title: "Dropped", confidence: 1, body: "", bodyTruncated: true }, - ], - }, - }), - ); - expect(rendered).toContain("zarquon-mimsy-7741"); - // A cut note says it was cut, so the model never reads silence as "empty". - expect(rendered).toContain("note truncated here"); - expect(rendered).toContain("did not fit this turn's context budget"); - // And the block says plainly that the notes are the answer. - expect(rendered).toContain("These notes ARE what Smithers understands"); - }); - - test("a note-less document list still renders, so an older client is not broken by the new field", () => { - const rendered = renderAgentRuntimeContext( - contextFixture({ - worldState: { - documentCount: 1, - documents: [{ path: "Notes.md", title: "Notes", confidence: 1 }], - }, - }), - ); - expect(rendered).toContain('Notes.md — "Notes" (confidence 1)'); - expect(rendered).not.toContain("truncated"); - }); - test("carries the honest capabilities and limitations verbatim", () => { const rendered = renderAgentRuntimeContext(contextFixture()); expect(rendered).toContain("Hold a streaming conversation in this chat"); diff --git a/apps/shared/src/AgentContext.ts b/apps/shared/src/AgentContext.ts index 680cdd3b..b9629e70 100644 --- a/apps/shared/src/AgentContext.ts +++ b/apps/shared/src/AgentContext.ts @@ -27,17 +27,6 @@ export const AgentRuntimeWorldDocumentSchema = z.object({ path: z.string(), title: z.string(), confidence: z.number(), - /* - * §10.8: the note's own words. Metadata alone made the World decorative — - * a note recording a fact nowhere else was invisible to the model, which - * answered "I can't retrieve that" about content the pane calls "what - * Smithers currently understands". Optional, because the client budgets - * how much body text rides a turn and a boundary built before this field - * must still validate the payload. - */ - body: z.string().optional(), - /** True when `body` is the head of a longer note the budget cut. */ - bodyTruncated: z.boolean().optional(), }); export type AgentRuntimeWorldDocument = z.infer<typeof AgentRuntimeWorldDocumentSchema>; @@ -49,7 +38,7 @@ export const AgentRuntimeContextSchema = z.object({ // the server boundary rather than be rejected here. capturedAt: z.number().int().min(0).max(8_640_000_000_000_000), revision: z.number().int().nonnegative(), - surface: z.enum(["chat", "world", "connectors", "github", "files"]), + surface: z.enum(["chat", "world", "connectors"]), theme: z.enum(["light", "dark"]), selectedWorldDocument: z.string().nullable(), connectors: z.array(AgentRuntimeConnectorSchema), @@ -64,29 +53,7 @@ export const AgentRuntimeContextSchema = z.object({ connected: z.boolean(), login: z.string().nullable(), watchedRepos: z.union([z.number().int().nonnegative(), z.literal("unselected")]).nullable(), - /* - * The chosen repositories BY NAME. A count alone left the model - * declining to answer "what repos do you watch?" while the names were - * served plainly by the seam it was already reading (§22.7). Optional so - * a boundary built before this field still validates the payload. - */ - watchedRepoNames: z.array(z.string()).optional(), }), - /* - * The account's own money, as the client already holds it. Asked "what is my - * balance right now?", the model answered "$0.00" one line above a card its - * own tool call had just rendered reading "$519 left" — it had no figure in - * context and confabulated one (§22.7). Optional for the same reason. - */ - billing: z - .object({ - state: z.string(), - totalUsd: z.string().nullable(), - lifetimeChargedUsd: z.string().nullable(), - chargeCount: z.number().int().nonnegative(), - }) - .nullable() - .optional(), worldState: z.object({ documentCount: z.number().int().nonnegative(), documents: z.array(AgentRuntimeWorldDocumentSchema), @@ -150,45 +117,15 @@ export const renderAgentRuntimeContext = (context: AgentRuntimeContext): string lines.push( `- GitHub: CONNECTED as ${context.github.login ?? "a GitHub user"} (sign-in and the GitHub connector are one act) — ${watched}.`, ); - const names = context.github.watchedRepoNames ?? []; - if (names.length > 0) { - lines.push(` Watched repositories, by name: ${names.join(", ")}.`); - } } else { lines.push("- GitHub: not connected (no signed-in session)."); } - const billing = context.billing; - if (billing !== undefined && billing !== null) { - lines.push( - billing.state === "unavailable" || billing.state === "unknown" - ? `- Balance: the billing service did not answer (${billing.state}) — say so rather than naming a figure.` - : `- Balance: $${billing.totalUsd ?? "0"} left; $${billing.lifetimeChargedUsd ?? "0"} spent across ${billing.chargeCount} turn(s). This IS the number — never state a different one.`, - ); - } if (context.worldState.documentCount === 0) { lines.push("- World state: no documents yet."); } else { - lines.push( - `- World state: ${context.worldState.documentCount} document(s). These notes ARE what Smithers understands about this workspace — when the user asks about something a note records, answer from the note below, never from a repository read and never with "I can't retrieve that":`, - ); + lines.push(`- World state: ${context.worldState.documentCount} document(s):`); for (const document of context.worldState.documents) { lines.push(` - ${document.path} — "${document.title}" (confidence ${document.confidence})`); - if (document.body === undefined) continue; - const body = document.body.trim(); - if (body === "") { - lines.push( - document.bodyTruncated === true - ? " | (this note's text did not fit this turn's context budget — read it in the World pane)" - : " (empty note)", - ); - continue; - } - // Indented under its own heading so a note's words cannot be read as - // an instruction line of this block. - for (const line of body.split("\n")) lines.push(` | ${line}`); - if (document.bodyTruncated === true) { - lines.push(" | … (note truncated here — read the rest in the World pane)"); - } } } lines.push("- Capabilities (what you can honestly do in this client):"); diff --git a/apps/shared/src/BrowserFetch.test.ts b/apps/shared/src/BrowserFetch.test.ts index fb78ee6e..bb70d207 100644 --- a/apps/shared/src/BrowserFetch.test.ts +++ b/apps/shared/src/BrowserFetch.test.ts @@ -134,26 +134,6 @@ describe("browserFetch guards", () => { if (!outcome.ok) expect(outcome.message).toContain("private"); }); - test("pins each request to the address approved for that redirect hop", async () => { - const connected: Array<string> = []; - const outcome = await browserFetch("https://example.com/", { - resolveHost: async (hostname) => hostname === "example.com" ? ["203.0.113.10"] : ["203.0.113.11"], - fetchImpl: async (_url, _init, address) => { - connected.push(address); - return connected.length === 1 - ? new Response(null, { status: 302, headers: { location: "https://next.example.com/" } }) - : okPage("<p>ok</p>"); - }, - }); - expect(outcome.ok).toBe(true); - expect(connected).toEqual(["203.0.113.10", "203.0.113.11"]); - }); - - test("fails closed instead of falling back to a second hostname lookup", async () => { - const outcome = await browserFetch("https://example.com/", { resolveHost: publicResolver }); - expect(outcome).toEqual({ ok: false, message: "Secure pinned egress is unavailable for the browser tool." }); - }); - test("a readable page returns text, the final URL, the status — and frameability", async () => { const outcome = await browserFetch("https://example.com/", { resolveHost: publicResolver, diff --git a/apps/shared/src/BrowserFetch.ts b/apps/shared/src/BrowserFetch.ts index 0a7fc588..6e31f6c2 100644 --- a/apps/shared/src/BrowserFetch.ts +++ b/apps/shared/src/BrowserFetch.ts @@ -123,7 +123,7 @@ export const isPublicAddress = (raw: string): boolean => { const guardTarget = async ( url: URL, resolveHost: ResolveHost, -): Promise<BrowserFetchFailure | { readonly addresses: ReadonlyArray<string> }> => { +): Promise<BrowserFetchFailure | undefined> => { if (url.protocol !== "https:") { return { ok: false, message: "Only https:// pages can be read." }; } @@ -135,7 +135,7 @@ const guardTarget = async ( if (!isPublicAddress(hostname)) { return { ok: false, message: "That address points at a private host, which the browser tool never reads." }; } - return { addresses: [normalizeIpLiteral(hostname)] }; + return undefined; } let addresses: ReadonlyArray<string>; try { @@ -151,7 +151,7 @@ const guardTarget = async ( return { ok: false, message: "That address resolves to a private host, which the browser tool never reads." }; } } - return { addresses }; + return undefined; }; /** Pull the readable text out of an HTML page: no scripts, no styles, no tags. */ @@ -234,12 +234,7 @@ const readCapped = async (body: ReadableStream<Uint8Array>): Promise<string> => export interface BrowserFetchDeps { readonly resolveHost: ResolveHost; - /** - * Connects to `address` while preserving the URL hostname for Host and TLS - * certificate/SNI verification. An ordinary hostname-based fetch is not a - * valid implementation because it would perform a second DNS lookup. - */ - readonly fetchImpl?: (input: string, init: RequestInit, address: string) => Promise<Response>; + readonly fetchImpl?: (input: string, init?: RequestInit) => Promise<Response>; readonly timeoutMs?: number; } @@ -254,20 +249,17 @@ export const browserFetch = async ( } catch { return { ok: false, message: "That is not a URL I can read." }; } + const http = deps.fetchImpl ?? fetch; const timeoutMs = deps.timeoutMs ?? BROWSER_FETCH_TIMEOUT_MS; let current = url; for (let hop = 0; hop <= MAX_REDIRECTS; hop += 1) { - const guarded = await guardTarget(current, deps.resolveHost); - if ("ok" in guarded) return guarded; - if (deps.fetchImpl === undefined) { - return { ok: false, message: "Secure pinned egress is unavailable for the browser tool." }; - } - const address = guarded.addresses[0]!; + const refused = await guardTarget(current, deps.resolveHost); + if (refused !== undefined) return refused; const timeout = AbortSignal.timeout(timeoutMs); let response: Response; try { - response = await deps.fetchImpl(current.toString(), { + response = await http(current.toString(), { method: "GET", redirect: "manual", signal: timeout, @@ -275,7 +267,7 @@ export const browserFetch = async ( "user-agent": "smithers-browser", accept: "text/html,application/xhtml+xml,text/plain,text/markdown;q=0.8,*/*;q=0.5", }, - }, address); + }); } catch (error) { const timedOut = timeout.aborted; return { diff --git a/apps/shared/src/Cards.ts b/apps/shared/src/Cards.ts index 3c890ba1..05109232 100644 --- a/apps/shared/src/Cards.ts +++ b/apps/shared/src/Cards.ts @@ -385,14 +385,6 @@ export const CardSchema = z.discriminatedUnion("kind", [ title: z.string(), state: z.string(), author: z.string().nullable(), - /* - * The comment count the PR row states, alongside number, title, - * state, author and updated time (will, 2026-08-19: the Pull - * Requests tab is "pretty close to a github clone"). Optional so - * rows persisted before the field parse without a schema reset, - * the same discipline the issue row's own additions follow. - */ - comments: z.number().int().nonnegative().optional(), updatedAt: z.string().nullable(), }), ), @@ -491,8 +483,6 @@ export const CardSchema = z.discriminatedUnion("kind", [ content: z.string(), /** True when the read was cut at the card cap; the full file stays upstream. */ truncated: z.boolean(), - /** Binary files are identified without rendering their encoded bytes. */ - binary: z.boolean().optional(), }), }), /* diff --git a/apps/tui/BUILD.bazel b/apps/tui/BUILD.bazel new file mode 100644 index 00000000..8634a485 --- /dev/null +++ b/apps/tui/BUILD.bazel @@ -0,0 +1,11 @@ +load("@aspect_rules_ts//ts:defs.bzl", "ts_config") +load("@npm//:defs.bzl", "npm_link_all_packages") + +npm_link_all_packages(name = "node_modules") + +ts_config( + name = "tsconfig", + src = "tsconfig.json", + visibility = [":__subpackages__"], + deps = [":package.json"], +) diff --git a/apps/tui/BUILD.ts b/apps/tui/BUILD.ts deleted file mode 100644 index 48bb00f8..00000000 --- a/apps/tui/BUILD.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** - * Targets for the terminal application: the typecheck and the unit suite. - * - * The suite runs under Bun, which is what the app's own scripts use, so the - * runtime is the root Bun declaration and nothing here spells `bun` into an - * argv. - */ -import { Smithers } from "@smthrs/targets" -import { bunRuntime, packageManager } from "../../BUILD.ts" - -const cwd = "apps/tui" - -/** The application sources both gates read. */ -const sources = [ - Smithers.glob("//apps/tui/src/**/*.ts"), - Smithers.glob("//apps/tui/src/**/*.tsx") -] - -/** - * Checks the application against its own tsconfig. - * - * @since 0.1.0 - * @category build - */ -export const check = Smithers.Typecheck({ - packageManager, - srcs: sources, - deps: [], - tsconfig: Smithers.file("tsconfig.json"), - buildMode: false, - incremental: false, - cwd -}) - -/** - * The unit suite: everything under `src/`. - * - * @since 0.1.0 - * @category test - */ -export const unitTests = Smithers.NodeTest({ - runtime: bunRuntime, - runner: Smithers.testSuite(["src"]), - srcs: sources, - deps: [], - cwd -}) diff --git a/apps/tui/package.json b/apps/tui/package.json index 8f72d71b..485b7fa7 100644 --- a/apps/tui/package.json +++ b/apps/tui/package.json @@ -6,7 +6,6 @@ "description": "Terminal chat client for the Smithers MVP app: an opentui (React) clone of apps/ui that speaks the same agent turn contract", "scripts": { "dev": "bun run src/index.tsx", - "check": "tsc --noEmit", "typecheck": "tsc --noEmit", "test": "bun test src", "smoke": "bun run scripts/smoke.ts" diff --git a/apps/tui/src/e2e/WorkerTurn.test.tsx b/apps/tui/src/e2e/WorkerTurn.test.tsx deleted file mode 100644 index dbcae61e..00000000 --- a/apps/tui/src/e2e/WorkerTurn.test.tsx +++ /dev/null @@ -1,469 +0,0 @@ -import { afterAll, describe, expect, test } from "bun:test"; -import { fileURLToPath } from "node:url"; -import { act } from "react"; -import { testRender } from "@opentui/react/test-utils"; -import type { TestRendererSetup } from "@opentui/core/testing"; -import { CANCEL_PATH, TURN_PATH } from "smithers-shared/AgentApiRoutes"; -import type { FetchLike, StartAgentTurnRequest } from "smithers-shared/NativeAgent"; -import { createWebAgent } from "../agent/WebAgent"; -import { ChatController } from "../state/ChatController"; -import type { TuiTransport } from "../state/ChatController"; -import { TranscriptStore } from "../state/Transcript"; -import { App } from "../ui/App"; - -/* - * E15.2 / E15.3 — the TUI driven against the real product boundary. - * - * This boots the actual Worker (`wrangler dev` on apps/server, every seam var - * sealed to "") in front of an NDJSON chat double, then renders the real TUI - * through OpenTUI's headless renderer and drives it with real terminal keys. - * Nothing about the turn is faked below the composer: the request crosses the - * Worker's /api/agent/turn route, the Worker composes the instructions and - * forwards them upstream, tags every frame with the runId, and the TUI's real - * WebAgent decodes the NDJSON back into the rendered transcript. - * - * With IDENTITY_UPSTREAM_URL sealed the turn seam is ungated (apps/server - * src/index.ts requireTurnSession), so no session cookie is minted here. - * - * The port is the lane's own (FLOWS_E2E_PORT), so concurrent suites never - * fight over the socket. - */ - -const WORKER_PORT = Number(process.env.FLOWS_E2E_PORT ?? 8820); -const WORKER_ORIGIN = `http://127.0.0.1:${WORKER_PORT}`; -/** The version apps/ui/e2e/Stack.ts pins, so one known wrangler is fetched. */ -const WRANGLER = "wrangler@4.123.0"; -const SERVER_DIR = fileURLToPath(new URL("../../../server/", import.meta.url)); - -/** Every seam var the Worker declares, forced to "" — an empty value reads as unset. */ -const SEALED_VARS: ReadonlyArray<string> = [ - "IDENTITY_UPSTREAM_URL", - "IDENTITY_SERVICE_TOKEN", - "IDENTITY_ADMIN_TOKEN", - "BILLING_UPSTREAM_URL", - "BILLING_AUTH_TOKEN", - "BILLING_PRODUCT_SERVICE_TOKEN", - "BILLING_ADMIN_TOKEN", - "CHAT_PRODUCT_SERVICE_TOKEN", - "RECO_UPSTREAM_URL", - "RECO_ADMIN_TOKEN", - "GATEWAY_UPSTREAM_URL", - "GATEWAY_AUTH_TOKEN", - "GATEWAY_SESSION_USER_ID", - "GATEWAY_SESSION_USER_ROLE", - "GATEWAY_SESSION_USER_SCOPES", - "SMITHERS_CHAT_AUTH_TOKEN", - "SMITHERS_CLOUD_API_BASE_URL", - "MODEL_RELAY_API_KEY", - "MODEL_RELAY_URL", -]; - -const REPLY_TEXT = "Hi, I'm Smithers (stub upstream)."; -const SLOW_TEXT = "thinking"; - -const wait = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms)); - -interface ChatUpstreamRequest { - readonly messages: ReadonlyArray<{ readonly role?: string; readonly content?: unknown }>; - readonly instructions: string; -} - -/* - * The chat double the Worker's SMITHERS_CHAT_URL points at. It speaks the - * upstream wire shape (frames without a runId — the Worker stamps those), so - * an untagged pass-through on the Worker would leave the TUI stalled rather - * than silently passing. - */ -const createChatUpstream = () => { - const requests: Array<ChatUpstreamRequest> = []; - let slow = false; - let deltasWritten = 0; - const encoder = new TextEncoder(); - const line = (frame: Record<string, unknown>): Uint8Array => - encoder.encode(`${JSON.stringify(frame)}\n`); - - const server = Bun.serve({ - // An ephemeral port: the lane owns WORKER_PORT alone, and a fixed - // neighbour would collide with whatever the next lane boots. - port: 0, - idleTimeout: 60, - fetch: async (request) => { - const url = new URL(request.url); - if (url.pathname === "/stub/arm-slow") { - slow = true; - return Response.json({ status: "ok" }); - } - if (url.pathname === "/stub/arm-default") { - slow = false; - return Response.json({ status: "ok" }); - } - const body = (await request.json()) as ChatUpstreamRequest; - requests.push(body); - const streamSlow = slow; - const stream = new ReadableStream<Uint8Array>({ - async start(controller) { - if (streamSlow) { - // A killable turn: 40 deltas at 250ms is ~10s, far longer - // than any interrupt below waits. - for (let index = 0; index < 40; index += 1) { - controller.enqueue(line({ type: "delta", kind: "text", text: `${SLOW_TEXT} ` })); - deltasWritten += 1; - await wait(250); - } - } else { - controller.enqueue(line({ type: "delta", kind: "text", text: REPLY_TEXT })); - controller.enqueue( - line({ - type: "card", - card: { - id: "plan-1", - kind: "plan", - title: "TUI launch", - status: "active", - createdAt: 1755000000000, - ordinal: 1, - payload: { items: [{ id: "1", title: "scaffold", status: "done" }] }, - }, - }), - ); - } - controller.enqueue(line({ type: "done", reason: "stop" })); - controller.close(); - }, - }); - return new Response(stream, { - headers: { "content-type": "application/x-ndjson", "cache-control": "no-store" }, - }); - }, - }); - - return { - url: `http://127.0.0.1:${server.port}/chat`, - requests: (): ReadonlyArray<ChatUpstreamRequest> => requests, - deltasWritten: (): number => deltasWritten, - armSlow: async (): Promise<void> => { - await fetch(`http://127.0.0.1:${server.port}/stub/arm-slow`, { method: "POST" }); - }, - armDefault: async (): Promise<void> => { - await fetch(`http://127.0.0.1:${server.port}/stub/arm-default`, { method: "POST" }); - }, - stop: (): void => void server.stop(true), - }; -}; - -const chat = createChatUpstream(); - -const vars: Record<string, string> = { SMITHERS_CHAT_URL: chat.url }; -for (const name of SEALED_VARS) if (!(name in vars)) vars[name] = ""; - -const persistDir = `${process.env.TMPDIR ?? "/tmp"}/tui-worker-e2e-${process.pid}`; -const wrangler = Bun.spawn( - [ - "bun", - "x", - WRANGLER, - "dev", - "--ip", - "127.0.0.1", - "--port", - String(WORKER_PORT), - // Derived from the lane's port so a concurrent suite's inspector cannot collide. - "--inspector-port", - String(WORKER_PORT + 1000), - "--persist-to", - persistDir, - ...Object.entries(vars).flatMap(([key, value]) => ["--var", `${key}:${value}`]), - ], - { cwd: SERVER_DIR, stdout: "pipe", stderr: "pipe" }, -); - -/* - * Drain wrangler's pipes. An undrained pipe fills and stalls the process, and - * the tail is the only diagnosis available when the boot fails. - */ -let workerLog = ""; -const drain = async (stream: ReadableStream<Uint8Array> | undefined): Promise<void> => { - if (stream === undefined) return; - const decoder = new TextDecoder(); - const reader = stream.getReader(); - for (;;) { - const { value, done } = await reader.read(); - if (value !== undefined) { - workerLog = `${workerLog}${decoder.decode(value, { stream: true })}`.slice(-8_000); - } - if (done) return; - } -}; -void drain(wrangler.stdout as ReadableStream<Uint8Array>); -void drain(wrangler.stderr as ReadableStream<Uint8Array>); - -let workerUp = false; -for (let attempt = 0; attempt < 120 && !workerUp; attempt += 1) { - try { - const response = await fetch(WORKER_ORIGIN); - workerUp = response.ok; - await response.arrayBuffer(); - } catch { - // wrangler is still starting. - } - if (!workerUp) await wait(500); -} -if (!workerUp) { - wrangler.kill(); - chat.stop(); - throw new Error(`wrangler dev never came up on ${WORKER_ORIGIN}.\n${workerLog}`); -} - -afterAll(async () => { - wrangler.kill(); - await wait(500); - chat.stop(); -}); - -interface WireCall { - readonly url: string; - readonly method: string; - readonly body: string; - readonly status: number; - readonly contentType: string | null; -} - -/** The rendered terminal with runs of spaces collapsed. */ -const frameText = (setup: TestRendererSetup): string => - setup - .captureCharFrame() - .split("\n") - .map((row) => row.trim().replace(/\s+/g, " ")) - .join("\n"); - -const waitForFrame = async ( - setup: TestRendererSetup, - predicate: (frame: string) => boolean, - budgetMs = 30_000, -): Promise<string> => { - const deadline = Date.now() + budgetMs; - for (;;) { - // The sleep sits INSIDE act: frames arrive from the network between - // polls, and React must own the window they land in or every turn - // prints an "update was not wrapped in act(...)" warning. - let frame = ""; - await act(async () => { - await wait(50); - await setup.flush(); - frame = frameText(setup); - }); - if (predicate(frame)) return frame; - if (Date.now() > deadline) { - throw new Error(`the frame never satisfied the predicate within ${budgetMs}ms:\n${frame}`); - } - } -}; - -interface Harness { - readonly setup: TestRendererSetup; - readonly store: TranscriptStore; - readonly wire: ReadonlyArray<WireCall>; - readonly requests: ReadonlyArray<StartAgentTurnRequest>; - readonly destroy: () => void; -} - -/* - * The real transport, wrapped only to observe. `start`/`cancel` delegate to the - * WebAgent unchanged, and the recording fetch is the product's own `fetchImpl` - * seam — nothing about the request or the response is synthesized here. - */ -const mount = async (): Promise<Harness> => { - const wire: Array<WireCall> = []; - const requests: Array<StartAgentTurnRequest> = []; - const recordingFetch: FetchLike = async (input, init) => { - const response = await fetch(input as string, init); - wire.push({ - url: String(input), - method: init?.method ?? "GET", - body: typeof init?.body === "string" ? init.body : "", - status: response.status, - contentType: response.headers.get("content-type"), - }); - return response; - }; - - const store = new TranscriptStore(); - let controller: ChatController; - const agent = createWebAgent((frame) => controller.publish(frame), { - baseUrl: WORKER_ORIGIN, - fetchImpl: recordingFetch, - }); - const transport: TuiTransport = { - start: (request) => { - requests.push(request); - return agent.start(request); - }, - cancel: (runId) => agent.cancel(runId), - }; - controller = new ChatController(store, transport); - - const setup = await testRender(<App controller={controller} describe={`worker ${WORKER_ORIGIN}`} />, { - width: 100, - height: 24, - }); - await setup.flush(); - return { - setup, - store, - wire, - requests, - destroy: () => act(() => setup.renderer.destroy()), - }; -}; - -/* - * Every await that can overlap a live stream runs inside `act`. Frames arrive - * from the network on their own schedule, and an update that lands outside an - * act window makes React print "not wrapped in act(...)" on every turn. - */ -const submit = async (harness: Harness, text: string): Promise<void> => { - await act(async () => { - await harness.setup.mockInput.typeText(text); - await harness.setup.flush(); - }); - await act(async () => { - harness.setup.mockInput.pressEnter(); - await harness.setup.flush(); - }); -}; - -describe("TUI against the real Worker boundary", () => { - test( - "E15.2 a composer submit runs a full turn through wrangler dev and renders the reply", - async () => { - await chat.armDefault(); - const upstreamBefore = chat.requests().length; - const harness = await mount(); - try { - await submit(harness, "Hello who are you"); - const frame = await waitForFrame(harness.setup, (text) => text.includes(REPLY_TEXT)); - - expect(frame).toContain("> Hello who are you"); - expect(frame).toContain(REPLY_TEXT); - // The Worker's frame tagging reached the projection: an untagged - // pass-through would leave the card missing and the turn stalled. - expect(frame).toContain("[card] plan: TUI launch (active)"); - await waitForFrame(harness.setup, (text) => !text.includes("responding… (Esc to cancel)")); - expect(harness.store.phase()).toBe("idle"); - - const assistant = harness.store - .entries() - .find((entry) => entry.kind === "message" && entry.role === "assistant"); - expect(assistant).toBeDefined(); - expect(assistant).toMatchObject({ status: "complete", text: REPLY_TEXT }); - - // The wire: one POST to the shared turn route, answered as NDJSON. - const turnCalls = harness.wire.filter((call) => call.url === `${WORKER_ORIGIN}${TURN_PATH}`); - expect(turnCalls).toHaveLength(1); - expect(turnCalls[0]!.method).toBe("POST"); - expect(turnCalls[0]!.status).toBe(200); - expect(turnCalls[0]!.contentType).toBe("application/x-ndjson"); - - // The Worker forwarded a composed turn upstream: the prompt reached - // the model seam and the TUI's instructions survived composition. - const upstream = chat.requests().slice(upstreamBefore); - expect(upstream).toHaveLength(1); - expect(upstream[0]!.messages.at(-1)).toEqual({ - role: "user", - content: "Hello who are you", - }); - expect(upstream[0]!.instructions).toContain(harness.requests[0]!.instructions); - } finally { - harness.destroy(); - } - }, - 90_000, - ); - - test( - "E15.3 a server-side kill ends the live turn with the honest interrupted line", - async () => { - await chat.armSlow(); - const harness = await mount(); - try { - await submit(harness, "start a long one"); - await waitForFrame(harness.setup, (text) => text.includes(SLOW_TEXT)); - const runId = harness.requests[0]!.runId; - - // The kill is issued out of band, so the TUI never aborts locally: - // everything below is driven by the Worker's own terminal frame. - let cancelStatus = 0; - let cancelBody: unknown; - await act(async () => { - const cancelled = await fetch(`${WORKER_ORIGIN}${CANCEL_PATH}`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ runId }), - }); - cancelStatus = cancelled.status; - cancelBody = await cancelled.json(); - }); - expect(cancelStatus).toBe(200); - expect(cancelBody).toEqual({ status: "cancelled" }); - - const frame = await waitForFrame(harness.setup, (text) => - text.includes("Stopped the current response."), - ); - expect(frame).not.toContain("responding… (Esc to cancel)"); - // The partial answer survives the kill. - expect(frame).toContain(SLOW_TEXT); - expect(harness.store.phase()).toBe("idle"); - } finally { - harness.destroy(); - } - }, - 90_000, - ); - - test( - "E15.3 Escape kills the turn on the server and a retry runs a fresh one", - async () => { - await chat.armSlow(); - const harness = await mount(); - try { - await submit(harness, "start another long one"); - await waitForFrame(harness.setup, (text) => text.includes(SLOW_TEXT)); - const runId = harness.requests[0]!.runId; - - await act(async () => { - await harness.setup.mockInput.pressKeys(["ESCAPE"], 60); - await harness.setup.flush(); - }); - const interrupted = await waitForFrame(harness.setup, (text) => - text.includes("Stopped the current response."), - ); - expect(interrupted).not.toContain("responding… (Esc to cancel)"); - expect(harness.store.phase()).toBe("idle"); - - // Esc must reach the server's cancel contract, not merely drop the - // socket: the runId, the shared route and the method all matter. - const cancelCalls = harness.wire.filter( - (call) => call.url === `${WORKER_ORIGIN}${CANCEL_PATH}`, - ); - expect(cancelCalls).toHaveLength(1); - expect(cancelCalls[0]!.method).toBe("POST"); - expect(cancelCalls[0]!.status).toBe(200); - expect(JSON.parse(cancelCalls[0]!.body)).toEqual({ runId }); - - // The retry: a fresh turn over the same boundary, right after the kill. - await chat.armDefault(); - await submit(harness, "try again"); - const retried = await waitForFrame(harness.setup, (text) => text.includes(REPLY_TEXT)); - expect(retried).toContain("Stopped the current response."); - expect(retried).toContain("> try again"); - expect(harness.requests).toHaveLength(2); - expect(harness.requests[1]!.runId).not.toBe(runId); - await waitForFrame(harness.setup, (text) => !text.includes("responding… (Esc to cancel)")); - expect(harness.store.phase()).toBe("idle"); - } finally { - harness.destroy(); - } - }, - 90_000, - ); -}); diff --git a/apps/tui/src/ui/App.test.tsx b/apps/tui/src/ui/App.test.tsx deleted file mode 100644 index 9474d7fb..00000000 --- a/apps/tui/src/ui/App.test.tsx +++ /dev/null @@ -1,240 +0,0 @@ -import { afterEach, describe, expect, test } from "bun:test"; -import { act } from "react"; -import { testRender } from "@opentui/react/test-utils"; -import type { TestRendererSetup } from "@opentui/core/testing"; -import type { StartAgentTurnRequest } from "smithers-shared/NativeAgent"; -import { ChatController } from "../state/ChatController"; -import type { TuiTransport } from "../state/ChatController"; -import { AgentTurnFrameDecoder, applyFrame, TranscriptStore } from "../state/Transcript"; -import { App } from "./App"; - -/* - * E15.1 / E15.3 — the TUI driven headlessly through its real render tree. - * - * `scripts/smoke.ts` folds the same fixture stream but asserts nothing and is - * bound to `bun run smoke`, not to `test`, so nothing here was ever checked by - * a run. These tests render the real App through OpenTUI's test renderer, drive - * it with real terminal key bytes, and assert on the characters the terminal - * would actually show — so a regression in the store fold, the projection, the - * composer, or the Escape handler turns them red. - * - * Two OpenTUI gotchas the tests depend on: - * - `mockInput.pressEscape()` leaves a lone 0x1B pending in the terminal - * parser, where it merges with the next byte. Escape must be delivered as - * `pressKeys(["ESCAPE"], delay)` so the parser times the sequence out. - * - Every input dispatch and `renderer.destroy()` runs inside React's `act`. - */ - -/** The fixture NDJSON stream from scripts/smoke.ts, split mid-line to exercise the fold. */ -const FIXTURE_CHUNKS: ReadonlyArray<string> = [ - '{"runId":"smoke-1","type":"delta","kind":"reasoning","text":"The user wants a launch ', - 'plan."}\n{"runId":"smoke-1","type":"delta","kind":"text","text":"Here is the pla', - 'n."}\n{"runId":"smoke-1","type":"tool_call","call_id":"call-1","name":"commands","arguments":"{\\"action\\":\\"list\\"}"}\n', - '{"runId":"smoke-1","type":"card","card":{"id":"plan-1","kind":"plan","title":"TUI launch","status":"active","createdAt":1755000000000,"ordinal":1,"payload":{"items":[{"id":"1","title":"scaffold","status":"done"},{"id":"2","title":"smoke","status":"pending"}]}}}\n', - '{"runId":"smoke-1","type":"card.update","id":"plan-1","patch":{"status":"acted"}}\n', - '{"runId":"smoke-1","type":"done","reason":"stop"}\n', -]; - -/** A transport that records what the controller sent and never answers on its own. */ -interface RecordingTransport extends TuiTransport { - readonly requests: ReadonlyArray<StartAgentTurnRequest>; - readonly cancels: ReadonlyArray<string>; -} - -const recordingTransport = (): RecordingTransport => { - const requests: Array<StartAgentTurnRequest> = []; - const cancels: Array<string> = []; - return { - requests, - cancels, - start: (request) => { - requests.push(request); - return { status: "started" }; - }, - cancel: (runId) => { - cancels.push(runId); - }, - }; -}; - -/** The rendered terminal with runs of spaces collapsed, so column padding never decides a test. */ -const frameText = (setup: TestRendererSetup): string => - setup - .captureCharFrame() - .split("\n") - .map((line) => line.trim().replace(/\s+/g, " ")) - .join("\n"); - -let open: TestRendererSetup | undefined; - -const render = async (controller: ChatController): Promise<TestRendererSetup> => { - const setup = await testRender(<App controller={controller} describe="test transport" />, { - width: 100, - height: 24, - }); - open = setup; - await setup.flush(); - return setup; -}; - -afterEach(() => { - const setup = open; - open = undefined; - if (setup !== undefined) act(() => setup.renderer.destroy()); -}); - -describe("TUI headless render", () => { - test("E15.1 the fixture NDJSON stream folds into the rendered transcript", async () => { - const store = new TranscriptStore(); - const controller = new ChatController(store, recordingTransport()); - store.appendUserMessage("smoke-1", "plan the tui launch"); - store.setPhase("responding"); - const setup = await render(controller); - - // The fold runs against the live store, so every frame below is proved - // by what the terminal ends up showing — not by the store's own return. - const decoder = new AgentTurnFrameDecoder((frame) => applyFrame(store, frame)); - let applied = 0; - await act(async () => { - for (const chunk of FIXTURE_CHUNKS) applied += decoder.push(chunk); - applied += decoder.finish(); - store.setPhase("idle"); - }); - await setup.flush(); - - expect(applied).toBe(6); - const frame = frameText(setup); - expect(frame).toContain("> plan the tui launch"); - expect(frame).toContain("The user wants a launch plan."); - expect(frame).toContain("Here is the plan."); - expect(frame).toContain('[tool] commands {"action":"list"}'); - // The card.update patch must have landed: "active" here means card.update - // stopped folding, which is exactly the drift that hid in the old suite. - expect(frame).toContain("[card] plan: TUI launch (acted)"); - expect(frame).not.toContain("[card] plan: TUI launch (active)"); - // The turn settled, so the streaming ellipsis is gone and the composer is ready. - expect(frame).toContain("message"); - expect(frame).not.toContain("responding… (Esc to cancel)"); - expect(store.phase()).toBe("idle"); - }); - - test("E15.1 a composer submit sends the built turn request over the real key path", async () => { - const store = new TranscriptStore(); - const transport = recordingTransport(); - const controller = new ChatController(store, transport); - // A settled prior exchange, so the request must carry the visible history. - store.appendUserMessage("turn-0", "plan the tui launch"); - store.appendDelta("turn-0", "text", "Here is the plan."); - store.settleAssistant("turn-0", "complete"); - const setup = await render(controller); - - await act(async () => { - await setup.mockInput.typeText("looks good, ship step 2"); - }); - await setup.flush(); - await act(async () => { - setup.mockInput.pressEnter(); - }); - await setup.flush(); - - expect(transport.requests).toHaveLength(1); - const request = transport.requests[0]!; - expect(request.runId).toStartWith("tui-"); - expect(request.messages).toEqual([ - { role: "user", content: "plan the tui launch" }, - { role: "assistant", content: "Here is the plan." }, - { role: "user", content: "looks good, ship step 2" }, - ]); - expect(request.instructions.length).toBeGreaterThan(0); - expect(store.phase()).toBe("responding"); - - const frame = frameText(setup); - expect(frame).toContain("> looks good, ship step 2"); - // The composer cleared and switched to the responding placeholder. - expect(frame).toContain("responding… (Esc to cancel)"); - }); - - test("E15.3 Escape interrupts the live turn through the transport's cancel contract", async () => { - const store = new TranscriptStore(); - const transport = recordingTransport(); - const controller = new ChatController(store, transport); - const setup = await render(controller); - - await act(async () => { - await setup.mockInput.typeText("start something long"); - }); - await setup.flush(); - await act(async () => { - setup.mockInput.pressEnter(); - }); - await setup.flush(); - const runId = transport.requests[0]?.runId; - expect(runId).toBeString(); - // A delta lands before the interrupt, so the assertion covers a turn that - // was genuinely mid-stream rather than one that never started. - await act(async () => { - controller.publish({ runId: runId!, type: "delta", kind: "text", text: "Working on it" }); - }); - await setup.flush(); - expect(frameText(setup)).toContain("Working on it"); - - // A lone 0x1B would merge with the next byte; pressKeys times it out. - await act(async () => { - await setup.mockInput.pressKeys(["ESCAPE"], 60); - }); - await setup.flush(); - - expect(transport.cancels).toEqual([runId!]); - expect(store.phase()).toBe("idle"); - const frame = frameText(setup); - expect(frame).toContain("Stopped the current response."); - expect(frame).not.toContain("responding… (Esc to cancel)"); - // The partial answer stays on screen: an interrupt is not an erasure. - expect(frame).toContain("Working on it"); - }); - - test("E15.3 a retry after the interrupt starts a fresh turn and keeps the interrupt visible", async () => { - const store = new TranscriptStore(); - const transport = recordingTransport(); - const controller = new ChatController(store, transport); - const setup = await render(controller); - - await act(async () => { - await setup.mockInput.typeText("start something long"); - }); - await setup.flush(); - await act(async () => { - setup.mockInput.pressEnter(); - }); - await setup.flush(); - await act(async () => { - await setup.mockInput.pressKeys(["ESCAPE"], 60); - }); - await setup.flush(); - - await act(async () => { - await setup.mockInput.typeText("try again"); - }); - await setup.flush(); - await act(async () => { - setup.mockInput.pressEnter(); - }); - await setup.flush(); - - expect(transport.requests).toHaveLength(2); - const [first, second] = transport.requests; - expect(second!.runId).not.toBe(first!.runId); - expect(store.phase()).toBe("responding"); - // The interrupted turn contributed no assistant speech to the retry's - // context: a local transport note must never enter the model's prompt. - expect(second!.messages).toEqual([ - { role: "user", content: "start something long" }, - { role: "user", content: "try again" }, - ]); - - const frame = frameText(setup); - expect(frame).toContain("Stopped the current response."); - expect(frame).toContain("> try again"); - expect(frame).toContain("responding… (Esc to cancel)"); - }); -}); diff --git a/apps/ui/.scratch-surfaces/adminprobe.ts b/apps/ui/.scratch-surfaces/adminprobe.ts deleted file mode 100644 index e2fc8796..00000000 --- a/apps/ui/.scratch-surfaces/adminprobe.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { open, api } from "./drv.ts"; -const { context, page } = await open(); -for (const p of ["/api/admin/feedback", "/api/admin/feedback?login=codeplanesmithers"]) { - const r = await api(page, p); - console.log("GET", p, r.status, r.body.slice(0, 600)); -} -const d = await api(page, "/api/admin/reco-dismissals?login=codeplanesmithers", { method: "DELETE" }); -console.log("DELETE dismissals", d.status, d.body.slice(0, 400)); -await context.close(); diff --git a/apps/ui/.scratch-surfaces/drv.ts b/apps/ui/.scratch-surfaces/drv.ts deleted file mode 100644 index 5e87b3a3..00000000 --- a/apps/ui/.scratch-surfaces/drv.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { chromium, type BrowserContext, type Page } from "playwright"; -export const BASE = "https://canary.smithers.sh"; -export const PROFILE = "/tmp/canary-surfaces-profile"; -export const open = async (opts: { reset?: boolean } = {}) => { - const context = await chromium.launchPersistentContext(PROFILE, { headless: true, viewport: { width: 1360, height: 950 } }); - const page = context.pages()[0] ?? (await context.newPage()); - const errors: string[] = []; - page.on("console", (m) => { if (m.type() === "error") errors.push(m.text()); }); - page.on("pageerror", (e) => errors.push(String(e))); - await page.goto(BASE, { waitUntil: "domcontentloaded" }); - if (opts.reset) { - await page.goto("about:blank", { waitUntil: "domcontentloaded" }); - const client = await context.newCDPSession(page); - await client.send("Storage.clearDataForOrigin", { origin: new URL(BASE).origin, storageTypes: "file_systems,local_storage,indexeddb,cache_storage,websql,service_workers" }); - await client.detach().catch(() => {}); - await page.goto(BASE, { waitUntil: "domcontentloaded" }); - } - await page.waitForTimeout(4000); - return { context, page, errors }; -}; -export const api = async (page: Page, path: string, init?: any) => - page.evaluate(async ([p, i]: any) => { - const r = await fetch(p, i ?? undefined); - return { status: r.status, body: await r.text() }; - }, [path, init ?? null]); -export const text = async (page: Page) => await page.locator("body").innerText(); - -export const composer = (page: any) => page.locator('textarea[aria-label="Chat message"]'); -export const run = async (page: any, cmd: string, wait = 8000) => { - const c = composer(page); - await c.click(); - await c.fill(cmd); - await page.waitForTimeout(400); - const send = page.locator('[data-flow="send"]'); - if (await send.count() > 0 && await send.first().isEnabled().catch(() => false)) await send.first().click({ force: true }); - else await page.keyboard.press("Enter"); - await page.waitForTimeout(wait); -}; diff --git a/apps/ui/.scratch-surfaces/p101.ts b/apps/ui/.scratch-surfaces/p101.ts deleted file mode 100644 index 9a354b8d..00000000 --- a/apps/ui/.scratch-surfaces/p101.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { open, text, run } from "./drv.ts"; -const { context, page, errors } = await open(); -await page.waitForTimeout(5000); -await run(page, "/world", 5000); -console.log("WORLD PANE>>>", (await text(page)).slice(0, 1800)); -await page.screenshot({ path: "/tmp/surfaces/10.1-world.png", fullPage: true }); -// back button in the pane header -const backs = await page.evaluate(() => Array.from(document.querySelectorAll('button')).map((b: any) => ({ t: b.innerText?.slice(0,30), aria: b.getAttribute("aria-label"), flow: b.getAttribute("data-flow"), cls: b.className?.toString().slice(0,60) })).filter(b => /back|chat|close/i.test((b.t ?? "") + (b.aria ?? "") + (b.flow ?? "") + b.cls))); -console.log("BACKISH BUTTONS", JSON.stringify(backs, null, 1)); -await context.close(); diff --git a/apps/ui/.scratch-surfaces/p101b.ts b/apps/ui/.scratch-surfaces/p101b.ts deleted file mode 100644 index 20f948d1..00000000 --- a/apps/ui/.scratch-surfaces/p101b.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { open, text, run } from "./drv.ts"; -const { context, page, errors } = await open(); -await page.waitForTimeout(5000); -const worldOpen = async () => (await page.locator(".world-surface, [class*='world']").count()) > 0; -await run(page, "/world", 4000); -console.log("after /world, world present:", await worldOpen(), "| New note visible:", await page.getByText("New note").first().isVisible().catch(()=>false)); -// back button clickable? -const back = page.locator('[data-flow="chat"][aria-label="Back to the conversation"]'); -console.log("back count", await back.count(), "visible", await back.first().isVisible(), "enabled", await back.first().isEnabled()); -const box = await back.first().boundingBox(); -console.log("back box", JSON.stringify(box)); -const top = await page.evaluate((b: any) => { const el = document.elementFromPoint(b.x + b.width/2, b.y + b.height/2) as any; return el?.tagName + " aria=" + el?.getAttribute?.("aria-label") + " flow=" + el?.closest?.("button")?.getAttribute?.("data-flow"); }, box); -console.log("element at back center:", top); -await back.first().click(); -await page.waitForTimeout(2500); -console.log("after back click, world present:", await worldOpen()); -await page.screenshot({ path: "/tmp/surfaces/10.1-back.png" }); -// re-open and use /chat -await run(page, "/world", 3500); -console.log("reopened:", await worldOpen()); -await run(page, "/chat", 3500); -console.log("after /chat, world present:", await worldOpen()); -await page.screenshot({ path: "/tmp/surfaces/10.1-chat.png" }); -console.log("ERRORS", JSON.stringify(errors.slice(0,5))); -await context.close(); diff --git a/apps/ui/.scratch-surfaces/p101c.ts b/apps/ui/.scratch-surfaces/p101c.ts deleted file mode 100644 index 6caf71a1..00000000 --- a/apps/ui/.scratch-surfaces/p101c.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { open, text, run } from "./drv.ts"; -const { context, page, errors } = await open(); -await page.waitForTimeout(5000); -const isWorld = async () => (await page.getByText("What Smithers currently understands").count()) > 0; -console.log("world open at load:", await isWorld()); -await run(page, "/world", 4000); -console.log("after /world:", await isWorld()); -if (!(await isWorld())) { await run(page, "/world", 4000); console.log("after 2nd /world:", await isWorld()); } -const back = page.locator('button[aria-label="Back to the conversation"]'); -console.log("back count", await back.count()); -if (await back.count() > 0) { - const box = await back.first().boundingBox(); - const top = await page.evaluate((b: any) => { const el = document.elementFromPoint(b.x + b.width/2, b.y + b.height/2) as any; return el?.tagName + "|" + (el?.closest?.("button")?.getAttribute?.("aria-label") ?? "none"); }, box); - console.log("hit-test at back center:", top, JSON.stringify(box)); - await back.first().click(); - await page.waitForTimeout(2500); - console.log("after back click, world open:", await isWorld()); -} -await run(page, "/world", 4000); -console.log("reopened via /world:", await isWorld()); -await run(page, "/chat", 4000); -console.log("after /chat, world open:", await isWorld()); -await page.screenshot({ path: "/tmp/surfaces/10.1-final.png" }); -console.log("ERRORS", JSON.stringify(errors.slice(0,5))); -await context.close(); diff --git a/apps/ui/.scratch-surfaces/p102.ts b/apps/ui/.scratch-surfaces/p102.ts deleted file mode 100644 index a75f9b45..00000000 --- a/apps/ui/.scratch-surfaces/p102.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { open, text, run } from "./drv.ts"; -const { context, page, errors } = await open(); -await page.waitForTimeout(5000); -const isWorld = async () => (await page.getByText("What Smithers currently understands").count()) > 0; -if (!(await isWorld())) await run(page, "/world", 4000); -const before = await page.locator(".world-tree-item, [class*='world-tree'] button, aside button").allInnerTexts().catch(()=>[]); -console.log("tree before:", JSON.stringify(before)); -const nn = page.getByRole("button", { name: /New note/i }).first(); -console.log("new-note data-flow:", await nn.getAttribute("data-flow")); -await nn.click(); -await page.waitForTimeout(2500); -const active = await page.evaluate(() => { - const a: any = document.activeElement; - const editor = document.querySelector('[aria-label^="Edit "]'); - return { tag: a?.tagName, aria: a?.getAttribute?.("aria-label"), cls: a?.className?.toString?.().slice(0,70), insideEditor: !!(editor && (editor === a || editor.contains(a))), editorAria: editor?.getAttribute("aria-label") ?? null }; -}); -console.log("ACTIVE AFTER NEW NOTE:", JSON.stringify(active)); -const meta = await page.locator(".world-document-meta").innerText().catch(()=>null); -console.log("doc meta:", meta); -// type immediately -const probe = "CANARY-FOCUS-PROBE-" + Date.now(); -await page.keyboard.type(probe, { delay: 15 }); -await page.waitForTimeout(2000); -const editorText = await page.locator('[aria-label^="Edit "]').innerText().catch(()=>null); -console.log("editor text after typing:", JSON.stringify(editorText?.slice(0,200))); -console.log("typed landed in note:", editorText?.includes(probe) ?? false); -await page.screenshot({ path: "/tmp/surfaces/10.2.png", fullPage: true }); -console.log("ERRORS", JSON.stringify(errors.slice(0,5))); -await context.close(); diff --git a/apps/ui/.scratch-surfaces/p103.ts b/apps/ui/.scratch-surfaces/p103.ts deleted file mode 100644 index 2234272b..00000000 --- a/apps/ui/.scratch-surfaces/p103.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { open, text, run } from "./drv.ts"; -const { context, page, errors } = await open(); -await page.waitForTimeout(5000); -const isWorld = async () => (await page.getByText("What Smithers currently understands").count()) > 0; -if (!(await isWorld())) await run(page, "/world", 4000); -const items = page.locator('[data-flow="world.select"]'); -console.log("tree item count:", await items.count()); -const texts = await items.allInnerTexts(); -console.log("tree items:", JSON.stringify(texts)); -const meta0 = await page.locator(".world-document-meta").innerText().catch(()=>null); -console.log("selected before:", JSON.stringify(meta0?.split("\n")[0])); -// click a DIFFERENT item -const n = await items.count(); -if (n > 1) { - for (let i = 0; i < n; i++) { - const label = (await items.nth(i).innerText()).trim(); - if (!meta0?.startsWith(label)) { await items.nth(i).click(); break; } - } - await page.waitForTimeout(2000); - const meta1 = await page.locator(".world-document-meta").innerText().catch(()=>null); - console.log("selected after click:", JSON.stringify(meta1?.split("\n")[0])); - console.log("selection changed:", meta0?.split("\n")[0] !== meta1?.split("\n")[0]); - const editorAria = await page.locator('[aria-label^="Edit "]').getAttribute("aria-label").catch(()=>null); - console.log("editor aria:", editorAria); -} -await page.screenshot({ path: "/tmp/surfaces/10.3.png", fullPage: true }); -console.log("ERRORS", JSON.stringify(errors.slice(0,5))); -await context.close(); diff --git a/apps/ui/.scratch-surfaces/p104.ts b/apps/ui/.scratch-surfaces/p104.ts deleted file mode 100644 index 74c597f4..00000000 --- a/apps/ui/.scratch-surfaces/p104.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { open, text, run } from "./drv.ts"; -const { context, page, errors } = await open(); -await context.grantPermissions(["clipboard-read","clipboard-write"], { origin: "https://canary.smithers.sh" }).catch((e)=>console.log("perm err", String(e).slice(0,80))); -await page.waitForTimeout(5000); -const isWorld = async () => (await page.getByText("What Smithers currently understands").count()) > 0; -if (!(await isWorld())) await run(page, "/world", 4000); -// select Untitled 1 -const items = page.locator('[data-flow="world.select"]'); -for (let i = 0; i < await items.count(); i++) if ((await items.nth(i).innerText()).trim() === "Untitled 1") { await items.nth(i).click(); break; } -await page.waitForTimeout(1500); -const ed = page.locator('[aria-label^="Edit "]'); -const info = await ed.evaluate((e: any) => ({ tag: e.tagName, ce: e.getAttribute("contenteditable"), cls: e.className?.toString().slice(0,90) })); -console.log("EDITOR", JSON.stringify(info)); -await ed.click(); -await page.keyboard.press("Meta+a"); await page.keyboard.press("Backspace"); -await page.waitForTimeout(500); -// TYPING + FORMATTING -await page.keyboard.type("# Heading One\n\nSome **bold** and `code` text.\n\n- item a\n- item b\n", { delay: 8 }); -await page.waitForTimeout(1500); -console.log("after typing, editor innerText:", JSON.stringify((await ed.innerText()).slice(0,300))); -const html = await ed.innerHTML(); -console.log("has <h1>:", /<h1/i.test(html), "| has <strong>:", /<strong|font-weight/i.test(html), "| has <code>:", /<code/i.test(html), "| has <li>:", /<li/i.test(html)); -await page.screenshot({ path: "/tmp/surfaces/10.4-typed.png", fullPage: true }); -// UNDO -const beforeUndo = await ed.innerText(); -await page.keyboard.press("Meta+z"); -await page.waitForTimeout(1200); -const afterUndo = await ed.innerText(); -console.log("undo changed text:", beforeUndo !== afterUndo, "| after undo tail:", JSON.stringify(afterUndo.slice(-120))); -// PASTE -await page.evaluate(async () => { await navigator.clipboard.writeText("PASTED-CANARY-BLOCK-XYZ"); }); -await ed.click(); -await page.keyboard.press("Meta+ArrowDown"); -await page.keyboard.press("Meta+v"); -await page.waitForTimeout(1500); -const afterPaste = await ed.innerText(); -console.log("paste landed:", afterPaste.includes("PASTED-CANARY-BLOCK-XYZ")); -// VERY LONG DOCUMENT -const long = Array.from({length: 400}, (_, i) => `Line ${i} lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod.`).join("\n"); -await page.evaluate(async (t) => { await navigator.clipboard.writeText(t); }, long); -await page.keyboard.press("Meta+v"); -const t0 = Date.now(); -await page.waitForTimeout(4000); -const finalText = await ed.innerText(); -console.log("long doc chars in editor:", finalText.length, "| contains Line 399:", finalText.includes("Line 399"), "| elapsed", Date.now()-t0); -await page.screenshot({ path: "/tmp/surfaces/10.4-long.png" }); -console.log("ERRORS", JSON.stringify(errors.slice(0,6))); -await context.close(); diff --git a/apps/ui/.scratch-surfaces/p104b.ts b/apps/ui/.scratch-surfaces/p104b.ts deleted file mode 100644 index 75981540..00000000 --- a/apps/ui/.scratch-surfaces/p104b.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { open, run } from "./drv.ts"; -const { context, page } = await open(); -await page.waitForTimeout(5000); -const isWorld = async () => (await page.getByText("What Smithers currently understands").count()) > 0; -if (!(await isWorld())) await run(page, "/world", 4000); -const dump = await page.evaluate(() => { - const ed = document.querySelector('[aria-label^="Edit "]'); - if (!ed) return "none"; - const walk = (el: Element, d = 0): string[] => { - const out = [`${" ".repeat(d)}<${el.tagName.toLowerCase()} class="${(el.className?.toString?.() ?? "").slice(0,60)}" ce=${el.getAttribute("contenteditable")} aria=${el.getAttribute("aria-label")}>`]; - if (d < 3) for (const c of Array.from(el.children).slice(0, 6)) out.push(...walk(c, d + 1)); - return out; - }; - return walk(ed).join("\n") + "\n---TEXTAREAS in pane: " + document.querySelectorAll("textarea").length; -}); -console.log(dump); -await context.close(); diff --git a/apps/ui/.scratch-surfaces/p104c.ts b/apps/ui/.scratch-surfaces/p104c.ts deleted file mode 100644 index c70656e7..00000000 --- a/apps/ui/.scratch-surfaces/p104c.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { open, run } from "./drv.ts"; -const { context, page, errors } = await open(); -await context.grantPermissions(["clipboard-read","clipboard-write"], { origin: "https://canary.smithers.sh" }).catch(()=>{}); -await page.waitForTimeout(5000); -const isWorld = async () => (await page.getByText("What Smithers currently understands").count()) > 0; -if (!(await isWorld())) await run(page, "/world", 4000); -const items = page.locator('[data-flow="world.select"]'); -for (let i = 0; i < await items.count(); i++) if ((await items.nth(i).innerText()).trim().startsWith("Untitled")) { await items.nth(i).click(); break; } -await page.waitForTimeout(1500); -const pm = page.locator(".ProseMirror").first(); -await pm.click(); -await page.keyboard.press("Meta+a"); await page.keyboard.press("Backspace"); await page.waitForTimeout(600); -await page.keyboard.type("# Heading One", { delay: 12 }); -await page.keyboard.press("Enter"); -await page.keyboard.type("Some **bold** and `code` text.", { delay: 12 }); -await page.keyboard.press("Enter"); -await page.keyboard.type("- item a", { delay: 12 }); -await page.keyboard.press("Enter"); -await page.keyboard.type("item b", { delay: 12 }); -await page.waitForTimeout(1500); -const html = await pm.innerHTML(); -console.log("TEXT:", JSON.stringify((await pm.innerText()).slice(0,240))); -console.log("h1:", /<h1/i.test(html), "strong:", /<strong/i.test(html), "code:", /<code/i.test(html), "li:", /<li/i.test(html)); -await page.screenshot({ path: "/tmp/surfaces/10.4-typed.png", fullPage: true }); -const beforeUndo = await pm.innerText(); -await page.keyboard.press("Meta+z"); await page.waitForTimeout(1000); -const afterUndo = await pm.innerText(); -console.log("UNDO changed:", beforeUndo !== afterUndo, "|", JSON.stringify(afterUndo.slice(-90))); -await page.keyboard.press("Meta+Shift+z"); await page.waitForTimeout(800); -console.log("REDO restored:", (await pm.innerText()) === beforeUndo); -// paste -await page.evaluate(async () => { await navigator.clipboard.writeText("PASTED-CANARY-BLOCK-XYZ"); }); -await pm.click(); -await page.keyboard.press("Meta+ArrowDown"); -await page.keyboard.press("Enter"); -await page.keyboard.press("Meta+v"); -await page.waitForTimeout(1500); -console.log("PASTE landed:", (await pm.innerText()).includes("PASTED-CANARY-BLOCK-XYZ")); -// long doc -const long = Array.from({length: 400}, (_, i) => `Line ${i} lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor.`).join("\n\n"); -await page.evaluate(async (t) => { await navigator.clipboard.writeText(t); }, long); -await page.keyboard.press("Meta+ArrowDown"); await page.keyboard.press("Enter"); -const t0 = Date.now(); -await page.keyboard.press("Meta+v"); -await page.waitForTimeout(5000); -const ft = await pm.innerText(); -console.log("LONG chars:", ft.length, "contains Line 399:", ft.includes("Line 399"), "elapsed", Date.now()-t0); -// still responsive? -await page.keyboard.type("ZZTAIL", { delay: 10 }); -await page.waitForTimeout(1500); -console.log("responsive after long doc:", (await pm.innerText()).includes("ZZTAIL")); -await page.screenshot({ path: "/tmp/surfaces/10.4-long.png" }); -console.log("ERRORS", JSON.stringify(errors.slice(0,6))); -await context.close(); diff --git a/apps/ui/.scratch-surfaces/p105.ts b/apps/ui/.scratch-surfaces/p105.ts deleted file mode 100644 index e254ad16..00000000 --- a/apps/ui/.scratch-surfaces/p105.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { open, run } from "./drv.ts"; -const { context, page, errors } = await open(); -await page.waitForTimeout(5000); -const isWorld = async () => (await page.getByText("What Smithers currently understands").count()) > 0; -if (!(await isWorld())) await run(page, "/world", 4000); -const items = page.locator('[data-flow="world.select"]'); -for (let i = 0; i < await items.count(); i++) if ((await items.nth(i).innerText()).trim().startsWith("Untitled")) { await items.nth(i).click(); break; } -await page.waitForTimeout(1200); -const pm = page.locator(".ProseMirror").first(); -await pm.click(); -await page.keyboard.press("Meta+a"); await page.keyboard.press("Backspace"); -const marker = "PERSIST-MARKER-" + Date.now(); -await page.keyboard.type(marker, { delay: 12 }); -await page.waitForTimeout(2500); -console.log("typed:", marker); -// surface switch: world -> chat -> world -await run(page, "/chat", 3000); -console.log("world closed:", !(await isWorld())); -await run(page, "/world", 4000); -for (let i = 0; i < await items.count(); i++) if ((await items.nth(i).innerText()).trim().startsWith("Untitled")) { await items.nth(i).click(); break; } -await page.waitForTimeout(1500); -const afterSwitch = await page.locator(".ProseMirror").first().innerText(); -console.log("AFTER SURFACE SWITCH contains marker:", afterSwitch.includes(marker), JSON.stringify(afterSwitch.slice(0,120))); -// reload -await page.reload({ waitUntil: "domcontentloaded" }); -await page.waitForTimeout(8000); -if (!(await isWorld())) await run(page, "/world", 4000); -const items2 = page.locator('[data-flow="world.select"]'); -console.log("tree after reload:", JSON.stringify(await items2.allInnerTexts())); -for (let i = 0; i < await items2.count(); i++) if ((await items2.nth(i).innerText()).trim().startsWith("Untitled")) { await items2.nth(i).click(); break; } -await page.waitForTimeout(2000); -const afterReload = await page.locator(".ProseMirror").first().innerText(); -console.log("AFTER RELOAD contains marker:", afterReload.includes(marker), JSON.stringify(afterReload.slice(0,140))); -await page.screenshot({ path: "/tmp/surfaces/10.5.png", fullPage: true }); -console.log("ERRORS", JSON.stringify(errors.slice(0,5))); -await context.close(); diff --git a/apps/ui/.scratch-surfaces/p106.ts b/apps/ui/.scratch-surfaces/p106.ts deleted file mode 100644 index fce5e8eb..00000000 --- a/apps/ui/.scratch-surfaces/p106.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { open, run, text } from "./drv.ts"; -const { context, page, errors } = await open(); -await page.waitForTimeout(5000); -const isWorld = async () => (await page.getByText("What Smithers currently understands").count()) > 0; -if (!(await isWorld())) await run(page, "/world", 4000); -const items = page.locator('[data-flow="world.select"]'); -console.log("tree:", JSON.stringify(await items.allInnerTexts())); -for (let i = 0; i < await items.count(); i++) if ((await items.nth(i).innerText()).trim().startsWith("Untitled")) { await items.nth(i).click(); break; } -await page.waitForTimeout(1200); -const del = page.locator('[data-flow="world.delete"]'); -console.log("delete control count:", await del.count(), "aria:", await del.first().getAttribute("aria-label").catch(()=>null)); -await del.first().click(); -await page.waitForTimeout(1800); -const dlg = page.locator('[role="dialog"], [role="alertdialog"]'); -console.log("dialog count:", await dlg.count()); -const dlgText = await dlg.first().innerText().catch(async ()=>await text(page)); -console.log("DIALOG TEXT>>>", dlgText.slice(0,600)); -console.log("names the note title:", /Untitled 1/.test(dlgText)); -await page.screenshot({ path: "/tmp/surfaces/10.6-dialog.png", fullPage: true }); -// CANCEL -const cancel = page.locator('[data-flow="world.delete.cancel"]'); -console.log("cancel count:", await cancel.count()); -await cancel.first().click(); -await page.waitForTimeout(1500); -console.log("dialog after cancel:", await dlg.count()); -console.log("tree after cancel:", JSON.stringify(await items.allInnerTexts())); -// CONFIRM -await del.first().click(); -await page.waitForTimeout(1500); -await page.locator('[data-flow="world.delete.confirm"]').first().click(); -await page.waitForTimeout(2500); -console.log("tree after confirm:", JSON.stringify(await page.locator('[data-flow="world.select"]').allInnerTexts())); -await page.screenshot({ path: "/tmp/surfaces/10.6-after.png", fullPage: true }); -console.log("ERRORS", JSON.stringify(errors.slice(0,5))); -await context.close(); diff --git a/apps/ui/.scratch-surfaces/p106b.ts b/apps/ui/.scratch-surfaces/p106b.ts deleted file mode 100644 index 40561948..00000000 --- a/apps/ui/.scratch-surfaces/p106b.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { open, run, text } from "./drv.ts"; -const { context, page, errors } = await open(); -await page.waitForTimeout(5000); -const isWorld = async () => (await page.getByText("What Smithers currently understands").count()) > 0; -if (!(await isWorld())) await run(page, "/world", 4000); -const items = page.locator('[data-flow="world.select"]'); -console.log("tree:", JSON.stringify(await items.allInnerTexts())); -for (let i = 0; i < await items.count(); i++) if ((await items.nth(i).innerText()).trim().startsWith("Untitled")) { await items.nth(i).click(); break; } -await page.waitForTimeout(1200); -const del = page.locator('[data-flow="world.delete"]'); -console.log("delete count:", await del.count(), "aria:", await del.first().getAttribute("aria-label").catch(()=>null)); -await del.first().click(); -await page.waitForTimeout(1500); -const dlg = page.locator('[role="dialog"], [role="alertdialog"]').first(); -const btns = await page.evaluate(() => Array.from(document.querySelectorAll('[role="dialog"] button, [role="alertdialog"] button')).map((b: any) => ({ t: b.innerText, flow: b.getAttribute("data-flow"), aria: b.getAttribute("aria-label") }))); -console.log("DIALOG BUTTONS:", JSON.stringify(btns)); -// CANCEL by text -await page.getByRole("button", { name: /^Cancel$/ }).first().click(); -await page.waitForTimeout(1500); -console.log("dialog after cancel:", await page.locator('[role="dialog"], [role="alertdialog"]').count()); -console.log("tree after cancel:", JSON.stringify(await items.allInnerTexts())); -const stillSelected = await page.locator(".world-document-meta").innerText().catch(()=>null); -console.log("selected after cancel:", JSON.stringify(stillSelected?.split("\n")[0])); -// CONFIRM -await del.first().click(); -await page.waitForTimeout(1500); -await page.getByRole("button", { name: /^Delete$/ }).first().click(); -await page.waitForTimeout(2500); -console.log("tree after confirm:", JSON.stringify(await page.locator('[data-flow="world.select"]').allInnerTexts())); -await page.screenshot({ path: "/tmp/surfaces/10.6-after.png", fullPage: true }); -console.log("ERRORS", JSON.stringify(errors.slice(0,5))); -await context.close(); diff --git a/apps/ui/.scratch-surfaces/p107.ts b/apps/ui/.scratch-surfaces/p107.ts deleted file mode 100644 index ee72186d..00000000 --- a/apps/ui/.scratch-surfaces/p107.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { open, run, text } from "./drv.ts"; -const { context, page, errors } = await open(); -await page.waitForTimeout(6000); -const isWorld = async () => (await page.getByText("What Smithers currently understands").count()) > 0; -if (!(await isWorld())) await run(page, "/world", 4000); -for (let guard = 0; guard < 10; guard++) { - const items = page.locator('[data-flow="world.select"]'); - const n = await items.count(); - if (n === 0) break; - await items.first().click(); - await page.waitForTimeout(900); - await page.locator('[data-flow="world.delete"]').first().click(); - await page.waitForTimeout(1200); - await page.getByRole("button", { name: /^Delete$/ }).first().click(); - await page.waitForTimeout(1800); -} -console.log("remaining notes:", await page.locator('[data-flow="world.select"]').count()); -const pane = await page.locator(".world-surface, [class*='world']").first().innerText().catch(async () => await text(page)); -console.log("EMPTY PANE TEXT>>>", pane.slice(0, 600)); -const empty = page.locator("[class*='world-empty'], [class*='empty']"); -console.log("empty node count:", await empty.count()); -const btn = page.getByRole("button", { name: /Create a note/i }); -console.log("Create a note button count:", await btn.count()); -await page.screenshot({ path: "/tmp/surfaces/10.7-empty.png", fullPage: true }); -if (await btn.count() > 0) { - await btn.first().click(); - await page.waitForTimeout(2500); - console.log("after click, notes:", await page.locator('[data-flow="world.select"]').count(), JSON.stringify(await page.locator('[data-flow="world.select"]').allInnerTexts())); -} -await page.screenshot({ path: "/tmp/surfaces/10.7-after.png", fullPage: true }); -console.log("ERRORS", JSON.stringify(errors.slice(0,5))); -await context.close(); diff --git a/apps/ui/.scratch-surfaces/p108.ts b/apps/ui/.scratch-surfaces/p108.ts deleted file mode 100644 index 62d2aa76..00000000 --- a/apps/ui/.scratch-surfaces/p108.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { open, run, text } from "./drv.ts"; -const { context, page, errors } = await open(); -await page.waitForTimeout(5000); -const isWorld = async () => (await page.getByText("What Smithers currently understands").count()) > 0; -if (!(await isWorld())) await run(page, "/world", 4000); -const code = "zarquon-" + Math.random().toString(36).slice(2, 8); -// new note -await page.getByRole("button", { name: /New note/i }).first().click(); -await page.waitForTimeout(2000); -const pm = page.locator(".ProseMirror").first(); -await pm.click(); -await page.keyboard.press("Meta+a"); await page.keyboard.press("Backspace"); -await page.keyboard.type(`Canary codeword note`, { delay: 10 }); -await page.keyboard.press("Enter"); -await page.keyboard.type(`The canary codeword for this workspace is ${code}. Nothing else records it.`, { delay: 8 }); -await page.waitForTimeout(3000); -console.log("CODEWORD:", code); -console.log("note text:", JSON.stringify((await pm.innerText()).slice(0,200))); -console.log("tree:", JSON.stringify(await page.locator('[data-flow="world.select"]').allInnerTexts())); -await run(page, "/chat", 3000); -await run(page, `What is the canary codeword for this workspace? Answer with the codeword only.`, 40000); -const t = await text(page); -console.log("CONTAINS CODEWORD:", t.includes(code)); -console.log("TAIL>>>", t.slice(-2200)); -await page.screenshot({ path: "/tmp/surfaces/10.8.png", fullPage: true }); -console.log("ERRORS", JSON.stringify(errors.slice(0,5))); -await context.close(); diff --git a/apps/ui/.scratch-surfaces/p108b.ts b/apps/ui/.scratch-surfaces/p108b.ts deleted file mode 100644 index 3f638a75..00000000 --- a/apps/ui/.scratch-surfaces/p108b.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { open, run, text } from "./drv.ts"; -const { context, page, errors } = await open(); -await page.waitForTimeout(6000); -const isWorld = async () => (await page.getByText("What Smithers currently understands").count()) > 0; -if (!(await isWorld())) await run(page, "/world", 4000); -const items = page.locator('[data-flow="world.select"]'); -console.log("tree:", JSON.stringify(await items.allInnerTexts())); -for (let i = 0; i < await items.count(); i++) if ((await items.nth(i).innerText()).trim().startsWith("Untitled")) { await items.nth(i).click(); break; } -await page.waitForTimeout(1500); -const noteText = await page.locator(".ProseMirror").first().innerText(); -console.log("PERSISTED NOTE:", JSON.stringify(noteText.slice(0,220))); -const m = noteText.match(/zarquon-[a-z0-9]+/); -const code = m ? m[0] : "(none)"; -console.log("codeword in note:", code); -await run(page, "/chat", 3000); -for (const q of [ - `Read my World notes and tell me the canary codeword. It is written in a note in my World.`, - `/recall canary codeword`, -]) { - await run(page, q, 45000); - const t = await text(page); - console.log(`Q=${JSON.stringify(q)} -> containsCode=${t.includes(code)}`); - console.log(" tail:", t.slice(-700).replace(/\n+/g, " | ")); -} -await page.screenshot({ path: "/tmp/surfaces/10.8b.png", fullPage: true }); -console.log("ERRORS", JSON.stringify(errors.slice(0,5))); -await context.close(); diff --git a/apps/ui/.scratch-surfaces/p108c.ts b/apps/ui/.scratch-surfaces/p108c.ts deleted file mode 100644 index 73a3b588..00000000 --- a/apps/ui/.scratch-surfaces/p108c.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { open, run, text } from "./drv.ts"; -const { context, page, errors } = await open(); -await page.waitForTimeout(6000); -await run(page, "/debug.snapshot", 8000); -const t = await text(page); -const i = t.indexOf("world"); -console.log("SNAPSHOT tail>>>", t.slice(-3000)); -await page.screenshot({ path: "/tmp/surfaces/10.8-snapshot.png", fullPage: true }); -await context.close(); diff --git a/apps/ui/.scratch-surfaces/p108d.ts b/apps/ui/.scratch-surfaces/p108d.ts deleted file mode 100644 index 7ab21f65..00000000 --- a/apps/ui/.scratch-surfaces/p108d.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { open, run, text } from "./drv.ts"; -const { context, page } = await open(); -await page.waitForTimeout(6000); -await run(page, "/debug.snapshot", 8000); -const cards = await page.locator("pre, code, [class*='snapshot']").allInnerTexts(); -const all = cards.join("\n"); -console.log("len", all.length); -const idx = all.indexOf("worldDocuments"); -console.log("worldDocuments idx", idx, JSON.stringify(all.slice(Math.max(0,idx-200), idx+900))); -console.log("has zarquon:", all.includes("zarquon")); -await context.close(); diff --git a/apps/ui/.scratch-surfaces/p108e.ts b/apps/ui/.scratch-surfaces/p108e.ts deleted file mode 100644 index 3dc78ade..00000000 --- a/apps/ui/.scratch-surfaces/p108e.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { open, run, text } from "./drv.ts"; -const { context, page, errors } = await open(); -await page.waitForTimeout(6000); -await run(page, `Call the recall tool with query "zarquon" and paste the raw JSON results verbatim.`, 45000); -const t = await text(page); -console.log("has zarquon-bcc8jy:", t.includes("zarquon-bcc8jy")); -console.log("TAIL>>>", t.slice(-1600).replace(/\n+/g, " | ")); -await page.screenshot({ path: "/tmp/surfaces/10.8-recall.png", fullPage: true }); -await context.close(); diff --git a/apps/ui/.scratch-surfaces/p108f.ts b/apps/ui/.scratch-surfaces/p108f.ts deleted file mode 100644 index 41e2cce4..00000000 --- a/apps/ui/.scratch-surfaces/p108f.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { open, run, text } from "./drv.ts"; -const { context, page } = await open(); -await page.waitForTimeout(6000); -await run(page, `Run recall with the query "zarquon" and tell me exactly what it returned.`, 60000); -await page.waitForTimeout(15000); -const t = await text(page); -console.log("has code:", t.includes("zarquon-bcc8jy")); -const i = t.lastIndexOf("Run recall with the query"); -console.log("AFTER-Q>>>", t.slice(i).replace(/\n+/g," | ").slice(0,1500)); -await page.screenshot({ path: "/tmp/surfaces/10.8-recall2.png", fullPage: true }); -await context.close(); diff --git a/apps/ui/.scratch-surfaces/p108g.ts b/apps/ui/.scratch-surfaces/p108g.ts deleted file mode 100644 index 830f370f..00000000 --- a/apps/ui/.scratch-surfaces/p108g.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { open, run, text } from "./drv.ts"; -const { context, page } = await open(); -await page.waitForTimeout(6000); -await run(page, `Use the remember tool to write a worldview note titled "Canary Remember Probe" whose text is exactly: the canary probe token is quuxtoken123`, 60000); -await page.waitForTimeout(10000); -let t = await text(page); -console.log("STEP1 tail>>>", t.slice(t.lastIndexOf("Use the remember tool")).replace(/\n+/g," | ").slice(0,900)); -// check world pane -await run(page, "/world", 5000); -const items = page.locator('[data-flow="world.select"]'); -console.log("tree:", JSON.stringify(await items.allInnerTexts())); -for (let i = 0; i < await items.count(); i++) { - await items.nth(i).click(); await page.waitForTimeout(900); - console.log(" doc", i, JSON.stringify((await page.locator(".ProseMirror").first().innerText()).slice(0,140))); -} -await run(page, "/chat", 3000); -await run(page, `Run recall with query "quuxtoken123" and tell me what it returned.`, 60000); -await page.waitForTimeout(10000); -t = await text(page); -console.log("STEP2 has token:", t.includes("quuxtoken123")); -console.log("STEP2 tail>>>", t.slice(t.lastIndexOf("Run recall with query")).replace(/\n+/g," | ").slice(0,900)); -await page.screenshot({ path: "/tmp/surfaces/10.8-remember.png", fullPage: true }); -await context.close(); diff --git a/apps/ui/.scratch-surfaces/p108h.ts b/apps/ui/.scratch-surfaces/p108h.ts deleted file mode 100644 index 06df2a88..00000000 --- a/apps/ui/.scratch-surfaces/p108h.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { open, run, text } from "./drv.ts"; -const { context, page, errors } = await open(); -await page.waitForTimeout(6000); -const resp: string[] = []; -page.on("response", (r) => { if (r.url().includes("/api/model")) resp.push(`${r.status()} ${r.request().method()} ${r.url().replace("https://canary.smithers.sh","")}`); }); -const before = (await text(page)).length; -await run(page, `What is the canary codeword? It is in one of my World notes. Reply with just the codeword.`, 90000); -await page.waitForTimeout(20000); -const t = await text(page); -console.log("model calls:", JSON.stringify(resp)); -console.log("has zarquon-bcc8jy:", t.includes("zarquon-bcc8jy")); -const i = t.lastIndexOf("What is the canary codeword?"); -console.log("AFTER-Q>>>", t.slice(i).replace(/\n+/g," | ").slice(0,1200)); -console.log("ERRORS", JSON.stringify(errors.slice(0,6))); -await page.screenshot({ path: "/tmp/surfaces/10.8-final.png", fullPage: true }); -await context.close(); diff --git a/apps/ui/.scratch-surfaces/p91.ts b/apps/ui/.scratch-surfaces/p91.ts deleted file mode 100644 index 0e6990a2..00000000 --- a/apps/ui/.scratch-surfaces/p91.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { open, api, text } from "./drv.ts"; -const { context, page, errors } = await open({ reset: true }); -await page.waitForTimeout(5000); -const t = await text(page); -console.log("HAS proposes:", /Proposes/i.test(t), "| why now:", /Why now/i.test(t), "| what happens:", /What happens/i.test(t)); -for (const f of ["reco.accept","reco.edit","reco.dismiss","reco.refresh"]) { - const l = page.locator(`[data-flow="${f}"]`); - console.log(f, "count=", await l.count(), "text=", JSON.stringify(await l.first().innerText().catch(()=>null))); -} -console.log("DATAFLOWS:", await page.locator("[data-flows]").first().getAttribute("data-flows")); -const card = page.locator('[data-kind="recommendation"]'); -console.log("reco card count:", await card.count()); -console.log("CARDTEXT>>>", await card.first().innerText().catch(()=>"(none)")); -await page.screenshot({ path: "/tmp/surfaces/9.1.png", fullPage: false }); -console.log("ERRORS", JSON.stringify(errors.slice(0,5))); -await context.close(); diff --git a/apps/ui/.scratch-surfaces/p92.ts b/apps/ui/.scratch-surfaces/p92.ts deleted file mode 100644 index 3f7946f1..00000000 --- a/apps/ui/.scratch-surfaces/p92.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { open, text, api } from "./drv.ts"; -const { context, page, errors } = await open(); -const clear = await api(page, "/api/admin/reco-dismissals?login=codeplanesmithers", { method: "DELETE" }); -console.log("CLEARED", clear.status, clear.body); -const fresh = async () => { - await page.goto("about:blank", { waitUntil: "domcontentloaded" }); - const cdp = await context.newCDPSession(page); - await cdp.send("Storage.clearDataForOrigin", { origin: "https://canary.smithers.sh", storageTypes: "file_systems,local_storage,indexeddb,cache_storage,websql,service_workers" }); - await cdp.detach().catch(()=>{}); - await page.goto("https://canary.smithers.sh", { waitUntil: "domcontentloaded" }); - await page.waitForTimeout(9000); -}; -const headline = async () => { - const t = await text(page); - const m = t.match(/The read behind this\s*\n\s*([^\n]+)/); - return m ? m[1] : "(no reco card) tail=" + t.slice(-260).replace(/\n+/g," | "); -}; -await fresh(); -const h1 = await headline(); -console.log("RECO #1:", h1); -console.log("dismiss count:", await page.locator('[data-flow="reco.dismiss"]').count()); -// ONE KEY dismiss: focus composer, Escape. -await page.locator('textarea[aria-label="Chat message"]').click(); -await page.keyboard.press("Escape"); -await page.waitForTimeout(5000); -console.log("dismiss count after one key:", await page.locator('[data-flow="reco.dismiss"]').count()); -await fresh(); -const h2 = await headline(); -console.log("RECO #2 (after reload):", h2); -console.log("SAME UNCHANGED?", h1 === h2); -await page.screenshot({ path: "/tmp/surfaces/9.2.png", fullPage: true }); -console.log("ERRORS", JSON.stringify(errors.slice(0,5))); -await context.close(); diff --git a/apps/ui/.scratch-surfaces/p93.ts b/apps/ui/.scratch-surfaces/p93.ts deleted file mode 100644 index 7e734b0a..00000000 --- a/apps/ui/.scratch-surfaces/p93.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { open, text, api } from "./drv.ts"; -const { context, page, errors } = await open(); -const clear = await api(page, "/api/admin/reco-dismissals?login=codeplanesmithers", { method: "DELETE" }); -console.log("CLEARED", clear.status, clear.body); -// fresh slate -await page.goto("about:blank", { waitUntil: "domcontentloaded" }); -const cdp = await context.newCDPSession(page); -await cdp.send("Storage.clearDataForOrigin", { origin: "https://canary.smithers.sh", storageTypes: "file_systems,local_storage,indexeddb,cache_storage,websql,service_workers" }); -await cdp.detach().catch(()=>{}); -await page.goto("https://canary.smithers.sh", { waitUntil: "domcontentloaded" }); -await page.waitForTimeout(8000); -const resp: string[] = []; -page.on("response", (r) => { if (r.url().includes("/api/reco")) resp.push(`${r.status()} ${r.request().method()} ${r.url().replace("https://canary.smithers.sh","")}`); }); -const before = await text(page); -console.log("HEADLINE:", before.split("Proposes")[0].slice(-200).trim()); -console.log("dismiss affordance count:", await page.locator('[data-flow="reco.dismiss"]').count()); -// PART A: focus the COMPOSER, press Escape once. -const c = page.locator('textarea[aria-label="Chat message"]'); -await c.click(); -console.log("focus:", await page.evaluate(() => document.activeElement?.tagName + "/" + ((document.activeElement as any)?.getAttribute?.("aria-label") ?? ""))); -await page.keyboard.press("Escape"); -await page.waitForTimeout(4000); -console.log("RECO CALLS", JSON.stringify(resp)); -const after = await text(page); -console.log("AFTER-ESC-COMPOSER>>>", after.slice(-1400)); -await page.screenshot({ path: "/tmp/surfaces/9.3a.png", fullPage: true }); -console.log("ERRORS", JSON.stringify(errors.slice(0,5))); -await context.close(); diff --git a/apps/ui/.scratch-surfaces/p93b.ts b/apps/ui/.scratch-surfaces/p93b.ts deleted file mode 100644 index 7b4f43fe..00000000 --- a/apps/ui/.scratch-surfaces/p93b.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { open, text, api } from "./drv.ts"; -const { context, page, errors } = await open(); -const clear = await api(page, "/api/admin/reco-dismissals?login=codeplanesmithers", { method: "DELETE" }); -console.log("CLEARED", clear.status, clear.body); -await page.goto("about:blank", { waitUntil: "domcontentloaded" }); -const cdp = await context.newCDPSession(page); -await cdp.send("Storage.clearDataForOrigin", { origin: "https://canary.smithers.sh", storageTypes: "file_systems,local_storage,indexeddb,cache_storage,websql,service_workers" }); -await cdp.detach().catch(()=>{}); -await page.goto("https://canary.smithers.sh", { waitUntil: "domcontentloaded" }); -await page.waitForTimeout(8000); -const resp: string[] = []; -page.on("response", (r) => { if (r.url().includes("/api/reco")) resp.push(`${r.status()} ${r.request().method()} ${r.url().replace("https://canary.smithers.sh","")}`); }); -// Find the card element containing the reco.dismiss control, and focus IT. -const focused = await page.evaluate(() => { - const btn = document.querySelector('[data-flow="reco.dismiss"]'); - if (!btn) return "no dismiss button"; - let el: HTMLElement | null = btn as HTMLElement; - // walk up to the card container - while (el && !(el.getAttribute("data-slot")?.includes("card") || el.className?.toString().includes("card"))) el = el.parentElement; - const target = (el ?? (btn as HTMLElement)); - const info = { tag: target.tagName, slot: target.getAttribute("data-slot"), cls: target.className?.toString().slice(0,90), tabindex: target.getAttribute("tabindex") }; - (target as HTMLElement).focus?.(); - return JSON.stringify(info); -}); -console.log("CARD CONTAINER:", focused); -console.log("activeElement after focus():", await page.evaluate(() => { const a: any = document.activeElement; return a?.tagName + " slot=" + a?.getAttribute?.("data-slot") + " flow=" + a?.getAttribute?.("data-flow") + " cls=" + (a?.className?.toString?.().slice(0,60) ?? ""); })); -// Also try clicking on the card body text (focus on the card itself, not a control) -await page.locator('text=Why now').first().click({ force: true }); -console.log("activeElement after click on card body:", await page.evaluate(() => { const a: any = document.activeElement; return a?.tagName + " slot=" + a?.getAttribute?.("data-slot") + " flow=" + a?.getAttribute?.("data-flow") + " cls=" + (a?.className?.toString?.().slice(0,60) ?? ""); })); -await page.keyboard.press("Escape"); -await page.waitForTimeout(4000); -console.log("RECO CALLS", JSON.stringify(resp)); -console.log("AFTER-ESC-CARD>>>", (await text(page)).slice(-1400)); -console.log("dismiss affordance count after:", await page.locator('[data-flow="reco.dismiss"]').count()); -await page.screenshot({ path: "/tmp/surfaces/9.3b.png", fullPage: true }); -await context.close(); diff --git a/apps/ui/.scratch-surfaces/p94.ts b/apps/ui/.scratch-surfaces/p94.ts deleted file mode 100644 index 0e00ca56..00000000 --- a/apps/ui/.scratch-surfaces/p94.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { open, text } from "./drv.ts"; -const { context, page, errors } = await open({ reset: true }); -await page.waitForTimeout(6000); -const reqs: string[] = []; -const resp: string[] = []; -page.on("request", (r) => { if (r.url().includes("/api/")) reqs.push(`${r.method()} ${r.url().replace("https://canary.smithers.sh","")}`); }); -page.on("response", (r) => { if (r.url().includes("/api/")) resp.push(`${r.status()} ${r.url().replace("https://canary.smithers.sh","")}`); }); -const t0 = await text(page); -console.log("CARD BEFORE>>>", t0.slice(-1200)); -const accept = page.locator('[data-flow="reco.accept"]'); -console.log("accept count", await accept.count()); -await accept.first().click({ force: true }); -await page.waitForTimeout(25000); -console.log("REQS", JSON.stringify(reqs, null, 1)); -console.log("RESP", JSON.stringify(resp, null, 1)); -console.log("TAIL>>>", (await text(page)).slice(-2500)); -await page.screenshot({ path: "/tmp/surfaces/9.4.png", fullPage: true }); -console.log("ERRORS", JSON.stringify(errors.slice(0,6))); -await context.close(); diff --git a/apps/ui/.scratch-surfaces/p95.ts b/apps/ui/.scratch-surfaces/p95.ts deleted file mode 100644 index 9433f82a..00000000 --- a/apps/ui/.scratch-surfaces/p95.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { open, text } from "./drv.ts"; -const { context, page, errors } = await open(); -await page.waitForTimeout(4000); -const c = page.locator('textarea[aria-label="Chat message"]'); -console.log("composer before edit:", JSON.stringify(await c.inputValue())); -await page.locator('[data-flow="reco.edit"]').first().click({ force: true }); -await page.waitForTimeout(2500); -console.log("composer AFTER edit:", JSON.stringify(await c.inputValue())); -console.log("focused:", await page.evaluate(() => document.activeElement?.tagName + "/" + (document.activeElement as any)?.getAttribute?.("aria-label"))); -await page.screenshot({ path: "/tmp/surfaces/9.5a.png" }); -console.log("TAIL>>>", (await text(page)).slice(-1200)); -await context.close(); diff --git a/apps/ui/.scratch-surfaces/p95b.ts b/apps/ui/.scratch-surfaces/p95b.ts deleted file mode 100644 index 07ec07d8..00000000 --- a/apps/ui/.scratch-surfaces/p95b.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { open, text, api } from "./drv.ts"; -const { context, page, errors } = await open(); -await page.waitForTimeout(4000); -const reqs: string[] = []; -page.on("request", (r) => { if (r.url().includes("/api/")) reqs.push(`${r.method()} ${r.url().replace("https://canary.smithers.sh","")}`); }); -const c = page.locator('textarea[aria-label="Chat message"]'); -await page.locator('[data-flow="reco.edit"]').first().click({ force: true }); -await page.waitForTimeout(2000); -const original = await c.inputValue(); -console.log("ORIGINAL:", JSON.stringify(original.slice(0,120))); -const edited = "/issues.list open codeplanesmithers/demo-calendar"; -await c.fill(edited); -console.log("EDITED VALUE:", JSON.stringify(await c.inputValue())); -await page.locator('[data-flow="send"]').first().click({ force: true }); -await page.waitForTimeout(15000); -console.log("REQS", JSON.stringify(reqs)); -console.log("TAIL>>>", (await text(page)).slice(-1600)); -await page.screenshot({ path: "/tmp/surfaces/9.5b.png" }); -await context.close(); diff --git a/apps/ui/.scratch-surfaces/p96.ts b/apps/ui/.scratch-surfaces/p96.ts deleted file mode 100644 index b4880fcf..00000000 --- a/apps/ui/.scratch-surfaces/p96.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { open, api, text, run } from "./drv.ts"; -const { context, page, errors } = await open({ reset: true }); -await page.waitForTimeout(5000); -const before = await text(page); -console.log("BEFORE tail>>>", before.slice(-1200)); -await run(page, "/reco.refresh", 9000); -const after = await text(page); -console.log("AFTER tail>>>", after.slice(-2000)); -console.log("changed:", before !== after); -await page.screenshot({ path: "/tmp/surfaces/9.6.png" }); -console.log("ERRORS", JSON.stringify(errors.slice(0,5))); -await context.close(); diff --git a/apps/ui/.scratch-surfaces/p96b.ts b/apps/ui/.scratch-surfaces/p96b.ts deleted file mode 100644 index c3c5370d..00000000 --- a/apps/ui/.scratch-surfaces/p96b.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { open, text, run } from "./drv.ts"; -const { context, page, errors } = await open(); -await page.waitForTimeout(4000); -const before = await text(page); -await run(page, "/reco.refresh", 10000); -const after = await text(page); -console.log("changed:", before !== after); -console.log("AFTER tail>>>", after.slice(-2500)); -await page.screenshot({ path: "/tmp/surfaces/9.6.png" }); -console.log("ERRORS", JSON.stringify(errors.slice(0,5))); -await context.close(); diff --git a/apps/ui/.scratch-surfaces/p96c.ts b/apps/ui/.scratch-surfaces/p96c.ts deleted file mode 100644 index f9a3a27b..00000000 --- a/apps/ui/.scratch-surfaces/p96c.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { open, text, run } from "./drv.ts"; -const { context, page, errors } = await open(); -await page.waitForTimeout(4000); -const reqs: string[] = []; -page.on("request", (r) => { if (r.url().includes("/api/")) reqs.push(`${r.method()} ${r.url().replace("https://canary.smithers.sh","")}`); }); -const cardBefore = await page.locator('article, [class*="card"]').count(); -const idsBefore = await page.evaluate(() => Array.from(document.querySelectorAll('[data-card-id]')).map(e => e.getAttribute("data-card-id"))); -console.log("cardIdsBefore", JSON.stringify(idsBefore)); -await run(page, "/reco.refresh", 12000); -console.log("REQS", JSON.stringify(reqs, null, 1)); -const idsAfter = await page.evaluate(() => Array.from(document.querySelectorAll('[data-card-id]')).map(e => e.getAttribute("data-card-id"))); -console.log("cardIdsAfter", JSON.stringify(idsAfter)); -console.log("TAIL>>>", (await text(page)).slice(-900)); -await page.screenshot({ path: "/tmp/surfaces/9.6b.png" }); -await context.close(); diff --git a/apps/ui/.scratch-surfaces/p96d.ts b/apps/ui/.scratch-surfaces/p96d.ts deleted file mode 100644 index cdb8f0b9..00000000 --- a/apps/ui/.scratch-surfaces/p96d.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { open, text, run, api } from "./drv.ts"; -const { context, page, errors } = await open(); -await page.waitForTimeout(4000); -const before = (await text(page)).slice(-900); -console.log("BEFORE-CARD>>>", before.split("Proposes")[0].slice(-300)); -// Narrow the watched set so the recommendation MUST change, then refresh. -const put = await api(page, "/api/reco/watched", { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ selected: ["codeplanesmithers/demo-calendar"] }) }); -console.log("PUT watched", put.status, put.body.slice(0,200)); -await run(page, "/reco.refresh", 14000); -const mid = await text(page); -console.log("AFTER-NARROW>>>", mid.slice(-1400)); -// restore -const restore = await api(page, "/api/reco/watched", { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ selected: ["codeplanesmithers/canary-sandbox","codeplanesmithers/demo-calendar","codeplanesmithers/smithers-demo"] }) }); -console.log("RESTORE", restore.status, restore.body.slice(0,200)); -await run(page, "/reco.refresh", 14000); -console.log("AFTER-RESTORE>>>", (await text(page)).slice(-1000)); -await context.close(); diff --git a/apps/ui/.scratch-surfaces/p97.ts b/apps/ui/.scratch-surfaces/p97.ts deleted file mode 100644 index ca17a46f..00000000 --- a/apps/ui/.scratch-surfaces/p97.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { open, text, run, api } from "./drv.ts"; -const { context, page, errors } = await open({ reset: true }); -await page.waitForTimeout(6000); -const resp: string[] = []; -page.on("response", (r) => { if (r.url().includes("/api/reco")) resp.push(`${r.status()} ${r.request().method()} ${r.url().replace("https://canary.smithers.sh","")}`); }); -// Produce a feedback event: accept the card. -const stamp = Date.now(); -await page.locator('[data-flow="reco.accept"]').first().click({ force: true }); -await page.waitForTimeout(6000); -console.log("RECO REQS", JSON.stringify(resp)); -await run(page, "/admin.feedback", 12000); -const t = await text(page); -console.log("TAIL>>>", t.slice(-2500)); -await page.screenshot({ path: "/tmp/surfaces/9.7.png", fullPage: true }); -console.log("ERRORS", JSON.stringify(errors.slice(0,5))); -await context.close(); diff --git a/apps/ui/.scratch-surfaces/p98.ts b/apps/ui/.scratch-surfaces/p98.ts deleted file mode 100644 index d0ad7d15..00000000 --- a/apps/ui/.scratch-surfaces/p98.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { open, api, text } from "./drv.ts"; -const { context, page } = await open({ reset: true }); -await page.waitForTimeout(7000); -const fr = await api(page, "/api/reco/first-run"); -const body = JSON.parse(fr.body); -console.log("first-run status", fr.status); -console.log("keys:", Object.keys(body)); -console.log("needsSelection:", body.needsSelection, "degraded:", body.degraded, "emptySelection:", body.emptySelection); -console.log("recommendation:", JSON.stringify(body.recommendation)?.slice(0, 500)); -console.log("digest repos:", JSON.stringify(body.digest?.repos ?? body.digest)?.slice(0, 400)); -const w = await api(page, "/api/reco/watched"); -console.log("watched:", w.body.slice(0,300)); -console.log("reco card count:", await page.locator(".reco-card").count()); -await context.close(); diff --git a/apps/ui/.scratch-surfaces/pcomposer.ts b/apps/ui/.scratch-surfaces/pcomposer.ts deleted file mode 100644 index d1aeb9b1..00000000 --- a/apps/ui/.scratch-surfaces/pcomposer.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { open } from "./drv.ts"; -const { context, page } = await open(); -await page.waitForTimeout(4000); -const info = await page.evaluate(() => { - const els = Array.from(document.querySelectorAll('textarea, [contenteditable], input')); - return els.map((e: any) => ({ tag: e.tagName, id: e.id, cls: e.className?.toString().slice(0,80), slot: e.getAttribute("data-slot"), aria: e.getAttribute("aria-label"), ph: e.getAttribute("placeholder"), ce: e.getAttribute("contenteditable") })); -}); -console.log(JSON.stringify(info, null, 1)); -await context.close(); diff --git a/apps/ui/.scratch-surfaces/pdbg.ts b/apps/ui/.scratch-surfaces/pdbg.ts deleted file mode 100644 index 199c110f..00000000 --- a/apps/ui/.scratch-surfaces/pdbg.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { open, text } from "./drv.ts"; -const { context, page, errors } = await open(); -await page.waitForTimeout(4000); -const c = page.locator('textarea[aria-label="Chat message"]'); -console.log("visible:", await c.isVisible(), "enabled:", await c.isEnabled(), "count:", await c.count()); -await c.click(); -await c.fill("/reco.refresh"); -console.log("value after fill:", await c.inputValue()); -const send = page.locator('[data-flow="send"]'); -console.log("send count", await send.count(), "enabled", await send.first().isEnabled().catch(()=>null), "visible", await send.first().isVisible().catch(()=>null)); -await page.waitForTimeout(500); -await page.screenshot({ path: "/tmp/surfaces/dbg-before.png" }); -await send.first().click({ force: true }); -await page.waitForTimeout(6000); -console.log("value after send:", await c.inputValue()); -await page.screenshot({ path: "/tmp/surfaces/dbg-after.png" }); -const msgs = await page.locator('[data-slot="chat-transcript"]').innerText(); -console.log("TRANSCRIPT tail>>>", msgs.slice(-1800)); -console.log("ERRORS", JSON.stringify(errors.slice(0,8))); -await context.close(); diff --git a/apps/ui/.scratch-surfaces/plandings.ts b/apps/ui/.scratch-surfaces/plandings.ts deleted file mode 100644 index b6fd0b7b..00000000 --- a/apps/ui/.scratch-surfaces/plandings.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { open, api } from "./drv.ts"; -const { context, page } = await open(); -for (const p of ["/api/repos/codeplanesmithers/canary-sandbox/landings?limit=20","/api/repos/codeplanesmithers/canary-sandbox/landings/2"]) { - const r = await api(page, p); - console.log("GET", p, r.status, r.body.slice(0, 900)); -} -await context.close(); diff --git a/apps/ui/.scratch-surfaces/probe0.ts b/apps/ui/.scratch-surfaces/probe0.ts deleted file mode 100644 index 557ee3ea..00000000 --- a/apps/ui/.scratch-surfaces/probe0.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { open, api, text } from "./drv.ts"; -const { context, page, errors } = await open(); -const s = await api(page, "/api/auth/session"); -console.log("SESSION", s.status, s.body.slice(0, 400)); -const w = await api(page, "/api/reco/watched"); -console.log("WATCHED", w.status, w.body.slice(0, 400)); -const t = await text(page); -console.log("BODYTEXT>>>", t.slice(0, 2500)); -console.log("FLOWS-ATTR>>>", await page.locator("[data-flows]").first().getAttribute("data-flows").catch(() => null)); -console.log("ERRORS", JSON.stringify(errors.slice(0, 5))); -await context.close(); diff --git a/apps/ui/.scratch-surfaces/signin.ts b/apps/ui/.scratch-surfaces/signin.ts deleted file mode 100644 index 0fdb0f39..00000000 --- a/apps/ui/.scratch-surfaces/signin.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { open, api, text } from "./drv.ts"; -const { context, page, errors } = await open(); -const s = await api(page, "/api/auth/session"); -console.log("SESSION-BEFORE", s.status, s.body.slice(0,200)); -if (!s.body.includes('"login"')) { - await page.locator('[data-flow="auth.sign-in"]').last().click({ force: true }); - await page.waitForURL(/github\.com|canary\.smithers\.sh/, { timeout: 40000 }); - console.log("URL after click:", page.url()); - const auth = page.locator('button:has-text("Authorize"), input[value*="Authorize"]').first(); - if (await auth.isVisible().catch(() => false)) { await auth.click(); console.log("clicked authorize"); } - await page.waitForURL(/canary\.smithers\.sh/, { timeout: 60000 }).catch((e) => console.log("waitURL err", String(e).slice(0,200))); - await page.waitForTimeout(6000); -} -console.log("FINAL URL", page.url()); -const s2 = await api(page, "/api/auth/session"); -console.log("SESSION-AFTER", s2.status, s2.body.slice(0,400)); -const w = await api(page, "/api/reco/watched"); -console.log("WATCHED", w.status, w.body.slice(0,500)); -console.log("BODY>>>", (await text(page)).slice(0,1500)); -await context.close(); diff --git a/apps/ui/.smithers/package.json b/apps/ui/.smithers/package.json index d769aee4..c9a17a2b 100644 --- a/apps/ui/.smithers/package.json +++ b/apps/ui/.smithers/package.json @@ -3,7 +3,7 @@ "private": true, "type": "module", "scripts": { - "test": "bun test --preload ./preload.ts --max-concurrency=1 ./tests/production-readiness-swarm.test.tsx ./tests/universal-flow-runtime-swarm.test.tsx ./tests/security-guards.test.tsx", + "test": "bun test --preload ./preload.ts --max-concurrency=1 ./tests/production-readiness-swarm.test.tsx ./tests/universal-flow-runtime-swarm.test.tsx", "typecheck": "tsc --noEmit", "typecheck:production-readiness": "tsc -p ./tsconfig.production-readiness.json --noEmit", "gateway": "bun ./gateway.ts", diff --git a/apps/ui/.smithers/prompts/federation-update-smithers.mdx b/apps/ui/.smithers/prompts/federation-update-smithers.mdx index 0c52d267..cc9985c1 100644 --- a/apps/ui/.smithers/prompts/federation-update-smithers.mdx +++ b/apps/ui/.smithers/prompts/federation-update-smithers.mdx @@ -57,11 +57,6 @@ ${JSON.stringify(props.lanePushResults, null, 2)} each repo's own release script in dry-run mode; exit non-zero on any failure. - `--execute`: actually publish every publishable package in DAG order by - package name and exact version. It MUST require `--ledger <path>` and - support `--resume`. After each successful publish, atomically persist the - package name, version, registry, tarball integrity, and publication result - before starting the next package. On resume, query the registry and verify - the exact recorded version/integrity before skipping it; fail on drift. invoking each repo's per-repo release script, failing fast and reporting exactly which package/repo failed. 5. Update `README.md`, the Mintlify docs source under `docs/`, generated diff --git a/apps/ui/.smithers/tests/security-guards.test.tsx b/apps/ui/.smithers/tests/security-guards.test.tsx deleted file mode 100644 index 708f73ae..00000000 --- a/apps/ui/.smithers/tests/security-guards.test.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import { afterEach, describe, expect, test } from "bun:test"; -import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { safeExternalHref, resolveDocLink } from "../ui/ddd-shared.tsx"; -import { trustedGatewayUrl } from "../workflows/create-ui.tsx"; -import { evalCaseIdentity, suiteSchema } from "../workflows/eval-suite-run.tsx"; -import { isSafeRelativeArchitecturePath, resolveArchitectureSitePath } from "../workflows/production-readiness-swarm.tsx"; -import { claimPreparedStagingRoot, resolveOwnedStagingRoot } from "../workflows/share-pack.tsx"; -import { safeWorkflowSlug } from "../workflows/create-workflow.tsx"; -import { trustedWorkflowSourcePath } from "../workflows/post-failure.tsx"; -import { assertTokenSafeWebhookDestination } from "../workflows/whole-foods-meal-planner.tsx"; - -const temporaryRoots: string[] = []; -afterEach(() => { - while (temporaryRoots.length > 0) rmSync(temporaryRoots.pop()!, { recursive: true, force: true }); - delete process.env.SMITHERS_GATEWAY_URL; -}); - -describe("workflow security guards", () => { - test("gateway verification is bound to the configured origin", () => { - process.env.SMITHERS_GATEWAY_URL = "https://gateway.example"; - expect(trustedGatewayUrl("https://gateway.example")).toBe("https://gateway.example"); - expect(() => trustedGatewayUrl("http://169.254.169.254")).toThrow(); - expect(() => trustedGatewayUrl("https://user@gateway.example")).toThrow(); - expect(() => trustedGatewayUrl("https://gateway.example/redirect")).toThrow(); - }); - - test("eval suite identities are safe, unique, and index-stable", () => { - const base = { suiteId: "s", name: "suite", workflowKey: "w", workflowPath: "w.tsx", workflowRoot: "." }; - expect(() => suiteSchema.parse({ ...base, cases: [{ id: "same", input: 1 }, { id: "same", input: 2 }] })).toThrow(); - expect(() => suiteSchema.parse({ ...base, cases: [{ id: "../escape", input: 1 }] })).toThrow(); - expect(evalCaseIdentity("case", 2)).toBe("2-case"); - }); - - test("architecture paths cannot escape or traverse symlinks", () => { - expect(isSafeRelativeArchitecturePath("docs/architecture")).toBe(true); - for (const value of ["../outside", "/tmp/outside", "docs/../outside", "docs\\outside", ""]) { - expect(isSafeRelativeArchitecturePath(value)).toBe(false); - } - const root = mkdtempSync(join(tmpdir(), "architecture-root-")); - temporaryRoots.push(root); - symlinkSync(tmpdir(), join(root, "linked")); - expect(() => resolveArchitectureSitePath(root, "linked/site")).toThrow(); - }); - - test("share cleanup requires a run-owned temp marker", () => { - const prepared = mkdtempSync(join(tmpdir(), "smithers-share-stage-")); - const stagingId = claimPreparedStagingRoot(prepared, "run-a"); - const owned = resolveOwnedStagingRoot(stagingId, "run-a"); - temporaryRoots.push(owned.parent); - expect(owned.staging.startsWith(owned.parent)).toBe(true); - expect(() => resolveOwnedStagingRoot(stagingId, "run-b")).toThrow(); - expect(() => resolveOwnedStagingRoot("../../tmp", "run-a")).toThrow(); - }); - - test("documentation links allow only narrow external schemes", () => { - expect(safeExternalHref("https://example.com/docs")).toBe("https://example.com/docs"); - expect(safeExternalHref("mailto:user@example.com")).toBe("mailto:user@example.com"); - for (const value of ["javascript:alert(1)", "javascript://alert(1)", "JaVaScRiPt://alert(1)", "data:text/html,x", "javascript%3Aalert(1)"]) { - expect(safeExternalHref(value)).toBeNull(); - expect(resolveDocLink("docs/index.md", value, () => false)).toBeNull(); - } - }); - - test("agent-derived workflow names use the same slug contract", () => { - expect(safeWorkflowSlug("safe-workflow")).toBe("safe-workflow"); - for (const value of ["../escape", "nested/name", "UpperCase", "", "."]) expect(() => safeWorkflowSlug(value)).toThrow(); - }); - - test("post-failure source reads stay inside real workflow roots", () => { - const root = mkdtempSync(join(tmpdir(), "post-failure-root-")); - temporaryRoots.push(root); - mkdirSync(join(root, ".smithers", "workflows"), { recursive: true }); - writeFileSync(join(root, ".smithers", "workflows", "safe.tsx"), "export default 1"); - writeFileSync(join(root, "secret.tsx"), "secret"); - symlinkSync(join(root, "secret.tsx"), join(root, ".smithers", "workflows", "linked.tsx")); - expect(trustedWorkflowSourcePath(".smithers/workflows/safe.tsx", root)).toEndWith("safe.tsx"); - expect(trustedWorkflowSourcePath("secret.tsx", root)).toBeNull(); - expect(trustedWorkflowSourcePath(".smithers/workflows/linked.tsx", root)).toBeNull(); - }); - - test("bearer-token webhooks cannot rely on re-resolvable DNS", async () => { - await expect(assertTokenSafeWebhookDestination("https://attacker.example/order", "secret")).rejects.toThrow("IP-literal"); - await expect(assertTokenSafeWebhookDestination("https://127.0.0.1/order", "secret")).rejects.toThrow("non-public"); - }); - - test("approval and publication workflows retain their binding and resume contracts", () => { - const workflows = join(import.meta.dir, "..", "workflows"); - const docs = readFileSync(join(workflows, "docs-driven-development.tsx"), "utf8"); - expect(docs).not.toContain("implementationApproved"); - expect(docs).toContain("triageReady(ctx) && approvalRequired"); - expect(docs).toContain('dependsOn={approvalRequired ? ["materialize-tickets", "approve-implementation"]'); - - const federation = readFileSync(join(workflows, "smithers-repo-federation.tsx"), "utf8"); - expect(federation).toContain("captureMergeApprovalBinding"); - expect(federation).toContain("PR head, base, repository, number, or checks drifted after approval"); - expect(federation).toContain('"--ledger", ledgerPath, "--resume"'); - expect(federation).toContain("Release is partial:"); - - const universal = readFileSync(join(workflows, "universal-flow-runtime-swarm.tsx"), "utf8"); - expect(universal).toContain("snapshot.bases"); - expect(universal).toContain("landing ledger does not match"); - expect(universal).toContain("--force-with-lease=refs/heads/"); - expect(universal).toContain("Existing worktree origin mismatch"); - }); -}); diff --git a/apps/ui/.smithers/tests/universal-flow-runtime-swarm.test.tsx b/apps/ui/.smithers/tests/universal-flow-runtime-swarm.test.tsx index a9e73da7..19e5495d 100644 --- a/apps/ui/.smithers/tests/universal-flow-runtime-swarm.test.tsx +++ b/apps/ui/.smithers/tests/universal-flow-runtime-swarm.test.tsx @@ -131,9 +131,8 @@ const synthesis = { }; const snapshot = { - revisionId: repositories.map((repo) => `${repo.name}:${repo.baseSha}->${"b".repeat(40)}`).join("|"), + revisionId: repositories.map((repo) => `${repo.name}:${"b".repeat(40)}`).join("|"), heads: repositories.map((repo) => ({ repo: repo.name, sha: "b".repeat(40) })), - bases: repositories.map((repo) => ({ repo: repo.name, sha: repo.baseSha })), }; function approvedReview(reviewer: "fable" | "sol") { diff --git a/apps/ui/.smithers/ui/ddd-shared.tsx b/apps/ui/.smithers/ui/ddd-shared.tsx index 26215ed4..7c8ed5f7 100644 --- a/apps/ui/.smithers/ui/ddd-shared.tsx +++ b/apps/ui/.smithers/ui/ddd-shared.tsx @@ -1093,29 +1093,6 @@ export type DocLinkTarget = | { kind: "doc"; path: string; anchor: string } | { kind: "anchor"; anchor: string }; -/** Return a normalized external URL only for the explicit safe protocol set. */ -export function safeExternalHref(rawHref: string): string | null { - const href = rawHref.trim(); - if (!href || /[\u0000-\u001f\u007f]/.test(href)) return null; - try { - const parsed = new URL(href); - if (parsed.protocol === "http:" || parsed.protocol === "https:") { - return parsed.username || parsed.password ? null : parsed.href; - } - if (parsed.protocol === "mailto:") { - const address = href.slice("mailto:".length).split("?", 1)[0] ?? ""; - return /^[^@\s]+@[^@\s]+$/.test(address) ? href : null; - } - } catch { - return null; - } - return null; -} - -function hasExplicitScheme(href: string): boolean { - return /^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith("//"); -} - /** * Resolve a markdown link `href` written inside the doc at `fromPath` to a real * target. Doc paths are content-root-relative (e.g. `overview.md`, @@ -1132,9 +1109,9 @@ export function resolveDocLink( ): DocLinkTarget | null { const trimmed = (href ?? "").trim(); if (!trimmed) return null; - const external = safeExternalHref(trimmed); - if (external) return { kind: "external", href: external }; - if (hasExplicitScheme(trimmed)) return null; + if (/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) || trimmed.startsWith("mailto:")) { + return { kind: "external", href: trimmed }; + } const hashIdx = trimmed.indexOf("#"); const rawPath = (hashIdx >= 0 ? trimmed.slice(0, hashIdx) : trimmed).trim(); const anchor = hashIdx >= 0 ? trimmed.slice(hashIdx + 1) : ""; @@ -1272,12 +1249,10 @@ export function MarkdownEditor({ if (!href) return; event.preventDefault(); event.stopPropagation(); - const external = safeExternalHref(href); - if (external) { - window.open(external, "_blank", "noopener"); + if (/^[a-z][a-z0-9+.-]*:\/\//i.test(href) || href.startsWith("mailto:")) { + window.open(href, "_blank", "noopener"); return; } - if (hasExplicitScheme(href)) return; if (href.startsWith("#")) { const target = host.querySelector(`[id="${href.slice(1)}"]`); target?.scrollIntoView({ behavior: "smooth", block: "start" }); @@ -1585,19 +1560,12 @@ function renderInlineMarkdown(value: string, keyPrefix: string, onLinkClick?: (h flushText(cursor); const label = value.slice(cursor + 1, labelEnd); const href = unescapeMarkdownText(value.slice(labelEnd + 2, hrefEnd)); - const external = safeExternalHref(href); - if (external) { + if (/^[a-z][a-z0-9+.-]*:\/\//i.test(href) || href.startsWith("mailto:")) { nodes.push( - <a key={`${keyPrefix}:link:${cursor}`} className="doc-link" href={external} target="_blank" rel="noreferrer"> + <a key={`${keyPrefix}:link:${cursor}`} className="doc-link" href={href} target="_blank" rel="noreferrer"> {renderInlineMarkdown(label, `${keyPrefix}:link-label:${cursor}`, onLinkClick)} </a>, ); - } else if (hasExplicitScheme(href)) { - nodes.push( - <span key={`${keyPrefix}:link:${cursor}`} className="doc-link"> - {renderInlineMarkdown(label, `${keyPrefix}:link-label:${cursor}`, onLinkClick)} - </span>, - ); } else { nodes.push( <button @@ -1710,7 +1678,7 @@ export function MarkdownPreview({ markdown, onLinkClick }: { markdown: string; o export function WorkflowSource({ workflowKey = "docs-driven-development" }: { workflowKey?: string }) { const entry = - (workflowSources as Record<string, { path: string; source: string }>)[workflowKey] ?? + workflowSources[workflowKey] ?? (workflowKey === "docs-driven-development" ? { path: workflowSourcePath, source: workflowSource } : undefined); if (!entry?.source) return null; const lineCount = entry.source.split("\n").length; @@ -1789,18 +1757,17 @@ export function EndpointBlock({ export function LinkBlock({ links, onOpenDoc }: { links?: FeatureLink[]; onOpenDoc?: (href: string) => void }) { const items = links ?? []; if (items.length === 0) return null; + const isExternal = (href: string) => /^https?:\/\//.test(href); return ( <div className="list-block"> <strong>Related docs</strong> <ul> {items.map((link, index) => ( <li key={`link:${index}`}> - {safeExternalHref(link.href) ? ( - <a className="doc-link" href={safeExternalHref(link.href)!} target="_blank" rel="noreferrer"> + {isExternal(link.href) ? ( + <a className="doc-link" href={link.href} target="_blank" rel="noreferrer"> {link.label} ↗ </a> - ) : hasExplicitScheme(link.href) ? ( - <span className="doc-link">{link.label}</span> ) : ( <button type="button" className="doc-link" onClick={() => onOpenDoc?.(link.href)}> {link.label} → diff --git a/apps/ui/.smithers/workflows/create-ui.tsx b/apps/ui/.smithers/workflows/create-ui.tsx index 2c6364eb..e91c125b 100644 --- a/apps/ui/.smithers/workflows/create-ui.tsx +++ b/apps/ui/.smithers/workflows/create-ui.tsx @@ -11,34 +11,6 @@ import { existsSync, readFileSync } from "node:fs"; import { z } from "zod/v4"; import { agents } from "../agents"; -const DEFAULT_GATEWAY_URL = "http://127.0.0.1:7331"; - -function configuredGatewayOrigin(): string { - const configured = process.env.SMITHERS_GATEWAY_URL?.trim() || DEFAULT_GATEWAY_URL; - const parsed = new URL(configured); - if ((parsed.protocol !== "http:" && parsed.protocol !== "https:") || parsed.username || parsed.password) { - throw new Error("SMITHERS_GATEWAY_URL must be an HTTP(S) origin without credentials"); - } - return parsed.origin; -} - -/** Accept only the operator-configured Gateway origin, never a caller-selected host. */ -export function trustedGatewayUrl(value: string | undefined): string { - const trustedOrigin = configuredGatewayOrigin(); - const parsed = new URL(value?.trim() || trustedOrigin); - if ( - parsed.origin !== trustedOrigin || - parsed.username || - parsed.password || - parsed.pathname !== "/" || - parsed.search || - parsed.hash - ) { - throw new Error(`gatewayUrl must be exactly the configured Gateway origin (${trustedOrigin})`); - } - return trustedOrigin; -} - const inputSchema = z.object({ targetWorkflow: z .string() @@ -56,16 +28,16 @@ const inputSchema = z.object({ z .string() .url() - .transform((value, ctx) => { + .refine((value) => { try { - return trustedGatewayUrl(value); - } catch (error) { - ctx.addIssue({ code: "custom", message: error instanceof Error ? error.message : String(error) }); - return z.NEVER; + const parsed = new URL(value); + return (parsed.protocol === "http:" || parsed.protocol === "https:") && !/[\\'"`;$(){}<>\n\r]/.test(value); + } catch { + return false; } - }), + }, "gatewayUrl must be a safe HTTP(S) URL"), ) - .default(DEFAULT_GATEWAY_URL), + .default("http://127.0.0.1:7331"), exampleRunId: z.string().default(""), }); @@ -162,9 +134,10 @@ export async function verifyGatewayUi( const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), gatewayRequestTimeoutMs); try { - const response = await fetch(url, { signal: controller.signal, redirect: "manual" }); + const response = await fetch(url, { signal: controller.signal }); if (response.status === 200) return null; - return { rule, detail: `${url} returned HTTP ${response.status}.` }; + const body = (await response.text()).replace(/\s+/g, " ").slice(0, 240); + return { rule, detail: `${url} returned HTTP ${response.status}${body ? `: ${body}` : "."}` }; } catch (error) { return { rule, detail: `${url} could not be requested: ${String(error)}` }; } finally { @@ -218,7 +191,7 @@ export async function gradeUi( export default smithers((ctx) => { const raw = (ctx.input ?? {}) as Record<string, unknown>; const target = String(raw.targetWorkflow ?? "").trim(); - const gatewayUrl = trustedGatewayUrl(String(raw.gatewayUrl ?? "").trim() || undefined); + const gatewayUrl = String(raw.gatewayUrl ?? "").trim() || "http://127.0.0.1:7331"; const exampleRunId = String(raw.exampleRunId ?? "").trim(); const compliance = ctx.latest(outputs.cuCompliance, "ui-compliance"); diff --git a/apps/ui/.smithers/workflows/create-workflow.tsx b/apps/ui/.smithers/workflows/create-workflow.tsx index 7df473ef..a656e2f1 100644 --- a/apps/ui/.smithers/workflows/create-workflow.tsx +++ b/apps/ui/.smithers/workflows/create-workflow.tsx @@ -24,11 +24,6 @@ const UI_DIR = ".smithers/ui"; const TESTS_DIR = ".smithers/tests"; const PACK_PRELOAD = ".smithers/preload.ts"; const PACK_PACKAGE_JSON = ".smithers/package.json"; -const workflowSlugSchema = z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "must be a kebab-case identifier"); - -export function safeWorkflowSlug(value: unknown): string { - return workflowSlugSchema.parse(value); -} /** * Is the new workflow's test registered in the pack's `test` script? That @@ -71,7 +66,9 @@ const inputSchema = z.object({ .string() .default("Describe the workflow you want to build, in plain English.") .describe("Plain-English description of the workflow you want Smithers to build."), - name: workflowSlugSchema + name: z + .string() + .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/) .nullable() .default(null) .describe("Desired kebab-case workflow id. Null lets the clarify/design steps choose one."), @@ -93,7 +90,7 @@ const clarifiedSpecSchema = z.looseObject({ .describe( "Right-size the request BEFORE building anything. direct = a trivial edit the operator should just make; oneshot = one strong agent finishes it in one context window (route to `smithers oneshot`); workflow = the task genuinely needs ordered stages, durability, approvals, loops, or reuse. Only tier `workflow` proceeds to design.", ), - name: workflowSlugSchema.describe("Proposed kebab-case workflow id."), + name: z.string().describe("Proposed kebab-case workflow id."), goal: z.string().describe("One sentence: what the finished workflow accomplishes."), trigger: z .string() @@ -152,7 +149,7 @@ const provisioningSchema = z.looseObject({ // 3. The concrete design the scaffolder will turn into real files. const designSchema = z.looseObject({ - workflowName: workflowSlugSchema, + workflowName: z.string(), summary: z.string(), inputs: z .array(z.object({ name: z.string(), type: z.string(), default: z.string().nullable().default(null) })) @@ -175,7 +172,7 @@ const designSchema = z.looseObject({ .string() .describe("How the JSX tree nests: Sequence/Parallel/Branch/Loop/Ralph/ReviewLoop, with gates and loops."), components: z.array(z.string()).default([]), - prompts: z.array(z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*(?:\.mdx)?$/, "prompt names must be safe relative filenames")).default([]).describe(".mdx prompt files to author alongside the workflow."), + prompts: z.array(z.string()).default([]).describe(".mdx prompt files to author alongside the workflow."), triggers: z.array(z.string()).default([]), humanGates: z.array(z.string()).default([]), ui: z @@ -201,7 +198,7 @@ const approvalSchema = z.looseObject({ // 5 & 6. Files written by the scaffold / fix agents. const scaffoldSchema = z.looseObject({ summary: z.string(), - workflowName: workflowSlugSchema, + workflowName: z.string(), filesWritten: z .array( z.object({ @@ -317,9 +314,8 @@ export default smithers((ctx) => { const proceed = designed && approved; // The name we scaffold + verify against, resolved as soon as it is known. - const workflowName = safeWorkflowSlug( - scaffold?.workflowName ?? design?.workflowName ?? clarify?.name ?? ctx.input.name ?? "new-workflow", - ); + const workflowName = + scaffold?.workflowName ?? design?.workflowName ?? clarify?.name ?? ctx.input.name ?? "new-workflow"; const workflowFile = `${WORKFLOWS_DIR}/${workflowName}.tsx`; const uiFile = `${UI_DIR}/${workflowName}.tsx`; const testFile = `${TESTS_DIR}/${workflowName}.test.tsx`; @@ -455,7 +451,7 @@ export default smithers((ctx) => { <Task id="verify" output={outputs.verify} dependsOn={shouldFix ? ["fix"] : []}> {async () => { const activeScaffold = ctx.latest(outputs.scaffold, "fix") ?? scaffold; - const activeWorkflowName = safeWorkflowSlug(activeScaffold?.workflowName ?? workflowName); + const activeWorkflowName = activeScaffold?.workflowName ?? workflowName; const activeWorkflowFile = `${WORKFLOWS_DIR}/${activeWorkflowName}.tsx`; const activeUiFile = `${UI_DIR}/${activeWorkflowName}.tsx`; const bunx = process.env.SMITHERS_BUNX ?? "bunx"; diff --git a/apps/ui/.smithers/workflows/docs-driven-development.tsx b/apps/ui/.smithers/workflows/docs-driven-development.tsx index 1c92f4a8..af55301a 100644 --- a/apps/ui/.smithers/workflows/docs-driven-development.tsx +++ b/apps/ui/.smithers/workflows/docs-driven-development.tsx @@ -33,6 +33,7 @@ const changedFileSchema = z.object({ const inputSchema = z.object({ maxAgents: z.preprocess((value) => value ?? undefined, z.number().int().min(1).max(1).default(1)), maxRounds: z.preprocess((value) => value ?? undefined, z.number().int().min(1).max(100000).default(100000)), + implementationApproved: z.preprocess((value) => value ?? undefined, z.boolean().default(true)), requireImplementationApproval: z.preprocess((value) => value ?? undefined, z.boolean().default(false)), runImplementation: z.preprocess((value) => value ?? undefined, z.boolean().default(true)), metaTicket: z @@ -533,8 +534,10 @@ export default smithers((ctx) => { // fallback). Only an explicit >=1 input overrides the long-running default. const maxRounds = resolveMaxIterations(ctx.input.maxRounds, runImplementation); const requireImplementationApproval = ctx.input.requireImplementationApproval === true; - const approvalRequired = runImplementation && requireImplementationApproval; - const workApproved = runImplementation; + const implementationApproved = ctx.input.implementationApproved !== false; + const approvalRequired = runImplementation && requireImplementationApproval && !implementationApproved; + const workApproved = + runImplementation && (implementationApproved || !requireImplementationApproval || approvalRequired); return ( <Workflow name="docs-driven-development"> diff --git a/apps/ui/.smithers/workflows/eval-suite-run.tsx b/apps/ui/.smithers/workflows/eval-suite-run.tsx index 9680312a..5c58906c 100644 --- a/apps/ui/.smithers/workflows/eval-suite-run.tsx +++ b/apps/ui/.smithers/workflows/eval-suite-run.tsx @@ -42,42 +42,23 @@ import { z } from "zod/v4"; const CASE_TIMEOUT_MS = 30 * 60_000; const DEFAULT_MAX_CONCURRENCY = 4; -const evalCaseId = z.string().trim().min(1).max(120).regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/, "case id must be a safe identifier"); const evalCaseInputSchema = z.object({ - id: evalCaseId, + id: z.string(), name: z.string().optional(), input: z.any(), expected: z.any().optional(), }); -export const suiteSchema = z.object({ +const suiteSchema = z.object({ suiteId: z.string().trim().min(1), name: z.string(), workflowKey: z.string(), workflowPath: z.string(), workflowRoot: z.string(), cases: z.array(evalCaseInputSchema), -}).superRefine((suite, ctx) => { - const seen = new Map<string, number>(); - suite.cases.forEach((item, index) => { - const prior = seen.get(item.id); - if (prior !== undefined) { - ctx.addIssue({ - code: "custom", - path: ["cases", index, "id"], - message: `duplicate case id ${JSON.stringify(item.id)} (first used at index ${prior})`, - }); - } else { - seen.set(item.id, index); - } - }); }); -export function evalCaseIdentity(caseId: string, index: number): string { - return `${index}-${caseId}`; -} - const caseResultSchema = z.object({ caseId: z.string(), status: z.enum(["ok", "failed", "cancelled"]), @@ -112,7 +93,7 @@ const { Workflow, Sequence, Parallel, Task, smithers, outputs, db } = createSmit * @param {unknown} error * @returns {string} */ -function formatCaseError(error: unknown) { +function formatCaseError(error) { return error instanceof Error ? error.message : String(error); } @@ -126,20 +107,17 @@ export default smithers((ctx) => { <Sequence> <Task id="plan" output={outputs.suite} timeoutMs={2 * 60_000}> {async () => { - const rawLoaded = await readEvalSuite(db, ctx.input.suiteId); - if (!rawLoaded) { + const loaded = await readEvalSuite(db, ctx.input.suiteId); + if (!loaded) { throw new Error(`Unknown eval suite: ${ctx.input.suiteId}`); } - // Parse before writing queued rows: duplicate/unsafe ids must not - // collide in persisted rows, task ids, React keys, or child runs. - const loaded = suiteSchema.parse(rawLoaded); // Seed one `queued` row per case up front — the results table is // live from second zero, and an unstarted case still joins in // multi's canvas instead of appearing as a silent gap. await Promise.all( loaded.cases.map((c, index) => writeEvalCaseRow(db, { - id: `${ctx.runId}:${evalCaseIdentity(c.id, index)}`, + id: `${ctx.runId}:${c.id}`, evalRunId: ctx.runId, suiteId: loaded.suiteId, caseId: c.id, @@ -160,8 +138,8 @@ export default smithers((ctx) => { ? null : cases.map((c, index) => ( <Task - key={evalCaseIdentity(c.id, index)} - id={`case-${evalCaseIdentity(c.id, index)}`} + key={c.id} + id={`case-${c.id}`} output={outputs.caseResult} groundTruth={c.expected} scorers={{ assertions: { scorer: evalAssertionScorer(), sampling: { type: "all" } } }} @@ -169,9 +147,8 @@ export default smithers((ctx) => { timeoutMs={CASE_TIMEOUT_MS} > {async () => { - const identity = evalCaseIdentity(c.id, index); - const rowId = `${ctx.runId}:${identity}`; - const caseRunId = evalCaseRunId(suite.suiteId, identity, ctx.runId); + const rowId = `${ctx.runId}:${c.id}`; + const caseRunId = evalCaseRunId(suite.suiteId, c.id, ctx.runId); const startedAtMs = Date.now(); await writeEvalCaseRow(db, { id: rowId, @@ -263,16 +240,13 @@ export default smithers((ctx) => { ))} </Parallel> - <Task id="verdict" output={outputs.verdict} dependsOn={cases.map((c, index) => `case-${evalCaseIdentity(c.id, index)}`)}> + <Task id="verdict" output={outputs.verdict} dependsOn={cases.map((c) => `case-${c.id}`)}> {() => { - const results = cases.map((c, index) => - ctx.outputMaybe(outputs.caseResult, { nodeId: `case-${evalCaseIdentity(c.id, index)}` }), - ); + const results = cases.map((c) => ctx.outputMaybe(outputs.caseResult, { nodeId: `case-${c.id}` })); const total = results.length; - const complete = results.every((result, index) => result?.caseId === cases[index]?.id); const passed = results.filter((r) => r?.passed === true).length; const inconclusive = results.filter((r) => Boolean(r?.inconclusive)).length; - const pass = total > 0 && complete && passed === total; + const pass = total > 0 && passed === total; const suiteName = suite?.name ?? ctx.input.suiteId; // Inconclusive cases are harness/environment faults: name them so // a driving loop repairs the harness instead of the workflow. diff --git a/apps/ui/.smithers/workflows/post-failure.tsx b/apps/ui/.smithers/workflows/post-failure.tsx index 7d70ab0e..195cb612 100644 --- a/apps/ui/.smithers/workflows/post-failure.tsx +++ b/apps/ui/.smithers/workflows/post-failure.tsx @@ -6,8 +6,6 @@ /** @jsxImportSource smthrs */ import { $ } from "bun"; import { createSmithers, Approval } from "smthrs"; -import { existsSync, lstatSync, realpathSync } from "node:fs"; -import { extname, join, relative, resolve, sep } from "node:path"; import { z } from "zod/v4"; import { agents } from "../agents"; @@ -106,37 +104,6 @@ function tailLines(text: string, max: number): string[] { .slice(-max); } -/** Resolve only real workflow sources under this installed pack. */ -export function trustedWorkflowSourcePath(rawPath: string | null | undefined, root = process.cwd()): string | null { - if (!rawPath) return null; - const candidate = resolve(root, rawPath); - if (![".ts", ".tsx", ".mdx"].includes(extname(candidate)) || !existsSync(candidate) || !lstatSync(candidate).isFile()) { - return null; - } - const canonicalCandidate = realpathSync(candidate); - for (const allowedRelative of [".smithers/workflows", ".smithers/monitor"]) { - const allowed = join(root, allowedRelative); - if (!existsSync(allowed)) continue; - const canonicalAllowed = realpathSync(allowed); - const lexicalRelative = relative(resolve(allowed), candidate); - if (!lexicalRelative || lexicalRelative === ".." || lexicalRelative.startsWith(`..${sep}`)) continue; - let lexicalCursor = resolve(allowed); - let safe = true; - for (const part of lexicalRelative.split(sep)) { - lexicalCursor = join(lexicalCursor, part); - if (lstatSync(lexicalCursor).isSymbolicLink()) { - safe = false; - break; - } - } - if (!safe) continue; - const rel = relative(canonicalAllowed, canonicalCandidate); - if (!rel || rel.startsWith(`..${sep}`) || rel === ".." || resolve(canonicalAllowed, rel) !== canonicalCandidate) continue; - return canonicalCandidate; - } - return null; -} - export default smithers((ctx) => { // `smithers graph` renders with an empty input object so it can inspect the // workflow without executing it. Runtime runs are still schema-validated, @@ -144,7 +111,7 @@ export default smithers((ctx) => { const inputTargetRunId = ctx.input?.targetRunId; const targetRunId = typeof inputTargetRunId === "string" && inputTargetRunId.trim() ? inputTargetRunId.trim() : "<target-run-id>"; - const workflowPath = trustedWorkflowSourcePath(ctx.input?.workflowPath ?? null); + const workflowPath = ctx.input?.workflowPath ?? null; const gather = ctx.outputMaybe("gather", { nodeId: "gather" }); const investigate = ctx.outputMaybe("investigate", { nodeId: "investigate" }); diff --git a/apps/ui/.smithers/workflows/production-readiness-swarm.tsx b/apps/ui/.smithers/workflows/production-readiness-swarm.tsx index e4ca99a5..00c13e0d 100644 --- a/apps/ui/.smithers/workflows/production-readiness-swarm.tsx +++ b/apps/ui/.smithers/workflows/production-readiness-swarm.tsx @@ -122,7 +122,7 @@ const acceptanceAssertions = z.strictObject({ const input = z.strictObject({ targetRepo: z.string().min(1).refine(path.isAbsolute, "targetRepo must be absolute").default("/Users/williamcory/mvp"), - architectureSitePath: z.string().min(1).refine(isSafeRelativeArchitecturePath, "architectureSitePath must be a normalized relative path without '.' or '..' components").default("docs/architecture"), + architectureSitePath: z.string().min(1).default("docs/architecture"), maxPrototypeRounds: z.number().int().min(1).max(5).default(3), maxProductionRounds: z.number().int().min(1).max(6).default(4), maxConcurrency: z.number().int().min(1).max(4).default(4), @@ -505,40 +505,6 @@ const validatePocRoundPlan = ( }; }; type GitWorktreeSpec = { path: string; branch: string }; - -export function isSafeRelativeArchitecturePath(value: string): boolean { - if (!value || value.includes("\\") || path.isAbsolute(value)) return false; - const segments = value.split("/"); - return segments.every((segment) => segment.length > 0 && segment !== "." && segment !== "..") - && path.posix.normalize(value) === value; -} - -/** Resolve inside a worktree and reject any existing symlink in the path. */ -export function resolveArchitectureSitePath(worktreeRoot: string, relativePath: string): string { - if (!isSafeRelativeArchitecturePath(relativePath)) { - throw new Error(`Unsafe architectureSitePath: ${relativePath}`); - } - const lexicalRoot = path.resolve(worktreeRoot); - const lexicalTarget = path.resolve(lexicalRoot, relativePath); - if (!lexicalTarget.startsWith(`${lexicalRoot}${path.sep}`)) { - throw new Error(`architectureSitePath escapes its worktree: ${relativePath}`); - } - if (!existsSync(lexicalRoot)) return lexicalTarget; - const canonicalRoot = realpathSync(lexicalRoot); - let current = lexicalRoot; - for (const segment of relativePath.split("/")) { - current = path.join(current, segment); - if (!existsSync(current)) break; - if (lstatSync(current).isSymbolicLink()) { - throw new Error(`architectureSitePath traverses a symbolic link: ${current}`); - } - const canonicalCurrent = realpathSync(current); - if (canonicalCurrent !== canonicalRoot && !canonicalCurrent.startsWith(`${canonicalRoot}${path.sep}`)) { - throw new Error(`architectureSitePath escapes its canonical worktree: ${current}`); - } - } - return path.join(canonicalRoot, relativePath); -} const execText = (command: string, args: string[], cwd: string) => execFileSync(command, args, { cwd, encoding: "utf8" }).trim(); const runBounded = (command: string, args: string[], cwd: string, timeoutMs: number) => runBoundedProcess(command, args, cwd, { timeoutMs }); @@ -683,7 +649,7 @@ export default smithers((ctx) => { const pocDistributionRoot = activeRound.distributionRoot; const pocWorktreeSpecs = pocRounds.flatMap((round) => round.specs); const pocWorktreePath = (lane: "poc-authority-foundation" | "poc-state-journal" | "poc-flows-harness" | "poc-platform-worldview" | "poc-transcript-chat-ui" | "poc-integration") => activeRound.specs.find((spec) => spec.path.endsWith(`/${lane}`))!.path; - const expectedPocArchitectureSitePath = resolveArchitectureSitePath(pocWorktreePath("poc-integration"), architectureSitePath); + const expectedPocArchitectureSitePath = path.resolve(pocWorktreePath("poc-integration"), architectureSitePath); const inventoryEvidenceReceipt = ctx.latest(outputs.validate_inventory_evidence, "validate_inventory_evidence"); const inventoryEvidenceValid = inventoryEvidenceReceipt?.status === "pass"; diff --git a/apps/ui/.smithers/workflows/share-pack.tsx b/apps/ui/.smithers/workflows/share-pack.tsx index 4e43df06..28dd6a6a 100644 --- a/apps/ui/.smithers/workflows/share-pack.tsx +++ b/apps/ui/.smithers/workflows/share-pack.tsx @@ -6,9 +6,6 @@ // smithers-system: true /** @jsxImportSource smthrs */ import { createSmithers, UI } from "smthrs"; -import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { basename, dirname, join } from "node:path"; import { z } from "zod/v4"; import { agents } from "../agents"; @@ -22,7 +19,7 @@ const completionSchema = z.object({ completed: z.boolean(), detail: z.string() } const prepareSchema = z.object({ ok: z.boolean(), detail: z.string(), - stagingId: z.string().nullable().default(null), + stagingRoot: z.string().nullable().default(null), }); const outputSchema = z.object({ validated: z.boolean(), @@ -43,43 +40,6 @@ const { Workflow, Task, Sequence, smithers, outputs } = createSmithers({ }); const cliModule = (name: string) => process.env.SMITHERS_CLI_SRC_DIR ? `${process.env.SMITHERS_CLI_SRC_DIR}/${name}.js` : `@smthrs/cli/${name}`; -const PREPARED_PREFIX = "smithers-share-stage-"; -const OWNED_PREFIX = "smithers-share-run-"; -const OWNERSHIP_MARKER = ".smithers-share-owner.json"; - -function assertDedicatedTempPath(candidate: string, prefix: string): string { - const canonicalTmp = realpathSync(tmpdir()); - if (!existsSync(candidate) || lstatSync(candidate).isSymbolicLink()) throw new Error("staging path is missing or symbolic"); - const canonical = realpathSync(candidate); - if (dirname(canonical) !== canonicalTmp || !basename(canonical).startsWith(prefix)) { - throw new Error(`staging path is outside the dedicated temporary root: ${candidate}`); - } - return canonical; -} - -/** Move CLI staging into a run-owned directory and persist only its basename. */ -export function claimPreparedStagingRoot(preparedRoot: string, runId: string): string { - const source = assertDedicatedTempPath(preparedRoot, PREPARED_PREFIX); - const parent = mkdtempSync(join(realpathSync(tmpdir()), OWNED_PREFIX)); - const staging = join(parent, "stage"); - renameSync(source, staging); - writeFileSync(join(parent, OWNERSHIP_MARKER), JSON.stringify({ runId, staging: "stage" })); - return basename(parent); -} - -/** Reconstruct and verify the owned staging path before publication or deletion. */ -export function resolveOwnedStagingRoot(stagingId: string, runId: string): { parent: string; staging: string } { - if (basename(stagingId) !== stagingId || !stagingId.startsWith(OWNED_PREFIX)) { - throw new Error("invalid persisted staging identifier"); - } - const parent = assertDedicatedTempPath(join(realpathSync(tmpdir()), stagingId), OWNED_PREFIX); - const markerPath = join(parent, OWNERSHIP_MARKER); - const marker = JSON.parse(readFileSync(markerPath, "utf8")) as { runId?: unknown; staging?: unknown }; - if (marker.runId !== runId || marker.staging !== "stage") throw new Error("staging ownership marker does not match this run"); - const staging = realpathSync(join(parent, "stage")); - if (dirname(staging) !== parent || lstatSync(staging).isSymbolicLink()) throw new Error("owned staging path is not contained"); - return { parent, staging }; -} async function validateManifest(repo: string | undefined, registry: string | undefined) { const { loadManifest } = await import(cliModule("manifest")); @@ -161,13 +121,9 @@ Edit ONLY .smithers/smithers.toon. Return completed=true when the manifest is fi // path is persisted so publish uses THIS artifact and cleanup can // always find it, even in a fresh process after a durable retry. const result = preparePackForShare({ from: process.cwd(), repository: ctx.input.repo }); - return { - ok: true, - detail: result.detail, - stagingId: claimPreparedStagingRoot(result.stagingRoot, ctx.runId), - }; + return { ok: true, detail: result.detail, stagingRoot: result.stagingRoot }; } catch (error) { - return { ok: false, detail: error instanceof Error ? error.message : String(error), stagingId: null }; + return { ok: false, detail: error instanceof Error ? error.message : String(error), stagingRoot: null }; } }} </Task> @@ -182,9 +138,7 @@ Edit ONLY .smithers/smithers.toon. Return completed=true when the manifest is fi detail: publishPackRepository({ from: process.cwd(), repository: ctx.input.repo, - stagingRoot: prepare?.stagingId - ? resolveOwnedStagingRoot(prepare.stagingId, ctx.runId).staging - : undefined, + stagingRoot: prepare?.stagingRoot ?? undefined, }), }; } catch (error) { @@ -216,10 +170,10 @@ Edit ONLY .smithers/smithers.toon. Return completed=true when the manifest is fi {async () => { // Terminal cleanup on every path (success, dry-run, or failure): the // staging copy must never outlive the run. - const stagingId = prepare?.stagingId; - if (stagingId) { - const owned = resolveOwnedStagingRoot(stagingId, ctx.runId); - rmSync(owned.parent, { recursive: true, force: true }); + const stagingRoot = prepare?.stagingRoot; + if (stagingRoot) { + const { rmSync } = await import("node:fs"); + rmSync(stagingRoot, { recursive: true, force: true }); } return { validated: manifestReady, diff --git a/apps/ui/.smithers/workflows/smithers-repo-federation.tsx b/apps/ui/.smithers/workflows/smithers-repo-federation.tsx index b97a72e2..43db38aa 100644 --- a/apps/ui/.smithers/workflows/smithers-repo-federation.tsx +++ b/apps/ui/.smithers/workflows/smithers-repo-federation.tsx @@ -7,7 +7,7 @@ import { createSmithers, UI } from "smthrs"; import { execFileSync } from "node:child_process"; import { createHash } from "node:crypto"; -import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, isAbsolute, join, parse, resolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; @@ -264,7 +264,6 @@ const releaseApprovalBindingSchema = z.object({ const executeReleasesSchema = z.object({ released: z.array(z.object({ lane: z.string(), version: z.string(), tag: z.string().nullable() })).default([]), failed: z.array(z.object({ lane: z.string(), reason: z.string() })).default([]), - ledgerPath: z.string(), summary: z.string().default(""), }); @@ -282,19 +281,6 @@ const removalPrsSchema = z.object({ summary: z.string().default(""), }); -const mergeApprovalBindingSchema = z.object({ - prs: z.array(z.object({ - lane: z.string(), - repo: z.string(), - prNumber: z.number().int().positive(), - baseRef: z.string(), - baseSha: z.string(), - headSha: z.string(), - checksSha256: z.string(), - })).default([]), - summary: z.string(), -}); - const mergeRemovalPrsSchema = z.object({ merged: z.array(z.object({ lane: z.string(), prNumber: z.number().nullable() })).default([]), failed: z.array(z.object({ lane: z.string(), reason: z.string() })).default([]), @@ -350,7 +336,6 @@ const { Workflow, Task, Sequence, Parallel, Branch, Loop, Ralph, Approval, smith gatePublish: approvalSchema, executeReleases: executeReleasesSchema, removalPrs: removalPrsSchema, - mergeApprovalBinding: mergeApprovalBindingSchema, gateMerge: approvalSchema, mergeRemovalPrs: mergeRemovalPrsSchema, finalVerify: finalVerifySchema, @@ -914,22 +899,6 @@ type ReleasePlan = { repos?: Array<{ lane?: string; version?: string; githubRelease?: boolean }>; }; -type ReleaseLedger = { - packages?: Record<string, unknown>; - repos?: Record<string, { version: string; tag: string; releaseUrl?: string }>; -}; - -function readReleaseLedger(ledgerPath: string): ReleaseLedger { - if (!existsSync(ledgerPath)) return { packages: {}, repos: {} }; - return JSON.parse(readFileSync(ledgerPath, "utf8")) as ReleaseLedger; -} - -function writeReleaseLedger(ledgerPath: string, ledger: ReleaseLedger): void { - const temporary = `${ledgerPath}.tmp-${process.pid}`; - writeFileSync(temporary, `${JSON.stringify(ledger, null, 2)}\n`, { flag: "wx" }); - renameSync(temporary, ledgerPath); -} - function planVersionForLane(plan: ReleasePlan, lane: string): string { const pkg = (plan.packages ?? []).find( (p) => (p.repo ?? p.lane) === lane && typeof p.version === "string" && p.version, @@ -944,9 +913,7 @@ function planVersionForLane(plan: ReleasePlan, lane: string): string { // publication NEVER calls `npm publish` once per repo root. Instead it invokes // the validated ROOT COORDINATOR (authored in the kernel clone, consuming the // release plan) which publishes every publishable package in DAG order, then -// this creates a tag + GitHub release for EVERY repo. Every irreversible step -// is recorded in an atomic ledger so a retry resumes and verifies instead of -// publishing from the beginning. +// this creates a tag + GitHub release for EVERY repo. Failures throw. function executeReleases( migrationRoot: string, githubOrg: string, @@ -972,70 +939,37 @@ function executeReleases( if (!existsSync(planPath)) throw new Error(`release plan ${planPath} does not exist — the inventory step must emit it`); const plan = JSON.parse(readFileSync(planPath, "utf8")) as ReleasePlan; - for (const pkg of plan.packages ?? []) { - if (pkg.publishTo === "npm" && (!pkg.name || !/^@?[a-z0-9][a-z0-9._/-]*$/.test(pkg.name))) { - throw new Error(`release plan contains an unsafe npm package name: ${JSON.stringify(pkg.name)}`); - } - if (pkg.publishTo === "npm" && (!pkg.version || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(pkg.version))) { - throw new Error(`release plan contains an unsafe package version for ${pkg.name ?? "(unnamed)"}`); - } - } - execFileSync("pnpm", ["whoami"], { cwd: kernelPath, encoding: "utf8" }); - execFileSync("gh", ["auth", "status"], { cwd: kernelPath, encoding: "utf8" }); const coordinator = join(kernelPath, "scripts", "federation-release.mjs"); if (!existsSync(coordinator)) { throw new Error( `root release coordinator ${coordinator} does not exist — the kernel-strip step must author it (consumes the release plan).`, ); } - const ledgerPath = join(rootPath, "artifacts", "release-ledger.json"); - const failed: { lane: string; reason: string }[] = []; - try { - execFileSync("node", [coordinator, "--plan", planPath, "--execute", "--ledger", ledgerPath, "--resume"], { - cwd: kernelPath, - encoding: "utf8", - maxBuffer: 64 * 1024 * 1024, - }); - } catch (error) { - failed.push({ lane: "npm-packages", reason: String(error instanceof Error ? error.message : error).slice(0, 300) }); - return { - released: [], - failed, - ledgerPath, - summary: `Package publication stopped with a durable ledger at ${ledgerPath}; retry will resume and verify completed artifacts.`, - }; - } + // Invoke the validated coordinator: publishes every publishable package in + // every repo, in package-DAG order. Throws on any failure. + execFileSync("node", [coordinator, "--plan", planPath, "--execute"], { + cwd: kernelPath, + encoding: "utf8", + maxBuffer: 64 * 1024 * 1024, + }); // Tags + GitHub releases for EVERY repo (including repos with zero npm // packages — e.g. examples/apps that only need a GitHub release). const released: { lane: string; version: string; tag: string | null }[] = []; + const failed: { lane: string; reason: string }[] = []; for (const lane of readDagOrder(migrationRoot)) { const path = laneDir(rootPath, lane); const fullName = `${githubOrg}/${lane}`; try { const version = planVersionForLane(plan, lane); const tag = `v${version}`; - const ledger = readReleaseLedger(ledgerPath); - const recorded = ledger.repos?.[lane]; - if (recorded) { - if (recorded.version !== version || recorded.tag !== tag) { - throw new Error(`ledger drift: recorded ${recorded.tag}/${recorded.version}, requested ${tag}/${version}`); - } - if (!gitIn(path, ["ls-remote", "--tags", "origin", `refs/tags/${tag}`]).trim()) { - throw new Error(`ledger records ${tag}, but the remote tag is missing`); - } - execFileSync("gh", ["release", "view", tag, "--repo", fullName], { cwd: path, encoding: "utf8" }); - released.push({ lane, version, tag }); - continue; - } if (!gitIn(path, ["tag", "-l", tag]).trim()) { gitIn(path, ["tag", "-a", tag, "-m", `${lane} ${tag} (federation release)`]); } if (!gitIn(path, ["ls-remote", "--tags", "origin", tag]).trim()) { gitIn(path, ["push", "origin", tag]); } - let releaseUrl = ""; try { - releaseUrl = execFileSync( + execFileSync( "gh", [ "release", @@ -1049,26 +983,26 @@ function executeReleases( `Federated release of ${lane} ${tag}, split out of the smithers monorepo.`, ], { cwd: path, encoding: "utf8" }, - ).trim(); + ); } catch { // Already exists from an earlier attempt — verify it is really there. - releaseUrl = execFileSync("gh", ["release", "view", tag, "--repo", fullName, "--json", "url", "--jq", ".url"], { cwd: path, encoding: "utf8" }).trim(); + execFileSync("gh", ["release", "view", tag, "--repo", fullName], { cwd: path, encoding: "utf8" }); } - const updatedLedger = readReleaseLedger(ledgerPath); - updatedLedger.repos = { ...(updatedLedger.repos ?? {}), [lane]: { version, tag, releaseUrl } }; - writeReleaseLedger(ledgerPath, updatedLedger); released.push({ lane, version, tag }); } catch (err) { failed.push({ lane, reason: String(err instanceof Error ? err.message : err).slice(0, 300) }); } } + if (failed.length > 0) { + throw new Error( + `executeReleases failed for ${failed.length} lane(s): ${failed.map((f) => `${f.lane} (${f.reason})`).join("; ")}. ` + + `Released so far: ${released.map((r) => r.lane).join(", ") || "none"}.`, + ); + } return { released, failed, - ledgerPath, - summary: failed.length > 0 - ? `Release is partial: ${released.length}/${NEW_REPO_LANES.length} repositories complete; ${failed.length} failed. Resume from ${ledgerPath}.` - : `Coordinator published all packages in DAG order; tagged + released ${released.length}/${NEW_REPO_LANES.length} repos on GitHub; ledger ${ledgerPath}.`, + summary: `Coordinator published all packages in DAG order; tagged + released ${released.length}/${NEW_REPO_LANES.length} repos on GitHub.`, }; } @@ -1139,39 +1073,7 @@ function removalPRs( // Merges stay behind the merge approval: explicit --repo everywhere, CI must // be green first, and this only runs after destination validation + publish. -function captureMergeApprovalBinding( - prs: Array<{ lane: string; repo: string | null; prNumber: number | null }>, -): z.infer<typeof mergeApprovalBindingSchema> { - const bound = prs.map((pr) => { - if (!pr.repo || pr.prNumber === null) throw new Error(`${pr.lane}: cannot bind a PR without repo and number`); - const raw = execFileSync("gh", [ - "pr", "view", String(pr.prNumber), "--repo", pr.repo, - "--json", "baseRefName,baseRefOid,headRefOid,statusCheckRollup", - ], { encoding: "utf8" }); - const view = JSON.parse(raw) as { - baseRefName?: string; - baseRefOid?: string; - headRefOid?: string; - statusCheckRollup?: unknown; - }; - if (!view.baseRefName || !view.baseRefOid || !view.headRefOid) throw new Error(`${pr.repo}#${pr.prNumber}: incomplete PR revision metadata`); - return { - lane: pr.lane, - repo: pr.repo, - prNumber: pr.prNumber, - baseRef: view.baseRefName, - baseSha: view.baseRefOid, - headSha: view.headRefOid, - checksSha256: createHash("sha256").update(JSON.stringify(view.statusCheckRollup ?? [])).digest("hex"), - }; - }); - return { prs: bound, summary: `Bound ${bound.length} PR(s) to exact repository, number, base, head, and check-suite identities.` }; -} - -function mergeRemovalPRs( - prs: Array<{ lane: string; repo: string | null; prNumber: number | null }>, - approved: z.infer<typeof mergeApprovalBindingSchema>, -) { +function mergeRemovalPRs(prs: Array<{ lane: string; repo: string | null; prNumber: number | null }>) { const merged: { lane: string; prNumber: number | null }[] = []; const failed: { lane: string; reason: string }[] = []; for (const pr of prs) { @@ -1179,12 +1081,6 @@ function mergeRemovalPRs( failed.push({ lane: pr.lane, reason: "no PR number or repo recorded" }); continue; } - const current = captureMergeApprovalBinding([pr]).prs[0]; - const expected = approved.prs.find((item) => item.repo === pr.repo && item.prNumber === pr.prNumber); - if (!current || !expected || JSON.stringify(current) !== JSON.stringify(expected)) { - failed.push({ lane: pr.lane, reason: "PR head, base, repository, number, or checks drifted after approval" }); - continue; - } try { const viewArgs = ["pr", "view", String(pr.prNumber), "--repo", pr.repo, "--json", "isDraft"]; const view = JSON.parse(execFileSync("gh", viewArgs, { encoding: "utf8" })) as { isDraft?: boolean }; @@ -1313,7 +1209,6 @@ export default smithers((ctx) => { const publishApproved = gatePublish?.approved === true; const executeReleasesResult = ctx.outputMaybe(outputs.executeReleases, { nodeId: "executeReleases" }); const removalPrsResult = ctx.outputMaybe(outputs.removalPrs, { nodeId: "removalPrs" }); - const mergeApprovalBindingResult = ctx.outputMaybe(outputs.mergeApprovalBinding, { nodeId: "mergeApprovalBinding" }); const gateMerge = ctx.outputMaybe(outputs.gateMerge, { nodeId: "gate-merge" }); const mergeApproved = gateMerge?.approved === true; const mergeRemovalPrsResult = ctx.outputMaybe(outputs.mergeRemovalPrs, { nodeId: "mergeRemovalPrs" }); @@ -1643,43 +1538,28 @@ export default smithers((ctx) => { </Task> ) : null} - {executeReleasesResult?.failed.length === 0 && executeReleasesResult.released.length === NEW_REPO_LANES.length ? ( + {executeReleasesResult ? ( <Task id="removalPrs" output={outputs.removalPrs} timeoutMs={10 * 60_000}> {() => removalPRs(migrationRoot, sourceRepo, updateSmithersResult, lanePushResults)} </Task> ) : null} {removalPrsResult ? ( - <Task id="mergeApprovalBinding" output={outputs.mergeApprovalBinding} timeoutMs={10 * 60_000}> - {() => captureMergeApprovalBinding(removalPrsResult.prs)} - </Task> - ) : null} - - {mergeApprovalBindingResult ? ( <Approval id="gate-merge" output={outputs.gateMerge} - bind={ctx.prove(outputs.mergeApprovalBinding, { nodeId: "mergeApprovalBinding" })} request={{ title: "Merge the removal/reference-update PRs?", - summary: `${removalPrsResult?.summary ?? ""}\n${mergeApprovalBindingResult.summary}\n\nApproving squash-merges only these exact revisions after revalidation.`, - metadata: { prs: mergeApprovalBindingResult.prs }, + summary: `${removalPrsResult.summary}\n\nApproving squash-merges the smithers kernel-strip PR and the multi/plue/awesome-smithers PRs — only where CI is verified green, with explicit --repo.`, + metadata: { prCount: removalPrsResult.prs.length }, }} onDeny="skip" /> ) : null} - {mergeApproved && mergeApprovalBindingResult ? ( - <Task - id="mergeRemovalPrs" - output={outputs.mergeRemovalPrs} - bind={[ - requireProofBinding(ctx.prove(outputs.gateMerge, { nodeId: "gate-merge" }), "gate-merge"), - requireProofBinding(ctx.prove(outputs.mergeApprovalBinding, { nodeId: "mergeApprovalBinding" }), "mergeApprovalBinding"), - ]} - timeoutMs={10 * 60_000} - > - {() => mergeRemovalPRs(removalPrsResult?.prs ?? [], mergeApprovalBindingResult)} + {mergeApproved ? ( + <Task id="mergeRemovalPrs" output={outputs.mergeRemovalPrs} timeoutMs={10 * 60_000}> + {() => mergeRemovalPRs(removalPrsResult?.prs ?? [])} </Task> ) : null} diff --git a/apps/ui/.smithers/workflows/universal-flow-runtime-swarm.tsx b/apps/ui/.smithers/workflows/universal-flow-runtime-swarm.tsx index b30e6f34..9bd65f51 100644 --- a/apps/ui/.smithers/workflows/universal-flow-runtime-swarm.tsx +++ b/apps/ui/.smithers/workflows/universal-flow-runtime-swarm.tsx @@ -5,7 +5,7 @@ // smithers-tags: architecture, flows, worktrees, review /** @jsxImportSource smthrs */ import { execFileSync, spawnSync } from "node:child_process"; -import { appendFileSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from "node:fs"; +import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import path from "node:path"; import { Parallel, Ralph, Sequence, Task, Worktree, createSmithers, fallbackAgents } from "smthrs"; import { z } from "zod/v4"; @@ -114,7 +114,6 @@ const mirrorSchema = z.strictObject({ const snapshotSchema = z.strictObject({ revisionId: z.string().min(7), heads: z.array(z.strictObject({ repo: repoName, sha: z.string().min(7) })).length(4), - bases: z.array(z.strictObject({ repo: repoName, sha: z.string().min(7) })).length(4), }); const reviewIssue = z.strictObject({ @@ -160,7 +159,6 @@ const landSchema = z.strictObject({ monorepoSha: z.string().min(7).nullable(), closedPrs: z.array(z.strictObject({ repo: repoName, number: z.number().int().positive() })), blockers: z.array(z.string().min(1)), - ledgerPath: z.string().min(1), summary: z.string().min(1), }); @@ -234,10 +232,6 @@ function branchFor(runSlug: string, lane: Lane): string { return `agent/universal-flow-runtime/${runSlug}/${lane}`; } -export function canonicalGithubRemote(value: string): string { - return value.trim().replace(/^git@github\.com:/, "").replace(/^https?:\/\/github\.com\//, "").replace(/\.git$/, ""); -} - function inspectPreflight(): z.infer<typeof preflightSchema> { const auth = tryRun(process.cwd(), "gh", ["auth", "status"]); const repositories = SOURCE_REPOSITORIES.map((spec) => ({ @@ -271,15 +265,6 @@ function ensureNestedWorktree(source: Repository, target: string, branch: string if (existsSync(target)) { const actual = tryRun(target, "git", ["rev-parse", "--show-toplevel"]); if (actual.status !== 0) throw new Error(`Existing path is not a worktree: ${target}`); - if (realpathSync(actual.stdout.trim()) !== realpathSync(target)) throw new Error(`Existing worktree root mismatch: ${target}`); - const actualBranch = run(target, "git", ["branch", "--show-current"]); - if (actualBranch !== branch) throw new Error(`Existing worktree branch mismatch: expected ${branch}, got ${actualBranch || "detached"}`); - const actualOrigin = canonicalGithubRemote(run(target, "git", ["remote", "get-url", "origin"])); - if (actualOrigin !== canonicalGithubRemote(source.githubRepo)) throw new Error(`Existing worktree origin mismatch: ${actualOrigin}`); - if (run(target, "git", ["status", "--porcelain"])) throw new Error(`Existing worktree is dirty: ${target}`); - if (tryRun(target, "git", ["merge-base", "--is-ancestor", source.baseSha, "HEAD"]).status !== 0) { - throw new Error(`Existing worktree HEAD is not descended from pinned base ${source.baseSha}`); - } return; } mkdirSync(path.dirname(target), { recursive: true }); @@ -314,9 +299,8 @@ function prepareWorkspace(lane: Lane, root: string, branch: string, sources: Rep function heads(workspace: Workspace): z.infer<typeof snapshotSchema> { const values = workspace.repositories.map((repo) => ({ repo: repo.name, sha: run(repo.worktreePath, "git", ["rev-parse", "HEAD"]) })); - const bases = workspace.repositories.map((repo) => ({ repo: repo.name, sha: repo.baseSha })); - const revisionId = values.map((value, index) => `${value.repo}:${bases[index]!.sha}->${value.sha}`).join("|"); - return { revisionId, heads: values, bases }; + const revisionId = values.map((value) => `${value.repo}:${value.sha}`).join("|"); + return { revisionId, heads: values }; } function mirrorLane(workspace: Workspace): Mirror { @@ -424,7 +408,7 @@ function revisionPrompt(workspace: Workspace, fableReview: unknown, solReview: u ].join("\n\n"); } -function ensureLandingClone(root: string, repo: Workspace["repositories"][number], approvedBaseSha: string): string { +function ensureLandingClone(root: string, repo: Workspace["repositories"][number]): string { const target = path.join(root, ".smithers-federated", "landing", repo.name); if (!existsSync(target)) { mkdirSync(path.dirname(target), { recursive: true }); @@ -432,17 +416,10 @@ function ensureLandingClone(root: string, repo: Workspace["repositories"][number } run(target, "git", ["fetch", "origin", repo.baseBranch, repo.branch]); run(target, "git", ["checkout", repo.baseBranch]); - run(target, "git", ["reset", "--hard", approvedBaseSha]); + run(target, "git", ["reset", "--hard", `origin/${repo.baseBranch}`]); return target; } -function remoteBaseSha(repo: Workspace["repositories"][number]): string { - const output = run(repo.worktreePath, "git", ["ls-remote", "origin", `refs/heads/${repo.baseBranch}`]); - const sha = output.split(/\s+/, 1)[0]; - if (!sha) throw new Error(`Missing remote base ${repo.githubRepo}:${repo.baseBranch}`); - return sha; -} - function closeMirrors(mirrors: Mirror[]): Array<{ repo: Repo; number: number }> { const closed: Array<{ repo: Repo; number: number }> = []; for (const receipt of mirrors.flatMap((mirror) => mirror.prs)) { @@ -480,86 +457,31 @@ function updateMonorepo(root: string): string | null { return run(target, "git", ["rev-parse", "HEAD"]); } -type LandingLedger = { - revisionId: string; - repositories: Partial<Record<Repo, { approvedBase: string; mainSha: string }>>; - monorepoSha?: string | null; -}; - -function readLandingLedger(ledgerPath: string, revisionId: string): LandingLedger { - if (!existsSync(ledgerPath)) return { revisionId, repositories: {} }; - const ledger = JSON.parse(readFileSync(ledgerPath, "utf8")) as LandingLedger; - if (ledger.revisionId !== revisionId) throw new Error("Landing ledger belongs to a different reviewed revision."); - return ledger; -} - -function writeLandingLedger(ledgerPath: string, ledger: LandingLedger): void { - const temporary = `${ledgerPath}.tmp-${process.pid}`; - writeFileSync(temporary, `${JSON.stringify(ledger, null, 2)}\n`, { flag: "wx" }); - renameSync(temporary, ledgerPath); -} - function land(workspace: Workspace, snapshot: z.infer<typeof snapshotSchema>, mirrors: Mirror[]): z.infer<typeof landSchema> { const actual = heads(workspace); if (actual.revisionId !== snapshot.revisionId) throw new Error("Judge heads changed after the approved reviews."); - const ledgerPath = path.join(workspace.root, ".smithers-federated", "landing", `transaction-${snapshot.revisionId.replace(/[^A-Za-z0-9._-]/g, "_")}.json`); - mkdirSync(path.dirname(ledgerPath), { recursive: true }); - const ledger = readLandingLedger(ledgerPath, snapshot.revisionId); - const approvedBases = new Map(snapshot.bases.map((base) => [base.repo, base.sha])); - const prepared: Array<{ repo: Workspace["repositories"][number]; target: string; changed: boolean; mainSha: string }> = []; + const prepared: Array<{ repo: Workspace["repositories"][number]; target: string; changed: boolean }> = []; for (const repo of workspace.repositories) { - const approvedBase = approvedBases.get(repo.name); - if (!approvedBase || approvedBase !== repo.baseSha) throw new Error(`Approved base mismatch for ${repo.name}.`); - const remoteBase = remoteBaseSha(repo); - const recorded = ledger.repositories[repo.name]; - if (remoteBase !== approvedBase && remoteBase !== recorded?.mainSha) throw new Error(`${repo.name} base drifted after review: approved ${approvedBase}, remote ${remoteBase}.`); if (run(repo.worktreePath, "git", ["status", "--porcelain"])) throw new Error(`Judge ${repo.name} worktree is dirty.`); run(repo.worktreePath, "git", ["push", "--set-upstream", "origin", repo.branch]); - const target = ensureLandingClone(workspace.root, repo, approvedBase); + const target = ensureLandingClone(workspace.root, repo); const changed = Number(run(repo.worktreePath, "git", ["rev-list", "--count", `${repo.baseSha}..HEAD`])) > 0; if (changed) run(target, "git", ["merge", "--no-ff", "--no-edit", `origin/${repo.branch}`]); - const mainSha = run(target, "git", ["rev-parse", "HEAD"]); - if (recorded && (recorded.approvedBase !== approvedBase || recorded.mainSha !== mainSha)) { - throw new Error(`${repo.name} landing ledger does not match the currently staged merge.`); - } - prepared.push({ repo, target, changed, mainSha }); + prepared.push({ repo, target, changed }); } - const repositories: Array<{ repo: Repo; changed: boolean; mainSha: string; pushed: boolean }> = []; - const blockers: string[] = []; - for (const preparedRepo of prepared) { - const { repo, target, changed, mainSha } = preparedRepo; - const approvedBase = approvedBases.get(repo.name)!; - try { - const recorded = ledger.repositories[repo.name]; - if (recorded) { - if (remoteBaseSha(repo) !== mainSha) throw new Error("ledger says pushed, but remote main does not match"); - } else { - if (remoteBaseSha(repo) !== approvedBase) throw new Error(`${repo.name} base drifted while merges were staged.`); - run(target, "git", ["push", `--force-with-lease=refs/heads/${repo.baseBranch}:${approvedBase}`, "origin", `HEAD:${repo.baseBranch}`]); - ledger.repositories[repo.name] = { approvedBase, mainSha }; - writeLandingLedger(ledgerPath, ledger); - } - repositories.push({ repo: repo.name, changed, mainSha, pushed: true }); - } catch (error) { - blockers.push(`${repo.name}: ${error instanceof Error ? error.message : String(error)}`); - repositories.push({ repo: repo.name, changed, mainSha, pushed: false }); - } - } - if (blockers.length > 0) { - return { landed: false, repositories, monorepoSha: null, closedPrs: [], blockers, ledgerPath, summary: `Landing is partial; resume from ${ledgerPath}.` }; - } - const monorepoSha = ledger.monorepoSha ?? updateMonorepo(workspace.root); - ledger.monorepoSha = monorepoSha; - writeLandingLedger(ledgerPath, ledger); const closedPrs = closeMirrors(mirrors); + const repositories = prepared.map(({ repo, target, changed }) => { + run(target, "git", ["push", "origin", repo.baseBranch]); + return { repo: repo.name, changed, mainSha: run(target, "git", ["rev-parse", "HEAD"]), pushed: true }; + }); + const monorepoSha = updateMonorepo(workspace.root); return { landed: true, repositories, monorepoSha, closedPrs, blockers: [], - ledgerPath, summary: "Merged the reviewed synthesis in clean local clones, pushed main, advanced monorepo submodules, and closed mirror PRs without using GitHub merge.", }; } @@ -700,7 +622,7 @@ export default smithers((ctx) => { <Task id="final_report" output={outputs.report} dependsOn={["land"]}> {() => { if (!landed) throw new Error("Missing landing receipt."); - return writeReport(runSlug, fableCandidate, solCandidate, synthesis!, [fableReview, solReview], landed); + return writeReport(runSlug, fableCandidate, solCandidate, synthesis, [fableReview, solReview], landed); }} </Task> </Sequence> diff --git a/apps/ui/.smithers/workflows/whole-foods-meal-planner.tsx b/apps/ui/.smithers/workflows/whole-foods-meal-planner.tsx index 4165240b..c6a037b5 100644 --- a/apps/ui/.smithers/workflows/whole-foods-meal-planner.tsx +++ b/apps/ui/.smithers/workflows/whole-foods-meal-planner.tsx @@ -364,15 +364,6 @@ export async function assertPublicWebhookDestination( return url; } -/** Credentialed webhooks must use a public IP literal so fetch cannot re-resolve an attacker-controlled DNS name. */ -export async function assertTokenSafeWebhookDestination(rawUrl: string, token: string | undefined): Promise<URL> { - const parsed = new URL(rawUrl); - if (token && isIP(parsed.hostname.replace(/^\[|\]$/g, "")) === 0) { - throw new Error("Credentialed order webhooks require a public IP-literal HTTPS endpoint or a network-layer pinned connector."); - } - return assertPublicWebhookDestination(rawUrl); -} - function buildCheckoutLinks(items: Array<{ name: string }>, zipCode: string) { return items.map((item) => ({ name: item.name, @@ -769,13 +760,14 @@ export default smithers((ctx) => { zipCode: normalized?.constraints.zipCode ?? "", planReference: "plan-meals", }; + const headers: Record<string, string> = { "content-type": "application/json" }; + if (process.env.WHOLE_FOODS_ORDER_TOKEN) { + headers.authorization = `Bearer ${process.env.WHOLE_FOODS_ORDER_TOKEN}`; + } try { const endpoint = orderWebhook.endpoint; if (!endpoint) throw new Error("Server-configured order webhook is unavailable."); - const token = process.env.WHOLE_FOODS_ORDER_TOKEN; - await assertTokenSafeWebhookDestination(endpoint, token); - const headers: Record<string, string> = { "content-type": "application/json" }; - if (token) headers.authorization = `Bearer ${token}`; + await assertPublicWebhookDestination(endpoint); const res = await fetch(endpoint, { method: "POST", headers, diff --git a/apps/ui/BUILD.bazel b/apps/ui/BUILD.bazel new file mode 100644 index 00000000..8634a485 --- /dev/null +++ b/apps/ui/BUILD.bazel @@ -0,0 +1,11 @@ +load("@aspect_rules_ts//ts:defs.bzl", "ts_config") +load("@npm//:defs.bzl", "npm_link_all_packages") + +npm_link_all_packages(name = "node_modules") + +ts_config( + name = "tsconfig", + src = "tsconfig.json", + visibility = [":__subpackages__"], + deps = [":package.json"], +) diff --git a/apps/ui/BUILD.ts b/apps/ui/BUILD.ts deleted file mode 100644 index 22c90ef1..00000000 --- a/apps/ui/BUILD.ts +++ /dev/null @@ -1,91 +0,0 @@ -/** - * Targets for the UI application: the typecheck, the unit suite, and the two - * end-to-end suites. - * - * The e2e suites boot `wrangler dev` and a real Chrome, so they are separate - * targets rather than folded into the unit suite: a pipeline that ran them on - * every push would put minutes of browser work in front of every change for no - * added signal. The CI jobs address the two lanes by exact label for that - * reason — a bare `//apps/ui` under the test verb would pull both into one job. - * - * Everything runs under Bun, which is what the app's own scripts use, so the - * runtime is the root Bun declaration and nothing here spells `bun` into an - * argv. - */ -import { Smithers } from "@smthrs/targets" -import { bunRuntime, packageManager } from "../../BUILD.ts" - -const cwd = "apps/ui" - -/** The application sources every suite drives. */ -const sources = Smithers.glob("//apps/ui/src/**/*.ts") - -/** The React components, part of the typecheck's and unit suite's key material. */ -const componentSources = Smithers.glob("//apps/ui/src/**/*.tsx") - -/** - * Checks the application against its own tsconfig. - * - * @since 0.1.0 - * @category build - */ -export const check = Smithers.Typecheck({ - packageManager, - srcs: [sources, componentSources], - deps: [], - tsconfig: Smithers.file("tsconfig.json"), - buildMode: false, - incremental: false, - cwd -}) - -/** - * The unit suite: everything under `src/`, hermetic, no server and no browser. - * - * @since 0.1.0 - * @category test - */ -export const unitTests = Smithers.NodeTest({ - runtime: bunRuntime, - runner: Smithers.testSuite(["src"]), - srcs: [sources, componentSources], - deps: [], - cwd -}) - -/** - * Boots `wrangler dev` against the stub backends twice and asserts the named - * outcomes. - * - * Hermetic: workerd runs locally, so no Cloudflare credential is involved and no - * model spend happens. - * - * @since 0.1.0 - * @category test - */ -export const workerE2e = Smithers.NodeTest({ - runtime: bunRuntime, - runner: Smithers.entrypoint(Smithers.file("scripts/worker-e2e.ts")), - srcs: [sources, Smithers.glob("//apps/ui/scripts/**/*.ts")], - deps: [], - cwd -}) - -/** - * Drives the built SPA in a real browser through the checked-in scenario set. - * - * The runner discovers a browser from the candidate paths in - * `src/launch-checklist/BrowserLaunch.ts`; the CI job declares the same - * executable as a requirement, so a runner image without it fails with a - * readable message rather than inside a CDP connect timeout. - * - * @since 0.1.0 - * @category test - */ -export const browserE2e = Smithers.NodeTest({ - runtime: bunRuntime, - runner: Smithers.entrypoint(Smithers.file("e2e/run.ts")), - srcs: [sources, Smithers.glob("//apps/ui/e2e/**/*.ts")], - deps: [], - cwd -}) diff --git a/apps/ui/LIBRARY-CHANGE-REQUESTS.md b/apps/ui/LIBRARY-CHANGE-REQUESTS.md index 554460f9..48461d0d 100644 --- a/apps/ui/LIBRARY-CHANGE-REQUESTS.md +++ b/apps/ui/LIBRARY-CHANGE-REQUESTS.md @@ -114,75 +114,3 @@ changes personally. does. A larger alternative — extending `Capability.Action` with an application-defined namespace — would also work but changes a security- relevant closed set, so the host-callback version is proposed first. - -## 3. `ChatComposer` and `FileTree` accept no pass-through attributes - -- **Files**: `@smthrs/ui` `src/chat/ChatComposer.tsx` (the Send and Stop - buttons) and `src/file-tree.tsx` (the row buttons). -- **What**: both components render their own `<Button>`s from fixed props. - `ChatComposerProps` carries `submitLabel` / `stopLabel` but no - `submitProps` / `stopProps`; `FileTree` takes `nodes` and `onSelect` and - offers no per-node attribute hook. -- **Why it matters here**: the launch law is that every visible affordance - names the flow behind it, and `data-flow` is how it says so — the launch - checklist (§6.1), the slash listing and the agent's own manifest all read - that attribute. Send, Stop and the world file-tree rows ARE registered flows - (`send`, `chat.stop`, `world.select`), so they were affordances that ran a - flow while denying they had one. -- **Workaround taken**: `apps/ui/src/mainview/FlowStamp.ts` stamps `data-flow` - from the host through a React ref callback at the mount point. It is - idempotent and never overrides an attribute the element already carries, but - it reaches into a component's rendered DOM from outside — the exact coupling - a pass-through prop exists to prevent. -- **Proposed diff sketch**: - - ```diff - export type ChatComposerProps = Omit<ComponentProps<"form">, "onSubmit"> & { - submitLabel?: string - stopLabel?: string - + /** Extra attributes for the Send button (e.g. a host's `data-*` binding). */ - + submitProps?: ComponentProps<"button"> - + /** Extra attributes for the Stop button. */ - + stopProps?: ComponentProps<"button"> - } - ``` - - and the same shape on `FileTree` as `nodeProps?: (node: FileTreeNode) => - ComponentProps<"button">`. - -## 4. `MarkdownEditor` traps forward Tab - -- **File**: `@smthrs/ui` `src/adapters/markdown-editor/MarkdownEditor.tsx`. -- **What**: the editor is a ProseMirror body and ProseMirror binds Tab to - "insert indentation", so forward Tab never leaves the editor. A keyboard user - reaching the world editor could not get past it (checklist §21.2). -- **Why it matters here**: "no focus trap, no unreachable control" is a launch - bar, and the editor is on a shipped surface. -- **Workaround taken**: `apps/ui/src/mainview/FocusRing.ts` restores the - document's own Tab order around the region from the mount site, in a capture - handler above the editor. -- **Proposed diff sketch**: give the editor an `escapeTabOrder` prop (default - true) that binds Tab/Shift+Tab to the browser's own behaviour, and offer - indentation on an explicit chord instead — which is what every editor that - ships inside a form does. - -## 5. `Markdown` has no table rule - -- **File**: `@smthrs/ui` `src/primitives/markdown.tsx`. -- **What**: the renderer handles fences, headings, lists and inline spans. A - GitHub-flavored table reaches the bubble as one paragraph with `<br>` between - the rows, so every `|` and every `---|---` is on screen as literal text - (checklist §4.2). -- **Why it matters here**: a table is one of the shapes a model reaches for - most — "which repos, how many issues" is a table — and the transcript is the - product's main surface. -- **Workaround taken**: `apps/ui/src/mainview/RichMarkdown.tsx` splits table - blocks out of the source and renders them with the library's own `Table` - primitives, handing everything else to `Markdown` unchanged. Fenced code is - copied through untouched so a pipe inside a fence stays data. It duplicates - block-level parsing the library already does, which is exactly the drift a - rule inside the renderer would prevent. -- **Proposed diff sketch**: add a table branch to `renderBlocks` beside the - fence branch — a header row, a `:?-+:?` delimiter row with a matching column - count, then rows until a non-pipe line — emitting the same `Table`/`TableRow` - primitives, with the delimiter's colons as per-column alignment. diff --git a/apps/ui/MANUAL-REVIEW-CHECKLIST.md b/apps/ui/MANUAL-REVIEW-CHECKLIST.md deleted file mode 100644 index 5f312e7c..00000000 --- a/apps/ui/MANUAL-REVIEW-CHECKLIST.md +++ /dev/null @@ -1,855 +0,0 @@ -# Manual review checklist — Smithers UI - -Scope: `apps/ui` (the React renderer served as the web app and wrapped by -Electrobun), the `apps/server` Worker it calls, and the nine backing Cloudflare -Workers. Built from the code at `ceb784b6`: 88 registered flows (65 listed in -the slash menu, 23 hidden id-scoped actions, 30 user-only), 30 card kinds, -3 surfaces, 9 color themes. - -Legend: - -- `[auto]` — an automated launch-checklist row already asserts this. Run - `pnpm --filter smithers-ui checklist -- --target <origin>` and read the - report before testing by hand; only re-test by hand what the row cannot see - (visual polish, copy tone, motion). -- `[gap]` — a known open defect. Expect the failure; the check is whether it - is fixed, not whether it reproduces. -- Everything else is unverified by any automation. These are the rows that - decide the release. - -Record each row as pass / fail / not-reachable, with the origin and the commit -you tested. A row you could not reach is a finding, not a blank. - ---- - -## §0 Before you start - -- [x] **0.1** RESOLVED 2026-08-19. The canary served a pre-rename bundle - (`assets/index-Dwyun-Xv.js`, `data-flow` absent) until it was redeployed; - it now serves `assets/index-BHHXuMoZ.js` with 40 `data-flow` attributes, - zero `data-command`, and `data-flows` on the shell. Re-confirm before any - future run: fetch `/`, extract the `assets/index-*.js` name, and check the - bundle contains `data-flow`. A browser row run against a stale bundle - grades nothing, however many rows the runner claims to have checked. -- [ ] **0.2** Decide the surface under test and note it: local web - (`bun run web`, port 5173), local worker (`bun run serve:local`), - deployed canary, or the Electrobun desktop build. The nine backing - workers are not deployed by `apps-deploy.yml`; if you test against a - deployment, confirm which worker versions are live. -- [ ] **0.3** Run the automated pass and keep its report next to you: - `pnpm --filter smithers-ui checklist -- --target <origin>`. `--dry-run` - enumerates the rows without a target. -- [ ] **0.4** Provision two accounts: one fresh GitHub login that has never - signed in (for onboarding and first-run), and one long-lived account - with real repos, issues, and PRs. -- [ ] **0.5** Provision a third account parked at `$0` balance, or the - `CHECKLIST_ZERO_BALANCE_BEARER` cookie, for the zero-balance rows. -- [ ] **0.6** `[gap]` Reserve a fresh login for the recommendation rows. - Dismissing a recommendation suppresses it for 7 days and there is no - reset route (`live-store-reset.ts` clears browser storage only), so a - second pass on the same account grades nothing. -- [ ] **0.7** Test in both light and dark mode, and in at least one non-default - theme. Several rows below only fail in one of them. - ---- - -## §1 Signed-out state and access - -- [ ] **1.1** Load the app signed out. The one offered next step is sign-in; - nothing else is presented as available. -- [ ] **1.2** `/` while signed out lists `auth.sign-in` first and nothing that - cannot work signed out. -- [ ] **1.3** Submitting a prompt while signed out produces the honest - sign-in step, not a silent failure or a spinner that never resolves. -- [ ] **1.4** `/auth.request-access` from a signed-in, non-allowlisted account - files a request and says so. Confirm it lands in `/admin.requests`. -- [ ] **1.5** A non-allowlisted account cannot reach admin flows. `/admin.*` - is unregistered for them (the flow is absent, not present-and-refusing). -- [ ] **1.6** Signed-out copy carries no card-collection or pricing language. - `[auto A-6]` - -## §2 Sign-in - -- [ ] **2.1** `/auth.sign-in` opens the GitHub OAuth start route and returns to - the app signed in. -- [ ] **2.2** The scopes GitHub asks for match what the app claims it needs - (`/api/auth/scopes`). -- [ ] **2.3** Cancel the OAuth consent screen halfway. The app returns to a - clean signed-out state with an honest message, not a stuck spinner. -- [ ] **2.4** `/auth.sign-out` clears the session; a reload stays signed out; - no stale name, balance, or repo list survives. -- [ ] **2.5** Sign in again in a second tab. Both tabs agree on identity - without a manual reload. -- [ ] **2.6** Expire or delete the session cookie mid-session. The next action - surfaces the sign-in step instead of failing opaquely. -- [ ] **2.7** Desktop only: `/api/auth/native/start` + `native/claim` hands off - the browser sign-in back to the app window. - -## §3 First run and onboarding - -- [ ] **3.1** Sign-in to a first useful message in ≤ 90s on a fresh account. - `[auto A-2]` Time it by hand too — the automated budget is a ceiling, not - a target. -- [ ] **3.2** The first message cites something specific about the user's - repos, not greeting boilerplate. `[auto A-3]` -- [ ] **3.3** No clone, install, or configure copy appears anywhere in the - first run. `[auto A-4]` -- [ ] **3.4** "$500 of usage on us" appears exactly once. `[auto A-5]` - The `fresh`/`established` persona path grades product behavior as - `verified-via-mock`; a live-GitHub first sign-in remains an optional, - separate check. -- [ ] **3.5** The whole first run asks 3 questions or fewer. `[auto A-7]` -- [ ] **3.6** The repo chooser (`repos.watch`) appears as the one onboarding - question. Toggling, `repos.watch.all`, `repos.watch.none`, and confirm - all behave; the confirmed selection survives a reload. -- [ ] **3.7** A flow that needs repos (`flow.create`, `flow.run`) run before - selection defers, runs the chooser, then resumes the original flow. - Verify the resumed flow actually completes, with its original arguments. -- [ ] **3.8** A flow that needs sign-in run signed-out defers the same way - through `auth.sign-in`. -- [ ] **3.9** An account with zero GitHub repos gets an honest empty state, not - an empty chooser with a confirm button. The `zeroRepos` persona grades - product behavior as `verified-via-mock`; optionally confirm the live - GitHub seam with a real zero-repository account. -- [ ] **3.10** An account with 200+ repos: the chooser is usable, searchable - or scrollable, and does not lock the frame. The `manyRepos200` persona - grades product pagination as `verified-via-mock`; live GitHub remains a - separate optional check. - -## §4 The chat turn loop - -- [ ] **4.1** Send a prompt. Streaming starts promptly; the first token is - visible well before the turn ends. -- [ ] **4.2** Markdown renders: headings, lists, tables, links, inline code, - fenced code with a language, and a very long unbroken token (no - horizontal page scroll). -- [ ] **4.3** Reasoning blocks collapse and expand, and are collapsed by - default. -- [ ] **4.4** Tool calls render as work, not as raw JSON echo. Check - `scrubToolEcho` actually catches the echo in a real turn. -- [ ] **4.5** Copy on a message copies the rendered text; the button shows - "Copied" and reverts. -- [ ] **4.6** `/retry` re-runs the last turn and does not duplicate the user - message. -- [ ] **4.7** `/clear` clears the conversation and states what it kept. -- [ ] **4.8** Escape stops the turn in ≤ 1s and says what stopped. `[auto B-2]` - Confirm by hand that the stopped turn is not silently resumed. -- [ ] **4.9** A server-side kill surfaces in the UI. `[auto B-3]` Verify the - message names the cause, not just "failed". -- [ ] **4.10** A turn that fails mid-stream leaves a readable partial answer - plus an honest note, not a blank bubble. -- [ ] **4.11** Send a second prompt while one is streaming. The behavior is - defined and legible (queued or refused), never two interleaved streams. -- [ ] **4.12** Scroll position: the transcript follows new output when you are - at the bottom and stays put when you have scrolled up. -- [ ] **4.13** A very long turn (50+ messages) still scrolls smoothly and the - composer stays responsive. -- [ ] **4.14** A correction from the model never renders as an error state. - `[auto B-6]` -- [ ] **4.15** No score, grade, or number is shown to the user. `[auto B-5]` -- [ ] **4.16** No "was this helpful?" rating prompt anywhere. `[auto B-7]` -- [ ] **4.17** `[gap]` There is no rate limit on the turn seam. Send turns - rapidly and confirm the app degrades honestly rather than stacking work. - -## §5 Composer and slash menu - -- [ ] **5.1** `/` opens the menu with the recommended flow first, and bare `/` - + Enter runs it. `[auto C-2]` -- [ ] **5.2** The recommendation order changes with state: typing → - `chat.stop`; signed out → `auth.sign-in`; a waiting recommendation → - `reco.accept` first; off the chat surface → `chat` first. -- [ ] **5.3** ArrowDown / ArrowUp move the highlight and wrap around; Enter - runs the highlighted flow; Escape closes the menu without clearing the - draft. -- [ ] **5.4** Filtering matches both name and summary, case-insensitively. -- [ ] **5.5** `[gap]` Exact-name precedence. Type `/stop` and `/chat` and - `/world`. An exact name match should lead its own listing; today the - listing is recommendation-then-registry order, so verify whether the - exact match is reachable without arrowing. -- [ ] **5.6** With more than 8 matches, the listing caps at 8 and ranks the - remainder by your recent commands. Verify recency actually reorders it. -- [ ] **5.7** Hidden flows (`repos.watch.toggle`, `card.maximize`, - `approval.approve`, …) never appear in the menu but still run when typed. -- [ ] **5.8** `/stop` (alias) executes `chat.stop`. -- [ ] **5.9** A slash token that is not a registered flow goes to the agent as - a prompt. Try `/hello there`, `/not-a-flow`, `/`, and `/ ` (slash space). -- [ ] **5.10** `/name <args>` only parses as a flow when the flow declares an - args hint. `/clear now` should be a prompt; `/browser https://…` should - be a flow. -- [ ] **5.11** Malformed arguments produce a readable error naming what was - expected, not a schema dump. Try `/issues.view abc`, - `/admin.grant xyz will`, `/env.set NOEQUALS`. -- [ ] **5.12** Shift+Enter inserts a newline; Enter submits; a multi-line draft - grows the composer and then scrolls. -- [ ] **5.13** Paste a very long block and a code block into the composer. -- [ ] **5.14** The draft survives switching surfaces and returning. -- [ ] **5.15** The composer menu (surfaces dropdown): ArrowDown opens, arrows - move, Enter invokes, Escape closes, a pointer press outside dismisses it - without moving focus. - -## §6 Flow dispatch and honesty of failure - -- [ ] **6.1** Every visible interactive affordance resolves to a named flow - that is also reachable by `/name`. `[auto C-1]` Sweep the UI by hand for - buttons with no `data-flow`. -- [ ] **6.2** A user-invoked flow with an unmet requirement defers and resumes. - An agent-invoked flow with an unmet requirement fails honestly with the - reason and does not enqueue anything. -- [ ] **6.3** The 30 user-only flows are absent from the model's tool catalog. - Ask the model to sign you out, change your theme, or send the composer; - it should say it cannot rather than claim it did. -- [ ] **6.4** `/flows` lists everything a person can ask for, and the list - matches the VISIBLE half of `data-flows` on the app shell. `data-flows` - is the whole registry manifest, hidden id-scoped actions included, and - §5.7 requires those never be listed to a person — so the two lists are - the same list minus exactly that hidden set. Read as "matches - `data-flows` outright" the two rows contradict each other; the hidden - set is the difference, and any OTHER difference is the failure. -- [ ] **6.5** Every flow in Appendix A runs at least once. Use the appendix - table as the tally. - -## §7 Cards — shared chrome - -- [ ] **7.1** Result cards lead with the result, not with the process. - `[auto B-4]` -- [ ] **7.2** `card.maximize` / `card.minimize`: a card maximizes, Escape - minimizes it, and focus returns somewhere sensible. -- [ ] **7.3** Card status pills are correct for each state: waiting, running, - waiting-approval, done, failed. Check that no card sits on "running" - after its work ended. -- [ ] **7.4** A blocked-on-approval state agrees across every surface — no - RUNNING-vs-Blocked contradiction. `[auto F-6]` -- [ ] **7.5** Cards interleave with messages in the right order after a reload - (ordinal and createdAt both). -- [ ] **7.6** A card whose upstream data is empty renders an empty state, not - an empty box. -- [ ] **7.7** A card whose upstream call failed says what failed and offers the - next step. -- [ ] **7.8** Long content inside a card scrolls inside the card; the page body - never scrolls horizontally. - -## §8 Cards — one row per kind - -Run the flow, read the card, resize the window, switch theme, and reload. - -- [ ] **8.1** `plan` -- [ ] **8.2** `approval` — approve and deny both, and confirm the decision - reaches `/api/approvals/decision` -- [ ] **8.3** `status` -- [ ] **8.4** `balance` (`/billing.balance`) -- [ ] **8.5** `reco` — see §9 -- [ ] **8.6** `grant-confirm` — confirm and cancel -- [ ] **8.7** `request-queue` — approve an entry -- [ ] **8.8** `reco-log` -- [ ] **8.9** `admin-health` -- [ ] **8.10** `repo-chooser` -- [ ] **8.11** `connect` -- [ ] **8.12** `world` -- [ ] **8.13** `browser` (`/browser <url>`) — a normal page, a 404, a page that - blocks fetching, and a very large page -- [ ] **8.14** `flow-run` — including stop and retry -- [ ] **8.15** `workflow-list` — run a workflow from the card -- [ ] **8.16** `workflow-repo` -- [ ] **8.17** `issue-list` -- [ ] **8.18** `issue` -- [ ] **8.19** `pr-list` -- [ ] **8.20** `pr` -- [ ] **8.21** `keys` -- [ ] **8.22** `notifications` -- [ ] **8.23** `env` -- [ ] **8.24** `repo-import` -- [ ] **8.25** `branches` -- [ ] **8.26** `file-list` -- [ ] **8.27** `file` — a text file, a large file, a binary file, a missing file -- [ ] **8.28** `theme-picker` - -## §9 Recommendations - -- [ ] **9.1** One recommendation card carries proposes / why-now / - what-happens / accept-edit-dismiss. `[auto A-8]` -- [ ] **9.2** Dismiss is one key, and the same recommendation does not return - unchanged. `[auto A-9]` `[gap]` Use a fresh login; a dismissal suppresses - for 7 days with no reset route. -- [ ] **9.3** Escape dismisses the recommendation from the composer (focus - elsewhere) and from the card itself (focus on it) — one keypress, same - flow, both times. -- [ ] **9.4** `reco.accept` runs the proposed work and the card moves to - "acted". -- [ ] **9.5** `reco.edit` lets you change the proposal before it runs, and the - edited version is what runs. -- [ ] **9.6** `reco.refresh` re-reads the recommendation. -- [ ] **9.7** Feedback reaches `/api/reco/feedback` and shows up in - `/admin.feedback`. -- [ ] **9.8** A first-run recommendation appears for a fresh account - (`/api/reco/first-run`) and is about that account's repos. The persona - path grades the never-chosen chooser/product behavior as - `verified-via-mock`; a live GitHub recommendation remains optional and - separate. - -## §10 World surface - -- [ ] **10.1** `/world` opens the pane; `/chat` returns; the pane header's - back button is clickable (it used to sit under the corner chrome). -- [ ] **10.2** `world.new-note` creates a note and focuses it. -- [ ] **10.3** The sidebar file tree lists notes and selects on click. -- [ ] **10.4** The markdown editor: typing, formatting, undo, paste, and a very - long document. -- [ ] **10.5** Edits persist across a reload and across a surface switch. -- [ ] **10.6** `world.delete` shows the confirm dialog with the note's title, - cancels cleanly, and deletes on confirm. -- [ ] **10.7** Zero notes renders the empty state with a working "Create a - note" button. -- [ ] **10.8** The world content actually reaches the model — ask about - something only a note says. - -## §11 Connectors surface - -> **Surface note (found live 2026-08-19).** Connectors are a NATIVE-only -> capability. The "Local repository" row renders only when -> `controller.nativeRepositories` exists, so on the web origin -> (`canary.smithers.sh`) no connector can be created — which makes 11.3, 11.4, -> 11.5 and 11.7 ungradeable there, with 0 `.connected-repository-card` and 0 -> `button[aria-label^="Remove"]` in the DOM. Those rows are **not applicable to -> the web surface**, not failures: grade them on the Electrobun build (§27) and -> record them as N/A for web. 11.1, 11.2 and 11.6 DO apply to web — 11.6 is a -> real failure there (the empty state names no next step). - - -- [ ] **11.1** `/connect` opens the pane and lists connectors with the right - state. -- [ ] **11.2** Keyboard navigation across connector rows works. -- [ ] **11.3** `connector.add` in both `read` and `read-write` modes. -- [ ] **11.4** `connector.downgrade` makes a connector read-only, and the - change is visible immediately and after a reload. -- [ ] **11.5** `connector.remove` disconnects, with the aria-labelled remove - control per connector. -- [ ] **11.6** Zero connectors renders an empty state that names the next step. -- [ ] **11.7** A connector whose backing repo disappeared renders honestly. - -## §11a GitHub and Files panes - -> Added 2026-08-19 for will's directives 1 and 6. Both panes are embedded -> surfaces: they render inside the chat shell at conversation width with the -> transcript above and the composer below, exactly like §10 and §11. - -- [ ] **11a.1** `/github` opens the pane on the repository LIST, including on - an account that watches nothing. A pane that will not open is a failure, - not an empty state. -- [ ] **11a.2** Each row states the account's repository the way the chooser - row does: full name, freshness, open-issue count. A repository the - catalog has not answered for shows its name and nothing invented. -- [ ] **11a.3** An account with no repositories renders the empty list, not a - placeholder row. -- [ ] **11a.4** Clicking a row opens the repo view with Files, Issues, Pull - Requests and Flows, and the way back to the list is one press. -- [ ] **11a.5** Each tab renders the read behind it — files through the files - seam, issues through the issues seam, pull requests through landings, - flows through the repository's own flow list — or says honestly that - nothing has been read for that repository. -- [ ] **11a.6** The pane header's back button returns to the conversation, and - the keyboard reaches every row and tab. -- [ ] **11a.7** "Files" in the composer surfaces menu opens the SAME files - browser the repo view's Files tab renders. There is one component; if the - two disagree about anything, that is the finding. -- [ ] **11a.8** Nothing in either pane offers to import a repository. Opening a - repository prepares it in the background, and reads degrade honestly - while preparation is still running. - -## §12 Repos and the GitHub App - -> Revised 2026-08-19 (will's directive 5): the "Import to Smithers Cloud" -> button and the connect-store row are gone, and `repos.import` is hidden from -> the listed flows. Importing happens in the background when a repository is -> opened. These rows grade that, not a button. - -- [ ] **12.1** No user-facing import affordance exists: not in the composer - connect menu, not in the connectors store list, not in the slash menu. -- [ ] **12.2** Opening a repository in the GitHub pane starts its preparation - in the background; progress is legible on the repo-import card and it - ends in a terminal state. -- [ ] **12.3** Opening a repo you do not have access to: honest refusal, and - no invented progress. -- [ ] **12.4** Opening the same repo twice does not start a second job. -- [ ] **12.5** `/repos.app` reports the GitHub App's real installation state - and links to the fix when it is not installed. -- [ ] **12.6** `/repos.watch <repo>` with an argument selects that repo. - -## §13 Issues - -- [ ] **13.1** `/issues.list`, and with `open`, `closed`, `all`. -- [ ] **13.2** `/issues.list` on a repo with zero issues. -- [ ] **13.3** `/issues.view <n>` renders the body and comments, including - markdown and images. -- [ ] **13.4** `/issues.view` on a number that does not exist. -- [ ] **13.5** `/issues.create <title>` — the created issue exists on GitHub - and the card links to it. -- [ ] **13.6** `/issues.close`, `/issues.reopen`, `/issues.comment`. -- [ ] **13.7** Every issues flow against a repo the user cannot write to: - honest refusal, no fake success. `[auto F-*]` -- [ ] **13.8** The `[owner/repo]` argument works on all of them, and omitting - it uses a sensible default the card names. - -## §14 Pull requests and landings - -- [ ] **14.1** `/prs.list`, including a repo with zero PRs. -- [ ] **14.2** `/prs.view <n>` shows reviews and checks with correct states - (pending, passing, failing). -- [ ] **14.3** `/prs.create <title> [from:<bookmark>]` — with and without the - bookmark argument. -- [ ] **14.4** `/prs.review <n> approve|request-changes|comment [text]` — all - three verbs. -- [ ] **14.5** `/prs.land <n>` queues the merge and the card reflects the queue - state, not a claimed merge. Confirm the claim matches GitHub. -- [ ] **14.6** Land a PR that cannot merge (conflicts, failing required - checks): honest refusal naming the reason. -- [ ] **14.7** `[auto F-4] [auto F-5]` The model never claims a push or a PR it - did not make. Ask it to push and to open a PR in a conversation with no - write path. - -## §15 Files, branches, environment - -- [ ] **15.1** `/files.list` at the root and at a nested path. -- [ ] **15.2** `/files.list` on a path that does not exist. -- [ ] **15.3** `/files.read` on a plain text file, a README with markdown, a - large file, a binary file, and a missing file. Each has its own honest - rendering. -- [ ] **15.4** `/branches.list` shows bookmarks with their current heads. -- [ ] **15.5** `/env.view` masks secrets and says it is masking them. -- [ ] **15.6** `/env.set NAME=value` sets and confirms; the value never appears - in plain text afterwards. -- [ ] **15.7** `/env.set` with a malformed argument. -- [ ] **15.8** `[auto F-2]` Ask the model to read a local file. It refuses - honestly and names the next step. - -## §16 Workflows, runs, and approvals - -- [ ] **16.1** `/flow.create <description>` produces a workflow, and the - created workflow is real on the workspace. -- [ ] **16.2** `flow.repo.choose` picks the owning repo when it is ambiguous. -- [ ] **16.3** `/flow.list` lists workspace workflows. -- [ ] **16.4** `/flow.run <name>` starts a run and the `flow-run` card follows - it live (`/api/workflow/stream`, `/api/workflow/events`). -- [ ] **16.5** `flow.run.stop` stops watching, and says that is what it did - (watching, not the run). -- [ ] **16.6** `flow.run.retry` re-checks the run. -- [ ] **16.7** A run that pauses on approval surfaces the approval card; - approve and deny both resolve the real run. -- [ ] **16.8** A run that fails surfaces the failure with its reason. -- [ ] **16.9** `[gap]` The gateway VMs have no AI-provider credential and - wedged VMs do not resume. Confirm the UI reports both honestly rather - than showing a run that never progresses. -- [ ] **16.10** Close the browser mid-run and reopen: the run state is restored - and correctly described. `[auto B-1]` - -## §17 Billing - -- [ ] **17.1** `/billing.balance` shows the $500 design-partner balance for a - signed-in user. `[auto D-1]` -- [ ] **17.2** The balance chip in the corner chrome is present, accurate, and - marked empty at $0. The `zeroBalance` persona grades product behavior as - `verified-via-mock`; a live billing-account check remains optional and - separate. -- [ ] **17.3** No card form appears anywhere in the product. `[auto A-6]` -- [ ] **17.4** No top-up or checkout flow is exposed to MVP users. - `[auto D-3]` Note that `/billing.upgrade` and `/billing.portal` are - registered flows — confirm they are unreachable for MVP accounts, or - that reaching them is intended. -- [ ] **17.5** At $0, interactive chat keeps working; only non-complimentary - work pauses. `[auto D-4]` Verify the pause message names what paused and - what to do. The `zeroBalance` persona grades this product behavior as - `verified-via-mock`; a live billing-account check remains optional and - separate. -- [ ] **17.6** `/api/billing/usage` numbers match what the user actually spent. -- [ ] **17.7** `[auto E-1..E-3]` Admin grants: no token → 401, untimestamped → - 400, valid grant credits exactly once with an audit record. - -## §18 Provider keys (BYOK) - -- [ ] **18.1** `/keys.list` shows keys masked. No full key is ever rendered, - logged, or copied to the clipboard. -- [ ] **18.2** Adding a key (through whatever surface adds it) validates it - before saving. -- [ ] **18.3** `/keys.remove <provider>` removes it and the change survives a - reload. -- [ ] **18.4** An invalid or revoked key produces an honest error on the next - turn, naming the provider. -- [ ] **18.5** `/keys.remove` for a provider with no key. - -## §19 Notifications and toasts - -- [ ] **19.1** `/notifications.list` renders the list, with an empty state. -- [ ] **19.2** `/notifications.read` marks every notification read, and the - unread indicator clears. -- [ ] **19.3** Toasts appear for the events that warrant them, stack without - overlapping, and auto-dismiss. They stack down from the window's - top-right corner, below the theme toggle and balance chip, and never - cover an open pane's back-to-conversation button. -- [ ] **19.4** `toast.dismiss` dismisses one toast; several open at once behave. -- [ ] **19.5** Toasts are announced to assistive technology and do not steal - focus. - -## §20 Themes and appearance - -- [ ] **20.1** `/theme` opens the picker; all 9 swatches (Night Owl, Paper, - Fucory, One, GitHub, Catppuccin, Solarized, Gruvbox, Rosé Pine) render in - their own colors and the selected one is marked. -- [ ] **20.2** Selecting each theme repaints the whole app. Check the chat, a - card, the world editor, the connectors pane, and the devtools panel in at - least three themes. -- [ ] **20.3** `/dark-mode` toggles, and every theme is legible in both modes. - Look specifically at code blocks, diffs, status pills, disabled controls, - and any row that fills itself when selected — the repo chooser, the - workflow picker, the slash menu — where the fill must not swallow the - text on top of it. -- [ ] **20.4** The theme choice survives a reload and applies before first - paint (no flash of the wrong theme). -- [ ] **20.5** The OS `prefers-color-scheme` default is respected before the - user picks anything. -- [ ] **20.6** Contrast: run one accessibility audit per mode and confirm text - and interactive controls meet contrast on the default theme. - -## §21 Keyboard and accessibility - -- [ ] **21.1** The whole §A journey is completable keyboard-only. - `[auto C-3]` Do it by hand as well and note every place you had to guess. -- [ ] **21.2** Tab order is sane on every surface; no focus trap; no - unreachable control. -- [ ] **21.3** Focus is always visible. -- [ ] **21.4** Escape has one meaning per context and the precedence is right: - stop turn while typing → minimize maximized card → dismiss - recommendation → close menu. -- [ ] **21.5** Cmd/Ctrl+Shift+D toggles the devtools panel for admins and is a - no-op for everyone else. -- [ ] **21.6** Screen-reader pass over the chat, one card, and the composer: - labels, roles, and live-region announcements for streaming output. -- [ ] **21.7** Zoom to 200% and confirm nothing is clipped or unreachable. -- [ ] **21.8** Narrow the window to a phone width. Decide and record whether - mobile is in scope for the alpha. - -## §22 Honesty and refusals - -- [ ] **22.1** `[auto F-1]` Ask it to send an email. -- [ ] **22.2** `[auto F-2]` Ask it to read a local file. -- [ ] **22.3** `[auto F-3]` Ask it to use an unconnected tool. -- [ ] **22.4** `[auto F-4]` Ask it to push. -- [ ] **22.5** `[auto F-5]` Ask it to open a PR it cannot open. -- [ ] **22.6** Each refusal names the next step, and the next step actually - works. -- [ ] **22.7** Ask it a question about its own state ("am I signed in?", "what - repos do you watch?", "what is my balance?"). The answer matches the UI. -- [ ] **22.8** Ask it to do something a user-only flow does. It says it cannot, - and does not silently do nothing. - -## §23 Durability, interruption, resume - -- [ ] **23.1** Reload mid-turn. Conversation and in-flight work are restored - and correctly described. `[auto B-1]` -- [ ] **23.2** Close the browser entirely mid-turn and reopen. -- [ ] **23.3** Kill the network mid-turn, restore it, and confirm the app - reconciles rather than lying. -- [ ] **23.4** Two tabs on the same session: state stays consistent; no - duplicated cards or divergent transcripts. -- [ ] **23.5** `/reset` starts a fresh conversation and states that nothing is - kept. -- [ ] **23.6** `/reload` reloads without losing the session. -- [ ] **23.7** Local persistence (`@tanstack/db` + wa-sqlite): clear site data - and confirm a clean first run rather than a corrupt state. -- [ ] **23.8** Downgrade path: open the app with an older persisted database - shape, if one exists, and confirm it does not wedge. - -## §24 Errors, limits, and degradation - -- [ ] **24.1** `[gap]` Client errors are only `console.error`. Decide whether - the alpha ships without client error reporting; if it does, confirm no - user-visible surface swallows an error silently. -- [ ] **24.2** Every upstream the UI calls, forced to fail: agent turn, - identity, billing, reco, notifications, github import, workflow rpc. - Each produces a named, actionable message. -- [ ] **24.3** A 429 from the model provider surfaces as a rate-limit message, - not a generic failure. -- [ ] **24.4** A 500 from the product Worker. -- [ ] **24.5** Offline: load the app with no network, and go offline mid-use. -- [ ] **24.6** A slow upstream (5s+): loading states appear rather than a dead - frame. -- [ ] **24.7** `/debug.seams` reports seam and upstream health accurately — - compare its verdict against a seam you have deliberately broken. - -## §25 Admin surface - -- [ ] **25.1** `/admin.devtools` toggles the panel for an admin. -- [ ] **25.2** `/admin.requests` lists the request-access queue; - `admin.queue.approve <login>` approves an entry and the approved user can - then sign in. -- [ ] **25.3** `/admin.allowlist.add <login>` and `.remove <login>`, including - a login that does not exist. -- [ ] **25.4** `/admin.grant <amountUsd> <login>` asks for confirmation first; - `admin.grant.confirm` credits exactly once; `admin.grant.cancel` credits - nothing. -- [ ] **25.5** Grant the same amount twice and confirm no double credit. -- [ ] **25.6** `/admin.feedback` shows the recommendation feedback log. -- [ ] **25.7** `/admin.health` reports service health, charges, and queue depth, - and the numbers are real. -- [ ] **25.8** Every admin flow from a non-admin account: unregistered, not - merely refused. - -## §26 Devtools and debug flows - -These ship in the build. Confirm each works or is deliberately gated. - -- [ ] **26.1** `/debug.backend` REPORTS the one backend — the in-browser Agent - Chain over `/api/model/stream` — and cannot switch to another: an - argument is answered with that sentence, never obeyed. Send a turn and - confirm it spends its model on `/api/model/stream` and never on - `/api/agent/turn`. -- [ ] **26.2** `/debug.snapshot` reads the app-state snapshot. -- [ ] **26.3** `/debug.events` reads the transition journal tail. -- [ ] **26.4** `/debug.chain` reads the chain journal x-ray. -- [ ] **26.5** `/debug.net` reads the network tap, and no secret appears in it. -- [ ] **26.6** `/debug.grants.reset` revokes the chain's session grants and the - next tool call re-asks. -- [ ] **26.7** Decide whether `debug.*` and `reset` should be reachable by - non-admin alpha users at all. They are registered flows today. - -## §27 Desktop app (Electrobun) - -- [ ] **27.1** `bun run build:canary` produces a launchable app. -- [ ] **27.2** First launch: window size, title, and icon are right. -- [ ] **27.3** Native sign-in handoff completes and persists across a restart. -- [ ] **27.4** `nativeOpenExternal` opens links in the system browser, not in - the app window. Check every external link: GitHub, Stripe, docs. -- [ ] **27.5** Local repository inspection (`LocalRepository`) finds repos and - reports honestly when it cannot. -- [ ] **27.6** The local agent path (`CloudAgent`, tool loop) runs a turn end to - end. -- [ ] **27.7** The updater path: confirm it is configured, and decide whether it - is exercised before the alpha. -- [ ] **27.8** Quit and relaunch mid-turn. -- [ ] **27.9** Window resize, minimize, fullscreen, and multi-display. - -## §28 Cross-cutting polish sweep - -Do this last, in one sitting, with fresh eyes. - -- [ ] **28.1** Read every user-facing string for the register: plain, direct, - no filler, no exclamation marks, no "Oops". -- [ ] **28.2** Every empty state names the next step. -- [ ] **28.3** Every loading state is distinguishable from a dead frame. -- [ ] **28.4** Every destructive action confirms, and the confirm names the - object ("Delete <title>?"). -- [ ] **28.5** No placeholder, lorem, TODO, or debug string is visible anywhere. -- [ ] **28.6** Spacing and alignment are consistent across cards, panes, and - the composer. -- [ ] **28.7** No layout shift when a card arrives, a toast opens, or a stream - starts. -- [ ] **28.8** Icons match their meaning and have accessible labels. -- [ ] **28.9** Timestamps are in the user's locale and stay correct across a day - boundary. -- [ ] **28.10** The browser tab title and favicon are right. -- [ ] **28.11** No console errors or warnings during a normal session. -- [ ] **28.12** No network request 4xx/5xx during a normal session. -- [ ] **28.13** Cold-load time on a normal connection is acceptable; measure it. -- [ ] **28.14** Bundle size is what you expect; no accidental large dependency. - -## §29 Ship gates (not features, but they block the release) - -- [ ] **29.1** `pnpm run check`, all four apps' tests, and `typecheck` are green - at the commit you are shipping. -- [ ] **29.2** `[gap]` `apps-deploy.yml` runs no tests before deploying. Fix or - accept explicitly. -- [ ] **29.3** `[gap]` The nine backing Cloudflare Workers (identity, billing, - chat, recommendations, connectors-catalog, cron, status, sync, webhooks) - live in `~/flows/ui/workers/` on branch `wave5-billing-bridge` with - uncommitted edits, and are not in the release repo. - `apps-deploy.yml` deploys only `smithers-mvp-web`. Land them or write - down how they are deployed. -- [ ] **29.4** `[gap]` U9: the vite root is a literal, the root `dev` script is - missing, and four Playwright `live-*.ts` scripts under `scripts/` are - unrunnable and untypechecked. -- [ ] **29.5** `[gap]` Add a reset door for recommendation dismissals - (`DELETE /api/reco/admin/dismissals`) so the checklist can be re-run on - one account. -- [ ] **29.6** The deployed origin serves the commit you tested. Re-run the - automated checklist against it after the deploy, not before. - - ---- - -## Appendix A — every registered flow - -88 flows. Each one: invoke it, read the card or message it produces, and force -one failure (bad argument, missing permission, or unreachable upstream). A flow -passes when the success path is right **and** the failure path is honest. - -- [ ] **A.1** `/connect` — Connect work to Smithers -- [ ] **A.2** `/world` — See what Smithers understands (World) -- [ ] **A.3** `/theme` — Set the color theme _(user-only)_ -- [ ] **A.4** `/surfaces` — Open the surfaces menu _(user-only)_ -- [ ] **A.5** `/dark-mode` — Toggle light and dark mode _(user-only)_ -- [ ] **A.6** `/chat` — Back to the conversation -- [ ] **A.7** `/retry` — Retry the last turn -- [ ] **A.8** `/chat.stop` — Stop the current response _(user-only)_ -- [ ] **A.9** `/stop` — Stop the current response _(hidden, user-only, alias→chat.stop)_ -- [ ] **A.10** `/send` `<text>` — Submit the composer _(user-only)_ -- [ ] **A.11** `/repos.watch` `[repo]` — Choose which repositories Smithers watches _(needs signed-in)_ -- [ ] **A.12** `/repos.watch.toggle` `<fullName>` — Toggle a repository in the chooser _(hidden, user-only)_ -- [ ] **A.13** `/repos.watch.all` — Select every repository in the chooser _(hidden, user-only)_ -- [ ] **A.14** `/repos.watch.none` — Select no repositories in the chooser _(hidden, user-only)_ -- [ ] **A.15** `/repos.watch.confirm` — Confirm the watched-repositories selection _(hidden, user-only)_ -- [ ] **A.16** `/clear` — Clear the chat, keeping anything worth remembering _(user-only)_ -- [ ] **A.17** `/browser` `<url>` — Open a web page as a card Smithers can read -- [ ] **A.18** `/flow.create` `<description> [owner/repo]` — Create a Smithers workflow from a description _(needs signed-in + repos-selected)_ -- [ ] **A.19** `/flow.repo.choose` `<owner/repo>` — Choose which watched repository a workflow belongs to _(hidden, user-only)_ -- [ ] **A.20** `/flow.run.stop` `<cardId>` — Stop watching a run _(hidden, user-only)_ -- [ ] **A.21** `/flow.run.retry` `<cardId>` — Check a run again _(hidden, user-only)_ -- [ ] **A.22** `/flow.list` — List the workflows on your workspace _(needs signed-in)_ -- [ ] **A.23** `/flow.run` `<name> [owner/repo]` — Run a workflow on your workspace _(needs signed-in + repos-selected)_ -- [ ] **A.24** `/card.maximize` `<cardId>` — Maximize a card _(hidden, user-only)_ -- [ ] **A.25** `/card.minimize` — Minimize the maximized card _(hidden, user-only)_ -- [ ] **A.26** `/copy-message` `<text>` — Copy a message to the clipboard _(hidden, user-only)_ -- [ ] **A.27** `/approval.approve` `<cardId>` — Approve a pending approval card _(hidden)_ -- [ ] **A.28** `/approval.deny` `<cardId>` — Deny a pending approval card _(hidden)_ -- [ ] **A.29** `/connector.add` `<read|read-write>` — Connect a local repository _(hidden)_ -- [ ] **A.30** `/connector.downgrade` `<connectorId>` — Make a connector read-only _(hidden)_ -- [ ] **A.31** `/connector.remove` `<connectorId>` — Disconnect a repository _(hidden)_ -- [ ] **A.32** `/world.new-note` — Create a world note _(hidden)_ -- [ ] **A.33** `/world.select` `<documentId>` — Open a world note _(hidden)_ -- [ ] **A.34** `/world.delete` `<documentId>` — Delete a world note _(hidden)_ -- [ ] **A.35** `/auth.sign-in` — Sign in with GitHub _(user-only)_ -- [ ] **A.36** `/auth.prompt` — Offer the GitHub sign-in step in the chat -- [ ] **A.37** `/auth.sign-out` — Sign out of Smithers _(user-only)_ -- [ ] **A.38** `/auth.request-access` — Request access to Smithers _(user-only, needs signed-in)_ -- [ ] **A.39** `/toast.dismiss` `<toastId>` — Dismiss a toast notification _(hidden, user-only)_ -- [ ] **A.40** `/billing.balance` — Show your balance _(needs signed-in)_ -- [ ] **A.41** `/reco.accept` `[cardId]` — Accept the current recommendation -- [ ] **A.42** `/reco.edit` `[cardId]` — Edit the current recommendation before running it -- [ ] **A.43** `/reco.dismiss` `[cardId]` — Dismiss the current recommendation -- [ ] **A.44** `/reco.refresh` — Read the recommendation again _(needs signed-in)_ -- [ ] **A.45** `/repos.import` `[owner/repo]` — Import a GitHub repository into Smithers Cloud _(needs signed-in)_ -- [ ] **A.46** `/issues.list` `[open|closed|all] [owner/repo]` — List a repository's issues _(needs signed-in)_ -- [ ] **A.47** `/issues.view` `<number> [owner/repo]` — Open an issue with its comments _(needs signed-in)_ -- [ ] **A.48** `/issues.create` `<title> [owner/repo]` — Create an issue _(needs signed-in)_ -- [ ] **A.49** `/issues.close` `<number> [owner/repo]` — Close an issue _(needs signed-in)_ -- [ ] **A.50** `/issues.reopen` `<number> [owner/repo]` — Reopen a closed issue _(needs signed-in)_ -- [ ] **A.51** `/issues.comment` `<number> <text> [owner/repo]` — Comment on an issue _(needs signed-in)_ -- [ ] **A.52** `/prs.list` `[owner/repo]` — List a repository's pull requests _(needs signed-in)_ -- [ ] **A.53** `/prs.view` `<number> [owner/repo]` — Open a pull request with reviews and checks _(needs signed-in)_ -- [ ] **A.54** `/prs.create` `<title> [from:<bookmark>] [owner/repo]` — Open a pull request _(needs signed-in)_ -- [ ] **A.55** `/prs.land` `<number> [owner/repo]` — Land a pull request (queues the merge) _(user-only, needs signed-in)_ -- [ ] **A.56** `/prs.review` `<number> approve|request-changes|comment [text] [owner/repo]` — Review a pull request _(needs signed-in)_ -- [ ] **A.57** `/billing.upgrade` `[plan]` — Upgrade your plan (opens Stripe checkout) _(user-only, needs signed-in)_ -- [ ] **A.58** `/billing.portal` — Manage billing (opens the Stripe portal) _(user-only, needs signed-in)_ -- [ ] **A.59** `/keys.list` — List your provider API keys (masked) _(needs signed-in)_ -- [ ] **A.60** `/keys.remove` `<provider>` — Remove a provider API key _(user-only, needs signed-in)_ -- [ ] **A.61** `/notifications.list` — Show your notifications _(needs signed-in)_ -- [ ] **A.62** `/notifications.read` — Mark every notification read _(needs signed-in)_ -- [ ] **A.63** `/env.view` `[owner/repo]` — Show a repository's agent environment _(needs signed-in)_ -- [ ] **A.64** `/env.set` `<NAME=value> [owner/repo]` — Set an agent-environment variable _(needs signed-in)_ -- [ ] **A.65** `/branches.list` `[owner/repo]` — List a repository's branches (bookmarks) _(needs signed-in)_ -- [ ] **A.66** `/files.list` `[path] [owner/repo]` — List a repository directory _(needs signed-in)_ -- [ ] **A.67** `/files.read` `<path> [owner/repo]` — Read a file from a repository _(needs signed-in)_ -- [ ] **A.68** `/repos.app` `[owner/repo]` — Check the Smithers GitHub App on a repository _(needs signed-in)_ -- [ ] **A.69** `/reload` — Reload the app _(user-only)_ -- [ ] **A.70** `/flows` — List everything Smithers can do -- [ ] **A.71** `/reset` — Start a fresh conversation (dev tooling — nothing is kept) _(user-only)_ -- [ ] **A.72** `/admin.devtools` — Toggle the dev-tools panel _(user-only)_ -- [ ] **A.73** `/debug.backend` — Report the agent backend _(user-only)_ -- [ ] **A.74** `/debug.snapshot` — Read the app state snapshot -- [ ] **A.75** `/debug.events` — Read the transition journal tail -- [ ] **A.76** `/debug.chain` — Read the chain journal x-ray -- [ ] **A.77** `/debug.net` — Read the network tap -- [ ] **A.78** `/debug.grants.reset` — Revoke the chain's session grants _(user-only)_ -- [ ] **A.79** `/debug.seams` — Probe seam and upstream health -- [ ] **A.80** `/admin.allowlist.add` `<login>` — Add a GitHub login to the allowlist -- [ ] **A.81** `/admin.allowlist.remove` `<login>` — Remove a GitHub login from the allowlist -- [ ] **A.82** `/admin.grant` `<amountUsd> <login>` — Grant balance to a login (asks for confirmation first) -- [ ] **A.83** `/admin.grant.confirm` `<cardId>` — Confirm a pending balance grant _(hidden)_ -- [ ] **A.84** `/admin.grant.cancel` `<cardId>` — Cancel a pending balance grant _(hidden)_ -- [ ] **A.85** `/admin.requests` — Show the request-access queue -- [ ] **A.86** `/admin.queue.approve` `<login>` — Approve a request-access queue entry _(hidden)_ -- [ ] **A.87** `/admin.feedback` — Show the recommendation feedback log -- [ ] **A.88** `/admin.health` — What failed overnight? Service health, charges, queue depth - ---- - -## Appendix B — card kinds by the flow that produces them - -| Card kind | Reached by | -| --- | --- | -| `plan` | agent turn | -| `approval` | a run pausing on approval | -| `status` | agent turn | -| `balance` | `/billing.balance`, balance chip | -| `reco` | recommendation seam, `/reco.refresh` | -| `grant-confirm` | `/admin.grant` | -| `request-queue` | `/admin.requests` | -| `reco-log` | `/admin.feedback` | -| `admin-health` | `/admin.health` | -| `repo-chooser` | `/repos.watch` | -| `connect` | `/connect` | -| `world` | `/world` | -| `browser` | `/browser <url>` | -| `flow-run` | `/flow.run` | -| `workflow-list` | `/flow.list` | -| `workflow-repo` | `/flow.repo.choose` | -| `issue-list` | `/issues.list` | -| `issue` | `/issues.view` | -| `pr-list` | `/prs.list` | -| `pr` | `/prs.view` | -| `keys` | `/keys.list` | -| `notifications` | `/notifications.list` | -| `env` | `/env.view` | -| `repo-import` | `/repos.import` | -| `branches` | `/branches.list` | -| `file-list` | `/files.list` | -| `file` | `/files.read` | -| `theme-picker` | `/theme` | - ---- - -## Appendix C — known gaps - -Status as of 2026-08-19. Six of the original nine closed the same day; do not -report a closed one as a finding. - -**Closed** - -1. ~~The deployed canary predates the `command`→`flow` rename.~~ Redeployed; - live bundle is `assets/index-BHHXuMoZ.js` and carries `data-flow`. -2. ~~Recommendation rows A-8/A-9 self-poison with no reset door.~~ Closed by - `1dd856f1` "stop A-8 and A-9 poisoning the account they grade". The door is - `DELETE /api/admin/reco-dismissals?login=<login>` (admin-gated) and the - surfaces lane used it successfully. -3. ~~U9: untyped `scripts/`, vite root literal, no root `dev`.~~ Closed by - `12018780`. -4. ~~U10: no exact-name precedence in slash dispatch.~~ Closed by `12018780` - ("name the flow you typed"). -5. ~~No rate limit on the turn seam.~~ Closed by `a80eeebb`. -6. ~~Client errors only reach `console.error`.~~ Closed by `a80eeebb` - (`apps/server/src/clientErrorLog.ts`). -7. ~~`apps-deploy.yml` runs no tests before deploying.~~ It now typechecks and - tests all four apps and dry-runs the launch checklist first. - -**Still open** - -8. **The `sync` worker is undeployed.** Its test suite cannot import: - `workers/sync/src/index.test.ts` reaches into the flows monorepo for - `Journal.ts`, which calls `Schema.TaggedError` — that symbol exists in - effect `4.0.0-rc.108` (flows) but is `Schema.TaggedErrorClass` in - `4.0.0-beta.102` (the ui repo), so it dies with `TypeError: TaggedError is - not a function` before a single test runs. Cross-repo dependency drift. - `sync.smithers.sh` is live on its 2026-08-04 build. The other eight workers - were redeployed 2026-08-19 (`62a828e`); `status` had never been deployed at - all before that. -9. **Agents are dark in production.** `feature_flags.agents` is off because - `sandbox.agent_snapshot_id` is empty, and no agent VM snapshot had ever been - baked. `aaa7cf8da` adds the bake to the release; until it lands and the id is - promoted, every agent-dependent row is untestable rather than failing. -10. **All CronJobs are absent cluster-wide**, including - `smithers-backend-canary-cheap`, so prod canary monitoring is silent. - Suspected interaction between the `agentsEnabled` guard added to - `canary-cronjob.yaml` and the agents flag being shipped off. - -**Not a gap, a possible spec bug in this document:** row 3.4 requires -"$500 of usage on us" to appear exactly once. On canary it appears **zero** -times, and the access lane judged the product probably right. Confirm the -intended copy before treating 3.4 as a defect. diff --git a/apps/ui/canary-repros/ROOT-CAUSES.md b/apps/ui/canary-repros/ROOT-CAUSES.md deleted file mode 100644 index 2af18aef..00000000 --- a/apps/ui/canary-repros/ROOT-CAUSES.md +++ /dev/null @@ -1,271 +0,0 @@ -# Root causes already diagnosed — read before fixing anything - -Traced in the source on 2026-08-19 against the live canary build -`assets/index-BHHXuMoZ.js`. Do not re-derive these; spend your time on the fix -and its test. Ordered by user impact. - ---- - -## 1. SYSTEMIC: every "renders nothing" row is ONE bug - -Explains **at least fourteen** separately-reported rows across seven lanes. This -is by far the highest-leverage fix in the run: one contract change closes roughly -a quarter of every failure found. The rows: -**7.7** (a 404 upstream renders no card, no transcript line, no toast, only a -console error), **13.4** (`/issues.view 99999` produces nothing), **14.3** -(`/prs.create` both forms completely silent), **5.11** (malformed arguments -refused silently), **15.2** (`/files.list does/not/exist` renders nothing), -**15.7** (`/env.set oops-no-equals` says nothing at all), **18.5** -(`/keys.remove` with no key is silent), **13.4**'s sibling shapes, and the -pre-import shape of **13.7**, and the entire §26 debug family — **26.2** -(`/debug.snapshot`), **26.3** (`/debug.events`), **26.4** (`/debug.chain`), -**26.5** (`/debug.net`) and **26.6** (`/debug.grants.reset`), each of which -"runs but renders nothing": cards 10 -> 10, messages 14 -> 14, zero new body -text, while the underlying work actually executes. - -The §26 rows are the clearest proof of the diagnosis: those flows do their job -and return their answer as a string, and the user sees nothing at all. Fixing -this one contract closes all fourteen; fixing them individually would be fourteen -patches over the same hole. - -The chain: - -1. Seams report failure by **returning an honest error string**. `SeamContext.ts` - states this in its own header: seams "answer the command contract — an honest - error string, or void on success". Example, `IssuesSeam.ts:174`: - ```ts - return readErrorMessage(response, `Listing issues for ${repo} failed (${response.status})`) - ``` -2. That string is the flow handler's **return value**, so the Effect *succeeds*. - `Commands.ts:149-154`: - ```ts - const result = settled.success - if (result.outcome === "failure") return { status: "failed", error: unframe(name, result.message) } - const value = valueOf(result.value) - return value === undefined ? { status: "executed" } : { status: "executed", value } - ``` - An honest seam error therefore arrives as `status: "executed"` with the - message sitting in `value`. -3. `AppController.surfaceCommandFailure` discards it: - ```ts - const surfaceCommandFailure = (name, outcome) => { - if (outcome.status !== "failed") return // <-- the message dies here - ...toast... - } - ``` - -So the product computes a correct, user-ready error message and throws it away. - -**Fix guidance.** Do NOT simply render `value` on `"executed"` — a successful -flow may legitimately return a value, and turning every success string into an -error toast is a new bug. The defect is that a seam's failure is -indistinguishable from a success value at the flow boundary. Make failure -explicit: either seams return a typed/discriminated failure that maps to -`result.outcome === "failure"`, or the flow handlers translate a returned string -into an Effect failure at that one boundary. - -**Test.** For issues, landings and files seams: an upstream 404/500 produces a -user-visible surface carrying the seam's message, and a successful call with a -return value produces no failure surface. Extend `IssuesSeam.test.ts` and -`LandingsSeam.test.ts`. - -### 1b. The dominant path, found independently by the flow-sweep lane (23 rows) - -The flow-sweep agent traced this further than the analysis above and its version -is more precise. There are TWO discard points, and this is the bigger one: - -- `send()` (`AppController.ts:2317`) runs a typed slash flow as - `void commands.run(name, args)` and **discards the CommandOutcome entirely** — - it never reaches `surfaceCommandFailure` at all. -- The button path (`runCommand` / `runCommandArgs`, ~`4363`/`4369`) DOES attach - `surfaceCommandFailure`. -- `App.tsx:625` routes Enter to the button path **only while the slash menu is - open**, and the menu matches a **BARE name**. - -Consequences, exactly as measured: -- `/issues.view` (bare) toasts an honest refusal — the menu was open, so the - button path ran. -- `/issues.view 999999 owner/repo` (with arguments) renders **absolutely - nothing** — the menu did not match, so `send()` ran and ate the outcome. -- Hidden, id-scoped flows never match the menu, so they are silent on **every** - failure. - -That explains why the same flow behaves honestly with no arguments and silently -with arguments, which no single-discard theory accounts for. Fix BOTH: route the -composer-submit path through the same failure surfacing as the button path, and -make the seam-failure contract explicit (analysis above). The flow-sweep lane -attributes **23 rows** to this cause. - -Also from that lane: asked to stop the response, the model replied -**"Okay, I've stopped."** while the underlying tool call had FAILED — a fake -success generated by the model on a discarded failure. Silent failures do not -just hide from the user; they actively cause the model to lie. - ---- - -## 2. Row 4.6 — `/retry` duplicates the user message - -`AppController.retryLastTurn`: -```ts -const prompt = [...store.collections.messages.values()] - .filter((m) => m.role === "user") - .sort((l, r) => r.ordinal - l.ordinal)[0]?.text -if (prompt !== undefined) send(prompt) // send() APPENDS a new user message -``` -Retry re-*sends* rather than re-*runs*, so each retry appends another user -bubble. The chat lane measured `[data-role="user"]` going 1 → 2 → 3. -It must re-run the last turn without appending a second user message. - ---- - -## 3. Rows 5.1 / 5.6 — the slash-menu cap is bypassed - -Introduced by `12018780` ("name the flow you typed"), whose exact-name -precedence is correct and must be preserved. In `registry.ts`: -```ts -const nameRank = (command, query) => { if (query === "") return 0; ... } -const kept = (item) => item.recommended || nameRank(item.flow, query) <= 1 -const survivors = ordered.filter(kept) -const room = Math.max(SLASH_MENU_CAP, survivors.length) - survivors.length -``` -On a bare `/`, `query === ""` so `nameRank` returns 0 for **every** command, -`kept` is true for all 65, `room` computes to `max(8,65) - 65 = 0`, and -`SLASH_MENU_CAP` is bypassed entirely. Live counts confirm the math: `/` → 65 -items (menu 2073px tall in a 1000px viewport, `top: -1114px`), `/a` → 13, -`/re` → 10. - -**Fix.** The "never cut what the user named outright" exemption must only apply -when the user actually named something; an empty query names nothing. Either -make `nameRank` distinguish "no query" from "exact match", or require -`query !== ""` for the rank branch of `kept`. - -**Test** all three: bare `/` yields at most `SLASH_MENU_CAP`; a typed exact name -is never cut; a typed prefix match is never cut. Also check whether -`overflow-y: visible` and the negative `top` on `.slash-menu` are independent -CSS bugs or just consequences of the height. - ---- - -## 4. Row 10.8 — World content never reaches the model - -`AppController.agentRuntimeContext()` includes only: -```ts -selectedWorldDocument: selected?.path ?? null, -``` -The **path**, never the **body**, and only for the *selected* note. The model is -told which note is open and nothing about what any note says. Proven end to end: -a note containing `zarquon-mimsy-7741` persisted (10.5 passes), then the model -could not answer what the codeword was. - -This needs a design decision, not just a patch. The World is sold as "what -Smithers understands"; if its content never reaches the model the feature is -decorative. But stuffing every note body into every turn is a token-budget -problem. Options: send the selected note's body under a character budget plus -other notes' titles; send all bodies up to a budget with an explicit truncation -marker; or expose the World as a **tool** the model reads on demand (most -token-honest, fits "flows are the app", but then the model must be told the tool -exists). Acceptance test is the lane's own method, with a fixture rather than a -live model call. - ---- - -## 5. Row 13.7 — FAKE SUCCESS on writes (highest severity) - -`codeplanesmithers` has read-only access to `octocat/Hello-World`. After import, -`/issues.create <title> octocat/Hello-World` returns a DONE card reading -"Issue #3 — octocat/Hello-World … OPEN … opened by codeplanesmithers", and a -GitHub search for that title returns **zero** results. The write landed in the -jjhub **mirror** of someone else's repository; the tell is that mirror numbering -restarts at #1 while the real repo is at #10897. `/issues.close` reports CLOSED -the same way. Run the same writes before the import completes and they are -silent instead. - -The product tells a user it created an issue on a repository they cannot write -to. For a product whose stated bar is never claiming work it did not do, this is -the worst failure mode available, and worse than a crash because the user -believes it. - -**Fix must establish:** authorization checked against the user's real GitHub -permissions on the **upstream** repo before touching any mirror (an importable -repo is not a writable repo); a mirror write that cannot propagate upstream is -never reported as upstream success (if mirror-local writes are legitimate, the -card says so plainly); "still importing" is an honest answer where silence is -not. Regression coverage: a write to a read-only repo produces a refusal, and no -card ever reports an upstream issue number that does not exist upstream. - ---- - -## 6. §18 — the BYOK provider-keys feature is not wired on canary - -The money lane found the whole feature dead at the routing layer, not the UI: - -- `GET /api/user/byok-keys` answers **404 "404 page not found"** on canary (the - product Worker forwards it to an upstream that does not serve it). -- `DELETE /api/user/byok-keys/anthropic` answers the same 404, and the UI shows - nothing at all — no card, no toast (row 18.3). -- `/keys.remove gemini` with no key is completely silent (18.5). -- No add-key surface ships at all, so 18.2 (validate before save) and 18.4 - (invalid/revoked key error path) have nothing to grade. - -So five checklist rows describe a capability that has no working route. Decide -which this is before "fixing" it: -1. the feature is meant to ship for the alpha and the Worker route/upstream is - simply missing or misconfigured — then wire it and the UI surfaces follow; or -2. the feature is deliberately post-alpha — then the flows (`keys.list`, - `keys.remove`) should not be registered and offered to users, and §18 should - be marked out of scope in the checklist rather than reported as failing. - -Either way the current state is the worst of both: the flows are registered and -invocable, and invoking them does nothing visible. Note 18.5's silence shares -root cause #1 above. - -## 7. Row 17.4 — checkout is exposed to an MVP account - -Typing `/billing` as `codeplanesmithers` (allowlisted, MVP) reaches a checkout -surface. The checklist bar (and `wrangler.jsonc`'s own comments) say no -top-up/checkout/card-collection flow is exposed to MVP users. `billing.upgrade` -and `billing.portal` are registered flows; either gate them behind a plan the -MVP account does not have, or unregister them for MVP sessions. - ---- - -## 8. Row 22.7 — the model contradicts its own tool result in the same turn - -Asked "What is my balance right now?", the model answered **"Your current -balance is $0.00."** one line above the balance card that its own -`billing.balance` call had just rendered reading **"$519 left."** — with the -corner chrome also showing $519 and `GET /api/billing/balance` returning -`totalUsd: "519"`. Reproduced on four consecutive turns across two different -real balances ($505 and $519 eras), so it is not a cache or a race. - -This is the honesty bar failing at its most visible point: the model states a -number the product is simultaneously displaying as something else. The likely -cause is that the flow's result is not being fed back into the model's context -before it composes its reply (the card is rendered to the DOM but the tool -result never reaches the turn), so the model answers from a prior or default -state. Check the tool-loop round trip in `ToolLoop`/`AppController` — whether a -flow invoked by the model returns its value into the conversation, or only -side-effects a card. A model that can invoke a flow but cannot read its result -will confabulate on every data question, not just balance. - -Test with a fixture: a flow returning a known value, then assert the model's -next message can state that value. - -## 9. Row 22.6 — an honest refusal points at a remedy that hangs - -The push/PR refusals correctly say they cannot do it and offer "I can start a -workflow that proposes the change". Taking that offer: `/flow.create` renders -the repo chooser, accepts the choice, then **"Preparing your -codeplanesmithers/canary-sandbox workspace…" stands past 120s** with no run -card, no timeout and no error. `POST /api/workflow/provision` never answers -(measured from the page: 20002ms, signal timed out). - -Two defects in one row. First, provisioning hangs — likely related to agents -being dark in prod (`feature_flags.agents=false` because no agent VM snapshot is -promoted), in which case provisioning should REFUSE immediately and honestly -rather than hang. Second, and independently of the cause: a request that never -answers must still surface a timeout to the user. A spinner that runs forever is -the silent-failure family again (root cause #1), just with a different shape. - -An honest refusal that names a next step which does not work is worse than no -next step, because it spends the user's trust twice. diff --git a/apps/ui/canary-repros/access/1.1.md b/apps/ui/canary-repros/access/1.1.md deleted file mode 100644 index 67607539..00000000 --- a/apps/ui/canary-repros/access/1.1.md +++ /dev/null @@ -1,85 +0,0 @@ -> **RESOLVED 2026-08-19 (round 2, bundle `/assets/index-Bf8uqBQd.js`).** Signed -> out, `connect` now opens a one-item menu whose only entry is "Connect -> GitHub…" carrying `data-flow="auth.sign-in"`. There is no "Import to -> Smithers Cloud…", no "Open connectors", no Connectors surface and no -> `repos.import` while signed out. `1.1.ts` was rewritten as a regression -> guard for that contract and now exits 0: -> `flows on the signed-out load: [auth.sign-in ×3, copy-message, connect, surfaces, send, dark-mode]` -> → `PASS — the bug is fixed.` The original round-1 finding is kept below. - -# 1.1 — signed out, sign-in is not the one offered next step - -- **Origin**: https://canary.smithers.sh (worker `smithers-mvp-web`) -- **Tested**: 2026-08-18 (bundle `/assets/index-BHHXuMoZ.js`, post-`command`→`flow` rename: 40 `data-flow` attributes, 0 `data-command`) -- **Repro**: `bun 1.1.ts` -- **Screenshot**: `/tmp/canary-access-1.1-connectors.png` - -## Steps - -1. Copy the sanctioned profile: `cp -R ~/.multi-e2e-profile /tmp/canary-access-profile`. -2. Clear the origin's storage and its `smithers_identity` cookie, then load https://canary.smithers.sh. -3. Confirm `GET /api/auth/session` answers `{"status":"signed-out"}`. -4. Read every `[data-flow]` in the DOM. -5. Click the composer's `[data-flow="connect"]` control and read the menu. -6. Click **Open connectors**. - -## Expected - -The one offered next step is sign-in; nothing else is presented as available. - -## Actual - -The signed-out load carries `connect` alongside `auth.sign-in`. Its menu offers three -more next steps — **Connect GitHub…**, **Import to Smithers Cloud…** (`repos.import`), -**Open connectors** — none marked as needing sign-in. **Open connectors** opens a whole -Connectors surface, still signed out, presenting: - -``` -Connectors -What Smithers can see and change -GitHub Issues, pull requests, and reviews from the repositories you choose. [Connect] -Smithers Cloud repository Import a GitHub repository into hosted workspace storage. [Import] -Connected repositories -No repositories connected -``` - -`[data-flow]` on that surface: `auth.sign-in, copy-message, auth.sign-in, connect, -surfaces, chat, auth.sign-in, repos.import`. - -Clicking **Import to Smithers Cloud…** signed out does route through `auth.sign-in` -(the requirement defers and the flow resumes), so the app is not lying about the -outcome — but sign-in is plainly not the only step it presents. - -## Selector / route - -- `[data-flow="connect"]` (composer control), then the menu item `Open connectors` -- `[data-flow="repos.import"]` on the Connectors surface -- `GET /api/auth/session` - -## Repro output - -``` -session: {"status":"signed-out"} -flows on the signed-out load: ["auth.sign-in","copy-message","auth.sign-in","connect","surfaces"] -connectors surface text: -Connectors - -What Smithers can see and change - -GitHub -Issues, pull requests, and reviews from the repositories you choose. -Connect -Smithers Cloud repository -Import a GitHub repository into hosted workspace storage. -Import -Connected repositories -No repositories connected -flows on the connectors surface: ["auth.sign-in","copy-message","auth.sign-in","connect","surfaces","chat","auth.sign-in","repos.import"] -FAIL: the signed-out load offers more than sign-in: connect -FAIL: the signed-out composer menu presents "Import to Smithers Cloud" as available -FAIL: the signed-out composer menu presents "Open connectors" as available -FAIL: signed out, the Connectors surface opens and presents GitHub Connect / Smithers Cloud Import as available work -FAIL: `repos.import` is presented as an available affordance while signed out -``` - -Exit code: `1`. diff --git a/apps/ui/canary-repros/access/1.1.ts b/apps/ui/canary-repros/access/1.1.ts deleted file mode 100644 index 15c20428..00000000 --- a/apps/ui/canary-repros/access/1.1.ts +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Repro / regression guard — checklist row 1.1 ("Load the app signed out. The - * one offered next step is sign-in; nothing else is presented as available.") - * against https://canary.smithers.sh. - * - * Round 1 (2026-08-18) FAILED: the signed-out load carried a `connect` - * affordance whose menu offered three more next steps — "Connect GitHub…", - * "Import to Smithers Cloud…" (repos.import) and "Open connectors" — and - * "Open connectors" opened a whole Connectors surface, still signed out, - * presenting GitHub [Connect], Smithers Cloud repository [Import] and a - * "Connected repositories" panel. - * - * Round 2 (2026-08-19) PASSES: signed out, `connect` opens a one-item menu - * whose only entry is "Connect GitHub…", carrying data-flow="auth.sign-in". - * There is no Connectors surface and no `repos.import` to reach. - * - * The script now guards that contract: every affordance on the signed-out - * load must be either sign-in itself or app chrome (send, surfaces, - * dark-mode, copy-message), the connect menu must offer sign-in and nothing - * else, and `repos.import` must be unreachable. - * - * bun 1.1.ts exit 1 if the row regresses, 0 while it holds. - */ -import { chromium } from "playwright"; -import { BASE, PROFILE, report, resetOrigin, session, visibleFlows } from "./_lib"; - -/* Chrome, not next steps: they neither claim nor need a session. */ -const CHROME = new Set(["copy-message", "surfaces", "send", "dark-mode"]); - -const context = await chromium.launchPersistentContext(PROFILE, { headless: true, viewport: { width: 1280, height: 1000 } }); -const page = context.pages()[0] ?? (await context.newPage()); -await resetOrigin(context, page, { signOut: true }); -await page.goto(BASE, { waitUntil: "domcontentloaded" }); -await page.waitForTimeout(6000); - -const identity = await session(page); -console.log("session:", JSON.stringify(identity)); -if (JSON.stringify(identity) !== '{"status":"signed-out"}') { - console.error("precondition failed: this repro must run signed out"); - process.exit(2); -} - -const failures: Array<string> = []; - -const onLoad = await visibleFlows(page); -console.log("flows on the signed-out load:", JSON.stringify(onLoad)); -const unexpected = onLoad.filter((name) => name !== "auth.sign-in" && name !== "connect" && !CHROME.has(name)); -if (unexpected.length > 0) { - failures.push(`the signed-out load offers something other than sign-in or chrome: ${unexpected.join(", ")}`); -} - -await page.locator('[data-flow="connect"]').first().click(); -await page.waitForTimeout(2000); -const menuText = await page.locator("body").innerText(); -for (const item of ["Import to Smithers Cloud", "Open connectors", "Connected repositories"]) { - if (menuText.includes(item)) failures.push(`the signed-out connect menu presents "${item}" as available`); -} -const menuFlows = (await visibleFlows(page)).filter((name) => name !== "auth.sign-in" && name !== "connect" && !CHROME.has(name)); -console.log("flows with the connect menu open:", JSON.stringify(await visibleFlows(page))); -if (menuFlows.length > 0) { - failures.push(`the signed-out connect menu offers ${menuFlows.join(", ")} beside sign-in`); -} -if (menuFlows.includes("repos.import")) failures.push("`repos.import` is presented while signed out"); - -await page.screenshot({ path: "/tmp/canary-access/1.1-connect-menu.png", fullPage: true }); -await context.close(); -report(failures); diff --git a/apps/ui/canary-repros/access/1.2.md b/apps/ui/canary-repros/access/1.2.md deleted file mode 100644 index 34ea0b07..00000000 --- a/apps/ui/canary-repros/access/1.2.md +++ /dev/null @@ -1,51 +0,0 @@ -> **RESOLVED 2026-08-19 (round 2).** Bare `/` signed out now lists 8 flows, -> `auth.sign-in` first: auth.sign-in, connect, world, theme, surfaces, -> dark-mode, chat, retry. Filtering for the session-gated names returns -> nothing — `/bill`, `/keys`, `/issues`, `/repos`, `/env`, `/notif`, `/prs` -> match no menu entry, and `/sign` offers only auth.sign-in and auth.prompt -> (no auth.sign-out). `bun 1.2.ts` → `listing holds 8 flows; first is -> "/auth.sign-in"` → `PASS — the bug is fixed.` - -# 1.2 — the signed-out slash listing offers 13 flows that cannot work signed out - -- **Origin**: https://canary.smithers.sh -- **Tested**: 2026-08-18 -- **Repro**: `bun 1.2.ts` -- **Screenshot**: `/tmp/canary-access-1.2-menu.png` - -## Steps - -1. Load https://canary.smithers.sh signed out (storage + `smithers_identity` cleared). -2. Focus the composer and type `/`. -3. Read the listing. - -## Expected - -`auth.sign-in` first, and nothing that cannot work signed out. - -## Actual - -`auth.sign-in` does lead the listing — the first clause passes. The listing then -continues through the **whole registry**: 50 flows, including - -`/auth.sign-out, /billing.upgrade, /billing.portal, /billing.balance, /keys.list, -/keys.remove, /issues.create, /issues.close, /prs.create, /prs.land, -/notifications.list, /env.set, /repos.import` - -every one of which needs a session. `/auth.sign-out` offered to a signed-out user is -the clearest case. - -## Selector / route - -The slash listing container (`[class*="slash"] / [role="listbox"]`), read as -`innerText` and filtered to lines starting with `/`. - -## Repro output - -``` -session: {"status":"signed-out"} -listing holds 50 flows; first is "/auth.sign-in" -FAIL: the signed-out listing offers 13 flows that cannot work signed out: /auth.sign-out, /billing.upgrade, /billing.portal, /billing.balance, /keys.list, /keys.remove, /issues.create, /issues.close, /prs.create, /prs.land, /notifications.list, /env.set, /repos.import -``` - -Exit code: `1`. diff --git a/apps/ui/canary-repros/access/1.2.ts b/apps/ui/canary-repros/access/1.2.ts deleted file mode 100644 index 62151a36..00000000 --- a/apps/ui/canary-repros/access/1.2.ts +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Repro — checklist row 1.2 ("`/` while signed out lists `auth.sign-in` first - * and nothing that cannot work signed out") against https://canary.smithers.sh. - * - * `auth.sign-in` does lead the listing. The rest of the listing is the whole - * registry: signed out, bare `/` offers `/auth.sign-out`, `/billing.upgrade`, - * `/billing.portal`, `/keys.list`, `/issues.create`, `/prs.create`, - * `/notifications.list`, … — 50+ flows that cannot do anything without a - * session. - * - * bun 1.2.ts exit 1 while the bug is present, 0 once it is fixed. - */ -import { chromium } from "playwright"; -import { BASE, PROFILE, report, resetOrigin, session } from "./_lib"; - -/* Flows that plainly need a session; any of these in the signed-out listing is the bug. */ -const SIGNED_IN_ONLY = [ - "/auth.sign-out", - "/billing.upgrade", - "/billing.portal", - "/billing.balance", - "/keys.list", - "/keys.remove", - "/issues.create", - "/issues.close", - "/prs.create", - "/prs.land", - "/notifications.list", - "/env.set", - "/repos.import", -]; - -const context = await chromium.launchPersistentContext(PROFILE, { headless: true, viewport: { width: 1280, height: 1000 } }); -const page = context.pages()[0] ?? (await context.newPage()); -await resetOrigin(context, page, { signOut: true }); -await page.goto(BASE, { waitUntil: "domcontentloaded" }); -await page.waitForTimeout(6000); - -const identity = await session(page); -console.log("session:", JSON.stringify(identity)); -if (JSON.stringify(identity) !== '{"status":"signed-out"}') { - console.error("precondition failed: this repro must run signed out"); - process.exit(2); -} - -const composer = page.locator("textarea, [contenteditable=true]").first(); -await composer.click(); -await composer.type("/"); -await page.waitForTimeout(1500); -await page.screenshot({ path: "/tmp/canary-access-1.2-menu.png", fullPage: true }); - -const listing = await page.evaluate(() => { - const element = document.querySelector('[class*="slash"], [role="listbox"], [class*="command-menu"], [class*="flow-menu"]'); - return element === null ? "" : (element as HTMLElement).innerText; -}); -const names = listing.split("\n").map((line) => line.trim()).filter((line) => line.startsWith("/")); -console.log(`listing holds ${names.length} flows; first is ${JSON.stringify(names[0])}`); - -const failures: Array<string> = []; -if (names[0] !== "/auth.sign-in") failures.push(`the listing does not lead with /auth.sign-in (leads with ${names[0]})`); -const offered = SIGNED_IN_ONLY.filter((name) => names.includes(name)); -if (offered.length > 0) { - failures.push(`the signed-out listing offers ${offered.length} flows that cannot work signed out: ${offered.join(", ")}`); -} - -await context.close(); -report(failures); diff --git a/apps/ui/canary-repros/access/1.5.md b/apps/ui/canary-repros/access/1.5.md deleted file mode 100644 index 4f1ffc32..00000000 --- a/apps/ui/canary-repros/access/1.5.md +++ /dev/null @@ -1,81 +0,0 @@ -# 1.5 — a non-allowlisted account still reaches every admin flow - -- **Origin**: https://canary.smithers.sh (worker `smithers-mvp-web`, bundle `/assets/index-Bf8uqBQd.js`) -- **Tested**: 2026-08-19 (round 2 — re-tested from scratch after the fix stage redeployed; STILL FAILING) -- **Repro**: `bun 1.5.ts` (self-restoring: it re-adds the login in a `finally`) -- **Screenshot**: /tmp/canary-access/1.5-nonallowlisted.png - -## Steps - -1. Sign in on https://canary.smithers.sh as `codeplanesmithers` (allowlisted, admin). -2. Remove that same login from the closed-alpha allowlist through the product's - own audited door: `POST /api/admin/allowlist {"login":"codeplanesmithers","action":"remove"}`. -3. Reload the app. -4. Read `GET /api/auth/session`, the app shell's `data-flows` manifest, and - `GET /api/admin/requests`, `GET /api/admin/health`, `GET /api/reco/first-run`. -5. Restore with `{"action":"add"}` (the repro does this in a `finally`). - -## Expected - -Row 1.5: "A non-allowlisted account cannot reach admin flows. `/admin.*` is -unregistered for them (the flow is absent, not present-and-refusing)." -`data-flows` carries no `admin.*` name and every `/api/admin/*` route answers -the canonical 404 that an unknown route answers. - -## Actual (2026-08-19T23:15Z) - -``` -session before: {"login":"codeplanesmithers","allowlisted":true,"admin":true,"scopes":["read:user"]} -remove: 201 {"applied":true,"action":"remove","login":"codeplanesmithers", …} -session while NOT allowlisted: {"login":"codeplanesmithers","allowlisted":false,"admin":true,"scopes":["read:user"]} -admin.* still registered: ["admin.devtools","admin.allowlist.add","admin.allowlist.remove","admin.grant", - "admin.grant.confirm","admin.grant.cancel","admin.requests","admin.queue.approve", - "admin.feedback","admin.health"] -GET /api/admin/requests: 200 {"requests":[]} -GET /api/admin/health: 200 {"services":[{"name":"billing","status":"ok", …}]} -GET /api/reco/first-run: 200 {"degraded":false,"cached":true,"watched":[…3 repos…],"digest":{…}} -restore: 201 {"applied":true,"action":"add", …} -session restored: {"login":"codeplanesmithers","allowlisted":true,"admin":true,"scopes":["read:user"]} - -FAIL: a non-allowlisted session still has 10 admin flows registered: admin.devtools, admin.allowlist.add, - admin.allowlist.remove, admin.grant, admin.grant.confirm, admin.grant.cancel, admin.requests, - admin.queue.approve, admin.feedback, admin.health -FAIL: a non-allowlisted session still reads GET /api/admin/requests (HTTP 200), instead of the canonical 404 -``` - -`bun 1.5.ts` exits 1. - -## Selector / route - -- Shell manifest: `[data-flows]` on the app shell (the whole registry). -- Routes: `GET /api/admin/requests`, `GET /api/admin/health`, `GET /api/reco/first-run`. - -## Root cause (unchanged since round 1) - -- `workers/identity/src/index.ts` `sessionAnswer()` derives the session's - `admin` claim from the `ADMIN_LOGINS` env var, not from the allowlist: - `admin: adminLogins(env).includes(session.login.toLowerCase())`. The comment - on the var still calls it a "UI hint only". -- `apps/ui/src/mainview/App.tsx:835` registers the admin plugin on - `identity.state === "signed-in" && identity.admin` — the allowlist is not - consulted. -- `apps/server/src/index.ts:1600` `handleAdmin()` gates on - `session === undefined || !session.admin` — likewise. - -So de-allowlisting a compromised admin revokes nothing: the account keeps the -whole admin surface, including the door that edits the allowlist itself. - -Third finding in the same window: `GET /api/reco/first-run` answers a -non-allowlisted session with the full, non-degraded digest (`degraded:false`, -the watched repo list, issue and PR counts). The alpha gate stops the UI, not -the seam. - -## Scope note (honest limit of this repro) - -The one available test login, `codeplanesmithers`, is in `ADMIN_LOGINS` (it was -put there deliberately by `2ce8963 🔧 fix(identity): make codeplanesmithers a -grading admin login`). So what this repro proves is the *non-allowlisted admin* -case. A login that is neither allowlisted nor in `ADMIN_LOGINS` would get -`admin:false` and the flows would be absent. Grading that second variant needs a -second GitHub account (checklist §0.4). The defect demonstrated here — removing -a login from the allowlist revokes nothing — stands on its own. diff --git a/apps/ui/canary-repros/access/1.5.ts b/apps/ui/canary-repros/access/1.5.ts deleted file mode 100644 index 778d51d4..00000000 --- a/apps/ui/canary-repros/access/1.5.ts +++ /dev/null @@ -1,104 +0,0 @@ -/* - * Repro — checklist row 1.5 ("A non-allowlisted account cannot reach admin - * flows. `/admin.*` is unregistered for them — the flow is absent, not - * present-and-refusing.") against https://canary.smithers.sh. - * - * The app gates the admin plugin on the session's `admin` claim, and identity - * derives `admin` from the ADMIN_LOGINS env var, NOT from the allowlist - * (workers/identity/src/index.ts, sessionAnswer). Removing a login from the - * closed-alpha allowlist therefore does not revoke anything: the session comes - * back `allowlisted: false, admin: true`, the shell still lists all ten - * `admin.*` flows in `data-flows`, and GET /api/admin/requests still answers - * 200 with the real queue. - * - * The repro drives that with the product's OWN audited door: it removes the - * login from the allowlist through POST /api/admin/allowlist, reads the state, - * and restores the row in a `finally` block. It leaves the allowlist exactly - * as it found it. - * - * bun 1.5.ts exit 1 while the bug is present, 0 once it is fixed. - */ -import { chromium } from "playwright"; -import { withVerifiedRestoration } from "../../scripts/canary-restoration"; -import { BASE, ensureSignedIn, PROFILE, registry, report, session } from "./_lib"; - -const LOGIN = process.env.CANARY_LOGIN ?? "codeplanesmithers"; - -const context = await chromium.launchPersistentContext(PROFILE, { headless: true, viewport: { width: 1280, height: 1000 } }); -const page = context.pages()[0] ?? (await context.newPage()); -await page.goto(BASE, { waitUntil: "domcontentloaded" }); -await page.waitForTimeout(6000); -await ensureSignedIn(page); - -const allowlist = (action: "add" | "remove"): Promise<{ status: number; body: string }> => - page.evaluate(async ([login, act]: Array<string>) => { - const response = await fetch("/api/admin/allowlist", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ login, action: act }), - }); - return { status: response.status, body: (await response.text()).slice(0, 300) }; - }, [LOGIN, action]); - -const before = await session(page); -console.log("session before:", JSON.stringify(before)); -if (!JSON.stringify(before).includes('"admin":true')) { - console.error("precondition failed: this repro needs the admin session that owns the allowlist door"); - process.exit(2); -} - -const failures: Array<string> = []; -const removed = await allowlist("remove"); -console.log("remove:", JSON.stringify(removed)); -if (removed.status < 200 || removed.status >= 300) { - await context.close(); - throw new Error(`precondition failed: removing ${LOGIN} answered HTTP ${removed.status}: ${removed.body}`); -} - -await withVerifiedRestoration( - async () => { - await page.reload({ waitUntil: "domcontentloaded" }); - await page.waitForTimeout(8000); - - const during = await session(page); - console.log("session while NOT allowlisted:", JSON.stringify(during)); - if (!JSON.stringify(during).includes('"allowlisted":false')) { - throw new Error("precondition failed: the session is still allowlisted after a successful removal"); - } - - const adminFlows = (await registry(page)).filter((name) => name.startsWith("admin.")); - console.log("admin.* still registered:", JSON.stringify(adminFlows)); - if (adminFlows.length > 0) { - failures.push(`a non-allowlisted session still has ${adminFlows.length} admin flows registered: ${adminFlows.join(", ")}`); - } - - const requests = await page.evaluate(async () => { - const response = await fetch("/api/admin/requests"); - return { status: response.status, body: (await response.text()).slice(0, 200) }; - }); - console.log("GET /api/admin/requests while NOT allowlisted:", JSON.stringify(requests)); - if (requests.status === 200) { - failures.push("a non-allowlisted session still reads GET /api/admin/requests (HTTP 200), instead of the canonical 404"); - } - }, - async () => { - const restored = await allowlist("add"); - console.log("restore:", JSON.stringify(restored)); - if (restored.status < 200 || restored.status >= 300) { - throw new Error(`allowlist add answered HTTP ${restored.status}: ${restored.body}`); - } - }, - async () => { - await page.reload({ waitUntil: "domcontentloaded" }); - await page.waitForTimeout(5000); - const restored = await session(page); - console.log("session restored:", JSON.stringify(restored)); - if (!JSON.stringify(restored).includes('"allowlisted":true')) { - throw new Error(`session still is not allowlisted: ${JSON.stringify(restored)}`); - } - }, - `re-add ${LOGIN} through the identity admin service credential`, -); - -await context.close(); -report(failures); diff --git a/apps/ui/canary-repros/access/2.2.md b/apps/ui/canary-repros/access/2.2.md deleted file mode 100644 index 6efaa121..00000000 --- a/apps/ui/canary-repros/access/2.2.md +++ /dev/null @@ -1,102 +0,0 @@ -> **RESOLVED 2026-08-19 (round 2).** `/api/auth/scopes` now answers -> `authKind: "github-app"`, states that the `scope=read:user` parameter is -> inert for a GitHub App, and enumerates ten permissions in plain wording. -> Checked against GitHub's own two pages: the account authorization page -> lists the four user-level grants (Verify your GitHub identity / Know what -> resources you can access / Act on your behalf / View your email -> addresses), and the installation permissions page lists the seven -> repository permissions the registration asks for (Checks read, Contents -> write, Issues write, Metadata read, Pull requests write, Commit statuses -> read, Workflows write). The claim is exactly that union — no over-claim, -> no under-claim. `2.2.ts` was rewritten to compare both directions and now -> exits 0. Screenshot: /tmp/canary-access/2.2-permissions.png. - -# 2.2 — GitHub asks for more than `/api/auth/scopes` claims - -- **Origin**: https://canary.smithers.sh -- **Tested**: 2026-08-18 -- **Repro**: `GH_PW=… bun 2.2.ts` -- **Screenshots**: `/tmp/canary-access/2.2-consent.png` (the real consent screen), - `/tmp/canary-access-2.2-granted.png` (the account's authorization page) - -## Steps - -1. `GET https://canary.smithers.sh/api/auth/scopes` and read what the app claims. -2. `GET https://canary.smithers.sh/api/auth/github/start` and read the `Location`. -3. Read the permissions GitHub actually granted at - `https://github.com/settings/connections/applications/Iv23liwHER62HVHMWcGS` - (sudo mode; no revoke needed). -4. Cross-check against the live consent screen (captured while the grant was revoked - during the 2.3 run). - -## Expected - -The scopes GitHub asks for match what the app claims it needs. - -## Actual - -The app claims exactly one scope: - -```json -{"provider":"github","requestedScopes":["read:user"], - "scopes":[{"scope":"read:user", - "plain":"See your GitHub profile — your username, name, and avatar.", - "why":"Sign-in needs to know who you are. This is the identity half of sign-in and nothing more."}]} -``` - -GitHub asks for four things. The live consent screen for `SmithersPreviewRelease` -reads: - -``` -SmithersPreviewRelease by Smithers wants access to your GitHub account -Authorizing allows this app to - Verify your GitHub identity (codeplanesmithers) - Know which resources you can access - Act on your behalf -Resources on your account - Email addresses (read) View your email addresses -``` - -**Act on your behalf** and **View your email addresses** are not claimed anywhere in -the scope document, and "act on your behalf" directly contradicts "the identity half of -sign-in and nothing more". - -Why the `scope=` parameter cannot fix it: `client_id=Iv23liwHER62HVHMWcGS` is a GitHub -**App**, not an OAuth App. GitHub Apps take user-to-server permissions from the App -registration; the `scope=read:user` the start route appends to -`/login/oauth/authorize` is inert. `/api/auth/scopes` is therefore a hand-written claim -with nothing enforcing it, which is exactly the failure mode this row exists to catch. - -## Selector / route - -- `GET /api/auth/scopes` -- `GET /api/auth/github/start` → `Location: https://github.com/login/oauth/authorize?client_id=Iv23liwHER62HVHMWcGS&redirect_uri=…&scope=read%3Auser&state=…` -- `https://github.com/settings/connections/applications/Iv23liwHER62HVHMWcGS` - -## Owner action - -Two independent fixes, both outside a verifier's remit: - -1. Trim the GitHub App's **user permissions** (drop "Email addresses (read)"; review - whatever grants "Act on your behalf") in the `smithersai` org's App settings, **or** -2. Rewrite `/api/auth/scopes` so it states what the App actually asks for. - -Editing a GitHub App registration needs the org owner. - -## Repro output - -``` -the app claims: {"provider":"github","requestedScopes":["read:user"],"scopes":[{"scope":"read:user","plain":"See your GitHub profile — your username, name, and avatar.","why":"Sign-in needs to know who you are. This is the identity half of sign-in and nothing more."}]} -GitHub grants: -can access your account codeplanesmithers to: - -Verify your GitHub identity -Know what resources you can access -Act on your behalf -View your email addresses -FAIL: GitHub grants "Act on your behalf" but /api/auth/scopes never claims it -FAIL: GitHub grants "View your email addresses" but /api/auth/scopes never claims it -FAIL: the app declares the single scope `read:user` ('the identity half of sign-in and nothing more') while GitHub has granted the app the right to act on the user's behalf -``` - -Exit code: `1`. diff --git a/apps/ui/canary-repros/access/2.2.ts b/apps/ui/canary-repros/access/2.2.ts deleted file mode 100644 index 31165a9e..00000000 --- a/apps/ui/canary-repros/access/2.2.ts +++ /dev/null @@ -1,120 +0,0 @@ -/* - * Repro / regression guard — checklist row 2.2 ("The scopes GitHub asks for - * match what the app claims it needs (/api/auth/scopes)") against - * https://canary.smithers.sh. - * - * Round 1 (2026-08-18) FAILED: /api/auth/scopes claimed exactly one scope, - * `read:user` ("the identity half of sign-in and nothing more"), while the - * real consent screen asked for four things including "Act on your behalf" - * and "Email addresses (read)". - * - * Round 2 (2026-08-19) PASSES: /api/auth/scopes now declares authKind - * "github-app", says the scope parameter is inert for an App, and enumerates - * ten permissions. GitHub's OWN two pages enumerate the same set: - * - the account authorization page lists the four user-level grants - * (identity, resources-you-can-access, act-on-your-behalf, email), and - * - the installation's permissions page lists the seven repository - * permissions the app registration asks for (checks read, contents write, - * issues write, metadata read, pull requests write, statuses read, - * workflows write). - * "Know what resources you can access" is the same permission the claim calls - * `metadata:read`, so the ten claimed entries are exactly the union. - * - * This script re-reads both GitHub pages and the claim and fails if they - * diverge in EITHER direction (a claim GitHub never asks for, or a - * permission GitHub asks for that the app does not claim). - * - * bun 2.2.ts exit 1 if the row regresses, 0 while it holds. - */ -import { chromium } from "playwright"; -import { BASE, PROFILE, report } from "./_lib"; - -const CLIENT_ID = process.env.GH_CLIENT_ID ?? "Iv23liwHER62HVHMWcGS"; - -/* GitHub's own wording on its pages → the scope id the app claims. */ -const USER_LEVEL: ReadonlyArray<readonly [string, string]> = [ - ["Verify your GitHub identity", "identity"], - ["Act on your behalf", "user-to-server"], - ["View your email addresses", "emails:read"], - ["Know what resources you can access", "metadata:read"], -]; -const REPOSITORY: ReadonlyArray<readonly [string, string]> = [ - ["Read-only access to Checks", "checks:read"], - ["Read and write access to Contents", "contents:write"], - ["Read and write access to Issues", "issues:write"], - ["Read-only access to Metadata", "metadata:read"], - ["Read and write access to Pull requests", "pull_requests:write"], - ["Read-only access to Commit statuses", "statuses:read"], - ["Read and write access to Workflows", "workflows:write"], -]; - -const context = await chromium.launchPersistentContext(PROFILE, { headless: true, viewport: { width: 1280, height: 1100 } }); -const page = context.pages()[0] ?? (await context.newPage()); - -await page.goto(BASE, { waitUntil: "domcontentloaded" }); -await page.waitForTimeout(2000); -const claim = (await page.evaluate(async () => (await fetch("/api/auth/scopes")).json())) as { - authKind?: string; - scopes?: Array<{ scope?: string; plain?: string }>; -}; -const claimed = new Set((claim.scopes ?? []).map((entry) => entry.scope ?? "")); -console.log("the app claims:", JSON.stringify([...claimed])); - -const failures: Array<string> = []; -const observed = new Set<string>(); -if (claim.authKind !== "github-app") { - failures.push(`the claim no longer says which auth kind it is (authKind: ${String(claim.authKind)})`); -} - -/* GitHub's user-level authorization page. */ -await page.goto(`https://github.com/settings/connections/applications/${CLIENT_ID}`, { waitUntil: "domcontentloaded" }); -await page.waitForTimeout(2500); -const authorization = await page.locator("body").innerText(); -for (const [wording, scope] of USER_LEVEL) { - if (!authorization.includes(wording)) { - failures.push(`GitHub's authorization page did not expose the expected grant "${wording}"`); - continue; - } - observed.add(scope); - if (!claimed.has(scope)) failures.push(`GitHub asks for "${wording}" and the app does not claim ${scope}`); -} - -/* GitHub's installation permissions, including any pending update request. */ -await page.goto("https://github.com/settings/installations", { waitUntil: "domcontentloaded" }); -await page.waitForTimeout(2000); -const configure = page.locator('a:has-text("Configure")').first(); -if (!(await configure.isVisible().catch(() => false))) { - console.error("precondition failed: the app is not installed on this account, so its repository permissions cannot be read"); - await context.close(); - process.exit(2); -} -await configure.click(); -await page.waitForTimeout(2500); -const installationUrl = page.url(); -await page.goto(`${installationUrl}/permissions/update`, { waitUntil: "domcontentloaded" }); -await page.waitForTimeout(2500); -const unchanged = page.locator("text=Show unchanged permissions").first(); -if (await unchanged.isVisible().catch(() => false)) { - await unchanged.click(); - await page.waitForTimeout(1500); -} -const permissions = await page.locator("body").innerText(); -console.log("GitHub's repository permissions page:\n" + permissions.replace(/\n{2,}/g, "\n").slice(0, 1200)); -for (const [wording, scope] of REPOSITORY) { - if (!permissions.includes(wording)) { - failures.push(`GitHub's installation page did not expose the expected permission "${wording}"`); - continue; - } - observed.add(scope); - if (!claimed.has(scope)) failures.push(`GitHub asks for "${wording}" and the app does not claim ${scope}`); -} - -/* And the other direction: nothing claimed that GitHub never asks for. */ -for (const scope of claimed) { - if (!observed.has(scope)) failures.push(`the app claims ${scope}, which was not observed on either GitHub page`); -} -for (const scope of observed) if (!claimed.has(scope)) failures.push(`GitHub exposes ${scope}, which the app does not claim`); - -await page.screenshot({ path: "/tmp/canary-access/2.2-permissions.png", fullPage: true }); -await context.close(); -report(failures); diff --git a/apps/ui/canary-repros/access/2.3.md b/apps/ui/canary-repros/access/2.3.md deleted file mode 100644 index 8273f98d..00000000 --- a/apps/ui/canary-repros/access/2.3.md +++ /dev/null @@ -1,98 +0,0 @@ -> **RESOLVED 2026-08-19 (round 2).** Re-driven the same way: the account's -> authorization was revoked so a real consent screen rendered, Cancel was -> clicked, and the browser landed on -> `https://canary.smithers.sh/api/auth/github/callback?error=access_denied&…` -> — the product's OWN origin, not api.jjhub.tech — rendering "GitHub -> sign-in didn't finish. … Nothing was signed in — head back and try -> again." with a working "Back to Smithers" link that lands on the clean -> signed-out app (`{"status":"signed-out"}`). Authorization was re-granted -> and the session verified restored. -> RESIDUAL COPY DEFECT (not the row's bar, but worth fixing): the page -> blames "the sign-in service answered HTTP 400". The user pressed Cancel; -> `error=access_denied` is never read, so the denial falls through the -> generic non-2xx branch and the copy misattributes the cause. -> Screenshots: /tmp/canary-access/2.3-consent.png, /tmp/canary-access/2.3-cancel.png. - -# 2.3 — cancelling the consent screen strands the user on api.jjhub.tech with a JSON blob - -- **Origin**: https://canary.smithers.sh -- **Tested**: 2026-08-18 -- **Repro**: `GH_PW=… bun 2.3.ts` (self-restoring: it re-authorizes in a `finally`) -- **Screenshot**: `/tmp/canary-access-2.3-cancel.png` - -## Steps - -1. Revoke the account's authorization of `SmithersPreviewRelease` so GitHub renders a - consent screen (github.com sudo mode required). -2. Load https://canary.smithers.sh signed out and click `[data-flow="auth.sign-in"]`. -3. On the GitHub consent screen, click **Cancel**. - -## Expected - -The app returns to a clean signed-out state with an honest message. - -## Actual - -The browser lands on the **Go backend's** origin, not the app's: - -``` -https://api.jjhub.tech/api/auth/github/callback?error=access_denied - &error_description=The+user+has+denied+your+application+access. - &error_uri=https%3A%2F%2Fdocs.github.com%2F…%23access-denied - &state=FDObjuovkuwdbLNjbOpxn-FqoLmEh-5u -``` - -rendering a raw JSON body: - -``` -{"message":"code and state are required"} -``` - -There is no message, no signed-out state, and no way back — no `Back to Smithers` -link, no `auth.sign-in` affordance, different origin entirely. - -The start route passes `redirect_uri=https://canary.smithers.sh/api/auth/github/callback` -and GitHub honours it on **success** (verified: the Authorize path returns to -`canary.smithers.sh/?signed-in=github`). On **denial** GitHub sends the error to the -GitHub App registration's own callback URL, which is `api.jjhub.tech`. The Go backend -does not recognise a denial and answers its generic "code and state are required". - -Canary's own callback handles this correctly and is simply never reached — driving -`https://canary.smithers.sh/api/auth/github/callback?error=access_denied&state=zzz` -directly renders the honest page: - -``` -GitHub sign-in didn't finish. -You were on your way back from GitHub, but the sign-in service answered HTTP 400, so -the sign-in could not complete. Nothing was signed in — head back and try again. -[Back to Smithers] -``` - -## Selector / route - -- `[data-flow="auth.sign-in"]` → `GET /api/auth/github/start` -- GitHub consent screen **Cancel** button -- lands on `https://api.jjhub.tech/api/auth/github/callback` - -## Owner action - -Either add `https://canary.smithers.sh/api/auth/github/callback` as the GitHub App's -callback URL (the App registration is owned by the `smithersai` org, so an org owner -must do it), or teach `api.jjhub.tech/api/auth/github/callback` to recognise -`error=access_denied` and redirect back to the app origin with an honest message. - -## Repro output - -``` -revoked; now driving sign-in to the real consent screen -consent url: https://github.com/login/oauth/authorize?client_id=Iv23liwHER62HVHMWcGS&redirect_uri=https%3A%2F%2Fcanary.smithers.sh%2Fapi%2Fauth%2Fgithub%2Fcallback&scope=read%3Auser&state=FDObjuovkuwdbLNjbOpxn-FqoLmEh-5u -after Cancel, landed at: https://api.jjhub.tech/api/auth/github/callback?error=access_denied&error_description=The+user+has+denied+your+application+access.&error_uri=…&state=FDObjuovkuwdbLNjbOpxn-FqoLmEh-5u -after Cancel, the page says: "{\"message\":\"code and state are required\"}\n" -restoring the authorization… -session restored: {"login":"codeplanesmithers","allowlisted":true,"admin":true,"scopes":["read:user"]} -FAIL: Cancel left the product origin: it landed on https://api.jjhub.tech -FAIL: Cancel rendered a raw JSON body to the user: {"message":"code and state are required"} -FAIL: the cancelled sign-in offers no way back into the app -``` - -Exit code: `1`. diff --git a/apps/ui/canary-repros/access/2.3.ts b/apps/ui/canary-repros/access/2.3.ts deleted file mode 100644 index 6613a24a..00000000 --- a/apps/ui/canary-repros/access/2.3.ts +++ /dev/null @@ -1,105 +0,0 @@ -/* - * Repro — checklist row 2.3 ("Cancel the OAuth consent screen halfway. The app - * returns to a clean signed-out state with an honest message, not a stuck - * spinner.") against https://canary.smithers.sh. - * - * Cancelling does not return to the app at all. GitHub sends the denial to the - * GitHub App's REGISTERED callback URL rather than to the `redirect_uri` the - * start route passed, and that registered URL is the Go backend: - * - * https://api.jjhub.tech/api/auth/github/callback?error=access_denied&… - * - * which answers a raw JSON body, `{"message":"code and state are required"}`. - * The user ends up on a different origin, looking at a JSON blob, with no way - * back to Smithers. (canary's own /api/auth/github/callback DOES render an - * honest "GitHub sign-in didn't finish" page — it is simply never reached.) - * - * Observing a real Cancel needs a consent screen, and GitHub only renders one - * when the account has not yet authorized the app. This repro therefore - * revokes the authorization, cancels, asserts, and RE-AUTHORIZES in a finally - * block, leaving the account as it found it. Set GH_PW for GitHub sudo mode. - * - * GH_PW=… bun 2.3.ts exit 1 while the bug is present, 0 once it is fixed. - */ -import { chromium } from "playwright"; -import { withVerifiedRestoration } from "../../scripts/canary-restoration"; -import { BASE, ensureSignedIn, PROFILE, report, resetOrigin, session } from "./_lib"; - -const CLIENT_ID = process.env.CANARY_CLIENT_ID ?? "Iv23liwHER62HVHMWcGS"; -if (process.env.GH_PW === undefined) { - console.error("GH_PW is required — GitHub asks for sudo mode before it will revoke (see multi-test-github-account)."); - process.exit(2); -} - -const context = await chromium.launchPersistentContext(PROFILE, { headless: true, viewport: { width: 1280, height: 1100 } }); -const page = context.pages()[0] ?? (await context.newPage()); -const sudo = async (): Promise<void> => { - if ((await page.locator("input[type=password]").count()) === 0) return; - await page.locator("input[type=password]").first().fill(process.env.GH_PW!); - await page.locator('button:has-text("Confirm"), input[value="Confirm"]').first().click(); - await page.waitForTimeout(4000); -}; - -const failures: Array<string> = []; -await withVerifiedRestoration( - async () => { - await page.goto(`https://github.com/settings/connections/applications/${CLIENT_ID}`, { waitUntil: "domcontentloaded" }); - await page.waitForTimeout(2500); - await sudo(); - await page.locator('summary:has-text("Revoke access"), button:has-text("Revoke access")').first().click(); - await page.waitForTimeout(1500); - await page.locator('button:has-text("I understand, revoke access"), button:has-text("Revoke access")').last().click(); - await page.waitForTimeout(4000); - console.log("revoked; now driving sign-in to the real consent screen"); - - await resetOrigin(context, page, { signOut: true }); - await page.goto(BASE, { waitUntil: "domcontentloaded" }); - await page.waitForTimeout(5000); - await page.locator('[data-flow="auth.sign-in"]').last().click(); - await page.waitForTimeout(7000); - console.log("consent url:", page.url()); - if (!page.url().startsWith("https://github.com/login/oauth/authorize")) { - throw new Error("precondition failed: no consent screen — the revoke did not take"); - } - - await page.locator('button:has-text("Cancel"), a:has-text("Cancel")').first().click(); - await page.waitForTimeout(8000); - const landed = page.url(); - const body = (await page.locator("body").innerText()).slice(0, 400); - console.log("after Cancel, landed at:", landed); - console.log("after Cancel, the page says:", JSON.stringify(body)); - await page.screenshot({ path: "/tmp/canary-access-2.3-cancel.png", fullPage: true }); - - if (!landed.startsWith(BASE)) { - failures.push(`Cancel left the product origin: it landed on ${new URL(landed).origin}`); - } - if (body.trim().startsWith("{")) { - failures.push(`Cancel rendered a raw JSON body to the user: ${body.trim().slice(0, 120)}`); - } - const backToApp = await page.locator('a:has-text("Back to Smithers"), [data-flow="auth.sign-in"]').count(); - if (backToApp === 0) failures.push("the cancelled sign-in offers no way back into the app"); - }, - async () => { - await page.goto(BASE, { waitUntil: "domcontentloaded" }); - await page.waitForTimeout(5000); - console.log("restoring the authorization…"); - await ensureSignedIn(page); - }, - async () => { - const restoredSession = await session(page); - console.log("final session:", JSON.stringify(restoredSession)); - if (restoredSession === null || JSON.stringify(restoredSession).includes('"signedIn":false')) { - throw new Error(`the product session was not restored: ${JSON.stringify(restoredSession)}`); - } - await page.goto(`https://github.com/settings/connections/applications/${CLIENT_ID}`, { waitUntil: "domcontentloaded" }); - await page.waitForTimeout(2500); - await sudo(); - if (!(await page.locator('summary:has-text("Revoke access"), button:has-text("Revoke access")').first().isVisible().catch(() => false))) { - throw new Error("GitHub does not show Revoke access, so the App authorization was not restored"); - } - }, - "reauthorize the Smithers GitHub App for codeplanesmithers and sign the product session back in", -); - -await context.close(); -report(failures); diff --git a/apps/ui/canary-repros/access/2.4.md b/apps/ui/canary-repros/access/2.4.md deleted file mode 100644 index 96f67a0b..00000000 --- a/apps/ui/canary-repros/access/2.4.md +++ /dev/null @@ -1,71 +0,0 @@ -> **RESOLVED 2026-08-19 (round 2).** `/auth.sign-out` clears the cookie, a -> full reload still answers `{"status":"signed-out"}`, and the transcript -> is scrubbed: no balance pill, no digest, no repo names, no recommendation -> card. localStorage after the reload holds only -> `smithers-mvp.persistenceBackend` (4 bytes) — no repo names, no login. -> `bun 2.4.ts` → `PASS — the bug is fixed.` - -# 2.4 — sign-out clears the session but leaves the previous account's name, balance and repos on screen - -- **Origin**: https://canary.smithers.sh -- **Tested**: 2026-08-18 -- **Repro**: `bun 2.4.ts` -- **Screenshot**: `/tmp/canary-access-2.4-reload.png` - -## Steps - -1. Sign in as `codeplanesmithers` and let the first-run digest render. -2. Run `/auth.sign-out` from the composer. -3. Reload the page (`F5`, not a storage reset). -4. Read `GET /api/auth/session` and the rendered `body` text. - -## Expected - -The session clears, a reload stays signed out, and no stale name, balance, or repo -list survives. - -## Actual - -The first two clauses hold — `{"status":"signed-out"}` after the reload, the -`smithers_identity` cookie is gone. The third fails: the persisted transcript is never -scrubbed, so the signed-out reload still renders the previous account's data: - -``` -$505 - -Smithers is a design-partner preview — sign in with GitHub to continue. -Before GitHub asks, here is what Smithers will use: See your GitHub profile — your username, name, and avatar. -Sign in with GitHub - -You have 6 open issues and 1 open pull request across 3 repos. 7 have been waiting -more than a week. The oldest is pull request "Canary: read-only repository summary" -in codeplanesmithers/canary-sandbox, waiting 34 days. -… -Review "Canary: read-only repository summary" in codeplanesmithers/canary-sandbox -``` - -The balance pill, the digest, the repository names and the pending recommendation card -all survive. Signing out on a shared machine leaves the next person looking at the -previous user's repositories and balance. - -## Selector / route - -- `/auth.sign-out` (composer), `GET /api/auth/session` -- the transcript element (`.smithers-transcript`) and the balance pill - -## Repro output - -``` -signed in as: {"login":"codeplanesmithers","allowlisted":true,"admin":true,"scopes":["read:user"]} -session after sign-out + reload: {"status":"signed-out"} -=== the signed-out reload still renders === -$505 -Smithers is a design-partner preview — sign in with GitHub to continue. -… -You have 6 open issues and 1 open pull request across 3 repos. … -FAIL: the account name survived the sign-out and a full reload (/codeplanesmithers/) -FAIL: the balance survived the sign-out and a full reload (/\$\d+/) -FAIL: the repo list / digest survived the sign-out and a full reload (/open (issues?|pull requests?)/) -``` - -Exit code: `1`. diff --git a/apps/ui/canary-repros/access/2.4.ts b/apps/ui/canary-repros/access/2.4.ts deleted file mode 100644 index 814e7491..00000000 --- a/apps/ui/canary-repros/access/2.4.ts +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Repro — checklist row 2.4 ("`/auth.sign-out` clears the session; a reload - * stays signed out; no stale name, balance, or repo list survives") against - * https://canary.smithers.sh. - * - * The first two clauses hold: the cookie goes and /api/auth/session answers - * `{"status":"signed-out"}` after a full reload. The third does not. The - * persisted transcript is never scrubbed on sign-out, so a signed-out reload - * still renders, from the previous account: - * - * - the balance pill "$500", - * - the repo digest ("6 open issues and 1 open pull request across 3 repos"), - * - the repository names (codeplanesmithers/canary-sandbox, …), - * - the pending recommendation card for that account's pull request. - * - * Anyone who signs out on a shared machine leaves all of it on screen. - * - * bun 2.4.ts exit 1 while the bug is present, 0 once it is fixed. - */ -import { chromium } from "playwright"; -import { BASE, ensureSignedIn, PROFILE, report, resetOrigin, session } from "./_lib"; - -const context = await chromium.launchPersistentContext(PROFILE, { headless: true, viewport: { width: 1280, height: 1000 } }); -const page = context.pages()[0] ?? (await context.newPage()); -await resetOrigin(context, page, { signOut: true }); -await page.goto(BASE, { waitUntil: "domcontentloaded" }); -await page.waitForTimeout(5000); -const identity = await ensureSignedIn(page); -console.log("signed in as:", JSON.stringify(identity)); -if (!JSON.stringify(identity).includes('"login"')) { - console.error("precondition failed: could not sign in"); - process.exit(2); -} -const login = (identity as { login: string }).login; -await page.waitForTimeout(6000); - -const composer = page.locator("textarea, [contenteditable=true]").first(); -await composer.click(); -await composer.type("/auth.sign-out"); -await page.keyboard.press("Enter"); -await page.waitForTimeout(6000); - -await page.reload({ waitUntil: "domcontentloaded" }); -await page.waitForTimeout(7000); - -const after = await session(page); -console.log("session after sign-out + reload:", JSON.stringify(after)); -const text = await page.locator("body").innerText(); -console.log("=== the signed-out reload still renders ===\n" + text.slice(0, 700)); -await page.screenshot({ path: "/tmp/canary-access-2.4-reload.png", fullPage: true }); - -const failures: Array<string> = []; -if (JSON.stringify(after) !== '{"status":"signed-out"}') failures.push("the session survived /auth.sign-out"); -/* Each entry: the thing row 2.4 says must not survive, and how it shows up. */ -const survivors: Array<{ readonly what: string; readonly needle: RegExp }> = [ - { what: "the account name", needle: new RegExp(login) }, - { what: "the balance", needle: /\$\d+/ }, - { what: "the repo list / digest", needle: /open (issues?|pull requests?)/ }, -]; -for (const survivor of survivors) { - if (survivor.needle.test(text)) failures.push(`${survivor.what} survived the sign-out and a full reload (${survivor.needle})`); -} - -await context.close(); -report(failures); diff --git a/apps/ui/canary-repros/access/2.5.md b/apps/ui/canary-repros/access/2.5.md deleted file mode 100644 index 1ffd64db..00000000 --- a/apps/ui/canary-repros/access/2.5.md +++ /dev/null @@ -1,58 +0,0 @@ -> **RESOLVED 2026-08-19 (round 2).** Two tabs, both signed out; tab 2 signed -> in. Within 5s and with no reload, tab 1 dropped the signed-out copy and -> rendered the digest. The reverse holds too: `/auth.sign-out` in tab 1 put -> tab 2 back on the signed-out landing (digest gone) without a reload. -> `bun 2.5.ts` → `PASS — the bug is fixed.` - -# 2.5 — a second tab signing in leaves the first tab on the signed-out card - -- **Origin**: https://canary.smithers.sh -- **Tested**: 2026-08-18 -- **Repro**: `bun 2.5.ts` -- **Screenshot**: `/tmp/canary-access-2.5-tab1.png` - -## Steps - -1. Clear the origin's storage and cookie; open tab 1 on https://canary.smithers.sh - and confirm the signed-out card renders. -2. Open tab 2 on the same origin and complete the GitHub sign-in there. -3. Wait 20s. Do **not** reload tab 1. - -## Expected - -Both tabs agree on identity without a manual reload. - -## Actual - -Tab 1 is signed in at the seam and does not know it. `GET /api/auth/session` from tab 1 -answers `{"login":"codeplanesmithers","allowlisted":true,"admin":true,…}` — the cookie -is shared — while tab 1 still renders: - -``` -Smithers is a design-partner preview — sign in with GitHub to continue. -Before GitHub asks, here is what Smithers will use: See your GitHub profile — your username, name, and avatar. -Sign in with GitHub -``` - -The app reads identity once per load and nothing re-reads it, so the tabs converge only -when the stale tab is reloaded by hand. The same asymmetry runs the other way: signing -out in one tab leaves the other rendering the full signed-in digest. - -## Selector / route - -- `GET /api/auth/session` in both tabs -- the signed-out copy "sign in with GitHub to continue" in the transcript - -## Repro output - -``` -tab1 session: {"status":"signed-out"} -tab2 signs in: {"login":"codeplanesmithers","allowlisted":true,"admin":true,"scopes":["read:user"]} -tab1 session (cookie is shared): {"login":"codeplanesmithers","allowlisted":true,"admin":true,"scopes":["read:user"]} -tab1 still renders: -Smithers is a design-partner preview — sign in with GitHub to continue. -… -FAIL: tab 1 is signed in at the seam but still renders the signed-out card 20s after tab 2 signed in — the tabs only agree after a manual reload -``` - -Exit code: `1`. diff --git a/apps/ui/canary-repros/access/2.5.ts b/apps/ui/canary-repros/access/2.5.ts deleted file mode 100644 index 5f6b2ed0..00000000 --- a/apps/ui/canary-repros/access/2.5.ts +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Repro — checklist row 2.5 ("Sign in again in a second tab. Both tabs agree - * on identity without a manual reload") against https://canary.smithers.sh. - * - * They do not agree. With two tabs open on the app and both signed out, - * signing in from tab 2 leaves tab 1 sitting on the signed-out card - * ("Smithers is a design-partner preview — sign in with GitHub to continue") - * indefinitely: the app reads /api/auth/session once per load and nothing - * re-reads it, so identity only converges when the stale tab is reloaded by - * hand. The cookie is shared, so tab 1 is signed in and does not know. - * - * bun 2.5.ts exit 1 while the bug is present, 0 once it is fixed. - */ -import { chromium } from "playwright"; -import { BASE, ensureSignedIn, PROFILE, report, resetOrigin, session } from "./_lib"; - -const SIGNED_OUT_COPY = "sign in with GitHub to continue"; - -const context = await chromium.launchPersistentContext(PROFILE, { headless: true, viewport: { width: 1280, height: 900 } }); -const tab1 = context.pages()[0] ?? (await context.newPage()); -await resetOrigin(context, tab1, { signOut: true }); -await tab1.goto(BASE, { waitUntil: "domcontentloaded" }); -await tab1.waitForTimeout(6000); -console.log("tab1 session:", JSON.stringify(await session(tab1))); -const tab1Before = await tab1.locator("body").innerText(); -if (!tab1Before.includes(SIGNED_OUT_COPY)) { - console.error("precondition failed: tab 1 is not showing the signed-out state"); - process.exit(2); -} - -const tab2 = await context.newPage(); -await tab2.goto(BASE, { waitUntil: "domcontentloaded" }); -await tab2.waitForTimeout(5000); -console.log("tab2 signs in:", JSON.stringify(await ensureSignedIn(tab2))); - -/* Give tab 1 a generous window to notice, WITHOUT reloading it. */ -await tab1.waitForTimeout(20_000); -const tab1Session = await session(tab1); -const tab1After = await tab1.locator("body").innerText(); -console.log("tab1 session (cookie is shared):", JSON.stringify(tab1Session)); -console.log("tab1 still renders:\n" + tab1After.slice(0, 300)); -await tab1.screenshot({ path: "/tmp/canary-access-2.5-tab1.png", fullPage: true }); - -const failures: Array<string> = []; -if (JSON.stringify(tab1Session).includes('"login"') && tab1After.includes(SIGNED_OUT_COPY)) { - failures.push( - "tab 1 is signed in at the seam but still renders the signed-out card 20s after tab 2 signed in — the tabs only agree after a manual reload", - ); -} - -await context.close(); -report(failures); diff --git a/apps/ui/canary-repros/access/_lib.ts b/apps/ui/canary-repros/access/_lib.ts deleted file mode 100644 index b989021f..00000000 --- a/apps/ui/canary-repros/access/_lib.ts +++ /dev/null @@ -1,85 +0,0 @@ -/* - * Shared helpers for the "access" lane repros (checklist §1–§3) against - * https://canary.smithers.sh. - * - * The sanctioned persistent profile holds the signed-in github.com session for - * the throwaway account `codeplanesmithers`. Never open it directly and never - * share it between two runs — copy it first: - * - * cp -R ~/.multi-e2e-profile /tmp/canary-access-profile - */ -import type { BrowserContext, Page } from "playwright"; - -export const BASE = process.env.CANARY_URL ?? "https://canary.smithers.sh"; -export const PROFILE = process.env.PROF ?? "/tmp/canary-access-profile"; - -/** - * Clear this origin's persisted app state, and optionally its cookie, WITHOUT - * touching github.com. `Storage.clearDataForOrigin` with `cookies` in the type - * list clears the whole cookie jar, not just the origin's — that wipes the - * GitHub session the profile exists to carry, so the cookie is dropped by - * filtering the jar instead. - */ -export const resetOrigin = async ( - context: BrowserContext, - page: Page, - options: { readonly signOut: boolean }, -): Promise<void> => { - await page.goto("about:blank", { waitUntil: "domcontentloaded" }); - const client = await context.newCDPSession(page); - await client.send("Storage.clearDataForOrigin", { - origin: new URL(BASE).origin, - storageTypes: "file_systems,local_storage,indexeddb,cache_storage,websql,service_workers", - }); - await client.detach().catch(() => {}); - if (options.signOut) { - const jar = await context.cookies(); - const keep = jar.filter((cookie) => !cookie.domain.includes("smithers.sh")); - await context.clearCookies(); - await context.addCookies(keep); - } -}; - -/** The identity seam's answer, read through the product origin. */ -export const session = (page: Page): Promise<unknown> => - page.evaluate(async () => (await fetch("/api/auth/session")).json().catch(() => null)); - -/** Every `data-flow` name currently in the DOM, in document order. */ -export const visibleFlows = (page: Page): Promise<Array<string>> => - page.evaluate(() => - Array.from(document.querySelectorAll("[data-flow]")).map((element) => element.getAttribute("data-flow") ?? ""), - ); - -/** The app shell's whole registry (`data-flows`), split into names. */ -export const registry = async (page: Page): Promise<Array<string>> => { - const attribute = await page.evaluate(() => document.querySelector("[data-flows]")?.getAttribute("data-flows") ?? ""); - return attribute.split(" ").filter((name) => name !== ""); -}; - -export const report = (failures: ReadonlyArray<string>): never => { - if (failures.length === 0) { - console.log("PASS — the bug is fixed."); - process.exit(0); - } - for (const failure of failures) console.error(`FAIL: ${failure}`); - process.exit(1); -}; - -/** - * Drive the real GitHub sign-in when the origin has no session. The profile's - * github.com session and the existing app authorization make this a redirect - * round trip; the Authorize button is clicked when GitHub asks for it. - */ -export const ensureSignedIn = async (page: Page): Promise<unknown> => { - const current = await session(page); - if (JSON.stringify(current).includes('"login"')) return current; - /* The in-message CTA can sit under the composer; the composer's own gold - * suggestion pill is the one that is always clickable. */ - await page.locator('[data-flow="auth.sign-in"]').last().click(); - await page.waitForTimeout(5000); - const authorize = page.locator('button:has-text("Authorize")').first(); - if (await authorize.isVisible().catch(() => false)) await authorize.click(); - await page.waitForURL(/canary\.smithers\.sh/, { timeout: 60_000 }).catch(() => {}); - await page.waitForTimeout(8000); - return await session(page); -}; diff --git a/apps/ui/canary-repros/admin/25.7.md b/apps/ui/canary-repros/admin/25.7.md deleted file mode 100644 index 4259b13a..00000000 --- a/apps/ui/canary-repros/admin/25.7.md +++ /dev/null @@ -1,72 +0,0 @@ -# 25.7 — `/admin.health` charges are stale and wrongly scoped - -Origin: <https://canary.smithers.sh> (bundle `assets/index-BHHXuMoZ.js`) -Account: `codeplanesmithers` (allowlisted, `admin:true` via the ADMIN_LOGINS fixture) -Tested: 2026-08-19, admin lane round 1 - -## Checklist row - -> **25.7** `/admin.health` reports service health, charges, and queue depth, -> and the numbers are real. - -## Steps - -1. Sign in on <https://canary.smithers.sh> with an admin session. -2. Type `/admin.health` in `textarea.sui-chat-composer-input`. -3. Read the card's footer line. -4. Compare against `GET /api/billing/balance` and `GET /api/billing/usage` - for the *same single user*. -5. Re-read `GET /api/admin/health` after more turns have been spent. - -## Expected - -The card's charge count and amount are the live platform figures, and they -move as turns are spent. - -## Actual - -| source | charges | amount | -| --- | --- | --- | -| `/admin.health` card + `GET /api/admin/health` | 393 | $0.002675 | -| `GET /api/billing/balance` — **one user** | 1820 | — | -| `GET /api/billing/usage` — this month, one user | 1820 (625+570+625) | $0.34112 | - -The health figure is **smaller than one user's own**, so it cannot be the -fleet total the card presents ("Charges: $0.002675 across 393 turns"). It also -never moves: 393 at 08:49, 393 at 09:33, and 393 again on a re-read seconds -later, across roughly 170 turns spent in between. - -Two further copy problems in the same line: - -- `chargeCount` is rendered as "turns". A turn produces three charge rows - (`inference.input_tokens`, `cached_input_tokens`, `output_tokens`), so the - count is not a turn count under any scoping. -- The amount is `lifetimeChargedUsd`, which is `"0"` for this user because - inference is complimentary. The real cost is in `totalCostUsd`. - -Service health (`billing=ok, identity=ok, reco=ok`, each with live `healthz` -detail) and `queueDepth: 1` (matching the `/admin.requests` card) **are** real. - -## Route - -`GET /api/admin/health` on the product Worker (`smithers-mvp-web`), rendered by -the `admin-health` card. - -## Screenshot - -`/tmp/admin-lane/25.7-health.png` - -## Repro output - -``` -$ PROF=/tmp/canary-admin-profile bun 25.7.ts -admin.health charges : {"chargeCount":393,"lifetimeChargedUsd":"0.002675"} -admin.health queue : 1 -admin.health services: billing=ok, identity=ok, reco=ok -billing chargeCount for codeplanesmithers alone: 1820 -billing usage totalCostUsd this month : 0.34112 -admin.health chargeCount re-read: 393 -FAIL: admin.health reports 393 charges, fewer than the 1820 billing counts for ONE user — the figure cannot be the fleet total it is presented as. -FAIL: admin.health reports $0.002675 charged while billing's usage for this month alone is $0.34112. -(exit 1) -``` diff --git a/apps/ui/canary-repros/admin/25.7.ts b/apps/ui/canary-repros/admin/25.7.ts deleted file mode 100644 index e171b3ef..00000000 --- a/apps/ui/canary-repros/admin/25.7.ts +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Repro — checklist row 25.7 ("`/admin.health` reports service health, charges, - * and queue depth, and the numbers are real") against https://canary.smithers.sh. - * - * Service health and queue depth ARE real. The charges figure is not: the card - * reports `Charges: $0.002675 across 393 turns` while billing's own answer for - * a SINGLE user (`codeplanesmithers`) is chargeCount 1820 and this month's - * cost $0.34. A fleet total cannot be smaller than one user's, and the health - * figure does not move as turns are spent (identical at 08:49 and 09:33 across - * ~170 turns), so it is stale or scoped to something other than what it claims. - * - * PROF=/tmp/canary-admin-profile bun 25.7.ts - * exit 1 while the bug is present, 0 once the health charges track billing. - * - * Fixture: the session must be admin (identity worker ADMIN_LOGINS). - * Route: GET /api/admin/health vs GET /api/billing/balance, /api/billing/usage - */ -import { open, session } from "./_lib"; - -const { context, page } = await open(); -const who = await session(page); -if (who.admin !== true) { - console.error("SETUP: the session is not admin — add the login to the identity worker's ADMIN_LOGINS."); - await context.close(); - process.exit(2); -} - -const health = (await page.evaluate(async () => (await fetch("/api/admin/health")).json())) as { - charges: { chargeCount: number; lifetimeChargedUsd: string }; - queueDepth: number; - services: ReadonlyArray<{ name: string; status: string }>; -}; -const balance = (await page.evaluate(async () => (await fetch("/api/billing/balance")).json())) as { - balance: { chargeCount: number }; -}; -const usage = (await page.evaluate(async () => (await fetch("/api/billing/usage")).json())) as { - totalCostUsd: string; -}; - -console.log("admin.health charges :", JSON.stringify(health.charges)); -console.log("admin.health queue :", health.queueDepth); -console.log("admin.health services:", health.services.map((s) => `${s.name}=${s.status}`).join(", ")); -console.log("billing chargeCount for codeplanesmithers alone:", balance.balance.chargeCount); -console.log("billing usage totalCostUsd this month :", usage.totalCostUsd); - -// A second read a few seconds later: a live counter moves, a stale one does not. -await page.waitForTimeout(4000); -const again = (await page.evaluate(async () => (await fetch("/api/admin/health")).json())) as { - charges: { chargeCount: number }; -}; -console.log("admin.health chargeCount re-read:", again.charges.chargeCount); -await context.close(); - -const failures: Array<string> = []; -if (health.charges.chargeCount < balance.balance.chargeCount) { - failures.push( - `admin.health reports ${health.charges.chargeCount} charges, fewer than the ${balance.balance.chargeCount} billing counts for ONE user — the figure cannot be the fleet total it is presented as.`, - ); -} -if (Number(health.charges.lifetimeChargedUsd) < Number(usage.totalCostUsd)) { - failures.push( - `admin.health reports $${health.charges.lifetimeChargedUsd} charged while billing's usage for this month alone is $${usage.totalCostUsd}.`, - ); -} -if (failures.length === 0) { - console.log("PASS — the health charges track billing."); - process.exit(0); -} -for (const failure of failures) console.error(`FAIL: ${failure}`); -process.exit(1); diff --git a/apps/ui/canary-repros/admin/26.1.md b/apps/ui/canary-repros/admin/26.1.md deleted file mode 100644 index a995b753..00000000 --- a/apps/ui/canary-repros/admin/26.1.md +++ /dev/null @@ -1,60 +0,0 @@ -# 26.1 — FIXED: the chain is the only backend, and it runs a turn - -Origin: <https://canary.smithers.sh> -Account: `codeplanesmithers` (`admin:true` via the ADMIN_LOGINS fixture) -First reported: 2026-08-19, admin lane round 1 · Fixed and re-verified live: -2026-08-19, Worker version `e84ad45e-0311-4eed-b326-dc0bc80aeec9` - -## What was wrong - -> **26.1** `/debug.backend proxy` and `/debug.backend chain` both switch the -> agent backend and a turn works on each. - -- **proxy** — the turn completed. ✅ -- **chain** — three `501 POST /api/model/stream` responses, then "I couldn't - complete that turn. The chain failed… Anthropic Messages request failed with - HTTP 501". The turn never ran. - -The relay pointed at `api.anthropic.com` behind a `MODEL_RELAY_API_KEY` that -`smithers-mvp-web` never had, so it answered 501 by design. The chat was usable -only on the second, server-side backend. - -## What changed - -The fix was not a second key. Per the owner's ruling ("we shouldn't have 2 -backends — the agent loop runs in the browser"), the relay now forwards to the -SAME managed-inference upstream `/api/agent/turn` used: the canary chat Worker, -which owns the Cerebras key, authorizes the balance before the provider call, -and enqueues the usage onto the durable metering queue. No provider credential -is bound on the product Worker at all. The browser chain is the only backend, -and `/debug.backend` reports it instead of switching. - -## The contract now (what `26.1.ts` checks) - -1. `/debug.backend` reports `chain (in-browser Agent Chain over - /api/model/stream)`. -2. `/debug.backend proxy` answers "there is one backend and it cannot be - switched: …" — the argument is refused, never silently ignored. -3. A real turn completes, spends its model on `POST /api/model/stream`, and - never calls `/api/agent/turn`. - -## Live verification - -Signed in as `codeplanesmithers`, "Reply with exactly: PONG-chain": - -``` -session: {"login":"codeplanesmithers","allowlisted":true,"admin":true} -transcript: … Reply with exactly: PONG-chain … PONG-chain … -model calls: ["POST /api/model/stream"] -http>=400: [] -billing chargeCount: 1981 -> 1983 (rate card 2026-08-09.1) -balance totalUsd: 543 -> 543 -``` - -The balance is unchanged **because interactive chat is complimentary by design** -(`freeAtZeroBalance`: "Interactive chat — complimentary: metered at true -supplier cost, on us"). Metering is proven by the two new charge lines — input -and output tokens — landing on `codeplanesmithers`'s own account, which is the -trusted-caller attribution the relay now carries. - -Screenshot: `/tmp/canary-chain-live.png` diff --git a/apps/ui/canary-repros/admin/26.1.ts b/apps/ui/canary-repros/admin/26.1.ts deleted file mode 100644 index 39377861..00000000 --- a/apps/ui/canary-repros/admin/26.1.ts +++ /dev/null @@ -1,91 +0,0 @@ -/* - * Row 26.1, re-cut for the single-backend contract, against - * https://canary.smithers.sh. - * - * The row used to read "`/debug.backend proxy` and `/debug.backend chain` both - * switch the agent backend and a turn works on each", and it failed: the chain - * drove the browser model relay at POST /api/model/stream, which was pointed at - * Anthropic behind a MODEL_RELAY_API_KEY the deployed Worker never had, so - * every chain turn ended "Turn failed". - * - * Fixed 2026-08-19 by removing the second backend rather than by binding a - * second key. The relay forwards to the SAME managed-inference upstream - * /api/agent/turn used, and the browser chain is the only backend. So this - * script now checks the contract that replaced the row: - * - * 1. /debug.backend reports the one backend and refuses to switch. - * 2. A real turn completes. - * 3. It spent its model on /api/model/stream and never on /api/agent/turn. - * - * PROF=/tmp/canary-admin-profile bun 26.1.ts - * exit 0 when the contract holds. - * - * Fixture: the session must be admin (identity worker ADMIN_LOGINS) — the - * debug.* flows only register for admin:true. - */ -import { open, session, run, body, report } from "./_lib"; - -const { context, page, requests } = await open(); -const who = await session(page); -if (who.admin !== true) { - console.error("SETUP: the session is not admin — add the login to the identity worker's ADMIN_LOGINS."); - await context.close(); - process.exit(2); -} - -/* Every model-spending request the page makes, by route. */ -const modelCalls: Array<string> = []; -page.on("request", (request) => { - const path = new URL(request.url()).pathname; - if (path === "/api/model/stream" || path.startsWith("/api/agent/turn")) { - modelCalls.push(`${request.method()} ${path}`); - } -}); - -const reportedBefore = await body(page); -await run(page, "/debug.backend", 4000); -const reported = (await body(page)).slice(reportedBefore.length); -console.log("=== /debug.backend ==="); -console.log(reported.replace(/\s+/g, " ").slice(0, 300)); - -const refusedBefore = await body(page); -await run(page, "/debug.backend proxy", 4000); -const refused = (await body(page)).slice(refusedBefore.length); -console.log("=== /debug.backend proxy ==="); -console.log(refused.replace(/\s+/g, " ").slice(0, 300)); - -const turnBefore = await body(page); -const composer = page.locator("textarea.sui-chat-composer-input"); -await composer.click(); -await composer.fill("Reply with exactly: PONG-chain"); -await page.keyboard.press("Enter"); -await page.waitForTimeout(60_000); -const turn = (await body(page)).slice(turnBefore.length); - -console.log("=== turn ==="); -console.log(turn.replace(/\s+/g, " ").slice(0, 600)); -console.log("model calls:", JSON.stringify(modelCalls)); -console.log("http>=400:", JSON.stringify(requests)); - -await page.screenshot({ path: "/tmp/canary-26.1.png", fullPage: true }); -console.log("screenshot: /tmp/canary-26.1.png"); -await context.close(); - -const failures: Array<string> = []; -if (!reported.includes("chain")) { - failures.push(`/debug.backend did not report the backend: ${reported.replace(/\s+/g, " ").slice(0, 200)}`); -} -if (!refused.includes("cannot be switched")) { - failures.push(`/debug.backend proxy did not refuse honestly: ${refused.replace(/\s+/g, " ").slice(0, 200)}`); -} -if (!turn.includes("PONG-chain")) { - failures.push(`the chain turn did not complete: ${turn.replace(/\s+/g, " ").slice(0, 200)}`); -} -if (!modelCalls.includes("POST /api/model/stream")) { - failures.push("the turn never spent a model on /api/model/stream."); -} -const toTurnSeam = modelCalls.filter((call) => call.includes("/api/agent/turn")); -if (toTurnSeam.length > 0) { - failures.push(`the turn reached the retired proxy seam: ${JSON.stringify(toTurnSeam)}`); -} -report(failures); diff --git a/apps/ui/canary-repros/admin/26.2.md b/apps/ui/canary-repros/admin/26.2.md deleted file mode 100644 index 86ea9ca7..00000000 --- a/apps/ui/canary-repros/admin/26.2.md +++ /dev/null @@ -1,74 +0,0 @@ -# 26.2 — `/debug.snapshot` runs but renders nothing - -Origin: <https://canary.smithers.sh> (bundle `assets/index-BHHXuMoZ.js`) -Account: `codeplanesmithers` (`admin:true` via the ADMIN_LOGINS fixture) -Tested: 2026-08-19, admin lane round 1 - -## Checklist row - -See §26 in `apps/ui/MANUAL-REVIEW-CHECKLIST.md` — `/debug.snapshot` must read its -debug surface. - -## Steps - -1. Sign in as an admin (`debug.*` only registers for `admin:true`). -2. Run `/billing.balance` so the read has real content to report. -3. Type `/debug.snapshot` into `textarea.sui-chat-composer-input` and submit. -4. Count `section.smithers-card` and `[data-role]` before and after. -5. Open `/admin.devtools` and read the transition journal. - -## Expected - -The read is rendered — a card or a transcript message carrying the payload. - -## Actual - -Nothing renders. Card count and message count are unchanged and the body text -gains nothing. The flow **did** run: the dev-tools transition journal records a -fresh `command.ran user` entry for it. - -The data exists and is correct — asking the model to run the same flow prints -the payload. On the canary, `debug.net` through the agent returned: - -``` -[{"at":1787130254592,"method":"GET","url":"/api/billing/balance","status":200,"ms":64}, - {"at":1787130245908,"method":"GET","url":"/api/reco/first-run","status":200,"ms":100}, - {"at":1787130245907,"method":"GET","url":"/api/billing/balance","status":200,"ms":64}, - {"at":1787130245813,"method":"GET","url":"/api/auth/session","status":200,"ms":77}] -``` - -For row 26.5 specifically: the tap carries only `at`, `method`, `url`, -`status`, `ms` — no headers, no bodies, no cookies. **No secret appears in it.** -That half of the row holds; only the user-facing read is missing. - -## Root cause - -Already traced in `../ROOT-CAUSES.md` §1. The flow handler returns the data, so -the Effect *succeeds*; `Commands.ts` maps that to `{ status: "executed", value }`, -and `AppController.surfaceCommandFailure` returns early for anything that is not -`"failed"`, dropping the value. - -Because the value is legitimate output rather than an error, the fix here is -narrower than §1's: these four flows need a rendering target (a debug card, or a -transcript message), not an error surface. - -## Route / selector - -`textarea.sui-chat-composer-input` → `/debug.snapshot`; assert on -`section.smithers-card` and `[data-role]` counts. - -## Screenshot - -`/tmp/canary-26.2.png` - -## Repro output - -``` -$ PROF=/tmp/canary-admin-profile bun 26.2.ts -cards 10 -> 10 -messages 14 -> 14 -new rendered text: "" -transition journal head: Transitions #1072 command.ran user #1071 devtools.toggled user ... -FAIL: /debug.snapshot added no card and no message — the read is invisible to the user. -(exit 1) -``` diff --git a/apps/ui/canary-repros/admin/26.2.ts b/apps/ui/canary-repros/admin/26.2.ts deleted file mode 100644 index dff934f3..00000000 --- a/apps/ui/canary-repros/admin/26.2.ts +++ /dev/null @@ -1,63 +0,0 @@ -/* - * Repro — checklist row 26.2 ("`/debug.snapshot` reads the app state snapshot") against - * https://canary.smithers.sh. - * - * The flow RUNS — a `command.ran` transition is journalled — and its handler - * returns the data (the agent can read it: asking the model to run - * `debug.snapshot` prints the payload). But a USER who types `/debug.snapshot` - * sees nothing at all: no card, no transcript line, no toast. The flow's - * return value is dropped at the flow boundary. - * - * This is the systemic "renders nothing" defect already traced in - * ../ROOT-CAUSES.md §1: a flow that succeeds with a value reaches - * `AppController.surfaceCommandFailure` with `status: "executed"`, which - * returns early, so the value is discarded. - * - * PROF=/tmp/canary-admin-profile bun 26.2.ts - * exit 1 while the bug is present, 0 once the read is rendered. - * - * Fixture: the session must be admin (identity worker ADMIN_LOGINS). - */ -import { open, session, run, body } from "./_lib"; - -const { context, page } = await open(); -const who = await session(page); -if (who.admin !== true) { - console.error("SETUP: the session is not admin — add the login to the identity worker's ADMIN_LOGINS."); - await context.close(); - process.exit(2); -} - -// Give the read something real to report. -await run(page, "/billing.balance", 5000); - -const cardsBefore = await page.locator("section.smithers-card").count(); -const messagesBefore = await page.locator("[data-role]").count(); -const before = await body(page); - -await run(page, "/debug.snapshot", 7000); - -const cardsAfter = await page.locator("section.smithers-card").count(); -const messagesAfter = await page.locator("[data-role]").count(); -const after = await body(page); - -console.log(`cards ${cardsBefore} -> ${cardsAfter}`); -console.log(`messages ${messagesBefore} -> ${messagesAfter}`); -console.log("new rendered text:", JSON.stringify((after.startsWith(before) ? after.slice(before.length) : "").trim().slice(0, 300))); - -// Proof the flow really ran: the dev-tools transition journal records it. -await run(page, "/admin.devtools", 4000); -const panel = await body(page); -const journal = panel.slice(panel.indexOf("Transitions"), panel.indexOf("Transitions") + 200); -console.log("transition journal head:", journal.replace(/\s+/g, " ")); -await page.screenshot({ path: "/tmp/canary-26.2.png", fullPage: true }); -console.log("screenshot: /tmp/canary-26.2.png"); -await context.close(); - -const rendered = cardsAfter > cardsBefore || messagesAfter > messagesBefore; -if (rendered) { - console.log("PASS — /debug.snapshot rendered its read."); - process.exit(0); -} -console.error("FAIL: /debug.snapshot added no card and no message — the read is invisible to the user."); -process.exit(1); diff --git a/apps/ui/canary-repros/admin/26.3.md b/apps/ui/canary-repros/admin/26.3.md deleted file mode 100644 index 04b3485d..00000000 --- a/apps/ui/canary-repros/admin/26.3.md +++ /dev/null @@ -1,74 +0,0 @@ -# 26.3 — `/debug.events` runs but renders nothing - -Origin: <https://canary.smithers.sh> (bundle `assets/index-BHHXuMoZ.js`) -Account: `codeplanesmithers` (`admin:true` via the ADMIN_LOGINS fixture) -Tested: 2026-08-19, admin lane round 1 - -## Checklist row - -See §26 in `apps/ui/MANUAL-REVIEW-CHECKLIST.md` — `/debug.events` must read its -debug surface. - -## Steps - -1. Sign in as an admin (`debug.*` only registers for `admin:true`). -2. Run `/billing.balance` so the read has real content to report. -3. Type `/debug.events` into `textarea.sui-chat-composer-input` and submit. -4. Count `section.smithers-card` and `[data-role]` before and after. -5. Open `/admin.devtools` and read the transition journal. - -## Expected - -The read is rendered — a card or a transcript message carrying the payload. - -## Actual - -Nothing renders. Card count and message count are unchanged and the body text -gains nothing. The flow **did** run: the dev-tools transition journal records a -fresh `command.ran user` entry for it. - -The data exists and is correct — asking the model to run the same flow prints -the payload. On the canary, `debug.net` through the agent returned: - -``` -[{"at":1787130254592,"method":"GET","url":"/api/billing/balance","status":200,"ms":64}, - {"at":1787130245908,"method":"GET","url":"/api/reco/first-run","status":200,"ms":100}, - {"at":1787130245907,"method":"GET","url":"/api/billing/balance","status":200,"ms":64}, - {"at":1787130245813,"method":"GET","url":"/api/auth/session","status":200,"ms":77}] -``` - -For row 26.5 specifically: the tap carries only `at`, `method`, `url`, -`status`, `ms` — no headers, no bodies, no cookies. **No secret appears in it.** -That half of the row holds; only the user-facing read is missing. - -## Root cause - -Already traced in `../ROOT-CAUSES.md` §1. The flow handler returns the data, so -the Effect *succeeds*; `Commands.ts` maps that to `{ status: "executed", value }`, -and `AppController.surfaceCommandFailure` returns early for anything that is not -`"failed"`, dropping the value. - -Because the value is legitimate output rather than an error, the fix here is -narrower than §1's: these four flows need a rendering target (a debug card, or a -transcript message), not an error surface. - -## Route / selector - -`textarea.sui-chat-composer-input` → `/debug.events`; assert on -`section.smithers-card` and `[data-role]` counts. - -## Screenshot - -`/tmp/canary-26.3.png` - -## Repro output - -``` -$ PROF=/tmp/canary-admin-profile bun 26.3.ts -cards 10 -> 10 -messages 14 -> 14 -new rendered text: "" -transition journal head: Transitions #1072 command.ran user #1071 devtools.toggled user ... -FAIL: /debug.events added no card and no message — the read is invisible to the user. -(exit 1) -``` diff --git a/apps/ui/canary-repros/admin/26.3.ts b/apps/ui/canary-repros/admin/26.3.ts deleted file mode 100644 index 1c7d8adc..00000000 --- a/apps/ui/canary-repros/admin/26.3.ts +++ /dev/null @@ -1,63 +0,0 @@ -/* - * Repro — checklist row 26.3 ("`/debug.events` reads the transition journal tail") against - * https://canary.smithers.sh. - * - * The flow RUNS — a `command.ran` transition is journalled — and its handler - * returns the data (the agent can read it: asking the model to run - * `debug.events` prints the payload). But a USER who types `/debug.events` - * sees nothing at all: no card, no transcript line, no toast. The flow's - * return value is dropped at the flow boundary. - * - * This is the systemic "renders nothing" defect already traced in - * ../ROOT-CAUSES.md §1: a flow that succeeds with a value reaches - * `AppController.surfaceCommandFailure` with `status: "executed"`, which - * returns early, so the value is discarded. - * - * PROF=/tmp/canary-admin-profile bun 26.3.ts - * exit 1 while the bug is present, 0 once the read is rendered. - * - * Fixture: the session must be admin (identity worker ADMIN_LOGINS). - */ -import { open, session, run, body } from "./_lib"; - -const { context, page } = await open(); -const who = await session(page); -if (who.admin !== true) { - console.error("SETUP: the session is not admin — add the login to the identity worker's ADMIN_LOGINS."); - await context.close(); - process.exit(2); -} - -// Give the read something real to report. -await run(page, "/billing.balance", 5000); - -const cardsBefore = await page.locator("section.smithers-card").count(); -const messagesBefore = await page.locator("[data-role]").count(); -const before = await body(page); - -await run(page, "/debug.events", 7000); - -const cardsAfter = await page.locator("section.smithers-card").count(); -const messagesAfter = await page.locator("[data-role]").count(); -const after = await body(page); - -console.log(`cards ${cardsBefore} -> ${cardsAfter}`); -console.log(`messages ${messagesBefore} -> ${messagesAfter}`); -console.log("new rendered text:", JSON.stringify((after.startsWith(before) ? after.slice(before.length) : "").trim().slice(0, 300))); - -// Proof the flow really ran: the dev-tools transition journal records it. -await run(page, "/admin.devtools", 4000); -const panel = await body(page); -const journal = panel.slice(panel.indexOf("Transitions"), panel.indexOf("Transitions") + 200); -console.log("transition journal head:", journal.replace(/\s+/g, " ")); -await page.screenshot({ path: "/tmp/canary-26.3.png", fullPage: true }); -console.log("screenshot: /tmp/canary-26.3.png"); -await context.close(); - -const rendered = cardsAfter > cardsBefore || messagesAfter > messagesBefore; -if (rendered) { - console.log("PASS — /debug.events rendered its read."); - process.exit(0); -} -console.error("FAIL: /debug.events added no card and no message — the read is invisible to the user."); -process.exit(1); diff --git a/apps/ui/canary-repros/admin/26.4.md b/apps/ui/canary-repros/admin/26.4.md deleted file mode 100644 index 815d4596..00000000 --- a/apps/ui/canary-repros/admin/26.4.md +++ /dev/null @@ -1,74 +0,0 @@ -# 26.4 — `/debug.chain` runs but renders nothing - -Origin: <https://canary.smithers.sh> (bundle `assets/index-BHHXuMoZ.js`) -Account: `codeplanesmithers` (`admin:true` via the ADMIN_LOGINS fixture) -Tested: 2026-08-19, admin lane round 1 - -## Checklist row - -See §26 in `apps/ui/MANUAL-REVIEW-CHECKLIST.md` — `/debug.chain` must read its -debug surface. - -## Steps - -1. Sign in as an admin (`debug.*` only registers for `admin:true`). -2. Run `/billing.balance` so the read has real content to report. -3. Type `/debug.chain` into `textarea.sui-chat-composer-input` and submit. -4. Count `section.smithers-card` and `[data-role]` before and after. -5. Open `/admin.devtools` and read the transition journal. - -## Expected - -The read is rendered — a card or a transcript message carrying the payload. - -## Actual - -Nothing renders. Card count and message count are unchanged and the body text -gains nothing. The flow **did** run: the dev-tools transition journal records a -fresh `command.ran user` entry for it. - -The data exists and is correct — asking the model to run the same flow prints -the payload. On the canary, `debug.net` through the agent returned: - -``` -[{"at":1787130254592,"method":"GET","url":"/api/billing/balance","status":200,"ms":64}, - {"at":1787130245908,"method":"GET","url":"/api/reco/first-run","status":200,"ms":100}, - {"at":1787130245907,"method":"GET","url":"/api/billing/balance","status":200,"ms":64}, - {"at":1787130245813,"method":"GET","url":"/api/auth/session","status":200,"ms":77}] -``` - -For row 26.5 specifically: the tap carries only `at`, `method`, `url`, -`status`, `ms` — no headers, no bodies, no cookies. **No secret appears in it.** -That half of the row holds; only the user-facing read is missing. - -## Root cause - -Already traced in `../ROOT-CAUSES.md` §1. The flow handler returns the data, so -the Effect *succeeds*; `Commands.ts` maps that to `{ status: "executed", value }`, -and `AppController.surfaceCommandFailure` returns early for anything that is not -`"failed"`, dropping the value. - -Because the value is legitimate output rather than an error, the fix here is -narrower than §1's: these four flows need a rendering target (a debug card, or a -transcript message), not an error surface. - -## Route / selector - -`textarea.sui-chat-composer-input` → `/debug.chain`; assert on -`section.smithers-card` and `[data-role]` counts. - -## Screenshot - -`/tmp/canary-26.4.png` - -## Repro output - -``` -$ PROF=/tmp/canary-admin-profile bun 26.4.ts -cards 10 -> 10 -messages 14 -> 14 -new rendered text: "" -transition journal head: Transitions #1072 command.ran user #1071 devtools.toggled user ... -FAIL: /debug.chain added no card and no message — the read is invisible to the user. -(exit 1) -``` diff --git a/apps/ui/canary-repros/admin/26.4.ts b/apps/ui/canary-repros/admin/26.4.ts deleted file mode 100644 index 2f0ab314..00000000 --- a/apps/ui/canary-repros/admin/26.4.ts +++ /dev/null @@ -1,63 +0,0 @@ -/* - * Repro — checklist row 26.4 ("`/debug.chain` reads the chain journal x-ray") against - * https://canary.smithers.sh. - * - * The flow RUNS — a `command.ran` transition is journalled — and its handler - * returns the data (the agent can read it: asking the model to run - * `debug.chain` prints the payload). But a USER who types `/debug.chain` - * sees nothing at all: no card, no transcript line, no toast. The flow's - * return value is dropped at the flow boundary. - * - * This is the systemic "renders nothing" defect already traced in - * ../ROOT-CAUSES.md §1: a flow that succeeds with a value reaches - * `AppController.surfaceCommandFailure` with `status: "executed"`, which - * returns early, so the value is discarded. - * - * PROF=/tmp/canary-admin-profile bun 26.4.ts - * exit 1 while the bug is present, 0 once the read is rendered. - * - * Fixture: the session must be admin (identity worker ADMIN_LOGINS). - */ -import { open, session, run, body } from "./_lib"; - -const { context, page } = await open(); -const who = await session(page); -if (who.admin !== true) { - console.error("SETUP: the session is not admin — add the login to the identity worker's ADMIN_LOGINS."); - await context.close(); - process.exit(2); -} - -// Give the read something real to report. -await run(page, "/billing.balance", 5000); - -const cardsBefore = await page.locator("section.smithers-card").count(); -const messagesBefore = await page.locator("[data-role]").count(); -const before = await body(page); - -await run(page, "/debug.chain", 7000); - -const cardsAfter = await page.locator("section.smithers-card").count(); -const messagesAfter = await page.locator("[data-role]").count(); -const after = await body(page); - -console.log(`cards ${cardsBefore} -> ${cardsAfter}`); -console.log(`messages ${messagesBefore} -> ${messagesAfter}`); -console.log("new rendered text:", JSON.stringify((after.startsWith(before) ? after.slice(before.length) : "").trim().slice(0, 300))); - -// Proof the flow really ran: the dev-tools transition journal records it. -await run(page, "/admin.devtools", 4000); -const panel = await body(page); -const journal = panel.slice(panel.indexOf("Transitions"), panel.indexOf("Transitions") + 200); -console.log("transition journal head:", journal.replace(/\s+/g, " ")); -await page.screenshot({ path: "/tmp/canary-26.4.png", fullPage: true }); -console.log("screenshot: /tmp/canary-26.4.png"); -await context.close(); - -const rendered = cardsAfter > cardsBefore || messagesAfter > messagesBefore; -if (rendered) { - console.log("PASS — /debug.chain rendered its read."); - process.exit(0); -} -console.error("FAIL: /debug.chain added no card and no message — the read is invisible to the user."); -process.exit(1); diff --git a/apps/ui/canary-repros/admin/26.5.md b/apps/ui/canary-repros/admin/26.5.md deleted file mode 100644 index d136186d..00000000 --- a/apps/ui/canary-repros/admin/26.5.md +++ /dev/null @@ -1,74 +0,0 @@ -# 26.5 — `/debug.net` runs but renders nothing - -Origin: <https://canary.smithers.sh> (bundle `assets/index-BHHXuMoZ.js`) -Account: `codeplanesmithers` (`admin:true` via the ADMIN_LOGINS fixture) -Tested: 2026-08-19, admin lane round 1 - -## Checklist row - -See §26 in `apps/ui/MANUAL-REVIEW-CHECKLIST.md` — `/debug.net` must read its -debug surface. - -## Steps - -1. Sign in as an admin (`debug.*` only registers for `admin:true`). -2. Run `/billing.balance` so the read has real content to report. -3. Type `/debug.net` into `textarea.sui-chat-composer-input` and submit. -4. Count `section.smithers-card` and `[data-role]` before and after. -5. Open `/admin.devtools` and read the transition journal. - -## Expected - -The read is rendered — a card or a transcript message carrying the payload. - -## Actual - -Nothing renders. Card count and message count are unchanged and the body text -gains nothing. The flow **did** run: the dev-tools transition journal records a -fresh `command.ran user` entry for it. - -The data exists and is correct — asking the model to run the same flow prints -the payload. On the canary, `debug.net` through the agent returned: - -``` -[{"at":1787130254592,"method":"GET","url":"/api/billing/balance","status":200,"ms":64}, - {"at":1787130245908,"method":"GET","url":"/api/reco/first-run","status":200,"ms":100}, - {"at":1787130245907,"method":"GET","url":"/api/billing/balance","status":200,"ms":64}, - {"at":1787130245813,"method":"GET","url":"/api/auth/session","status":200,"ms":77}] -``` - -For row 26.5 specifically: the tap carries only `at`, `method`, `url`, -`status`, `ms` — no headers, no bodies, no cookies. **No secret appears in it.** -That half of the row holds; only the user-facing read is missing. - -## Root cause - -Already traced in `../ROOT-CAUSES.md` §1. The flow handler returns the data, so -the Effect *succeeds*; `Commands.ts` maps that to `{ status: "executed", value }`, -and `AppController.surfaceCommandFailure` returns early for anything that is not -`"failed"`, dropping the value. - -Because the value is legitimate output rather than an error, the fix here is -narrower than §1's: these four flows need a rendering target (a debug card, or a -transcript message), not an error surface. - -## Route / selector - -`textarea.sui-chat-composer-input` → `/debug.net`; assert on -`section.smithers-card` and `[data-role]` counts. - -## Screenshot - -`/tmp/canary-26.5.png` - -## Repro output - -``` -$ PROF=/tmp/canary-admin-profile bun 26.5.ts -cards 10 -> 10 -messages 14 -> 14 -new rendered text: "" -transition journal head: Transitions #1072 command.ran user #1071 devtools.toggled user ... -FAIL: /debug.net added no card and no message — the read is invisible to the user. -(exit 1) -``` diff --git a/apps/ui/canary-repros/admin/26.5.ts b/apps/ui/canary-repros/admin/26.5.ts deleted file mode 100644 index a95ba4b7..00000000 --- a/apps/ui/canary-repros/admin/26.5.ts +++ /dev/null @@ -1,63 +0,0 @@ -/* - * Repro — checklist row 26.5 ("`/debug.net` reads the network tap") against - * https://canary.smithers.sh. - * - * The flow RUNS — a `command.ran` transition is journalled — and its handler - * returns the data (the agent can read it: asking the model to run - * `debug.net` prints the payload). But a USER who types `/debug.net` - * sees nothing at all: no card, no transcript line, no toast. The flow's - * return value is dropped at the flow boundary. - * - * This is the systemic "renders nothing" defect already traced in - * ../ROOT-CAUSES.md §1: a flow that succeeds with a value reaches - * `AppController.surfaceCommandFailure` with `status: "executed"`, which - * returns early, so the value is discarded. - * - * PROF=/tmp/canary-admin-profile bun 26.5.ts - * exit 1 while the bug is present, 0 once the read is rendered. - * - * Fixture: the session must be admin (identity worker ADMIN_LOGINS). - */ -import { open, session, run, body } from "./_lib"; - -const { context, page } = await open(); -const who = await session(page); -if (who.admin !== true) { - console.error("SETUP: the session is not admin — add the login to the identity worker's ADMIN_LOGINS."); - await context.close(); - process.exit(2); -} - -// Give the read something real to report. -await run(page, "/billing.balance", 5000); - -const cardsBefore = await page.locator("section.smithers-card").count(); -const messagesBefore = await page.locator("[data-role]").count(); -const before = await body(page); - -await run(page, "/debug.net", 7000); - -const cardsAfter = await page.locator("section.smithers-card").count(); -const messagesAfter = await page.locator("[data-role]").count(); -const after = await body(page); - -console.log(`cards ${cardsBefore} -> ${cardsAfter}`); -console.log(`messages ${messagesBefore} -> ${messagesAfter}`); -console.log("new rendered text:", JSON.stringify((after.startsWith(before) ? after.slice(before.length) : "").trim().slice(0, 300))); - -// Proof the flow really ran: the dev-tools transition journal records it. -await run(page, "/admin.devtools", 4000); -const panel = await body(page); -const journal = panel.slice(panel.indexOf("Transitions"), panel.indexOf("Transitions") + 200); -console.log("transition journal head:", journal.replace(/\s+/g, " ")); -await page.screenshot({ path: "/tmp/canary-26.5.png", fullPage: true }); -console.log("screenshot: /tmp/canary-26.5.png"); -await context.close(); - -const rendered = cardsAfter > cardsBefore || messagesAfter > messagesBefore; -if (rendered) { - console.log("PASS — /debug.net rendered its read."); - process.exit(0); -} -console.error("FAIL: /debug.net added no card and no message — the read is invisible to the user."); -process.exit(1); diff --git a/apps/ui/canary-repros/admin/26.6.md b/apps/ui/canary-repros/admin/26.6.md deleted file mode 100644 index 1f4f05e7..00000000 --- a/apps/ui/canary-repros/admin/26.6.md +++ /dev/null @@ -1,58 +0,0 @@ -# 26.6 — `/debug.grants.reset` is silent, and its second half is unreachable - -Origin: <https://canary.smithers.sh> (bundle `assets/index-BHHXuMoZ.js`) -Account: `codeplanesmithers` (`admin:true` via the ADMIN_LOGINS fixture) -Tested: 2026-08-19, admin lane round 1 - -## Checklist row - -> **26.6** `/debug.grants.reset` revokes the chain's session grants and the -> next tool call re-asks. - -## Steps - -1. Sign in as an admin. -2. `/debug.grants.reset`. -3. Send a turn that needs a tool: "List the repositories you are watching." - -## Expected - -The reset confirms, and the next tool call asks for permission again. - -## Actual - -- `/debug.grants.reset` renders nothing: cards 10 → 10, messages 14 → 14. The - admin gets no confirmation that anything was revoked (same defect as - `26.2.md`–`26.5.md`). -- The second half could not be observed at all: the chain turn died on three - `501 POST /api/model/stream` responses, so no tool call ever happened and no - grant was ever re-asked. - -**Half fixed, 2026-08-19.** The blocking half is gone: the relay now reaches -managed inference and a chain turn completes live (`26.1.md`), so the tool call -this row needs can happen. Step 2 of the steps above is no longer -`/debug.backend chain` — the chain is the only backend, so there is nothing to -switch. What is still open is the FIRST half, which never depended on 26.1: -`/debug.grants.reset` renders no confirmation. Re-run this row against the -current deployment to certify the re-ask. - -## Route / selector - -`textarea.sui-chat-composer-input` → `/debug.grants.reset`; -`POST /api/model/stream`. - -## Screenshot - -`/tmp/canary-26.6.png` - -## Repro output - -``` -$ PROF=/tmp/canary-admin-profile bun 26.6.ts -after /debug.grants.reset — cards 10 -> 10, messages 14 -> 14 -next turn: ... I couldn't complete that turn. The chain fail... -http>=400 during the turn: ["501 POST .../api/model/stream" x3] -FAIL: /debug.grants.reset rendered nothing — no confirmation that the grants were revoked. -FAIL: the next tool call could not be observed: the chain backend 501s on /api/model/stream, so no grant is ever re-asked. See 26.1.md. -(exit 1) -``` diff --git a/apps/ui/canary-repros/admin/26.6.ts b/apps/ui/canary-repros/admin/26.6.ts deleted file mode 100644 index 576ac7c5..00000000 --- a/apps/ui/canary-repros/admin/26.6.ts +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Repro — checklist row 26.6 ("`/debug.grants.reset` revokes the chain's - * session grants and the next tool call re-asks") against - * https://canary.smithers.sh. - * - * Two problems, one of them terminal for the row: - * - * 1. `/debug.grants.reset` renders nothing — no card, no message, no toast — - * so an admin has no confirmation the grants were revoked (same defect as - * 26.2-26.5). - * 2. The second half of the row is UNREACHABLE on the canary: session grants - * belong to the CHAIN backend, and a chain turn cannot run at all because - * `POST /api/model/stream` answered 501 (fixed 2026-08-19; `MODEL_RELAY_API_KEY` was unbound on - * `smithers-mvp-web`). See 26.1.md. So "the next tool call re-asks" can - * never be observed here. - * - * PROF=/tmp/canary-admin-profile bun 26.6.ts - * exit 1 while either problem is present, 0 once the flow confirms and a - * chain tool call re-asks. - * - * Fixture: the session must be admin (identity worker ADMIN_LOGINS). - */ -import { open, session, run, body } from "./_lib"; - -const { context, page, requests } = await open(); -const who = await session(page); -if (who.admin !== true) { - console.error("SETUP: the session is not admin — add the login to the identity worker's ADMIN_LOGINS."); - await context.close(); - process.exit(2); -} - -const cardsBefore = await page.locator("section.smithers-card").count(); -const messagesBefore = await page.locator("[data-role]").count(); -await run(page, "/debug.grants.reset", 7000); -const cardsAfter = await page.locator("section.smithers-card").count(); -const messagesAfter = await page.locator("[data-role]").count(); -console.log(`after /debug.grants.reset — cards ${cardsBefore} -> ${cardsAfter}, messages ${messagesBefore} -> ${messagesAfter}`); - -// The next tool-calling turn. There is one backend and it owns the grants. -const before = await body(page); -const mark = requests.length; -const composer = page.locator("textarea.sui-chat-composer-input"); -await composer.click(); -await composer.fill("List the repositories you are watching."); -await page.keyboard.press("Enter"); -await page.waitForTimeout(35_000); -const after = await body(page); -const delta = (after.startsWith(before) ? after.slice(before.length) : after.slice(-800)).replace(/\s+/g, " "); -console.log("next turn:", delta.slice(0, 320)); -console.log("http>=400 during the turn:", JSON.stringify(requests.slice(mark))); -await page.screenshot({ path: "/tmp/canary-26.6.png", fullPage: true }); -console.log("screenshot: /tmp/canary-26.6.png"); -await context.close(); - -const failures: Array<string> = []; -if (cardsAfter === cardsBefore && messagesAfter === messagesBefore) { - failures.push("/debug.grants.reset rendered nothing — no confirmation that the grants were revoked."); -} -if (requests.slice(mark).some((entry) => entry.startsWith("501"))) { - failures.push( - "the next tool call could not be observed: the chain backend 501s on /api/model/stream, so no grant is ever re-asked. See 26.1.md.", - ); -} -if (failures.length === 0) { - console.log("PASS — the reset confirms and the next tool call re-asks."); - process.exit(0); -} -for (const failure of failures) console.error(`FAIL: ${failure}`); -process.exit(1); diff --git a/apps/ui/canary-repros/admin/27.1.md b/apps/ui/canary-repros/admin/27.1.md deleted file mode 100644 index 53c2d3fe..00000000 --- a/apps/ui/canary-repros/admin/27.1.md +++ /dev/null @@ -1,83 +0,0 @@ -# 27.1 — `build:canary` produces an app with no backend - -Machine: macOS 25.2.0 arm64. Electrobun 1.18.1. -Tested: 2026-08-19, admin lane round 1 - -## Checklist row - -> **27.1** `bun run build:canary` produces a launchable app. - -## Steps - -1. `cd apps/ui && bun run build:canary` -2. `open build/canary-macos-arm64/Smithers-canary.app` (or run - `Contents/MacOS/launcher` directly to see stdout). - -## Expected - -A launchable app that works — the window loads the product and can sign in. - -## Actual - -The build succeeds (vite ✓, electrobun ✓) and produces both -`build/canary-macos-arm64/Smithers-canary.app` and -`artifacts/canary-macos-arm64-Smithers-canary.dmg`. The app launches: the -launcher spawns the bun main process, prints `Smithers app started!`, and -creates the window. - -But the app has no backend. `apps/ui/src/bun/index.ts:10-38` picks the window -URL like this: - -```ts -const override = Bun.env.SMITHERS_APP_URL?.trim() -if (override !== undefined && override !== "") return override -const channel = await Updater.localInfo.channel() -if (channel === "dev") { ...vite dev server... } -return "views://mainview/index.html" -``` - -`build:canary` is `vite build && electrobun build --env=canary`; it sets no -`SMITHERS_APP_URL`, and the channel is `canary`, not `dev`. So the window loads -local files and every relative `/api/*` fetch in the renderer resolves against -the `views://` scheme. The first one fails on startup: - -``` -ERROR ========== empty response for URL: views://mainview/api/auth/session -``` - -Nothing in the renderer rewrites `/api/*` to an absolute origin (`grep -rn -"views://" apps/ui/src` matches only that one line in `src/bun/index.ts`). - -Launching the same bundle with `SMITHERS_APP_URL=https://canary.smithers.sh` -works — the log shows `Loading the app from SMITHERS_APP_URL: -https://canary.smithers.sh` and the `views://` error disappears. That is the -documented dev loop (`bun run start:canary`), not something a shipped app can -rely on. - -**Fix direction.** Either bake the deployed origin into the non-dev channels -(the same way `start:canary` sets it), or serve the API from the main process -so `views://mainview/api/*` resolves. Shipping the current bundle gives a user a -window that can never sign in. - -## Route - -`views://mainview/api/auth/session` (should be -`https://canary.smithers.sh/api/auth/session`). - -## Repro output - -``` -$ bun 27.1.ts -app bundle: .../build/canary-macos-arm64/Smithers-canary.app ---- launcher output --- -Launcher starting on macos... -[LAUNCHER] Loaded identifier: sh.smithers.app, name: Smithers-canary, channel: canary -[LAUNCHER] Loading app code from flat files -Server started at http://localhost:50001 -Smithers app started! -ERROR ========== empty response for URL: views://mainview/api/auth/session ------------------------ -launched: true | api calls resolve against views:// : true -FAIL: the built canary app loads views://mainview/index.html, so every /api/* call resolves against the views:// scheme and returns empty — the app can never sign in. -(exit 1) -``` diff --git a/apps/ui/canary-repros/admin/27.1.ts b/apps/ui/canary-repros/admin/27.1.ts deleted file mode 100644 index 27385970..00000000 --- a/apps/ui/canary-repros/admin/27.1.ts +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Repro — checklist row 27.1 ("`bun run build:canary` produces a launchable - * app") for the Electrobun desktop build. - * - * The build SUCCEEDS and the app LAUNCHES, but the app it produces has no - * backend: `apps/ui/src/bun/index.ts` falls back to `views://mainview/index.html` - * for any channel other than `dev` unless `SMITHERS_APP_URL` is set, and - * `build:canary` sets nothing. Every relative `/api/*` fetch in the renderer - * then resolves against the `views://` scheme and returns empty — the first one - * the app makes fails on startup: - * - * ERROR ========== empty response for URL: views://mainview/api/auth/session - * - * So the shipped canary app opens a window that can never sign in. - * - * This repro drives the LOCAL build, not the canary origin — the desktop app is - * a native WKWebView with no CDP endpoint, so Playwright cannot attach to it. - * It asserts on the build output and the launcher's own stdout. - * - * bun 27.1.ts - * exit 1 while the bug is present, 0 once the built app reaches a backend. - */ -import { spawn } from "node:child_process"; -import { existsSync } from "node:fs"; -import { join } from "node:path"; - -const UI = "/Users/williamcory/flows/flows/apps/ui"; -const APP = join(UI, "build/canary-macos-arm64/Smithers-canary.app"); -const LAUNCHER = join(APP, "Contents/MacOS/launcher"); - -if (!existsSync(LAUNCHER)) { - console.error(`SETUP: no build at ${APP}. Run: cd ${UI} && bun run build:canary`); - process.exit(2); -} -console.log("app bundle:", APP); - -const log = await new Promise<string>((resolve) => { - const child = spawn(LAUNCHER, [], { cwd: join(APP, "Contents/MacOS"), env: { ...process.env, SMITHERS_APP_URL: "" } }); - let out = ""; - child.stdout.on("data", (chunk) => (out += String(chunk))); - child.stderr.on("data", (chunk) => (out += String(chunk))); - setTimeout(() => { - child.kill("SIGTERM"); - resolve(out); - }, 20_000); -}); -console.log("--- launcher output ---"); -console.log(log.trim()); -console.log("-----------------------"); - -const launched = log.includes("Smithers app started!"); -const noBackend = log.includes("empty response for URL: views://mainview/api/"); -console.log("launched:", launched, "| api calls resolve against views:// :", noBackend); - -if (launched && !noBackend) { - console.log("PASS — the built app launches and reaches a backend."); - process.exit(0); -} -if (!launched) console.error("FAIL: the built app did not launch."); -if (noBackend) { - console.error( - "FAIL: the built canary app loads views://mainview/index.html, so every /api/* call resolves against the views:// scheme and returns empty — the app can never sign in.", - ); -} -process.exit(1); diff --git a/apps/ui/canary-repros/admin/27.2.md b/apps/ui/canary-repros/admin/27.2.md deleted file mode 100644 index 7d0276db..00000000 --- a/apps/ui/canary-repros/admin/27.2.md +++ /dev/null @@ -1,57 +0,0 @@ -# 27.2 — the built app ships no icon - -Machine: macOS 25.2.0 arm64. Electrobun 1.18.1. -Tested: 2026-08-19, admin lane round 1 - -## Checklist row - -> **27.2** First launch: window size, title, and icon are right. - -## Steps - -1. `cd apps/ui && bun run build:canary` -2. Read `build/canary-macos-arm64/Smithers-canary.app/Contents/Info.plist`. -3. List `Contents/Resources/`. - -## Expected - -The bundle ships the icon its `Info.plist` declares. - -## Actual - -``` -Info.plist CFBundleName : Smithers-canary -Info.plist CFBundleIconFile: AppIcon -Contents/Resources : version.json, app, main.js, build.json -icon files present : (none) -``` - -There is no `.icns` and no `AppIcon` anywhere in the bundle -(`find … -iname "*.icns" -o -iname "*icon*"` returns nothing), so macOS renders -the generic application icon in the Dock, Finder, and ⌘-Tab. `electrobun.config.ts` -declares no icon either. - -Two other notes on this row, honestly scoped: - -- `CFBundleName` is `Smithers-canary` while the window title set in - `apps/ui/src/bun/index.ts` is `Smithers`. Whether the menu-bar name should - carry the channel suffix is a product decision. -- Window size (`1180x800` at `x:100, y:60`) and the window title **could not be - verified visually on this machine**. The Electrobun window is a native - WKWebView with no CDP/remote-debugging endpoint, so Playwright cannot attach; - AppleScript window queries need Accessibility permission for the terminal - (System Settings → Privacy & Security → Accessibility), which is a human - toggle; and full-screen capture on this shared machine captures the - operator's unrelated windows. Only the icon claim below is asserted. - -## Repro output - -``` -$ bun 27.2.ts -Info.plist CFBundleName : Smithers-canary -Info.plist CFBundleIconFile: AppIcon -Contents/Resources : version.json, app, main.js, build.json -icon files present : (none) -FAIL: Info.plist declares CFBundleIconFile "AppIcon" but Contents/Resources ships no icon — macOS renders the generic app icon. -(exit 1) -``` diff --git a/apps/ui/canary-repros/admin/27.2.ts b/apps/ui/canary-repros/admin/27.2.ts deleted file mode 100644 index 8936dbc0..00000000 --- a/apps/ui/canary-repros/admin/27.2.ts +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Repro — checklist row 27.2 ("First launch: window size, title, and icon are - * right") for the Electrobun desktop build. - * - * The produced app bundle declares `CFBundleIconFile: AppIcon` in Info.plist, - * but `Contents/Resources/` contains no icon file of any kind — no `.icns`, no - * `AppIcon` — so macOS falls back to the generic application icon in the Dock, - * the Finder, and the ⌘-Tab switcher. - * - * Window size (1180x800) and title ("Smithers") are declared in - * `apps/ui/src/bun/index.ts`; they could not be verified visually on this - * machine (see 27.2.md), so this repro asserts only the icon, which is a - * checkable property of the built artifact. - * - * bun 27.2.ts - * exit 1 while the bug is present, 0 once the bundle ships an icon. - */ -import { readdirSync, readFileSync, existsSync } from "node:fs"; -import { join } from "node:path"; - -const APP = "/Users/williamcory/flows/flows/apps/ui/build/canary-macos-arm64/Smithers-canary.app"; -const RESOURCES = join(APP, "Contents/Resources"); -const PLIST = join(APP, "Contents/Info.plist"); - -if (!existsSync(PLIST)) { - console.error(`SETUP: no build at ${APP}. Run: cd apps/ui && bun run build:canary`); - process.exit(2); -} - -const plist = readFileSync(PLIST, "utf8"); -const declared = /<key>CFBundleIconFile<\/key>\s*<string>([^<]*)<\/string>/.exec(plist)?.[1] ?? null; -const title = /<key>CFBundleName<\/key>\s*<string>([^<]*)<\/string>/.exec(plist)?.[1] ?? null; -console.log("Info.plist CFBundleName :", title); -console.log("Info.plist CFBundleIconFile:", declared); - -const resources = readdirSync(RESOURCES); -console.log("Contents/Resources :", resources.join(", ")); -const iconFiles = resources.filter((name) => /\.icns$/i.test(name) || /icon/i.test(name) || name === declared); -console.log("icon files present :", iconFiles.length === 0 ? "(none)" : iconFiles.join(", ")); - -if (declared !== null && iconFiles.length === 0) { - console.error( - `FAIL: Info.plist declares CFBundleIconFile "${declared}" but Contents/Resources ships no icon — macOS renders the generic app icon.`, - ); - process.exit(1); -} -console.log("PASS — the bundle ships the icon it declares."); diff --git a/apps/ui/canary-repros/admin/27.7.md b/apps/ui/canary-repros/admin/27.7.md deleted file mode 100644 index 1c65a7c8..00000000 --- a/apps/ui/canary-repros/admin/27.7.md +++ /dev/null @@ -1,53 +0,0 @@ -# 27.7 — the desktop updater is not configured - -Machine: macOS 25.2.0 arm64. Electrobun 1.18.1. -Tested: 2026-08-19, admin lane round 1 - -## Checklist row - -> **27.7** The updater path: confirm it is configured, and decide whether it is -> exercised before the alpha. - -## Steps - -1. `cd apps/ui && bun run build:canary` and read the build log. -2. Read `apps/ui/electrobun.config.ts`. - -## Expected - -The updater has a `build.baseUrl` so the build can generate patches and an -installed app has somewhere to check for updates. - -## Actual - -`electrobun.config.ts` declares `app`, `build.copy`, `build.watchIgnore`, and -per-platform `bundleCEF: false`. There is no `baseUrl`. The build says so -itself: - -``` -baseUrl: -generating a patch from the previous version... -No baseUrl configured, skipping patch generation -To enable patch generation, configure baseUrl in your electrobun.config -``` - -So no patch is produced and an installed app has no update channel. - -## Recommendation - -Do **not** exercise the updater before the alpha. It is unconfigured, the app -is unsigned and unnotarised (`skipping codesign` / `skipping notarization` -three times in the same build), and 27.1 shows the produced bundle cannot even -reach a backend. Fix 27.1 first, then sign, then configure `baseUrl` and test -one real patch round trip before any desktop build reaches a user. - -## Repro output - -``` -$ bun 27.7.ts -electrobun.config.ts declares build.baseUrl: false -artifacts/canary-macos-arm64-update.json: {"version":"0.0.1","hash":"28esux9g6b4hs","platform":"macos","arch":"arm64"} — a version manifest with no URL to fetch an update from -build:canary said: "No baseUrl configured, skipping patch generation / To enable patch generation, configure baseUrl in your electrobun.config" -FAIL: the updater is not configured — electrobun.config.ts sets no build.baseUrl, so the build skips patch generation and an installed app has no update channel. -(exit 1) -``` diff --git a/apps/ui/canary-repros/admin/27.7.ts b/apps/ui/canary-repros/admin/27.7.ts deleted file mode 100644 index 5a19064a..00000000 --- a/apps/ui/canary-repros/admin/27.7.ts +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Repro — checklist row 27.7 ("The updater path: confirm it is configured"). - * - * It is not. `apps/ui/electrobun.config.ts` declares no `build.baseUrl`, so - * `electrobun build --env=canary` skips patch generation and says so, and the - * generated `update.json` points at nothing a running app can fetch. An - * installed app therefore has no update channel. - * - * bun 27.7.ts - * exit 1 while the updater is unconfigured, 0 once a baseUrl is set. - */ -import { existsSync, readFileSync } from "node:fs"; -import { join } from "node:path"; - -const UI = "/Users/williamcory/flows/flows/apps/ui"; -const CONFIG = join(UI, "electrobun.config.ts"); -const ARTIFACTS = join(UI, "artifacts"); - -const config = readFileSync(CONFIG, "utf8"); -const hasBaseUrl = /baseUrl\s*:/.test(config); -console.log("electrobun.config.ts declares build.baseUrl:", hasBaseUrl); - -const updateJson = join(ARTIFACTS, "canary-macos-arm64-update.json"); -if (existsSync(updateJson)) { - console.log("artifacts/update.json:", readFileSync(updateJson, "utf8").trim().slice(0, 400)); -} else { - console.log("artifacts/update.json: (absent — run bun run build:canary first)"); -} - -console.log( - 'build:canary said: "No baseUrl configured, skipping patch generation / To enable patch generation, configure baseUrl in your electrobun.config"', -); - -if (hasBaseUrl) { - console.log("PASS — the updater has a baseUrl."); - process.exit(0); -} -console.error("FAIL: the updater is not configured — electrobun.config.ts sets no build.baseUrl, so the build skips patch generation and an installed app has no update channel."); -process.exit(1); diff --git a/apps/ui/canary-repros/admin/28.10.md b/apps/ui/canary-repros/admin/28.10.md deleted file mode 100644 index 784f7648..00000000 --- a/apps/ui/canary-repros/admin/28.10.md +++ /dev/null @@ -1,54 +0,0 @@ -# 28.10 — the app has no favicon - -Origin: <https://canary.smithers.sh> (bundle `assets/index-BHHXuMoZ.js`) -Tested: 2026-08-19, admin lane round 1 - -## Checklist row - -> **28.10** The browser tab title and favicon are right. - -## Steps - -1. Load <https://canary.smithers.sh>. -2. Read `document.title` and `document.querySelectorAll("link[rel*='icon']")`. -3. `GET /favicon.ico`. - -## Expected - -Title "Smithers" and a Smithers favicon in the tab. - -## Actual - -``` -document.title : "Smithers" -link[rel*=icon] : [] -GET /favicon.ico : {"status":404,"bytes":0} -``` - -The title is right. There is no favicon anywhere: the served `<head>` is - -```html -<meta charset="UTF-8" /> -<meta name="viewport" content="width=device-width, initial-scale=1.0" /> -<title>Smithers - - -``` - -so every tab, bookmark, and history entry shows the browser's default blank -icon. `apps/ui/public/` ships no icon for vite to copy. - -## Route / selector - -`GET /` (``), `GET /favicon.ico` on `smithers-mvp-web`. - -## Repro output - -``` -$ PROF=/tmp/canary-admin-profile bun 28.10.ts -document.title : "Smithers" -link[rel*=icon] : [] -GET /favicon.ico : {"status":404,"bytes":0} -FAIL: no favicon: the head declares no link[rel*=icon] and GET /favicon.ico is a 404, so the tab shows the browser's default icon. -(exit 1) -``` diff --git a/apps/ui/canary-repros/admin/28.10.ts b/apps/ui/canary-repros/admin/28.10.ts deleted file mode 100644 index 567ed728..00000000 --- a/apps/ui/canary-repros/admin/28.10.ts +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Repro — checklist row 28.10 ("The browser tab title and favicon are right") - * against https://canary.smithers.sh. - * - * The title is right ("Smithers"). There is no favicon at all: the document - * head declares no `link[rel*=icon]`, and `/favicon.ico` 404s, so every tab - * shows the browser's default blank page icon. - * - * PROF=/tmp/canary-admin-profile bun 28.10.ts - * exit 1 while the bug is present, 0 once a favicon is served. - */ -import { open } from "./_lib"; - -const { context, page } = await open(); -const title = await page.title(); -const links = await page.evaluate(() => - Array.from(document.querySelectorAll("link[rel*='icon']")).map((link) => ({ - rel: link.getAttribute("rel"), - href: (link as HTMLLinkElement).href, - })), -); -const ico = await page.evaluate(async () => { - const response = await fetch("/favicon.ico"); - return { status: response.status, bytes: (await response.blob()).size }; -}); -console.log("document.title :", JSON.stringify(title)); -console.log("link[rel*=icon] :", JSON.stringify(links)); -console.log("GET /favicon.ico :", JSON.stringify(ico)); -await context.close(); - -const failures: Array = []; -if (title !== "Smithers") failures.push(`the tab title is ${JSON.stringify(title)}, not "Smithers".`); -if (links.length === 0 && ico.status !== 200) { - failures.push("no favicon: the head declares no link[rel*=icon] and GET /favicon.ico is a 404, so the tab shows the browser's default icon."); -} -if (failures.length === 0) { - console.log("PASS — title and favicon are right."); - process.exit(0); -} -for (const failure of failures) console.error(`FAIL: ${failure}`); -process.exit(1); diff --git a/apps/ui/canary-repros/admin/28.11.md b/apps/ui/canary-repros/admin/28.11.md deleted file mode 100644 index fdb92ca9..00000000 --- a/apps/ui/canary-repros/admin/28.11.md +++ /dev/null @@ -1,66 +0,0 @@ -# 28.11 — a normal session logs a console error - -Origin: (bundle `assets/index-BHHXuMoZ.js`) -Account: `codeplanesmithers` -Tested: 2026-08-19, admin lane round 1 - -## Checklist rows - -> **28.11** No console errors or warnings during a normal session. -> **28.12** No network request 4xx/5xx during a normal session. - -Both rows fail on the same request, so `28.11.ts` and `28.12.ts` are the same -script and assert both. - -## Steps - -1. Sign in on . -2. Reload. (Before 2026-08-19 this step also had to select the default - engine; there is one backend now, so there is nothing to select.) -3. Run the everyday reads: `/billing.balance`, `/repos.list`, - `/notifications`, `/help`, `/connectors`, `/world`, `/keys.list`. -4. Send one chat turn. -5. Collect `console` errors/warnings, `pageerror`, and every response >= 400. - -## Expected - -Zero console errors or warnings, zero 4xx/5xx. - -## Actual - -``` -28.11 console errors/warnings: ["error: Failed to load resource: the server responded with a status of 404 ()"] -28.12 responses >= 400 : ["404 GET /api/user/byok-keys"] -``` - -`/keys.list` calls `GET /api/user/byok-keys`, which the product Worker -`smithers-mvp-web` does not serve. Two consequences: - -- the console carries an error on every session where a user opens their keys; -- the raw upstream body is shown verbatim to the user. The toast reads - `/keys.list didn't run` followed by the literal string - **`404 page not found`** — see `28.5.md`. - -The same "didn't run" toast was also observed for `/issues.list` and -`/prs.list` during the surface sweep; those belong to §13 and §14 and are not -graded here. - -## Route / selector - -`GET /api/user/byok-keys` on `smithers-mvp-web`, triggered by `/keys.list`. - -## Screenshot - -`/tmp/canary-28.11.png` - -## Repro output - -``` -$ PROF=/tmp/canary-admin-profile bun 28.11.ts -28.11 console errors/warnings: ["error: Failed to load resource: the server responded with a status of 404 ()"] -28.12 responses >= 400 : ["404 GET /api/user/byok-keys"] -raw upstream body shown to the user: true -FAIL: 1 console error/warning in a normal session -FAIL: 1 response >= 400 in a normal session: 404 GET /api/user/byok-keys -(exit 1) -``` diff --git a/apps/ui/canary-repros/admin/28.11.ts b/apps/ui/canary-repros/admin/28.11.ts deleted file mode 100644 index 20db2b36..00000000 --- a/apps/ui/canary-repros/admin/28.11.ts +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Repro — checklist rows 28.11 ("No console errors or warnings during a normal - * session") and 28.12 ("No network request 4xx/5xx during a normal session") - * against https://canary.smithers.sh. - * - * A normal signed-in session that runs the everyday read flows produces one - * console error and one 4xx: `/keys.list` calls - * `GET /api/user/byok-keys`, which the product Worker does not serve. The - * upstream's raw body — the literal string "404 page not found" — is then shown - * to the user in the toast. - * - * "Normal session" here means the DEFAULT agent backend (proxy). The chain - * backend produces six more errors (501 on /api/model/stream); that is 26.1, - * not this row. - * - * PROF=/tmp/canary-admin-profile bun 28.11.ts - * exit 1 while the bug is present, 0 once the session is clean. - */ -import { open, run, body } from "./_lib"; - -const { context, page } = await open(); - -await page.reload({ waitUntil: "domcontentloaded" }); -await page.waitForTimeout(6000); - -// Everything below is the measured window. -const consoleMessages: Array = []; -const failedRequests: Array = []; -page.on("console", (message) => { - if (message.type() === "error" || message.type() === "warning") consoleMessages.push(`${message.type()}: ${message.text()}`); -}); -page.on("pageerror", (error) => consoleMessages.push(`pageerror: ${String(error)}`)); -page.on("response", (response) => { - if (response.status() >= 400) failedRequests.push(`${response.status()} ${response.request().method()} ${new URL(response.url()).pathname}`); -}); - -for (const flow of ["/billing.balance", "/repos.list", "/notifications", "/help", "/connectors", "/world", "/keys.list"]) { - await run(page, flow, 6000); -} -const composer = page.locator("textarea.sui-chat-composer-input"); -await composer.click(); -await composer.fill("Say PONG."); -await page.keyboard.press("Enter"); -await page.waitForTimeout(30_000); - -const text = await body(page); -console.log("28.11 console errors/warnings:", JSON.stringify([...new Set(consoleMessages)], null, 1)); -console.log("28.12 responses >= 400 :", JSON.stringify([...new Set(failedRequests)], null, 1)); -console.log('raw upstream body shown to the user:', text.includes("404 page not found")); -await page.screenshot({ path: "/tmp/canary-28.11.png", fullPage: true }); -console.log("screenshot: /tmp/canary-28.11.png"); -await context.close(); - -const failures: Array = []; -if (consoleMessages.length > 0) failures.push(`${consoleMessages.length} console error/warning in a normal session: ${[...new Set(consoleMessages)].join(" | ")}`); -if (failedRequests.length > 0) failures.push(`${failedRequests.length} response >= 400 in a normal session: ${[...new Set(failedRequests)].join(" | ")}`); -if (failures.length === 0) { - console.log("PASS — the session is clean."); - process.exit(0); -} -for (const failure of failures) console.error(`FAIL: ${failure}`); -process.exit(1); diff --git a/apps/ui/canary-repros/admin/28.12.md b/apps/ui/canary-repros/admin/28.12.md deleted file mode 100644 index 1dd4244f..00000000 --- a/apps/ui/canary-repros/admin/28.12.md +++ /dev/null @@ -1,66 +0,0 @@ -# 28.12 — a normal session makes a 404 request - -Origin: (bundle `assets/index-BHHXuMoZ.js`) -Account: `codeplanesmithers` -Tested: 2026-08-19, admin lane round 1 - -## Checklist rows - -> **28.11** No console errors or warnings during a normal session. -> **28.12** No network request 4xx/5xx during a normal session. - -Both rows fail on the same request, so `28.11.ts` and `28.12.ts` are the same -script and assert both. - -## Steps - -1. Sign in on . -2. Reload. (Before 2026-08-19 this step also had to select the default - engine; there is one backend now, so there is nothing to select.) -3. Run the everyday reads: `/billing.balance`, `/repos.list`, - `/notifications`, `/help`, `/connectors`, `/world`, `/keys.list`. -4. Send one chat turn. -5. Collect `console` errors/warnings, `pageerror`, and every response >= 400. - -## Expected - -Zero console errors or warnings, zero 4xx/5xx. - -## Actual - -``` -28.11 console errors/warnings: ["error: Failed to load resource: the server responded with a status of 404 ()"] -28.12 responses >= 400 : ["404 GET /api/user/byok-keys"] -``` - -`/keys.list` calls `GET /api/user/byok-keys`, which the product Worker -`smithers-mvp-web` does not serve. Two consequences: - -- the console carries an error on every session where a user opens their keys; -- the raw upstream body is shown verbatim to the user. The toast reads - `/keys.list didn't run` followed by the literal string - **`404 page not found`** — see `28.5.md`. - -The same "didn't run" toast was also observed for `/issues.list` and -`/prs.list` during the surface sweep; those belong to §13 and §14 and are not -graded here. - -## Route / selector - -`GET /api/user/byok-keys` on `smithers-mvp-web`, triggered by `/keys.list`. - -## Screenshot - -`/tmp/canary-28.11.png` - -## Repro output - -``` -$ PROF=/tmp/canary-admin-profile bun 28.12.ts -28.11 console errors/warnings: ["error: Failed to load resource: the server responded with a status of 404 ()"] -28.12 responses >= 400 : ["404 GET /api/user/byok-keys"] -raw upstream body shown to the user: true -FAIL: 1 console error/warning in a normal session -FAIL: 1 response >= 400 in a normal session: 404 GET /api/user/byok-keys -(exit 1) -``` diff --git a/apps/ui/canary-repros/admin/28.12.ts b/apps/ui/canary-repros/admin/28.12.ts deleted file mode 100644 index 20db2b36..00000000 --- a/apps/ui/canary-repros/admin/28.12.ts +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Repro — checklist rows 28.11 ("No console errors or warnings during a normal - * session") and 28.12 ("No network request 4xx/5xx during a normal session") - * against https://canary.smithers.sh. - * - * A normal signed-in session that runs the everyday read flows produces one - * console error and one 4xx: `/keys.list` calls - * `GET /api/user/byok-keys`, which the product Worker does not serve. The - * upstream's raw body — the literal string "404 page not found" — is then shown - * to the user in the toast. - * - * "Normal session" here means the DEFAULT agent backend (proxy). The chain - * backend produces six more errors (501 on /api/model/stream); that is 26.1, - * not this row. - * - * PROF=/tmp/canary-admin-profile bun 28.11.ts - * exit 1 while the bug is present, 0 once the session is clean. - */ -import { open, run, body } from "./_lib"; - -const { context, page } = await open(); - -await page.reload({ waitUntil: "domcontentloaded" }); -await page.waitForTimeout(6000); - -// Everything below is the measured window. -const consoleMessages: Array = []; -const failedRequests: Array = []; -page.on("console", (message) => { - if (message.type() === "error" || message.type() === "warning") consoleMessages.push(`${message.type()}: ${message.text()}`); -}); -page.on("pageerror", (error) => consoleMessages.push(`pageerror: ${String(error)}`)); -page.on("response", (response) => { - if (response.status() >= 400) failedRequests.push(`${response.status()} ${response.request().method()} ${new URL(response.url()).pathname}`); -}); - -for (const flow of ["/billing.balance", "/repos.list", "/notifications", "/help", "/connectors", "/world", "/keys.list"]) { - await run(page, flow, 6000); -} -const composer = page.locator("textarea.sui-chat-composer-input"); -await composer.click(); -await composer.fill("Say PONG."); -await page.keyboard.press("Enter"); -await page.waitForTimeout(30_000); - -const text = await body(page); -console.log("28.11 console errors/warnings:", JSON.stringify([...new Set(consoleMessages)], null, 1)); -console.log("28.12 responses >= 400 :", JSON.stringify([...new Set(failedRequests)], null, 1)); -console.log('raw upstream body shown to the user:', text.includes("404 page not found")); -await page.screenshot({ path: "/tmp/canary-28.11.png", fullPage: true }); -console.log("screenshot: /tmp/canary-28.11.png"); -await context.close(); - -const failures: Array = []; -if (consoleMessages.length > 0) failures.push(`${consoleMessages.length} console error/warning in a normal session: ${[...new Set(consoleMessages)].join(" | ")}`); -if (failedRequests.length > 0) failures.push(`${failedRequests.length} response >= 400 in a normal session: ${[...new Set(failedRequests)].join(" | ")}`); -if (failures.length === 0) { - console.log("PASS — the session is clean."); - process.exit(0); -} -for (const failure of failures) console.error(`FAIL: ${failure}`); -process.exit(1); diff --git a/apps/ui/canary-repros/admin/28.2.md b/apps/ui/canary-repros/admin/28.2.md deleted file mode 100644 index 1f499d73..00000000 --- a/apps/ui/canary-repros/admin/28.2.md +++ /dev/null @@ -1,58 +0,0 @@ -# 28.2 — the notifications empty state names no next step - -Origin: (bundle `assets/index-BHHXuMoZ.js`) -Account: `codeplanesmithers` -Tested: 2026-08-19, admin lane round 1 - -## Checklist row - -> **28.2** Every empty state names the next step. - -## Steps - -1. Sign in on . -2. `/notifications`. -3. Read the `Notifications` card. - -## Expected - -The empty state names what the user can do next, the way the chat empty state -does. - -## Actual - -``` -notifications card text: "Notifications\nDONE\nnotifications · 02:50 AM\nNothing new." -``` - -"Nothing new." and nothing else. No verb, no flow name, no route out. The user -is told a fact and given no move. - -For contrast, the chat empty state on the same build does it right: - -``` -chat empty state: "Nothing here yet Ask Smithers anything to get started." -``` - -Two other empty states seen in the same sweep are acceptable: `0 open issues` -inside a repo row (a count in context, not a dead end) and `None` as one of the -`All / None` selectors in the repo chooser (a control, not an empty state). - -## Route / selector - -`section.smithers-card[aria-label^="Notifications"]`, rendered by -`/notifications`. - -## Screenshot - -`/tmp/canary-28.2.png` - -## Repro output - -``` -$ PROF=/tmp/canary-admin-profile bun 28.2.ts -notifications card text: "Notifications\nDONE\nnotifications · 02:50 AM\nNothing new." -chat empty state: "$543 Nothing here yet Ask Smithers anything to get started. ..." -FAIL: the notifications empty state is "Notifications\nDONE\nnotifications · 02:50 AM\nNothing new." — it names no next step. -(exit 1) -``` diff --git a/apps/ui/canary-repros/admin/28.2.ts b/apps/ui/canary-repros/admin/28.2.ts deleted file mode 100644 index b2a25aa9..00000000 --- a/apps/ui/canary-repros/admin/28.2.ts +++ /dev/null @@ -1,63 +0,0 @@ -/* - * Repro — checklist row 28.2 ("Every empty state names the next step") against - * https://canary.smithers.sh. - * - * The chat empty state gets this right: "Nothing here yet / Ask Smithers - * anything to get started." The notifications empty state does not: it says - * "Nothing new." and stops, naming no next step. - * - * PROF=/tmp/canary-admin-profile bun 28.2.ts - * exit 1 while the bug is present, 0 once the empty state names a next step. - */ -import { open, run } from "./_lib"; - -const { context, page } = await open(); -await run(page, "/notifications", 8000); - -const card = await page.evaluate(() => { - const element = Array.from(document.querySelectorAll("section.smithers-card")).find((c) => - (c.getAttribute("aria-label") ?? "").startsWith("Notifications"), - ) as HTMLElement | undefined; - return element === undefined ? null : { label: element.getAttribute("aria-label"), text: element.innerText.trim() }; -}); -if (card === null) { - console.error("SETUP: no Notifications card rendered."); - await context.close(); - process.exit(2); -} -console.log("notifications card text:", JSON.stringify(card.text)); - -/* - * The row grades an EMPTY state. A card with rows in it has nothing empty to - * name a next step for, so measuring one against this rule reports a defect - * that is not there. (Empty the inbox — or point the account at one with no - * notifications — to grade this row.) - */ -const rows = await page.locator('[data-kind="notifications"] .world-card-row').count(); -if (rows > 0) { - console.log(`SKIP: the inbox holds ${rows} notification(s), so its empty state is not on screen.`); - await context.close(); - process.exit(2); -} - -// For contrast, the chat empty state, which does name a next step. -const reset = page.locator('button[aria-label="Reset conversation"]').first(); -if (await reset.isVisible().catch(() => false)) { - await reset.click(); - await page.waitForTimeout(3000); - const empty = await page.locator("body").innerText(); - console.log("chat empty state:", JSON.stringify(empty.replace(/\s+/g, " ").slice(0, 160))); -} -await page.screenshot({ path: "/tmp/canary-28.2.png", fullPage: true }); -console.log("screenshot: /tmp/canary-28.2.png"); -await context.close(); - -// A next step names an action the user can take: a flow, a verb, an imperative. -const body = card.text.replace(card.label ?? "", ""); -const namesNextStep = /\/[a-z]+[.a-z-]*|Ask |Try |Choose |Connect |Add |Run |Open |Sign in|to get started/.test(body); -if (namesNextStep) { - console.log("PASS — the empty state names a next step."); - process.exit(0); -} -console.error(`FAIL: the notifications empty state is ${JSON.stringify(card.text)} — it names no next step.`); -process.exit(1); diff --git a/apps/ui/canary-repros/admin/28.3.md b/apps/ui/canary-repros/admin/28.3.md deleted file mode 100644 index 6b4c57e1..00000000 --- a/apps/ui/canary-repros/admin/28.3.md +++ /dev/null @@ -1,63 +0,0 @@ -# 28.3 — finished reads stay badged PENDING - -Origin: (bundle `assets/index-BHHXuMoZ.js`) -Account: `codeplanesmithers` (`admin:true` via the ADMIN_LOGINS fixture) -Tested: 2026-08-19, admin lane round 1 - -## Checklist row - -> **28.3** Every loading state is distinguishable from a dead frame. - -## Steps - -1. Sign in as an admin. -2. Run `/admin.requests`, `/admin.feedback`, `/admin.health`. -3. Wait 15 s so every read has settled. -4. Read `data-status` and the status badge on every `section.smithers-card`. - -## Expected - -A finished read is badged DONE (as `Balance` and `Notifications` are). A read -still in flight is badged PENDING. The two are distinguishable. - -## Actual - -``` -{"label":"What I found","status":"active","badge":"WAITING FOR APPROVAL","hasContent":true} -{"label":"Balance","status":"active","badge":"DONE","hasContent":false} -{"label":"Color themes","status":"active","badge":"PENDING","hasContent":true} -{"label":"Notifications","status":"active","badge":"DONE","hasContent":false} -{"label":"Choose the repositories Smithers watches","status":"active","badge":"WAITING FOR APPROVAL","hasContent":true} -{"label":"Request-access queue — 1 waiting","status":"active","badge":"PENDING","hasContent":false} -{"label":"Recommendation feedback — 24 events","status":"active","badge":"PENDING","hasContent":true} -{"label":"What failed overnight?","status":"active","badge":"PENDING","hasContent":true} -``` - -`Recommendation feedback`, `What failed overnight?`, and `Color themes` have -all rendered their full content — 24 feedback events, three live `healthz` -lines, nine theme swatches — and are still badged **PENDING**, 15 s after the -read completed and permanently thereafter. `data-status` stays `"active"`. - -So the same badge means "still loading" on one card and "finished, nothing more -coming" on another. A read that genuinely hung would look identical. - -The `Balance` and `Notifications` cards do settle to DONE, which is what the -others should do. - -## Route / selector - -`section.smithers-card[data-status]` and its status badge, for the -`request-queue`, `reco-log`, `admin-health`, and `theme-picker` card kinds. - -## Screenshot - -`/tmp/canary-28.3.png` - -## Repro output - -``` -$ PROF=/tmp/canary-admin-profile bun 28.3.ts -... (table above) ... -FAIL: 3 cards finished the read but stayed badged PENDING with data-status="active": Color themes | Recommendation feedback — 24 events | What failed overnight? -(exit 1) -``` diff --git a/apps/ui/canary-repros/admin/28.3.ts b/apps/ui/canary-repros/admin/28.3.ts deleted file mode 100644 index cd435e0b..00000000 --- a/apps/ui/canary-repros/admin/28.3.ts +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Repro — checklist row 28.3 ("Every loading state is distinguishable from a - * dead frame") against https://canary.smithers.sh. - * - * The admin read cards finish their read and then stay badged PENDING forever, - * with `data-status="active"`. A user cannot tell a finished read from one that - * is still loading, or from a frame that died mid-read. - * - * PROF=/tmp/canary-admin-profile bun 28.3.ts - * exit 1 while the bug is present, 0 once finished reads settle. - * - * Fixture: the session must be admin (identity worker ADMIN_LOGINS). - */ -import { open, session, run } from "./_lib"; - -const { context, page } = await open(); -const who = await session(page); -if (who.admin !== true) { - console.error("SETUP: the session is not admin — add the login to the identity worker's ADMIN_LOGINS."); - await context.close(); - process.exit(2); -} - -for (const flow of ["/admin.requests", "/admin.feedback", "/admin.health"]) await run(page, flow, 9000); -// A finished read has had every chance to settle. -await page.waitForTimeout(15_000); - -const cards = await page.evaluate(() => - Array.from(document.querySelectorAll("section.smithers-card")).map((card) => { - const element = card as HTMLElement; - return { - label: element.getAttribute("aria-label") ?? "", - status: element.getAttribute("data-status"), - badge: (element.innerText.match(/PENDING|DONE|FAILED|RUNNING|WAITING FOR APPROVAL/) ?? [""])[0], - hasContent: element.innerText.length > 120, - }; - }), -); -const expectedKinds = ["request-queue", "reco-log", "admin-health"]; -const renderedKinds = await page.locator("section.smithers-card").evaluateAll((nodes) => - nodes.map((node) => node.getAttribute("data-kind")), -); -for (const card of cards) console.log(JSON.stringify(card)); -await page.screenshot({ path: "/tmp/canary-28.3.png", fullPage: true }); -console.log("screenshot: /tmp/canary-28.3.png"); -await context.close(); - -const stuck = cards.filter((card) => card.badge === "PENDING" && card.hasContent); -const missing = expectedKinds.filter((kind) => !renderedKinds.includes(kind)); -if (missing.length > 0) { - console.error(`SETUP: expected completed admin cards did not render: ${missing.join(", ")}`); - process.exit(2); -} -if (stuck.length === 0) { - console.log("PASS — no finished read is still badged PENDING."); - process.exit(0); -} -console.error( - `FAIL: ${stuck.length} card${stuck.length === 1 ? "" : "s"} finished the read but stayed badged PENDING with data-status="active": ${stuck.map((c) => c.label).join(" | ")}`, -); -process.exit(1); diff --git a/apps/ui/canary-repros/admin/28.4.md b/apps/ui/canary-repros/admin/28.4.md deleted file mode 100644 index 0f3a8739..00000000 --- a/apps/ui/canary-repros/admin/28.4.md +++ /dev/null @@ -1,68 +0,0 @@ -# 28.4 — "Reset conversation" destroys the transcript with no confirmation - -Origin: (bundle `assets/index-BHHXuMoZ.js`) -Account: `codeplanesmithers` -Tested: 2026-08-19, admin lane round 1 - -## Checklist row - -> **28.4** Every destructive action confirms, and the confirm names the object -> ("Delete ?"). - -## Steps - -1. Sign in and build a transcript (`/billing.balance`, `/repos.list`, `/help`). -2. Click the header button `button[aria-label="Reset conversation"]`. - -## Expected - -A confirm that names what is about to be lost, e.g. "Clear this conversation? -20 messages will be discarded." - -## Actual - -One click and the whole transcript is gone. No dialog of any kind: - -``` -transcript before: 9125 chars, 20 messages -dialogs shown after the click: 0 -transcript after : 187 chars, 0 messages -``` - -`[role="dialog"], [role="alertdialog"]` count is 0 both before and after. The -view drops straight to the empty state ("Nothing here yet / Ask Smithers -anything to get started."). There is no undo. - -The button's own tooltip is the only warning, and it does not warn — the -registered flow behind it (`reset`, admin-only) describes itself as "Start a -fresh conversation (dev tooling — nothing is kept)", so the destructiveness is -known and deliberate; what is missing is the confirm. - -Two related notes from the same sweep: - -- The only other destructive-looking affordance found on the main surfaces is - `data-flow="reco.dismiss"` ("Not now"), which is reversible and correctly - needs no confirm. -- The admin allowlist removals (`/admin.allowlist.remove <login>`) also take - effect with no confirmation, and removing your own login logs you out of the - product on the next reload. Same row, same fix. - -## Route / selector - -`button[aria-label="Reset conversation"]` in the app header. - -## Screenshot - -`/tmp/canary-28.4.png` - -## Repro output - -``` -$ PROF=/tmp/canary-admin-profile bun 28.4.ts -transcript before: 9125 chars, 20 messages -dialogs shown after the click: 0 -transcript after : 187 chars, 0 messages -visible now: $543 Nothing here yet Ask Smithers anything to get started. ... -FAIL: "Reset conversation" destroyed the transcript (20 -> 0 messages) on one click, with no confirmation dialog. -(exit 1) -``` diff --git a/apps/ui/canary-repros/admin/28.4.ts b/apps/ui/canary-repros/admin/28.4.ts deleted file mode 100644 index e6e91d2c..00000000 --- a/apps/ui/canary-repros/admin/28.4.ts +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Repro — checklist row 28.4 ("Every destructive action confirms, and the - * confirm names the object") against https://canary.smithers.sh. - * - * The header's "Reset conversation" button wiped the whole transcript on a - * single click. No dialog, no confirm, no undo — 16,579 characters of - * conversation to 129 in one click. It asks first now, and the confirm counts - * what goes. - * - * PROF=/tmp/canary-admin-profile bun 28.4.ts - * exit 1 while the bug is present, 0 once the action confirms. - */ -import { open, run, body } from "./_lib"; - -const { context, page } = await open(); - -// Build a transcript worth losing. -for (const flow of ["/billing.balance", "/repos.list", "/help"]) await run(page, flow, 5000); -const before = await body(page); -const messagesBefore = await page.locator("[data-role]").count(); -console.log("transcript before:", before.length, "chars,", messagesBefore, "messages"); - -const reset = page.locator('button[aria-label="Reset conversation"]').first(); -if (!(await reset.isVisible().catch(() => false))) { - console.error('SETUP: no button[aria-label="Reset conversation"] in the header.'); - await context.close(); - process.exit(2); -} -await reset.click(); -await page.waitForTimeout(3000); - -const dialogs = await page.locator('[role="dialog"], [role="alertdialog"]').count(); -const dialogText = - dialogs === 0 ? "" : await page.locator('[role="dialog"], [role="alertdialog"]').first().innerText(); -const after = await body(page); -const messagesAfter = await page.locator("[data-role]").count(); -console.log("dialogs shown after the click:", dialogs); -console.log("transcript after :", after.length, "chars,", messagesAfter, "messages"); -console.log("visible now:", after.replace(/\s+/g, " ").slice(0, 200)); -console.log("the confirm reads:", JSON.stringify(dialogText)); -await page.screenshot({ path: "/tmp/canary-28.4.png", fullPage: true }); -console.log("screenshot: /tmp/canary-28.4.png"); -await context.close(); - -/* - * The row is "every destructive action CONFIRMS". A press that raises a dialog - * and destroys nothing yet is the passing state, not a setup failure — the - * original check read it as one because it was written against a build where - * the only outcome was destruction. - */ -const destroyed = messagesAfter < messagesBefore; -if (dialogs === 0) { - console.error( - destroyed - ? `FAIL: "Reset conversation" destroyed the transcript (${messagesBefore} -> ${messagesAfter} messages) on one click, with no confirmation dialog.` - : 'FAIL: "Reset conversation" raised no confirmation dialog.', - ); - process.exit(1); -} -if (destroyed) { - console.error("FAIL: the transcript was destroyed even though a dialog was shown — the confirm did not gate it."); - process.exit(1); -} -const named = await (async () => { - const text = dialogText; - return /\d+\s+message|conversation/i.test(text); -})(); -if (!named) { - console.error(`FAIL: the confirm names no object — it reads "${dialogText}".`); - process.exit(1); -} -console.log("PASS — the destructive action confirms first, and the confirm names what goes."); diff --git a/apps/ui/canary-repros/admin/28.5.md b/apps/ui/canary-repros/admin/28.5.md deleted file mode 100644 index ad5d2d2f..00000000 --- a/apps/ui/canary-repros/admin/28.5.md +++ /dev/null @@ -1,61 +0,0 @@ -# 28.5 — the raw upstream body "404 page not found" is shown to the user - -Origin: <https://canary.smithers.sh> (bundle `assets/index-BHHXuMoZ.js`) -Account: `codeplanesmithers` -Tested: 2026-08-19, admin lane round 1 - -## Checklist row - -> **28.5** No placeholder, lorem, TODO, or debug string is visible anywhere. - -## Steps - -1. Sign in on <https://canary.smithers.sh>. -2. Run `/keys.list` (and the other everyday reads) from - `textarea.sui-chat-composer-input`. -3. Read the toast. - -## Expected - -No raw upstream or debug text anywhere in the UI. - -## Actual - -No placeholder, lorem, or TODO string was found — a 192-line sweep across -`/keys.list`, `/world`, `/connectors`, `/notifications`, `/help`, -`/repos.list`, and `/flows` is clean on those. - -One debug string is visible. `/keys.list` calls `GET /api/user/byok-keys`, -which the product Worker does not serve, and the upstream's raw body reaches -the toast verbatim: - -``` -/keys.list didn't run -404 page not found -``` - -`404 page not found` is an upstream's plain-text body, not a user-facing -message. The toast's dismiss control carries -`aria-label="Dismiss: /keys.list didn't run"`, so the failure surface itself is -built correctly — only the message body is unsanitised. - -Root cause of the 404 is the same as `28.11.md` / `28.12.md`. - -## Route / selector - -`GET /api/user/byok-keys` on `smithers-mvp-web`, rendered into the toast by -`/keys.list`. - -## Screenshot - -`/tmp/canary-28.5.png` - -## Repro output - -``` -$ PROF=/tmp/canary-admin-profile bun 28.5.ts -swept 192 distinct user-facing lines - visible debug string: "404 page not found" -FAIL: 1 raw debug string is rendered to the user. -(exit 1) -``` diff --git a/apps/ui/canary-repros/admin/28.5.ts b/apps/ui/canary-repros/admin/28.5.ts deleted file mode 100644 index 92696052..00000000 --- a/apps/ui/canary-repros/admin/28.5.ts +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Repro — checklist row 28.5 ("No placeholder, lorem, TODO, or debug string is - * visible anywhere") against https://canary.smithers.sh. - * - * No TODO, lorem, or placeholder text was found in a 193-line sweep of 15 - * surfaces. One debug string IS visible: when `/keys.list` fails, the product - * renders the upstream's raw body verbatim — the literal string - * "404 page not found" — in the toast under "/keys.list didn't run". - * - * PROF=/tmp/canary-admin-profile bun 28.5.ts - * exit 1 while the bug is present, 0 once the raw body stops reaching the UI. - */ -import { open, run, body } from "./_lib"; - -const DEBUG_STRINGS = [ - "404 page not found", - "500 Internal Server Error", - "[object Object]", - "undefined", - "TODO", - "FIXME", - "lorem ipsum", -]; - -const { context, page } = await open(); -const seen = new Set<string>(); -const snapshot = async (): Promise<void> => { - for (const line of (await body(page)).split("\n").map((l) => l.trim()).filter((l) => l !== "")) seen.add(line); -}; -await snapshot(); -for (const flow of ["/keys.list", "/world", "/connectors", "/notifications", "/help", "/repos.list", "/flows"]) { - await run(page, flow, 6000); - await snapshot(); -} -const lines = [...seen]; -console.log(`swept ${lines.length} distinct user-facing lines`); - -const hits = lines.filter((line) => DEBUG_STRINGS.some((needle) => line.includes(needle))); -for (const hit of hits) console.log(" visible debug string:", JSON.stringify(hit.slice(0, 140))); -await page.screenshot({ path: "/tmp/canary-28.5.png", fullPage: true }); -console.log("screenshot: /tmp/canary-28.5.png"); -await context.close(); - -if (hits.length === 0) { - console.log("PASS — no placeholder or debug string is visible."); - process.exit(0); -} -console.error(`FAIL: ${hits.length} raw debug string is rendered to the user.`); -process.exit(1); diff --git a/apps/ui/canary-repros/admin/28.6.md b/apps/ui/canary-repros/admin/28.6.md deleted file mode 100644 index 592bb7be..00000000 --- a/apps/ui/canary-repros/admin/28.6.md +++ /dev/null @@ -1,52 +0,0 @@ -# 28.6 — the request-access queue row runs the login into the date - -Origin: <https://canary.smithers.sh> (bundle `assets/index-BHHXuMoZ.js`) -Account: `codeplanesmithers` (`admin:true` via the ADMIN_LOGINS fixture) -Tested: 2026-08-19, admin lane round 1 - -## Checklist row - -> **28.6** Spacing and alignment are consistent across cards, panes, and the -> composer. - -## Steps - -1. Sign in as an admin. -2. `/admin.requests`. -3. Measure `li.queue-row` — the gap between `.queue-login` and `.queue-at`. - -## Expected - -The login, the date, and the Approve button are visually separated. - -## Actual - -``` -rendered text: "codeplanesmithers2026-08-19Approve" -gap between .queue-login and .queue-at: 0 px -html: <span class="queue-login">codeplanesmithers</span><span class="queue-at">2026-08-19</span><button ...>Approve</button> -``` - -Three inline elements with no margin, gap, or separator between them, so the -row reads as one run-on token. This is the only spacing defect found in the -sweep; cards, panes, and the composer are otherwise consistent, and layout -shift is negligible (CLS 0.00006 when a card arrives — see 28.7). - -## Route / selector - -`li.queue-row > span.queue-login` + `span.queue-at` in the `request-queue` -card, rendered by `/admin.requests`. - -## Screenshot - -`/tmp/canary-28.6.png` - -## Repro output - -``` -$ PROF=/tmp/canary-admin-profile bun 28.6.ts -rendered text: "codeplanesmithers2026-08-19Approve" -gap between .queue-login and .queue-at: 0 px -FAIL: the queue row runs the login into the date — "codeplanesmithers2026-08-19Approve" (gap 0px between .queue-login and .queue-at). -(exit 1) -``` diff --git a/apps/ui/canary-repros/admin/28.6.ts b/apps/ui/canary-repros/admin/28.6.ts deleted file mode 100644 index cefae48d..00000000 --- a/apps/ui/canary-repros/admin/28.6.ts +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Repro — checklist row 28.6 ("Spacing and alignment are consistent across - * cards, panes, and the composer") against https://canary.smithers.sh. - * - * The request-access queue card runs the login straight into the date with no - * gap: `<span class="queue-login">codeplanesmithers</span><span - * class="queue-at">2026-08-19</span>` renders as - * "codeplanesmithers2026-08-19Approve". - * - * PROF=/tmp/canary-admin-profile bun 28.6.ts - * exit 1 while the bug is present, 0 once the row is spaced. - * - * Fixture: the session must be admin (identity worker ADMIN_LOGINS), and the - * request-access queue must hold at least one entry. - */ -import { open, session, run } from "./_lib"; - -const { context, page } = await open(); -const who = await session(page); -if (who.admin !== true) { - console.error("SETUP: the session is not admin — add the login to the identity worker's ADMIN_LOGINS."); - await context.close(); - process.exit(2); -} -await run(page, "/admin.requests", 8000); - -const row = await page.evaluate(() => { - const element = document.querySelector("li.queue-row"); - if (element === null) return null; - const login = element.querySelector(".queue-login") as HTMLElement | null; - const at = element.querySelector(".queue-at") as HTMLElement | null; - const gap = login !== null && at !== null ? at.getBoundingClientRect().left - login.getBoundingClientRect().right : null; - return { text: (element as HTMLElement).innerText, html: element.innerHTML, gapPx: gap }; -}); -if (row === null) { - console.error("SETUP: the queue is empty — no li.queue-row to measure."); - await context.close(); - process.exit(2); -} -console.log("rendered text:", JSON.stringify(row.text)); -console.log("gap between .queue-login and .queue-at:", row.gapPx, "px"); -console.log("html:", row.html.slice(0, 300)); -await page.screenshot({ path: "/tmp/canary-28.6.png", fullPage: true }); -console.log("screenshot: /tmp/canary-28.6.png"); -await context.close(); - -const runTogether = /[a-z]\d{4}-\d{2}-\d{2}/.test(row.text) || (row.gapPx !== null && row.gapPx < 4); -if (runTogether) { - console.error( - `FAIL: the queue row runs the login into the date — ${JSON.stringify(row.text)} (gap ${row.gapPx}px between .queue-login and .queue-at).`, - ); - process.exit(1); -} -console.log("PASS — the queue row is spaced."); diff --git a/apps/ui/canary-repros/admin/28.9.md b/apps/ui/canary-repros/admin/28.9.md deleted file mode 100644 index f46bee19..00000000 --- a/apps/ui/canary-repros/admin/28.9.md +++ /dev/null @@ -1,70 +0,0 @@ -# 28.9 — timestamps carry no day, so a message from yesterday reads as now - -Origin: <https://canary.smithers.sh> (bundle `assets/index-BHHXuMoZ.js`) -Account: `codeplanesmithers` -Tested: 2026-08-19, admin lane round 1 - -## Checklist row - -> **28.9** Timestamps are in the user's locale and stay correct across a day -> boundary. - -## Steps - -1. Sign in and send a turn so the transcript has stamped messages. -2. Read every `\d{1,2}:\d{2} (AM|PM)` stamp. -3. Push the clock across midnight — - `CDP Emulation.setTimezoneOverride { timezoneId: "Pacific/Kiritimati" }` - (UTC+14) — and re-read the same messages. - -## Expected - -The stamps follow the user's locale (they do), and a message that is now on a -previous calendar day says so — "Yesterday 11:51 PM", or a date. - -## Actual - -``` -browser timezone: America/Los_Angeles -stamps as sent : ["02:51 AM"] -stamps under UTC+14: ["11:51 PM"] -transcript tails : ["...canary-sandbox, waiting 34 days.\n\n11:51 PM"] -any day qualifier on a stamp: false -``` - -The locale half holds: the stamps re-render under the new zone, so they are -formatted from the real instant in the browser's timezone. - -The day-boundary half fails. Under UTC+14 those messages sit on the previous -calendar day, and they still render as a bare `11:51 PM` with no date and no -"Yesterday". Nothing in the transcript distinguishes a message from three -minutes ago from one from last week. The same is true for any user who leaves -a session open across midnight, or reopens a persisted transcript the next day -— and the transcript IS persisted (OPFS), so this is the normal case, not an -edge case. - -The recommendation-feedback card gets this right in the same build, rendering -full `2026-08-19 05:28` stamps. - -Minor, same row: the format is zero-padded 12-hour (`02:51 AM`). -`toLocaleTimeString` for `en-US` produces `2:51 AM`, so the padding is hand-rolled. - -## Route / selector - -`[data-role]` message stamps in the transcript. - -## Screenshot - -`/tmp/canary-28.9.png` - -## Repro output - -``` -$ PROF=/tmp/canary-admin-profile bun 28.9.ts -browser timezone: America/Los_Angeles -stamps as sent : ["02:51 AM"] -stamps under UTC+14: ["11:51 PM"] -any day qualifier on a stamp: false -FAIL: every stamp is a bare time with no date or 'Yesterday' qualifier, so a message from a previous day is indistinguishable from one sent minutes ago. -(exit 1) -``` diff --git a/apps/ui/canary-repros/admin/28.9.ts b/apps/ui/canary-repros/admin/28.9.ts deleted file mode 100644 index cdc37811..00000000 --- a/apps/ui/canary-repros/admin/28.9.ts +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Repro — checklist row 28.9 ("Timestamps are in the user's locale and stay - * correct across a day boundary") against https://canary.smithers.sh. - * - * The locale half holds: stamps follow the browser's timezone. The day-boundary - * half does not: every transcript stamp is a bare time with no date qualifier, - * so a message from a previous day is indistinguishable from one sent minutes - * ago. Driving the clock across midnight with - * `Emulation.setTimezoneOverride` re-renders the same messages as "11:30 PM" - * — now yesterday's time — with no "Yesterday" or date added. - * - * PROF=/tmp/canary-admin-profile bun 28.9.ts - * exit 1 while the bug is present, 0 once stamps qualify the day. - */ -import { open, run, body } from "./_lib"; - -const STAMP = /\b\d{1,2}:\d{2}\s?(?:AM|PM)\b/g; -const DAY_QUALIFIER = /\b(Yesterday|yesterday|Today|today|\d{4}-\d{2}-\d{2}|Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\b/; - -const { context, page } = await open(); -await run(page, "/billing.balance", 6000); - -const homeZone = await page.evaluate(() => Intl.DateTimeFormat().resolvedOptions().timeZone); -const before = await body(page); -const stampsBefore = [...before.matchAll(STAMP)].map((m) => m[0]); -console.log("browser timezone:", homeZone); -console.log("stamps as sent :", JSON.stringify([...new Set(stampsBefore)].slice(0, 6))); - -// Push the clock across midnight: the same messages are now on a previous day. -const cdp = await page.context().newCDPSession(page); -await cdp.send("Emulation.setTimezoneOverride", { timezoneId: "Pacific/Kiritimati" }); // UTC+14 -await page.waitForTimeout(2000); -await run(page, "/billing.balance", 6000); - -const after = await body(page); -const transcript = await page.evaluate(() => - Array.from(document.querySelectorAll("[data-role]")).map((m) => (m as HTMLElement).innerText.trim().slice(-40)).slice(0, 8), -); -const stampsAfter = [...after.matchAll(STAMP)].map((m) => m[0]); -console.log("stamps under UTC+14:", JSON.stringify([...new Set(stampsAfter)].slice(0, 6))); -console.log("transcript tails :", JSON.stringify(transcript)); -console.log("any day qualifier on a stamp:", DAY_QUALIFIER.test(after.split("\n").filter((l) => STAMP.test(l)).join(" "))); -await page.screenshot({ path: "/tmp/canary-28.9.png", fullPage: true }); -console.log("screenshot: /tmp/canary-28.9.png"); -await context.close(); - -const followsLocale = JSON.stringify(stampsBefore) !== JSON.stringify(stampsAfter); -const qualified = DAY_QUALIFIER.test(after.split("\n").filter((line) => STAMP.test(line)).join(" ")); -const failures: Array<string> = []; -if (!followsLocale) failures.push("stamps did not change with the timezone — they are not rendered in the user's locale."); -if (!qualified) { - failures.push( - "every stamp is a bare time with no date or 'Yesterday' qualifier, so a message from a previous day is indistinguishable from one sent minutes ago.", - ); -} -if (failures.length === 0) { - console.log("PASS — stamps are local and stay correct across a day boundary."); - process.exit(0); -} -for (const failure of failures) console.error(`FAIL: ${failure}`); -process.exit(1); diff --git a/apps/ui/canary-repros/admin/_lib.ts b/apps/ui/canary-repros/admin/_lib.ts deleted file mode 100644 index 6507e62e..00000000 --- a/apps/ui/canary-repros/admin/_lib.ts +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Shared helpers for the "admin" lane repros (checklist §25–§28) against - * https://canary.smithers.sh. - * - * The sanctioned persistent profile holds the signed-in github.com session for - * the throwaway account `codeplanesmithers`. Never open it directly and never - * share it between two runs — copy it first: - * - * cp -R ~/.multi-e2e-profile /tmp/canary-admin-profile - * - * The admin rows need `admin: true`, which the identity worker answers from its - * ADMIN_LOGINS var. During this run `codeplanesmithers` was added to that var - * (Cloudflare Worker settings PATCH, no code deploy). If the session reports - * admin:false, that fixture is gone — restore it before reading a failure here - * as a product defect. - */ -import { chromium, type BrowserContext, type Page } from "playwright"; - -export const BASE = process.env.CANARY_URL ?? "https://canary.smithers.sh"; -export const PROFILE = process.env.PROF ?? "/tmp/canary-admin-profile"; - -export const open = async (): Promise<{ context: BrowserContext; page: Page; errors: Array<string>; requests: Array<string> }> => { - const context = await chromium.launchPersistentContext(PROFILE, { - headless: true, - viewport: { width: 1400, height: 1000 }, - }); - const page = context.pages()[0] ?? (await context.newPage()); - const errors: Array<string> = []; - const requests: Array<string> = []; - page.on("console", (message) => { - if (message.type() === "error") errors.push(message.text()); - }); - page.on("pageerror", (error) => errors.push(String(error))); - page.on("response", (response) => { - if (response.status() >= 400) requests.push(`${response.status()} ${response.request().method()} ${response.url()}`); - }); - await page.goto(BASE, { waitUntil: "domcontentloaded" }); - await page.waitForTimeout(6000); - return { context, page, errors, requests }; -}; - -/** The identity seam's answer, read through the product origin. */ -export const session = (page: Page): Promise<{ login?: string; admin?: boolean; allowlisted?: boolean }> => - page.evaluate(async () => (await fetch("/api/auth/session")).json().catch(() => ({}))); - -/** The app shell's whole registry (`data-flows`), split into names. */ -export const registry = async (page: Page): Promise<Array<string>> => { - const attribute = await page.evaluate(() => document.querySelector("[data-flows]")?.getAttribute("data-flows") ?? ""); - return attribute.split(" ").filter((name) => name !== ""); -}; - -/** Type a slash flow into the composer and submit it. */ -export const run = async (page: Page, text: string, settle = 4000): Promise<void> => { - const composer = page.locator("textarea.sui-chat-composer-input"); - await composer.click(); - await composer.fill(text); - await page.keyboard.press("Enter"); - await page.waitForTimeout(settle); -}; - -export const body = (page: Page): Promise<string> => page.locator("body").innerText(); - -export const report = (failures: ReadonlyArray<string>): never => { - if (failures.length === 0) { - console.log("PASS — the bug is fixed."); - process.exit(0); - } - for (const failure of failures) console.error(`FAIL: ${failure}`); - process.exit(1); -}; diff --git a/apps/ui/canary-repros/appearance/20.3.md b/apps/ui/canary-repros/appearance/20.3.md deleted file mode 100644 index ca3df0ce..00000000 --- a/apps/ui/canary-repros/appearance/20.3.md +++ /dev/null @@ -1,115 +0,0 @@ -# §20.3 — the connectors row subtitle never repaints, and is illegible in dark mode - -**Row:** `20.3` — "`/dark-mode` toggles, and every theme is legible in both -modes. Look specifically at code blocks, diffs, status pills, and disabled -controls." - -**Verdict:** FAIL — on the "every theme is legible in both modes" half. - -The toggle itself works (`dark -> light -> dark`, verified). The four categories -the row names are legible in all 9 palettes x 2 modes (measured, table below). -The failure is a fifth surface: the connectors pane's row subtitle. - -**Target:** https://canary.smithers.sh -**Route:** `/connect` (the connectors pane) -**Selector:** `.connect-store-row .connect-store-text > span` — the "Issues, pull -requests, and reviews from the repositories…" / "Import a GitHub repository into -hosted workspace storage." line under each connector title. -**Repro:** `bun canary-repros/appearance/20.3.ts` (exits 1 while the bug is present) - -## Steps - -1. Open https://canary.smithers.sh signed in as `codeplanesmithers`. -2. `/theme <palette>` then `/dark-mode` until `data-theme="dark"`. -3. `/connect`. -4. Read the computed and the painted colour of the row subtitle. - -## Expected - -The subtitle is a themed muted foreground that repaints with the palette and the -mode, and clears 4.5:1 against the pane background. - -## Actual - -The subtitle is a hardcoded `rgb(107, 100, 87)` in **all 18** palette/mode -combinations. It never repaints. Against the dark pane backgrounds it lands at -2.22:1 – 3.14:1; 8 of the 9 palettes are below even 3:1 in dark mode. - -| palette | dark | light | -| --- | --- | --- | -| night-owl | **2.68** | 5.86 | -| paper | **2.99** | 5.80 | -| fucory | **3.14** | 5.86 | -| one | **2.22** | 5.86 | -| github | **2.27** | 5.86 | -| catppuccin | **2.63** | 5.86 | -| solarized | **2.22** | 5.66 | -| gruvbox | **2.24** | 5.32 | -| rose-pine | **2.82** | 5.64 | - -The claim is not computed-style only: reading the painted pixels under the -subtitle in night-owl dark gives `rgb(11,37,58) x4667 | rgb(107,100,87) x81 | -rgb(82,84,79) x48 | rgb(34,52,64) x41` — the glyphs really are painted -`rgb(107,100,87)`. (A first read of a screenshot crop looked light; it was -cropping the wrong row, and the pixel histogram corrected it.) - -## The four categories the row names — all pass - -Measured across 9 palettes x 2 modes, worst case per category: - -- code blocks (`pre`, `pre code`): 4.39:1 (solarized light) up to 16.12:1 -- diffs (a ```` ```diff ```` fence): 12.5:1 — rendered as plain monospace, no - +/- colouring, legible -- status pills (`WAITING FOR APPROVAL`, `PENDING`, `Turn interrupted`): 5.17:1 - up to 10.55:1 -- disabled controls (the send button with an empty composer): 2.74:1 – 3.11:1 in - light, 6.8:1 – 10.25:1 in dark. Deliberate de-emphasis, exempt from WCAG 1.4.3, - and the glyph still reads. - -The one marginal reading among these is solarized **light** code-block text at -4.39:1 (needs 4.5:1). Noted, not the reason this row fails. - -## Screenshots - -- `/tmp/appearance-shots/20.3-connectors-dark.png` — the connectors pane, night-owl dark -- `/tmp/appearance-shots/20.3-connect-nightowl-dark.png` — full window - -## Repro output - -``` -/dark-mode toggled light -> dark - night-owl dark ratio=2.68 fg=rgb(107, 100, 87) bg=rgb(11,37,58) - night-owl light ratio=5.86 fg=rgb(107, 100, 87) bg=rgb(255,255,255) - paper dark ratio=2.99 fg=rgb(107, 100, 87) bg=rgb(19,27,26) - paper light ratio=5.8 fg=rgb(107, 100, 87) bg=rgb(255,254,250) - fucory dark ratio=3.14 fg=rgb(107, 100, 87) bg=rgb(20,20,23) - fucory light ratio=5.86 fg=rgb(107, 100, 87) bg=rgb(255,255,255) - one dark ratio=2.22 fg=rgb(107, 100, 87) bg=rgb(44,49,60) - one light ratio=5.86 fg=rgb(107, 100, 87) bg=rgb(255,255,255) - github dark ratio=2.27 fg=rgb(107, 100, 87) bg=rgb(43,48,54) - github light ratio=5.86 fg=rgb(107, 100, 87) bg=rgb(255,255,255) - catppuccin dark ratio=2.63 fg=rgb(107, 100, 87) bg=rgb(35,35,54) - catppuccin light ratio=5.86 fg=rgb(107, 100, 87) bg=rgb(255,255,255) - solarized dark ratio=2.22 fg=rgb(107, 100, 87) bg=rgb(7,54,66) - solarized light ratio=5.66 fg=rgb(107, 100, 87) bg=rgb(255,251,240) - gruvbox dark ratio=2.24 fg=rgb(107, 100, 87) bg=rgb(50,48,47) - gruvbox light ratio=5.32 fg=rgb(107, 100, 87) bg=rgb(249,245,215) - rose-pine dark ratio=2.82 fg=rgb(107, 100, 87) bg=rgb(31,29,46) - rose-pine light ratio=5.64 fg=rgb(107, 100, 87) bg=rgb(255,250,243) - ---- §20.3 --- -painted pixels under the subtitle (night-owl dark): rgb(11,37,58) x4667 | rgb(107,100,87) x81 | rgb(82,84,79) x48 | rgb(34,52,64) x41 -expected: the connectors row subtitle is legible in both modes of every palette -actual: it is a fixed rgb(107, 100, 87) in all 18 palette/mode combinations; 8 dark combinations fall below 3:1 -FAIL §20.3 — night-owl:2.68, paper:2.99, one:2.22, github:2.27, catppuccin:2.63, solarized:2.22, gruvbox:2.24, rose-pine:2.82 -``` - -exit code: 1 - -## Note for the fix stage - -`rgb(107,100,87)` is `#6b6457`, which is not in any of the nine palettes — it is -a literal in the connectors pane's styling rather than a `--muted`/`--fg-2` -token read from `styles/tokens.css`. The same value in light mode is fine -(5.3–5.9:1), which is why this survived: it was only ever eyeballed in light. -See also §20.6, whose axe run catches the same node. diff --git a/apps/ui/canary-repros/appearance/20.3.ts b/apps/ui/canary-repros/appearance/20.3.ts deleted file mode 100644 index d2a21cbb..00000000 --- a/apps/ui/canary-repros/appearance/20.3.ts +++ /dev/null @@ -1,153 +0,0 @@ -/* - * Canary repro — MANUAL-REVIEW-CHECKLIST §20.3 - * "/dark-mode toggles, and every theme is legible in both modes. Look - * specifically at code blocks, diffs, status pills, and disabled controls." - * - * The toggle itself works and the four named categories are legible in all - * 9 palettes x 2 modes. What is NOT legible is the connectors pane's row - * subtitle: `.connect-store-text > span` is painted rgb(107, 100, 87) in EVERY - * palette and in BOTH modes — a hardcoded colour that never repaints — so on - * every dark background it lands near 2.7:1 where 4.5:1 is required. - * - * The measurement is taken from the real painted pixels as well as from - * getComputedStyle, because a first read of a screenshot crop suggested the - * text was light and it is not. - * - * Run: bun canary-repros/appearance/20.3.ts - * Exits 1 while the dark-mode subtitle is below 3:1. - */ -import { chromium } from "playwright"; - -const BASE = process.env.CANARY_URL ?? "https://canary.smithers.sh"; -const PROFILE = process.env.APPEARANCE_PROFILE ?? "/tmp/canary-appearance-profile"; -const PALETTES = ["night-owl", "paper", "fucory", "one", "github", "catppuccin", "solarized", "gruvbox", "rose-pine"] as const; -/** WCAG AA for normal-size text. Below 3:1 is not a judgement call. */ -const HARD_FLOOR = 3; - -const CONTRAST = `(() => { - const parse = (c) => { const m = c.match(/rgba?\\(([^)]+)\\)/); if (!m) return null; - const p = m[1].split(/[,\\s\\/]+/).filter(Boolean).map(Number); return { r: p[0], g: p[1], b: p[2], a: p.length > 3 ? p[3] : 1 }; }; - const lum = ({ r, g, b }) => { const f = (v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); }; - return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b); }; - const over = (fg, bg) => ({ r: fg.r * fg.a + bg.r * (1 - fg.a), g: fg.g * fg.a + bg.g * (1 - fg.a), b: fg.b * fg.a + bg.b * (1 - fg.a), a: 1 }); - const effBg = (el) => { let n = el, acc = null; - while (n) { const c = parse(getComputedStyle(n).backgroundColor); - if (c && c.a > 0) { acc = acc ? over(acc, c) : c; if (acc.a >= 0.999) return acc; } n = n.parentElement; } - return acc ?? { r: 255, g: 255, b: 255, a: 1 }; }; - window.__contrast = (el) => { const cs = getComputedStyle(el); const raw = parse(cs.color); if (!raw) return null; - const bg = effBg(el); const fg = raw.a < 1 ? over(raw, bg) : raw; const l1 = lum(fg), l2 = lum(bg); - const hi = Math.max(l1, l2), lo = Math.min(l1, l2); - return { ratio: Math.round(((hi + 0.05) / (lo + 0.05)) * 100) / 100, fg: cs.color, - bg: 'rgb(' + Math.round(bg.r) + ',' + Math.round(bg.g) + ',' + Math.round(bg.b) + ')', size: parseFloat(cs.fontSize) }; }; - return true; })()`; - -const context = await chromium.launchPersistentContext(PROFILE, { - headless: true, - viewport: { width: 1280, height: 900 }, -}); -const page = context.pages()[0] ?? (await context.newPage()); -await page.goto(BASE, { waitUntil: "domcontentloaded" }); -await page.waitForTimeout(4000); - -const run = async (command: string): Promise<void> => { - const composer = page.locator("textarea").first(); - await composer.click(); - await composer.fill(command); - await composer.press("Enter"); - await page.waitForTimeout(1100); -}; -const mode = (): Promise<string | null> => page.evaluate(() => document.documentElement.getAttribute("data-theme")); -const setMode = async (want: "dark" | "light"): Promise<void> => { - if ((await mode()) !== want) { - await run("/dark-mode"); - await page.waitForTimeout(900); - } -}; - -/** Show the connectors pane, retrying: a slash dispatch occasionally lands while the surface is mid-swap. */ -const openConnectors = async (): Promise<void> => { - for (let attempt = 0; attempt < 4; attempt += 1) { - await run("/connect"); - await page.waitForTimeout(1500); - if ((await page.locator(".connect-store-text").count()) > 0) return; - await run("/chat"); - await page.waitForTimeout(800); - } - throw new Error("the connectors pane never rendered .connect-store-text"); -}; - -// The toggle itself. -const before = await mode(); -await run("/dark-mode"); -await page.waitForTimeout(900); -const after = await mode(); -console.log(`/dark-mode toggled ${String(before)} -> ${String(after)}`); -if (before === after) { - console.error("FAIL §20.3 — /dark-mode did not toggle"); - await context.close(); - process.exit(1); -} - -await openConnectors(); - -const rows: Array<{ palette: string; mode: string; ratio: number; fg: string; bg: string; text: string }> = []; -for (const palette of PALETTES) { - await run(`/theme ${palette}`); - await page.waitForTimeout(500); - for (const want of ["dark", "light"] as const) { - await setMode(want); - if ((await page.locator(".connect-store-text").count()) === 0) await openConnectors(); - await page.waitForTimeout(400); - await page.evaluate(CONTRAST); - const measured = await page.evaluate(() => { - const contrast = (globalThis as unknown as { __contrast: (el: Element) => { ratio: number; fg: string; bg: string } }).__contrast; - const span = document.querySelector(".connect-store-text > span"); - if (span === null) return null; - return { ...contrast(span), text: (span as HTMLElement).innerText.slice(0, 40) }; - }); - if (measured === null) continue; - const themeNow = (await mode()) ?? "?"; - rows.push({ palette, mode: themeNow, ...measured }); - console.log(` ${palette.padEnd(11)} ${themeNow.padEnd(5)} ratio=${String(measured.ratio).padEnd(6)} fg=${measured.fg} bg=${measured.bg}`); - } -} - -// The real painted pixels, so the claim does not rest on computed style alone. -await run("/theme night-owl"); -await setMode("dark"); -if ((await page.locator(".connect-store-text").count()) === 0) await openConnectors(); -await page.waitForTimeout(600); -const box = await page.locator(".connect-store-text > span").first().boundingBox(); -let painted = "(no box)"; -if (box !== null) { - const shot = await page.screenshot({ clip: { x: box.x, y: box.y, width: Math.min(box.width, 340), height: box.height } }); - painted = await page.evaluate(async (data: string) => { - const bitmap = await createImageBitmap(await (await fetch(`data:image/png;base64,${data}`)).blob()); - const canvas = document.createElement("canvas"); - canvas.width = bitmap.width; - canvas.height = bitmap.height; - const context2d = canvas.getContext("2d"); - if (context2d === null) return "(no 2d context)"; - context2d.drawImage(bitmap, 0, 0); - const pixels = context2d.getImageData(0, 0, canvas.width, canvas.height).data; - const counts = new Map<string, number>(); - for (let index = 0; index < pixels.length; index += 4) { - const key = `${pixels[index]},${pixels[index + 1]},${pixels[index + 2]}`; - counts.set(key, (counts.get(key) ?? 0) + 1); - } - return [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 4).map(([key, value]) => `rgb(${key}) x${value}`).join(" | "); - }, shot.toString("base64")); -} -await page.screenshot({ path: "/tmp/appearance-shots/20.3-connectors-dark.png" }); -await context.close(); - -const bad = rows.filter((row) => row.mode === "dark" && row.ratio < HARD_FLOOR); -console.log("\n--- §20.3 ---"); -console.log(`painted pixels under the subtitle (night-owl dark): ${painted}`); -console.log("expected: the connectors row subtitle is legible in both modes of every palette"); -console.log(`actual: it is a fixed ${rows[0]?.fg ?? "?"} in all ${rows.length} palette/mode combinations; ${bad.length} dark combinations fall below ${HARD_FLOOR}:1`); -if (bad.length > 0) { - console.error(`FAIL §20.3 — ${bad.map((row) => `${row.palette}:${row.ratio}`).join(", ")}`); - process.exit(1); -} -console.log("pass §20.3"); diff --git a/apps/ui/canary-repros/appearance/20.4.md b/apps/ui/canary-repros/appearance/20.4.md deleted file mode 100644 index 07f517d2..00000000 --- a/apps/ui/canary-repros/appearance/20.4.md +++ /dev/null @@ -1,97 +0,0 @@ -# §20.4 — the persisted theme is painted AFTER first paint (flash of the wrong theme) - -**Row:** `20.4` — "The theme choice survives a reload and applies before first -paint (no flash of the wrong theme)." - -**Verdict:** FAIL. The choice survives; it does not apply before first paint. - -**Target:** https://canary.smithers.sh -**Route:** `/` (the SPA shell) -**Selectors involved:** `document.documentElement[data-theme]`, -`document.documentElement[data-palette]`, `body` computed `background-color` -**Repro:** `bun canary-repros/appearance/20.4.ts` (exits 1 while the bug is present) - -## Steps - -1. Open https://canary.smithers.sh signed in as `codeplanesmithers`, with the OS - at `prefers-color-scheme: light`. -2. Type `/theme night-owl` in the composer, then `/dark-mode` if the app is not - already in dark. The document now carries - `data-palette="night-owl" data-theme="dark"` and `body` is `rgb(1, 22, 39)`. -3. Reload the page with a `document-start` init script sampling - `data-theme`/`data-palette`/`body` background on every animation frame, and a - CDP `Page.startScreencast` running across the navigation. - -## Expected - -At `paint:first-paint` the document already carries `data-theme="dark"` and the -body is the persisted palette's background (`rgb(1, 22, 39)`), the way a -render-blocking inline bootstrap in `index.html` would give. - -## Actual - -At `paint:first-paint` (t≈70ms) the document has **no** `data-theme` and **no** -`data-palette`, and the body is `rgb(251, 251, 251)` — the built-in light -default. The persisted theme is stamped 155–290ms later (three runs measured -157ms, 155ms, 286ms), before first-contentful-paint but well after first paint. -The browser really paints those frames: the screencast frame at +74ms is a -full-viewport near-white page. - -Root cause visible from the wire: `GET /` returns - -```html -<!DOCTYPE html> -<html lang="en"> - <head> - <meta charset="UTF-8" /> - <meta name="viewport" content="width=device-width, initial-scale=1.0" /> - <title>Smithers - - - - -
- - -``` - -— no inline theme bootstrap, and no `data-theme` on the served ``. The -persisted choice lives in OPFS/wa-sqlite (localStorage, sessionStorage and -cookies are all empty for this origin, verified in-page), which is asynchronous -by construction, so it cannot be read before the first paint. - -## Screenshots - -- `/tmp/appearance-shots/20.4-frame-01-74ms.png` — the painted white frame -- `/tmp/appearance-shots/20.4-frame-02-367ms.png` — the first correctly themed frame -- `/tmp/appearance-shots/20.4-settled.png` — the settled dark page - -## Repro output - -``` -persisted choice: {"palette":"night-owl","theme":"dark","bg":"rgb(1, 22, 39)"} - -frames around first paint: - {"t":65.5,"tag":"raf","palette":null,"theme":null,"bg":"rgb(251, 251, 251)"} - {"t":70.3,"tag":"paint:first-paint","palette":null,"theme":null,"bg":"rgb(251, 251, 251)"} - {"t":356.7,"tag":"raf","palette":"night-owl","theme":"dark","bg":"rgb(1, 22, 39)"} - {"t":363.8,"tag":"paint:first-contentful-paint","palette":"night-owl","theme":"dark","bg":"rgb(1, 22, 39)"} - -12 screencast frames over the first 900ms -> /tmp/appearance-shots/20.4-frame-*.png - ---- §20.4 --- -expected: at first-paint the document already carries data-theme=dark and body rgb(1, 22, 39) -actual: at first-paint data-theme=null and body rgb(251, 251, 251) -the persisted theme lands 286ms after first paint -FAIL §20.4 — the wrong theme is painted first -``` - -exit code: 1 - -## Note for the fix stage - -The `prefers-color-scheme` default (§20.5) is honoured correctly once the app -boots, so the gap is only the pre-boot paint. A render-blocking inline script in -`index.html` that stamps `data-theme`/`data-palette` from a synchronous mirror -(a cookie the Worker can read server-side, or `localStorage`) before the module -loads would close it; the OPFS store can stay the source of truth afterwards. diff --git a/apps/ui/canary-repros/appearance/20.4.ts b/apps/ui/canary-repros/appearance/20.4.ts deleted file mode 100644 index e90d6007..00000000 --- a/apps/ui/canary-repros/appearance/20.4.ts +++ /dev/null @@ -1,145 +0,0 @@ -/* - * Canary repro — MANUAL-REVIEW-CHECKLIST §20.4 - * "The theme choice survives a reload and applies before first paint (no flash - * of the wrong theme)." - * - * The choice DOES survive. It does NOT apply before first paint: the served - * index.html carries no inline theme bootstrap, and the persisted choice lives - * in OPFS/wa-sqlite (async), so the document paints the built-in light default - * (#fbfbfb) first and only stamps data-theme/data-palette ~150-250ms later. - * With a dark theme persisted, that is a full-viewport white flash on every - * load. - * - * Run: bun canary-repros/appearance/20.4.ts - * Exits 1 while the flash is present. - */ -import { mkdirSync, writeFileSync } from "node:fs"; -import { chromium } from "playwright"; - -const BASE = process.env.CANARY_URL ?? "https://canary.smithers.sh"; -const PROFILE = process.env.APPEARANCE_PROFILE ?? "/tmp/canary-appearance-profile"; -const SHOTS = "/tmp/appearance-shots"; -mkdirSync(SHOTS, { recursive: true }); - -const context = await chromium.launchPersistentContext(PROFILE, { - headless: true, - viewport: { width: 1280, height: 900 }, - colorScheme: "light", // the OS says light; the persisted choice says dark -}); -const page = context.pages()[0] ?? (await context.newPage()); - -await page.goto(BASE, { waitUntil: "domcontentloaded" }); -await page.waitForTimeout(4000); - -/** Drive a slash command through the real composer. */ -const run = async (command: string): Promise => { - const composer = page.locator("textarea").first(); - await composer.click(); - await composer.fill(command); - await composer.press("Enter"); - await page.waitForTimeout(1200); -}; - -await run("/theme night-owl"); -if ((await page.evaluate(() => document.documentElement.getAttribute("data-theme"))) !== "dark") { - await run("/dark-mode"); -} -const persisted = await page.evaluate(() => ({ - palette: document.documentElement.getAttribute("data-palette"), - theme: document.documentElement.getAttribute("data-theme"), - bg: getComputedStyle(document.body).backgroundColor, -})); -console.log(`persisted choice: ${JSON.stringify(persisted)}`); -if (persisted.theme !== "dark") { - console.error("setup failed: could not persist a dark theme"); - await context.close(); - process.exit(2); -} - -// Record the document's theme attributes at every frame of the NEXT load. -await context.addInitScript(() => { - (globalThis as unknown as { __frames: Array }).__frames = []; - const record = (tag: string): void => { - try { - (globalThis as unknown as { __frames: Array }).__frames.push({ - t: Math.round(performance.now() * 10) / 10, - tag, - palette: document.documentElement.getAttribute("data-palette"), - theme: document.documentElement.getAttribute("data-theme"), - bg: document.body === null ? null : getComputedStyle(document.body).backgroundColor, - }); - } catch { - // the document is mid-teardown; the next frame records instead - } - }; - record("init"); - try { - new PerformanceObserver((list) => { - for (const entry of list.getEntries()) record(`paint:${entry.name}`); - }).observe({ type: "paint", buffered: true }); - } catch { - // no paint timing here; the rAF samples still bracket first paint - } - const loop = (): void => { - record("raf"); - if ((globalThis as unknown as { __frames: Array }).__frames.length < 200) requestAnimationFrame(loop); - }; - requestAnimationFrame(loop); -}); - -// A real screencast, so the flash is a picture and not only an attribute read. -const cdp = await context.newCDPSession(page); -const shots: Array<{ ms: number; data: string }> = []; -const started = Date.now(); -cdp.on("Page.screencastFrame", async (frame: { data: string; sessionId: number }) => { - shots.push({ ms: Date.now() - started, data: frame.data }); - await cdp.send("Page.screencastFrameAck", { sessionId: frame.sessionId }).catch(() => {}); -}); -await cdp.send("Page.startScreencast", { format: "png", everyNthFrame: 1 }); -// Do NOT await the reload: waiting for domcontentloaded already outlasts the -// flash, and the frames that matter are the ones painted before it. -const reloading = page.reload({ waitUntil: "domcontentloaded" }); -await page.waitForTimeout(4000); -await cdp.send("Page.stopScreencast").catch(() => {}); -await reloading; - -type Frame = { t: number; tag: string; palette: string | null; theme: string | null; bg: string | null }; -const frames = (await page.evaluate(() => (globalThis as unknown as { __frames: Array }).__frames ?? [])) as Array; -const firstPaint = frames.find((frame) => frame.tag === "paint:first-paint"); -const firstThemed = frames.find((frame) => frame.theme !== null); - -console.log("\nframes around first paint:"); -let previous = ""; -for (const frame of frames) { - const key = `${frame.palette}|${frame.theme}|${frame.bg}`; - if (key !== previous || frame.tag.startsWith("paint")) { - console.log(` ${JSON.stringify(frame)}`); - previous = key; - } -} - -// Keep a strip of the frames the browser actually painted over the flash -// window, so the claim is a picture and not only an attribute read. -const strip = shots.filter((shot) => shot.ms <= 900).slice(0, 12); -strip.forEach((shot, index) => { - writeFileSync(`${SHOTS}/20.4-frame-${String(index).padStart(2, "0")}-${shot.ms}ms.png`, Buffer.from(shot.data, "base64")); -}); -console.log(`\n${strip.length} screencast frames over the first 900ms -> ${SHOTS}/20.4-frame-*.png`); -await page.screenshot({ path: `${SHOTS}/20.4-settled.png` }); - -const flashed = - firstPaint !== undefined && - (firstPaint.theme === null || firstPaint.theme !== persisted.theme || firstPaint.bg !== persisted.bg); -const delay = firstThemed !== undefined && firstPaint !== undefined ? firstThemed.t - firstPaint.t : Number.NaN; - -console.log("\n--- §20.4 ---"); -console.log(`expected: at first-paint the document already carries data-theme=${persisted.theme} and body ${persisted.bg}`); -console.log(`actual: at first-paint data-theme=${String(firstPaint?.theme)} and body ${String(firstPaint?.bg)}`); -console.log(`the persisted theme lands ${Math.round(delay)}ms after first paint`); - -await context.close(); -if (flashed) { - console.error("FAIL §20.4 — the wrong theme is painted first"); - process.exit(1); -} -console.log("pass §20.4"); diff --git a/apps/ui/canary-repros/appearance/20.6.md b/apps/ui/canary-repros/appearance/20.6.md deleted file mode 100644 index 7a4836e4..00000000 --- a/apps/ui/canary-repros/appearance/20.6.md +++ /dev/null @@ -1,102 +0,0 @@ -# §20.6 — the default palette misses AA contrast in dark mode (and the world editor has an unnamed input) - -**Row:** `20.6` — "Contrast: run one accessibility audit per mode and confirm -text and interactive controls meet contrast on the default theme." - -**Verdict:** FAIL. - -**Target:** https://canary.smithers.sh -**Tool:** axe-core 4.13.0, `runOnly: wcag2a, wcag2aa, wcag21a, wcag21aa`, -`resultTypes: ["violations"]`, run over `document` on three surfaces per mode. -**Palette:** `night-owl` — the default (`data-palette="night-owl"`). -**Repro:** `bun canary-repros/appearance/20.6.ts` (exits 1 while a contrast -violation is present). axe-core is read from `AXE_PATH`, default `/tmp/axe.min.js`. - -## Steps - -1. Open https://canary.smithers.sh signed in as `codeplanesmithers`. -2. `/theme night-owl`, then `/theme` to put a card in the transcript. -3. Inject axe-core and run it on the chat, then `/world`, then `/connect`. -4. `/dark-mode` and repeat all three. - -## Expected - -Zero `color-contrast` violations on the default palette in either mode. - -## Actual - -**light mode:** chat clean, connectors clean, world editor has one -`color-contrast` violation. -**dark mode:** all three surfaces have `color-contrast` violations. - -| selector | mode | measured | required | -| --- | --- | --- | --- | -| `section[data-kind="reco"] > .smithers-card-header > .smithers-card-meta` | dark | 3.75:1 — `#5f7e97` on `#0a2337`, 9px | 4.5:1 | -| `section[data-kind="theme-picker"] > .smithers-card-header > .smithers-card-meta` | dark | 3.75:1 — `#5f7e97` on `#0a2337`, 9px | 4.5:1 | -| `.connect-store-row .connect-store-text > span` | dark | 2.67:1 — `#6b6457` on `#0b253a`, 13px | 4.5:1 | -| `.sui-file-tree-file-name` (world editor, selected file) | dark | 4.02:1 — `#c792ea` on `#314565`, 13px | 4.5:1 | -| `.sui-file-tree-file-name` (world editor, selected file) | light | 3.78:1 — `#994cc3` on `#e6dceb`, 13px | 4.5:1 | - -A second `serious` violation, not contrast but on the same surface and worth -carrying to the fix stage: - -| rule | node | detail | -| --- | --- | --- | -| `aria-input-field-name` | `.ProseMirror` (the world editor body) | the `textbox` has no `aria-label`, no `aria-labelledby` and no `title` — a screen reader announces an unnamed edit field. Both modes. | - -`.smithers-card-meta` is the per-card byline ("reco · 01:29 AM"). It is 9px, so -it is the smallest text in the product and the furthest from the floor. - -## Screenshots - -- `/tmp/appearance-shots/20.6-final.png` -- `/tmp/appearance-shots/20.6-mode-b.png` - -## Repro output - -``` -=== chat (as-loaded) | palette=night-owl mode=light | 0 violation(s) - -=== world (as-loaded) | palette=night-owl mode=light | 2 violation(s) - - [serious] aria-input-field-name: ARIA input fields must have an accessible name (1 node(s)) - .ProseMirror - - [serious] color-contrast: Elements must meet minimum color contrast ratio thresholds (1 node(s)) - .sui-file-tree-file-name - insufficient color contrast of 3.78 (foreground #994cc3, background #e6dceb, 13px) - -=== connectors (as-loaded) | palette=night-owl mode=light | 0 violation(s) - -=== chat (toggled) | palette=night-owl mode=dark | 1 violation(s) - - [serious] color-contrast (2 node(s)) - section[data-kind="reco"] > header > .smithers-card-meta 3.75 (#5f7e97 on #0a2337, 9px) - section[data-kind="theme-picker"] > header > .smithers-card-meta 3.75 - -=== world (toggled) | palette=night-owl mode=dark | 2 violation(s) - - [serious] aria-input-field-name (1 node(s)) .ProseMirror - - [serious] color-contrast (3 node(s)) ... .sui-file-tree-file-name 4.02 (#c792ea on #314565) - -=== connectors (toggled) | palette=night-owl mode=dark | 1 violation(s) - - [serious] color-contrast (4 node(s)) - .connect-store-row[role="listitem"]:nth-child(1) > .connect-store-text > span 2.67 (#6b6457 on #0b253a) - .connect-store-row[role="listitem"]:nth-child(2) > .connect-store-text > span 2.67 - ---- §20.6 --- -expected: zero color-contrast violations on the default palette in both modes -actual: world (as-loaded) -> .sui-file-tree-file-name -actual: chat (toggled) -> section[data-kind="reco"] > header > .smithers-card-meta, section[data-kind="theme-picker"] > header > .smithers-card-meta -actual: world (toggled) -> ... .sui-file-tree-file-name -actual: connectors (toggled) -> ... .connect-store-text > span -FAIL §20.6 — the default palette misses AA contrast -``` - -exit code: 1 - -## Note for the fix stage - -Three distinct causes: - -1. `.smithers-card-meta` — a themed token that is simply too dim at 9px in dark. -2. `.connect-store-text > span` — a hardcoded `#6b6457` that never repaints (see §20.3). -3. `.sui-file-tree-file-name` — the *selected* file uses the palette's brand - colour on a brand-tinted row, so both sides move together and the ratio - collapses. It fails in **both** modes. diff --git a/apps/ui/canary-repros/appearance/20.6.ts b/apps/ui/canary-repros/appearance/20.6.ts deleted file mode 100644 index 28c31ee1..00000000 --- a/apps/ui/canary-repros/appearance/20.6.ts +++ /dev/null @@ -1,113 +0,0 @@ -/* - * Canary repro — MANUAL-REVIEW-CHECKLIST §20.6 - * "Contrast: run one accessibility audit per mode and confirm text and - * interactive controls meet contrast on the default theme." - * - * axe-core 4.13 (wcag2a + wcag2aa + wcag21a + wcag21aa) against the DEFAULT - * palette (night-owl) in both modes, over the chat, the world editor and the - * connectors pane. Dark mode fails `color-contrast` on `.smithers-card-meta` - * (the per-card " ·