Skip to content

fix: autogenerate mission name - #8

Open
CLXKON001 wants to merge 211 commits into
coderlevelup:mainfrom
HlalanathiMashimbye:mission-patch-mission-name
Open

fix: autogenerate mission name#8
CLXKON001 wants to merge 211 commits into
coderlevelup:mainfrom
HlalanathiMashimbye:mission-patch-mission-name

Conversation

@CLXKON001

Copy link
Copy Markdown

No description provided.

HlalanathiMashimbye and others added 30 commits June 20, 2026 11:34
…ork (#1)

This repo is now a real GitHub fork of coderlevelup/4tronix-rover-simulator,
so our work layers on David's history instead of a copied-in island, and we
can open PRs upstream and move CI across to the main project later.

Brought over (none of which upstream had):
- mission-authoring/ and mission-control/ (the two Next.js apps)
- .github/workflows/ci.yml (build + test gate for both apps + yard)
- scripts/dev-yard.js and root package.json (cross-platform dev scripts)
- yard/rover/{rover_physics,test_rover_physics}.py and .env.example
  (canonical steering model + tests; standalone, no conflict)

Upstream's yard is kept as-is: David's service.py/rover_server.py/drivers.py
have advanced past our old snapshot (photo capture, interruptible exec, SSE),
so those are NOT overwritten. sim_recorder/sandbox consolidation and the
copy-to-clipboard close-the-loop will follow as focused PRs.

.gitignore: re-anchored lib/ -> /lib/ (so mission-authoring/src/lib is not
ignored) and added node_modules, *.mp4, root package-lock.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Port operator auth from mission-control into the hub. This is Next.js 16, so
request middleware lives in src/proxy.ts (not middleware.ts).

- /login page + OperatorLogin form; POST /api/auth/session sets the httpOnly
  session cookie after verifying the Firebase ID token
- src/proxy.ts guards /operator (redirect to /login) and /api/operator (JSON 401),
  setting trusted x-operator-* headers from the verified session JWT
- /api/auth/set-custom-claims is locked behind ADMIN_API_SECRET and fails closed
- getFirebaseAdminAuth added to infrastructure/persistence/firebase-admin.ts
- AuthProvider mounted in app/layout.tsx; jose declared as a direct dependency

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
- yard/satellite/web_server.py now binds :3001 (was :5050), overridable via the
  SATELLITE_PORT env var
- add scripts/dev-satellite.js, a cross-platform launcher mirroring dev-yard.js
- npm run dev now launches hub:3000 + yard satellite:3001 + rover:8523 with pinned
  ports (mission-control moved to :3002 transitionally to free :3001; it is removed
  entirely in AB#248)
- document the dev ports in the README

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
- GET /api/operator/missions lists all missions (verifyOperatorAuth; 401 otherwise)
- PATCH /api/operator/missions/[missionId] with admin-only actions:
  add-youtube-url (validates URL, completed-only), mark-complete
  (status=completed + completedAt), cancel, mark-failed
- uses the hub's FirestoreMissionRepository; drops the execute/dispatch action
  and its rover-config / GroundStationDispatch dependencies

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Switch the hub BlocklyEditor from the legacy Blockly.Xml API to
Blockly.serialization, and adopt the yard's richer block set so a learner's
workspace is portable to the yard.

- new src/components/mission/roverBlockly.ts: shared block defs, category
  toolbox, and the Python + simulator-command generators, mirroring
  yard/satellite/templates/code.html (block types, field names and dropdown
  values match, so serialized workspaces round-trip across editors)
- BlocklyEditor saves/loads via Blockly.serialization.workspaces and bootstraps
  an 'On uplink' (rover_on_receive) hat like the yard
- Python generation now matches the rover program the yard runs (low-level
  servo + time.sleep) instead of the old rover.forward(speed, duration) form
- add unit tests for the generators

No Mission schema change, so existing missions are unaffected (persisting
blocklyState is a separate story).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The hub now has operator login (AB#250) and the operator API (AB#251), so the
duplicated mission-control app is no longer needed.

- delete the mission-control/ directory (144 files)
- CI: drop the mission-control build job (mission-authoring + yard remain)
- root package.json: remove dev:control; npm run dev now runs only
  hub:3000 + yard satellite:3001 + rover:8523
- README: drop the transitional 'mission-control on :3002' note

rover-config, simulator, and dispatch routes were intentionally not ported.
No code imports referenced mission-control; history is preserved in git.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
picamera2 is Raspberry Pi-only and makes 'pip install -r
yard/satellite/requirements.txt' fail on macOS/Windows. Gate it with a
PEP 508 marker (sys_platform == "linux") so the full install succeeds
everywhere — picamera2 is simply skipped off Linux.

Also point the dev-satellite.js setup hint at the web server's actual deps
(flask + requests); web_server.py needs nothing else for 'npm run dev', so
local setup no longer pulls picamera2/opencv.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Remove challenges + leaderboard end-to-end (the team is dropping them entirely,
not framing them 'coming soon'):

- delete pages /challenges, /leaderboard and the /missions catalogue
- delete API routes api/challenges/*, api/leaderboard
- delete components/challenge/*, ChallengeService, BadgeService,
  ChallengeSubmissionService, the Challenge + Badge entities, both
  ValidationEngines, IChallengeRepository + FirestoreChallengeRepository,
  data/challenges + data/badges, useChallengeSubmit
- strip gamification (XP, levels, badges, challenge progress) from the Learner
  entity; delete the now-unused ILearnerRepository + FirestoreLearnerRepository
- drop Mission.challengeId (entity, schema, MissionService, Firestore mapping,
  mission detail, MissionCard badge, submit flow)
- remove Challenges/Leaderboard from the navbar (desktop + mobile)
- drop the navbar's placeholder notification data

Landing stays the mission video feed. next build + typecheck + jest green
(23 suites). No remaining challenge/leaderboard imports.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Restyle the landing feed (app/page.tsx) toward clean YouTube-style cards:

- responsive card grid (1 col mobile, 2 col desktop) with the video prominent
  on top like a YouTube thumbnail
- move 'Try it yourself' out of the card middle into the top-right corner beside
  the mission title (consistent action position)
- tone down the glowy look (drop shadow-glow-mars, cosmic-gradient overlays,
  rounded-[2rem], heavy blur, animated badges) for a clean card aesthetic
- keep the code visible alongside the video (scrollable); landing stays the
  mission viewer

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* Fixes AB#252: operator page lists queued missions with code

Add an admin-gated /operator page that lists queued (not-yet-run) missions with
their stored Python and a Copy Python button.

- new app/operator/page.tsx ('use client'), gated client-side via
  useAuth().isOperator (proxy.ts guards /operator server-side; the API enforces
  verifyOperatorAuth)
- loads GET /api/operator/missions, filters status=queued (oldest first)
- Copy Python uses navigator.clipboard.writeText with copied feedback

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Make the /operator console reachable: add an Operator nav link

Show an Operator link (desktop + mobile bottom bar) when useAuth().isOperator is
true, so signed-in operators can reach /operator — previously only the post-login
redirect navigated there.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Extend the operator console to close the loop on the Python path:
- list all submitted missions (not just queued) with status pills
- 'Mark complete' on queued/processing missions (PATCH mark-complete)
- paste a YouTube URL + 'Attach video' on completed missions
  (PATCH add-youtube-url; invalid URLs rejected with the API's message)
- show the attached video link once set

Learner mission + history views already render youtubeUrl, so the video appears
against the mission for the learner once attached.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…#12)

Landing (app/page.tsx):
- add a search box to filter the feed by mission name or code, with a magnifier
  icon, a clear (x) button, a live result count, and a no-match empty state
- reword copy to drop the em dash

Also strip em dashes from the rest of the hub source (comments and UI strings
now use hyphens). The operator page is purged on its own open PR (#11).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Save the serialized Blockly workspace with block-built missions so an operator
can later reload the learner's exact blocks.

- add optional blocklyState (serialized JSON) to the Mission entity and
  createMissionSchema
- BlocklyEditor emits the serialized workspace via onBlocklyStateChange,
  EditorPanel threads it through, and MissionWorkspace includes it in the submit
  only when in Blockly mode
- MissionService passes it through; FirestoreMissionRepository maps it on read,
  and toFirestoreDoc already strips undefined so Python-only missions write no
  blocklyState

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
#16)

The Blockly generator (AB#254) emits the real rover API the yard runs
(rover.setServo, getDistance, setColor, fromRGB, setPixel, show), but those were
missing from the submission allowlist, so block-built missions failed validation
('rover.setServo is not in the approved rover command list'). Add them to
ROVER_COMMAND_ALLOWLIST.

Also drop stale challengeId references in the unit tests (challengeId was removed
in AB#262), which were erroring under tsc --noEmit.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* Fixes AB#256: copy blocks in the operator page

Add a Copy blocks button beside Copy Python on each mission, shown only when the
mission has blocklyState. It copies the serialized Blockly JSON to the clipboard
for pasting into the yard editor's Import box. Hidden for Python-only missions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Add search, status filters, and refresh to the operator console

Adds a toolbar to the operator console: search by name/yard/id, status filter
chips (all/queued/completed/failed), a result count, and a manual Refresh, with
a no-match state. Recovers the toolbar UX that did not make the AB#253 merge.
Also drops em dashes from the file.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Add an Import button to the yard Blockly editor that prompts for pasted Blockly
JSON and loads it via Blockly.serialization.workspaces.load. Invalid input shows
a toast and does not crash; the existing run flow is unchanged.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
- Rebrand Rover Cadets to Mission Control (navbar + page metadata)
- Landing is now a discovery feed: all recent missions as clickable cards
  linking to the detail page (was completed-with-video only)
- Redesign feed cards: video-first thumbnail with a hover play affordance,
  a real run-time chip (executionMetadata.duration_ms), the status badge,
  then title and meta, and a compact editor-style code peek
- Detail page: dynamic status, a real run-output panel (console output)
  instead of placeholder text, and fixed the retired /missions and
  /mission?id= links to /missions/:id
- Share one learner-facing status helper (lib/discoveryStatus.ts): a mission
  reads as Completed or Pending only, never Failed (a deliberate choice so a
  learner is not made to feel bad); the operator console still shows full status

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Rename (no references to the old name remain anywhere):
- mission-authoring/ -> mission-control/ (directory, package names, CI job +
  working-directory + cache paths, root scripts, README; dev:authoring -> dev:control)

Remove bloat:
- .archived/ (7 backed-up API routes)
- docs/ (17 stale per-story implementation summaries and update logs)
- jest-results.json (120K test artifact; now git-ignored)
- module-level GIT_WORKFLOW.md (redundant with the repo workflow)
- .hintrc (unused webhint config)
- components/examples/SimpleLearnerExample.tsx (dead demo, 0 inbound imports)

Quality:
- verified clean-architecture layering: domain and application have no
  dependency on infrastructure, react, or next
- build green; 23 jest suites green

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* chore: eliminate explicit-any type-safety errors and gate lint in CI

Type safety (all 35 no-explicit-any errors removed):
- Typed the Firestore admin/client snapshot shapes the repository uses
  (FirestoreMissionRepository) instead of `any`
- Typed Firestore timestamp mappers, API/auth error handlers, and the
  allowlist `.includes()` checks
- Typed the Blockly/Monaco command flow via the exported SimulationCommand
- The only remaining `any` is genuinely untyped third-party surface (the
  Blockly CDN global, Monaco editor instances) behind documented,
  rule-specific eslint-disable lines

Also:
- Removed unused vars/imports; honored the `_`-prefix ignore convention
- Downgraded the two React 19-era hook rules (set-state-in-effect,
  immutability) to warnings in eslint.config.mjs, documented, pending a
  careful per-case migration; all other rules stay errors
- Wired `npm run lint` into the CI job as a gate (0 errors enforced)

lint: 0 errors / 19 warnings; build green; 23 jest suites green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: keep convertTimestamps/claims caller-compatible for the build type-check

next build type-checks source files (it skips the pre-existing test-file
errors). My Record<string, unknown> tightening of the two convertTimestamps
helpers rejected their typed callers, and the claims object did not match
CustomClaims. Restore the Firestore-bridge helpers to a documented any and
type claims as CustomClaims.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The standalone simulator was unreachable and broken:
- app/simulator/[missionId] route was not linked from anywhere
- its components/simulator/* (SimulatorScaffold, SimulatorVisualization)
  fetched /api/simulator/execute, which does not exist (404)
- infrastructure/simulator/* (rover-movement, simulator-executor) had zero
  importers
- simulator-execution.test.ts was an it.todo stub for the dead engine

Removed all of the above. The live in-workspace 2D sim (manual-mode trajectory
via SimulationPanel/RoverSimulatorScaffold) is untouched.

Not included here (needs a product decision, flagged in the PR): the
MissionWorkspace "Run" in code/Blockly mode still calls /api/simulate/video,
which proxies to a yard endpoint that 404s. Removing it ripples through the
editor chain; rewiring it to a client-side sim is a feature, not cleanup.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Removed 9 files with zero inbound references (verified by symbol scan + a
cascade pass; build/lint/tsc/jest confirm nothing else depended on them):
- components/learner/LearnerDashboard.tsx
- components/learner/LearnerProfileCard.tsx (only used by LearnerDashboard)
- components/mission/ManualControlPanel.tsx
- components/mission/MissionStatusScaffold.tsx
- components/mission/RoverEditorScaffold.tsx
- components/shared/ScaffoldCard.tsx (only used by the deleted scaffolds)
- hooks/useLearnerID.ts
- hooks/useMissionSubmit.ts
- lib/services/learnerMissionService.ts

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
David wants a sim run to produce a video, like a real rover run does. The old
path POSTed commands to /api/simulate/video, which proxied to a yard endpoint
that does not exist (404 every time).

Now, code/Blockly "Run":
- simulates the commands through the existing client-side physics model
  (lib/simulateCommands.ts) into a trajectory, and animates it immediately
- captures that animation to a video via an offscreen canvas + MediaRecorder
  (lib/recordSimVideo.ts) and shows it in the existing sim <video> player
- falls back to the live animation if the browser cannot capture canvas streams,
  so a run is never broken

Removed the dead /api/simulate/video proxy route. Made RoverPhysics.update()
accept a fixed dt so the trajectory is computed deterministically off-clock.

Verified: lint 0 errors, tsc 0 source errors, build green, 22 jest suites green.
Note: MediaRecorder/captureStream are browser-only, so the capture path needs a
quick in-browser smoke test (CI/build cannot exercise it); the animation
fallback is deterministic.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
David's learning loop: every mission shows its simulated run, and once a real
yard run is attached you can compare them.

- Mission detail page now has Simulated | Real run tabs above the player.
- Simulated tab replays the 2D sim, regenerated on demand from the mission's
  code (parseRoverCode -> simulateCommands -> the existing RoverSimulatorScaffold
  canvas). Deterministic, so it needs no storage and stays in sync with the code.
- Real run tab shows the operator-attached video; it is disabled and marked
  "(pending)" until a run has been attached.
- Defaults to Simulated, so every mission has something to watch even before it
  has run in a yard.
- Extracted parseRoverCode into @/lib/parseRoverCode (shared by the editor and
  the detail page).

Verified: lint 0, tsc 0 source errors, build green, 22 jest suites green. The
Simulated tab uses the existing canvas animation (no MediaRecorder), so it runs
without the browser caveat that applies to the workspace video capture.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…own (#24)

The detail page was off-theme (raw slate colors) and used tabs that do not
scale past two runs.

- Rebuilt on the app's design system (bg-card / border-border / text-foreground
  / gradient-mars / font-display), matching the feed, navbar, and operator
  console instead of the old slate palette.
- Replaced the Simulated|Real tabs with a Run dropdown that scales to any number
  of runs (Simulated run + each real yard run as they are attached).
- Cleaner layout: back link, title + status, a 60/40 footage-vs-code split,
  stat chips (status / duration / execution), an editor-style code panel with
  copy, and a remix CTA.

Note: the dropdown is ready for multiple real runs, but the data model still
stores a single attached video, so today it lists Simulated + one Real run.
Per-run history is the deferred runs[] model change.

Verified: lint 0, tsc 0 source errors, build green, jest green.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Matched the create-mission workspace's sizing: the page is now
h-[calc(100vh-64px)] with overflow-hidden, a compact single-row header, and a
two-column body that fills the remaining height. The footage sits in a
fit-to-height column (the square sim no longer pushes the page tall) and only
the code panel scrolls, internally. Stat chips and the remix CTA are compact,
shrink-to-fit rows.

(The standalone run-output card was dropped to keep everything on one screen;
it can return behind a toggle if needed.)

Verified: lint 0, tsc 0 source errors, build green, jest green.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…e) (#26)

Blockly is what learners build with, so the mission detail page now leads with
the blocks. Inspired by micro:bit MakeCode's Blocks/Python toggle.

- New read-only BlocklyViewer renders a mission's saved blocklyState (loads
  Blockly from the same CDN as the editor, defineRoverBlocks, no toolbox,
  pan/zoom on, editing off).
- Detail page code panel gains a Blocks | Python toggle; block-built missions
  default to Blocks. Python-only missions just show the code (no toggle).
- Remix now carries the blocks into the workspace for block-built missions
  (sets roverWorkspace + opens ?mode=blockly), else the Python (?mode=code).

Verified: lint 0, tsc 0 source errors, build green, jest green.
Note: BlocklyViewer renders via the Blockly CDN (browser-only), so the rendered
blocks need a quick in-browser check; it mirrors the working editor's load path.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…bmit (#27)

Two of David's iteration-2 gaps:

Mission names
- generateRandomMissionName now returns a two-word name like "Helios Explorer"
  (no numeric suffix, no dashes). Names are for humans and need not be unique.

Email flow (David: never ask on landing; offer it after a mission is created)
- Removed the auto-prompt on first visit in LearnerContext.
- Removed the always-visible "Edit email" button from the Navbar.
- After a mission is submitted, if the learner has no saved email, the optional
  notifications prompt opens. The history page email entry is unchanged.

Verified: lint 0, tsc 0 source errors, build green, 22 jest suites green.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
David: replace the manual-control arrows with little blocks you tap to run, so
a learner sees that blocks drive the rover before opening the Blockly editor (a
progression toward block coding).

- Replaced the hold-to-drive arrow grid with a palette of block-styled buttons
  (Drive forward / backward, Spin left/right, Steer left/right, Stop) that
  mirror the Blockly movement blocks.
- Tap a block to run that instruction for a beat, then it stops; tapping more
  blocks extends the path one block at a time. The active block is highlighted.
- Keyboard now taps blocks too (W A S D, Q E; space to stop).
- Rebuilt on the app's design tokens; removed the now-dead CSS module.

The trajectory output (onTrajectoryUpdate) is unchanged, so the simulator and
EditorPanel wiring are untouched.

Verified: lint 0, tsc 0 source errors, build green, 22 jest suites green.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
First pass used flat coloured pills that did not read as blocks. After looking
at the running UI, restyled the tap buttons to mirror the editor's blocks:
- real category colours (movement blue #2196F3, spin purple #9C27B0, steer
  cyan #00BCD4, stop red #f44336) and the real labels (Move Forward, Spin Left,
  Steer Right, ...)
- chunky rounded body with a Blockly-style connector tab on the bottom edge and
  bottom shading for depth (ManualControlRealtime.module.css)

So a learner sees the same Lego pieces here as in the Blockly editor. Verified
by screenshotting the running app.

Verified: lint 0, tsc 0 source errors, build green, 22 jest suites green.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…lmer backdrop)

Add block-category colour tokens, claymorphic depth/scrollbar utilities and a prefers-reduced-motion pass; tone the glowy starfield/nebula down to one subtle layer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
HlalanathiMashimbye and others added 30 commits August 10, 2026 07:21
Reorg (moderate): declutter the repo root down to the simulator library and
Qt sim (roversimulator, rover_web_driver, roversimui, rtc_window) plus the
files that must live at the root (package/firebase/firestore config, README,
the camera script and its setup guides).

- examples/: very-simple-example.py, square.py, move-rover.py
- real-rover/: driveRover.py, read-keypress-windows.py, connect_to_real_rover.py
  (pi_camera_stream.py stays at root: real-rover/ already has a different
  script of the same name)
- Moved scripts get a small sys.path shim so 'import roversimulator' /
  'rover_web_driver' still resolve from their new folder.
- Updated references: README, real-rover/README, yard/docs/rover-server.md.

Docs:
- README setup fixed: .venv (was 'env'), cross-platform activation, and a note
  that Mission Control is the current platform vs the legacy desktop simulator.
- Moved team-deploy-tasks.md into docs/.
- Added READMEs for scripts/, yard/rover/, yard/satellite/.
…ve broke (#55)

Completes the in-progress redesign: the Quick actions and Sync cards move off
the queue page and onto Settings, which is where diagnostics belong, and the
repo gets a tidy-up alongside it (examples/, real-rover/, scripts/, per-area
READMEs; status.html renamed to settings.html to match the /settings route).

The move had left both pages broken. Four separate faults, none of them
visible from the markup alone:

1. A stray "." on its own line in settings.html's script. One character, and
   the whole <script> failed to parse - so nothing on the page worked: no
   camera controls, no sync save, no setup list, no status cards. The page
   returned 200 and looked plausible, which is why it did not read as broken.

2. A second renderStatus() pasted in below the real one. Later definition
   wins, so the duplicate silently shadowed the function that drives the
   Satellite/Rover/Camera cards - those would never have populated.

3. The pasted Sync/Quick-actions JS still referenced the queue page's state:
   missions, meta, syncedAt, bannerState, missionFailures, poller, ageFrom,
   esc, and a #qFresh element that does not exist here. Every one was a
   ReferenceError waiting for its callback. Rewritten against this page's own
   data instead - /operator/api/missions supplies lastSyncedAt, pendingWrites,
   stale and counts, and reads local SQLite so it costs no Firestore quota.
   "Loaded" became "Missions held", which is a number that means something on
   a settings page.

4. .settings-grid declares two columns but had gained a fourth child, so it
   wrapped to 2x2: the left column clipped mid-card and Save overlapped the
   footer. Rebuilt as three columns - devices, the sync setting, then status
   and meta - each able to scroll internally so the page itself still does
   not.

Also dropped from Settings: the System health card, which listed Rover /
Camera / Satellite immediately beside the three richer stat cards showing the
same three things with hostnames, ports and camera controls; and the "
Diagnostics & settings" quick action, which linked to the page it was on.

On home.html, the rail removal had deleted the closing tags, the {% endif %}
and the entire <script> along with it, so the queue page was a Jinja syntax
error. Finished properly: single-column grid as intended, and the JS that
only existed to fill the rail (renderStatus, hrow, nrow, syncLabel,
connectionQuality, updateNet, the refresh/clear-cache handlers, the
YardStatus subscription) removed with it rather than left pointing at
elements that no longer exist.

Verified in a browser rather than by reading: both pages render, no console
errors, filters/search/info-tips/refresh all work, neither page scrolls, and
the three satellite routes plus the two kiosk pages all return 200. 202
satellite tests pass.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…ns (#52)

Three companion documents for the architecture diagram (AB#265):

- diagram-spec.md      the blueprint: what the diagram must show and why
- diagram-prompt.md    a self-contained, paste-ready generation prompt
- design-decisions.md  the reasoning behind each architectural choice,
                       written to be defended out loud - each entry gives the
                       forcing constraint, the decision, the alternative that
                       was rejected, where it lives in the code, and the
                       honest limitation

Committed on their own branch rather than folded into the yard-console PR
they happened to be sitting in the working tree beside: they are reference
documentation with a different audience and reviewer, and mixing them into a
UI/bug-fix diff would have buried both.

Known staleness, worth a follow-up rather than holding these back: both
diagram-spec.md and diagram-prompt.md list rover_physics.py as a live
component of the rover service. It is not - nothing imports it outside its
own test, and it has since been marked deprecated in favour of
rover-physics.ts, which now drives both mission-control and the yard monitor.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…wo diagrams (#56)

The satellite has listened on 3001 since the default moved, but the docs and
one template still said 5050. The template mattered: the monitor's footer
hard-coded :5050, so the TV mounted on the wall was displaying an address that
would not connect. The port was read only inside __main__, so no template could
see the real value; it is now a module-level SERVER_PORT passed to the monitor.

Also documents SATELLITE_PORT in the config table. Its absence is why the drift
survived: the port was configurable, undocumented, and its default moved while
every doc kept quoting the old one.

Adds the companion writeups for the network and deployment diagrams, and drops
rover_physics.py from the rover band in the diagram spec and prompt. It is
deprecated and nothing imports it outside its own test, so a diagram generated
from those files would have drawn a component that is not in the running
system. It moves to the deprecated group instead of vanishing, because it still
exists on disk for David's merge.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…at works (#58)

Three unrelated reports, all in the learner-facing app.

TWO CLEAR BUTTONS. The navbar field is input[type=search], and WebKit draws
its own clear button inside those. Ours is the one positioned with the filter
chips, themed, and carrying an accessible name, so the native pair is
suppressed rather than dropping type=search - that is what gives the field its
search semantics and Escape-to-clear.

BLOCKS OPENED OFF-SCREEN. Blocks keep the coordinates they were authored at,
and nothing centred the viewport on open, so a program built off to one side
opened on empty canvas and had to be hunted for. Affected all three ways in:
create, mission preview and remix, the last worst of all, since those
coordinates belong to whoever built the mission. scrollCenter, not zoomToFit -
the learner's zoom is theirs, and rescaling on open is the behaviour a
recenter button was removed for.

THE BELL WAS A PLACEHOLDER. It rendered an empty array with a comment saying
the backend would be wired later. It now watches recent completions and shows
a dot when one has landed since the learner last looked. Opening the panel
clears the dot; each row also has its own X, because an auto-clear that
silently fails leaves a dot nobody can get rid of.

Deliberately not scoped to the current learner: there is no login here, a
learner's own results already reach them by email, and "a rover just finished
someone's run" is the thing worth glancing up for.

Ordered by completedAt with the status checked in the browser. Filtering on
status in the query would make it composite, and Firestore refuses composite
queries until an index is deployed - the bell would have stayed dark in
production until an infra change landed. This relies on completedAt being
written only on completion and cleared on rerun, which the yard's mission_store
is the only writer of.

Cost is one listener over 8 documents, attached once per session because the
Navbar lives in the root layout: 8 reads to open the site, then one per actual
completion. Not a poll.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix(mission-control): one clear button, centred blocks, and a bell that works

Three unrelated reports, all in the learner-facing app.

TWO CLEAR BUTTONS. The navbar field is input[type=search], and WebKit draws
its own clear button inside those. Ours is the one positioned with the filter
chips, themed, and carrying an accessible name, so the native pair is
suppressed rather than dropping type=search - that is what gives the field its
search semantics and Escape-to-clear.

BLOCKS OPENED OFF-SCREEN. Blocks keep the coordinates they were authored at,
and nothing centred the viewport on open, so a program built off to one side
opened on empty canvas and had to be hunted for. Affected all three ways in:
create, mission preview and remix, the last worst of all, since those
coordinates belong to whoever built the mission. scrollCenter, not zoomToFit -
the learner's zoom is theirs, and rescaling on open is the behaviour a
recenter button was removed for.

THE BELL WAS A PLACEHOLDER. It rendered an empty array with a comment saying
the backend would be wired later. It now watches recent completions and shows
a dot when one has landed since the learner last looked. Opening the panel
clears the dot; each row also has its own X, because an auto-clear that
silently fails leaves a dot nobody can get rid of.

Deliberately not scoped to the current learner: there is no login here, a
learner's own results already reach them by email, and "a rover just finished
someone's run" is the thing worth glancing up for.

Ordered by completedAt with the status checked in the browser. Filtering on
status in the query would make it composite, and Firestore refuses composite
queries until an index is deployed - the bell would have stayed dark in
production until an infra change landed. This relies on completedAt being
written only on completion and cleared on rerun, which the yard's mission_store
is the only writer of.

Cost is one listener over 8 documents, attached once per session because the
Navbar lives in the root layout: 8 reads to open the site, then one per actual
completion. Not a poll.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* searchbar fix.

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…emo (#60)

* feat(infra): host Mission Control on a personal GCP project for the demo

Deploying to bt-impact-academy is blocked, and not on billing: the UCT team
inherits only roles/editor there, which has neither
iam.workloadIdentityPools.create nor setIamPolicy (infra/README.md records this,
verified with testIamPermissions). So the WIF pool cannot be created and the
public allUsers invoker binding cannot be granted, which is why every Deploy
staging run dies at auth with invalid_target. A personal project has an owner
with both, and no organization above it to police public ingress.

demo.tfvars repoints the existing Terraform with no code change. Everything was
already variable-driven except the state bucket, which backend.tf hardcodes and
`terraform init -backend-config` overrides, so moving back to Impact is a
re-init rather than a rewrite. Applied clean: 42 resources, both services live.

firebase_credential_source is the substantive change. The module mounted
FIREBASE_CLIENT_EMAIL and FIREBASE_PRIVATE_KEY from Secret Manager, but
firebase-admin.ts already prefers Application Default Credentials, and the
runtime service account holds roles/datastore.user on the project Firestore
lives in. Set to "adc" there is no service account key anywhere: none to store,
rotate or leak, and no PEM to get its newlines mangled on the way through
Secret Manager. It defaults to the old behaviour so an existing deployment does
not change identity underneath itself on the next apply.

cloudbuild.yaml and deploy-demo.sh are the manual equivalent of the CD workflow,
for getting a URL up without waiting on CI. They build on Cloud Build rather
than locally for two reasons: no machine here has a Docker daemon, and these are
Apple Silicon, so a local build produces linux/arm64 and Cloud Run runs amd64 -
the service starts and dies with "exec format error", which reads like a broken
app rather than a broken image.

This is temporary. Learner data belongs in Impact's project and a deployment
tied to a personal Google account cannot be handed over.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* style(infra): terraform fmt

Caught by the Terraform plan check, which reached its formatting step for the
first time now that the WIF variables point at a pool that exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(infra): point the backend at the bucket holding the live state

The Terraform plan check runs a bare `terraform init`, so it cannot be handed
a -backend-config the way a local run can. With backend.tf still naming
Impact's bucket, the workflow authenticated successfully and then failed at
Init on every PR touching infra/.

Documents the asymmetry at the point of the change: locally the backend is
overridable, in CI it is not, so whichever bucket holds the live state has to
be the one written here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…iew (#61)

* feat(mission-control): use the navbar rover as the icon and link preview

The tab showed the stock Next.js favicon, and pasting the URL anywhere - a
WhatsApp message, a slide, the submission - produced a preview with no image.

All four assets are the SAME centre crop of public/rover-hero.jpg that the
navbar already renders top-left, cropped the same way its object-cover
object-center does, so the rover in the tab is recognisably the rover on the
page rather than a second, differently-framed one:

  favicon.ico          256px, PNG-in-ICO so /favicon.ico keeps working
  icon.png             512px
  apple-icon.png       512px, for an iOS home-screen shortcut
  opengraph-image.jpg  1200x630, the standard link-preview size

metadataBase is the part that is easy to miss: Open Graph requires an ABSOLUTE
image URL, and without a base Next emits a relative one that a crawler resolves
against its own host, so the preview silently shows nothing. It reads
NEXT_PUBLIC_APP_URL, which is already set as a GitHub Actions variable and baked
in at build time, and falls back to localhost for development.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(mission-control): BMP-encode the favicon so the production build accepts it

The first version wrapped a PNG in an ICO container. Browsers read that fine
and `next dev` served it happily, but `next build` decodes icons with a Rust
image library that expects BMP data inside an ICO and failed with "unable to
decode image data". A dev-only pass is the worst kind: it looked correct right
up to the deploy.

Now a genuine BMP-encoded icon, with the two details that make ICO files
render as garbage when missed: the pixel rows are flipped to bottom-up (sips
writes top-down, legal in a BMP file, not legal inside an ICO), and the DIB
header declares double the real height to account for the 1-bit AND mask that
follows the pixels.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
The site described piloting a rover called Sparky and earning mission patches.
"Sparky" appears exactly once in the repository - in that string - and there is
no patch or badge feature anywhere in the codebase. It was leftover copy from an
earlier concept, and since this string feeds the meta description and both the
Open Graph and Twitter cards, it was the first thing anyone saw when the link
was shared.

Replaced with the actual flow: write a mission in blocks or Python, send it to a
real rover at the yard, watch the video of the run.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Two separate bugs, both invisible above 768px.

HORIZONTAL OVERFLOW. .workspaceSplitGrid declared grid-template-columns: 1fr.
A bare `1fr` is minmax(AUTO, 1fr), and that auto minimum lets the track grow to
its content's min-content width - so Blockly's canvas and the simulator pushed
the column wider than the phone and everything past the fold was unreachable,
with an ancestor's overflow:hidden swallowing the scrollbar that would have
hinted at it. Now minmax(0, 1fr).

CLIPPED SECOND PANEL. Both workspace pages set h-[calc(100vh-64px)] with
overflow-hidden. That is right where the panels sit side by side and a page
that never scrolls is the point. On a phone they stack, so the second panel -
the Blockly editor on the mission page, the simulator and the whole submit bar
on Create Mission - was rendered and then clipped out of existence. Pinned from
md up, free to grow below it.

The grid also sized both stacked panels into a single viewport height, leaving
each about 340px. Below 768px the grid grows and the page scrolls instead, with
a 70vh floor per panel; a phone scrolls anyway and half a screen of Blockly is
not usable.

Scoped to max-width 767px deliberately, not the lg breakpoint the columns use.
Tablets are the device the yard runs on and their layout is untouched:
verified main still computes to a pinned 960px there, and 736px with the
three-track drag divider at 1280px.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…estion (#64)

On a learner's FIRST submit there was no confirmation at all. There is a
"Mission sent!" banner in the submit bar, but the email prompt opens in the
same tick behind a full-screen backdrop that covers it, and the banner cleared
itself on a 5-second timer that ran while the modal was still up. Read the
prompt, press Skip, and the confirmation had already expired. The one moment
that should feel like an achievement said nothing.

A dialog now waits for the email decision instead of racing it, with copy that
depends on the answer:

  skipped  It is in the queue for the rover. No email needed - find it again
           any time under My History.
  saved    It is in the queue for the rover. We will email <address> once it
           has run.

The address is repeated back deliberately: that is the only chance to notice a
typo before the single notification goes to nobody.

Watching showEmailPrompt close covers Skip and Save with one path, because
LearnerContext.setLearnerEmail clears the prompt too. It dismisses on a tap
rather than a timer, a timer being what caused this.

Learners who already have an email saved skip the prompt entirely and get the
dialog straight away, which is why the confirmation cannot live inside the
prompt itself.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Team code filled in, and a note that the hosted site avoids needing a Firebase
project of your own to try it.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
`npm install && npm run dev` is what the README and the submission ReadMe both
tell you to do, and on a clean copy it fails: `sh: next: command not found`.
There are no npm workspaces here, so the root install brings in firebase and
typescript only - mission-control has its own package.json and nothing ever
installed it. Found by unzipping the submission and following our own
instructions.

A postinstall hook now installs mission-control too, so the documented command
is the true one. CI is unaffected: its mission-control job runs `npm ci` with
working-directory: mission-control and never reads the root manifest.

Verified against the actual submission zip: unpacked clean, `npm install`,
then the dev server booted and served HTTP 200.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
The ReadMe listed "Python 3.11 or later" as a prerequisite and then never said
how to make one, so the first thing anyone does is guess - and the common guess
is the Windows path, which fails silently-ish on macOS with "no such file or
directory: .venv/Scripts/activate".

Documents both platforms, and says plainly which requirements file to use:
the root one is the original PyQt6/OpenCV desktop simulator and has nothing to
do with Mission Control, but it is the obvious thing to reach for from the
repo root.

Verified by following the instructions from scratch in an empty directory:
fresh venv, the documented pip command, then 202 satellite and 99 rover tests
passing.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…lkthrough-v2

Add updated code walkthrough v2
…ode (#71)

* docs: rework the code walkthrough against the rubric

Three gaps, plus a rendering bug found while fixing them.

MISSING WHYS. Most sections restated what the code does. Every section now
argues the choice against the alternative we did not take: TypeScript over
JavaScript because a mission changes shape four times between browser and
operator console and a renamed field should be a compile error, not an
undefined in front of a child; Flask over Django because the satellite shares
a single-core Pi with a camera stream; Python because 4tronix ship the rover
library in Python and anything else means a bridge process.

The stack is now grouped by the job each tool does (learner web app,
authoring, yard services, data and platform, outbound) with a real brand logo
and a one-line reason per component. Practicality, cost and scalability get a
section of their own plus a trade-offs block on the sections where they bite -
including the 576,000-reads-a-day figure the naive sync worker would have cost.

THE YARD WAS MISSING. The document described Mission Control as if it were the
whole system. It now has a section on the satellite and operator console, the
SQLite mirror and outbox, the execution lease, and the push-before-pull
ordering rule, and the UI and navigation sections cover both audiences rather
than only learners.

HIGHLIGHTER BUG. hl() ran four independent replace passes; the comment pass
emitted <span class="c-com"> and the string pass then matched the "c-com"
inside that markup and wrapped it again, so every snippet containing a # or //
rendered as class=class="c-str">"c-com">#... Replaced with a single
left-to-right tokeniser. Verified across all 14 sections.

Test counts corrected to the measured 538 (233 Jest, 202 satellite, 103 rover);
the document claimed 44.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: make the walkthrough read like slides, not prose

Same content, different register. The paragraphs moved out of the document
and into the presenter's script, because a screen behind a speaker is for
scanning and a paragraph on it competes with the person talking.

Every "why" is now bullets. Stack groups carry 2-4 lines each instead of a
block of prose, the trade-off tables are tightened, and leads are one line.
Section 6 also splits its four validation layers into a table so the point -
that only the fourth one is actually load-bearing - is visible rather than
buried mid-paragraph.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…ed (#68)

Two faults in the one control that exists for when something is going wrong.

STOP opened a confirmation dialog and only cut the motors after a second
click. Every other action on the page is bookkeeping that can wait a second;
this one halts a machine moving across a room with children around it. The
asymmetry decides it: an accidental stop costs one re-send, since the mission
returns to Queued, while a stop that lands a second late costs whatever the
rover hit. The warning belongs before the press, which is what the note under
the button already says - and it said exactly what the dialog said, so the
dialog was repeating a sentence the operator had just read.

It also matched nothing: the tablet's stop has always called /api/queue/clear
immediately, so the person responsible for safety had the slower control.

The second fault was found while verifying the first. act() re-enabled the
button only in its catch, so a SUCCESSFUL stop left it disabled reading
"STOPPING…" until the page was reloaded. Every other action's button lives in
the rail, which loadMission() rebuilds from scratch; the stop button is pinned
outside the rail precisely so it never scrolls away, which also means nothing
ever restores it. An emergency control that works once per page load is not an
emergency control. Restoring now happens in a finally, on every outcome.

Verified against a running rover with a 30-second drive: one press halts it and
returns the mission to Queued, twice in a row, with no dialog and the button
live again afterwards.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Serve learners via HTTPS/HTTP LB instead of allUsers on Cloud Run, matching
Impact domain-restricted sharing. Lock ingress to the LB and disable invoker
IAM per Google's documented escape hatch.
…e origin (#74)

The status email's only CTA pointed at /history, a generic list. A learner who
is told their mission completed wants that run, not a list to search. The
primary button now goes to /missions/<id>; history stays as the secondary link.

Two things had to change for that link to be correct in production.

The three routes that send mail each rebuilt `${NEXT_PUBLIC_APP_URL}/history`
by hand, so the service could only ever be handed one URL. They now pass the
base origin and MissionNotificationService derives both links, which is also
one fewer place for them to drift apart.

And the origin itself could not come from NEXT_PUBLIC_APP_URL. Next inlines
every NEXT_PUBLIC_* reference when `next build` runs and freezes it in the
image (next/docs 01-app/02-guides/environment-variables.md), while our prod
deploy promotes the exact digest already serving on staging. Prod would have
emailed learners links to the staging domain. resolveAppUrl() prefers APP_URL,
which has no NEXT_PUBLIC_ prefix and so stays a real runtime lookup that
Terraform sets per service; NEXT_PUBLIC_APP_URL remains as a local-dev
fallback. layout.tsx's metadataBase had the same freeze and is fixed with it.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
#73)

* infra: hostnames for Impact, so the load balancers can serve HTTPS

Both environments are currently plain HTTP on bare load-balancer IPs. The app
collects learner email addresses and the learners are children, so those
addresses cross the network in clear text. #69 built the mechanism for fixing
this; this file supplies the missing input.

Setting `domains` makes Terraform provision a Google-managed certificate and
an HTTPS forwarding rule per environment. Planned against live state:
6 to add, 2 to change, 0 to destroy. The existing HTTP forwarding rules are
untouched, so there is no window where the site is unreachable.

The DNS records have to exist BEFORE the apply. Google validates ownership by
resolving the hostname to the load balancer, so applying first leaves the
certificate in PROVISIONING until DNS catches up. The records needed are in
the file header.

Hostnames are a proposal, not a decision: sapient.rocks is the product's own
domain and its DNS is on GoDaddy, but if Impact would rather serve this from
a domain of theirs, only this file changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* infra: point Impact at the marsyard domains, and set APP_URL per environment

David provided marsyard.sapient.rocks (prod) and marsyard.labs.ws (staging);
the A records are live in GoDaddy against the reserved LB IPs, so the managed
certs can now validate.

Also sets the app's public origin as a RUNTIME env var. Prod promotes the exact
image digest already serving on staging rather than rebuilding, and Next freezes
every NEXT_PUBLIC_* value when `next build` runs, so the origin baked into that
image is staging's. Passing APP_URL per service means prod resolves its own
domain from the same bytes. The app reads it in a separate change; setting it
here is a no-op until then.

Resend now sends from missions@marsyard.sapient.rocks. resend_sandbox_recipient
stays unset: while it has a value every mission email is redirected to one
inbox and no learner receives mail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(ci): point the plan workflow at the live state, and plan with impact.tfvars

The terraform-plan check has failed on every infra PR since the deployment
moved to Impact. Two separate causes, and fixing only the first would have been
worse than leaving it red.

backend.tf still named mars-rover-cloud-platform-tfstate, the old demo
project's bucket, so `terraform init` 403'd: the read-only plan identity is
granted on var.tfstate_bucket, which is bt-impact-academy-tfstate. The comment
in backend.tf already warned that CI runs a bare init and cannot override the
bucket, so this file has to track wherever the live state lives.

With init fixed the plan then ran, and proposed destroying six resources. The
workflow planned with no -var-file, so `domains` fell back to its empty default
and the plan read as: tear down both managed certs, both HTTPS target proxies
and both HTTPS forwarding rules, and revert APP_URL and RESEND_FROM_EMAIL to
their defaults. The step also writes -out=tfplan, so that was a real destroy
plan sitting in CI. Planning with the same var-file the apply uses brings it
back to 0 to add, 2 to change, 0 to destroy.

The remaining 2 changes are drift: `gcloud run deploy` writes a scaling block
back onto the service after each CD run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…d key (#75)

Every server-side Firestore call on Impact's deployment fails:

  POST /api/missions -> 500
  {"error":"Failed to parse private key: error:1E08010C:DECODER routines::unsupported"}

FIREBASE_PRIVATE_KEY is mounted from Secret Manager but was never seeded with
a real key, so firebase-admin takes the service-account path and dies on the
CHANGE_ME placeholder. main.tf already called this out as the worst of the
three states. No mission can be submitted at all, and no learner email is ever
sent, because notifyStatusChange is never reached.

Switching to ADC removes the key entirely rather than seeding one. Firestore
lives in this same project and each runtime SA already holds
roles/datastore.user, so there is nothing to store, rotate or leak.
firebase-admin picks that path when neither FIREBASE_CLIENT_EMAIL nor
FIREBASE_PRIVATE_KEY is set.

NOT YET APPLIED. The apply also removes four secretAccessor bindings that are
no longer needed, and that needs secretmanager.secrets.setIamPolicy, which the
current operator identity does not hold:

  Error 403: Permission 'secretmanager.secrets.setIamPolicy' denied for
  resource 'projects/bt-impact-academy/secrets/firebase-private-key'

Needs Gavin or Werner to grant it, or to run the apply.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…76)

notifyStatusChange already returns a typed outcome - sent, or not sent with a
reason - specifically so a failure is not invisible. The notify route awaited
it and threw it away, returning a bare {success: true}. So "sent", "skipped
because the learner has no address" and "Resend rejected it" were
indistinguishable to the caller.

That is not theoretical. Testing the deep link against staging, the email
silently did not arrive and the route reported success either way, so there
was nothing to separate a broken template from a Resend domain that had not
finished verifying. It was the latter.

Sending stays best-effort: still always HTTP 200, because the caller is the
yard operator console and a provider outage must not fail an operator mid
mission. The reason simply travels back in the body.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Both files were one-off presentation artefacts for the iteration-2 code
walkthrough, generated rather than maintained, and nothing in the repo or the
build references either. The walkthrough is now given live in the editor
against the real files, so a separate HTML copy of the same content can only
drift from the code it describes.

Closes PR #72, which only ever edited the v2 file.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…sticking (#78)

* fix(yard): stop the needs-review count getting permanently stuck

An operator reported a review count frozen at 7, including missions that had
since been rerun successfully.

resolve_review was the ONLY code path that ever cleared needs_review, and it
has no UI at all. So a mission flagged by recovery, then rerun and completed
normally, kept the flag forever. The count could only go up, and no action
available to an operator could bring it down.

Three separate faults produced that:

Nothing cleared the flag on a normal finish. release_mission now clears it on
any terminal status, because a completed mission is by definition no longer
ambiguous. A rollback to 'queued' deliberately keeps it: requeuing does not
answer the question the flag asks.

Rerun refused the exact missions that needed it. A flagged mission sits in
'processing', and for_rerun required a terminal status, so it returned
'not-terminal' - the recovery flow's own missions were the ones rerun could not
touch. Rerun now accepts a flagged 'processing' mission and clears the flag,
because the operator pressing rerun IS the human decision recovery.py
deliberately refuses to make on its own. A processing mission that is NOT
flagged still cannot be rerun; the exception is scoped to recovery.

The list was hidden. The banner rendered its count in the toggle header with
the missions collapsed underneath, so the operator saw "7 missions need review"
and no missions. It now starts expanded: a count you cannot see the missions
behind is just an unexplained number.

Also excludes soft-deleted missions from the count, which could otherwise
inflate it with something the operator cannot open.

Adds clear_stale_review_flags.py for the flags already on disk, which the code
fix cannot reach because nothing will touch those missions again. Dry-run by
default, matching set-operator-role.mjs. It clears terminal missions only and
leaves anything still 'processing' flagged, since that is genuine ambiguity.
Writes go through the outbox so Firestore gets the correction too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: reject out-of-range rover arguments, and stop errored runs failing silently

Mission "Elsje" carried rover.forward(6300). It passed validation, reached the
rover, and the rover refused to run it. The operator was shown nothing: the
mission simply sat in 'processing' with no reason given.

Two independent gaps produced that.

Nothing checked the arguments. ast-allowlist-analyzer is pattern-based despite
its name (its own header says so: full AST parsing would need a Python runtime
in the browser), and every check asked only which NAMES appear, never what they
were called with. Speeds are a percentage of full power on the 4tronix API, so
ROVER_ARGUMENT_LIMITS pins them to 0-100 and sleeps to 0-60, well under the
sandbox's own wall-clock limit so a learner gets a sentence instead of a
watchdog kill. Only the first numeric argument is checked, which is where every
speed and duration sits; guessing at servo indices or RGB triples would reject
valid programs. Anything non-literal is left alone deliberately - this layer is
fast feedback, not the safety boundary. The sandbox on the Pi is that.

The rover's own error was never read. It records status='error' with the reason
on the instruction (service.py:405), but completed_mission_ids only ever
collected 'completed' entries, so a mission whose code could not run stayed in
'processing' forever and the reason was discarded. rover_outcomes now returns
both, and an errored run is FLAGGED FOR REVIEW carrying the rover's own text,
never marked failed - this module may not assert an outcome nobody established,
and 'failed' reaches the learner as a run that went wrong when the truth is the
code never ran. The reason is truncated: it lands on a mission document and in
the operator's banner, and a runaway traceback should do neither.

Also corrects the allowlist docstring, which documented forward(distance_cm)
when the real API takes a speed.

Together with the needs-review fix on this branch, an operator now sees the
mission in the review banner with "rover could not run it: SyntaxError ..."
rather than a queue entry that never moves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
tests/test_blockly_codegen.py and tests/test_status_page.py are skipped by
conftest whenever playwright is absent, and requirements-test.txt deliberately
omits it so a plain `pytest tests` works on any machine. Both of those choices
are right on their own. Together they meant the two files ran on nobody's
machine unless someone installed playwright by hand - including CI.

What was ungated: the yard editor's Blockly-to-Python generator, which every
learner's program passes through before it reaches a rover.

This is not hypothetical. Moving the block definitions into a module shared
with mission-control turned code.html's script into an ES module; module scope
is not global, the tests drive the page through globals, and all seven codegen
tests broke. Nothing in CI would have reported it.

A separate job from yard-satellite because it is the slow one - it downloads a
browser and needs network access for Blockly from unpkg. Keeping it separate
leaves that job's ~2s feedback intact. Chromium is cached, keyed on a pinned
playwright version for the same reason terraform_version is pinned: a new
release should not change what CI does under the team.

19 tests, ~27s locally.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Groundwork for moving the operator surface into mission-control. No new
features; this clears things the next phases would otherwise build on top of.

Scope the Firestore pull to this satellite's yard. sync_from_firestore
accepted a yard_id and never used it, so every mirror ingested EVERY yard's
missions: another yard's queue showed up in this console and was dispatchable
from it, onto a rover in a different building, and the read budget grew with
yards we do not serve. newest_submitted_at is scoped too, because a mirror
already holding a newer foreign row would otherwise report it as the
incremental cursor and skip this yard's own missions - a permanently empty
queue with nothing logged to say why. Adds the two composite indexes the
scoped queries need; the existing status+yardId+submittedAt index cannot serve
them because status is the index prefix and neither query filters on it.

Expose needsReview/reviewReason in the console's mission contract. The home
page already shows a needs-review banner linking to each flagged mission, but
the mission page could not see the flag, so the banner led operators to a page
offering nothing. The resolve endpoint has existed all along with no way to
reach it. This is the data half; the control comes in phase 3.

Delete the Firestore transaction twin of acquire_mission
(_get_mission_ref/_transactional/_acquire) and the autouse test fixture that
existed only to patch it. Locking moved to SQLite in PR 3 and no route has
called these since.

Delete PATCH /api/missions/[id]. Its docstring claims the operator console and
execution agent use it; nothing does. It was an unauthenticated
any-mission-to-any-status write. The status-email path it also covered keeps
19 tests via /notify.

Delete set_operator_claims.py. It wrote only the custom claim and passed a
bare dict to set_custom_user_claims, replacing the whole claims object. The
Node script writes the claim and the users/{uid} ledger, merges, revokes
refresh tokens, and is dry-run by default. Docs now point there.

Add APP_ENV and a non-production banner (standup 2026-08-20). Runtime rather
than NEXT_PUBLIC_ for the same reason as APP_URL: prod promotes the image
built during the staging deploy, so a build-time value would label prod as
staging. An operator about to dispatch a real rover must never be unsure which
environment they are in. yardId moves out of a JSX literal into config
alongside it.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…ce (#79)

code.html carried its own copy of all 16 block definitions, the toolbox and
the Python generator - byte-identical to roverBlockly.ts, kept in step by a
comment in each copy asking humans to mirror their changes into the other:

    These MUST stay compatible with the yard editor ... When you change a
    block here, mirror it there (and vice-versa).

That is a build step written as an instruction to people, and it is why
codegen fixes landed on one editor and not the other. Two surfaces that are
supposed to emit identical rover programs were maintained separately.

roverBlockly.ts turned out to have zero imports - it takes Blockly as a
parameter - so it drops straight into the existing build-roversim pipeline
that already ships the simulator to the satellite. code.html now imports the
compiled output, and `npm run check:roversim` (already in CI) fails if that
committed output drifts from the TypeScript.

Removes 526 lines from code.html. The file moves to src/lib alongside its
consumers, which also fixes a layering inversion: lib/parseRoverCode and
lib/simulateCommands were importing from components/.

Deletes roversim-shim.d.ts and its drift guard. The shim existed because the
build could not see the real file ("pulls in the whole Blockly package" - it
does not), so SimulationCommand was declared twice and compared by a custom
check. One definition needs no guard.

The page script becomes type="module" to import at all. Safe for handlers -
the page has none - but module scope is NOT global, and the Playwright codegen
tests drive the page through `workspace` and the generator by name. Those are
now published on window deliberately, the same way monitor.html publishes
__yardMonitorHooks. The tests call the shared generator directly, so they now
cover the module both editors use rather than a yard-only copy.

WORTH KNOWING: that regression would have shipped. requirements-test.txt
deliberately omits playwright and conftest skips those files when it is
absent, so tests/test_blockly_codegen.py never runs in CI. It was caught only
by installing playwright locally. All 7 pass.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…f local (#82)

The live staging site displayed "LOCAL DEVELOPMENT".

APP_ENV was missing from the Cloud Run services, which is one half. The other
half is that setting it would not have been enough: the banner is a server
component in the root layout, and the layout is statically prerendered, so
resolveEnvironment() ran while `next build` ran - long before any runtime
variable exists - and the answer was baked into the HTML.

Moving the value off NEXT_PUBLIC_ and onto a runtime variable achieved nothing
while the RENDER was still build-time. That is the same class of mistake one
level up, and it is worth naming: prod promotes the exact image built during
the staging deploy, so anything decided at build time is decided as staging.

`await connection()` opts the component into dynamic rendering so the variable
is read per request (next/docs 01-app/02-guides/environment-variables.md). The
cost is that every route becomes server-rendered on demand rather than
prerendered. Accepted: `/` is already a client component that fetches Firestore
in the browser, so the prerendered HTML was an empty shell, and a banner that
lies about which environment an operator is dispatching a rover from is worse
than a shell that costs a render.

Verified from ONE build, which is the property that matters:

    APP_ENV=staging  -> "STAGING - not the live site..."
    APP_ENV=prod     -> no banner at all
    APP_ENV unset    -> "LOCAL DEVELOPMENT"

Also switches ts-jest to the automatic JSX runtime. The config asked for the
classic transform, which emits React.createElement and needs React in scope, so
no component in this repo could be tested at all - there are no .tsx tests. The
automatic runtime is what Next itself uses; all 243 existing tests still pass.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
A learner previously saw a blank mission-name field they had to fill
in or roll themselves, and could still type in an invalid/empty name.
Now a name is generated up front (and re-generated after each submit)
using the existing generator, and the field only supports re-rolling
via the dice button, not free text - so mission creation can never
result in an unnamed mission. Simplified the now-unreachable
name-validation plumbing across MissionWorkspace/MissionSubmitBar/
MissionNameInput accordingly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants