Compare commits

..

No commits in common. "11f609eb3d4b5d0f0248795b63edbb7b14cf4877" and "4777b28698f2400592a32dcbb4bf2467c95e3971" have entirely different histories.

144 changed files with 1237 additions and 14015 deletions

View File

@ -99,27 +99,3 @@ RUST_RUNTIME_TIMEOUT_MS=300000
OLLAMA_URL=http://localhost:11434/v1 OLLAMA_URL=http://localhost:11434/v1
OLLAMA_MODELS= OLLAMA_MODELS=
FEATURE_FLAGS_ENABLED=true FEATURE_FLAGS_ENABLED=true
# ── Fleet ops/observability ───────────────────────────────────
# Bearer token Prometheus uses to scrape GET /api/fleet/metrics/prom. Must match
# the `credentials` in services/monitoring/prometheus/prometheus.yml. When unset,
# the endpoint requires an admin JWT instead (so it is never world-readable).
FLEET_METRICS_TOKEN=changeme-fleet-metrics-token
# Fleet feature flags (default OFF): cost/latency routing, per-engine breaker,
# per-product/-engine budget enforcement, and multi-tenant access enforcement.
FLEET_COST_ROUTING=
FLEET_ENGINE_BREAKER=
FLEET_BUDGETS=
FLEET_TENANT_ENFORCEMENT=
# Capacity autoscaling signal (§5) — tunes the advisory scale recommendation
# served at GET /api/fleet/autoscale[/all] (consumed by an external scaler).
# All optional; unset keys fall back to the in-code defaults shown below.
FLEET_AUTOSCALE_SCALE_OUT_PCT=85
FLEET_AUTOSCALE_SCALE_IN_PCT=20
FLEET_AUTOSCALE_MAX_STEP=5
FLEET_AUTOSCALE_MIN_SEATS=0
# Anti-flap cooldown (seconds): the /fleet/autoscale endpoints suppress a
# direction reversal (scale_in after scale_out, or vice versa) within this
# window so a consumer cannot thrash capacity. A critical scale-out (queued work
# with zero usable capacity) always bypasses the cooldown. Default 300.
FLEET_AUTOSCALE_COOLDOWN_SEC=300

2
.gitattributes vendored
View File

@ -1,4 +1,2 @@
* text=auto eol=lf
# Bash scripts must use LF so they run in WSL/Linux # Bash scripts must use LF so they run in WSL/Linux
*.sh text eol=lf *.sh text eol=lf

View File

@ -48,30 +48,3 @@ jobs:
- name: Test release package - name: Test release package
run: pnpm --filter @bytelyst/errors test run: pnpm --filter @bytelyst/errors test
e2e-fleet:
name: Fleet E2E (Playwright)
runs-on: [ubuntu-latest, bytelyst, hostinger]
container:
image: node:20-bookworm
timeout-minutes: 25
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
github-server-url: https://gitea.bytelyst.com
- name: Install pinned pnpm
run: |
npm install -g pnpm@10.6.5
pnpm --version
- name: Install dependencies
run: HUSKY=0 pnpm install --frozen-lockfile
- name: Install Playwright browser + system deps
run: pnpm --filter @bytelyst/tracker-web exec playwright install --with-deps chromium
- name: Run fleet e2e
run: pnpm --filter @bytelyst/tracker-web test:e2e

1
.gitignore vendored
View File

@ -3,7 +3,6 @@ dist/
coverage/ coverage/
.DS_Store .DS_Store
*.tsbuildinfo *.tsbuildinfo
graphify-out/
# Env / Secrets # Env / Secrets
.env .env

View File

@ -1,15 +0,0 @@
# Build output / deps
dist/
build/
node_modules/
coverage/
.next/
# Generated design-token outputs (produced by @bytelyst/design-tokens)
packages/design-tokens/generated/
# Mirrored AI chat-history / repo-doc snapshots. These are COPIES of files from
# other repos, refreshed periodically by the chat-history sync. Prettier must
# NOT reformat them — doing so causes permanent ping-pong churn (the generator
# rewrites the source style, prettier rewrites it back on every commit).
__LOCAL_LLMs/

View File

@ -12,17 +12,15 @@
## What's here ## What's here
Quick, dense reference cards for the terminal AI agents we use to delegate coding work, Quick, dense reference cards for the three terminal AI agents we use to delegate
plus an operational guide for running them **non-stop overnight**. Each sheet is coding work. Each sheet is **task-oriented** — commands, modes, session management,
**task-oriented** — commands, modes, session management, config, and the ByteLyst-specific config, and the ByteLyst-specific guardrails — not a marketing overview.
guardrails — not a marketing overview.
| CLI | Cheat sheet | Best for | | CLI | Cheat sheet | Best for |
| ------------------ | -------------------------------------------- | ------------------------------------------------------------------------------- | | ------------------ | -------------------------------------------- | ------------------------------------------------------------------------------- |
| 🤖 **Devin** | [`devin-cli.md`](./devin-cli.md) | Long-running autonomous sessions; delegate a scoped roadmap and walk away | | 🤖 **Devin** | [`devin-cli.md`](./devin-cli.md) | Long-running autonomous sessions; delegate a scoped roadmap and walk away |
| 🟣 **Claude Code** | [`claude-code-cli.md`](./claude-code-cli.md) | Interactive pair-programming in the terminal; deep multi-file edits with review | | 🟣 **Claude Code** | [`claude-code-cli.md`](./claude-code-cli.md) | Interactive pair-programming in the terminal; deep multi-file edits with review |
| 🟢 **Codex CLI** | [`codex-cli.md`](./codex-cli.md) | Fast local edits + scriptable `exec` runs in CI / one-shot automation | | 🟢 **Codex CLI** | [`codex-cli.md`](./codex-cli.md) | Fast local edits + scriptable `exec` runs in CI / one-shot automation |
| 🌙 **Long-running jobs** | [`long-running-jobs.md`](./long-running-jobs.md) | Running ANY of the above non-stop, unattended overnight (sleep/disconnect survival + best practices) |
--- ---
@ -97,4 +95,4 @@ and the per-repo `AGENTS.md`. Put them in the agent's opening prompt every time:
3. Add it to the table above. 3. Add it to the table above.
4. Commit: `docs(cheatsheets): update <cli> CLI cheat sheet`. 4. Commit: `docs(cheatsheets): update <cli> CLI cheat sheet`.
_Last updated: 2026-05-30_ _Last updated: 2026-05-28_

View File

@ -1,190 +0,0 @@
# 🌙 Overnight / Long-Running Agent Runs — Cheat Sheet
> **What it is:** how to run a multi-hour, unattended AI coding job (Devin / Claude Code /
> Codex / Copilot CLI) **non-stop** without it dying when the machine sleeps or the
> terminal closes — plus the ByteLyst best practices that make an unattended run *safe*.
> **Best for:** delegating a scoped roadmap (e.g. a 10-hour Phase build) and walking away
> overnight.
> **Companion docs:** the per-CLI sheets ([`devin-cli.md`](./devin-cli.md),
> [`claude-code-cli.md`](./claude-code-cli.md), [`codex-cli.md`](./codex-cli.md)) and the
> canonical [`../SKILLS/agent-behavior-guidelines.md`](../SKILLS/agent-behavior-guidelines.md).
> 🧠 **The whole problem in one line:** an unattended run has two enemies — the **machine
> sleeping** and the **terminal/session dying**. `caffeinate` kills the first, `tmux` the
> second, and a **checkpoint file** saves you from everything else (reboot, crash, power loss).
---
## The two failure modes (and what fixes each)
| Failure mode | What happens | Fix |
| ------------ | ------------ | --- |
| **Machine sleeps** (idle timeout / lid closed) | CPU + network suspend → job freezes mid-step, connections drop | **`caffeinate`** (macOS) / `systemd-inhibit` (Linux) — keep it plugged in, lid open |
| **Terminal/session dies** (window closed, app crash/update, SSH drop, logout) | Job receives `SIGHUP` and is killed | **`tmux`** / `screen` — the job is owned by the tmux server, not the window |
| **Reboot / crash / power loss** | Everything dies, including tmux | **Checkpoint + resume** — the job writes progress to a file; re-running resumes it |
`caffeinate` and `tmux` are **complementary**, not alternatives — use both for a real overnight run.
## `caffeinate` (macOS) — stop the Mac from sleeping
```bash
caffeinate -dimsu <your-command> # stays awake only while <command> runs
```
| Flag | Prevents |
| ---- | -------- |
| `-d` | **display** sleep |
| `-i` | **idle** sleep |
| `-m` | **disk** sleep |
| `-s` | system sleep (on AC power) |
| `-u` | declares **user active** |
> ⚠️ **Lid caveat:** `caffeinate` keeps the Mac awake, but **closing the lid still sleeps
> most Macs** unless on AC power with an external display (clamshell) or the right pmset.
> For overnight: **keep it plugged in and the lid open.**
## `tmux` — survive a closed terminal / disconnect
```bash
tmux new -s phase3 # start a named, detachable session
# ... launch your job inside it ...
# Ctrl-b then d # DETACH — job keeps running with no terminal attached
tmux attach -t phase3 # reattach later (after reconnect, or a new window)
tmux ls # list running sessions
```
- **Local Mac:** still useful — the job survives **closing the terminal app, an app
crash/auto-update, or logout** (a plain terminal job dies on all of these). It does
**nothing** for sleep — that's `caffeinate`'s job.
- **Over SSH:** essential — the job survives the SSH connection dropping.
**Linux equivalents:** `systemd-inhibit --what=idle:sleep <cmd>` (sleep), `screen` or
`tmux` (session), or `nohup <cmd> &` + `disown` (cheapest detach, no reattach).
## Putting it together
```bash
tmux new -s phase3
# inside the session:
caffeinate -dimsu <agent-cli> <all-allowed flag> "<the overnight prompt>" 2>&1 | tee ~/phase3.log
# Ctrl-b d to detach and walk away; tmux attach -t phase3 in the morning
```
`| tee ~/phase3.log` captures the full run to disk so you have the log even if the
scrollback is gone.
### Shortcut: the `longrun` helper
The ByteLyst alias set (`learning_ai_devops_tools/aliases/`) ships a `longrun` helper that
does all of the above in one command — detached `tmux` + `caffeinate` (macOS) + a
timestamped log:
```bash
longrun phase3 <agent-cli> <all-allowed flag> "<the overnight prompt>"
# -> starts a detached, kept-awake, logged session named "phase3"
ta phase3 # reattach (alias for tmux attach -t)
tail -f ~/longrun-phase3-*.log # follow output
tmux kill-session -t phase3 # stop it
awake <cmd> # macOS: run any command keeping the machine awake
```
Install the aliases via `learning_ai_devops_tools/aliases/install.sh` (see that folder's
README).
---
## Best practices for an unattended agent run
1. **Green baseline first.** Run the verify gate (`pnpm build && pnpm test`, browsers
installed) **before** launching, so the agent starts from a known-clean state — never
debug a pre-existing red suite at 2am.
2. **Self-contained roadmap, slice-by-slice.** Point the agent at a doc that encodes
scope, per-slice verify commands, and a done-definition (see [`../PROMPTS/`](../PROMPTS/)).
One slice = one commit + push.
3. **Checkpoint + resume.** The job must write progress to a file (e.g.
`docs/<phase>-progress.md`: slice, status, commit SHA, gate result). If it dies for any
reason, **re-running the same prompt resumes** from the first not-DONE slice. This is
your safety net for the failures `caffeinate`/`tmux` can't catch.
4. **Failure protocol, not thrash.** Tell it: max N honest attempts per slice, then commit
`wip(...) BLOCKED:` + mark FAILED + move to the next **independent** slice. Order slices
so the independent ones come first.
5. **Never merge unattended.** Push to a feature branch and open **one PR** — a human
reviews + merges in the morning. The agent must **never touch `main`**.
6. **Tests are sacred.** Never weaken/skip a test to go green — fix the code (see the
canonical guidelines). State this explicitly in the prompt.
7. **Scope + push-auth ready.** Cache git credentials / `gh auth login` first so the
overnight `push` never blocks on a prompt. Confirm push rights to the target remote.
8. **All-allowed flag.** Launch with the CLI's auto-approve flag so it never pauses for
permission (`--permission-mode dangerous` for Devin, `--dangerously-skip-permissions`
for Claude Code, `--dangerously-bypass-approvals-and-sandbox` for Codex — confirm with
`<cli> --help`).
## ByteLyst-specific gotchas (the ones that silently fail overnight)
- **Corp proxy.** `pnpm install` and Playwright browser downloads will **TLS-fail** behind
the intercepting proxy unless `NODE_EXTRA_CA_CERTS` (proxy CA) is set. **Prefer running
off-corp** for overnight jobs — simplest reliable path. (A proxy-blocked `pnpm install`
is the classic cause of an overnight job that "did nothing".)
- **Playwright e2e.** Slices with browser tests need `npx playwright install --with-deps`
**before** the run, or the e2e gate fails.
- **Workspace, not registry.** `@bytelyst/*` link via `workspace:*` from sibling
`packages/*` — run from the monorepo so they resolve locally; no registry needed for the
build itself.
- **Side-by-side repos.** If the prompt file lives in a sibling repo (e.g. the gigafactory
job lives in `learning_ai_devops_tools` but runs in `learning_ai_common_plat`), clone
**both under the same parent** so the `../` relative path resolves.
---
## Worked example — the gigafactory Phase 3 overnight run
```bash
# 1) bootstrap a clean baseline (once)
cd ~/code/mygh/learning_ai_common_plat && git pull && corepack enable \
&& pnpm install && pnpm build && pnpm test \
&& (cd dashboards/tracker-web && npx playwright install --with-deps)
cd ~/code/mygh/learning_ai_devops_tools && git pull # prompt + roadmap repo
# 2) launch non-stop
cd ~/code/mygh/learning_ai_common_plat
tmux new -s phase3
caffeinate -dimsu <agent-cli> <all-allowed flag> \
"Read ~/code/mygh/learning_ai_devops_tools/agent-queue/docs/jobs/phase3-overnight.md and execute it exactly: branch feat/gigafactory-phase3 off origin/main, implement Phase 3 slice-by-slice, verify each slice green before the next, checkpoint to docs/gigafactory-phase3-progress.md, commit+push per slice, open ONE PR and NEVER merge, never weaken tests (use the 3-attempt failure protocol), end with the consolidated report." \
2>&1 | tee ~/phase3.log
# Ctrl-b d to detach
```
In the morning: `tmux attach -t phase3`, read the consolidated report, then review +
merge the PR slice-by-slice.
## Troubleshooting
| Symptom | Likely cause | Fix |
| ------- | ------------ | --- |
| Job froze partway, no error | Mac slept (idle or **lid closed**) | Use `caffeinate -dimsu`; plug in; lid open |
| Job vanished when I closed Terminal | No tmux — `SIGHUP` killed it | Run inside `tmux`; reattach with `tmux attach` |
| "Did nothing" / 0 commits overnight | `pnpm install` TLS-failed on corp proxy | Run **off-corp**, or set `NODE_EXTRA_CA_CERTS` |
| e2e slice failed immediately | Playwright browsers not installed | `npx playwright install --with-deps` before launch |
| Push at end blocked / hung | git/gh auth not cached | `gh auth login` / cache credentials first |
| Re-ran prompt, it redid finished work | No checkpoint file read | Ensure the job reads `*-progress.md` and resumes |
## Quick-reference card
```text
caffeinate -dimsu <cmd> # macOS: stay awake while <cmd> runs (lid open + on AC!)
systemd-inhibit --what=idle:sleep <cmd> # Linux equivalent
tmux new -s <name> # detachable session (Ctrl-b d = detach)
tmux attach -t <name> # reattach (tmux ls = list)
... | tee ~/run.log # capture full output to disk
# launch: tmux -> caffeinate -dimsu <cli> <auto-approve flag> "<prompt>" | tee log
# safety net: checkpoint file + re-run prompt to resume; push to branch, never merge
```
---
**Related:** [`devin-cli.md`](./devin-cli.md) · [`claude-code-cli.md`](./claude-code-cli.md) ·
[`codex-cli.md`](./codex-cli.md) · [`../PROMPTS/`](../PROMPTS/) ·
[`../SKILLS/agent-behavior-guidelines.md`](../SKILLS/agent-behavior-guidelines.md)
_Last updated: 2026-05-30 · confirm CLI auto-approve flags against your installed version (`<cli> --help`)._

View File

@ -1,9 +1,9 @@
Last refresh: 2026-05-31T06:31:25Z (2026-05-30 23:31:25 PDT) Last refresh: 2026-05-29T17:09:25Z (2026-05-29 10:09:25 PDT)
Cascade conversations: 50 (537M) Cascade conversations: 50 (502M)
Memories: 138 Memories: 138
Implicit context: 20 Implicit context: 20
Code tracker dirs: 64 Code tracker dirs: 43
File edit history: 5408 entries File edit history: 5395 entries
Workspace storage: 52 workspaces Workspace storage: 52 workspaces
Repo docs: 7 files across 2 repos Repo docs: 7 files across 2 repos
Repo workflows: 56 files across 13 repos Repo workflows: 56 files across 13 repos

View File

@ -9,17 +9,17 @@
## 1. Project → Env File Map ## 1. Project → Env File Map
| # | Project | Env File | Port | | # | Project | Env File | Port |
|---|---------|----------|------| | --- | ----------------------------------------------- | ---------------------------------- | ---- |
| 1 | Desktop app (`src/`) | `.env` (root) | — | | 1 | Desktop app (`src/`) | `.env` (root) | — |
| 2 | Backend API (`backend/`) | `backend/.env` | 8000 | | 2 | Backend API (`backend/`) | `backend/.env` | 8000 |
| 3 | Admin Dashboard (`admin-dashboard-web/`) | `admin-dashboard-web/.env.local` | 3001 | | 3 | Admin Dashboard (`admin-dashboard-web/`) | `admin-dashboard-web/.env.local` | 3001 |
| 4 | User Dashboard (`user-dashboard-web/`) | `user-dashboard-web/.env.local` | 3002 | | 4 | User Dashboard (`user-dashboard-web/`) | `user-dashboard-web/.env.local` | 3002 |
| 5 | Tracker Dashboard (`tracker-dashboard-web/`) | `tracker-dashboard-web/.env.local` | 3003 | | 5 | Tracker Dashboard (`tracker-dashboard-web/`) | `tracker-dashboard-web/.env.local` | 3003 |
| 6 | Billing Service (`services/billing-service/`) | `services/billing-service/.env` | 4002 | | 6 | Billing Service (`services/billing-service/`) | `services/billing-service/.env` | 4002 |
| 7 | Growth Service (`services/growth-service/`) | `services/growth-service/.env` | 4001 | | 7 | Growth Service (`services/growth-service/`) | `services/growth-service/.env` | 4001 |
| 8 | Platform Service (`services/platform-service/`) | `services/platform-service/.env` | 4003 | | 8 | Platform Service (`services/platform-service/`) | `services/platform-service/.env` | 4003 |
| 9 | Tracker Service (`services/tracker-service/`) | `services/tracker-service/.env` | 4004 | | 9 | Tracker Service (`services/tracker-service/`) | `services/tracker-service/.env` | 4004 |
--- ---
@ -36,63 +36,63 @@ grep -rn 'MISSING_ENV_VALUE' --include='.env*' --include='*.env' . | grep -v nod
### 3.1 Root `.env` (Desktop App) ### 3.1 Root `.env` (Desktop App)
| Variable | Status | Action | | Variable | Status | Action |
|----------|--------|--------| | --------------------------------------- | ---------------------------------- | ----------------------------------------------------------------------- |
| `APPLICATIONINSIGHTS_CONNECTION_STRING` | ❌ Empty | Get from Azure Portal → Application Insights → Overview | | `APPLICATIONINSIGHTS_CONNECTION_STRING` | ❌ Empty | Get from Azure Portal → Application Insights → Overview |
| `ANH_CONNECTION_STRING` | ⚠️ Has `YOUR_KEY_HERE` placeholder | Replace with real SharedAccessKey from Azure Portal → Notification Hubs | | `ANH_CONNECTION_STRING` | ⚠️ Has `YOUR_KEY_HERE` placeholder | Replace with real SharedAccessKey from Azure Portal → Notification Hubs |
### 3.2 `backend/.env` ### 3.2 `backend/.env`
| Variable | Status | Action | | Variable | Status | Action |
|----------|--------|--------| | ------------------------------- | -------- | ------------------------------------------------------------------------ |
| `AZURE_EMAIL_CONNECTION_STRING` | ❌ Empty | Get from Azure Portal → Communication Services → Keys | | `AZURE_EMAIL_CONNECTION_STRING` | ❌ Empty | Get from Azure Portal → Communication Services → Keys |
| `SMTP_HOST` | ❌ Empty | Configure if using SMTP fallback instead of Azure Communication Services | | `SMTP_HOST` | ❌ Empty | Configure if using SMTP fallback instead of Azure Communication Services |
| `SMTP_USER` | ❌ Empty | Configure if using SMTP fallback | | `SMTP_USER` | ❌ Empty | Configure if using SMTP fallback |
| `SMTP_PASS` | ❌ Empty | Configure if using SMTP fallback | | `SMTP_PASS` | ❌ Empty | Configure if using SMTP fallback |
### 3.3 `admin-dashboard-web/.env.local` ### 3.3 `admin-dashboard-web/.env.local`
| Variable | Status | Action | | Variable | Status | Action |
|----------|--------|--------| | -------------------------- | -------- | ---------------------------------------------------------- |
| `NEXT_PUBLIC_POSTHOG_KEY` | ❌ Empty | Get from PostHog → Project Settings (optional — analytics) | | `NEXT_PUBLIC_POSTHOG_KEY` | ❌ Empty | Get from PostHog → Project Settings (optional — analytics) |
| `NEXT_PUBLIC_POSTHOG_HOST` | ❌ Empty | Get from PostHog → Project Settings (optional — analytics) | | `NEXT_PUBLIC_POSTHOG_HOST` | ❌ Empty | Get from PostHog → Project Settings (optional — analytics) |
### 3.4 `user-dashboard-web/.env.local` ### 3.4 `user-dashboard-web/.env.local`
| Variable | Status | Action | | Variable | Status | Action |
|----------|--------|--------| | -------------------------- | -------- | ------------------------------------------------------------------- |
| `ENTERPRISE_EMAIL_DOMAINS` | ❌ Empty | Set comma-separated list of domains that qualify for Enterprise SSO | | `ENTERPRISE_EMAIL_DOMAINS` | ❌ Empty | Set comma-separated list of domains that qualify for Enterprise SSO |
| `MICROSOFT_CLIENT_ID` | ❌ Empty | Register app in Azure Portal → Entra ID → App registrations | | `MICROSOFT_CLIENT_ID` | ❌ Empty | Register app in Azure Portal → Entra ID → App registrations |
| `MICROSOFT_CLIENT_SECRET` | ❌ Empty | Same as above | | `MICROSOFT_CLIENT_SECRET` | ❌ Empty | Same as above |
| `GOOGLE_CLIENT_ID` | ❌ Empty | Register app in Google Cloud Console → Credentials | | `GOOGLE_CLIENT_ID` | ❌ Empty | Register app in Google Cloud Console → Credentials |
| `GOOGLE_CLIENT_SECRET` | ❌ Empty | Same as above | | `GOOGLE_CLIENT_SECRET` | ❌ Empty | Same as above |
| `NEXT_PUBLIC_POSTHOG_KEY` | ❌ Empty | PostHog analytics (optional) | | `NEXT_PUBLIC_POSTHOG_KEY` | ❌ Empty | PostHog analytics (optional) |
| `NEXT_PUBLIC_POSTHOG_HOST` | ❌ Empty | PostHog analytics (optional) | | `NEXT_PUBLIC_POSTHOG_HOST` | ❌ Empty | PostHog analytics (optional) |
### 3.5 `tracker-dashboard-web/.env.local` ### 3.5 `tracker-dashboard-web/.env.local`
| Variable | Status | Action | | Variable | Status | Action |
|----------|--------|--------| | -------------------------- | -------- | ---------------------------- |
| `NEXT_PUBLIC_POSTHOG_KEY` | ❌ Empty | PostHog analytics (optional) | | `NEXT_PUBLIC_POSTHOG_KEY` | ❌ Empty | PostHog analytics (optional) |
| `NEXT_PUBLIC_POSTHOG_HOST` | ❌ Empty | PostHog analytics (optional) | | `NEXT_PUBLIC_POSTHOG_HOST` | ❌ Empty | PostHog analytics (optional) |
### 3.6 `services/growth-service/.env` ### 3.6 `services/growth-service/.env`
| Variable | Status | Action | | Variable | Status | Action |
|----------|--------|--------| | --------------------------------- | -------- | ------------------------------------------------------------ |
| `WEBHOOK_INVITATION_REDEEMED_URL` | ❌ Empty | Set to backend or platform-service webhook callback endpoint | | `WEBHOOK_INVITATION_REDEEMED_URL` | ❌ Empty | Set to backend or platform-service webhook callback endpoint |
| `WEBHOOK_REFERRAL_STATUS_URL` | ❌ Empty | Set to backend or platform-service webhook callback endpoint | | `WEBHOOK_REFERRAL_STATUS_URL` | ❌ Empty | Set to backend or platform-service webhook callback endpoint |
### 3.7 `services/billing-service/.env` ### 3.7 `services/billing-service/.env`
| Variable | Status | Action | | Variable | Status | Action |
|----------|--------|--------| | ------------------ | -------- | --------------------------------------------------------------- |
| `PLAN_LIMITS_JSON` | ❌ Empty | Optional — set JSON with per-plan limits if overriding defaults | | `PLAN_LIMITS_JSON` | ❌ Empty | Optional — set JSON with per-plan limits if overriding defaults |
### 3.8 `services/platform-service/.env` ### 3.8 `services/platform-service/.env`
| Variable | Status | Action | | Variable | Status | Action |
|----------|--------|--------| | ------------------------ | -------- | ------------------------------------------------------------------------ |
| `RATE_LIMIT_CONFIG_JSON` | ❌ Empty | Optional — set JSON with per-endpoint rate limits if overriding defaults | | `RATE_LIMIT_CONFIG_JSON` | ❌ Empty | Optional — set JSON with per-endpoint rate limits if overriding defaults |
### 3.9 `services/tracker-service/.env` ### 3.9 `services/tracker-service/.env`
@ -105,21 +105,21 @@ grep -rn 'MISSING_ENV_VALUE' --include='.env*' --include='*.env' . | grep -v nod
These were missing from `.env` files but had known values, so they were filled in: These were missing from `.env` files but had known values, so they were filled in:
| Project | Variable | Value Added | | Project | Variable | Value Added |
|---------|----------|-------------| | -------------------------------- | -------------------------- | --------------------------------------- |
| Root `.env` | `PLATFORM_SERVICE_URL` | `http://localhost:4003` | | Root `.env` | `PLATFORM_SERVICE_URL` | `http://localhost:4003` |
| Root `.env` | `LYSNR_API_URL` | `http://localhost:8000` | | Root `.env` | `LYSNR_API_URL` | `http://localhost:8000` |
| Root `.env` | `LYSNR_ADMIN_URL` | `http://localhost:3001` | | Root `.env` | `LYSNR_ADMIN_URL` | `http://localhost:3001` |
| Root `.env` | `LYSNR_DASHBOARD_URL` | `http://localhost:3002` | | Root `.env` | `LYSNR_DASHBOARD_URL` | `http://localhost:3002` |
| `backend/.env` | `BILLING_SERVICE_URL` | `http://localhost:4002` | | `backend/.env` | `BILLING_SERVICE_URL` | `http://localhost:4002` |
| `backend/.env` | `PLATFORM_SERVICE_URL` | `http://localhost:4003` | | `backend/.env` | `PLATFORM_SERVICE_URL` | `http://localhost:4003` |
| `backend/.env` | `CORS_ORIGINS` | Expanded to include all dashboard ports | | `backend/.env` | `CORS_ORIGINS` | Expanded to include all dashboard ports |
| `admin-dashboard-web/.env.local` | `STRIPE_PUBLISHABLE_KEY` | Test key (was missing) | | `admin-dashboard-web/.env.local` | `STRIPE_PUBLISHABLE_KEY` | Test key (was missing) |
| `admin-dashboard-web/.env.local` | `STRIPE_WEBHOOK_SECRET` | Test key (was missing) | | `admin-dashboard-web/.env.local` | `STRIPE_WEBHOOK_SECRET` | Test key (was missing) |
| `admin-dashboard-web/.env.local` | `STRIPE_PRICE_PRO` | `price_1Szl2z...` | | `admin-dashboard-web/.env.local` | `STRIPE_PRICE_PRO` | `price_1Szl2z...` |
| `admin-dashboard-web/.env.local` | `STRIPE_PRICE_ENTERPRISE` | `price_1Szl3D...` | | `admin-dashboard-web/.env.local` | `STRIPE_PRICE_ENTERPRISE` | `price_1Szl3D...` |
| `user-dashboard-web/.env.local` | `ENTERPRISE_EMAIL_DOMAINS` | Empty (needs config) | | `user-dashboard-web/.env.local` | `ENTERPRISE_EMAIL_DOMAINS` | Empty (needs config) |
| `services/billing-service/.env` | `USAGE_WARN_THRESHOLD` | `80` | | `services/billing-service/.env` | `USAGE_WARN_THRESHOLD` | `80` |
--- ---
@ -134,20 +134,21 @@ These were missing from `.env` files but had known values, so they were filled i
These values **must be identical** across all services that use them: These values **must be identical** across all services that use them:
| Secret | Used By | | Secret | Used By |
|--------|---------| | ------------------- | -------------------------------------------------------------------- |
| `JWT_SECRET` | All 4 Fastify services + all 3 dashboards + backend | | `JWT_SECRET` | All 4 Fastify services + all 3 dashboards + backend |
| `COSMOS_ENDPOINT` | All 4 Fastify services + admin + user dashboards + backend + desktop | | `COSMOS_ENDPOINT` | All 4 Fastify services + admin + user dashboards + backend + desktop |
| `COSMOS_KEY` | Same as above | | `COSMOS_KEY` | Same as above |
| `COSMOS_DATABASE` | Same as above (must be `lysnrai`) | | `COSMOS_DATABASE` | Same as above (must be `lysnrai`) |
| `STRIPE_SECRET_KEY` | billing-service, growth-service, admin-dashboard, user-dashboard | | `STRIPE_SECRET_KEY` | billing-service, growth-service, admin-dashboard, user-dashboard |
| `AZURE_BLOB_*` | platform-service, admin-dashboard, user-dashboard, desktop | | `AZURE_BLOB_*` | platform-service, admin-dashboard, user-dashboard, desktop |
--- ---
## 7. Production Deployment Notes ## 7. Production Deployment Notes
When deploying to the current stack: When deploying to the current stack:
- **Vercel** for public/front-end surfaces where applicable - **Vercel** for public/front-end surfaces where applicable
- **Azure VM / shared infra** for backend and internal service hosting - **Azure VM / shared infra** for backend and internal service hosting

View File

@ -1,5 +1,5 @@
--- ---
description: "Window 1: Phase 0 scaffolding + Manus cleanup (RUN FIRST — other windows depend on this)" description: 'Window 1: Phase 0 scaffolding + Manus cleanup (RUN FIRST — other windows depend on this)'
--- ---
# Window 1: Phase 0 Scaffolding + Manus Cleanup # Window 1: Phase 0 Scaffolding + Manus Cleanup
@ -45,7 +45,7 @@ canonical script. Legacy files (`CLAUDE.md`, `.cursorrules`, `.windsurfrules`,
`.clinerules`) are **deprecated** and must NOT be created — they used to `.clinerules`) are **deprecated** and must NOT be created — they used to
duplicate AGENTS.md content and drifted. duplicate AGENTS.md content and drifted.
1. `AGENTS.md` — AI agent onboarding guide (customize for efforise: Vite SPA + Fastify backend, productId efforise, port 4020, --er-* tokens). Copy structure from `../learning_ai_notes/AGENTS.md` or `../learning_ai_trails/AGENTS.md`. 1. `AGENTS.md` — AI agent onboarding guide (customize for efforise: Vite SPA + Fastify backend, productId efforise, port 4020, --er-\* tokens). Copy structure from `../learning_ai_notes/AGENTS.md` or `../learning_ai_trails/AGENTS.md`.
2. Add this repo to `../learning_ai_common_plat/.windsurf/workflows/repos.txt` if not already present. 2. Add this repo to `../learning_ai_common_plat/.windsurf/workflows/repos.txt` if not already present.
3. Run `bash ../learning_ai_common_plat/scripts/update-agent-docs.sh`. This will: 3. Run `bash ../learning_ai_common_plat/scripts/update-agent-docs.sh`. This will:
- Prepend the canonical-behavior-pointer block to `AGENTS.md` - Prepend the canonical-behavior-pointer block to `AGENTS.md`
@ -72,12 +72,14 @@ duplicate AGENTS.md content and drifted.
## Step 4: Manus Artifact Cleanup ## Step 4: Manus Artifact Cleanup
### 4a. Clean `vite.config.ts` ### 4a. Clean `vite.config.ts`
- Remove `vite-plugin-manus-runtime` plugin - Remove `vite-plugin-manus-runtime` plugin
- Remove `vite-plugin-manus-debug-collector` plugin + all LOG_DIR code - Remove `vite-plugin-manus-debug-collector` plugin + all LOG_DIR code
- Remove `@builder.io/vite-plugin-jsx-loc` plugin - Remove `@builder.io/vite-plugin-jsx-loc` plugin
- Replace `allowedHosts: [".manuspre.computer", ...]` with `allowedHosts: ["localhost"]` - Replace `allowedHosts: [".manuspre.computer", ...]` with `allowedHosts: ["localhost"]`
### 4b. Delete Manus Files ### 4b. Delete Manus Files
- Delete `client/src/components/ManusDialog.tsx` - Delete `client/src/components/ManusDialog.tsx`
- Delete `client/src/components/Map.tsx` (Google Maps boilerplate, 156 lines) - Delete `client/src/components/Map.tsx` (Google Maps boilerplate, 156 lines)
- Delete `client/public/__manus__/` directory (contains `debug-collector.js`) - Delete `client/public/__manus__/` directory (contains `debug-collector.js`)
@ -88,21 +90,25 @@ duplicate AGENTS.md content and drifted.
- Delete `patches/wouter@3.7.1.patch` (evaluate first — remove if not critical) - Delete `patches/wouter@3.7.1.patch` (evaluate first — remove if not critical)
### 4c. Clean `client/index.html` ### 4c. Clean `client/index.html`
- Remove `VITE_ANALYTICS_ENDPOINT` / `VITE_ANALYTICS_WEBSITE_ID` script references - Remove `VITE_ANALYTICS_ENDPOINT` / `VITE_ANALYTICS_WEBSITE_ID` script references
### 4d. Dependency Cleanup in `package.json` ### 4d. Dependency Cleanup in `package.json`
- **Downgrade** `zod` from `^4.1.12``^3.24.2` (CRITICAL — Zod 4 breaks @bytelyst/* integration)
- **Downgrade** `zod` from `^4.1.12``^3.24.2` (CRITICAL — Zod 4 breaks @bytelyst/\* integration)
- **Upgrade** `typescript` from `5.6.3``^5.7.3` - **Upgrade** `typescript` from `5.6.3``^5.7.3`
- **Remove:** `streamdown`, `cmdk`, `add` (devDep), `@types/google.maps` (devDep), `next-themes` - **Remove:** `streamdown`, `cmdk`, `add` (devDep), `@types/google.maps` (devDep), `next-themes`
- **Remove:** `express`, `@types/express` (server/ is deleted) - **Remove:** `express`, `@types/express` (server/ is deleted)
- **Remove** Manus vite plugins from devDeps: `vite-plugin-manus-runtime`, `@builder.io/vite-plugin-jsx-loc` - **Remove** Manus vite plugins from devDeps: `vite-plugin-manus-runtime`, `@builder.io/vite-plugin-jsx-loc`
### 4e. Move Files ### 4e. Move Files
- Move `ideas.md``docs/ideas.md` - Move `ideas.md``docs/ideas.md`
## Step 5: Create README.md ## Step 5: Create README.md
Write a proper README.md with: Write a proper README.md with:
- Product name + description - Product name + description
- Tech stack (Vite + React 19 SPA, Fastify 5 backend planned) - Tech stack (Vite + React 19 SPA, Fastify 5 backend planned)
- Setup instructions (`pnpm install`, `pnpm dev`) - Setup instructions (`pnpm install`, `pnpm dev`)

View File

@ -21,7 +21,7 @@
"prepare": "husky install" "prepare": "husky install"
}, },
"dependencies": { "dependencies": {
"@azure/cosmos": "^4.9.3", "@azure/cosmos": "^4.9.1",
"@azure/identity": "^4.13.0", "@azure/identity": "^4.13.0",
"@azure/keyvault-secrets": "^4.10.0", "@azure/keyvault-secrets": "^4.10.0",
"@bytelyst/api-client": "workspace:*", "@bytelyst/api-client": "workspace:*",
@ -47,7 +47,7 @@
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"jose": "^6.2.3", "jose": "^6.1.3",
"lucide-react": "^0.563.0", "lucide-react": "^0.563.0",
"next": "16.1.6", "next": "16.1.6",
"posthog-js": "^1.196.0", "posthog-js": "^1.196.0",
@ -62,8 +62,8 @@
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.58.2", "@playwright/test": "^1.58.2",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/bcryptjs": "^3.0.0", "@types/bcryptjs": "^2.4.6",
"@types/node": "^25.9.1", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"@vitest/coverage-v8": "^4.0.18", "@vitest/coverage-v8": "^4.0.18",
@ -71,7 +71,7 @@
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.1.6", "eslint-config-next": "16.1.6",
"husky": "^9.0.0", "husky": "^9.0.0",
"lint-staged": "^16.4.0", "lint-staged": "^15.0.0",
"prettier": "^3.0.0", "prettier": "^3.0.0",
"shadcn": "^3.8.4", "shadcn": "^3.8.4",
"tailwindcss": "^4", "tailwindcss": "^4",

View File

@ -7,12 +7,6 @@
PRODUCT_ID=lysnrai PRODUCT_ID=lysnrai
NEXT_PUBLIC_PRODUCT_ID=lysnrai NEXT_PUBLIC_PRODUCT_ID=lysnrai
# Optional: override the selectable product/project list shown in the sidebar
# switcher. JSON array of { id, name, icon? }. Leave unset to use the built-in
# default. With one entry the switcher auto-hides (single-tenant deployments).
# Example: NEXT_PUBLIC_PRODUCTS=[{"id":"acme","name":"Acme App"}]
NEXT_PUBLIC_PRODUCTS=
# ── Microservice URLs (consolidated platform-service) ── # ── Microservice URLs (consolidated platform-service) ──
PLATFORM_SERVICE_URL=http://localhost:4003 PLATFORM_SERVICE_URL=http://localhost:4003
PLATFORM_API_URL=http://localhost:4003 PLATFORM_API_URL=http://localhost:4003

View File

@ -1,350 +0,0 @@
import { test, expect, type Page, type Route } from '@playwright/test';
/**
* E2E tests for the Fleet (Agent Gigafactory) control-plane dashboard.
*
* Deterministic: every backend call is mocked at the Next.js proxy boundary
* (`/api/fleet/**`, `/api/auth/**`). No live platform-service is required. The
* mock dispatcher is method- and URL-aware and holds a little mutable state so
* operator actions (requeue) and budget pause/resume reflect in follow-up GETs.
*/
const PRODUCT = 'tracker-e2e';
const ISO = '2025-01-01T00:00:00.000Z';
type Job = {
id: string;
productId: string;
stage: string;
idempotencyKey: string;
bodyMd: string;
priority: string;
priorityOrder: number;
capabilities: string[];
kind: string;
attempts: number;
leaseEpoch: number;
createdAt: string;
updatedAt: string;
reviewPolicy?: { requiredApprovals: number; reviewers: string[] };
reviewDecisions?: {
reviewer: string;
decision: 'approve' | 'reject';
at: string;
note?: string;
}[];
gate?: string;
};
function makeJob(partial: Partial<Job> & { id: string; idempotencyKey: string }): Job {
return {
productId: PRODUCT,
stage: 'building',
bodyMd: '# task',
priority: 'high',
priorityOrder: 1,
capabilities: ['build'],
kind: 'task',
attempts: 1,
leaseEpoch: 2,
createdAt: ISO,
updatedAt: ISO,
...partial,
};
}
const FACTORY = {
id: 'f1',
productId: PRODUCT,
factoryId: 'factory-alpha',
capabilities: ['build', 'test'],
health: 'ok' as const,
load: 1,
seatLimit: 4,
lastHeartbeatAt: ISO,
};
/** Authenticate the dashboard: seed the token + selected product, mock /me. */
async function authenticate(page: Page): Promise<void> {
await page.addInitScript(
([product]) => {
localStorage.setItem('tracker_token', 'fake-e2e-token');
localStorage.setItem('tracker_selected_product', product);
},
[PRODUCT]
);
await page.route('**/api/auth/me', (route: Route) =>
route.fulfill({
json: { id: 'u1', email: 'admin@example.com', role: 'admin', displayName: 'Admin' },
})
);
}
/**
* Install the fleet API mock. Returns the mutable state so a test can inspect or
* assert on the latest values the dispatcher served.
*/
async function mockFleet(
page: Page,
opts?: { jobStage?: string; budgetStatus?: 'active' | 'paused' }
): Promise<{ state: { jobStage: string; budgetStatus: 'active' | 'paused' } }> {
const state = {
jobStage: opts?.jobStage ?? 'building',
budgetStatus: opts?.budgetStatus ?? ('active' as 'active' | 'paused'),
reviewDecisions: [] as { reviewer: string; decision: 'approve' | 'reject'; at: string }[],
};
const jobView = () =>
makeJob({
id: 'job-1',
idempotencyKey: 'feat-x',
stage: state.jobStage,
...(state.jobStage === 'review'
? {
reviewPolicy: { requiredApprovals: 2, reviewers: ['admin@example.com', 'bob@x.com'] },
reviewDecisions: state.reviewDecisions,
}
: {}),
});
const budget = () => ({
id: PRODUCT,
productId: PRODUCT,
ceilingUsd: 100,
window: 'monthly',
spentUsd: 25,
status: state.budgetStatus,
updatedAt: ISO,
});
await page.route('**/api/fleet/**', (route: Route) => {
const req = route.request();
const url = new URL(req.url());
const path = url.pathname; // e.g. /api/fleet/jobs/job-1/events
const method = req.method();
// ── Live event stream (SSE) ──
if (path.includes('/events/stream')) {
const evt = {
id: 'job-1:evt:0',
jobId: 'job-1',
seq: 0,
type: 'submitted',
at: ISO,
data: {},
};
return route.fulfill({
status: 200,
headers: { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' },
body: `retry: 3000\n\nid: 0\nevent: fleet-event\ndata: ${JSON.stringify(evt)}\n\n`,
});
}
if (path.endsWith('/events')) {
return route.fulfill({
json: {
events: [
{ id: 'job-1:evt:0', jobId: 'job-1', seq: 0, type: 'submitted', at: ISO, data: {} },
],
},
});
}
if (path.endsWith('/runs')) return route.fulfill({ json: { runs: [] } });
if (path.endsWith('/artifacts')) return route.fulfill({ json: { artifacts: [] } });
// DAG + explain: degrade to 404 so the page renders without those panels.
// Body omits `error` so the client throws "HTTP 404" (apiFetchOptional → null).
if (path.endsWith('/dag') || path.endsWith('/explain')) {
return route.fulfill({ status: 404, json: {} });
}
// ── Operator actions (requeue / cancel / reject) ──
const actionMatch = path.match(/\/jobs\/[^/]+\/actions\/(\w+)$/);
if (actionMatch && method === 'POST') {
const action = actionMatch[1];
state.jobStage =
action === 'requeue' ? 'queued' : action === 'reject' ? 'dead_letter' : 'failed';
return route.fulfill({
json: makeJob({ id: 'job-1', idempotencyKey: 'feat-x', stage: state.jobStage }),
});
}
// ── Review gate (multi-reviewer) ──
if (path.endsWith('/review/request') && method === 'POST') {
state.jobStage = 'review';
state.reviewDecisions = [];
return route.fulfill({ json: jobView() });
}
if (path.endsWith('/review') && method === 'POST') {
const payload = req.postDataJSON() as {
reviewer: string;
decision: 'approve' | 'reject';
};
if (payload.decision === 'reject') {
state.jobStage = 'queued';
return route.fulfill({ json: { ...jobView(), gate: 'rejected' } });
}
state.reviewDecisions = state.reviewDecisions
.filter(d => d.reviewer !== payload.reviewer)
.concat({ reviewer: payload.reviewer, decision: 'approve', at: ISO });
const approvals = new Set(
state.reviewDecisions.filter(d => d.decision === 'approve').map(d => d.reviewer)
).size;
const gate = approvals >= 2 ? 'approved' : 'pending';
if (gate === 'approved') state.jobStage = 'testing';
return route.fulfill({ json: { ...jobView(), gate } });
}
// ── Budgets ──
if (path.endsWith('/burndown')) return route.fulfill({ status: 404, json: {} });
if (path.endsWith('/pause') && method === 'POST') {
state.budgetStatus = 'paused';
return route.fulfill({ json: budget() });
}
if (path.endsWith('/resume') && method === 'POST') {
state.budgetStatus = 'active';
return route.fulfill({ json: budget() });
}
if (path.match(/\/budgets\/[^/]+$/)) return route.fulfill({ json: budget() });
// ── Jobs ──
if (path.match(/\/jobs\/[^/]+$/) && method === 'GET') {
return route.fulfill({ json: jobView() });
}
if (path.endsWith('/jobs') && method === 'GET') {
return route.fulfill({ json: { jobs: [jobView()] } });
}
if (path.endsWith('/factories')) return route.fulfill({ json: { factories: [FACTORY] } });
// ── Fleet metrics ──
if (path.endsWith('/metrics')) {
return route.fulfill({
json: {
productId: 'lysnrai',
generatedAt: ISO,
jobs: {
total: 1,
byStage: { queued: 1 },
queueDepth: 1,
blocked: 0,
active: 0,
oldestQueuedAgeMs: 1000,
},
factories: {
total: 1,
live: 1,
stale: 0,
byHealth: { ok: 1, degraded: 0, down: 0 },
seatsUsed: 1,
seatsTotal: 4,
utilizationPct: 25,
},
alerts: [
{ level: 'warning', code: 'queue_starvation', message: 'A job has waited too long.' },
],
},
});
}
return route.fulfill({ json: {} });
});
return { state };
}
test.describe('Fleet — Overview', () => {
test('renders factory cards and the recent-jobs table', async ({ page }) => {
await authenticate(page);
await mockFleet(page);
await page.goto('/dashboard/fleet');
await expect(page.getByRole('heading', { name: 'Fleet Control Plane' })).toBeVisible();
await expect(page.getByTestId('fleet-metrics')).toBeVisible();
await expect(page.getByTestId('fleet-alerts')).toBeVisible();
await expect(page.getByLabel('Factory factory-alpha')).toBeVisible();
await expect(page.getByRole('table', { name: 'Recent fleet jobs' })).toBeVisible();
await expect(page.getByRole('link', { name: 'feat-x' })).toBeVisible();
});
});
test.describe('Fleet — Jobs table', () => {
test('lists jobs and exposes the stage filter', async ({ page }) => {
await authenticate(page);
await mockFleet(page);
await page.goto('/dashboard/fleet/jobs');
await expect(page.getByRole('heading', { name: 'Fleet Jobs' })).toBeVisible();
await expect(page.getByRole('table', { name: 'Fleet jobs table' })).toBeVisible();
await expect(page.getByLabel('Filter by stage')).toBeVisible();
await expect(page.getByRole('link', { name: 'View job feat-x' })).toBeVisible();
});
});
test.describe('Fleet — Job detail', () => {
test('requeues a job and reflects the new stage; shows the live badge', async ({ page }) => {
await authenticate(page);
await mockFleet(page, { jobStage: 'building' });
await page.goto('/dashboard/fleet/jobs/job-1');
await expect(page.getByRole('heading', { name: 'feat-x' })).toBeVisible();
// SSE snapshot delivers an event → the Live indicator should appear.
await expect(page.getByTestId('live-indicator')).toBeVisible();
// Stage card starts at 'building'.
await expect(page.getByText('building', { exact: true })).toBeVisible();
await page.getByRole('button', { name: 'Requeue this job' }).click();
// After requeue the coordinator returns stage 'queued', mirrored on refresh.
await expect(page.getByText('queued', { exact: true }).first()).toBeVisible();
});
test('surfaces the polling indicator when the live stream is unavailable', async ({ page }) => {
await authenticate(page);
await mockFleet(page, { jobStage: 'building' });
// Override just the SSE stream to fail → the client falls back to polling.
// Registered after mockFleet so it takes precedence over the catch-all.
await page.route('**/api/fleet/jobs/*/events/stream', (route: Route) =>
route.fulfill({ status: 500, body: 'stream down' })
);
await page.goto('/dashboard/fleet/jobs/job-1');
await expect(page.getByRole('heading', { name: 'feat-x' })).toBeVisible();
// The degraded transport must be visible to the operator, not silent.
await expect(page.getByTestId('polling-indicator')).toBeVisible();
await expect(page.getByTestId('live-indicator')).toHaveCount(0);
// Events still render via the polling fallback (GET /events).
await expect(page.getByText('submitted', { exact: true })).toBeVisible();
});
});
test.describe('Fleet — Review gate', () => {
test('routes a building job to review and approves through the gate', async ({ page }) => {
await authenticate(page);
await mockFleet(page, { jobStage: 'building' });
await page.goto('/dashboard/fleet/jobs/job-1');
await expect(page.getByRole('heading', { name: 'feat-x' })).toBeVisible();
// Send the building job to review.
await page.getByRole('button', { name: 'Send this job to review' }).click();
await expect(page.getByTestId('review-gate')).toBeVisible();
await expect(page.getByTestId('review-progress')).toHaveText('0 / 2 approvals');
// First approval (admin@example.com) keeps the gate pending at 1/2.
await page.getByRole('button', { name: 'Approve this job' }).click();
await expect(page.getByTestId('review-progress')).toHaveText('1 / 2 approvals');
});
});
test.describe('Fleet — Budget', () => {
test('pauses and resumes the budget', async ({ page }) => {
await authenticate(page);
await mockFleet(page, { budgetStatus: 'active' });
await page.goto('/dashboard/fleet/budget');
await expect(page.getByRole('heading', { name: 'Fleet Budget' })).toBeVisible();
await expect(page.getByText('active', { exact: true })).toBeVisible();
await page.getByRole('button', { name: 'Pause budget' }).click();
await expect(page.getByText('paused', { exact: true })).toBeVisible();
await page.getByRole('button', { name: 'Resume budget' }).click();
await expect(page.getByText('active', { exact: true })).toBeVisible();
});
});

View File

@ -1,37 +0,0 @@
# tracker-web LaunchAgent (keep-alive on :3003)
Auto-starts the fleet **web tracker** (`tracker-web`) on login and restarts it on
crash/reboot, so `http://localhost:3003` stays up without a babysitter. Mirrors
the `agent-queue/launchd/` pattern in `learning_ai_devops_tools`.
## Files
- `tracker-web-boot.sh` — boot entrypoint: repairs `PATH`, loads `JWT_SECRET` (+
co.) from `../../services/platform-service/.env` and optional
`~/.tracker-web.env` overrides, points the app at the local platform-service
(`PLATFORM_API_URL`, default `http://localhost:4003`), then `exec pnpm dev`.
- `install.sh` — renders `~/Library/LaunchAgents/com.bytelyst.tracker-web.plist`
(`RunAtLoad` + unconditional `KeepAlive` so it restarts on any exit, incl. a
clean SIGTERM, + a 10s crash-loop throttle), frees `:3003` if something else is
on it, then bootstraps + kickstarts it.
## Use
```bash
bash launchd/install.sh # install + start
bash launchd/install.sh --uninstall # stop + remove
tail -f ~/Library/Logs/tracker-web/tracker-web.out.log # logs
launchctl print gui/$(id -u)/com.bytelyst.tracker-web | sed -n '1,20p' # status
```
## Notes
- **Prereqs:** the platform-service backend on `:4003` (see
`learning_ai_devops_tools/scripts/deploy-gigafactory.sh`) and `pnpm` on `PATH`.
- **Don't double-manage the port:** while this LaunchAgent owns `:3003`, do not
also run `deploy-gigafactory.sh --with-tracker` / `--tracker-only` — they would
clash on `:3003`. Use one or the other.
- **Per-machine overrides:** drop env in `~/.tracker-web.env` (not tracked) to
override `PLATFORM_API_URL`, `DEFAULT_PRODUCT_ID`, etc.
- macOS only (LaunchAgents). On Linux use a `systemd --user` unit.

View File

@ -1,110 +0,0 @@
#!/usr/bin/env bash
#
# install.sh — install (or remove) the macOS LaunchAgent that auto-starts
# tracker-web (the fleet web tracker, :3003) on login and keeps it alive across
# reboot/crash.
#
# bash launchd/install.sh # render plist, load, and start now
# bash launchd/install.sh --uninstall # stop, unload, and remove the plist
#
# The plist is generated from the resolved repo path so it works on any clone.
# Logs land in ~/Library/Logs/tracker-web/.
#
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd -P)"
WEB_DIR="$(cd -- "$SCRIPT_DIR/.." >/dev/null 2>&1 && pwd -P)"
WRAPPER="$SCRIPT_DIR/tracker-web-boot.sh"
LABEL="com.bytelyst.tracker-web"
PLIST="$HOME/Library/LaunchAgents/$LABEL.plist"
LOG_DIR="$HOME/Library/Logs/tracker-web"
UID_NUM="$(id -u)"
DOMAIN="gui/$UID_NUM"
if [ "$(uname -s)" != "Darwin" ]; then
echo "install.sh: macOS only (LaunchAgents). On Linux use a systemd --user unit." >&2
exit 1
fi
uninstall() {
echo "[launchd] booting out $LABEL ..."
launchctl bootout "$DOMAIN/$LABEL" 2>/dev/null || true
rm -f "$PLIST"
echo "[launchd] removed $PLIST (tracker-web stopped)"
}
if [ "${1:-}" = "--uninstall" ] || [ "${1:-}" = "-u" ]; then
uninstall
exit 0
fi
[ -f "$WRAPPER" ] || { echo "install.sh: missing $WRAPPER" >&2; exit 1; }
chmod +x "$WRAPPER" 2>/dev/null || true
mkdir -p "$HOME/Library/LaunchAgents" "$LOG_DIR"
# Avoid a port clash: if something else is already serving :3003 (e.g. a
# deploy-gigafactory.sh --tracker-only run), stop it so launchd owns the port.
if lsof -ti tcp:3003 >/dev/null 2>&1; then
echo "[launchd] freeing :3003 (an existing tracker is running) ..."
lsof -ti tcp:3003 | xargs kill 2>/dev/null || true
sleep 2
fi
echo "[launchd] writing $PLIST"
cat > "$PLIST" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>$LABEL</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>$WRAPPER</string>
</array>
<!-- Start on login and ALWAYS restart it if it exits, for any reason
(crash, reboot, OOM, or a clean SIGTERM that exits 0 — a long-lived web
server should never just stay down). -->
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<!-- Guard against tight crash loops. -->
<key>ThrottleInterval</key>
<integer>10</integer>
<key>WorkingDirectory</key>
<string>$WEB_DIR</string>
<key>StandardOutPath</key>
<string>$LOG_DIR/tracker-web.out.log</string>
<key>StandardErrorPath</key>
<string>$LOG_DIR/tracker-web.err.log</string>
<!-- launchd's PATH is minimal; the wrapper also repairs PATH defensively. -->
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
</dict>
</dict>
</plist>
EOF
# Reload cleanly (bootout first so a re-run picks up plist changes).
launchctl bootout "$DOMAIN/$LABEL" 2>/dev/null || true
launchctl bootstrap "$DOMAIN" "$PLIST"
launchctl enable "$DOMAIN/$LABEL"
launchctl kickstart -k "$DOMAIN/$LABEL"
echo "[launchd] installed + started: $LABEL (http://localhost:3003)"
echo "[launchd] status : launchctl print $DOMAIN/$LABEL | sed -n '1,20p'"
echo "[launchd] logs : tail -f $LOG_DIR/tracker-web.out.log"
echo "[launchd] stop : bash $SCRIPT_DIR/install.sh --uninstall"
echo
echo "NOTE: while this LaunchAgent owns :3003, do NOT also run"
echo " deploy-gigafactory.sh --with-tracker (it would clash on the port)."

View File

@ -1,44 +0,0 @@
#!/usr/bin/env bash
#
# tracker-web-boot.sh — boot/login entrypoint for the fleet web tracker.
#
# Launched by the macOS LaunchAgent (see ./install.sh) so tracker-web auto-starts
# on login and survives crash/reboot via KeepAlive — the supervision a bare
# `next dev` (or deploy-gigafactory.sh --tracker-only) doesn't provide.
#
# It does what launchd's minimal environment needs:
# 1. Repairs PATH so node/pnpm are found.
# 2. Loads JWT_SECRET + co. from platform-service/.env (and optional
# ~/.tracker-web.env overrides) so the telemetry/health proxies work.
# 3. Points the app at the local platform-service and execs `next dev`.
#
set -uo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd -P)"
WEB_DIR="$(cd -- "$SCRIPT_DIR/.." >/dev/null 2>&1 && pwd -P)" # dashboards/tracker-web
PS_ENV="$(cd -- "$WEB_DIR/../../services/platform-service" >/dev/null 2>&1 && pwd -P)/.env"
# launchd hands processes a bare PATH — prepend the usual node/pnpm locations.
export PATH="$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:${PATH:-}"
# Backend secret + any other shared config from platform-service/.env, then
# per-machine overrides (~/.tracker-web.env, NOT tracked) which win.
set -a
[ -f "$PS_ENV" ] && . "$PS_ENV"
[ -f "$HOME/.tracker-web.env" ] && . "$HOME/.tracker-web.env"
set +a
# Where the app reaches the fleet backend + which product it scopes to by default.
: "${PLATFORM_API_URL:=http://localhost:4003}"
: "${PLATFORM_SERVICE_URL:=$PLATFORM_API_URL}"
: "${DEFAULT_PRODUCT_ID:=lysnrai}"
: "${PRODUCT_ID:=$DEFAULT_PRODUCT_ID}"
export PLATFORM_API_URL PLATFORM_SERVICE_URL DEFAULT_PRODUCT_ID PRODUCT_ID
echo "[tracker-web-boot] $(date '+%Y-%m-%d %H:%M:%S') starting tracker-web on :3003" \
"(platform=$PLATFORM_API_URL, product=$DEFAULT_PRODUCT_ID)"
cd "$WEB_DIR"
# exec so the LaunchAgent tracks the real next-dev PID (clean KeepAlive restarts).
# `dev` matches the rest of the local setup; the package script pins --port 3003.
exec pnpm dev

View File

@ -34,10 +34,10 @@
"@bytelyst/data-viz": "workspace:*", "@bytelyst/data-viz": "workspace:*",
"@bytelyst/design-tokens": "workspace:*", "@bytelyst/design-tokens": "workspace:*",
"@bytelyst/errors": "workspace:*", "@bytelyst/errors": "workspace:*",
"@bytelyst/logger": "workspace:*",
"@bytelyst/motion": "workspace:*",
"@bytelyst/notifications-ui": "workspace:*", "@bytelyst/notifications-ui": "workspace:*",
"@bytelyst/telemetry-client": "workspace:*", "@bytelyst/telemetry-client": "workspace:*",
"@bytelyst/logger": "workspace:*",
"@bytelyst/motion": "workspace:*",
"@bytelyst/ui": "workspace:*", "@bytelyst/ui": "workspace:*",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"next": "16.1.6", "next": "16.1.6",
@ -49,16 +49,15 @@
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.58.2", "@playwright/test": "^1.58.2",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/node": "^25.9.1", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"@vitest/coverage-v8": "^4.0.18", "@vitest/coverage-v8": "^4.0.18",
"bundlesize": "^0.18.1", "bundlesize": "^0.18.1",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.1.6", "eslint-config-next": "16.1.6",
"happy-dom": "^20.9.0",
"husky": "^9.0.0", "husky": "^9.0.0",
"lint-staged": "^16.4.0", "lint-staged": "^15.0.0",
"prettier": "^3.0.0", "prettier": "^3.0.0",
"tailwindcss": "^4", "tailwindcss": "^4",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",

View File

@ -1,4 +1,4 @@
// @vitest-environment happy-dom // @vitest-environment jsdom
/** /**
* UX-5.3 command palette behaviour. * UX-5.3 command palette behaviour.
* *

View File

@ -15,25 +15,15 @@ import {
listJobs, listJobs,
getJob, getJob,
patchJob, patchJob,
operatorAction,
requestReview,
submitReview,
getJobRuns, getJobRuns,
getJobEvents, getJobEvents,
getJobArtifacts, getJobArtifacts,
getJobDag, getJobDag,
getJobExplain,
listFactories, listFactories,
availableEnginesForProduct,
getFleetMetrics,
getBudget, getBudget,
getBudgetBurndown,
upsertBudget, upsertBudget,
pauseBudget, pauseBudget,
resumeBudget, resumeBudget,
budgetUsagePct,
parseSseFrames,
subscribeJobEvents,
} from '@/lib/fleet-client'; } from '@/lib/fleet-client';
describe('fleet-client', () => { describe('fleet-client', () => {
@ -84,99 +74,6 @@ describe('fleet-client', () => {
expect.objectContaining({ method: 'PATCH' }) expect.objectContaining({ method: 'PATCH' })
); );
}); });
it('forwards an optional checkpoint in the PATCH body', async () => {
fetchSpy.mockResolvedValue({ id: 'j1', stage: 'building' });
await patchJob('j1', {
leaseEpoch: 3,
stage: 'building',
checkpoint: { wipBranch: 'feat/x', wipCommit: 'abc123' },
});
expect(fetchSpy).toHaveBeenCalledWith(
'/jobs/j1',
expect.objectContaining({
method: 'PATCH',
body: JSON.stringify({
leaseEpoch: 3,
stage: 'building',
checkpoint: { wipBranch: 'feat/x', wipCommit: 'abc123' },
}),
})
);
});
});
describe('budgetUsagePct', () => {
it('computes a clamped percentage for a normal ceiling', () => {
expect(budgetUsagePct(25, 100)).toBe(25);
expect(budgetUsagePct(150, 100)).toBe(100); // clamps over-budget to 100
});
it('returns 0 for a zero, missing, or non-finite ceiling (no NaN bar)', () => {
expect(budgetUsagePct(10, 0)).toBe(0);
expect(budgetUsagePct(0, 0)).toBe(0);
expect(budgetUsagePct(10, NaN)).toBe(0);
expect(budgetUsagePct(10, undefined as unknown as number)).toBe(0);
expect(budgetUsagePct(10, -5)).toBe(0);
});
});
describe('operatorAction', () => {
it('sends POST to /jobs/:id/actions/:action', async () => {
fetchSpy.mockResolvedValue({ id: 'j1', stage: 'queued' });
const res = await operatorAction('j1', 'requeue');
expect(res.stage).toBe('queued');
expect(fetchSpy).toHaveBeenCalledWith(
'/jobs/j1/actions/requeue',
expect.objectContaining({ method: 'POST' })
);
});
it('supports reject and cancel actions', async () => {
fetchSpy.mockResolvedValue({ id: 'j1', stage: 'dead_letter' });
await operatorAction('j1', 'reject');
expect(fetchSpy).toHaveBeenCalledWith(
'/jobs/j1/actions/reject',
expect.objectContaining({ method: 'POST' })
);
});
});
describe('review gate', () => {
it('requestReview POSTs the policy to /jobs/:id/review/request', async () => {
fetchSpy.mockResolvedValue({ id: 'j1', stage: 'review' });
const res = await requestReview('j1', { requiredApprovals: 2, reviewers: ['a@x.com'] });
expect(res.stage).toBe('review');
expect(fetchSpy).toHaveBeenCalledWith(
'/jobs/j1/review/request',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ requiredApprovals: 2, reviewers: ['a@x.com'] }),
})
);
});
it('requestReview sends an empty object when no policy given', async () => {
fetchSpy.mockResolvedValue({ id: 'j1', stage: 'review' });
await requestReview('j1');
expect(fetchSpy).toHaveBeenCalledWith(
'/jobs/j1/review/request',
expect.objectContaining({ method: 'POST', body: '{}' })
);
});
it('submitReview POSTs the decision to /jobs/:id/review', async () => {
fetchSpy.mockResolvedValue({ id: 'j1', stage: 'testing', gate: 'approved' });
const res = await submitReview('j1', { reviewer: 'a@x.com', decision: 'approve' });
expect(res.gate).toBe('approved');
expect(fetchSpy).toHaveBeenCalledWith(
'/jobs/j1/review',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ reviewer: 'a@x.com', decision: 'approve' }),
})
);
});
}); });
describe('getJobRuns', () => { describe('getJobRuns', () => {
@ -217,65 +114,6 @@ describe('fleet-client', () => {
}); });
}); });
describe('getJobExplain', () => {
it('returns score breakdown on success', async () => {
fetchSpy.mockResolvedValue({
jobId: 'j1',
stage: 'queued',
weights: {},
depsSatisfied: true,
unmetDeps: [],
factories: [{ factoryId: 'f1', eligible: true, ineligibleReasons: [], score: 3.2 }],
bestFactoryId: 'f1',
});
const res = await getJobExplain('j1');
expect(res?.bestFactoryId).toBe('f1');
expect(fetchSpy).toHaveBeenCalledWith('/jobs/j1/explain', expect.anything());
});
it('returns null on 404', async () => {
fetchSpy.mockRejectedValue(new Error('404 Not Found'));
const res = await getJobExplain('missing');
expect(res).toBeNull();
});
});
describe('getFleetMetrics', () => {
it('returns metrics on success', async () => {
fetchSpy.mockResolvedValue({
productId: 'lysnrai',
generatedAt: new Date().toISOString(),
jobs: {
total: 1,
byStage: {},
queueDepth: 1,
blocked: 0,
active: 0,
oldestQueuedAgeMs: null,
},
factories: {
total: 0,
live: 0,
stale: 0,
byHealth: { ok: 0, degraded: 0, down: 0 },
seatsUsed: 0,
seatsTotal: 0,
utilizationPct: 0,
},
alerts: [],
});
const res = await getFleetMetrics();
expect(res?.jobs.queueDepth).toBe(1);
expect(fetchSpy).toHaveBeenCalledWith('/metrics', expect.anything());
});
it('returns null on 404', async () => {
fetchSpy.mockRejectedValue(new Error('404 Not Found'));
const res = await getFleetMetrics();
expect(res).toBeNull();
});
});
describe('listFactories', () => { describe('listFactories', () => {
it('returns factories on success', async () => { it('returns factories on success', async () => {
fetchSpy.mockResolvedValue({ factories: [{ id: 'f1' }] }); fetchSpy.mockResolvedValue({ factories: [{ id: 'f1' }] });
@ -290,64 +128,6 @@ describe('fleet-client', () => {
}); });
}); });
describe('availableEnginesForProduct', () => {
const fresh = () => new Date().toISOString();
const stale = () => new Date(Date.now() - 120_000).toISOString(); // > 90s
const factory = (over: Record<string, unknown>) => ({
id: 'f',
productId: 'lysnrai',
factoryId: 'f',
capabilities: [],
health: 'ok',
load: 0,
seatLimit: 1,
lastHeartbeatAt: fresh(),
...over,
});
it('collects engine:* caps from healthy, live factories', async () => {
fetchSpy.mockResolvedValue({
factories: [
factory({ capabilities: ['os:mac', 'engine:devin', 'engine:claude'] }),
factory({ capabilities: ['engine:copilot'] }),
],
});
const engines = await availableEnginesForProduct('lysnrai');
expect(engines.sort()).toEqual(['claude', 'copilot', 'devin']);
});
it('ignores non-engine caps and unknown engines', async () => {
fetchSpy.mockResolvedValue({
factories: [factory({ capabilities: ['os:mac', 'has:git', 'engine:bogus'] })],
});
expect(await availableEnginesForProduct()).toEqual([]);
});
it('skips down factories', async () => {
fetchSpy.mockResolvedValue({
factories: [factory({ health: 'down', capabilities: ['engine:codex'] })],
});
expect(await availableEnginesForProduct()).toEqual([]);
});
it('skips stale (missed-heartbeat) factories even when health says ok', async () => {
fetchSpy.mockResolvedValue({
factories: [
factory({ capabilities: ['engine:codex'], lastHeartbeatAt: stale() }),
factory({ capabilities: ['engine:devin'], lastHeartbeatAt: fresh() }),
],
});
// codex's only host is stale → excluded; devin's host is fresh → kept.
expect(await availableEnginesForProduct()).toEqual(['devin']);
});
it('returns empty (⇒ caller offers all) when the list call fails', async () => {
fetchSpy.mockRejectedValue(new Error('Network error'));
expect(await availableEnginesForProduct()).toEqual([]);
});
});
describe('budget operations', () => { describe('budget operations', () => {
it('getBudget returns budget or null', async () => { it('getBudget returns budget or null', async () => {
fetchSpy.mockResolvedValue({ id: 'lysnrai', ceilingUsd: 100, spentUsd: 25 }); fetchSpy.mockResolvedValue({ id: 'lysnrai', ceilingUsd: 100, spentUsd: 25 });
@ -387,155 +167,5 @@ describe('fleet-client', () => {
expect.objectContaining({ method: 'POST' }) expect.objectContaining({ method: 'POST' })
); );
}); });
it('getBudgetBurndown fetches the series with a days query', async () => {
fetchSpy.mockResolvedValue({
productId: 'p1',
ceilingUsd: 50,
window: 'monthly',
totalUsd: 10,
days: [{ date: '2024-01-01', costUsd: 10, cumulativeUsd: 10 }],
});
const res = await getBudgetBurndown('p1', 30);
expect(res?.totalUsd).toBe(10);
expect(fetchSpy).toHaveBeenCalledWith('/budgets/p1/burndown?days=30', expect.anything());
});
it('getBudgetBurndown returns null on 404', async () => {
fetchSpy.mockRejectedValue(new Error('404 Not Found'));
const res = await getBudgetBurndown('p1');
expect(res).toBeNull();
});
});
describe('parseSseFrames', () => {
it('parses complete frames and skips keepalive comments', () => {
const buf =
'id: 0\nevent: fleet-event\ndata: {"seq":0,"type":"submitted"}\n\n' +
': keepalive\n\n' +
'id: 1\nevent: fleet-event\ndata: {"seq":1,"type":"progress"}\n\n';
const { events, rest } = parseSseFrames(buf);
expect(events).toHaveLength(2);
expect(events[0]).toMatchObject({ id: '0', event: 'fleet-event' });
expect(JSON.parse(events[0].data).type).toBe('submitted');
expect(events[1].id).toBe('1');
expect(rest).toBe('');
});
it('returns a trailing partial frame as rest', () => {
const buf = 'id: 0\nevent: fleet-event\ndata: {"seq":0}\n\nid: 1\ndata: {"seq"';
const { events, rest } = parseSseFrames(buf);
expect(events).toHaveLength(1);
expect(rest).toBe('id: 1\ndata: {"seq"');
});
});
describe('subscribeJobEvents', () => {
function streamResponse(chunks: string[]): Response {
const encoder = new TextEncoder();
const body = new ReadableStream<Uint8Array>({
start(controller) {
for (const c of chunks) controller.enqueue(encoder.encode(c));
controller.close();
},
});
return new Response(body, {
status: 200,
headers: { 'content-type': 'text/event-stream' },
});
}
afterEach(() => {
vi.unstubAllGlobals();
});
it('delivers parsed fleet-events to onEvent', async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
streamResponse([
'retry: 3000\n\n',
'id: 0\nevent: fleet-event\ndata: {"seq":0,"type":"submitted"}\n\n',
': keepalive\n\n',
'id: 1\nevent: fleet-event\ndata: {"seq":1,"type":"progress"}\n\n',
])
)
// never resolves → prevents a tight reconnect loop after the first close
.mockReturnValue(new Promise(() => {}));
vi.stubGlobal('fetch', fetchMock);
const received: number[] = [];
await new Promise<void>(resolve => {
const sub = subscribeJobEvents(
'j1',
{
onEvent: e => {
received.push(e.seq);
if (received.length === 2) {
sub.close();
resolve();
}
},
},
{ reconnectMs: 60_000 }
);
});
expect(received).toEqual([0, 1]);
expect(fetchMock).toHaveBeenCalledWith(
'/api/fleet/jobs/j1/events/stream',
expect.objectContaining({
headers: expect.objectContaining({ accept: 'text/event-stream' }),
})
);
});
it('invokes onError and stops when the stream is unavailable', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response('nope', { status: 500 }));
vi.stubGlobal('fetch', fetchMock);
const err = await new Promise<unknown>(resolve => {
subscribeJobEvents('j1', { onEvent: () => {}, onError: resolve });
});
expect(err).toBeInstanceOf(Error);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('treats a terminal error frame as fatal (onError, no reconnect)', async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
streamResponse([
'id: 0\nevent: fleet-event\ndata: {"seq":0,"type":"submitted"}\n\n',
'event: error\ndata: {"message":"stream interrupted"}\n\n',
])
)
.mockReturnValue(new Promise(() => {}));
vi.stubGlobal('fetch', fetchMock);
const seen: number[] = [];
const err = await new Promise<unknown>(resolve => {
subscribeJobEvents('j1', { onEvent: e => seen.push(e.seq), onError: resolve });
});
expect(seen).toEqual([0]);
expect(err).toBeInstanceOf(Error);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('sends Last-Event-ID header when resuming from a cursor', async () => {
const fetchMock = vi.fn().mockReturnValue(new Promise(() => {}));
vi.stubGlobal('fetch', fetchMock);
const sub = subscribeJobEvents('j1', { onEvent: () => {} }, { lastEventId: 5 });
await Promise.resolve();
sub.close();
expect(fetchMock).toHaveBeenCalledWith(
'/api/fleet/jobs/j1/events/stream',
expect.objectContaining({ headers: expect.objectContaining({ 'last-event-id': '5' }) })
);
});
}); });
}); });

View File

@ -1,178 +0,0 @@
// @vitest-environment happy-dom
/**
* Fleet overview page (§1) the consolidated operations surface.
*
* Verifies the panels that surface the §2/§3 signal: the engine circuit-breaker
* panel, the budget guardrail, and the dead-letter triage list with its Re-drive
* button (which calls the operator action). Uses the jsdom render harness +
* mocked fleet-client/auth-context (no network).
*/
import { describe, it, expect, beforeAll, beforeEach, vi } from 'vitest';
import { act, createElement } from 'react';
import { createRoot, type Root } from 'react-dom/client';
const { operatorAction, listFactories, getFleetMetrics, listJobs } = vi.hoisted(() => ({
operatorAction: vi.fn(),
listFactories: vi.fn(),
getFleetMetrics: vi.fn(),
listJobs: vi.fn(),
}));
vi.mock('@/lib/auth-context', () => ({ useAuth: () => ({ token: 'tok' }) }));
vi.mock('@/lib/fleet-client', () => ({
listFactories,
getFleetMetrics,
listJobs,
operatorAction,
}));
import FleetOverviewPage from '@/app/dashboard/fleet/page';
beforeAll(() => {
(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
});
const metricsBase = {
productId: 'p',
generatedAt: '2026-06-01T00:00:00.000Z',
jobs: { total: 1, byStage: {}, queueDepth: 0, blocked: 0, active: 0, oldestQueuedAgeMs: null },
factories: {
total: 0,
live: 0,
stale: 0,
byHealth: { ok: 0, degraded: 0, down: 0 },
seatsUsed: 0,
seatsTotal: 0,
utilizationPct: 0,
},
alerts: [],
};
const deadLetterJob = {
id: 'j-dead',
productId: 'p',
stage: 'dead_letter',
idempotencyKey: 'broken-task',
bodyMd: '',
priority: 'medium',
priorityOrder: 2,
capabilities: [],
kind: 'leaf',
attempts: 3,
leaseEpoch: 3,
engine: 'codex',
createdAt: '2026-06-01T00:00:00.000Z',
updatedAt: '2026-06-01T00:00:00.000Z',
};
async function renderPage(): Promise<{ root: Root; container: HTMLDivElement }> {
const container = document.createElement('div');
document.body.appendChild(container);
let root!: Root;
await act(async () => {
root = createRoot(container);
root.render(createElement(FleetOverviewPage));
});
// Flush the async refresh() (Promise.all of the mocked client calls).
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
return { root, container };
}
beforeEach(() => {
vi.clearAllMocks();
listFactories.mockResolvedValue({ factories: [] });
operatorAction.mockResolvedValue({});
// Default: recent jobs empty, dead-letter query returns the broken job.
listJobs.mockImplementation(async (params?: { stage?: string }) =>
params?.stage === 'dead_letter' ? { jobs: [deadLetterJob] } : { jobs: [] }
);
});
describe('fleet overview §1 — consolidated panels', () => {
it('renders the budget guardrail with projection and per-engine breakdown', async () => {
getFleetMetrics.mockResolvedValue({
...metricsBase,
budget: {
ceilingUsd: 100,
spentUsd: 40,
status: 'active',
window: 'monthly',
projectedUsd: 400,
engines: [{ engine: 'codex', spentUsd: 30, ceilingUsd: 30, exhausted: true }],
},
});
const { root, container } = await renderPage();
const budget = container.querySelector('[data-testid="fleet-budget"]');
expect(budget).not.toBeNull();
expect(budget!.textContent).toContain('$40.00 / $100.00');
expect(budget!.textContent).toContain('Projected monthly: $400.00');
expect(budget!.textContent).toContain('codex');
act(() => root.unmount());
container.remove();
});
it('renders only tripped circuit breakers', async () => {
getFleetMetrics.mockResolvedValue({
...metricsBase,
engineBreakers: [
{
factoryId: 'fac_1',
engine: 'codex',
state: 'OPEN',
failureCount: 3,
lastFailureAt: null,
},
{
factoryId: 'fac_1',
engine: 'devin',
state: 'CLOSED',
failureCount: 0,
lastFailureAt: null,
},
],
});
const { root, container } = await renderPage();
const panel = container.querySelector('[data-testid="fleet-breakers"]');
expect(panel).not.toBeNull();
expect(panel!.textContent).toContain('fac_1 · codex');
expect(panel!.textContent).toContain('OPEN');
expect(panel!.textContent).not.toContain('devin'); // CLOSED pairs are hidden
act(() => root.unmount());
container.remove();
});
it('lists dead-letter jobs and re-drives one via the operator action', async () => {
getFleetMetrics.mockResolvedValue({ ...metricsBase });
const { root, container } = await renderPage();
const triage = container.querySelector('[data-testid="fleet-dead-letter"]');
expect(triage).not.toBeNull();
expect(triage!.textContent).toContain('broken-task');
const btn = container.querySelector(
'[aria-label="Re-drive job broken-task"]'
) as HTMLButtonElement;
expect(btn).not.toBeNull();
await act(async () => {
btn.click();
await Promise.resolve();
});
expect(operatorAction).toHaveBeenCalledWith('j-dead', 'redrive');
act(() => root.unmount());
container.remove();
});
it('hides the budget and breaker panels when the metrics omit them', async () => {
getFleetMetrics.mockResolvedValue({ ...metricsBase });
listJobs.mockResolvedValue({ jobs: [] }); // no dead letters either
const { root, container } = await renderPage();
expect(container.querySelector('[data-testid="fleet-budget"]')).toBeNull();
expect(container.querySelector('[data-testid="fleet-breakers"]')).toBeNull();
expect(container.querySelector('[data-testid="fleet-dead-letter"]')).toBeNull();
act(() => root.unmount());
container.remove();
});
});

View File

@ -1,51 +0,0 @@
/**
* Configurable product list (generic-platform support): the NEXT_PUBLIC_PRODUCTS
* override parser must be defensive so a bad env value can never blank the switcher.
*/
import { describe, it, expect } from 'vitest';
import { parseProductsEnv, DEFAULT_PRODUCTS, type Product } from '@/lib/product-constants';
const fallback: readonly Product[] = [{ id: 'only', name: 'Only' }];
describe('parseProductsEnv', () => {
it('returns the fallback for empty / missing input', () => {
expect(parseProductsEnv(undefined, fallback)).toBe(fallback);
expect(parseProductsEnv('', fallback)).toBe(fallback);
expect(parseProductsEnv(' ', fallback)).toBe(fallback);
});
it('parses a valid JSON array of products', () => {
const raw = JSON.stringify([
{ id: 'acme', name: 'Acme App', icon: 'Box' },
{ id: 'beta', name: 'Beta' },
]);
expect(parseProductsEnv(raw, fallback)).toEqual([
{ id: 'acme', name: 'Acme App', icon: 'Box' },
{ id: 'beta', name: 'Beta' },
]);
});
it('defaults a missing name to the id and drops the icon when absent', () => {
expect(parseProductsEnv(JSON.stringify([{ id: 'x' }]), fallback)).toEqual([
{ id: 'x', name: 'x' },
]);
});
it('skips malformed entries but keeps valid ones', () => {
const raw = JSON.stringify([{ id: '' }, null, 42, { name: 'no id' }, { id: 'ok', name: 'OK' }]);
expect(parseProductsEnv(raw, fallback)).toEqual([{ id: 'ok', name: 'OK' }]);
});
it('falls back on non-array JSON, invalid JSON, or an all-invalid array', () => {
expect(parseProductsEnv('{"id":"x"}', fallback)).toBe(fallback); // object, not array
expect(parseProductsEnv('not json', fallback)).toBe(fallback);
expect(parseProductsEnv(JSON.stringify([{ bad: true }]), fallback)).toBe(fallback);
});
it('the built-in default is non-empty with unique ids', () => {
expect(DEFAULT_PRODUCTS.length).toBeGreaterThan(0);
const ids = DEFAULT_PRODUCTS.map(p => p.id);
expect(new Set(ids).size).toBe(ids.length);
});
});

View File

@ -1,6 +1,6 @@
// @vitest-environment happy-dom // @vitest-environment jsdom
import { describe, expect, it, beforeAll, beforeEach, afterEach, vi } from 'vitest'; import { describe, expect, it, beforeAll, beforeEach, vi } from 'vitest';
import { act, createElement } from 'react'; import { act, createElement } from 'react';
import { createRoot, type Root } from 'react-dom/client'; import { createRoot, type Root } from 'react-dom/client';
@ -14,10 +14,6 @@ beforeEach(() => {
localStorage.clear(); localStorage.clear();
}); });
afterEach(() => {
vi.unstubAllGlobals();
});
function renderProductHarness() { function renderProductHarness() {
const container = document.createElement('div'); const container = document.createElement('div');
document.body.appendChild(container); document.body.appendChild(container);
@ -77,51 +73,4 @@ describe('ProductProvider', () => {
cleanup(); cleanup();
}); });
it('does not fetch /products/mine when there is no auth token (keeps the default list)', () => {
const fetchSpy = vi.fn();
vi.stubGlobal('fetch', fetchSpy);
const { cleanup } = renderProductHarness();
expect(fetchSpy).not.toHaveBeenCalled();
cleanup();
});
it('replaces the list with the caller\u2019s projects from /products/mine when authed', async () => {
localStorage.setItem('tracker_token', 'tok');
localStorage.setItem('tracker_selected_product', 'acme');
const fetchSpy = vi.fn(async () => ({
ok: true,
json: async () => ({ products: [{ productId: 'acme', displayName: 'Acme App' }] }),
}));
vi.stubGlobal('fetch', fetchSpy as unknown as typeof fetch);
const container = document.createElement('div');
document.body.appendChild(container);
let root!: Root;
function Harness() {
const { products, productName } = useProduct();
return createElement(
'div',
null,
createElement('span', { 'data-testid': 'names' }, products.map(p => p.name).join(',')),
createElement('span', { 'data-testid': 'name' }, productName)
);
}
await act(async () => {
root = createRoot(container);
root.render(createElement(ProductProvider, null, createElement(Harness)));
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(fetchSpy).toHaveBeenCalledWith(
'/api/products/mine',
expect.objectContaining({ headers: { Authorization: 'Bearer tok' } })
);
expect(container.querySelector('[data-testid="names"]')?.textContent).toBe('Acme App');
expect(container.querySelector('[data-testid="name"]')?.textContent).toBe('Acme App');
act(() => root.unmount());
});
}); });

View File

@ -1,60 +0,0 @@
// @vitest-environment happy-dom
/**
* ProductSwitcher auto-hides for single-tenant deployments and lists the
* configured products otherwise.
*/
import { describe, it, expect, beforeAll, vi } from 'vitest';
import { act, createElement } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import type { Product } from '@/lib/product-constants';
const useProduct = vi.fn();
vi.mock('@/lib/product-context', () => ({ useProduct: () => useProduct() }));
import { ProductSwitcher } from '@/components/product-switcher';
beforeAll(() => {
(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
});
function render(products: readonly Product[]): { container: HTMLDivElement; root: Root } {
useProduct.mockReturnValue({
productId: products[0]?.id ?? 'x',
setProductId: vi.fn(),
products,
});
const container = document.createElement('div');
document.body.appendChild(container);
let root!: Root;
act(() => {
root = createRoot(container);
root.render(createElement(ProductSwitcher));
});
return { container, root };
}
describe('ProductSwitcher', () => {
it('renders nothing when there is zero or one product', () => {
const none = render([]);
expect(none.container.querySelector('select')).toBeNull();
act(() => none.root.unmount());
const one = render([{ id: 'solo', name: 'Solo' }]);
expect(one.container.querySelector('select')).toBeNull();
act(() => one.root.unmount());
});
it('renders a select with an option per product when there are multiple', () => {
const { container, root } = render([
{ id: 'a', name: 'Alpha' },
{ id: 'b', name: 'Beta' },
]);
const select = container.querySelector('select');
expect(select).not.toBeNull();
expect(select!.querySelectorAll('option')).toHaveLength(2);
expect(container.textContent).toContain('Alpha');
expect(container.textContent).toContain('Beta');
act(() => root.unmount());
});
});

View File

@ -1,77 +0,0 @@
/**
* Tests for /api/products/[...path] products registry proxy (owner-scoped
* "my projects" via GET /api/products/mine).
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import type { NextRequest } from 'next/server';
const mockFetch = vi.fn();
vi.stubGlobal('fetch', mockFetch);
import { GET } from '@/app/api/products/[...path]/route';
function mockNextRequest(
method: string,
body?: string,
headers?: Record<string, string>
): NextRequest {
const headerMap = new Map(Object.entries(headers || {}));
return {
method,
headers: {
get: (key: string) => headerMap.get(key.toLowerCase()) || headerMap.get(key) || null,
},
nextUrl: { searchParams: new URLSearchParams() },
text: async () => body || '',
} as unknown as NextRequest;
}
describe('products proxy', () => {
const originalEnv = { ...process.env };
beforeEach(() => {
vi.clearAllMocks();
process.env.PLATFORM_API_URL = 'http://localhost:4003';
});
afterEach(() => {
process.env = { ...originalEnv };
});
it('proxies GET /products/mine, forwarding the bearer token', async () => {
mockFetch.mockResolvedValue({
status: 200,
text: async () => JSON.stringify({ products: [{ productId: 'acme' }] }),
});
const req = mockNextRequest('GET', undefined, { authorization: 'Bearer tok' });
const res = await GET(req, { params: Promise.resolve({ path: ['mine'] }) });
expect(res.status).toBe(200);
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining('/api/products/mine'),
expect.objectContaining({
method: 'GET',
headers: expect.objectContaining({ Authorization: 'Bearer tok' }),
})
);
});
it('promotes x-tracker-token to a bearer when no Authorization is present', async () => {
mockFetch.mockResolvedValue({ status: 200, text: async () => '{}' });
const req = mockNextRequest('GET', undefined, { 'x-tracker-token': 'ttok' });
await GET(req, { params: Promise.resolve({ path: ['mine'] }) });
expect(mockFetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
headers: expect.objectContaining({ Authorization: 'Bearer ttok' }),
})
);
});
it('returns 502 when the products service is unavailable', async () => {
mockFetch.mockRejectedValue(new Error('ECONNREFUSED'));
const req = mockNextRequest('GET');
const res = await GET(req, { params: Promise.resolve({ path: ['mine'] }) });
expect(res.status).toBe(502);
await expect(res.json()).resolves.toEqual({ error: 'Products service unavailable' });
});
});

View File

@ -9,8 +9,7 @@ const PLATFORM_API = process.env.PLATFORM_API_URL || 'http://localhost:4003';
async function proxy(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) { async function proxy(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
const { path } = await params; const { path } = await params;
// platform-service mounts fleet routes under the /api prefix. const targetPath = `/fleet/${path.join('/')}`;
const targetPath = `/api/fleet/${path.join('/')}`;
const url = new URL(targetPath, PLATFORM_API); const url = new URL(targetPath, PLATFORM_API);
req.nextUrl.searchParams.forEach((value, key) => { req.nextUrl.searchParams.forEach((value, key) => {
@ -18,7 +17,7 @@ async function proxy(req: NextRequest, { params }: { params: Promise<{ path: str
}); });
try { try {
const headers: Record<string, string> = {}; const headers: Record<string, string> = { 'Content-Type': 'application/json' };
const auth = req.headers.get('authorization'); const auth = req.headers.get('authorization');
if (auth) headers['Authorization'] = auth; if (auth) headers['Authorization'] = auth;
@ -37,13 +36,7 @@ async function proxy(req: NextRequest, { params }: { params: Promise<{ path: str
if (req.method !== 'GET' && req.method !== 'HEAD') { if (req.method !== 'GET' && req.method !== 'HEAD') {
const body = await req.text(); const body = await req.text();
if (body) { if (body) fetchOptions.body = body;
fetchOptions.body = body;
// Only declare a JSON body when there actually is one — bodyless POSTs
// (operator actions: ship/requeue/cancel) must NOT send Content-Type, or
// the backend rejects them with FST_ERR_CTP_EMPTY_JSON_BODY.
headers['Content-Type'] = 'application/json';
}
} }
const res = await fetch(url.toString(), fetchOptions); const res = await fetch(url.toString(), fetchOptions);

View File

@ -1,56 +0,0 @@
/**
* Catch-all proxy to platform-service products endpoints.
* Forwards /api/products/* (notably GET /api/products/mine the caller's
* owner-scoped project list) to the platform-service registry.
*/
import { NextRequest, NextResponse } from 'next/server';
const PLATFORM_API = process.env.PLATFORM_API_URL || 'http://localhost:4003';
async function proxy(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
const { path } = await params;
// platform-service mounts product routes under the /api prefix.
const targetPath = `/api/products/${path.join('/')}`;
const url = new URL(targetPath, PLATFORM_API);
req.nextUrl.searchParams.forEach((value, key) => {
url.searchParams.set(key, value);
});
try {
const headers: Record<string, string> = {};
const auth = req.headers.get('authorization');
if (auth) headers['Authorization'] = auth;
const tokenHeader = req.headers.get('x-tracker-token');
if (tokenHeader && !auth) headers['Authorization'] = `Bearer ${tokenHeader}`;
const productId = req.headers.get('x-product-id');
if (productId) headers['x-product-id'] = productId;
const fetchOptions: RequestInit = { method: req.method, headers };
if (req.method !== 'GET' && req.method !== 'HEAD') {
const body = await req.text();
if (body) {
fetchOptions.body = body;
headers['Content-Type'] = 'application/json';
}
}
const res = await fetch(url.toString(), fetchOptions);
const data = await res.text();
return new NextResponse(data, {
status: res.status,
headers: { 'Content-Type': 'application/json' },
});
} catch {
return NextResponse.json({ error: 'Products service unavailable' }, { status: 502 });
}
}
export const GET = proxy;
export const POST = proxy;
export const PUT = proxy;
export const PATCH = proxy;
export const DELETE = proxy;

View File

@ -11,23 +11,11 @@ export async function POST(req: NextRequest) {
try { try {
const body = await req.text(); const body = await req.text();
// The backend telemetry ingest requires a JWT or an X-Install-Token. Browser
// beacons can't set an Authorization header, so derive an install token from
// the payload (installId) with a stable fallback to satisfy the auth gate.
let installToken = 'web-telemetry';
try {
const parsed = JSON.parse(body);
installToken = parsed.installId || parsed.events?.[0]?.installId || installToken;
} catch {
/* keep fallback */
}
const res = await fetch(`${PLATFORM_SERVICE_URL}/api/telemetry/events`, { const res = await fetch(`${PLATFORM_SERVICE_URL}/api/telemetry/events`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-Product-Id': PRODUCT_ID, 'X-Product-Id': PRODUCT_ID,
'X-Install-Token': installToken,
}, },
body, body,
}); });

View File

@ -4,20 +4,11 @@ import { useEffect, useState, useCallback } from 'react';
import { PageHeader } from '@bytelyst/dashboard-components'; import { PageHeader } from '@bytelyst/dashboard-components';
import { Button } from '@/components/ui/Primitives'; import { Button } from '@/components/ui/Primitives';
import { useAuth } from '@/lib/auth-context'; import { useAuth } from '@/lib/auth-context';
import { import { getBudget, pauseBudget, resumeBudget, type FleetBudget } from '@/lib/fleet-client';
getBudget,
getBudgetBurndown,
pauseBudget,
resumeBudget,
budgetUsagePct,
type FleetBudget,
type CostBurndown,
} from '@/lib/fleet-client';
export default function FleetBudgetPage() { export default function FleetBudgetPage() {
const { token } = useAuth(); const { token } = useAuth();
const [budget, setBudget] = useState<FleetBudget | null | undefined>(undefined); const [budget, setBudget] = useState<FleetBudget | null | undefined>(undefined);
const [burndown, setBurndown] = useState<CostBurndown | null>(null);
const [acting, setActing] = useState(false); const [acting, setActing] = useState(false);
const productId = const productId =
@ -29,9 +20,8 @@ export default function FleetBudgetPage() {
return; return;
} }
try { try {
const [b, bd] = await Promise.all([getBudget(productId), getBudgetBurndown(productId, 30)]); const b = await getBudget(productId);
setBudget(b); setBudget(b);
setBurndown(bd);
} catch { } catch {
setBudget(null); setBudget(null);
} }
@ -113,34 +103,23 @@ export default function FleetBudgetPage() {
</span> </span>
</div> </div>
{/* Spend bar — guards against a missing/zero ceiling (no NaN bar). */} {/* Spend bar */}
{(() => { <div>
const hasCeiling = Number.isFinite(budget.ceilingUsd) && budget.ceilingUsd > 0; <div className="flex justify-between text-sm mb-1">
const usagePct = budgetUsagePct(budget.spentUsd, budget.ceilingUsd); <span>Spent</span>
const overCeiling = hasCeiling && budget.spentUsd >= budget.ceilingUsd; <span>
return ( ${budget.spentUsd.toFixed(2)} / ${budget.ceilingUsd.toFixed(2)}
<div> </span>
<div className="flex justify-between text-sm mb-1"> </div>
<span>Spent</span> <div className="w-full bg-muted rounded-full h-2.5" aria-label="Budget usage bar">
<span> <div
${budget.spentUsd.toFixed(2)} /{' '} className={`h-2.5 rounded-full ${
{hasCeiling ? `$${budget.ceilingUsd.toFixed(2)}` : 'no ceiling set'} budget.spentUsd >= budget.ceilingUsd ? 'bg-red-500' : 'bg-blue-500'
</span> }`}
</div> style={{ width: `${Math.min(100, (budget.spentUsd / budget.ceilingUsd) * 100)}%` }}
<div className="w-full bg-muted rounded-full h-2.5" aria-label="Budget usage bar"> />
<div </div>
className={`h-2.5 rounded-full ${overCeiling ? 'bg-red-500' : 'bg-blue-500'}`} </div>
style={{ width: `${usagePct}%` }}
/>
</div>
{!hasCeiling && (
<p className="text-xs text-muted-foreground mt-1">
No spend ceiling configured usage is unbounded.
</p>
)}
</div>
);
})()}
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
<p>Window: {budget.window}</p> <p>Window: {budget.window}</p>
@ -173,62 +152,6 @@ export default function FleetBudgetPage() {
</div> </div>
</div> </div>
)} )}
{/* Cost burndown */}
{productId && burndown && burndown.days.length > 0 && <BurndownChart burndown={burndown} />}
</div> </div>
); );
} }
function BurndownChart({ burndown }: { burndown: CostBurndown }) {
const { days, ceilingUsd, totalUsd } = burndown;
const maxValue = Math.max(
ceilingUsd ?? 0,
...days.map(d => d.cumulativeUsd),
1 // avoid divide-by-zero
);
const ceilingPct = ceilingUsd ? (ceilingUsd / maxValue) * 100 : null;
return (
<section className="rounded-lg border p-6 max-w-3xl">
<div className="flex items-center justify-between mb-1">
<h2 className="font-semibold">Cost Burndown</h2>
<span className="text-sm text-muted-foreground">
${totalUsd.toFixed(2)} over {days.length} days
</span>
</div>
<p className="text-xs text-muted-foreground mb-4">
Cumulative spend per day{ceilingUsd ? ' vs. budget ceiling (dashed)' : ''}.
</p>
<div className="relative h-40 flex items-end gap-1" aria-label="Cost burndown chart">
{ceilingPct !== null && (
<div
className="absolute left-0 right-0 border-t border-dashed border-red-500/70"
style={{ bottom: `${ceilingPct}%` }}
aria-label={`Budget ceiling $${ceilingUsd?.toFixed(2)}`}
/>
)}
{days.map(d => {
const heightPct = (d.cumulativeUsd / maxValue) * 100;
const overCeiling = ceilingUsd !== null && d.cumulativeUsd >= ceilingUsd;
return (
<div key={d.date} className="flex-1 min-w-0 group relative" style={{ height: '100%' }}>
<div className="absolute bottom-0 left-0 right-0 flex items-end h-full">
<div
className={`w-full rounded-t ${overCeiling ? 'bg-red-500' : 'bg-blue-500'}`}
style={{ height: `${Math.max(heightPct, d.cumulativeUsd > 0 ? 2 : 0)}%` }}
title={`${d.date}: $${d.cumulativeUsd.toFixed(2)} cumulative ($${d.costUsd.toFixed(2)} that day)`}
/>
</div>
</div>
);
})}
</div>
<div className="flex justify-between text-xs text-muted-foreground mt-2">
<span>{days[0]?.date}</span>
<span>{days[days.length - 1]?.date}</span>
</div>
</section>
);
}

View File

@ -3,18 +3,11 @@
import { useEffect, useState, useCallback } from 'react'; import { useEffect, useState, useCallback } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { PageHeader } from '@bytelyst/dashboard-components'; import { PageHeader } from '@bytelyst/dashboard-components';
import { Button } from '@/components/ui/Primitives';
import { useAuth } from '@/lib/auth-context'; import { useAuth } from '@/lib/auth-context';
import { import { listJobs, type FleetJob } from '@/lib/fleet-client';
listJobs,
submitJob,
availableEnginesForProduct,
FLEET_ENGINES,
type FleetJob,
type FleetEngine,
} from '@/lib/fleet-client';
const STAGES = [ const STAGES = [
'',
'queued', 'queued',
'blocked', 'blocked',
'assigned', 'assigned',
@ -24,107 +17,27 @@ const STAGES = [
'shipped', 'shipped',
'failed', 'failed',
'dead_letter', 'dead_letter',
] as const; ];
// Semantic badge styling per stage (token-backed Tailwind colors).
const STAGE_STYLE: Record<string, string> = {
queued: 'bg-muted text-muted-foreground',
blocked: 'bg-amber-500/15 text-amber-700 dark:text-amber-400',
assigned: 'bg-sky-500/15 text-sky-700 dark:text-sky-400',
building: 'bg-blue-500/15 text-blue-700 dark:text-blue-400',
review: 'bg-amber-500/15 text-amber-700 dark:text-amber-400',
testing: 'bg-purple-500/15 text-purple-700 dark:text-purple-400',
shipped: 'bg-green-600/15 text-green-700 dark:text-green-400',
failed: 'bg-destructive/15 text-destructive',
dead_letter: 'bg-destructive/15 text-destructive',
};
const stageStyle = (s: string) => STAGE_STYLE[s] ?? 'bg-muted text-muted-foreground';
const stageLabel = (s: string) => s.replace(/_/g, ' ');
const PRIORITY_STYLE: Record<string, string> = {
critical: 'text-destructive font-semibold',
high: 'text-amber-700 dark:text-amber-400',
medium: 'text-muted-foreground',
low: 'text-muted-foreground',
};
const POLL_INTERVAL = 30_000; const POLL_INTERVAL = 30_000;
// MVP: PR-mode target repos (local checkouts under the factory's repo base).
// Base branch is fixed to `main`. Empty selection = no PR (plain job).
const FLEET_REPOS = [
'learning_ai_common_plat',
'learning_ai_devops_tools',
'learning_voice_ai_agent',
'learning_multimodal_memory_agents',
'learning_ai_clock',
'learning_ai_jarvis_jr',
'learning_ai_fastgap',
'learning_ai_peakpulse',
'learning_ai_flowmonk',
'learning_ai_notes',
'learning_ai_trails',
'learning_ai_efforise',
'learning_ai_local_memory_gpt',
'learning_ai_2nd_brain',
'learning_ai_auth_app',
'learning_agent_monitoring_fx',
'learning_notif_scanr',
'learning_ai_local_llms',
'learning_ai_mac_tooling',
'learning_ai_productivity_web',
'learning_ai_webui_copilot',
];
const FLEET_BASE_BRANCH = 'main';
// Live factories on this host (one agent-queue daemon per product — see
// _start_fleet.sh). Selecting one routes the job to that factory's product (the
// factory polling that product claims it). The id matches the factory's
// AQ_FACTORY_ID so the labels line up with what the fleet dashboard shows.
const FLEET_FACTORIES = [
{ id: 'mac-lysnrai', label: 'mac-lysnrai — LysnrAI', productId: 'lysnrai' },
{ id: 'mac-chronomind', label: 'mac-chronomind — ChronoMind', productId: 'chronomind' },
{ id: 'mac-mindlyst', label: 'mac-mindlyst — MindLyst', productId: 'mindlyst' },
{ id: 'mac-nomgap', label: 'mac-nomgap — NomGap', productId: 'nomgap' },
];
export default function FleetJobsPage() { export default function FleetJobsPage() {
const { token } = useAuth(); const { token } = useAuth();
const [jobs, setJobs] = useState<FleetJob[]>([]); const [jobs, setJobs] = useState<FleetJob[]>([]);
const [stage, setStage] = useState(''); const [stage, setStage] = useState('');
const [hideShipped, setHideShipped] = useState(false);
const [search, setSearch] = useState('');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
// New-job form state
const [showForm, setShowForm] = useState(false);
const [factoryId, setFactoryId] = useState(FLEET_FACTORIES[0].id);
const [body, setBody] = useState('');
const [priority, setPriority] = useState<'critical' | 'high' | 'medium' | 'low'>('high');
const [engine, setEngine] = useState<FleetEngine>('devin');
// Engines the selected factory's product actually advertises ([] ⇒ unknown,
// offer all). Keeps users from picking an engine the host can't run.
const [engineOptions, setEngineOptions] = useState<FleetEngine[]>([]);
// Empty by default: no agent-queue factory advertises a `build` capability
// (caps are os:* / engine:* / node:* / has:*), so a non-empty default here makes
// the job unroutable. Leave blank ⇒ any capable factory for the product claims it.
const [caps, setCaps] = useState('');
const [repo, setRepo] = useState('');
const [verifyCmd, setVerifyCmd] = useState('');
const [autoMerge, setAutoMerge] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [submitMsg, setSubmitMsg] = useState<{ ok: boolean; text: string } | null>(null);
// Fetch the full recent window once; stage / hide-shipped / search are applied
// client-side so the stage counts stay accurate and filtering is instant.
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
try { try {
const res = await listJobs({ limit: '100' } as never); const params: Record<string, string> = { limit: '50' };
if (stage) params.stage = stage;
const res = await listJobs(params as never);
setJobs(res.jobs); setJobs(res.jobs);
} catch { } catch {
/* degrade */ /* degrade */
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, []); }, [stage]);
useEffect(() => { useEffect(() => {
if (!token) return; if (!token) return;
@ -133,399 +46,69 @@ export default function FleetJobsPage() {
return () => clearInterval(id); return () => clearInterval(id);
}, [token, refresh]); }, [token, refresh]);
// Restore the hide-shipped preference (client-only; avoids hydration mismatch).
useEffect(() => {
setHideShipped(localStorage.getItem('fleet_hide_shipped') === '1');
}, []);
// When the target factory changes, learn which engines its product advertises
// and constrain the picker (so you can't pick an engine the host can't run).
useEffect(() => {
if (!token || !showForm) return;
const factory = FLEET_FACTORIES.find(f => f.id === factoryId) ?? FLEET_FACTORIES[0];
let cancelled = false;
availableEnginesForProduct(factory.productId)
.then(engines => {
if (cancelled) return;
setEngineOptions(engines);
// Keep the selection valid: prefer the current pick, else devin, else first.
if (engines.length > 0) {
setEngine(prev =>
engines.includes(prev) ? prev : engines.includes('devin') ? 'devin' : engines[0]!
);
}
})
.catch(() => {
if (!cancelled) setEngineOptions([]);
});
return () => {
cancelled = true;
};
}, [token, showForm, factoryId]);
const toggleHideShipped = useCallback((next: boolean) => {
setHideShipped(next);
localStorage.setItem('fleet_hide_shipped', next ? '1' : '0');
}, []);
// Stage counts (over everything fetched) + the filtered, sorted view.
const stageCounts = jobs.reduce<Record<string, number>>((acc, j) => {
acc[j.stage] = (acc[j.stage] ?? 0) + 1;
return acc;
}, {});
const q = search.trim().toLowerCase();
const visible = jobs
.filter(j => {
if (stage && j.stage !== stage) return false;
if (hideShipped && j.stage === 'shipped') return false;
if (q) {
const hay = `${j.idempotencyKey} ${j.repo ?? ''} ${j.id}`.toLowerCase();
if (!hay.includes(q)) return false;
}
return true;
})
.sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt));
const handleSubmit = useCallback(
async (asDraft = false) => {
if (!body.trim()) {
setSubmitMsg({ ok: false, text: 'Job body is required.' });
return;
}
setSubmitting(true);
setSubmitMsg(null);
try {
const capabilities = caps
.split(',')
.map(c => c.trim())
.filter(Boolean);
const factory = FLEET_FACTORIES.find(f => f.id === factoryId) ?? FLEET_FACTORIES[0];
const { job } = await submitJob(
{
idempotencyKey: `ui-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
bodyMd: body.trim(),
priority,
capabilities,
engine,
...(asDraft ? { draft: true } : {}),
...(repo
? {
repo,
baseBranch: FLEET_BASE_BRANCH,
...(verifyCmd.trim() ? { verify: verifyCmd.trim() } : {}),
...(autoMerge ? { autoMerge: true } : {}),
}
: {}),
},
factory.productId
);
// Route the dashboard view to the factory's product so the job is visible.
if (typeof window !== 'undefined') {
localStorage.setItem('tracker_selected_product', factory.productId);
}
setSubmitMsg({
ok: true,
text:
`${asDraft ? 'Saved draft' : 'Submitted'} ${job.id} to ${factory.id} (${factory.productId}, stage: ${job.stage})` +
(repo
? ` — PR mode: ${repo}@${FLEET_BASE_BRANCH}${verifyCmd.trim() ? ' +verify' : ''}${autoMerge ? ' +auto-merge' : ''}`
: ' — no PR (plain job)') +
(asDraft ? ' — open it to edit + submit.' : '.'),
});
setBody('');
await refresh();
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Submit failed.';
setSubmitMsg({ ok: false, text: msg });
} finally {
setSubmitting(false);
}
},
[body, caps, priority, engine, repo, verifyCmd, autoMerge, factoryId, refresh]
);
return ( return (
<div className="p-6 space-y-6"> <div className="p-6 space-y-6">
<PageHeader title="Fleet Jobs" /> <PageHeader title="Fleet Jobs" />
{/* New job */}
<div className="rounded-lg border">
<button
type="button"
onClick={() => setShowForm(v => !v)}
className="flex w-full items-center justify-between px-4 py-3 text-sm font-medium"
aria-expanded={showForm}
>
<span>New Job</span>
<span className="text-muted-foreground">{showForm ? '' : '+'}</span>
</button>
{showForm && (
<div className="space-y-3 border-t px-4 py-4">
<div>
<label htmlFor="job-factory" className="mb-1 block text-sm font-medium">
Factory
</label>
<select
id="job-factory"
value={factoryId}
onChange={e => setFactoryId(e.target.value)}
className="rounded border bg-background px-2 py-1 text-sm"
>
{FLEET_FACTORIES.map(f => (
<option key={f.id} value={f.id}>
{f.label}
</option>
))}
</select>
</div>
<div>
<label htmlFor="job-body" className="mb-1 block text-sm font-medium">
Task (markdown)
</label>
<textarea
id="job-body"
value={body}
onChange={e => setBody(e.target.value)}
rows={4}
placeholder="e.g. Create a file HELLO.md with one line: hello. Then stop."
className="w-full rounded border bg-background px-2 py-1 text-sm font-mono"
/>
</div>
<div className="flex flex-wrap items-end gap-4">
<div>
<label htmlFor="job-priority" className="mb-1 block text-sm font-medium">
Priority
</label>
<select
id="job-priority"
value={priority}
onChange={e =>
setPriority(e.target.value as 'critical' | 'high' | 'medium' | 'low')
}
className="rounded border bg-background px-2 py-1 text-sm"
>
<option value="critical">critical</option>
<option value="high">high</option>
<option value="medium">medium</option>
<option value="low">low</option>
</select>
</div>
<div>
<label htmlFor="job-engine" className="mb-1 block text-sm font-medium">
Engine
</label>
<select
id="job-engine"
value={engine}
onChange={e => setEngine(e.target.value as FleetEngine)}
className="rounded border bg-background px-2 py-1 text-sm"
>
{(engineOptions.length > 0 ? engineOptions : FLEET_ENGINES).map(e => (
<option key={e} value={e}>
{e}
</option>
))}
</select>
{engineOptions.length > 0 && (
<p className="mt-1 text-xs text-muted-foreground">advertised by this factory</p>
)}
</div>
<div>
<label htmlFor="job-caps" className="mb-1 block text-sm font-medium">
Capabilities (comma-separated)
</label>
<input
id="job-caps"
value={caps}
onChange={e => setCaps(e.target.value)}
placeholder="optional — e.g. engine:devin, has:docker"
className="rounded border bg-background px-2 py-1 text-sm"
/>
</div>
<div>
<label htmlFor="job-repo" className="mb-1 block text-sm font-medium">
Repo (optional opens a PR)
</label>
<select
id="job-repo"
value={repo}
onChange={e => setRepo(e.target.value)}
className="rounded border bg-background px-2 py-1 text-sm font-mono"
>
<option value=""> none (no PR) </option>
{FLEET_REPOS.map(r => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
{repo && (
<p className="mt-1 text-xs text-muted-foreground">
PR against <span className="font-mono">{FLEET_BASE_BRANCH}</span>
</p>
)}
</div>
{repo && (
<div>
<label htmlFor="job-verify" className="mb-1 block text-sm font-medium">
Verify command (optional)
</label>
<input
id="job-verify"
value={verifyCmd}
onChange={e => setVerifyCmd(e.target.value)}
placeholder="e.g. pnpm install && pnpm test"
className="w-64 rounded border bg-background px-2 py-1 text-sm font-mono"
/>
<p className="mt-1 text-xs text-muted-foreground">
Runs in the checkout; the PR opens only if it passes.
</p>
</div>
)}
{repo && (
<label className="flex items-center gap-2 self-end pb-1 text-sm">
<input
type="checkbox"
checked={autoMerge}
onChange={e => setAutoMerge(e.target.checked)}
aria-label="Auto-merge the PR when opened"
/>
Auto-merge PR
</label>
)}
<Button
variant="secondary"
onClick={() => handleSubmit(true)}
disabled={submitting}
aria-label="Save as draft (not submitted)"
>
{submitting ? 'Saving…' : 'Save as draft'}
</Button>
<Button
onClick={() => handleSubmit(false)}
disabled={submitting}
aria-label="Submit new job"
>
{submitting ? 'Submitting…' : 'Submit'}
</Button>
</div>
{submitMsg && (
<p
className={`text-sm ${submitMsg.ok ? 'text-green-600 dark:text-green-400' : 'text-destructive'}`}
role="status"
>
{submitMsg.text}
</p>
)}
</div>
)}
</div>
{/* Filters */} {/* Filters */}
<div className="space-y-3"> <div className="flex gap-3 items-center">
{/* Stage chips with live counts (click to filter) */} <label htmlFor="stage-filter" className="text-sm font-medium">
<div className="flex flex-wrap items-center gap-1.5"> Stage:
<button </label>
type="button" <select
onClick={() => setStage('')} id="stage-filter"
className={`rounded-full border px-2.5 py-1 text-xs font-medium ${ value={stage}
stage === '' ? 'bg-primary text-primary-foreground border-primary' : 'hover:bg-muted' onChange={e => setStage(e.target.value)}
}`} className="rounded border px-2 py-1 text-sm bg-background"
> aria-label="Filter by stage"
All <span className="opacity-70">{jobs.length}</span> >
</button> {STAGES.map(s => (
{STAGES.filter(s => stageCounts[s]).map(s => ( <option key={s} value={s}>
<button {s || 'All'}
key={s} </option>
type="button"
onClick={() => setStage(stage === s ? '' : s)}
className={`rounded-full px-2.5 py-1 text-xs font-medium ${stageStyle(s)} ${
stage === s ? 'ring-2 ring-primary ring-offset-1 ring-offset-background' : ''
}`}
>
{stageLabel(s)} <span className="opacity-70">{stageCounts[s]}</span>
</button>
))} ))}
</div> </select>
{/* Search + hide-shipped */}
<div className="flex flex-wrap items-center gap-4">
<input
type="search"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Search key / repo / id…"
className="w-64 rounded border bg-background px-2 py-1 text-sm"
aria-label="Search jobs"
/>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={hideShipped}
onChange={e => toggleHideShipped(e.target.checked)}
aria-label="Hide shipped jobs"
/>
Hide shipped
</label>
<span className="text-xs text-muted-foreground">
{visible.length} of {jobs.length} shown
</span>
</div>
</div> </div>
{/* Table */} {/* Table */}
{loading ? ( {loading ? (
<p className="text-muted-foreground">Loading jobs...</p> <p className="text-muted-foreground">Loading jobs...</p>
) : visible.length === 0 ? ( ) : jobs.length === 0 ? (
<p className="text-muted-foreground"> <p className="text-muted-foreground">No jobs match the current filter.</p>
{jobs.length === 0 ? 'No jobs yet — submit one above.' : 'No jobs match the filters.'}
</p>
) : ( ) : (
<div className="overflow-x-auto rounded-lg border"> <div className="overflow-x-auto">
<table className="w-full text-sm" aria-label="Fleet jobs table"> <table className="w-full text-sm" aria-label="Fleet jobs table">
<thead> <thead>
<tr className="border-b bg-muted/40 text-left text-xs uppercase tracking-wide text-muted-foreground"> <tr className="border-b text-left text-muted-foreground">
<th className="px-3 py-2">Job</th> <th className="pb-2 pr-4">Idempotency Key</th>
<th className="px-3 py-2">Stage</th> <th className="pb-2 pr-4">Stage</th>
<th className="px-3 py-2">Priority</th> <th className="pb-2 pr-4">Priority</th>
<th className="px-3 py-2">Repo</th> <th className="pb-2 pr-4">Kind</th>
<th className="px-3 py-2 text-right">Attempts</th> <th className="pb-2 pr-4">Attempts</th>
<th className="px-3 py-2 text-right">Created</th> <th className="pb-2">Created</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{visible.map(j => ( {jobs.map(j => (
<tr key={j.id} className="border-b last:border-0 hover:bg-muted/40"> <tr key={j.id} className="border-b last:border-0 hover:bg-muted/50 cursor-pointer">
<td className="px-3 py-2"> <td className="py-2 pr-4">
<Link <Link
href={`/dashboard/fleet/jobs/${j.id}`} href={`/dashboard/fleet/jobs/${j.id}`}
className="font-mono text-xs hover:underline" className="hover:underline font-mono text-xs"
aria-label={`View job ${j.idempotencyKey}`} aria-label={`View job ${j.idempotencyKey}`}
> >
{j.idempotencyKey} {j.idempotencyKey}
</Link> </Link>
{j.kind !== 'leaf' && (
<span className="ml-2 text-[10px] text-muted-foreground">{j.kind}</span>
)}
</td> </td>
<td className="px-3 py-2"> <td className="py-2 pr-4">
<span <span className="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium bg-muted">
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${stageStyle(j.stage)}`} {j.stage}
>
{stageLabel(j.stage)}
</span> </span>
</td> </td>
<td className={`px-3 py-2 capitalize ${PRIORITY_STYLE[j.priority] ?? ''}`}> <td className="py-2 pr-4 capitalize">{j.priority}</td>
{j.priority} <td className="py-2 pr-4">{j.kind}</td>
</td> <td className="py-2 pr-4">{j.attempts}</td>
<td className="px-3 py-2 font-mono text-xs"> <td className="py-2 text-xs text-muted-foreground">
{j.repo ? (
<span title={`${j.repo}@${j.baseBranch ?? 'main'}`}>{j.repo}</span>
) : (
<span className="text-muted-foreground"></span>
)}
</td>
<td className="px-3 py-2 text-right tabular-nums">{j.attempts}</td>
<td className="px-3 py-2 text-right text-xs text-muted-foreground whitespace-nowrap">
{new Date(j.createdAt).toLocaleString()} {new Date(j.createdAt).toLocaleString()}
</td> </td>
</tr> </tr>

View File

@ -4,15 +4,7 @@ import { useEffect, useState, useCallback } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { PageHeader } from '@bytelyst/dashboard-components'; import { PageHeader } from '@bytelyst/dashboard-components';
import { useAuth } from '@/lib/auth-context'; import { useAuth } from '@/lib/auth-context';
import { import { listFactories, listJobs, type FleetFactory, type FleetJob } from '@/lib/fleet-client';
listFactories,
listJobs,
getFleetMetrics,
operatorAction,
type FleetFactory,
type FleetJob,
type FleetMetrics,
} from '@/lib/fleet-client';
const POLL_INTERVAL = 30_000; const POLL_INTERVAL = 30_000;
@ -31,15 +23,6 @@ function HealthBadge({ health }: { health: string }) {
); );
} }
function MetricCard({ label, value }: { label: string; value: number | string }) {
return (
<div className="rounded-lg border p-3">
<p className="text-xs text-muted-foreground">{label}</p>
<p className="text-2xl font-semibold tabular-nums">{value}</p>
</div>
);
}
function StageBadge({ stage }: { stage: string }) { function StageBadge({ stage }: { stage: string }) {
const colors: Record<string, string> = { const colors: Record<string, string> = {
queued: 'bg-blue-500/20 text-blue-700 dark:text-blue-400', queued: 'bg-blue-500/20 text-blue-700 dark:text-blue-400',
@ -57,114 +40,17 @@ function StageBadge({ stage }: { stage: string }) {
); );
} }
const usd = (n: number) => `$${n.toFixed(2)}`;
/** Budget guardrail: spend vs ceiling + projected end-of-window burn (§3). */
function BudgetGuardrail({ budget }: { budget: NonNullable<FleetMetrics['budget']> }) {
const pct =
budget.ceilingUsd > 0 ? Math.min(100, (budget.spentUsd / budget.ceilingUsd) * 100) : 0;
const overProjected = budget.projectedUsd !== null && budget.projectedUsd > budget.ceilingUsd;
const exhausted = budget.spentUsd >= budget.ceilingUsd;
return (
<section aria-label="Budget guardrail" data-testid="fleet-budget">
<h2 className="text-lg font-semibold mb-3">Budget</h2>
<div className="rounded-lg border p-4 space-y-3">
<div className="flex items-center justify-between text-sm">
<span>
{usd(budget.spentUsd)} / {usd(budget.ceilingUsd)}{' '}
<span className="text-muted-foreground">({budget.window})</span>
</span>
<span className="text-xs text-muted-foreground capitalize">{budget.status}</span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-muted">
<div
className={`h-full ${exhausted ? 'bg-red-500' : overProjected ? 'bg-yellow-500' : 'bg-green-500'}`}
style={{ width: `${pct}%` }}
/>
</div>
{budget.projectedUsd !== null && (
<p
className={`text-xs ${overProjected ? 'text-yellow-700 dark:text-yellow-400' : 'text-muted-foreground'}`}
>
Projected {budget.window}: {usd(budget.projectedUsd)}
{overProjected && ' — over ceiling at current burn'}
</p>
)}
{budget.engines.length > 0 && (
<ul className="text-xs text-muted-foreground space-y-1">
{budget.engines.map(e => (
<li key={e.engine} className="flex justify-between">
<span className="font-mono">{e.engine}</span>
<span className={e.exhausted ? 'text-red-600 dark:text-red-400' : ''}>
{usd(e.spentUsd)} / {usd(e.ceilingUsd)}
{e.exhausted && ' (capped)'}
</span>
</li>
))}
</ul>
)}
</div>
</section>
);
}
/** Circuit-breaker panel: (factory, engine) pairs currently being routed around (§2). */
function BreakerPanel({ breakers }: { breakers: NonNullable<FleetMetrics['engineBreakers']> }) {
const tripped = breakers.filter(b => b.state !== 'CLOSED');
if (tripped.length === 0) return null;
return (
<section aria-label="Engine circuit breakers" data-testid="fleet-breakers">
<h2 className="text-lg font-semibold mb-3">Engine circuit breakers</h2>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{tripped.map(b => (
<div
key={`${b.factoryId}:${b.engine}`}
className="rounded-lg border border-red-500/30 bg-red-500/5 p-3 text-sm"
>
<div className="flex items-center justify-between">
<span className="font-mono truncate">
{b.factoryId} · {b.engine}
</span>
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${b.state === 'OPEN' ? 'bg-red-500/20 text-red-700 dark:text-red-400' : 'bg-yellow-500/20 text-yellow-700 dark:text-yellow-400'}`}
>
{b.state}
</span>
</div>
<p className="mt-1 text-xs text-muted-foreground">
{b.failureCount} failure(s)
{b.lastFailureAt && ` · last ${new Date(b.lastFailureAt).toLocaleTimeString()}`}
</p>
</div>
))}
</div>
</section>
);
}
export default function FleetOverviewPage() { export default function FleetOverviewPage() {
const { token } = useAuth(); const { token } = useAuth();
const [factories, setFactories] = useState<FleetFactory[]>([]); const [factories, setFactories] = useState<FleetFactory[]>([]);
const [jobs, setJobs] = useState<FleetJob[]>([]); const [jobs, setJobs] = useState<FleetJob[]>([]);
const [metrics, setMetrics] = useState<FleetMetrics | null>(null);
const [deadLetters, setDeadLetters] = useState<FleetJob[]>([]);
const [redriving, setRedriving] = useState<string | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
try { try {
const [facRes, jobRes, metricsRes, dlRes] = await Promise.all([ const [facRes, jobRes] = await Promise.all([listFactories(), listJobs({ limit: 10 })]);
listFactories(),
listJobs({ limit: 10 }),
getFleetMetrics().catch(() => null),
listJobs({ stage: 'dead_letter', limit: 50 }).catch(() => ({ jobs: [] as FleetJob[] })),
]);
setFactories(facRes.factories); setFactories(facRes.factories);
setJobs(jobRes.jobs); setJobs(jobRes.jobs);
setMetrics(metricsRes);
// Filter client-side too, so the triage list is correct even if the server
// ignores the stage filter.
setDeadLetters(dlRes.jobs.filter(j => j.stage === 'dead_letter'));
} catch { } catch {
/* degrade gracefully */ /* degrade gracefully */
} finally { } finally {
@ -172,21 +58,6 @@ export default function FleetOverviewPage() {
} }
}, []); }, []);
const handleRedrive = useCallback(
async (id: string) => {
setRedriving(id);
try {
await operatorAction(id, 'redrive');
await refresh();
} catch {
/* surfaced on next poll */
} finally {
setRedriving(null);
}
},
[refresh]
);
useEffect(() => { useEffect(() => {
if (!token) return; if (!token) return;
refresh(); refresh();
@ -207,101 +78,6 @@ export default function FleetOverviewPage() {
<div className="p-6 space-y-8"> <div className="p-6 space-y-8">
<PageHeader title="Fleet Control Plane" /> <PageHeader title="Fleet Control Plane" />
{/* Metrics + alerts */}
{metrics && (
<section aria-label="Fleet metrics" data-testid="fleet-metrics">
{metrics.alerts.length > 0 && (
<div className="space-y-2 mb-4" data-testid="fleet-alerts">
{metrics.alerts.map(a => (
<div
key={a.code}
role="alert"
className={`rounded-md border px-3 py-2 text-sm ${
a.level === 'critical'
? 'border-red-500/40 bg-red-500/10 text-red-700 dark:text-red-400'
: 'border-yellow-500/40 bg-yellow-500/10 text-yellow-700 dark:text-yellow-400'
}`}
>
<span className="font-medium uppercase mr-2">{a.level}</span>
{a.message}
</div>
))}
</div>
)}
<div className="grid gap-4 grid-cols-2 sm:grid-cols-3 lg:grid-cols-5">
<MetricCard label="Queue depth" value={metrics.jobs.queueDepth} />
<MetricCard label="Active" value={metrics.jobs.active} />
<MetricCard label="Blocked" value={metrics.jobs.blocked} />
<MetricCard
label="Live factories"
value={`${metrics.factories.live}/${metrics.factories.total}`}
/>
<MetricCard label="Utilization" value={`${metrics.factories.utilizationPct}%`} />
</div>
</section>
)}
{/* Budget guardrail */}
{metrics?.budget && <BudgetGuardrail budget={metrics.budget} />}
{/* Engine circuit breakers */}
{metrics?.engineBreakers && metrics.engineBreakers.length > 0 && (
<BreakerPanel breakers={metrics.engineBreakers} />
)}
{/* Dead-letter triage */}
{deadLetters.length > 0 && (
<section aria-label="Dead-letter triage" data-testid="fleet-dead-letter">
<h2 className="text-lg font-semibold mb-3">
Dead-letter triage{' '}
<span className="text-sm font-normal text-muted-foreground">
({deadLetters.length})
</span>
</h2>
<div className="overflow-x-auto">
<table className="w-full text-sm" aria-label="Dead-letter jobs">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-2 pr-4">Key</th>
<th className="pb-2 pr-4">Engine</th>
<th className="pb-2 pr-4">Attempts</th>
<th className="pb-2">Action</th>
</tr>
</thead>
<tbody>
{deadLetters.map(j => (
<tr key={j.id} className="border-b last:border-0 hover:bg-muted/50">
<td className="py-2 pr-4">
<Link
href={`/dashboard/fleet/jobs/${j.id}`}
className="hover:underline font-mono text-xs"
>
{j.idempotencyKey}
</Link>
</td>
<td className="py-2 pr-4 font-mono text-xs">
{j.engine ?? j.engineClass ?? '—'}
</td>
<td className="py-2 pr-4 tabular-nums">{j.attempts}</td>
<td className="py-2">
<button
type="button"
onClick={() => handleRedrive(j.id)}
disabled={redriving === j.id}
aria-label={`Re-drive job ${j.idempotencyKey}`}
className="rounded-md border px-2 py-1 text-xs font-medium hover:bg-muted disabled:opacity-50"
>
{redriving === j.id ? 'Re-driving…' : 'Re-drive'}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
)}
{/* Factory cards */} {/* Factory cards */}
<section> <section>
<h2 className="text-lg font-semibold mb-3">Factories</h2> <h2 className="text-lg font-semibold mb-3">Factories</h2>

View File

@ -5,10 +5,6 @@ import { useProduct } from '@/lib/product-context';
export function ProductSwitcher() { export function ProductSwitcher() {
const { productId, setProductId, products } = useProduct(); const { productId, setProductId, products } = useProduct();
// Single-tenant deployments (a solo dev / freelancer with one project) get no
// switcher clutter — there is nothing to switch between.
if (products.length <= 1) return null;
return ( return (
<select <select
value={productId} value={productId}

View File

@ -1,7 +1,6 @@
'use client'; 'use client';
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react'; import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react';
import { PRODUCT_ID } from './product-constants';
interface User { interface User {
id: string; id: string;
@ -55,15 +54,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}, []); }, []);
const login = useCallback(async (email: string, password: string) => { const login = useCallback(async (email: string, password: string) => {
// platform-service LoginSchema requires productId — use the selected product
// (set by the product switcher) or fall back to the default product.
const productId =
(typeof window !== 'undefined' && localStorage.getItem('tracker_selected_product')) ||
PRODUCT_ID;
const res = await fetch('/api/auth/login', { const res = await fetch('/api/auth/login', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password, productId }), body: JSON.stringify({ email, password }),
}); });
if (!res.ok) { if (!res.ok) {
const body = await res.json().catch(() => ({})); const body = await res.json().catch(() => ({}));

View File

@ -8,10 +8,6 @@ import { createApiClient } from '@bytelyst/api-client';
// ── Types ─────────────────────────────────────────────────────────────────── // ── Types ───────────────────────────────────────────────────────────────────
/** Concrete coding-agent engines a factory can run. */
export const FLEET_ENGINES = ['devin', 'claude', 'codex', 'copilot'] as const;
export type FleetEngine = (typeof FLEET_ENGINES)[number];
export interface FleetJob { export interface FleetJob {
id: string; id: string;
productId: string; productId: string;
@ -27,20 +23,6 @@ export interface FleetJob {
leaseEpoch: number; leaseEpoch: number;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
reviewPolicy?: ReviewPolicy;
reviewDecisions?: ReviewDecision[];
/** Concrete engine to run (overrides engineClass); falls back to factory default. */
engine?: FleetEngine;
/** Abstract engine class resolved on the runner when no concrete `engine` is set. */
engineClass?: string;
repo?: string;
baseBranch?: string;
/** PR mode: verify command run in the checkout before the PR opens. */
verify?: string;
/** PR mode: squash-merge the PR automatically when verify passes. */
autoMerge?: boolean;
/** Job dependencies (idempotency keys this job is gated on). */
deps?: string[];
} }
export interface FleetFactory { export interface FleetFactory {
@ -54,27 +36,6 @@ export interface FleetFactory {
lastHeartbeatAt: string; lastHeartbeatAt: string;
} }
/** Per-run cost / token / effort metrics reported by a factory. */
export interface FleetRunInsights {
model?: string;
/** Concrete engine the factory ran (devin/claude/codex), reported at run time. */
engine?: string;
/** Agent session handle (e.g. a Devin session id) for traceability/recovery. */
sessionId?: string;
/** Web URL for the agent session, when the engine exposes one. */
sessionUrl?: string;
tokensIn?: number;
tokensOut?: number;
tokensCached?: number;
costUsd?: number;
estimated?: boolean;
turns?: number;
toolCalls?: number;
filesChanged?: number;
linesAdded?: number;
linesDeleted?: number;
}
export interface FleetRun { export interface FleetRun {
id: string; id: string;
jobId: string; jobId: string;
@ -84,10 +45,7 @@ export interface FleetRun {
startedAt: string; startedAt: string;
endedAt?: string; endedAt?: string;
result?: string; result?: string;
insights: FleetRunInsights; insights: Record<string, unknown>;
prUrl?: string;
branch?: string;
prState?: 'open' | 'merged';
} }
export interface FleetEvent { export interface FleetEvent {
@ -119,20 +77,6 @@ export interface FleetBudget {
updatedAt: string; updatedAt: string;
} }
export interface BurndownPoint {
date: string;
costUsd: number;
cumulativeUsd: number;
}
export interface CostBurndown {
productId: string;
ceilingUsd: number | null;
window: string | null;
totalUsd: number;
days: BurndownPoint[];
}
export interface DagNode { export interface DagNode {
id: string; id: string;
idempotencyKey: string; idempotencyKey: string;
@ -143,33 +87,6 @@ export interface DagNode {
children: DagNode[]; children: DagNode[];
} }
export interface ScoreBreakdown {
capabilityFit: number;
affinity: number;
load: number;
costFit: number;
health: number;
starvation: number;
}
export interface FactoryScoreExplain {
factoryId: string;
eligible: boolean;
ineligibleReasons: string[];
score: number;
breakdown: ScoreBreakdown;
}
export interface JobExplain {
jobId: string;
stage: string;
weights: Record<string, number>;
depsSatisfied: boolean;
unmetDeps: string[];
factories: FactoryScoreExplain[];
bestFactoryId: string | null;
}
// ── Client ────────────────────────────────────────────────────────────────── // ── Client ──────────────────────────────────────────────────────────────────
const fleetApi = createApiClient({ const fleetApi = createApiClient({
@ -217,404 +134,41 @@ export async function getJob(id: string): Promise<FleetJob | null> {
return apiFetchOptional(`/jobs/${id}`); return apiFetchOptional(`/jobs/${id}`);
} }
export interface SubmitJobBody { export async function patchJob(
idempotencyKey: string;
bodyMd: string;
priority?: 'critical' | 'high' | 'medium' | 'low';
capabilities?: string[];
/** PR mode: open a PR against this repo (`owner/name` or clone URL) + base branch. */
repo?: string;
baseBranch?: string;
/** PR mode: verify command run in the checkout before the PR opens; auto-merge the PR. */
verify?: string;
autoMerge?: boolean;
/** Concrete engine to run (devin/claude/codex/copilot); overrides engineClass. */
engine?: FleetEngine;
/** Save as a non-claimable, editable draft (stage `draft`) instead of queued. */
draft?: boolean;
}
/** Submit a new fleet job. Optionally target a specific product (factory's product),
* overriding the dashboard's selected product for this submission. */
export async function submitJob(
body: SubmitJobBody,
productId?: string
): Promise<{ job: FleetJob }> {
const headers = productId ? { 'x-product-id': productId } : undefined;
return apiFetch(`/jobs`, { method: 'POST', body: JSON.stringify(body), headers });
}
/** WIP checkpoint a factory carries across lease re-assignments (server schema). */
export interface FleetCheckpoint {
wipBranch: string;
wipBase?: string;
wipCommit?: string;
}
export interface PatchJobBody {
leaseEpoch: number;
stage?: string;
checkpoint?: FleetCheckpoint;
blockedReason?: string;
}
export async function patchJob(id: string, body: PatchJobBody): Promise<FleetJob> {
return apiFetch(`/jobs/${id}`, { method: 'PATCH', body: JSON.stringify(body) });
}
export type OperatorAction = 'requeue' | 'reject' | 'cancel' | 'ship' | 'redrive';
/**
* Operator-initiated lifecycle action (no lease required). The coordinator
* fences any current factory holder by bumping the lease epoch.
*/
export async function operatorAction(id: string, action: OperatorAction): Promise<FleetJob> {
return apiFetch(`/jobs/${id}/actions/${action}`, { method: 'POST' });
}
/** Editable fields on a not-yet-picked-up job (draft/queued/blocked). */
export interface UpdateDraftBody {
bodyMd?: string;
priority?: 'critical' | 'high' | 'medium' | 'low';
capabilities?: string[];
engine?: FleetEngine;
repo?: string;
baseBranch?: string;
verify?: string;
autoMerge?: boolean;
}
/** Edit a job's prompt/config in place — only valid while draft/queued/blocked. */
export async function updateDraft(id: string, body: UpdateDraftBody): Promise<FleetJob> {
return apiFetch(`/jobs/${id}/draft`, { method: 'PATCH', body: JSON.stringify(body) });
}
/** Submit a draft → queued (or blocked if it has unmet deps). Idempotent. */
export async function submitDraft(id: string): Promise<FleetJob> {
return apiFetch(`/jobs/${id}/submit`, { method: 'POST' });
}
// ── Multi-reviewer human gate ─────────────────────────────────────────────────
export interface ReviewPolicy {
requiredApprovals: number;
reviewers: string[];
}
export interface ReviewDecision {
reviewer: string;
decision: 'approve' | 'reject';
at: string;
note?: string;
}
export type ReviewGate = 'pending' | 'approved' | 'rejected';
/** Route a building job into the review gate with an optional policy. */
export async function requestReview(
id: string, id: string,
policy?: { requiredApprovals?: number; reviewers?: string[] } body: { leaseEpoch: number; stage: string }
): Promise<FleetJob> { ): Promise<FleetJob> {
return apiFetch(`/jobs/${id}/review/request`, { return apiFetch(`/jobs/${id}`, { method: 'PATCH', body: JSON.stringify(body) });
method: 'POST',
body: JSON.stringify(policy ?? {}),
});
}
/** Submit a single reviewer's approve/reject decision. */
export async function submitReview(
id: string,
input: { reviewer: string; decision: 'approve' | 'reject'; note?: string }
): Promise<FleetJob & { gate: ReviewGate }> {
return apiFetch(`/jobs/${id}/review`, {
method: 'POST',
body: JSON.stringify(input),
});
} }
export async function getJobRuns(jobId: string): Promise<{ runs: FleetRun[] }> { export async function getJobRuns(jobId: string): Promise<{ runs: FleetRun[] }> {
return apiFetch(`/jobs/${jobId}/runs`); return apiFetch(`/jobs/${jobId}/runs`);
} }
export interface PrReconcileResult {
ok: boolean;
updated: boolean;
prState?: 'open' | 'merged';
reason?: 'not_found' | 'no_pr' | 'already_merged' | 'unchanged';
}
/**
* Reconcile the job's PR state against GitHub (detect a PR merged in the GitHub
* UI). Flips the run's prState to `merged` server-side when `gh` reports MERGED.
*/
export async function reconcilePrState(jobId: string): Promise<PrReconcileResult> {
return apiFetch(`/jobs/${jobId}/pr/reconcile`, { method: 'POST' });
}
export async function getJobEvents(jobId: string): Promise<{ events: FleetEvent[] }> { export async function getJobEvents(jobId: string): Promise<{ events: FleetEvent[] }> {
return apiFetch(`/jobs/${jobId}/events`); return apiFetch(`/jobs/${jobId}/events`);
} }
// ── Fleet metrics + alerting ──────────────────────────────────────────────────
export interface FleetAlert {
level: 'warning' | 'critical';
code: string;
message: string;
}
/** One tripped/probing (factory, engine) circuit-breaker pair (§2). */
export interface EngineBreakerEntry {
factoryId: string;
engine: string;
state: 'CLOSED' | 'OPEN' | 'HALF_OPEN';
failureCount: number;
lastFailureAt: string | null;
}
/** Budget guardrail summary surfaced on metrics (§3). */
export interface FleetBudgetSummary {
ceilingUsd: number;
spentUsd: number;
status: string;
window: string;
projectedUsd: number | null;
engines: { engine: string; spentUsd: number; ceilingUsd: number; exhausted: boolean }[];
}
export interface FleetMetrics {
productId: string;
generatedAt: string;
jobs: {
total: number;
byStage: Record<string, number>;
queueDepth: number;
blocked: number;
active: number;
oldestQueuedAgeMs: number | null;
};
factories: {
total: number;
live: number;
stale: number;
byHealth: { ok: number; degraded: number; down: number };
seatsUsed: number;
seatsTotal: number;
utilizationPct: number;
};
/** Budget guardrail summary (§3) — null when no budget is configured. */
budget?: FleetBudgetSummary | null;
/** Per-(factory, engine) circuit-breaker snapshot (§2) — process-wide. */
engineBreakers?: EngineBreakerEntry[];
alerts: FleetAlert[];
}
export async function getFleetMetrics(): Promise<FleetMetrics | null> {
return apiFetchOptional('/metrics');
}
// ── Live event stream (SSE) ───────────────────────────────────────────────────
export interface ParsedSseEvent {
id?: string;
event?: string;
data: string;
}
/**
* Parse a raw SSE text buffer into complete frames. Returns the parsed events
* and any trailing partial frame (`rest`) that should be prepended to the next
* chunk. Comment lines (`:` keepalives) are skipped. Pure + side-effect free.
*/
export function parseSseFrames(buffer: string): { events: ParsedSseEvent[]; rest: string } {
const events: ParsedSseEvent[] = [];
let rest = buffer;
let idx = rest.indexOf('\n\n');
while (idx !== -1) {
const frame = rest.slice(0, idx);
rest = rest.slice(idx + 2);
idx = rest.indexOf('\n\n');
if (!frame.trim() || frame.startsWith(':')) continue;
const ev: ParsedSseEvent = { data: '' };
const dataLines: string[] = [];
for (const line of frame.split('\n')) {
if (line.startsWith('id:')) ev.id = line.slice(3).trim();
else if (line.startsWith('event:')) ev.event = line.slice(6).trim();
else if (line.startsWith('data:')) dataLines.push(line.slice(5).trimStart());
}
ev.data = dataLines.join('\n');
events.push(ev);
}
return { events, rest };
}
export interface JobEventSubscription {
close: () => void;
}
export interface SubscribeJobEventsOptions {
/** Resume cursor — only events with seq greater than this are delivered. */
lastEventId?: number;
/** Backoff before reconnecting after a clean server close (ms). */
reconnectMs?: number;
}
const sseDelay = (ms: number): Promise<void> => new Promise(resolve => setTimeout(resolve, ms));
/**
* Subscribe to a job's live event stream over SSE using `fetch` streaming (so
* auth + product headers can be sent native EventSource cannot). Calls
* `onEvent` for every new fleet-event and auto-reconnects with Last-Event-ID
* after a clean server close. On a hard failure it invokes `onError` and stops,
* letting callers fall back to polling `getJobEvents`. Returns a handle whose
* `close()` aborts the stream.
*/
export function subscribeJobEvents(
jobId: string,
handlers: { onEvent: (e: FleetEvent) => void; onError?: (err: unknown) => void },
opts?: SubscribeJobEventsOptions
): JobEventSubscription {
let closed = false;
const controller = new AbortController();
let lastId = opts?.lastEventId ?? -1;
const reconnectMs = Math.max(250, opts?.reconnectMs ?? 1500);
const token = typeof window !== 'undefined' ? localStorage.getItem('tracker_token') : null;
const pid =
typeof window !== 'undefined' ? localStorage.getItem('tracker_selected_product') : null;
const connect = async (): Promise<void> => {
while (!closed) {
try {
const headers: Record<string, string> = { accept: 'text/event-stream' };
if (token) headers['authorization'] = `Bearer ${token}`;
if (pid) headers['x-product-id'] = pid;
if (lastId >= 0) headers['last-event-id'] = String(lastId);
const res = await fetch(`/api/fleet/jobs/${jobId}/events/stream`, {
headers,
signal: controller.signal,
});
if (!res.ok || !res.body) throw new Error(`stream HTTP ${res.status}`);
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const { events, rest } = parseSseFrames(buffer);
buffer = rest;
for (const ev of events) {
// A terminal error frame means the server gave up mid-stream;
// surface it as fatal so the caller falls back to polling rather
// than reconnecting into the same failure forever.
if (ev.event === 'error') throw new Error('stream error frame');
try {
const parsed = JSON.parse(ev.data) as FleetEvent;
lastId = parsed.seq;
handlers.onEvent(parsed);
} catch {
/* skip malformed frame */
}
}
}
// Clean close (server hit its max duration) → reconnect after a backoff.
if (!closed) await sseDelay(reconnectMs);
} catch (err) {
if (closed) return;
controller.abort();
handlers.onError?.(err);
return;
}
}
};
void connect();
return {
close: () => {
closed = true;
controller.abort();
},
};
}
export async function getJobArtifacts(jobId: string): Promise<{ artifacts: FleetArtifact[] }> { export async function getJobArtifacts(jobId: string): Promise<{ artifacts: FleetArtifact[] }> {
return apiFetch(`/jobs/${jobId}/artifacts`); return apiFetch(`/jobs/${jobId}/artifacts`);
} }
/** Resolve a short-lived signed download URL for an artifact (e.g. a `log`). */
export async function getArtifactDownloadUrl(artifactId: string): Promise<string | null> {
const res = await apiFetchOptional<{ downloadUrl?: string }>(`/artifacts/${artifactId}`);
return res?.downloadUrl ?? null;
}
export async function getJobDag(jobId: string): Promise<{ dag: DagNode } | null> { export async function getJobDag(jobId: string): Promise<{ dag: DagNode } | null> {
return apiFetchOptional(`/jobs/${jobId}/dag`); return apiFetchOptional(`/jobs/${jobId}/dag`);
} }
export async function getJobExplain(jobId: string): Promise<JobExplain | null> {
return apiFetchOptional(`/jobs/${jobId}/explain`);
}
// ── Factories ─────────────────────────────────────────────────────────────── // ── Factories ───────────────────────────────────────────────────────────────
export async function listFactories(productId?: string): Promise<{ factories: FleetFactory[] }> { export async function listFactories(): Promise<{ factories: FleetFactory[] }> {
const headers = productId ? { 'x-product-id': productId } : undefined;
try { try {
return await apiFetch('/factories', headers ? { headers } : undefined); return await apiFetch('/factories');
} catch { } catch {
return { factories: [] }; return { factories: [] };
} }
} }
/** A factory missing a heartbeat for longer than this is treated as effectively
* down for engine advertisement. Mirrors the coordinator's `DEFAULT_STALE_FACTORY_MS`
* (90s) so the picker never offers an engine only a dead host advertised. */
const STALE_FACTORY_MS = 90_000;
/** True when the factory has missed heartbeats long enough to be considered dead. */
function isFactoryStale(f: FleetFactory, nowMs: number): boolean {
const last = Date.parse(f.lastHeartbeatAt);
return Number.isNaN(last) || nowMs - last > STALE_FACTORY_MS;
}
/** Concrete engines a product's live factories advertise (`engine:*` capabilities),
* intersected with the known engine set. Skips `down` and stale (missed-heartbeat)
* factories so the picker never offers an engine only a dead host had.
* Empty unknown (caller should not filter). */
export async function availableEnginesForProduct(productId?: string): Promise<FleetEngine[]> {
const { factories } = await listFactories(productId);
const nowMs = Date.now();
const seen = new Set<FleetEngine>();
for (const f of factories) {
if (f.health === 'down' || isFactoryStale(f, nowMs)) continue;
for (const cap of f.capabilities ?? []) {
if (cap.startsWith('engine:')) {
const e = cap.slice('engine:'.length) as FleetEngine;
if ((FLEET_ENGINES as readonly string[]).includes(e)) seen.add(e);
}
}
}
return [...seen];
}
// ── Budgets ───────────────────────────────────────────────────────────────── // ── Budgets ─────────────────────────────────────────────────────────────────
/**
* Spend as a clamped 0100 percentage of the ceiling. Guards against a missing,
* non-finite, or zero ceiling (which would otherwise yield NaN/Infinity and
* render a broken spend bar) by returning 0 callers should show a "no ceiling"
* state in that case.
*/
export function budgetUsagePct(spentUsd: number, ceilingUsd: number): number {
if (!Number.isFinite(ceilingUsd) || ceilingUsd <= 0) return 0;
const pct = (spentUsd / ceilingUsd) * 100;
if (!Number.isFinite(pct) || pct < 0) return 0;
return Math.min(100, pct);
}
export async function getBudget(productId: string): Promise<FleetBudget | null> { export async function getBudget(productId: string): Promise<FleetBudget | null> {
return apiFetchOptional(`/budgets/${productId}`); return apiFetchOptional(`/budgets/${productId}`);
} }
@ -637,11 +191,3 @@ export async function pauseBudget(productId: string): Promise<FleetBudget> {
export async function resumeBudget(productId: string): Promise<FleetBudget> { export async function resumeBudget(productId: string): Promise<FleetBudget> {
return apiFetch(`/budgets/${productId}/resume`, { method: 'POST' }); return apiFetch(`/budgets/${productId}/resume`, { method: 'POST' });
} }
export async function getBudgetBurndown(
productId: string,
days?: number
): Promise<CostBurndown | null> {
const qs = days ? `?days=${days}` : '';
return apiFetchOptional(`/budgets/${productId}/burndown${qs}`);
}

View File

@ -10,14 +10,6 @@ import type { NextRequest } from 'next/server';
const identity = loadProductIdentity(); const identity = loadProductIdentity();
// Single source of truth for the configurable product list (client-safe module).
export {
KNOWN_PRODUCTS,
DEFAULT_PRODUCTS,
parseProductsEnv,
type Product,
} from './product-constants';
export const PRODUCT_ID = identity.productId; export const PRODUCT_ID = identity.productId;
export const DISPLAY_NAME = identity.displayName; export const DISPLAY_NAME = identity.displayName;
export const LICENSE_PREFIX = identity.licensePrefix; export const LICENSE_PREFIX = identity.licensePrefix;
@ -30,3 +22,11 @@ export const PACKAGE_NAME = identity.packageName;
export function getRequestProductId(req: NextRequest): string { export function getRequestProductId(req: NextRequest): string {
return req.headers.get('x-product-id') || PRODUCT_ID; return req.headers.get('x-product-id') || PRODUCT_ID;
} }
/** All known products in the ByteLyst ecosystem. */
export const KNOWN_PRODUCTS = [
{ id: 'lysnrai', name: 'LysnrAI', icon: 'Mic' },
{ id: 'chronomind', name: 'ChronoMind', icon: 'Clock' },
{ id: 'nomgap', name: 'NomGap', icon: 'Apple' },
{ id: 'mindlyst', name: 'MindLyst', icon: 'Brain' },
] as const;

View File

@ -3,62 +3,15 @@
* *
* Use this in 'use client' components. For server-side code that needs * Use this in 'use client' components. For server-side code that needs
* loadProductIdentity(), import from product-config.ts instead. * loadProductIdentity(), import from product-config.ts instead.
*
* The product list is CONFIGURABLE so this dashboard works for any deployment
* (a third-party / freelance / self-hosted operator), not just the built-in set.
* Set `NEXT_PUBLIC_PRODUCTS` to a JSON array of `{ id, name, icon? }` to override
* the default list; an empty/invalid value falls back to the built-in default.
*/ */
export const PRODUCT_ID = process.env.NEXT_PUBLIC_PRODUCT_ID || process.env.PRODUCT_ID || 'lysnrai'; export const PRODUCT_ID = process.env.NEXT_PUBLIC_PRODUCT_ID || process.env.PRODUCT_ID || 'lysnrai';
export const DISPLAY_NAME = process.env.NEXT_PUBLIC_DISPLAY_NAME || 'LysnrAI'; export const DISPLAY_NAME = process.env.NEXT_PUBLIC_DISPLAY_NAME || 'LysnrAI';
/** A selectable product/project the dashboard can be scoped to. */ /** All known products in the ByteLyst ecosystem. */
export interface Product { export const KNOWN_PRODUCTS = [
id: string;
name: string;
icon?: string;
}
/** Built-in default product set (the original ByteLyst ecosystem). */
export const DEFAULT_PRODUCTS: readonly Product[] = [
{ id: 'lysnrai', name: 'LysnrAI', icon: 'Mic' }, { id: 'lysnrai', name: 'LysnrAI', icon: 'Mic' },
{ id: 'chronomind', name: 'ChronoMind', icon: 'Clock' }, { id: 'chronomind', name: 'ChronoMind', icon: 'Clock' },
{ id: 'nomgap', name: 'NomGap', icon: 'Apple' }, { id: 'nomgap', name: 'NomGap', icon: 'Apple' },
{ id: 'mindlyst', name: 'MindLyst', icon: 'Brain' }, { id: 'mindlyst', name: 'MindLyst', icon: 'Brain' },
]; ] as const;
/**
* Parse the `NEXT_PUBLIC_PRODUCTS` override into a validated product list. PURE +
* defensive: any malformed entry (missing id/name, non-array, bad JSON) falls
* back to `fallback`, so a bad env value can never blank out the switcher.
*/
export function parseProductsEnv(
raw: string | undefined,
fallback: readonly Product[] = DEFAULT_PRODUCTS
): readonly Product[] {
if (!raw || raw.trim() === '') return fallback;
try {
const parsed: unknown = JSON.parse(raw);
if (!Array.isArray(parsed)) return fallback;
const products: Product[] = [];
for (const entry of parsed) {
if (!entry || typeof entry !== 'object') continue;
const { id, name, icon } = entry as Record<string, unknown>;
if (typeof id !== 'string' || id.trim() === '') continue;
products.push({
id,
name: typeof name === 'string' && name.trim() !== '' ? name : id,
...(typeof icon === 'string' ? { icon } : {}),
});
}
return products.length > 0 ? products : fallback;
} catch {
return fallback;
}
}
/** Configured product set — `NEXT_PUBLIC_PRODUCTS` override, else the default. */
export const KNOWN_PRODUCTS: readonly Product[] = parseProductsEnv(
process.env.NEXT_PUBLIC_PRODUCTS
);

View File

@ -1,7 +1,7 @@
'use client'; 'use client';
import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from 'react'; import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from 'react';
import { KNOWN_PRODUCTS, PRODUCT_ID, type Product } from '@/lib/product-constants'; import { KNOWN_PRODUCTS, PRODUCT_ID } from '@/lib/product-constants';
const STORAGE_KEY = 'tracker_selected_product'; const STORAGE_KEY = 'tracker_selected_product';
const PRODUCT_CHANGED_EVENT = 'tracker:product-changed'; const PRODUCT_CHANGED_EVENT = 'tracker:product-changed';
@ -10,7 +10,7 @@ interface ProductContextValue {
productId: string; productId: string;
productName: string; productName: string;
setProductId: (id: string) => void; setProductId: (id: string) => void;
products: readonly Product[]; products: typeof KNOWN_PRODUCTS;
} }
const ProductContext = createContext<ProductContextValue | null>(null); const ProductContext = createContext<ProductContextValue | null>(null);
@ -20,27 +20,8 @@ function getInitialProduct(): string {
return localStorage.getItem(STORAGE_KEY) || PRODUCT_ID; return localStorage.getItem(STORAGE_KEY) || PRODUCT_ID;
} }
/** Map the platform-service product docs to the switcher's lightweight shape. */
function toProducts(docs: unknown): Product[] {
if (!Array.isArray(docs)) return [];
const out: Product[] = [];
for (const d of docs) {
if (!d || typeof d !== 'object') continue;
const { productId, id, displayName, name } = d as Record<string, unknown>;
const pid = typeof productId === 'string' ? productId : typeof id === 'string' ? id : null;
if (!pid) continue;
const label =
typeof displayName === 'string' ? displayName : typeof name === 'string' ? name : pid;
out.push({ id: pid, name: label });
}
return out;
}
export function ProductProvider({ children }: { children: ReactNode }) { export function ProductProvider({ children }: { children: ReactNode }) {
const [productId, setProductIdState] = useState<string>(getInitialProduct); const [productId, setProductIdState] = useState<string>(getInitialProduct);
// Start from the configured list (env/default); replace with the caller's
// owner-scoped projects once fetched. Falling back keeps dev/unauth working.
const [products, setProducts] = useState<readonly Product[]>(KNOWN_PRODUCTS);
useEffect(() => { useEffect(() => {
function syncSelectedProduct() { function syncSelectedProduct() {
@ -55,32 +36,6 @@ export function ProductProvider({ children }: { children: ReactNode }) {
}; };
}, []); }, []);
// Fetch the authenticated user's projects ("my projects") and use them as the
// switcher list. Best-effort: any failure / empty result keeps the configured
// fallback, so an unauthenticated or offline dashboard still renders.
useEffect(() => {
if (typeof window === 'undefined') return;
const token = localStorage.getItem('tracker_token');
if (!token) return;
let cancelled = false;
(async () => {
try {
const res = await fetch('/api/products/mine', {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) return;
const json: unknown = await res.json();
const list = toProducts((json as { products?: unknown }).products);
if (!cancelled && list.length > 0) setProducts(list);
} catch {
/* keep the configured fallback */
}
})();
return () => {
cancelled = true;
};
}, []);
const setProductId = useCallback((id: string) => { const setProductId = useCallback((id: string) => {
setProductIdState(id); setProductIdState(id);
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
@ -89,11 +44,13 @@ export function ProductProvider({ children }: { children: ReactNode }) {
} }
}, []); }, []);
const product = products.find(p => p.id === productId); const product = KNOWN_PRODUCTS.find(p => p.id === productId);
const productName = product?.name ?? productId; const productName = product?.name ?? productId;
return ( return (
<ProductContext.Provider value={{ productId, productName, setProductId, products }}> <ProductContext.Provider
value={{ productId, productName, setProductId, products: KNOWN_PRODUCTS }}
>
{children} {children}
</ProductContext.Provider> </ProductContext.Provider>
); );

View File

@ -5,7 +5,6 @@ export default defineConfig({
test: { test: {
environment: 'node', environment: 'node',
globals: true, globals: true,
setupFiles: ['./vitest.setup.ts'],
exclude: ['e2e/**', 'node_modules/**', '.next/**'], exclude: ['e2e/**', 'node_modules/**', '.next/**'],
coverage: { coverage: {
provider: 'v8', provider: 'v8',

View File

@ -1,47 +0,0 @@
/**
* Test bootstrap for tracker-web.
*
* Node 25 ships a global `localStorage` (Web Storage) that is a non-functional
* stub unless started with `--localstorage-file=<path>` accessing it yields an
* object with no `getItem`/`setItem`/`clear`. That global shadows the DOM test
* environment's storage, so tests (and the code under test) that use
* `localStorage` break with "localStorage.clear is not a function".
*
* Install a real in-memory Web Storage over it when the active one is missing the
* Storage API. Tests that stub localStorage themselves (vi.stubGlobal) still work
* this only provides the baseline the rest of the suite assumes.
*/
function createMemoryStorage(): Storage {
let store: Record<string, string> = {};
return {
get length() {
return Object.keys(store).length;
},
clear() {
store = {};
},
getItem(key: string) {
return Object.prototype.hasOwnProperty.call(store, key) ? store[key] : null;
},
setItem(key: string, value: string) {
store[String(key)] = String(value);
},
removeItem(key: string) {
delete store[key];
},
key(index: number) {
return Object.keys(store)[index] ?? null;
},
} as Storage;
}
for (const name of ['localStorage', 'sessionStorage'] as const) {
const current = (globalThis as Record<string, unknown>)[name] as Storage | undefined;
if (!current || typeof current.clear !== 'function') {
Object.defineProperty(globalThis, name, {
value: createMemoryStorage(),
configurable: true,
writable: true,
});
}
}

View File

@ -96,23 +96,6 @@ services:
timeout: 5s timeout: 5s
retries: 3 retries: 3
# ── Prometheus (fleet + infra metrics scrape) ─────────────────
prometheus:
image: prom/prometheus:v3.1.0
ports:
- '9090:9090'
volumes:
- ./services/monitoring/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- prometheus-data:/prometheus
command:
- '--config.file=/etc/prometheus/prometheus.yml'
restart: unless-stopped
healthcheck:
test: ['CMD', 'wget', '-q', '--spider', 'http://127.0.0.1:9090/-/ready']
interval: 15s
timeout: 5s
retries: 3
# ── API Gateway (Traefik) ─────────────────────────────────── # ── API Gateway (Traefik) ───────────────────────────────────
gateway: gateway:
image: traefik:v3.3 image: traefik:v3.3
@ -299,4 +282,3 @@ volumes:
azurite-data: azurite-data:
loki-data: loki-data:
grafana-data: grafana-data:
prometheus-data:

View File

@ -23,7 +23,7 @@ score = w.age * ageMinutes + w.priority * priorityOrder + w.retries * attempts +
### Weight Resolution Order ### Weight Resolution Order
1. **Per-request override**`weights` field in the `POST /fleet/claim` body 1. **Per-request override**`weights` field in `POST /fleet/jobs/:id/claim` body
2. **Product registry** — set via `setWeightRegistry({ [productId]: weights })` 2. **Product registry** — set via `setWeightRegistry({ [productId]: weights })`
3. **Defaults**`{ age: 1, priority: 10, retries: -2, capabilities: 5 }` 3. **Defaults**`{ age: 1, priority: 10, retries: -2, capabilities: 5 }`
@ -121,68 +121,23 @@ The UI calls platform-service fleet endpoints via `/api/fleet/[...path]` proxy.
| ------------------ | ----------------------- | ----------------------------------- | | ------------------ | ----------------------- | ----------------------------------- |
| `PLATFORM_API_URL` | `http://localhost:4003` | Platform-service base URL for proxy | | `PLATFORM_API_URL` | `http://localhost:4003` | Platform-service base URL for proxy |
## Job Lifecycle & Shipping (testing → shipped)
Stages: `queued → assigned → building → review → testing → shipped` (plus `blocked`,
`failed`, `dead_letter`). A factory drives `assigned → building → review`, then runs
its local verify gate.
There are two ways a job reaches the terminal `shipped` stage (the
`testing → shipped` transition has no claimable lease holder after the review gate,
so it is driven by one of):
1. **Factory autoship** (`AQ_FLEET_AUTOSHIP=1` on the agent-queue factory): when the
factory's local verify passes it reports `testing`, then advances the coordinator
job `testing → shipped` autonomously (the factory's verify **is** the test phase).
This is the autonomous `submit → … → shipped` path. Default off.
2. **`ship` operator action** (`POST /fleet/jobs/:id/actions/:action` with
`ship`): an operator/controller marks a non-terminal job `shipped`. Lease-free
(works after the human review gate), idempotent, and retries on optimistic-
concurrency conflict.
With `AQ_FLEET_AUTOSHIP=0` (default) a verify-passing job rests at `testing` for the
**human review gate** (`review/request` + multi-reviewer `review` approve) or a manual
`ship`.
Whenever a job reaches `shipped` (autoship PATCH, `ship` action, or a terminal lease
release), the coordinator mirrors the outcome onto the latest **run**
(`result = 'shipped'`, `endedAt` set) and — if budgets are enabled — accrues that
run's `insights.costUsd`. So the dashboard's per-run result/cost/tokens stay
consistent with the job stage.
### PR deliverable (PR mode)
A job may carry an optional **`repo`** (`owner/name` or a clone URL) + **`baseBranch`**.
When the factory runs with `AQ_FLEET_PR=1`, it runs the agent in an isolated checkout
on branch `aq/job/<jobId>`, then commits, pushes, and opens a PR via `gh`. The PR URL
- branch are reported on lease release and recorded on the **run** (`run.prUrl`,
`run.branch`) — the dashboard shows a **PR ↗** link in the job's Runs table. Submit
`repo`/`baseBranch` from the dashboard "New Job" form or the `POST /fleet/jobs` body.
This round opens the PR (merge stays a human/CI step); opt-in auto-merge is a planned
follow-up.
## API Reference Summary ## API Reference Summary
| Endpoint | Method | Phase | Notes | | Endpoint | Method | Phase | Notes |
| ---------------------------------- | ------ | ----- | --------------------------------------------------------- | | ---------------------------------- | ------ | ----- | -------------------------------------------------- |
| `/fleet/jobs` | GET | 2 | List jobs (query: stage, productId, limit, offset) | | `/fleet/jobs` | GET | 2 | List jobs (query: stage, productId, limit, offset) |
| `/fleet/jobs` | POST | 2 | Submit job (+ optional children[] for DAG) | | `/fleet/jobs` | POST | 2 | Submit job (+ optional children[] for DAG) |
| `/fleet/jobs/:id` | GET | 2 | Get job | | `/fleet/jobs/:id` | GET | 2 | Get job |
| `/fleet/jobs/:id` | PATCH | 2 | Update stage (fenced) | | `/fleet/jobs/:id` | PATCH | 2 | Update stage (fenced) |
| `/fleet/jobs/:id/actions/:action` | POST | 3 | Operator action: `requeue` / `reject` / `cancel` / `ship` | | `/fleet/jobs/:id/claim` | POST | 2 | Factory claims next job |
| `/fleet/jobs/:id/lease/release` | POST | 2 | Release lease (optional `stage`, `insights`, `result`) | | `/fleet/jobs/:id/children` | POST | 3 | Add children to existing job |
| `/fleet/claim` | POST | 2 | Factory claims the next best-fit job | | `/fleet/jobs/:id/dag` | GET | 3 | Get DAG subtree |
| `/fleet/jobs/:id/children` | POST | 3 | Add children to existing job | | `/fleet/factories` | GET | 2 | List factories |
| `/fleet/jobs/:id/dag` | GET | 3 | Get DAG subtree | | `/fleet/factories/:id/heartbeat` | POST | 2 | Factory heartbeat |
| `/fleet/factories/heartbeat` | POST | 2 | Factory heartbeat (register == first heartbeat) | | `/fleet/budgets/:productId` | GET | 3 | Get budget |
| `/fleet/factories/enroll` | POST | 2 | Enroll a factory → one-time scoped token | | `/fleet/budgets/:productId` | PUT | 3 | Upsert budget |
| `/fleet/metrics` | GET | 3 | Utilization, health rollup, queue/starvation alerts | | `/fleet/budgets/:productId/pause` | POST | 3 | Pause budget |
| `/fleet/queue-state` | GET | 4 | M0 RU gate: per-product monotonic queue `version` | | `/fleet/budgets/:productId/resume` | POST | 3 | Resume budget |
| `/fleet/budgets/:productId` | GET | 3 | Get budget |
| `/fleet/budgets/:productId` | PUT | 3 | Upsert budget |
| `/fleet/budgets/:productId/pause` | POST | 3 | Pause budget |
| `/fleet/budgets/:productId/resume` | POST | 3 | Resume budget |
## Architecture Decisions ## Architecture Decisions

View File

@ -1,22 +0,0 @@
# Gigafactory — Platform Docs
Planning, status, and operational docs for the **Agent Gigafactory** fleet
backend that lives in this repo (`services/platform-service` fleet module +
the `dashboards/tracker-web` UI).
## Contents
| Doc | What it is |
| ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------ |
| [`ROADMAP_COMPLETION_AUDIT.md`](ROADMAP_COMPLETION_AUDIT.md) | Audit of the current build state against the roadmap: completed / partial / missing features, risks, and prioritized remaining work. |
| [`TASKS_TO_COMPLETE.md`](TASKS_TO_COMPLETE.md) | The actionable, priority-ordered completion checklist that companions the audit. |
| [`gigafactory-phase3-progress.md`](gigafactory-phase3-progress.md) | Phase-3 progress log: per-slice end-state. |
| [`FLEET_CONTROL_PLANE.md`](FLEET_CONTROL_PLANE.md) | Operational guide for running and using the fleet control plane. |
## Source of truth
The canonical spec and system overview live in the runner repo,
`learning_ai_devops_tools`, under
`agent-queue/docs/GIGAFACTORY/` (`GIGAFACTORY_ROADMAP.md`,
`GIGAFACTORY_SYSTEM_OVERVIEW.md`, and `FLEET_DISPATCH_REDESIGN.md` — the Phase-4
broker design + the as-built **M0 RU gate** `fleet_queue_state` / `GET /fleet/queue-state`).

View File

@ -1,140 +0,0 @@
# Gigafactory — Roadmap Completion Audit
> Source of truth: `learning_ai_devops_tools/agent-queue/docs/GIGAFACTORY/GIGAFACTORY_ROADMAP.md`
> Audit date: 2026-05-30 · Auditor: Principal Full-Stack review
> Scope: `services/platform-service/src/modules/fleet/**` + `dashboards/tracker-web/src/app/dashboard/fleet/**`
>
> **⚠️ Status update (2026-05-31) — this audit is a point-in-time snapshot; some
> findings below are now superseded:**
>
> - The roadmap §0 tracker has since been **reconciled** — it now reads Phase 0
> ✅100% · 1 ✅~98% · 2 ✅~98% · 3 ✅100% · 4 ◐ in progress · 5 ☐. The
> "§0 is stale / Phase 3 0% / Phase 2 80%" notes below are **no longer accurate**.
> - **Phase 4 is no longer "not started": M0 (RU gate) is shipped**
> `fleet_queue_state` + `GET /fleet/queue-state` + `AQ_FLEET_GATE`. The broker
> (M1+) design + checklist live in
> `learning_ai_devops_tools/agent-queue/docs/GIGAFACTORY/FLEET_DISPATCH_REDESIGN.md`.
## 1. Product understanding
The **Agent Gigafactory** is a distributed system that turns work items (tracker Items, manifests)
into **jobs** executed in parallel by a fleet of **factories** (agent runners on mac/ubuntu/windows).
A coordinator (the `fleet` module in `platform-service`) owns durable job state in Cosmos, hands jobs
to factories via **atomic leases** with **fencing tokens**, recovers crashed work via a **reaper**,
and echoes status back to the tracker. A browser **control plane** in `tracker-web` lets operators
watch and steer the fleet.
## 2. Current architecture
```
tracker Item ──ingest──▶ fleet coordinator ──lease/claim──▶ factory agents (bash runner, AQ_FLEET)
│ (platform-service) │
├─ Cosmos: jobs/runs/leases/ ├─ heartbeat / renew / release
│ factories/events/artifacts/budgets └─ fenced stage transitions
└─ tracker-bridge ──echo──▶ tracker Item
tracker-web /dashboard/fleet ──/api/fleet proxy──▶ fleet REST (24 endpoints)
```
- **Module layout** (canonical pattern): `types.ts → repository.ts → coordinator.ts → scheduler.ts → routes.ts`
plus `tracker-bridge.ts`, `enrollment.ts`, `artifacts.ts`.
- **Concurrency core:** optimistic `rev`/`_etag` updates, `leaseEpoch` fencing, lease reaper.
- **Scheduler:** pure `selectJob` / `scoreCandidate` with capability hard-filter + weighted scoring;
Phase-3 `resolveWeights` (per-product/request) + `selectPreemptionVictim`.
- **Feature flags (default OFF):** `FLEET_PREEMPTION`, `FLEET_BUDGETS`, `FLEET_TRACKER_ECHO`,
`FLEET_REQUIRE_FACTORY_TOKEN`.
## 3. Verified test/build status (baseline)
| Check | Command | Result |
| --------------------- | --------------------------------------------------------------------------- | ------------------------ |
| Fleet module tests | `pnpm --filter @lysnrai/platform-service exec vitest run src/modules/fleet` | ✅ 134 passing (8 files) |
| Platform-service full | `pnpm --filter @lysnrai/platform-service test` | ✅ 1646 passing |
| tracker-web tests | `pnpm --filter @bytelyst/tracker-web test` | ✅ 198 passing |
| Monorepo build | `pnpm build` | ✅ green |
## 4. Completed features (verified in code)
### Phase 1 — single-host runner (95% per roadmap §0)
- ✅ Manifest parsing, priority ordering, capability match, engine-class, idempotency dedupe
- ✅ Profiles + resolution, deps/DAG blocking + cycle detection, warn-only allowed-scope
- ✅ Crash recovery (`recover_orphans`), WIP checkpoint/resume, retry w/ backoff, insights
- ✅ Tracker adapter (`from-tracker`/`to-tracker`, idempotent, non-fatal echo)
### Phase 2 — coordinator module (roadmap says 80%, code shows ~95%)
- ✅ `fleet` module scaffolded; Cosmos containers + repository (memory + Cosmos providers)
- ✅ Atomic claim (`revUpdateJob`/`_etag`) + lease reaper + fencing (`leaseEpoch`)
- ✅ Factory-agent API client (`lib/fleet-client.sh` behind `AQ_FLEET`)
- ✅ **Scheduler/router core wired**`coordinator.claimNextJob` calls `selectJob` (coordinator.ts:502)
- ✅ **Tracker adapter direct call**`tracker-bridge.ts` `ingestItemAsJob`/`echoJobToItem`
- ✅ **Factory enrollment + scoped tokens**`enrollment.ts` + `/fleet/factories/enroll|rotate|revoke`
- ✅ Feature flags + shadow/dual-run; two-factory demo; module test suite
### Phase 3 — control plane + DAG + budgets + scoring (roadmap says 0%, code shows ~90%)
- ✅ Tunable scoring weights (`resolveWeights`, per-product registry + request override)
- ✅ Preemption behind `FLEET_PREEMPTION` (`selectPreemptionVictim`)
- ✅ DAG decomposition — `POST /fleet/jobs/:id/children`, `GET /fleet/jobs/:id/dag`, parent block/unblock
- ✅ Budgets — `FleetBudgetDoc`, GET/PUT/pause/resume, enforcement behind `FLEET_BUDGETS`
- ✅ tracker-web fleet UI — overview, jobs table, job detail, budget pages + typed client + proxy
- ✅ Operator job actions (requeue/reject/cancel) — backend + UI (no lease held; fences worker)
- ✅ Scoring explainability — `GET /fleet/jobs/:id/explain` + routing-score UI panel
- ✅ Cost burndown — per-day series endpoint + chart with ceiling overlay
- ✅ SSE live log streaming — `GET /fleet/jobs/:id/events/stream` (resumable) + `subscribeJobEvents`
- ✅ Fleet Playwright e2e — `e2e/fleet.spec.ts` (overview, jobs, job-detail, budget, review gate)
- ✅ Fleet metrics + alerting — `GET /fleet/metrics` + overview metrics/alerts panel (§17)
- ✅ Multi-reviewer routing — review-policy human gate (`requestReview`/`submitReview`) + gate UI
## 5. Partial features (started, not complete)
| Feature | What exists | What's missing |
| ------------- | ------------------------------------ | ------------------------------------------------------- |
| TUI dashboard | legacy TUI against single-host queue | re-point at `/fleet` API for parity (P3, separate repo) |
## 6. Missing features (not started)
- **Phase 3:** TUI re-point at `/fleet` (in `learning_ai_devops_tools`)
- **Phase 4:** message broker (NATS/Redis), autoscaling hooks, capability marketplace, load/chaos suite
- **Phase 5:** outcome feature capture, offline eval harness, A/B weight tuning, recommendations
- **Phase 1 leftovers:** `budget.wall` wall-clock enforcement; Node `dash` tag surfacing
## 7. Broken flows
None found. Build + all test suites green at audit time.
## 8. Mock / stubbed flows
- `dashboards/tracker-web/src/app/api/fleet/[...path]/route.ts` proxies to `PLATFORM_API_URL`
(default `http://localhost:4003`). UI **degrades gracefully** (404 → null/empty) when the
fleet module is unreachable — this is intended, not a stub to replace.
- No mock data baked into pages; all reads go through the typed `fleet-client.ts`.
## 9. Security risks
- Factory token enforcement is **flag-gated** (`FLEET_REQUIRE_FACTORY_TOKEN`, default OFF). For
production, enrollment tokens should be ON. Documented in `.env.example`.
- Budget enforcement OFF by default — cost-runaway guard (§18) not active until `FLEET_BUDGETS=1`.
## 10. Deployment risks
- `PLATFORM_API_URL` must be set for the tracker-web proxy in non-local environments.
- Cosmos containers must exist with `/productId` partition keys before first write.
- No live-log transport to blob yet (§17) — operators rely on polling.
## 11. Prioritized remaining work
See `TASKS_TO_COMPLETE.md`. Highest-impact safe slices, in order:
1. **Operator job actions (requeue/reject/cancel)** — completes a Phase-3 §14 box; backend+UI; low risk
2. **Scoring explainability surfaced** — data already computed; additive endpoint + UI
3. **Cost burndown** — additive UI on existing budget data
4. **SSE live logs** — larger; needs streaming route + consumer
5. **Fleet Playwright e2e** — required for Phase-3 exit gate
6. Phase 4/5 — out of MVP scope; track as future
## 12. Note on roadmap drift
The roadmap §0 tracker is **stale**: it shows Phase 3 at 0% and Phase 2 at 80%, but the code
implements nearly all Phase-2 boxes and the core Phase-3 backend + UI. A docs reconciliation
(ticking §14 boxes in `learning_ai_devops_tools`) is itself a tracked task (`p2-roadmap-tick`).

View File

@ -1,86 +0,0 @@
# Gigafactory — Tasks to Complete
> Companion to `ROADMAP_COMPLETION_AUDIT.md`. Ordered by priority. Update checkboxes as work lands.
---
- [x] **Operator job actions — requeue / reject / cancel**
- Priority: P0 (highest-impact safe slice; completes Phase-3 §14 "approve/ship/reject/requeue")
- Current status: ✅ DONE — `operatorAction` + route + client + UI buttons + 8 tests; fleet 141 green
- Files involved:
- `services/platform-service/src/modules/fleet/coordinator.ts` (new `operatorAction`)
- `services/platform-service/src/modules/fleet/routes.ts` (new `POST /fleet/jobs/:id/actions/:action`)
- `services/platform-service/src/modules/fleet/coordinator.test.ts` (tests)
- `dashboards/tracker-web/src/lib/fleet-client.ts` (client fn)
- `dashboards/tracker-web/src/app/dashboard/fleet/jobs/[id]/page.tsx` (buttons)
- Implementation plan: operator action does NOT require a held lease; it bumps `leaseEpoch`
to fence any current holder (mirrors the reaper), preserves checkpoint, sets stage
(requeue→queued/blocked, reject→dead_letter, cancel→failed), appends an event.
- Acceptance criteria: requeue a building job → stage queued, epoch+1, zombie report fenced (409);
reject → dead_letter; cancel → failed; unknown action → 400; flag-independent; all prior tests green.
- Verification command: `pnpm --filter @lysnrai/platform-service exec vitest run src/modules/fleet`
- [x] **Scoring explainability surfaced in UI**
- Priority: P1 (data already computed; Phase-3 §14)
- Current status: ✅ DONE — `explainJob` + `GET /fleet/jobs/:id/explain` + ExplainPanel; fleet 144 green
- Files involved: `scheduler.ts`, `coordinator.ts`, `routes.ts`, `fleet-client.ts`, fleet job detail page
- Implementation plan: add `GET /fleet/jobs/:id/explain` returning the would-be score breakdown
against current factories; render a "why this routes here" panel.
- Acceptance criteria: endpoint returns per-factor contributions; UI shows them; degrade if absent.
- Verification command: `pnpm --filter @lysnrai/platform-service exec vitest run src/modules/fleet`
- [x] **Cost burndown chart**
- Priority: P1
- Current status: ✅ DONE — `costBurndown` + `GET /fleet/budgets/:id/burndown` + BurndownChart; fleet 147 green
- Files involved: `dashboards/tracker-web/src/app/dashboard/fleet/budget/page.tsx`, new client fn
- Implementation plan: aggregate run cost by day from events/runs; render burndown vs ceiling overlay.
- Acceptance criteria: per-day spend visible with ceiling line; empty state when no data.
- Verification command: `pnpm --filter @bytelyst/tracker-web test`
- [x] **SSE live log streaming**
- Priority: P2 (larger; §17 single-stream contract)
- Current status: ✅ DONE — `GET /fleet/jobs/:id/events/stream` (resumable SSE) + `subscribeJobEvents`
fetch-streaming consumer with Last-Event-ID resume, polling fallback, and a Live badge; fleet 150,
web 222 green
- Files involved: `services/platform-service/src/modules/fleet/routes.ts` (stream route + clampInt/delay),
`dashboards/tracker-web/src/lib/fleet-client.ts` (`parseSseFrames`, `subscribeJobEvents`),
job detail page (live subscribe + fallback + Live indicator), route + client tests
- Implementation plan: `GET /fleet/jobs/:id/events/stream` (SSE) emitting appended events;
UI subscribes via fetch streaming (auth headers) with polling fallback.
- Acceptance criteria: new events appear without refresh; reconnect + fallback work.
- Verification command: `pnpm --filter @lysnrai/platform-service test`
- [x] **Fleet Playwright e2e**
- Priority: P2 (Phase-3 exit gate)
- Current status: ✅ DONE — `e2e/fleet.spec.ts`, 4 specs (overview, jobs table, job-detail requeue +
live badge, budget pause/resume) against a method/URL-aware mocked `/api/fleet/**`; all green
- Files involved: `dashboards/tracker-web/e2e/fleet.spec.ts`
- Implementation plan: cover fleet map render, jobs table, job detail action, budget pause/resume
against a mocked fleet API.
- Acceptance criteria: e2e green in CI config.
- Verification command: `pnpm --filter @bytelyst/tracker-web exec playwright test fleet`
- [ ] **Phase-1 `budget.wall` enforcement** — P3 — `agent-queue.sh` — wall-clock ceiling extending timeout.
- [ ] **Node `dash` tag surfacing** — P3 — `dashboard.mjs` — profile/priority/caps/tracker-item link.
- [ ] **Roadmap §14 reconciliation** — P3 — tick Phase-2/3 boxes in `learning_ai_devops_tools`.
- [x] **Fleet metrics + alerting** — P3 — ✅ DONE — `GET /fleet/metrics` (`coordinator.fleetMetrics`):
queue depth, stage histogram, oldest-queued age (starvation), factory health/seat utilization, and
derived alerts (`no_live_capacity`, `all_factories_down`, `queue_starvation`, `saturated`,
`stale_factories`). Surfaced as a metrics+alerts panel on the fleet overview (`getFleetMetrics`).
Files: `coordinator.ts`, `routes.ts`, `fleet-client.ts`, `dashboard/fleet/page.tsx` + tests + e2e.
- [x] **Multi-reviewer routing** — P3 — ✅ DONE — review-policy human gate (§14). `requestReview`
routes a building job into `review` (fences worker); `submitReview` records per-reviewer
approve/reject (last-write-wins, identity-normalized), advances to `testing` once distinct
approvals reach the quorum, or vetoes any reject back to `queued` for rework. Routes:
`POST /fleet/jobs/:id/review/request`, `POST /fleet/jobs/:id/review`. UI: review-gate card on
job detail (`requestReview`/`submitReview`). Files: `types.ts`, `coordinator.ts`, `routes.ts`,
`fleet-client.ts`, `dashboard/fleet/jobs/[id]/page.tsx` + coordinator/route/client tests + e2e.
- [ ] **TUI re-point at `/fleet`** — P3 — Phase-3 §14.
### Phase 4 / 5 (post-MVP, tracked only)
- [ ] Message broker (NATS/Redis) push dispatch + backpressure
- [ ] Autoscaling hooks (ephemeral factories)
- [ ] Capability marketplace + cross-product fairness
- [ ] Load + chaos suite
- [ ] Outcome feature capture · offline eval harness · A/B weight tuning · recommendations

View File

@ -77,7 +77,7 @@
## Slice 5 — Docs + roadmap ## Slice 5 — Docs + roadmap
See `FLEET_CONTROL_PLANE.md` for the operational guide. See `docs/FLEET_CONTROL_PLANE.md` for the operational guide.
## Follow-ups ## Follow-ups

View File

@ -1,519 +0,0 @@
# Runbook — Run a Devin Fleet Job EndtoEnd (local)
> **Audience:** developers and coding agents.
> **Goal:** stand up `platform-service` + `tracker-web` + a **fleet factory** (the
> `agent-queue` runner) so a submitted job is claimed and executed **autonomously
> by the Devin CLI** against a target repo (worked example: `learning_ai_notes`),
> pushing a branch and opening a **real pull request**.
> ⚠️ **This is a real, costincurring, sideeffecting operation.** The factory runs
> an autonomous coding agent (Devin) that consumes API credits, can run for a long
> time, pushes a branch, and opens a **real PR** on GitHub. Read [§9 Safety &
> cost](#9-safety--cost) before launching. For unattended local prototyping only —
> not a production deployment guide.
---
## 1. Architecture (what talks to what)
```
you ──▶ tracker-web (:3003) ─┐
│ REST + SSE (/api/fleet/*)
coding agent / curl ────────┼─▶ platform-service (:4003) ──▶ Azure Cosmos (jobs/runs/leases/events)
│ ▲ ▲
Prometheus (:9090)┘ │ │ claim / lease-renew / report (Bearer JWT + X-Product-Id)
Grafana (:3000) ───────────┘ │
agent-queue FACTORY (fleet mode) ──▶ Devin CLI ──▶ git push + gh pr create
(learning_ai_devops_tools/agent-queue) (target repo, e.g. learning_ai_notes)
```
- **platform-service** — the fleet **coordinator**. Owns the job lifecycle
(`queued → assigned → building → review → testing → shipped|failed|dead_letter`),
atomic claim, leases, events, budgets, metrics. Code: `services/platform-service/src/modules/fleet/`.
- **tracker-web** (`:3003`) — submit/inspect jobs (`/dashboard/fleet/jobs/...`).
- **factory**`learning_ai_devops_tools/agent-queue` in **fleet mode**. Polls
`POST /api/fleet/claim`, runs the agent CLI in an isolated checkout, reports back,
and (PR mode) opens the PR.
- **Prometheus/Grafana** — fleet metrics + the "Fleet Overview" dashboard.
Lifecycle the factory drives:
```
queued ─▶ assigned ─▶ building ─▶ review ─▶ testing ─▶ shipped
(claim) (agent (rc=0) (verify (manual/auto ship)
running) passed)
└─ agent rc≠0 / timeout / verify fail ─▶ failed ─▶ (retry|dead_letter)
```
---
## 2. Prerequisites
| Tool | Why | Check |
| ----------------------------------------------- | ---------------------------------------------------------------------------- | -------------------- |
| Node ≥ 20 + `pnpm` (corepack) | host-run service, scripts, tracker-web, build | `node -v && pnpm -v` |
| `git` + `gh` (authenticated) | factory clones target repo, pushes branch, opens PR; `gh pr merge`/reconcile | `gh auth status` |
| `devin` CLI (authenticated) | the agent the factory runs | `devin --version` |
| Both repos cloned sidebyside | coordinator/dashboards + the factory | see below |
| repo `.env` (root of `learning_ai_common_plat`) | `JWT_SECRET`, Cosmos creds, `FLEET_METRICS_TOKEN` | `test -f .env` |
| Docker | **optional** — only for the Docker path (§3 Option B) / Grafana+Prometheus | `docker info` |
> **Node version:** the Docker image pins **node 22**; for the host path any **Node ≥ 20**
> works. Use one Node (nvm/asdf) for both repos to avoid native-module surprises.
### 2.1 Firsttime setup (fresh machine)
Clone both repos as **siblings** (the factory clones targets relative to a shared parent):
```bash
mkdir -p ~/code && cd ~/code
git clone <host>/learning_ai_common_plat.git
git clone <host>/learning_ai_devops_tools.git # contains agent-queue (the factory)
```
Create and fill `.env` at the **root of `learning_ai_common_plat`**:
```bash
cd ~/code/learning_ai_common_plat
cp .env.example .env
# then edit .env — minimum for the fleet flow:
# JWT_SECRET=<any strong secret; tokens are minted+verified with THIS value>
# FLEET_METRICS_TOKEN=changeme-fleet-metrics-token # only needed for Prometheus
# COSMOS_* / connection vars -> see note below
```
- `JWT_SECRET` — HS256 secret platform-service verifies tokens with. Any strong value;
it only needs to be **internally consistent on this machine** (the token you mint in
§5 and the running service must share it). **Required.**
- **Cosmos** — the default prototype talks to a **real Azure Cosmos account** (no emulator
in the default compose). On a new machine you must either (a) point `.env` at the **same
Cosmos account** (to see/share existing jobs) or (b) point at your own DB and set
`COSMOS_AUTO_INIT=true` so containers are created on boot. Without valid Cosmos creds the
service starts but every fleet call fails.
- `FLEET_METRICS_TOKEN` — only needed if you run Prometheus (§4); must match
`services/monitoring/prometheus/prometheus.yml` (`credentials:`).
### 2.2 Install + build the workspace (required for the host path)
Host-run resolves `@bytelyst/*` workspace packages from their **`dist/`** (the `exports`
field points at `dist`), so you must build them once before `tsx`/Next can import them:
```bash
cd ~/code/learning_ai_common_plat
pnpm install
pnpm -r build # builds all workspace packages (incl. @bytelyst/* → dist/)
# (faster, just the platform-service closure:)
# pnpm -r --filter @lysnrai/platform-service... build
```
> Skipping this is the #1 fresh-machine failure: `tsx watch` crashes with
> `Cannot find module '@bytelyst/...'/dist/index.js`. Re-run `pnpm -r build` after pulling
> changes to shared packages.
---
## 3. Bring up platform-service + tracker-web
Two ways. **Option A (all localhost, no Docker)** is recommended for a single dev Mac /
WSL box — everything runs on the host, so `gh`-backed features work out of the box.
**Option B (Docker)** is for when you also want the Grafana/Prometheus stack.
### Option A — all localhost, no Docker (recommended)
Two longlived processes, each in its own terminal. Both assume §2.1/§2.2 are done
(`.env` filled, `pnpm -r build` run).
**Terminal 1 — coordinator (platform-service, :4003):**
```bash
cd ~/code/learning_ai_common_plat/services/platform-service
pnpm exec tsx watch --env-file=../../.env src/server.ts
```
`tsx watch` hot-reloads on source changes. Use the explicit `--env-file=../../.env`
(the bare `pnpm dev` script does **not** load the root `.env`, so `JWT_SECRET`/Cosmos
would be missing). `FLEET_METRICS_TOKEN` is already in `.env` if you set it in §2.1.
**Terminal 2 — dashboard (tracker-web, :3003):**
```bash
cd ~/code/learning_ai_common_plat/dashboards/tracker-web
pnpm dev # serves http://localhost:3003 (proxies /api → :4003)
```
That's the whole coordinator + UI. **Monitoring (Grafana/Prometheus) is optional** on
the host path — `GET /api/fleet/metrics` (JSON), `GET /api/fleet/autoscale`, and the
tracker-web job pages cover observability without it. To get the Grafana "Fleet Overview"
dashboard you need Prometheus + Grafana (run them via Docker — Option B — or Homebrew
binaries pointed at `services/monitoring/...`).
Because everything is on the host, `gh` is on `PATH` → the PRstate **reconcile** (§8)
and shiptime `gh pr merge` work (unlike the Docker container, which has no `gh`).
Health checks:
```bash
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:4003/health # 200
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3003 # 200
```
### Option B — Docker (adds Grafana + Prometheus)
```bash
cd ~/code/learning_ai_common_plat
# targeted fleet subset that always builds cleanly:
docker compose up -d --build platform-service prometheus grafana
# (full stack: bash scripts/prototype-up.sh)
```
Starts `platform-service` (`:4003`), `prometheus` (`:9090`), `grafana` (`:3000`,
admin/`lysnrai`) + deps. Still run **tracker-web from source** (Option A, Terminal 2).
> **Docker caveats:**
>
> - `prototype-up.sh` may fail building the **dashboard** images when
> `corepack prepare pnpm@…` can't fetch pnpm on a restricted network → use the targeted
> subset above.
> - **`gh` is NOT in the container** → coordinatorside `gh pr merge` and PRreconcile (§8)
> are noops in Docker. Use the host path (Option A) if you need them.
> - Don't run both: the container and a host `tsx` both bind `:4003`
> (`docker compose stop platform-service` before hostrunning).
---
## 4. Make Prometheus auth work (only if running Prometheus)
Skip this on the host path unless you also run Prometheus. `prometheus.yml` scrapes
`/api/fleet/metrics/prom` with a bearer, so the running `platform-service` must see the
same `FLEET_METRICS_TOKEN`:
```bash
cd ~/code/learning_ai_common_plat
grep -q '^FLEET_METRICS_TOKEN=' .env || \
printf '\nFLEET_METRICS_TOKEN=changeme-fleet-metrics-token\n' >> .env
# host path: restart Terminal-1 tsx so it re-reads .env
# docker path: docker compose up -d platform-service
```
Verify (if Prometheus is up): `http://localhost:9090/api/v1/targets`
`platform-service-fleet` is `up`. The value must equal `credentials:` in
`services/monitoring/prometheus/prometheus.yml`.
---
## 5. Mint a local API token (dev only)
`platform-service` verifies HS256 JWTs signed with `JWT_SECRET` and requires
`type: "access"`. The tracker-web UI obtains one via login; for scripts/agents and
the factory, mint one directly. **Local dev only — never commit tokens or the secret.**
Save `mint-token.mjs` (resolve `jose` from the workspace):
```js
import { readFileSync } from 'node:fs';
// adjust the jose path to your checkout if needed:
import { SignJWT } from '/ABS/PATH/learning_ai_common_plat/node_modules/.pnpm/jose@5.10.0/node_modules/jose/dist/node/esm/index.js';
const env = readFileSync('/ABS/PATH/learning_ai_common_plat/.env', 'utf8');
const secret = new TextEncoder().encode(env.match(/^JWT_SECRET=(.*)$/m)[1].trim());
const ttl = process.argv[2] || '15m'; // e.g. '15m' for scripts, '24h' for a factory
process.stdout.write(
await new SignJWT({ sub: 'local-dev', role: 'admin', type: 'access' })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime(ttl)
.sign(secret)
);
```
```bash
node mint-token.mjs 15m > /tmp/tok # short-lived, for API calls
node mint-token.mjs 24h > /tmp/factok # longer-lived, for the factory daemon
```
> Find the jose path with:
> `find . -path '*/node_modules/jose/dist/*/esm/index.js' | head -1`.
Requests must also carry the **product**: header `X-Product-Id: <productId>`
(e.g. `notelett`). `role: admin` bypasses tenant ownership checks when
`FLEET_TENANT_ENFORCEMENT` is on (it's off by default).
---
## 6. Submit a job
### Via tracker-web (preferred)
Open `http://localhost:3003/dashboard/fleet/jobs`, "New job". **Set the correct
product first** (the product selector) — a job is partitioned by `productId`, and
submitting under the wrong product misattributes cost/metrics/ownership and the
factory won't see it under the product it polls.
PRmode fields that matter:
- **`repo`** — must be `owner/name` (e.g. `saravanakumardb1/learning_ai_notes`) or a
clone URL, **not** a bare name (the factory feeds it to `gh`).
- **`baseBranch`** — e.g. `main`.
- **`engine`** — `devin` (pins the agent; otherwise the factory's default/engineClass).
- **`autoMerge`** — leave **`false`** for a human merge gate (recommended for large PRs).
### Via API
```bash
JOB=$(curl -s -X POST http://localhost:4003/api/fleet/jobs \
-H "Authorization: Bearer $(cat /tmp/tok)" \
-H "X-Product-Id: notelett" -H 'Content-Type: application/json' \
-d '{
"idempotencyKey": "notelett-demo-1",
"bodyMd": "# Task\n…full prompt…",
"priority": "high",
"engine": "devin",
"repo": "saravanakumardb1/learning_ai_notes",
"baseBranch": "main",
"autoMerge": false
}')
echo "$JOB" # → { outcome: "created", job: { id: "fjob_…", stage: "queued", ... } }
```
The job is now `queued` and claimable. It will **not run** until a factory polls
for its product (next step).
---
## 7. Start the factory (agent-queue, fleet mode)
The factory lives in a **separate repo**: `learning_ai_devops_tools/agent-queue`.
Run it on the **host** (needs `devin` + `gh`). Read its `docs/RUN_POLICY.md` first.
### 7a. Sanitycheck connectivity (safe — registers + heartbeats only)
```bash
cd learning_ai_devops_tools/agent-queue
./agent-queue.sh init # idempotent
AQ_FLEET=1 AQ_FLEET_ROUTE=1 \
AQ_FLEET_API=http://localhost:4003/api \
AQ_PRODUCT_ID=notelett \
AQ_FLEET_TOKEN="$(cat /tmp/factok)" \
AQ_FACTORY_ID=mac-local-1 \
AQ_FLEET_CAPS=engine:devin \
AQ_FLEET_LEASE_RENEW_SEC=60 \
./agent-queue.sh fleet-status # → "heartbeat OK (registered)."
```
### 7b. Launch the run loop (claims + runs the agent)
```bash
cd learning_ai_devops_tools/agent-queue
AQ_FLEET=1 AQ_FLEET_ROUTE=1 AQ_FLEET_PR=1 \
AQ_FLEET_API=http://localhost:4003/api \
AQ_PRODUCT_ID=notelett \
AQ_FLEET_TOKEN="$(cat /tmp/factok)" \
AQ_FACTORY_ID=mac-local-1 \
AQ_FLEET_CAPS=engine:devin \
AQ_FLEET_LEASE_RENEW_SEC=60 \
./agent-queue.sh run --max 1
```
> ⚠️ **Set `AQ_FLEET_LEASE_RENEW_SEC` below 90 (e.g. 60).** This is the heartbeat/
> leaserenew cadence. The coordinator's reaper marks a factory **stale after 90s**
> (`DEFAULT_STALE_FACTORY_MS`, a constant — no env knob) and **reclaims its inflight
> lease**. The default cadence is **300s**, so a busy singleslot factory looks stale
> for most of every cycle and its running job gets requeued midrun (`leaseEpoch`
> climbs, stage flips back to `queued`, and the final report is **fenced** so the job
> never tidies to `review`/`shipped`). 60s keeps it comfortably live. (Add the same env
> to the §7a `fleet-status` check for consistency.)
Key fleet env vars (see `lib/fleet-client.sh`):
| Var | Meaning |
| -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `AQ_FLEET=1` | master switch — enable coordinator calls (0 = pure offline) |
| `AQ_FLEET_ROUTE=1` | coordinator is **authoritative** for claim (pulls work from platform-service) |
| `AQ_FLEET_PR=1` | PR mode — open a PR for jobs that target a `repo` |
| `AQ_FLEET_API` | base URL **including `/api`** (`http://localhost:4003/api`) |
| `AQ_FLEET_TOKEN` | **Bearer JWT** (mint per §5; ≥ run duration, e.g. 24h) |
| `AQ_PRODUCT_ID` | product to poll — sent as `X-Product-Id` (must match the job's product) |
| `AQ_FACTORY_ID` | this factory's id (registered/heartbeated) |
| `AQ_FLEET_CAPS` | advertised capabilities, e.g. `engine:devin` |
| `AQ_FLEET_LEASE_RENEW_SEC` | **set `<90`** (e.g. `60`) — heartbeat/renew cadence vs the 90s stale window (see warning) |
| `AQ_FLEET_REPO_BASE` | _(optional)_ dir of local checkouts; if `…/<repo>/.git` exists it uses a **git worktree**, else it `git clone`s `https://github.com/<repo>.git` into its cache |
| `AQ_FLEET_AUTOSHIP=1` | _(optional)_ auto-advance to `shipped` (skips the manual gate) |
The run loop `claim → assigned → building`, runs Devin in an isolated checkout,
heartbeats + renews the lease (`lease_renewed` events) so the reaper doesn't reclaim it,
then on agent exit moves to `review` and (PR mode) opens the PR. With `autoMerge:false`
it **stops at the human merge gate**.
> **Repo checkout:** the job's `repo` is `owner/name`, so by default the factory
> `git clone`s `https://github.com/<owner>/<name>.git` into its own cache
> (`queue/.state/repos/…`) — clean isolation, nothing touches your working copies. To
> reuse an existing local clone via a **git worktree** instead, set
> `AQ_FLEET_REPO_BASE=<parent>` where `<parent>/<owner>/<name>/.git` exists.
---
## 8. Observe progress
- **Factory/agent logs (the live Devin transcript):** use the helper
`scripts/fleet-logs.sh` (auto-finds the agent-queue logs; takes a full or partial job id,
defaults to the newest job):
```bash
scripts/fleet-logs.sh ls # list jobs: slot + step count
scripts/fleet-logs.sh status 3c0586ce # steps count + slot + latest step
scripts/fleet-logs.sh steps 3c0586ce 20 # last 20 transcript steps
scripts/fleet-logs.sh watch 3c0586ce # live-refresh the tail
scripts/fleet-logs.sh tail 3c0586ce # follow the runner lifecycle .log
scripts/fleet-logs.sh full 3c0586ce # all agent messages in your pager
```
(Override the factory location with `AQ=/path/to/agent-queue`. Needs `jq` for the
transcript commands.)
- **tracker-web:** `http://localhost:3003/dashboard/fleet/jobs/<jobId>` — live event
stream (SSE), runs, PR link + state. (Select the job's **product** in the UI first, or
it shows "job does not exist" — every call is scoped by `X-Product-Id`.)
- **Events/API:**
```bash
curl -s http://localhost:4003/api/fleet/jobs/<jobId>/events \
-H "Authorization: Bearer $(cat /tmp/tok)" -H "X-Product-Id: notelett"
```
- **Metrics:** `GET /api/fleet/metrics` (JSON, per product) · `GET /api/fleet/metrics/prom`
(Prometheus, all products; needs `FLEET_METRICS_TOKEN`) · Grafana **Fleet Overview**
(`http://localhost:3000/d/fleet-overview`).
- **Autoscale signal:** `GET /api/fleet/autoscale` (this product) / `…/autoscale/all`.
### PRstate reconcile (externallymerged PRs)
If you merge the PR in the GitHub UI, the coordinator doesn't know until told. Trigger
a reconcile (flips run `prState → merged` when `gh pr view` reports MERGED):
- UI: **"Refresh PR status"** button on the job's PR section, or
- API: `POST /api/fleet/jobs/<jobId>/pr/reconcile`.
> Requires `gh` where platform-service runs → use the **host path** (§3 Option A);
> it's a noop in the Docker container (no `gh`).
---
## 9. Safety & cost
- **Billable + autonomous + longrunning.** Each run consumes Devin credits and can
run for a long time unattended. Scope jobs deliberately; very large multiworkstream
specs are better split into several jobs.
- **Real PR.** PR mode pushes a branch and opens a PR on the target repo. Keep
`autoMerge:false` so a human reviews/merges; `gh pr merge` (auto) only fires when the
job opts in or `FLEET_SHIP_MERGES_PR=1`.
- **Isolation.** The factory works in an isolated worktree/clone, never your main
checkout (per `agent-queue/docs/RUN_POLICY.md`). Avoid blanket `--yolo` on live trees.
- **Stopping the daemon** midrun lets the lease expire; the coordinator's reaper then
reclaims and requeues the job (so partial work may be retried). Stop intentionally.
- **Tokens/secrets:** the minted JWT and `JWT_SECRET` are sensitive — never commit them
or paste into shared logs. `.env` is gitignored; keep it that way.
---
## 10. Teardown
```bash
# stop the factory: Ctrl-C the run loop
# host path: Ctrl-C the tsx (Terminal 1) and pnpm dev (Terminal 2)
# docker path:
# cd ~/code/learning_ai_common_plat && docker compose down # keep volumes
# docker compose down -v # also drop volumes
rm -f /tmp/tok /tmp/factok # discard minted tokens
```
---
## 11. Troubleshooting
| Symptom | Cause → Fix |
| -------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Cannot find module '@bytelyst/…/dist/index.js'` on `tsx`/Next start | workspace packages not built → `pnpm -r build` (§2.2). |
| `401 {"error":"Invalid or expired token"}` | JWT expired/missigned → remint (§5); ensure same `JWT_SECRET` as the running service. |
| Job claimed then flips back to `queued` midrun; `leaseEpoch` keeps climbing; final report **fenced**; PR opens but job never reaches `review`/`shipped` | factory heartbeat cadence (`AQ_FLEET_LEASE_RENEW_SEC`, default **300s**) > reaper stale window (**90s**) → set `AQ_FLEET_LEASE_RENEW_SEC=60` (§7). To recover the record after the fact, reconcile PR state (§8). |
| Job stays `queued`, never claimed | No factory for that product → `fleet-status` shows it registered? `AQ_PRODUCT_ID` must equal the job's product. Check `GET /api/fleet/factories` (XProductId) for `0 live`. |
| `POST …/pr/reconcile` or ship automerge does nothing | `gh` not present where platform-service runs (Docker container) → run the host path (§3 Option A). |
| Prometheus target `platform-service-fleet` = `down (401)` | service missing `FLEET_METRICS_TOKEN` → §4 (restart host `tsx` / recreate container). |
| `prototype-up.sh` build fails on `corepack prepare pnpm` | dashboard image network issue → use the targeted subset, or just use the host path (Option A). |
| `POST …/actions/<x>` returns 500 "Body cannot be empty" | sent `Content-Type: application/json` with no body → omit the header or send `{}`. |
| Port `4003` conflict | host `tsx watch` and a `platform-service` container both bind `4003` → run only one. |
| `gh pr create` fails | `repo` is a bare name → must be `owner/name` or a clone URL; confirm `gh auth status`. |
| PR/cost attributed to wrong product | job submitted under the wrong `productId` partition → resubmit under the right product and cancel the stray (`POST …/actions/cancel`). |
| `vitest` exits nonzero with `kill EPERM` after all suites pass | workerpool teardown artifact (sandbox), not a test failure → rerun; all suites already passed. |
---
## 12. Copypaste quickstart — all localhost (notelett → learning_ai_notes)
Assumes §2.1/§2.2 done (`.env` filled, `pnpm -r build` run). Four terminals.
```bash
# Terminal 1 — coordinator
cd ~/code/learning_ai_common_plat/services/platform-service
pnpm exec tsx watch --env-file=../../.env src/server.ts
# Terminal 2 — dashboard
cd ~/code/learning_ai_common_plat/dashboards/tracker-web && pnpm dev # :3003
# Terminal 3 — tokens + submit (save mint-token.mjs from §5; fix ABS paths)
node mint-token.mjs 15m > /tmp/tok
node mint-token.mjs 24h > /tmp/factok
curl -s -X POST http://localhost:4003/api/fleet/jobs \
-H "Authorization: Bearer $(cat /tmp/tok)" -H "X-Product-Id: notelett" \
-H 'Content-Type: application/json' \
-d '{"idempotencyKey":"notelett-demo-1","bodyMd":"# Task…","priority":"high","engine":"devin","repo":"saravanakumardb1/learning_ai_notes","baseBranch":"main","autoMerge":false}'
# Terminal 4 — factory (runs Devin → opens a real PR). NOTE the <90s heartbeat cadence.
cd ~/code/learning_ai_devops_tools/agent-queue && ./agent-queue.sh init
AQ_FLEET=1 AQ_FLEET_ROUTE=1 AQ_FLEET_PR=1 AQ_FLEET_API=http://localhost:4003/api \
AQ_PRODUCT_ID=notelett AQ_FLEET_TOKEN="$(cat /tmp/factok)" \
AQ_FACTORY_ID=mac-local-1 AQ_FLEET_CAPS=engine:devin AQ_FLEET_LEASE_RENEW_SEC=60 \
./agent-queue.sh run --max 1
```
---
## 13. WSL on Windows — differences to note
The flow is identical **inside a WSL2 (Ubuntu) shell**, with these adjustments. Treat
WSL as "the Linux host" — install and run **everything inside WSL**, not Windows.
- **Keep repos on the WSL filesystem, not `/mnt/c`.** Clone under e.g. `~/code` inside
WSL. On `/mnt/c` (the Windows drive over 9p) `tsx watch`/Next filewatching is
unreliable (inotify doesn't fire) and git/pnpm are far slower. This is the single most
important difference.
- **Install the toolchain inside WSL** (Linux builds): `node`/`pnpm` (nvm), `git`, **`gh`**,
and the **`devin` CLI** — and run `gh auth login` + Devin auth **inside WSL**. A `gh`/
`devin` installed on Windows is not visible to the WSL bash factory.
- **Line endings.** Clone inside WSL (don't reuse a Windows checkout with
`core.autocrlf=true`) so the `*.sh` scripts stay LF — CRLF breaks `agent-queue.sh`
(`bad interpreter`/`\r`). If needed: `git config --global core.autocrlf input`.
- **Reaching the UI from the Windows browser.** WSL2 forwards `localhost`, so
`http://localhost:3003` / `:4003` usually work from a Windows browser. If they don't
(older Windows / mirrorednetworking off), use the WSL IP (`hostname -I`) or set
`networkingMode=mirrored` in `.wslconfig`.
- **Ports.** Make sure nothing on the **Windows** side already binds `3003`/`4003`
(WSL2 publishes to the same localhost). Stop the Windows process or change ports.
- **Docker (Option B), if used.** Use **Docker Desktop with the WSL2 backend** and run
`docker compose` from inside the WSL shell. `host.docker.internal` resolves from
containers to the host as on Mac.
- **`/tmp` token paths** (`/tmp/tok`, `/tmp/factok`) are the WSL `/tmp` — fine; just keep
all four terminals in the same WSL distro so they share it.
- **Clock skew.** If WSL's clock drifts after sleep, JWT `iat/exp` checks can fail
(`Invalid or expired token`) — `sudo hwclock -s` (or restart WSL) to resync.
Everything else — env vars, `pnpm -r build`, `tsx --env-file`, the factory env incl.
`AQ_FLEET_LEASE_RENEW_SEC=60`, token minting — is identical to the Mac host path.
---
### Reference
- Coordinator routes: `services/platform-service/src/modules/fleet/routes.ts`
- Coordinator logic: `services/platform-service/src/modules/fleet/coordinator.ts`
- Factory fleet client: `learning_ai_devops_tools/agent-queue/lib/fleet-client.sh`
- Factory runner + PR mode: `learning_ai_devops_tools/agent-queue/agent-queue.sh`
- Gigafactory spec/roadmap: `learning_ai_devops_tools/agent-queue/docs/GIGAFACTORY/`
- Prometheus scrape config: `services/monitoring/prometheus/prometheus.yml`
- Grafana dashboard: `services/monitoring/grafana/dashboards/fleet-overview.json`

View File

@ -29,15 +29,15 @@
"@changesets/cli": "^2.28.1", "@changesets/cli": "^2.28.1",
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@size-limit/preset-small-lib": "^12.1.0", "@size-limit/preset-small-lib": "^12.1.0",
"@types/bcryptjs": "^3.0.0", "@types/bcryptjs": "^2.4.6",
"@types/node": "^25.9.1", "@types/node": "^20.0.0",
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
"@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "^8.60.0", "@typescript-eslint/parser": "^8.0.0",
"@vitest/coverage-v8": "^3.0.0", "@vitest/coverage-v8": "^3.0.0",
"eslint": "^9.0.0", "eslint": "^9.0.0",
"husky": "^9.0.0", "husky": "^9.0.0",
"lint-staged": "^16.4.0", "lint-staged": "^15.0.0",
"prettier": "^3.0.0", "prettier": "^3.0.0",
"size-limit": "^12.1.0", "size-limit": "^12.1.0",
"typescript": "^5.7.0", "typescript": "^5.7.0",

View File

@ -27,7 +27,7 @@
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"happy-dom": "^20.9.0", "happy-dom": "^18.0.1",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"typescript": "^5.7.3", "typescript": "^5.7.3",

View File

@ -26,7 +26,7 @@
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"happy-dom": "^20.9.0", "happy-dom": "^18.0.1",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4" "react-dom": "^19.2.4"
} }

View File

@ -27,7 +27,7 @@
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"happy-dom": "^20.9.0", "happy-dom": "^18.0.1",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"typescript": "^5.7.3", "typescript": "^5.7.3",

View File

@ -27,7 +27,7 @@
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"happy-dom": "^20.9.0", "happy-dom": "^18.0.1",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"typescript": "^5.7.3", "typescript": "^5.7.3",

View File

@ -85,7 +85,7 @@ describe('CommandRegistryProvider', () => {
]); ]);
return useCommands(); return useCommands();
}, },
{ wrapper: wrapper() } { wrapper: wrapper() },
); );
expect(result.current.map(c => c.id).sort()).toEqual(['temp', 'temp2']); expect(result.current.map(c => c.id).sort()).toEqual(['temp', 'temp2']);
unmount(); unmount();
@ -96,7 +96,9 @@ describe('CommandRegistryProvider', () => {
}); });
it('throws when used outside provider', () => { it('throws when used outside provider', () => {
expect(() => renderHook(() => useCommands())).toThrow(/CommandRegistryProvider/); expect(() => renderHook(() => useCommands())).toThrow(
/CommandRegistryProvider/,
);
}); });
}); });
@ -126,7 +128,7 @@ describe('CommandPalette', () => {
render( render(
<CommandRegistryProvider initial={seed}> <CommandRegistryProvider initial={seed}>
<CommandPalette open={false} onClose={() => {}} /> <CommandPalette open={false} onClose={() => {}} />
</CommandRegistryProvider> </CommandRegistryProvider>,
); );
expect(screen.queryByTestId('bl-cmdk')).toBeNull(); expect(screen.queryByTestId('bl-cmdk')).toBeNull();
}); });
@ -135,7 +137,7 @@ describe('CommandPalette', () => {
render( render(
<CommandRegistryProvider initial={seed}> <CommandRegistryProvider initial={seed}>
<CommandPalette open onClose={() => {}} /> <CommandPalette open onClose={() => {}} />
</CommandRegistryProvider> </CommandRegistryProvider>,
); );
expect(screen.getByTestId('bl-cmdk-panel')).toBeDefined(); expect(screen.getByTestId('bl-cmdk-panel')).toBeDefined();
expect(screen.getByTestId('bl-cmdk-item-new-task')).toBeDefined(); expect(screen.getByTestId('bl-cmdk-item-new-task')).toBeDefined();
@ -147,7 +149,7 @@ describe('CommandPalette', () => {
render( render(
<CommandRegistryProvider initial={seed}> <CommandRegistryProvider initial={seed}>
<CommandPalette open onClose={() => {}} /> <CommandPalette open onClose={() => {}} />
</CommandRegistryProvider> </CommandRegistryProvider>,
); );
expect(screen.queryByTestId('bl-cmdk-item-gated')).toBeNull(); expect(screen.queryByTestId('bl-cmdk-item-gated')).toBeNull();
}); });
@ -156,7 +158,7 @@ describe('CommandPalette', () => {
render( render(
<CommandRegistryProvider initial={seed}> <CommandRegistryProvider initial={seed}>
<CommandPalette open onClose={() => {}} /> <CommandPalette open onClose={() => {}} />
</CommandRegistryProvider> </CommandRegistryProvider>,
); );
const dialog = screen.getByTestId('bl-cmdk'); const dialog = screen.getByTestId('bl-cmdk');
fireEvent.keyDown(dialog, { key: 'Tab' }); fireEvent.keyDown(dialog, { key: 'Tab' });
@ -169,7 +171,7 @@ describe('CommandPalette', () => {
render( render(
<CommandRegistryProvider initial={seed}> <CommandRegistryProvider initial={seed}>
<CommandPalette open onClose={() => {}} /> <CommandPalette open onClose={() => {}} />
</CommandRegistryProvider> </CommandRegistryProvider>,
); );
fireEvent.change(screen.getByTestId('bl-cmdk-input'), { fireEvent.change(screen.getByTestId('bl-cmdk-input'), {
target: { value: 'task' }, target: { value: 'task' },
@ -182,10 +184,10 @@ describe('CommandPalette', () => {
render( render(
<CommandRegistryProvider initial={seed}> <CommandRegistryProvider initial={seed}>
<CommandPalette open onClose={onClose} /> <CommandPalette open onClose={onClose} />
</CommandRegistryProvider> </CommandRegistryProvider>,
); );
fireEvent.keyDown(screen.getByTestId('bl-cmdk'), { key: 'Enter' }); fireEvent.keyDown(screen.getByTestId('bl-cmdk'), { key: 'Enter' });
expect(seed[0].run as ReturnType<typeof vi.fn>).toHaveBeenCalledOnce(); expect((seed[0].run as ReturnType<typeof vi.fn>)).toHaveBeenCalledOnce();
expect(onClose).toHaveBeenCalled(); expect(onClose).toHaveBeenCalled();
}); });
@ -195,7 +197,7 @@ describe('CommandPalette', () => {
render( render(
<CommandRegistryProvider initial={seed}> <CommandRegistryProvider initial={seed}>
<CommandPalette open onClose={onClose} onNavigate={onNavigate} /> <CommandPalette open onClose={onClose} onNavigate={onNavigate} />
</CommandRegistryProvider> </CommandRegistryProvider>,
); );
fireEvent.keyDown(screen.getByTestId('bl-cmdk'), { key: 'Tab' }); fireEvent.keyDown(screen.getByTestId('bl-cmdk'), { key: 'Tab' });
fireEvent.keyDown(screen.getByTestId('bl-cmdk'), { key: 'Enter' }); fireEvent.keyDown(screen.getByTestId('bl-cmdk'), { key: 'Enter' });
@ -207,7 +209,7 @@ describe('CommandPalette', () => {
render( render(
<CommandRegistryProvider initial={seed}> <CommandRegistryProvider initial={seed}>
<CommandPalette open onClose={() => {}} /> <CommandPalette open onClose={() => {}} />
</CommandRegistryProvider> </CommandRegistryProvider>,
); );
const dialog = screen.getByTestId('bl-cmdk'); const dialog = screen.getByTestId('bl-cmdk');
fireEvent.keyDown(dialog, { key: 'ArrowDown' }); fireEvent.keyDown(dialog, { key: 'ArrowDown' });
@ -223,7 +225,7 @@ describe('CommandPalette', () => {
render( render(
<CommandRegistryProvider initial={seed}> <CommandRegistryProvider initial={seed}>
<CommandPalette open onClose={onClose} /> <CommandPalette open onClose={onClose} />
</CommandRegistryProvider> </CommandRegistryProvider>,
); );
fireEvent.keyDown(document, { key: 'Escape' }); fireEvent.keyDown(document, { key: 'Escape' });
expect(onClose).toHaveBeenCalledOnce(); expect(onClose).toHaveBeenCalledOnce();
@ -233,7 +235,7 @@ describe('CommandPalette', () => {
const { rerender } = render( const { rerender } = render(
<CommandRegistryProvider initial={seed}> <CommandRegistryProvider initial={seed}>
<CommandPalette open onClose={() => {}} initialMode="ask-ai" /> <CommandPalette open onClose={() => {}} initialMode="ask-ai" />
</CommandRegistryProvider> </CommandRegistryProvider>,
); );
expect(screen.getByTestId('bl-cmdk-ask-ai')).toBeDefined(); expect(screen.getByTestId('bl-cmdk-ask-ai')).toBeDefined();
@ -245,7 +247,7 @@ describe('CommandPalette', () => {
initialMode="ask-ai" initialMode="ask-ai"
askAiPanel={q => <div data-testid="custom-ai">{q || 'idle'}</div>} askAiPanel={q => <div data-testid="custom-ai">{q || 'idle'}</div>}
/> />
</CommandRegistryProvider> </CommandRegistryProvider>,
); );
expect(screen.getByTestId('custom-ai').textContent).toBe('idle'); expect(screen.getByTestId('custom-ai').textContent).toBe('idle');
}); });
@ -254,12 +256,12 @@ describe('CommandPalette', () => {
render( render(
<CommandRegistryProvider initial={seed}> <CommandRegistryProvider initial={seed}>
<CommandPalette open onClose={() => {}} /> <CommandPalette open onClose={() => {}} />
</CommandRegistryProvider> </CommandRegistryProvider>,
); );
const row = screen.getByTestId('bl-cmdk-item-archive'); const row = screen.getByTestId('bl-cmdk-item-archive');
expect(row.getAttribute('aria-disabled')).toBe('true'); expect(row.getAttribute('aria-disabled')).toBe('true');
fireEvent.click(row); fireEvent.click(row);
expect(seed[3].run as ReturnType<typeof vi.fn>).not.toHaveBeenCalled(); expect((seed[3].run as ReturnType<typeof vi.fn>)).not.toHaveBeenCalled();
}); });
it('recents persist to localStorage when a command is run', () => { it('recents persist to localStorage when a command is run', () => {
@ -292,7 +294,7 @@ describe('CommandPalette', () => {
render( render(
<CommandRegistryProvider initial={seed}> <CommandRegistryProvider initial={seed}>
<CommandPalette open onClose={() => {}} /> <CommandPalette open onClose={() => {}} />
</CommandRegistryProvider> </CommandRegistryProvider>,
); );
fireEvent.click(screen.getByTestId('bl-cmdk-item-new-task')); fireEvent.click(screen.getByTestId('bl-cmdk-item-new-task'));
expect(mem['bl-cmdk-recents']).toContain('new-task'); expect(mem['bl-cmdk-recents']).toContain('new-task');
@ -313,12 +315,16 @@ describe('useCommandPalette', () => {
expect(result.current.open).toBe(false); expect(result.current.open).toBe(false);
act(() => { act(() => {
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true })); window.dispatchEvent(
new KeyboardEvent('keydown', { key: 'k', metaKey: true }),
);
}); });
expect(result.current.open).toBe(true); expect(result.current.open).toBe(true);
act(() => { act(() => {
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true })); window.dispatchEvent(
new KeyboardEvent('keydown', { key: 'k', metaKey: true }),
);
}); });
expect(result.current.open).toBe(false); expect(result.current.open).toBe(false);
@ -329,19 +335,6 @@ describe('useCommandPalette', () => {
expect(result.current.open).toBe(false); expect(result.current.open).toBe(false);
}); });
it('ignores keydown events with an undefined key (autofill/IME) without throwing', () => {
const { result } = renderHook(() => useCommandPalette());
expect(() =>
act(() => {
// Some events (autofill, IME) dispatch keydown with no `key` set.
const ev = new KeyboardEvent('keydown', { metaKey: true });
Object.defineProperty(ev, 'key', { value: undefined });
window.dispatchEvent(ev);
})
).not.toThrow();
expect(result.current.open).toBe(false);
});
it('show/hide/toggle imperative helpers', () => { it('show/hide/toggle imperative helpers', () => {
const { result } = renderHook(() => useCommandPalette({ hotkey: null })); const { result } = renderHook(() => useCommandPalette({ hotkey: null }));
act(() => result.current.show()); act(() => result.current.show());
@ -355,7 +348,9 @@ describe('useCommandPalette', () => {
}); });
it('respects defaultOpen', () => { it('respects defaultOpen', () => {
const { result } = renderHook(() => useCommandPalette({ defaultOpen: true, hotkey: null })); const { result } = renderHook(() =>
useCommandPalette({ defaultOpen: true, hotkey: null }),
);
expect(result.current.open).toBe(true); expect(result.current.open).toBe(true);
}); });
}); });

View File

@ -32,9 +32,10 @@ export interface UseCommandPaletteHelpers {
* inputs unless the user explicitly holds the modifier. * inputs unless the user explicitly holds the modifier.
*/ */
export function useCommandPalette( export function useCommandPalette(
options: UseCommandPaletteOptions = {} options: UseCommandPaletteOptions = {},
): UseCommandPaletteHelpers { ): UseCommandPaletteHelpers {
const { hotkey = { key: 'k', meta: true, ctrl: true }, defaultOpen = false } = options; const { hotkey = { key: 'k', meta: true, ctrl: true }, defaultOpen = false } =
options;
const [open, setOpen] = useState(defaultOpen); const [open, setOpen] = useState(defaultOpen);
const show = useCallback(() => setOpen(true), []); const show = useCallback(() => setOpen(true), []);
@ -44,9 +45,6 @@ export function useCommandPalette(
useEffect(() => { useEffect(() => {
if (!hotkey) return; if (!hotkey) return;
const handler = (e: KeyboardEvent) => { const handler = (e: KeyboardEvent) => {
// Some keydown events (autofill, IME/composition, certain extensions) arrive
// with an undefined `key`; guard so we never crash on `.toLowerCase()`.
if (!e.key || !hotkey.key) return;
if (e.key.toLowerCase() !== hotkey.key.toLowerCase()) return; if (e.key.toLowerCase() !== hotkey.key.toLowerCase()) return;
const wantMeta = hotkey.meta ?? false; const wantMeta = hotkey.meta ?? false;
const wantCtrl = hotkey.ctrl ?? false; const wantCtrl = hotkey.ctrl ?? false;

View File

@ -83,72 +83,11 @@ async function createDatabaseSafe(
} }
} }
function buildIndexingPolicy(config: ContainerConfig) {
if (!config.compositeIndexes || config.compositeIndexes.length === 0) return undefined;
return {
indexingMode: 'consistent' as const,
automatic: true,
includedPaths: [{ path: '/*' }],
compositeIndexes: config.compositeIndexes.map(group =>
group.map(p => ({ path: p.path, order: p.order ?? 'ascending' }))
),
};
}
/** Normalize composite indexes for order-insensitive equality (default = ascending). */
function normalizeComposites(composites: unknown): string {
const arr = Array.isArray(composites) ? composites : [];
return JSON.stringify(
arr.map((group: unknown) =>
(Array.isArray(group) ? group : []).map((p: { path?: string; order?: string }) => ({
path: p.path,
order: p.order ?? 'ascending',
}))
)
);
}
/**
* Reconcile composite indexes onto an EXISTING container. `createIfNotExists`
* never updates an existing container's indexing policy, so we read the current
* definition and replace it only when the composite indexes differ.
*/
async function ensureIndexingPolicy(
database: Database,
name: string,
config: ContainerConfig
): Promise<void> {
const desired = buildIndexingPolicy(config);
if (!desired) return;
const container = database.container(name);
const { resource: def } = await container.read();
if (!def) return;
const current = (def.indexingPolicy ?? {}) as { compositeIndexes?: unknown };
if (
normalizeComposites(current.compositeIndexes) === normalizeComposites(desired.compositeIndexes)
) {
return; // already in place
}
await container.replace({
id: name,
partitionKey: def.partitionKey as PartitionKeyDefinition,
...(def.defaultTtl != null && { defaultTtl: def.defaultTtl }),
indexingPolicy: {
...(def.indexingPolicy ?? {}),
indexingMode: 'consistent',
automatic: true,
includedPaths: def.indexingPolicy?.includedPaths ?? desired.includedPaths,
compositeIndexes: desired.compositeIndexes,
},
});
}
async function createContainerSafe( async function createContainerSafe(
database: Database, database: Database,
name: string, name: string,
config: ContainerConfig config: ContainerConfig
): Promise<void> { ): Promise<void> {
const indexingPolicy = buildIndexingPolicy(config);
const payload = { const payload = {
id: name, id: name,
partitionKey: { partitionKey: {
@ -156,27 +95,14 @@ async function createContainerSafe(
kind: 'Hash', kind: 'Hash',
} as PartitionKeyDefinition, } as PartitionKeyDefinition,
...(config.defaultTtl != null && { defaultTtl: config.defaultTtl }), ...(config.defaultTtl != null && { defaultTtl: config.defaultTtl }),
...(indexingPolicy && { indexingPolicy }),
}; };
for (let attempt = 0; attempt < 3; attempt += 1) { for (let attempt = 0; attempt < 3; attempt += 1) {
try { try {
await database.containers.createIfNotExists(payload); await database.containers.createIfNotExists(payload);
// createIfNotExists won't update an existing container's index policy.
if (indexingPolicy) await ensureIndexingPolicy(database, name, config);
return; return;
} catch (err) { } catch (err) {
if (isCosmosConflict(err)) { if (isCosmosConflict(err)) return; // Container was created by another process.
// Container already existed — still reconcile its indexing policy (best-effort).
if (indexingPolicy) {
try {
await ensureIndexingPolicy(database, name, config);
} catch {
/* reconciliation is best-effort; container is usable */
}
}
return;
}
// Sometimes the database/container metadata isn't immediately visible after creation. // Sometimes the database/container metadata isn't immediately visible after creation.
if (isCosmosNotFound(err) && attempt < 2) { if (isCosmosNotFound(err) && attempt < 2) {

View File

@ -5,4 +5,4 @@ export {
initializeAllContainers, initializeAllContainers,
_resetRegistry, _resetRegistry,
} from './containers.js'; } from './containers.js';
export type { ContainerConfig, CompositeIndexPath } from './types.js'; export type { ContainerConfig } from './types.js';

View File

@ -1,19 +1,4 @@
/** A single path within a composite index, with its sort order. */
export interface CompositeIndexPath {
path: string;
order?: 'ascending' | 'descending';
}
export interface ContainerConfig { export interface ContainerConfig {
partitionKeyPath: string; partitionKeyPath: string;
defaultTtl?: number | null; defaultTtl?: number | null;
/**
* Composite indexes for multi-field ORDER BY queries. Azure Cosmos cannot
* serve a query that orders by two or more fields without a matching
* composite index (the local emulator is lenient, real Cosmos is not).
* Each entry is an ordered list of paths, e.g.
* `[[{ path: '/priorityOrder' }, { path: '/createdAt' }]]`.
* Applied on container creation and reconciled onto existing containers.
*/
compositeIndexes?: CompositeIndexPath[][];
} }

View File

@ -11,9 +11,7 @@
}, },
"main": "./dist/index.js", "main": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"files": [ "files": ["dist"],
"dist"
],
"scripts": { "scripts": {
"build": "tsc", "build": "tsc",
"test": "vitest run --pool forks", "test": "vitest run --pool forks",
@ -27,7 +25,7 @@
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"happy-dom": "^20.9.0", "happy-dom": "^18.0.1",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"typescript": "^5.7.3", "typescript": "^5.7.3",

View File

@ -27,7 +27,7 @@
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"happy-dom": "^20.9.0", "happy-dom": "^18.0.1",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"typescript": "^5.7.3", "typescript": "^5.7.3",

View File

@ -27,7 +27,7 @@
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"happy-dom": "^20.9.0", "happy-dom": "^18.0.1",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"typescript": "^5.7.3", "typescript": "^5.7.3",

View File

@ -32,7 +32,7 @@
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"happy-dom": "^20.9.0", "happy-dom": "^18.0.1",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"typescript": "^5.7.3", "typescript": "^5.7.3",

View File

@ -11,9 +11,7 @@
}, },
"main": "./dist/index.js", "main": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"files": [ "files": ["dist"],
"dist"
],
"scripts": { "scripts": {
"build": "tsc", "build": "tsc",
"test": "vitest run --pool forks", "test": "vitest run --pool forks",
@ -27,7 +25,7 @@
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"happy-dom": "^20.9.0", "happy-dom": "^18.0.1",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"typescript": "^5.7.3", "typescript": "^5.7.3",

View File

@ -25,7 +25,7 @@
"zod": "^3.22.0" "zod": "^3.22.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^25.9.1", "@types/node": "^22.0.0",
"typescript": "^5.7.0", "typescript": "^5.7.0",
"vitest": "^3.0.0" "vitest": "^3.0.0"
} }

View File

@ -21,7 +21,7 @@
"@bytelyst/queue": "workspace:*" "@bytelyst/queue": "workspace:*"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^25.9.1", "@types/node": "^22.12.0",
"vitest": "^3.0.5" "vitest": "^3.0.5"
}, },
"peerDependencies": { "peerDependencies": {

View File

@ -26,7 +26,7 @@
}, },
"devDependencies": { "devDependencies": {
"fastify": "^5.2.1", "fastify": "^5.2.1",
"jose": "^6.2.3", "jose": "^6.0.8",
"typescript": "^5.7.3", "typescript": "^5.7.3",
"vitest": "^3.0.5" "vitest": "^3.0.5"
}, },

View File

@ -11,9 +11,7 @@
}, },
"main": "./dist/index.js", "main": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"files": [ "files": ["dist"],
"dist"
],
"scripts": { "scripts": {
"build": "tsc", "build": "tsc",
"test": "vitest run --pool forks", "test": "vitest run --pool forks",
@ -27,7 +25,7 @@
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"happy-dom": "^20.9.0", "happy-dom": "^18.0.1",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"typescript": "^5.7.3", "typescript": "^5.7.3",

View File

@ -11,9 +11,7 @@
}, },
"main": "./dist/index.js", "main": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"files": [ "files": ["dist"],
"dist"
],
"scripts": { "scripts": {
"build": "tsc", "build": "tsc",
"test": "vitest run --pool forks", "test": "vitest run --pool forks",
@ -27,7 +25,7 @@
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"happy-dom": "^20.9.0", "happy-dom": "^18.0.1",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"typescript": "^5.7.3", "typescript": "^5.7.3",

View File

@ -11,9 +11,7 @@
}, },
"main": "./dist/index.js", "main": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"files": [ "files": ["dist"],
"dist"
],
"scripts": { "scripts": {
"build": "tsc", "build": "tsc",
"test": "vitest run --pool forks", "test": "vitest run --pool forks",
@ -27,7 +25,7 @@
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"happy-dom": "^20.9.0", "happy-dom": "^18.0.1",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"typescript": "^5.7.3", "typescript": "^5.7.3",

View File

@ -11,9 +11,7 @@
}, },
"main": "./dist/index.js", "main": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"files": [ "files": ["dist"],
"dist"
],
"scripts": { "scripts": {
"build": "tsc", "build": "tsc",
"test": "vitest run --pool forks", "test": "vitest run --pool forks",
@ -27,7 +25,7 @@
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"happy-dom": "^20.9.0", "happy-dom": "^18.0.1",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"typescript": "^5.7.3", "typescript": "^5.7.3",

View File

@ -19,7 +19,7 @@
"test": "vitest run --pool forks" "test": "vitest run --pool forks"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^25.9.1", "@types/node": "^22.12.0",
"vitest": "^3.0.5" "vitest": "^3.0.5"
}, },
"peerDependencies": { "peerDependencies": {

View File

@ -19,7 +19,7 @@
"test": "vitest run --pool forks" "test": "vitest run --pool forks"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^25.9.1", "@types/node": "^22.12.0",
"vitest": "^3.0.5" "vitest": "^3.0.5"
} }
} }

View File

@ -27,7 +27,7 @@
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"happy-dom": "^20.9.0", "happy-dom": "^18.0.1",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4" "react-dom": "^19.2.4"
} }

View File

@ -39,7 +39,7 @@
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"happy-dom": "^20.9.0", "happy-dom": "^18.0.1",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"typescript": "^5.7.3", "typescript": "^5.7.3",

View File

@ -21,7 +21,7 @@
"@bytelyst/telemetry-client": "workspace:*" "@bytelyst/telemetry-client": "workspace:*"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^25.9.1", "@types/node": "^22.12.0",
"typescript": "^5.7.3", "typescript": "^5.7.3",
"vitest": "^3.0.5" "vitest": "^3.0.5"
}, },

View File

@ -224,7 +224,7 @@
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.0.0",
"happy-dom": "^20.9.0", "happy-dom": "^18.0.1",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"storybook": "^8.5.0", "storybook": "^8.5.0",

View File

@ -25,7 +25,7 @@
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"happy-dom": "^20.9.0", "happy-dom": "^18.0.1",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"vitest": "^3.0.0" "vitest": "^3.0.0"

View File

@ -25,7 +25,7 @@
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"happy-dom": "^20.9.0", "happy-dom": "^18.0.1",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"vitest": "^3.0.0" "vitest": "^3.0.0"

1568
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -1,194 +0,0 @@
#Requires -Version 5.1
<#
.SYNOPSIS
Cosmos DB Cost / RU-consumption report.
.DESCRIPTION
Identifies which database (product) and which container drives the most
Azure Cosmos DB cost. For SERVERLESS accounts cost is dominated by Request
Units (RU) consumed; for PROVISIONED accounts it is the provisioned
throughput. The billing mode is auto-detected.
Reports:
1. Account billing mode (serverless vs provisioned) + region
2. Actual billed cost (Cost Management, last 30d) by service
3. RU consumption by database, ranked, with est. monthly $
4. RU consumption by container for the top databases
5. Storage (DataUsage) by database
.PARAMETER Account
Cosmos account name. Falls back to $env:COSMOS_ACCOUNT_NAME.
.PARAMETER ResourceGroup
Resource group. Falls back to $env:COSMOS_RESOURCE_GROUP.
.PARAMETER Days
Lookback window (days) for RU metrics. Default 7 (or $env:DAYS).
.PARAMETER Top
Rows per table. Default 15 (or $env:TOP).
.PARAMETER Drill
Number of top databases to drill into by container. Default 3.
.PARAMETER Rate
USD per 1M RU (serverless). Default 0.25 (or $env:SERVERLESS_RU_RATE).
.EXAMPLE
$env:COSMOS_ACCOUNT_NAME='cosmos-mywisprai'; $env:COSMOS_RESOURCE_GROUP='rg-mywisprai'
./scripts/cosmos-cost-report.ps1
.EXAMPLE
./scripts/cosmos-cost-report.ps1 -Account cosmos-mywisprai -ResourceGroup rg-mywisprai -Days 7
.NOTES
Prerequisites: Azure CLI installed and authenticated (az login).
#>
[CmdletBinding()]
param(
[string]$Account = $env:COSMOS_ACCOUNT_NAME,
[string]$ResourceGroup = $env:COSMOS_RESOURCE_GROUP,
[int] $Days = $(if ($env:DAYS) { [int]$env:DAYS } else { 7 }),
[int] $Top = $(if ($env:TOP) { [int]$env:TOP } else { 15 }),
[int] $Drill = 3,
[double]$Rate = $(if ($env:SERVERLESS_RU_RATE) { [double]$env:SERVERLESS_RU_RATE } else { 0.25 })
)
$ErrorActionPreference = 'Stop'
if (-not $Account) { Write-Error 'Set COSMOS_ACCOUNT_NAME or pass -Account'; exit 2 }
if (-not $ResourceGroup) { Write-Error 'Set COSMOS_RESOURCE_GROUP or pass -ResourceGroup'; exit 2 }
if (-not (Get-Command az -ErrorAction SilentlyContinue)) { Write-Error 'az CLI not found'; exit 2 }
$start = (Get-Date).ToUniversalTime().AddDays(-$Days).ToString('yyyy-MM-ddTHH:mm:ssZ')
$end = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
Write-Host '════════════════════════════════════════════════════════════════'
Write-Host ' Cosmos DB Cost Report'
Write-Host " Account: $Account Resource group: $ResourceGroup"
Write-Host " RU window: last ${Days}d ($start -> $end)"
Write-Host '════════════════════════════════════════════════════════════════'
$rid = az cosmosdb show -n $Account -g $ResourceGroup --query id -o tsv
$caps = az cosmosdb show -n $Account -g $ResourceGroup --query "capabilities[].name" -o tsv
$region = az cosmosdb show -n $Account -g $ResourceGroup --query "locations[0].locationName" -o tsv
$mode = if ($caps -match 'EnableServerless') { 'SERVERLESS' } else { 'PROVISIONED' }
Write-Host ''
Write-Host "▶ Billing mode : $mode"
Write-Host "▶ Region : $region"
Write-Host ("▶ Est. RU rate : `$$Rate per 1M RU (serverless)")
# ── 1. Actual billed cost (Cost Management, last 30d) ─────────────
Write-Host ''
Write-Host '── Actual billed cost — last 30d by service (resource group) ──'
$sub = az account show --query id -o tsv
$cmFrom = (Get-Date).ToUniversalTime().AddDays(-30).ToString('yyyy-MM-ddT00:00:00Z')
$cmTo = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddT00:00:00Z')
$cmBody = @{
type = 'ActualCost'
timeframe = 'Custom'
timePeriod = @{ from = $cmFrom; to = $cmTo }
dataset = @{
granularity = 'None'
aggregation = @{ totalCost = @{ name = 'Cost'; function = 'Sum' } }
grouping = @(@{ type = 'Dimension'; name = 'ServiceName' })
}
} | ConvertTo-Json -Depth 10 -Compress
$tmp = New-TemporaryFile
Set-Content -Path $tmp -Value $cmBody -NoNewline
try {
$rows = az rest --method post `
--url "https://management.azure.com/subscriptions/$sub/resourceGroups/$ResourceGroup/providers/Microsoft.CostManagement/query?api-version=2023-11-01" `
--body "@$tmp" --headers "Content-Type=application/json" `
--query "properties.rows" -o json 2>$null | ConvertFrom-Json
if ($rows) {
$rows | Sort-Object { [double]$_[0] } -Descending | ForEach-Object {
' {0,-28} ${1,8:N2} {2}' -f ([string]$_[1]).Substring(0,[Math]::Min(28,([string]$_[1]).Length)), [double]$_[0], $_[2]
}
} else {
Write-Host ' (no cost data — needs Cost Management reader on the subscription)'
}
} catch {
Write-Host ' (cost query unavailable — continuing with RU metrics)'
} finally {
Remove-Item $tmp -ErrorAction SilentlyContinue
}
# Helper: query TotalRequestUnits split by a dimension, return [pscustomobject]@{ k; ru }
function Get-RuByDimension {
param([string]$Filter)
$json = az monitor metrics list --resource $rid --metric TotalRequestUnits `
--aggregation Total --interval P1D --start-time $start --end-time $end `
--filter $Filter --top 500 `
--query "value[0].timeseries[].{k: metadatavalues[0].value, ru: sum(data[].total)}" `
-o json 2>$null
if (-not $json) { return @() }
$parsed = $json | ConvertFrom-Json
if (-not $parsed) { return @() }
,@($parsed | ForEach-Object {
[pscustomobject]@{ k = $(if ($_.k) { $_.k } else { '<none>' }); ru = [double]($_.ru) }
})
}
function Show-RuTable {
param([object[]]$Rows)
$ranked = @($Rows | Sort-Object ru -Descending)
$total = ($ranked | Measure-Object ru -Sum).Sum
if (-not $total -or $total -eq 0) { $total = 1.0 }
' {0,-28} {1,16} {2,8} {3,10}' -f 'name', "RU (${Days}d)", 'share', 'est $/mo' | Write-Host
' ' + ('-' * 68) | Write-Host
foreach ($r in ($ranked | Select-Object -First $Top)) {
$est = $r.ru / $Days * 30.0 / 1000000.0 * $Rate
$share = 100.0 * $r.ru / $total
$name = ([string]$r.k).Substring(0,[Math]::Min(28,([string]$r.k).Length))
' {0,-28} {1,16} {2,7:N1}% {3,9:N2}' -f $name, ('{0:N0}' -f $r.ru), $share, $est | Write-Host
}
$proj = $total / $Days * 30.0 / 1000000.0 * $Rate
' ' + ('-' * 68) | Write-Host
' {0,-28} {1,16} {2,8} {3,9:N2}' -f 'TOTAL', ('{0:N0}' -f $total), '', $proj | Write-Host
}
# ── 2. RU by database ─────────────────────────────────────────────
Write-Host ''
Write-Host "── RU consumption by database (product) — last ${Days}d ──"
$dbRows = Get-RuByDimension -Filter "DatabaseName eq '*'"
Show-RuTable -Rows $dbRows
# ── 3. Drill into the top databases by container ──────────────────
$topDbs = @($dbRows | Where-Object { $_.ru -gt 0 -and $_.k -ne '<none>' } |
Sort-Object ru -Descending | Select-Object -First $Drill -ExpandProperty k)
foreach ($db in $topDbs) {
Write-Host ''
Write-Host "── RU by container in '$db' — last ${Days}d ──"
$coll = Get-RuByDimension -Filter "DatabaseName eq '$db' and CollectionName eq '*'"
Show-RuTable -Rows $coll
}
# ── 4. Storage by database ────────────────────────────────────────
Write-Host ''
Write-Host '── Storage (DataUsage) by database — latest snapshot ──'
$storeJson = az monitor metrics list --resource $rid --metric DataUsage `
--aggregation Total --interval PT1H --start-time $start --end-time $end `
--filter "DatabaseName eq '*'" --top 200 `
--query "value[0].timeseries[].{k: metadatavalues[0].value, b: max(data[].total)}" `
-o json 2>$null
$store = if ($storeJson) { @($storeJson | ConvertFrom-Json) } else { @() }
if ($store.Count -eq 0) {
Write-Host ' (no storage data)'
} else {
foreach ($s in ($store | Sort-Object { [double]$_.b } -Descending)) {
$name = ([string]$s.k).Substring(0,[Math]::Min(28,([string]$s.k).Length))
' {0,-28} {1,10:N2} MB' -f $name, ([double]$s.b / 1MB) | Write-Host
}
}
Write-Host ''
Write-Host '════════════════════════════════════════════════════════════════'
Write-Host ' Notes:'
Write-Host " - Serverless cost ~= RU consumed x `$$Rate/1M + storage(`$~0.25/GB-mo)."
Write-Host " - 'est `$/mo' linearly projects the ${Days}d window to 30 days."
Write-Host ' - High RU + low request count => expensive per-op (cross-partition'
Write-Host ' queries / large docs) — prime rightsizing target.'
Write-Host ' - A *_locks container burning RU is usually lock-polling overhead.'
Write-Host '════════════════════════════════════════════════════════════════'

View File

@ -1,200 +0,0 @@
#!/usr/bin/env bash
# ──────────────────────────────────────────────────────────────────
# Cosmos DB — Cost / RU-consumption report
#
# Identifies which database (product) and which container drives the
# most Azure Cosmos DB cost. For SERVERLESS accounts cost is dominated
# by Request Units (RU) consumed; for PROVISIONED accounts it is the
# provisioned throughput. The script auto-detects the billing mode.
#
# What it reports:
# 1. Account billing mode (serverless vs provisioned) + region
# 2. Actual billed cost (Cost Management, last 30d) by service
# 3. RU consumption by database, ranked, with est. monthly $
# 4. RU consumption by container for the top databases
# 5. Storage (DataUsage) by database
#
# Prerequisites:
# - Azure CLI installed and authenticated (az login)
# - python3 on PATH (used for JSON shaping + date math)
# - COSMOS_ACCOUNT_NAME and COSMOS_RESOURCE_GROUP set (or pass flags)
#
# Usage:
# COSMOS_ACCOUNT_NAME=cosmos-mywisprai COSMOS_RESOURCE_GROUP=rg-mywisprai \
# ./scripts/cosmos-cost-report.sh
# ./scripts/cosmos-cost-report.sh -a cosmos-mywisprai -g rg-mywisprai -d 7
#
# Flags (override env):
# -a, --account Cosmos account name (COSMOS_ACCOUNT_NAME)
# -g, --resource-group Resource group (COSMOS_RESOURCE_GROUP)
# -d, --days Lookback window for RU (DAYS, default 7)
# -t, --top Rows per table (TOP, default 15)
# --drill #DBs to drill into (DRILL, default 3)
# --rate USD per 1M RU (serverless) (SERVERLESS_RU_RATE, 0.25)
# ──────────────────────────────────────────────────────────────────
set -euo pipefail
ACCOUNT="${COSMOS_ACCOUNT_NAME:-}"
RG="${COSMOS_RESOURCE_GROUP:-}"
DAYS="${DAYS:-7}"
TOP="${TOP:-15}"
DRILL="${DRILL:-3}"
RU_RATE="${SERVERLESS_RU_RATE:-0.25}"
while [[ $# -gt 0 ]]; do
case "$1" in
-a|--account) ACCOUNT="$2"; shift 2 ;;
-g|--resource-group) RG="$2"; shift 2 ;;
-d|--days) DAYS="$2"; shift 2 ;;
-t|--top) TOP="$2"; shift 2 ;;
--drill) DRILL="$2"; shift 2 ;;
--rate) RU_RATE="$2"; shift 2 ;;
-h|--help) sed -n '2,46p' "$0"; exit 0 ;;
*) echo "Unknown arg: $1" >&2; exit 2 ;;
esac
done
[[ -z "$ACCOUNT" ]] && { echo "ERROR: set COSMOS_ACCOUNT_NAME or pass -a" >&2; exit 2; }
[[ -z "$RG" ]] && { echo "ERROR: set COSMOS_RESOURCE_GROUP or pass -g" >&2; exit 2; }
command -v az >/dev/null || { echo "ERROR: az CLI not found" >&2; exit 2; }
command -v python3 >/dev/null || { echo "ERROR: python3 not found" >&2; exit 2; }
START="$(python3 -c 'import datetime,sys;print((datetime.datetime.utcnow()-datetime.timedelta(days=int(sys.argv[1]))).strftime("%Y-%m-%dT%H:%M:%SZ"))' "$DAYS")"
END="$(python3 -c 'import datetime;print(datetime.datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ"))')"
echo "════════════════════════════════════════════════════════════════"
echo " Cosmos DB Cost Report"
echo " Account: $ACCOUNT Resource group: $RG"
echo " RU window: last ${DAYS}d ($START$END)"
echo "════════════════════════════════════════════════════════════════"
RID="$(az cosmosdb show -n "$ACCOUNT" -g "$RG" --query id -o tsv)"
CAPS="$(az cosmosdb show -n "$ACCOUNT" -g "$RG" --query "capabilities[].name" -o tsv | tr '\n' ',' || true)"
REGION="$(az cosmosdb show -n "$ACCOUNT" -g "$RG" --query "locations[0].locationName" -o tsv)"
if echo "$CAPS" | grep -q "EnableServerless"; then
MODE="SERVERLESS"
else
MODE="PROVISIONED"
fi
echo ""
echo "▶ Billing mode : $MODE"
echo "▶ Region : $REGION"
echo "▶ Est. RU rate : \$$RU_RATE per 1M RU (serverless)"
# ── 1. Actual billed cost (Cost Management, last 30d) ─────────────
echo ""
echo "── Actual billed cost — last 30d by service (resource group) ──"
SUB="$(az account show --query id -o tsv)"
CM_FROM="$(python3 -c 'import datetime;print((datetime.datetime.utcnow()-datetime.timedelta(days=30)).strftime("%Y-%m-%dT00:00:00Z"))')"
CM_TO="$(python3 -c 'import datetime;print(datetime.datetime.utcnow().strftime("%Y-%m-%dT00:00:00Z"))')"
CM_BODY="$(mktemp)"
cat >"$CM_BODY" <<JSON
{"type":"ActualCost","timeframe":"Custom","timePeriod":{"from":"$CM_FROM","to":"$CM_TO"},
"dataset":{"granularity":"None","aggregation":{"totalCost":{"name":"Cost","function":"Sum"}},
"grouping":[{"type":"Dimension","name":"ServiceName"}]}}
JSON
if az rest --method post \
--url "https://management.azure.com/subscriptions/$SUB/resourceGroups/$RG/providers/Microsoft.CostManagement/query?api-version=2023-11-01" \
--body "@$CM_BODY" --headers "Content-Type=application/json" \
--query "properties.rows" -o json 2>/dev/null | \
python3 -c '
import json,sys
try: rows=json.load(sys.stdin) or []
except Exception: rows=[]
rows=[r for r in rows if isinstance(r,list) and len(r)>=2]
rows.sort(key=lambda r: float(r[0]), reverse=True)
if not rows:
print(" (no cost data — needs Cost Management reader on the subscription)")
for r in rows:
print(" %-28s $%8.2f %s" % (str(r[1])[:28], float(r[0]), r[2] if len(r)>2 else ""))
'; then :; else
echo " (cost query unavailable — continuing with RU metrics)"
fi
rm -f "$CM_BODY"
# Helper: query a TotalRequestUnits metric split by a dimension and emit
# "<name>\t<total_ru>" lines, ranked desc, with est monthly $.
ru_table() {
local filter="$1" dimidx="$2"
az monitor metrics list --resource "$RID" --metric TotalRequestUnits \
--aggregation Total --interval P1D --start-time "$START" --end-time "$END" \
--filter "$filter" --top 500 \
--query "value[0].timeseries[].{k: metadatavalues[$dimidx].value, ru: sum(data[].total)}" \
-o json 2>/dev/null
}
render_ru() {
python3 -c '
import json,sys
days=float(sys.argv[1]); rate=float(sys.argv[2]); top=int(sys.argv[3])
try: items=json.load(sys.stdin) or []
except Exception: items=[]
rows=[]
for it in items:
ru=it.get("ru") or 0
rows.append((it.get("k") or "<none>", float(ru)))
rows.sort(key=lambda x:x[1], reverse=True)
tot=sum(r[1] for r in rows) or 1.0
print(" %-28s %16s %8s %10s" % ("name","RU ("+str(int(days))+"d)","share","est $/mo"))
print(" "+"-"*68)
for name,ru in rows[:top]:
est=ru/days*30.0/1_000_000.0*rate
print(" %-28s %16s %7.1f%% %9.2f" % (name[:28], "{:,.0f}".format(ru), 100*ru/tot, est))
proj=tot/days*30.0/1_000_000.0*rate
print(" "+"-"*68)
print(" %-28s %16s %8s %9.2f" % ("TOTAL", "{:,.0f}".format(tot), "", proj))
' "$DAYS" "$RU_RATE" "$TOP"
}
# ── 2. RU by database ─────────────────────────────────────────────
echo ""
echo "── RU consumption by database (product) — last ${DAYS}d ──"
DB_JSON="$(ru_table "DatabaseName eq '*'" 0)"
echo "$DB_JSON" | render_ru
# ── 3. Drill into the top databases by container ──────────────────
TOP_DBS="$(echo "$DB_JSON" | python3 -c '
import json,sys
n=int(sys.argv[1])
try: items=json.load(sys.stdin) or []
except Exception: items=[]
items=[(i.get("k") or "", float(i.get("ru") or 0)) for i in items]
items.sort(key=lambda x:x[1], reverse=True)
for k,ru in items[:n]:
if k and ru>0: print(k)
' "$DRILL")"
for DB in $TOP_DBS; do
echo ""
echo "── RU by container in '$DB' — last ${DAYS}d ──"
ru_table "DatabaseName eq '$DB' and CollectionName eq '*'" 0 | render_ru
done
# ── 4. Storage by database ────────────────────────────────────────
echo ""
echo "── Storage (DataUsage) by database — latest snapshot ──"
az monitor metrics list --resource "$RID" --metric DataUsage \
--aggregation Total --interval PT1H --start-time "$START" --end-time "$END" \
--filter "DatabaseName eq '*'" --top 200 \
--query "value[0].timeseries[].{k: metadatavalues[0].value, b: max(data[].total)}" \
-o json 2>/dev/null | python3 -c '
import json,sys
try: items=json.load(sys.stdin) or []
except Exception: items=[]
rows=[(i.get("k") or "<none>", float(i.get("b") or 0)) for i in items]
rows.sort(key=lambda x:x[1], reverse=True)
for name,b in rows:
print(" %-28s %10.2f MB" % (name[:28], b/1024/1024))
if not rows: print(" (no storage data)")
'
echo ""
echo "════════════════════════════════════════════════════════════════"
echo " Notes:"
echo " - Serverless cost ≈ RU consumed × \$$RU_RATE/1M + storage(\$~0.25/GB-mo)."
echo " - 'est \$/mo' linearly projects the ${DAYS}d window to 30 days."
echo " - High RU + low request count ⇒ expensive per-op (cross-partition"
echo " queries / large docs) — prime rightsizing target."
echo " - A *_locks container burning RU is usually lock-polling overhead."
echo "════════════════════════════════════════════════════════════════"

View File

@ -1,141 +0,0 @@
#!/usr/bin/env bash
#
# fleet-logs.sh — quickly inspect a fleet factory job's logs (agent-queue).
#
# The factory (learning_ai_devops_tools/agent-queue) writes two artifacts per job
# under queue/logs/:
# • <base>.log — sparse runner lifecycle (launch, PR open, verify, errors)
# • <base>.devin-export.json — the live Devin transcript (steps[]: source + message)
# where <base> looks like 20260602-003202__fleet-<jobId>.
#
# Usage:
# scripts/fleet-logs.sh [command] [job]
#
# Commands:
# status [job] steps count + slot (building/review/...) + latest step (default)
# tail [job] follow the runner .log (-f)
# steps [job] [N] last N transcript steps (default 12)
# watch [job] [N] live-refresh the last N steps every few seconds
# full [job] all agent messages through your pager ($PAGER/less)
# path [job] print the resolved log file paths
# ls list known jobs (newest first) with slot + step count
#
# `job` is a full or PARTIAL job id (e.g. `3c0586ce`). Omit it to use the most
# recent job. Override the factory location with AQ=/path/to/agent-queue.
#
set -euo pipefail
# ── Locate the agent-queue checkout ───────────────────────────────────────────
resolve_aq() {
if [[ -n "${AQ:-}" ]]; then printf '%s' "$AQ"; return; fi
local here parent
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
parent="$(cd "$here/../.." 2>/dev/null && pwd || true)" # …/<code root>
for cand in \
"$parent/learning_ai_devops_tools/agent-queue" \
"$HOME/code/mygh/learning_ai_devops_tools/agent-queue" \
"$HOME/code/learning_ai_devops_tools/agent-queue"; do
[[ -d "$cand/queue/logs" ]] && { printf '%s' "$cand"; return; }
done
return 1
}
AQ_DIR="$(resolve_aq || true)"
if [[ -z "$AQ_DIR" || ! -d "$AQ_DIR/queue/logs" ]]; then
echo "error: agent-queue logs dir not found. Set AQ=/path/to/agent-queue." >&2
exit 1
fi
LOGDIR="$AQ_DIR/queue/logs"
QUEUE="$AQ_DIR/queue"
have_jq() { command -v jq >/dev/null 2>&1; }
need_jq() { have_jq || { echo "error: 'jq' is required for this command (brew install jq)." >&2; exit 1; }; }
# ── Resolve a job's export file by (optional) substring; default = newest ──────
resolve_export() {
local pat="${1:-}" f
if [[ -z "$pat" ]]; then
f="$(ls -t "$LOGDIR"/*.devin-export.json 2>/dev/null | head -1 || true)"
else
f="$(ls -t "$LOGDIR"/*"$pat"*.devin-export.json 2>/dev/null | head -1 || true)"
fi
[[ -n "$f" ]] || { echo "error: no transcript matching '${pat:-<latest>}' in $LOGDIR" >&2; return 1; }
printf '%s' "$f"
}
base_of() { basename "$1" .devin-export.json; } # 20260602-…__fleet-<jobId>
runner_log() { printf '%s/%s.log' "$LOGDIR" "$(base_of "$1")"; }
slot_of() {
local base="$1" d
for d in building review testing shipped failed inbox; do
[[ -e "$QUEUE/$d/$base.md" ]] && { printf '%s' "$d"; return; }
done
printf 'unknown'
}
steps_count() { have_jq && jq '.steps|length' "$1" 2>/dev/null || echo '?'; }
print_steps() {
local exp="$1" n="${2:-12}"
need_jq
jq -r --argjson n "$n" '.steps[-$n:][] | "[\(.source)] \(.message[0:300])"' "$exp" 2>/dev/null \
|| echo "(transcript mid-write — re-run)"
}
cmd="${1:-status}"; shift || true
case "$cmd" in
ls)
printf '%-40s %-10s %-7s %s\n' "JOB (base)" "SLOT" "STEPS" "FILE"
ls -t "$LOGDIR"/*.devin-export.json 2>/dev/null | while read -r f; do
b="$(base_of "$f")"
printf '%-40s %-10s %-7s %s\n' "$b" "$(slot_of "$b")" "$(steps_count "$f")" "$f"
done
;;
path)
exp="$(resolve_export "${1:-}")"
echo "transcript: $exp"
echo "runner log: $(runner_log "$exp")"
;;
status)
exp="$(resolve_export "${1:-}")"; base="$(base_of "$exp")"
echo "job: $base"
echo "slot: $(slot_of "$base")"
echo "steps: $(steps_count "$exp")"
echo "transcript: $exp"
echo "--- latest step ---"
print_steps "$exp" 3
;;
tail)
exp="$(resolve_export "${1:-}")"; log="$(runner_log "$exp")"
echo "==> tail -f $log (Ctrl-C to stop)"
tail -n +1 -f "$log"
;;
steps)
exp="$(resolve_export "${1:-}")"
print_steps "$exp" "${2:-12}"
;;
watch)
exp="$(resolve_export "${1:-}")"; n="${2:-8}"
echo "==> live last $n steps of $(base_of "$exp") (Ctrl-C to stop)"
while true; do
clear 2>/dev/null || true
printf '== %s | slot=%s | steps=%s | %s ==\n\n' \
"$(base_of "$exp")" "$(slot_of "$(base_of "$exp")")" "$(steps_count "$exp")" "$(date +%T)"
print_steps "$exp" "$n"
sleep 3
done
;;
full)
exp="$(resolve_export "${1:-}")"; need_jq
jq -r '.steps[] | "== \(.source) ==\n\(.message)\n"' "$exp" | "${PAGER:-less}"
;;
-h|--help|help)
sed -n '2,33p' "$0" | sed 's/^# \{0,1\}//'
;;
*)
echo "unknown command '$cmd' (try: status|tail|steps|watch|full|path|ls; -h for help)" >&2
exit 1
;;
esac

View File

@ -5,11 +5,11 @@
"type": "module", "type": "module",
"description": "DevOps scripts for the ByteLyst ecosystem (migration, audit, maintenance)", "description": "DevOps scripts for the ByteLyst ecosystem (migration, audit, maintenance)",
"dependencies": { "dependencies": {
"@azure/cosmos": "^4.9.3", "@azure/cosmos": "^4.2.0",
"@bytelyst/field-encrypt": "workspace:*" "@bytelyst/field-encrypt": "workspace:*"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^25.9.1", "@types/node": "^22.12.0",
"tsx": "^4.19.2", "tsx": "^4.19.2",
"typescript": "^5.7.3" "typescript": "^5.7.3"
} }

View File

@ -18,21 +18,21 @@
"@bytelyst/backend-flags": "workspace:*", "@bytelyst/backend-flags": "workspace:*",
"@bytelyst/backend-telemetry": "workspace:*", "@bytelyst/backend-telemetry": "workspace:*",
"@bytelyst/config": "workspace:*", "@bytelyst/config": "workspace:*",
"@bytelyst/errors": "workspace:*",
"@bytelyst/events": "workspace:*", "@bytelyst/events": "workspace:*",
"@bytelyst/errors": "workspace:*",
"@bytelyst/fastify-auth": "workspace:*", "@bytelyst/fastify-auth": "workspace:*",
"@bytelyst/fastify-core": "workspace:*", "@bytelyst/fastify-core": "workspace:*",
"@bytelyst/llm-router": "workspace:*", "@bytelyst/llm-router": "workspace:*",
"@bytelyst/logger": "workspace:*", "@bytelyst/logger": "workspace:*",
"@bytelyst/push": "workspace:*", "@bytelyst/push": "workspace:*",
"@fastify/cors": "^11.2.0", "@fastify/cors": "^10.0.2",
"fastify": "^5.2.1", "fastify": "^5.2.1",
"jose": "^6.2.3", "jose": "^6.0.11",
"zod": "^3.24.2" "zod": "^3.24.2"
}, },
"devDependencies": { "devDependencies": {
"@bytelyst/testing": "workspace:*", "@bytelyst/testing": "workspace:*",
"@types/node": "^25.9.1", "@types/node": "^22.12.0",
"tsx": "^4.19.2", "tsx": "^4.19.2",
"typescript": "^5.7.3", "typescript": "^5.7.3",
"vitest": "^3.0.5" "vitest": "^3.0.5"

View File

@ -19,25 +19,25 @@
"eval:compare": "GEMINI_OUT=evals/.results-gemini.json OLLAMA_OUT=evals/.results-ollama.json bash evals/compare-evals.sh" "eval:compare": "GEMINI_OUT=evals/.results-gemini.json OLLAMA_OUT=evals/.results-ollama.json bash evals/compare-evals.sh"
}, },
"dependencies": { "dependencies": {
"@azure/cosmos": "^4.9.3",
"@bytelyst/auth": "workspace:*", "@bytelyst/auth": "workspace:*",
"@bytelyst/config": "workspace:*", "@bytelyst/config": "workspace:*",
"@bytelyst/cosmos": "workspace:*", "@bytelyst/cosmos": "workspace:*",
"@bytelyst/errors": "workspace:*", "@bytelyst/errors": "workspace:*",
"@bytelyst/fastify-core": "workspace:*", "@bytelyst/fastify-core": "workspace:*",
"@bytelyst/queue": "workspace:*", "@bytelyst/queue": "workspace:*",
"@fastify/cors": "^11.2.0", "@azure/cosmos": "^4.2.0",
"@fastify/cors": "^10.0.2",
"@fastify/rate-limit": "^10.3.0", "@fastify/rate-limit": "^10.3.0",
"@fastify/swagger": "^9.4.2", "@fastify/swagger": "^9.4.2",
"@fastify/swagger-ui": "^5.2.5", "@fastify/swagger-ui": "^5.2.5",
"fastify": "^5.2.1", "fastify": "^5.2.1",
"fastify-metrics": "^10.3.0", "fastify-metrics": "^10.3.0",
"jose": "^6.2.3", "jose": "^6.0.8",
"redis": "^4.7.0", "redis": "^4.7.0",
"zod": "^3.24.2" "zod": "^3.24.2"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^25.9.1", "@types/node": "^22.12.0",
"tsx": "^4.19.2", "tsx": "^4.19.2",
"typescript": "^5.7.3", "typescript": "^5.7.3",
"vitest": "^3.0.5" "vitest": "^3.0.5"

View File

@ -16,13 +16,13 @@
"@bytelyst/config": "workspace:*", "@bytelyst/config": "workspace:*",
"@bytelyst/errors": "workspace:*", "@bytelyst/errors": "workspace:*",
"@bytelyst/fastify-core": "workspace:*", "@bytelyst/fastify-core": "workspace:*",
"@fastify/cors": "^11.2.0", "@fastify/cors": "^10.0.2",
"fastify": "^5.2.1", "fastify": "^5.2.1",
"jose": "^5.10.0", "jose": "^5.9.6",
"zod": "^3.24.2" "zod": "^3.24.2"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^25.9.1", "@types/node": "^22.12.0",
"tsx": "^4.19.2", "tsx": "^4.19.2",
"typescript": "^5.7.3", "typescript": "^5.7.3",
"vitest": "^3.0.5" "vitest": "^3.0.5"

View File

@ -1,234 +0,0 @@
{
"annotations": { "list": [] },
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 1,
"id": null,
"links": [],
"title": "Fleet Overview",
"tags": ["fleet", "gigafactory"],
"uid": "fleet-overview",
"schemaVersion": 38,
"version": 2,
"refresh": "30s",
"time": { "from": "now-6h", "to": "now" },
"timepicker": { "refresh_intervals": ["15s", "30s", "1m", "5m", "15m", "1h"] },
"templating": {
"list": [
{
"name": "product",
"type": "query",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"query": "label_values(fleet_queue_depth, product)",
"refresh": 2,
"includeAll": true,
"multi": true,
"current": { "text": "All", "value": "$__all" }
}
]
},
"panels": [
{
"title": "Open circuit breakers",
"type": "stat",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 4, "w": 6, "x": 0, "y": 0 },
"fieldConfig": {
"defaults": {
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "red", "value": 1 }
]
}
}
},
"options": {
"colorMode": "value",
"graphMode": "none",
"reduceOptions": { "calcs": ["lastNotNull"] }
},
"targets": [{ "refId": "A", "expr": "fleet_engine_breaker_open_count" }]
},
{
"title": "Dead-letter jobs",
"type": "stat",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 4, "w": 6, "x": 6, "y": 0 },
"fieldConfig": {
"defaults": {
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 1 }
]
}
}
},
"options": {
"colorMode": "value",
"graphMode": "none",
"reduceOptions": { "calcs": ["lastNotNull"] }
},
"targets": [
{
"refId": "A",
"expr": "sum(fleet_jobs_by_stage{product=~\"$product\",stage=\"dead_letter\"})"
}
]
},
{
"title": "Stale factories",
"type": "stat",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 4, "w": 6, "x": 12, "y": 0 },
"options": {
"colorMode": "value",
"graphMode": "none",
"reduceOptions": { "calcs": ["lastNotNull"] }
},
"targets": [{ "refId": "A", "expr": "sum(fleet_factories_stale{product=~\"$product\"})" }]
},
{
"title": "Active alerts",
"type": "stat",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 4, "w": 6, "x": 18, "y": 0 },
"options": {
"colorMode": "value",
"graphMode": "none",
"reduceOptions": { "calcs": ["lastNotNull"] }
},
"targets": [
{ "refId": "A", "expr": "count(fleet_alert_active{product=~\"$product\"}) or vector(0)" }
]
},
{
"title": "Queue depth",
"type": "timeseries",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 4 },
"fieldConfig": { "defaults": { "custom": { "drawStyle": "line", "fillOpacity": 10 } } },
"targets": [
{
"refId": "A",
"expr": "fleet_queue_depth{product=~\"$product\"}",
"legendFormat": "{{product}}"
}
]
},
{
"title": "Seat utilization %",
"type": "timeseries",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 4 },
"fieldConfig": {
"defaults": { "unit": "percent", "custom": { "drawStyle": "line", "fillOpacity": 10 } }
},
"targets": [
{
"refId": "A",
"expr": "fleet_utilization_pct{product=~\"$product\"}",
"legendFormat": "{{product}}"
}
]
},
{
"title": "Budget: spent vs ceiling (USD)",
"type": "timeseries",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 12 },
"fieldConfig": {
"defaults": { "unit": "currencyUSD", "custom": { "drawStyle": "line", "fillOpacity": 10 } }
},
"targets": [
{
"refId": "A",
"expr": "fleet_budget_spent_usd{product=~\"$product\"}",
"legendFormat": "{{product}} spent"
},
{
"refId": "B",
"expr": "fleet_budget_ceiling_usd{product=~\"$product\"}",
"legendFormat": "{{product}} ceiling"
},
{
"refId": "C",
"expr": "fleet_budget_projected_usd{product=~\"$product\"}",
"legendFormat": "{{product}} projected"
}
]
},
{
"title": "Reaper reclaims (rate/5m)",
"type": "timeseries",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 12 },
"fieldConfig": { "defaults": { "custom": { "drawStyle": "line", "fillOpacity": 10 } } },
"targets": [
{
"refId": "A",
"expr": "rate(fleet_reaper_expired_reclaimed_total[5m])",
"legendFormat": "expired"
},
{
"refId": "B",
"expr": "rate(fleet_reaper_stale_reclaimed_total[5m])",
"legendFormat": "stale"
}
]
},
{
"title": "Autoscale: products recommending scale-out",
"type": "stat",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 4, "w": 6, "x": 0, "y": 20 },
"fieldConfig": {
"defaults": {
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 1 }
]
}
}
},
"options": {
"colorMode": "value",
"graphMode": "none",
"reduceOptions": { "calcs": ["lastNotNull"] }
},
"targets": [
{
"refId": "A",
"expr": "sum(fleet_autoscale_action{product=~\"$product\",action=\"scale_out\"}) or vector(0)"
}
]
},
{
"title": "Autoscale: recommended seat delta",
"type": "timeseries",
"description": "Recommended change in factory seats per product (positive = scale out, negative = scale in). Raw signal, pre-hysteresis.",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 8, "w": 18, "x": 6, "y": 20 },
"fieldConfig": {
"defaults": { "custom": { "drawStyle": "line", "fillOpacity": 10 } }
},
"targets": [
{
"refId": "A",
"expr": "fleet_autoscale_delta{product=~\"$product\"}",
"legendFormat": "{{product}} delta"
},
{
"refId": "B",
"expr": "fleet_autoscale_pressure{product=~\"$product\"}",
"legendFormat": "{{product}} backlog"
}
]
}
]
}

View File

@ -3,7 +3,6 @@ apiVersion: 1
datasources: datasources:
- name: Prometheus - name: Prometheus
type: prometheus type: prometheus
uid: prometheus
access: proxy access: proxy
url: http://prometheus:9090 url: http://prometheus:9090
editable: false editable: false

View File

@ -10,7 +10,7 @@
}, },
"devDependencies": { "devDependencies": {
"@bytelyst/monitoring": "workspace:*", "@bytelyst/monitoring": "workspace:*",
"@types/node": "^25.9.1", "@types/node": "^22.12.0",
"tsx": "^4.19.2", "tsx": "^4.19.2",
"typescript": "^5.7.3" "typescript": "^5.7.3"
} }

View File

@ -8,21 +8,6 @@ scrape_configs:
- targets: - targets:
- prometheus:9090 - prometheus:9090
# Fleet coordinator metrics (queue depth, factory health, reaper, breakers,
# budgets). The endpoint requires a bearer token — set `credentials` below to
# the same value as platform-service's FLEET_METRICS_TOKEN (.env). The default
# here is a non-secret placeholder for the local prototype; change it for any
# shared/remote deployment.
- job_name: platform-service-fleet
metrics_path: /api/fleet/metrics/prom
scheme: http
authorization:
type: Bearer
credentials: changeme-fleet-metrics-token
static_configs:
- targets:
- platform-service:4003
- job_name: node-exporter - job_name: node-exporter
static_configs: static_configs:
- targets: - targets:

View File

@ -0,0 +1,67 @@
{
"platform-events": [
{
"id": "5be64f54-5c39-4f0f-af7c-ac7069121a11",
"queueName": "platform-events",
"type": "user.created",
"payload": {
"event": {
"id": "37c00297-57b4-4024-a946-82a2067f350b",
"type": "user.created",
"payload": {
"userId": "usr_8f2f412f-69f1-4b5f-949c-d40e175b7a93",
"email": "test@chronomind.app",
"plan": "free",
"productId": "chronomind"
},
"timestamp": "2026-05-30T06:45:48.670Z",
"source": "auth/register"
}
},
"status": "succeeded",
"attempts": 1,
"maxAttempts": 3,
"createdAt": "2026-05-30T06:45:48.671Z",
"scheduledAt": "2026-05-30T06:45:48.671Z",
"metadata": {
"source": "auth/register"
},
"idempotencyKey": "37c00297-57b4-4024-a946-82a2067f350b",
"productId": "chronomind",
"startedAt": "2026-05-30T06:45:48.759Z",
"completedAt": "2026-05-30T06:45:51.756Z"
},
{
"id": "7438ea3f-fc58-4d04-b58b-628dec76f1ce",
"queueName": "platform-events",
"type": "user.email_verification_requested",
"payload": {
"event": {
"id": "84156892-12f7-448d-9579-7f759af83bbf",
"type": "user.email_verification_requested",
"payload": {
"userId": "usr_8f2f412f-69f1-4b5f-949c-d40e175b7a93",
"email": "test@chronomind.app",
"verificationToken": "4d96e404-8c86-443c-bb95-cf68b7cc194c",
"displayName": "Test User",
"productId": "chronomind"
},
"timestamp": "2026-05-30T06:45:48.822Z",
"source": "auth/register"
}
},
"status": "succeeded",
"attempts": 1,
"maxAttempts": 3,
"createdAt": "2026-05-30T06:45:48.828Z",
"scheduledAt": "2026-05-30T06:45:48.828Z",
"metadata": {
"source": "auth/register"
},
"idempotencyKey": "84156892-12f7-448d-9579-7f759af83bbf",
"productId": "chronomind",
"startedAt": "2026-05-30T06:45:51.771Z",
"completedAt": "2026-05-30T06:45:54.163Z"
}
]
}

View File

@ -1,5 +1,2 @@
node_modules/ node_modules/
dist/ dist/
# Local datastore (memory provider event log) — never commit
.data/

View File

@ -15,7 +15,7 @@
"gen:module": "tsx scripts/gen-module.ts" "gen:module": "tsx scripts/gen-module.ts"
}, },
"dependencies": { "dependencies": {
"@azure/cosmos": "^4.9.3", "@azure/cosmos": "^4.2.0",
"@bytelyst/auth": "workspace:*", "@bytelyst/auth": "workspace:*",
"@bytelyst/blob": "workspace:*", "@bytelyst/blob": "workspace:*",
"@bytelyst/config": "workspace:*", "@bytelyst/config": "workspace:*",
@ -27,24 +27,24 @@
"@bytelyst/fastify-core": "workspace:*", "@bytelyst/fastify-core": "workspace:*",
"@bytelyst/queue": "workspace:*", "@bytelyst/queue": "workspace:*",
"@bytelyst/storage": "workspace:*", "@bytelyst/storage": "workspace:*",
"@fastify/cors": "^11.2.0", "@fastify/cors": "^10.0.2",
"@fastify/rate-limit": "^10.3.0", "@fastify/rate-limit": "^10.3.0",
"@fastify/swagger": "^9.4.2", "@fastify/swagger": "^9.4.2",
"@fastify/swagger-ui": "^5.2.5", "@fastify/swagger-ui": "^5.2.5",
"bcryptjs": "^3.0.3", "bcryptjs": "^2.4.3",
"fastify": "^5.2.1", "fastify": "^5.2.1",
"fastify-metrics": "^10.3.0", "fastify-metrics": "^10.3.0",
"fastify-zod-openapi": "^5.5.0", "fastify-zod-openapi": "^5.5.0",
"jose": "^6.2.3", "jose": "^6.0.8",
"nodemailer": "^6.10.1", "nodemailer": "^6.10.1",
"stripe": "^20.4.1", "stripe": "^17.5.0",
"yaml": "^2.8.2", "yaml": "^2.8.2",
"zod": "^3.24.2", "zod": "^3.24.2",
"zod-openapi": "^5.4.6" "zod-openapi": "^5.4.6"
}, },
"devDependencies": { "devDependencies": {
"@types/bcryptjs": "^3.0.0", "@types/bcryptjs": "^2.4.6",
"@types/node": "^25.9.1", "@types/node": "^22.12.0",
"tsx": "^4.19.2", "tsx": "^4.19.2",
"typescript": "^5.7.3", "typescript": "^5.7.3",
"vitest": "^3.0.5" "vitest": "^3.0.5"

View File

@ -105,7 +105,6 @@ export async function autoRegisterProduct(
deviceLimits: { free: 1, pro: 3, enterprise: 10 }, deviceLimits: { free: 1, pro: 3, enterprise: 10 },
websiteUrl: '', websiteUrl: '',
status: 'active', status: 'active',
ownerId: userId,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}; };

View File

@ -188,35 +188,14 @@ const CONTAINER_DEFS: Record<string, ContainerConfig> = {
translations: { partitionKeyPath: '/locale' }, translations: { partitionKeyPath: '/locale' },
i18n_locales: { partitionKeyPath: '/locale' }, i18n_locales: { partitionKeyPath: '/locale' },
// Agent Gigafactory — fleet coordinator (see modules/fleet/README.md) // Agent Gigafactory — fleet coordinator (see modules/fleet/README.md)
fleet_jobs: { fleet_jobs: { partitionKeyPath: '/productId' },
partitionKeyPath: '/productId',
// listJobs() orders by (priorityOrder, createdAt) — Azure Cosmos requires a
// matching composite index to serve multi-field ORDER BY (e.g. fleetMetrics).
compositeIndexes: [
[
{ path: '/priorityOrder', order: 'ascending' },
{ path: '/createdAt', order: 'ascending' },
],
],
},
fleet_runs: { partitionKeyPath: '/jobId' }, fleet_runs: { partitionKeyPath: '/jobId' },
// TTL backstop (2 days) for lease docs: a HELD lease is renewed continuously so fleet_leases: { partitionKeyPath: '/jobId' },
// its _ts stays fresh and it never expires while active; only finished
// (released/expired) leases — which stop being written — age out. This is purely
// defense-in-depth behind the app-level reaper/GC (which deletes finished leases
// after ~24h and owns recovery: requeue + epoch bump + checkpoint). NOTE: TTL is
// deliberately NOT set on fleet_events (event ids are `<jobId>:evt:<seq>` where
// seq = current count, so partial TTL deletion could collide ids) — events/runs/
// jobs are pruned by the cascade GC instead.
fleet_leases: { partitionKeyPath: '/jobId', defaultTtl: 2 * 86400 },
fleet_factories: { partitionKeyPath: '/productId' }, fleet_factories: { partitionKeyPath: '/productId' },
fleet_profiles: { partitionKeyPath: '/productId' }, fleet_profiles: { partitionKeyPath: '/productId' },
fleet_events: { partitionKeyPath: '/jobId' }, fleet_events: { partitionKeyPath: '/jobId' },
fleet_artifacts: { partitionKeyPath: '/jobId' }, fleet_artifacts: { partitionKeyPath: '/jobId' },
fleet_factory_tokens: { partitionKeyPath: '/productId' }, fleet_factory_tokens: { partitionKeyPath: '/productId' },
// M0 RU gate: per-product monotonic "work changed" counter (see
// docs/GIGAFACTORY/FLEET_DISPATCH_REDESIGN.md §8/§12).
fleet_queue_state: { partitionKeyPath: '/productId' },
}; };
export async function initCosmosIfNeeded(): Promise<void> { export async function initCosmosIfNeeded(): Promise<void> {

View File

@ -1,54 +0,0 @@
/**
* requireProductAccess flag-gated, owner-scoped tenant authorization (§tenancy).
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import type { FastifyRequest } from 'fastify';
let authPayload: { sub: string; role?: string } = { sub: 'owner_1', role: 'user' };
vi.mock('./auth.js', () => ({ extractAuth: vi.fn(async () => authPayload) }));
vi.mock('./auto-register.js', () => ({ autoRegisterProduct: vi.fn() }));
vi.mock('../modules/products/cache.js', () => ({
isValidProduct: () => true,
getProduct: (id: string) => ({ id, productId: id, status: 'active', ownerId: 'owner_1' }),
}));
import { requireProductAccess, isTenantEnforcementEnabled } from './request-context.js';
import { ForbiddenError } from './errors.js';
const reqFor = (productId: string): FastifyRequest =>
({ jwtPayload: { sub: authPayload.sub, productId }, headers: {} }) as unknown as FastifyRequest;
afterEach(() => {
delete process.env.FLEET_TENANT_ENFORCEMENT;
authPayload = { sub: 'owner_1', role: 'user' };
});
describe('requireProductAccess', () => {
it('enforcement OFF (default): resolves without an access check', async () => {
authPayload = { sub: 'someone_else', role: 'user' };
await expect(requireProductAccess(reqFor('p1'))).resolves.toBe('p1');
expect(isTenantEnforcementEnabled()).toBe(false);
});
it('enforcement ON: the owner is allowed', async () => {
process.env.FLEET_TENANT_ENFORCEMENT = '1';
authPayload = { sub: 'owner_1', role: 'user' };
await expect(requireProductAccess(reqFor('p1'))).resolves.toBe('p1');
});
it('enforcement ON: a non-owner, non-admin user is rejected (403)', async () => {
process.env.FLEET_TENANT_ENFORCEMENT = '1';
authPayload = { sub: 'intruder', role: 'user' };
await expect(requireProductAccess(reqFor('p1'))).rejects.toBeInstanceOf(ForbiddenError);
});
it('enforcement ON: admins and super_admins are always allowed', async () => {
process.env.FLEET_TENANT_ENFORCEMENT = '1';
authPayload = { sub: 'intruder', role: 'admin' };
await expect(requireProductAccess(reqFor('p1'))).resolves.toBe('p1');
authPayload = { sub: 'intruder', role: 'super_admin' };
await expect(requireProductAccess(reqFor('p1'))).resolves.toBe('p1');
});
});

View File

@ -10,11 +10,10 @@
*/ */
import type { FastifyRequest } from 'fastify'; import type { FastifyRequest } from 'fastify';
import { BadRequestError, ForbiddenError } from './errors.js'; import { BadRequestError } from './errors.js';
import { isValidProduct, getProduct } from '../modules/products/cache.js'; import { isValidProduct, getProduct } from '../modules/products/cache.js';
import type { ProductDoc } from '../modules/products/types.js'; import type { ProductDoc } from '../modules/products/types.js';
import { autoRegisterProduct } from './auto-register.js'; import { autoRegisterProduct } from './auto-register.js';
import { extractAuth } from './auth.js';
/** JWT payload shape attached to req by the onRequest hook (see Commit 3). */ /** JWT payload shape attached to req by the onRequest hook (see Commit 3). */
export interface JwtPayload { export interface JwtPayload {
@ -137,42 +136,3 @@ export function getRequestProductConfig(req: FastifyRequest): ProductDoc {
const id = getRequestProductId(req); const id = getRequestProductId(req);
return getProduct(id)!; return getProduct(id)!;
} }
/**
* `FLEET_TENANT_ENFORCEMENT` env gate default OFF. When OFF, tenant scoping is
* advisory only (the dashboard shows you your projects, but the API does not
* reject cross-tenant access) byte-for-byte the current behavior. When ON, the
* API enforces that a caller may only act on products they own (admins exempt).
*/
export function isTenantEnforcementEnabled(): boolean {
const v = (process.env.FLEET_TENANT_ENFORCEMENT ?? '').trim().toLowerCase();
return v === '1' || v === 'true' || v === 'on' || v === 'yes';
}
/**
* Resolve the request's productId AND authorize the caller for it (multi-tenant
* guard, §tenancy). Resolution + status gating is unchanged (`getRequestProductId`);
* the access check is additive and flag-gated:
*
* - enforcement OFF returns the resolved id (no behavior change).
* - admins / super_admins always allowed (operators see everything).
* - otherwise allowed only when the product is owned by the caller, OR is
* owner-less/legacy (grace for products created before ownership tracking;
* migrate them to lock down fully). A product owned by SOMEONE ELSE 403.
*
* Async because it needs the verified auth identity. Use this on tenant-scoped
* surfaces (e.g. the fleet control plane) in place of `getRequestProductId`.
*/
export async function requireProductAccess(req: FastifyRequest): Promise<string> {
const id = getRequestProductId(req);
if (!isTenantEnforcementEnabled()) return id;
const auth = await extractAuth(req);
if (auth.role === 'admin' || auth.role === 'super_admin') return id;
const product = getProduct(id);
if (product?.ownerId && product.ownerId !== auth.sub) {
throw new ForbiddenError(`Not authorized for product '${id}'`);
}
return id;
}

View File

@ -223,7 +223,6 @@ export async function enterpriseRoutes(app: FastifyInstance) {
role: user.role as string, role: user.role as string,
productId: idp.productId, productId: idp.productId,
plan: (user.plan ?? 'free') as 'free' | 'pro' | 'enterprise', plan: (user.plan ?? 'free') as 'free' | 'pro' | 'enterprise',
displayName: user.displayName,
}); });
const refreshToken = await jwt.createRefreshToken({ const refreshToken = await jwt.createRefreshToken({
sub: user.id, sub: user.id,
@ -333,7 +332,6 @@ export async function enterpriseRoutes(app: FastifyInstance) {
role: user.role as string, role: user.role as string,
productId: idp.productId, productId: idp.productId,
plan: (user.plan ?? 'free') as 'free' | 'pro' | 'enterprise', plan: (user.plan ?? 'free') as 'free' | 'pro' | 'enterprise',
displayName: user.displayName,
}); });
const refreshToken = await jwt.createRefreshToken({ const refreshToken = await jwt.createRefreshToken({
sub: user.id, sub: user.id,

View File

@ -17,7 +17,6 @@ export async function createAccessToken(payload: {
role: string; role: string;
productId: string; productId: string;
plan?: 'free' | 'pro' | 'enterprise'; plan?: 'free' | 'pro' | 'enterprise';
displayName?: string;
}): Promise<string> { }): Promise<string> {
return new SignJWT({ ...payload, type: 'access' }) return new SignJWT({ ...payload, type: 'access' })
.setProtectedHeader({ alg: 'HS256' }) .setProtectedHeader({ alg: 'HS256' })
@ -45,7 +44,6 @@ export async function verifyToken(token: string): Promise<{
role?: string; role?: string;
productId?: string; productId?: string;
plan?: 'free' | 'pro' | 'enterprise'; plan?: 'free' | 'pro' | 'enterprise';
displayName?: string;
type?: string; type?: string;
}> { }> {
const { payload } = await jwtVerify(token, getSecret(), { const { payload } = await jwtVerify(token, getSecret(), {
@ -57,7 +55,6 @@ export async function verifyToken(token: string): Promise<{
role?: string; role?: string;
productId?: string; productId?: string;
plan?: 'free' | 'pro' | 'enterprise'; plan?: 'free' | 'pro' | 'enterprise';
displayName?: string;
type?: string; type?: string;
}; };
} }

View File

@ -121,7 +121,6 @@ export async function magicLinkRoutes(app: FastifyInstance) {
role: user.role as string, role: user.role as string,
productId: user.productId, productId: user.productId,
plan: (user.plan ?? 'free') as 'free' | 'pro' | 'enterprise', plan: (user.plan ?? 'free') as 'free' | 'pro' | 'enterprise',
displayName: user.displayName,
}); });
const refreshToken = await jwt.createRefreshToken({ const refreshToken = await jwt.createRefreshToken({
sub: user.id, sub: user.id,

View File

@ -175,7 +175,6 @@ export async function oauthRoutes(app: FastifyInstance) {
role: user.role, role: user.role,
productId, productId,
plan: user.plan, plan: user.plan,
displayName: user.displayName,
}); });
const refreshToken = await jwt.createRefreshToken({ sub: user.id, productId }); const refreshToken = await jwt.createRefreshToken({ sub: user.id, productId });

View File

@ -247,7 +247,6 @@ export async function passkeyRoutes(app: FastifyInstance) {
role: user.role, role: user.role,
productId: body.productId, productId: body.productId,
plan: user.plan, plan: user.plan,
displayName: user.displayName,
}); });
const refreshToken = await jwt.createRefreshToken({ sub: user.id, productId: body.productId }); const refreshToken = await jwt.createRefreshToken({ sub: user.id, productId: body.productId });

Some files were not shown because too many files have changed in this diff Show More