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_MODELS=
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
*.sh text eol=lf

View File

@ -48,30 +48,3 @@ jobs:
- name: Test release package
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/
.DS_Store
*.tsbuildinfo
graphify-out/
# Env / Secrets
.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
Quick, dense reference cards for the terminal AI agents we use to delegate coding work,
plus an operational guide for running them **non-stop overnight**. Each sheet is
**task-oriented** — commands, modes, session management, config, and the ByteLyst-specific
guardrails — not a marketing overview.
Quick, dense reference cards for the three terminal AI agents we use to delegate
coding work. Each sheet is **task-oriented** — commands, modes, session management,
config, and the ByteLyst-specific guardrails — not a marketing overview.
| CLI | Cheat sheet | Best for |
| ------------------ | -------------------------------------------- | ------------------------------------------------------------------------------- |
| 🤖 **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 |
| 🟢 **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.
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)
Cascade conversations: 50 (537M)
Last refresh: 2026-05-29T17:09:25Z (2026-05-29 10:09:25 PDT)
Cascade conversations: 50 (502M)
Memories: 138
Implicit context: 20
Code tracker dirs: 64
File edit history: 5408 entries
Code tracker dirs: 43
File edit history: 5395 entries
Workspace storage: 52 workspaces
Repo docs: 7 files across 2 repos
Repo workflows: 56 files across 13 repos

View File

@ -9,17 +9,17 @@
## 1. Project → Env File Map
| # | Project | Env File | Port |
|---|---------|----------|------|
| 1 | Desktop app (`src/`) | `.env` (root) | — |
| 2 | Backend API (`backend/`) | `backend/.env` | 8000 |
| 3 | Admin Dashboard (`admin-dashboard-web/`) | `admin-dashboard-web/.env.local` | 3001 |
| 4 | User Dashboard (`user-dashboard-web/`) | `user-dashboard-web/.env.local` | 3002 |
| 5 | Tracker Dashboard (`tracker-dashboard-web/`) | `tracker-dashboard-web/.env.local` | 3003 |
| 6 | Billing Service (`services/billing-service/`) | `services/billing-service/.env` | 4002 |
| 7 | Growth Service (`services/growth-service/`) | `services/growth-service/.env` | 4001 |
| 8 | Platform Service (`services/platform-service/`) | `services/platform-service/.env` | 4003 |
| 9 | Tracker Service (`services/tracker-service/`) | `services/tracker-service/.env` | 4004 |
| # | Project | Env File | Port |
| --- | ----------------------------------------------- | ---------------------------------- | ---- |
| 1 | Desktop app (`src/`) | `.env` (root) | — |
| 2 | Backend API (`backend/`) | `backend/.env` | 8000 |
| 3 | Admin Dashboard (`admin-dashboard-web/`) | `admin-dashboard-web/.env.local` | 3001 |
| 4 | User Dashboard (`user-dashboard-web/`) | `user-dashboard-web/.env.local` | 3002 |
| 5 | Tracker Dashboard (`tracker-dashboard-web/`) | `tracker-dashboard-web/.env.local` | 3003 |
| 6 | Billing Service (`services/billing-service/`) | `services/billing-service/.env` | 4002 |
| 7 | Growth Service (`services/growth-service/`) | `services/growth-service/.env` | 4001 |
| 8 | Platform Service (`services/platform-service/`) | `services/platform-service/.env` | 4003 |
| 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)
| Variable | Status | Action |
|----------|--------|--------|
| `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 |
| Variable | Status | Action |
| --------------------------------------- | ---------------------------------- | ----------------------------------------------------------------------- |
| `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 |
### 3.2 `backend/.env`
| Variable | Status | Action |
|----------|--------|--------|
| `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_USER` | ❌ Empty | Configure if using SMTP fallback |
| `SMTP_PASS` | ❌ Empty | Configure if using SMTP fallback |
| Variable | Status | Action |
| ------------------------------- | -------- | ------------------------------------------------------------------------ |
| `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_USER` | ❌ Empty | Configure if using SMTP fallback |
| `SMTP_PASS` | ❌ Empty | Configure if using SMTP fallback |
### 3.3 `admin-dashboard-web/.env.local`
| Variable | Status | Action |
|----------|--------|--------|
| `NEXT_PUBLIC_POSTHOG_KEY` | ❌ Empty | Get from PostHog → Project Settings (optional — analytics) |
| Variable | Status | Action |
| -------------------------- | -------- | ---------------------------------------------------------- |
| `NEXT_PUBLIC_POSTHOG_KEY` | ❌ 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`
| Variable | Status | Action |
|----------|--------|--------|
| Variable | Status | Action |
| -------------------------- | -------- | ------------------------------------------------------------------- |
| `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_SECRET` | ❌ Empty | Same as above |
| `GOOGLE_CLIENT_ID` | ❌ Empty | Register app in Google Cloud Console → Credentials |
| `GOOGLE_CLIENT_SECRET` | ❌ Empty | Same as above |
| `NEXT_PUBLIC_POSTHOG_KEY` | ❌ Empty | PostHog analytics (optional) |
| `NEXT_PUBLIC_POSTHOG_HOST` | ❌ Empty | PostHog analytics (optional) |
| `MICROSOFT_CLIENT_ID` | ❌ Empty | Register app in Azure Portal → Entra ID → App registrations |
| `MICROSOFT_CLIENT_SECRET` | ❌ Empty | Same as above |
| `GOOGLE_CLIENT_ID` | ❌ Empty | Register app in Google Cloud Console → Credentials |
| `GOOGLE_CLIENT_SECRET` | ❌ Empty | Same as above |
| `NEXT_PUBLIC_POSTHOG_KEY` | ❌ Empty | PostHog analytics (optional) |
| `NEXT_PUBLIC_POSTHOG_HOST` | ❌ Empty | PostHog analytics (optional) |
### 3.5 `tracker-dashboard-web/.env.local`
| Variable | Status | Action |
|----------|--------|--------|
| `NEXT_PUBLIC_POSTHOG_KEY` | ❌ Empty | PostHog analytics (optional) |
| Variable | Status | Action |
| -------------------------- | -------- | ---------------------------- |
| `NEXT_PUBLIC_POSTHOG_KEY` | ❌ Empty | PostHog analytics (optional) |
| `NEXT_PUBLIC_POSTHOG_HOST` | ❌ Empty | PostHog analytics (optional) |
### 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_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`
| Variable | Status | Action |
|----------|--------|--------|
| Variable | Status | Action |
| ------------------ | -------- | --------------------------------------------------------------- |
| `PLAN_LIMITS_JSON` | ❌ Empty | Optional — set JSON with per-plan limits if overriding defaults |
### 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 |
### 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:
| Project | Variable | Value Added |
|---------|----------|-------------|
| Root `.env` | `PLATFORM_SERVICE_URL` | `http://localhost:4003` |
| Root `.env` | `LYSNR_API_URL` | `http://localhost:8000` |
| Root `.env` | `LYSNR_ADMIN_URL` | `http://localhost:3001` |
| Root `.env` | `LYSNR_DASHBOARD_URL` | `http://localhost:3002` |
| `backend/.env` | `BILLING_SERVICE_URL` | `http://localhost:4002` |
| `backend/.env` | `PLATFORM_SERVICE_URL` | `http://localhost:4003` |
| `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_WEBHOOK_SECRET` | Test key (was missing) |
| `admin-dashboard-web/.env.local` | `STRIPE_PRICE_PRO` | `price_1Szl2z...` |
| `admin-dashboard-web/.env.local` | `STRIPE_PRICE_ENTERPRISE` | `price_1Szl3D...` |
| `user-dashboard-web/.env.local` | `ENTERPRISE_EMAIL_DOMAINS` | Empty (needs config) |
| `services/billing-service/.env` | `USAGE_WARN_THRESHOLD` | `80` |
| Project | Variable | Value Added |
| -------------------------------- | -------------------------- | --------------------------------------- |
| Root `.env` | `PLATFORM_SERVICE_URL` | `http://localhost:4003` |
| Root `.env` | `LYSNR_API_URL` | `http://localhost:8000` |
| Root `.env` | `LYSNR_ADMIN_URL` | `http://localhost:3001` |
| Root `.env` | `LYSNR_DASHBOARD_URL` | `http://localhost:3002` |
| `backend/.env` | `BILLING_SERVICE_URL` | `http://localhost:4002` |
| `backend/.env` | `PLATFORM_SERVICE_URL` | `http://localhost:4003` |
| `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_WEBHOOK_SECRET` | Test key (was missing) |
| `admin-dashboard-web/.env.local` | `STRIPE_PRICE_PRO` | `price_1Szl2z...` |
| `admin-dashboard-web/.env.local` | `STRIPE_PRICE_ENTERPRISE` | `price_1Szl3D...` |
| `user-dashboard-web/.env.local` | `ENTERPRISE_EMAIL_DOMAINS` | Empty (needs config) |
| `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:
| Secret | Used By |
|--------|---------|
| `JWT_SECRET` | All 4 Fastify services + all 3 dashboards + backend |
| `COSMOS_ENDPOINT` | All 4 Fastify services + admin + user dashboards + backend + desktop |
| `COSMOS_KEY` | Same as above |
| `COSMOS_DATABASE` | Same as above (must be `lysnrai`) |
| `STRIPE_SECRET_KEY` | billing-service, growth-service, admin-dashboard, user-dashboard |
| `AZURE_BLOB_*` | platform-service, admin-dashboard, user-dashboard, desktop |
| Secret | Used By |
| ------------------- | -------------------------------------------------------------------- |
| `JWT_SECRET` | All 4 Fastify services + all 3 dashboards + backend |
| `COSMOS_ENDPOINT` | All 4 Fastify services + admin + user dashboards + backend + desktop |
| `COSMOS_KEY` | Same as above |
| `COSMOS_DATABASE` | Same as above (must be `lysnrai`) |
| `STRIPE_SECRET_KEY` | billing-service, growth-service, admin-dashboard, user-dashboard |
| `AZURE_BLOB_*` | platform-service, admin-dashboard, user-dashboard, desktop |
---
## 7. Production Deployment Notes
When deploying to the current stack:
- **Vercel** for public/front-end surfaces where applicable
- **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
@ -45,7 +45,7 @@ canonical script. Legacy files (`CLAUDE.md`, `.cursorrules`, `.windsurfrules`,
`.clinerules`) are **deprecated** and must NOT be created — they used to
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.
3. Run `bash ../learning_ai_common_plat/scripts/update-agent-docs.sh`. This will:
- Prepend the canonical-behavior-pointer block to `AGENTS.md`
@ -72,12 +72,14 @@ duplicate AGENTS.md content and drifted.
## Step 4: Manus Artifact Cleanup
### 4a. Clean `vite.config.ts`
- Remove `vite-plugin-manus-runtime` plugin
- Remove `vite-plugin-manus-debug-collector` plugin + all LOG_DIR code
- Remove `@builder.io/vite-plugin-jsx-loc` plugin
- Replace `allowedHosts: [".manuspre.computer", ...]` with `allowedHosts: ["localhost"]`
### 4b. Delete Manus Files
- Delete `client/src/components/ManusDialog.tsx`
- Delete `client/src/components/Map.tsx` (Google Maps boilerplate, 156 lines)
- 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)
### 4c. Clean `client/index.html`
- Remove `VITE_ANALYTICS_ENDPOINT` / `VITE_ANALYTICS_WEBSITE_ID` script references
### 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`
- **Remove:** `streamdown`, `cmdk`, `add` (devDep), `@types/google.maps` (devDep), `next-themes`
- **Remove:** `express`, `@types/express` (server/ is deleted)
- **Remove** Manus vite plugins from devDeps: `vite-plugin-manus-runtime`, `@builder.io/vite-plugin-jsx-loc`
### 4e. Move Files
- Move `ideas.md``docs/ideas.md`
## Step 5: Create README.md
Write a proper README.md with:
- Product name + description
- Tech stack (Vite + React 19 SPA, Fastify 5 backend planned)
- Setup instructions (`pnpm install`, `pnpm dev`)

View File

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

View File

@ -7,12 +7,6 @@
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) ──
PLATFORM_SERVICE_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/design-tokens": "workspace:*",
"@bytelyst/errors": "workspace:*",
"@bytelyst/logger": "workspace:*",
"@bytelyst/motion": "workspace:*",
"@bytelyst/notifications-ui": "workspace:*",
"@bytelyst/telemetry-client": "workspace:*",
"@bytelyst/logger": "workspace:*",
"@bytelyst/motion": "workspace:*",
"@bytelyst/ui": "workspace:*",
"clsx": "^2.1.1",
"next": "16.1.6",
@ -49,16 +49,15 @@
"devDependencies": {
"@playwright/test": "^1.58.2",
"@tailwindcss/postcss": "^4",
"@types/node": "^25.9.1",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitest/coverage-v8": "^4.0.18",
"bundlesize": "^0.18.1",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"happy-dom": "^20.9.0",
"husky": "^9.0.0",
"lint-staged": "^16.4.0",
"lint-staged": "^15.0.0",
"prettier": "^3.0.0",
"tailwindcss": "^4",
"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.
*

View File

@ -15,25 +15,15 @@ import {
listJobs,
getJob,
patchJob,
operatorAction,
requestReview,
submitReview,
getJobRuns,
getJobEvents,
getJobArtifacts,
getJobDag,
getJobExplain,
listFactories,
availableEnginesForProduct,
getFleetMetrics,
getBudget,
getBudgetBurndown,
upsertBudget,
pauseBudget,
resumeBudget,
budgetUsagePct,
parseSseFrames,
subscribeJobEvents,
} from '@/lib/fleet-client';
describe('fleet-client', () => {
@ -84,99 +74,6 @@ describe('fleet-client', () => {
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', () => {
@ -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', () => {
it('returns factories on success', async () => {
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', () => {
it('getBudget returns budget or null', async () => {
fetchSpy.mockResolvedValue({ id: 'lysnrai', ceilingUsd: 100, spentUsd: 25 });
@ -387,155 +167,5 @@ describe('fleet-client', () => {
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 { createRoot, type Root } from 'react-dom/client';
@ -14,10 +14,6 @@ beforeEach(() => {
localStorage.clear();
});
afterEach(() => {
vi.unstubAllGlobals();
});
function renderProductHarness() {
const container = document.createElement('div');
document.body.appendChild(container);
@ -77,51 +73,4 @@ describe('ProductProvider', () => {
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[] }> }) {
const { path } = await params;
// platform-service mounts fleet routes under the /api prefix.
const targetPath = `/api/fleet/${path.join('/')}`;
const targetPath = `/fleet/${path.join('/')}`;
const url = new URL(targetPath, PLATFORM_API);
req.nextUrl.searchParams.forEach((value, key) => {
@ -18,7 +17,7 @@ async function proxy(req: NextRequest, { params }: { params: Promise<{ path: str
});
try {
const headers: Record<string, string> = {};
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
const auth = req.headers.get('authorization');
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') {
const body = await req.text();
if (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';
}
if (body) fetchOptions.body = body;
}
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 {
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`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Product-Id': PRODUCT_ID,
'X-Install-Token': installToken,
},
body,
});

View File

@ -4,20 +4,11 @@ import { useEffect, useState, useCallback } from 'react';
import { PageHeader } from '@bytelyst/dashboard-components';
import { Button } from '@/components/ui/Primitives';
import { useAuth } from '@/lib/auth-context';
import {
getBudget,
getBudgetBurndown,
pauseBudget,
resumeBudget,
budgetUsagePct,
type FleetBudget,
type CostBurndown,
} from '@/lib/fleet-client';
import { getBudget, pauseBudget, resumeBudget, type FleetBudget } from '@/lib/fleet-client';
export default function FleetBudgetPage() {
const { token } = useAuth();
const [budget, setBudget] = useState<FleetBudget | null | undefined>(undefined);
const [burndown, setBurndown] = useState<CostBurndown | null>(null);
const [acting, setActing] = useState(false);
const productId =
@ -29,9 +20,8 @@ export default function FleetBudgetPage() {
return;
}
try {
const [b, bd] = await Promise.all([getBudget(productId), getBudgetBurndown(productId, 30)]);
const b = await getBudget(productId);
setBudget(b);
setBurndown(bd);
} catch {
setBudget(null);
}
@ -113,34 +103,23 @@ export default function FleetBudgetPage() {
</span>
</div>
{/* Spend bar — guards against a missing/zero ceiling (no NaN bar). */}
{(() => {
const hasCeiling = Number.isFinite(budget.ceilingUsd) && budget.ceilingUsd > 0;
const usagePct = budgetUsagePct(budget.spentUsd, budget.ceilingUsd);
const overCeiling = hasCeiling && budget.spentUsd >= budget.ceilingUsd;
return (
<div>
<div className="flex justify-between text-sm mb-1">
<span>Spent</span>
<span>
${budget.spentUsd.toFixed(2)} /{' '}
{hasCeiling ? `$${budget.ceilingUsd.toFixed(2)}` : 'no ceiling set'}
</span>
</div>
<div className="w-full bg-muted rounded-full h-2.5" aria-label="Budget usage bar">
<div
className={`h-2.5 rounded-full ${overCeiling ? 'bg-red-500' : 'bg-blue-500'}`}
style={{ width: `${usagePct}%` }}
/>
</div>
{!hasCeiling && (
<p className="text-xs text-muted-foreground mt-1">
No spend ceiling configured usage is unbounded.
</p>
)}
</div>
);
})()}
{/* Spend bar */}
<div>
<div className="flex justify-between text-sm mb-1">
<span>Spent</span>
<span>
${budget.spentUsd.toFixed(2)} / ${budget.ceilingUsd.toFixed(2)}
</span>
</div>
<div className="w-full bg-muted rounded-full h-2.5" aria-label="Budget usage bar">
<div
className={`h-2.5 rounded-full ${
budget.spentUsd >= budget.ceilingUsd ? 'bg-red-500' : 'bg-blue-500'
}`}
style={{ width: `${Math.min(100, (budget.spentUsd / budget.ceilingUsd) * 100)}%` }}
/>
</div>
</div>
<div className="text-sm text-muted-foreground">
<p>Window: {budget.window}</p>
@ -173,62 +152,6 @@ export default function FleetBudgetPage() {
</div>
</div>
)}
{/* Cost burndown */}
{productId && burndown && burndown.days.length > 0 && <BurndownChart burndown={burndown} />}
</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 Link from 'next/link';
import { PageHeader } from '@bytelyst/dashboard-components';
import { Button } from '@/components/ui/Primitives';
import { useAuth } from '@/lib/auth-context';
import {
listJobs,
submitJob,
availableEnginesForProduct,
FLEET_ENGINES,
type FleetJob,
type FleetEngine,
} from '@/lib/fleet-client';
import { listJobs, type FleetJob } from '@/lib/fleet-client';
const STAGES = [
'',
'queued',
'blocked',
'assigned',
@ -24,107 +17,27 @@ const STAGES = [
'shipped',
'failed',
'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;
// 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() {
const { token } = useAuth();
const [jobs, setJobs] = useState<FleetJob[]>([]);
const [stage, setStage] = useState('');
const [hideShipped, setHideShipped] = useState(false);
const [search, setSearch] = useState('');
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 () => {
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);
} catch {
/* degrade */
} finally {
setLoading(false);
}
}, []);
}, [stage]);
useEffect(() => {
if (!token) return;
@ -133,399 +46,69 @@ export default function FleetJobsPage() {
return () => clearInterval(id);
}, [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 (
<div className="p-6 space-y-6">
<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 */}
<div className="space-y-3">
{/* Stage chips with live counts (click to filter) */}
<div className="flex flex-wrap items-center gap-1.5">
<button
type="button"
onClick={() => setStage('')}
className={`rounded-full border px-2.5 py-1 text-xs font-medium ${
stage === '' ? 'bg-primary text-primary-foreground border-primary' : 'hover:bg-muted'
}`}
>
All <span className="opacity-70">{jobs.length}</span>
</button>
{STAGES.filter(s => stageCounts[s]).map(s => (
<button
key={s}
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 className="flex gap-3 items-center">
<label htmlFor="stage-filter" className="text-sm font-medium">
Stage:
</label>
<select
id="stage-filter"
value={stage}
onChange={e => setStage(e.target.value)}
className="rounded border px-2 py-1 text-sm bg-background"
aria-label="Filter by stage"
>
{STAGES.map(s => (
<option key={s} value={s}>
{s || 'All'}
</option>
))}
</div>
{/* 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>
</select>
</div>
{/* Table */}
{loading ? (
<p className="text-muted-foreground">Loading jobs...</p>
) : visible.length === 0 ? (
<p className="text-muted-foreground">
{jobs.length === 0 ? 'No jobs yet — submit one above.' : 'No jobs match the filters.'}
</p>
) : jobs.length === 0 ? (
<p className="text-muted-foreground">No jobs match the current filter.</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">
<thead>
<tr className="border-b bg-muted/40 text-left text-xs uppercase tracking-wide text-muted-foreground">
<th className="px-3 py-2">Job</th>
<th className="px-3 py-2">Stage</th>
<th className="px-3 py-2">Priority</th>
<th className="px-3 py-2">Repo</th>
<th className="px-3 py-2 text-right">Attempts</th>
<th className="px-3 py-2 text-right">Created</th>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-2 pr-4">Idempotency Key</th>
<th className="pb-2 pr-4">Stage</th>
<th className="pb-2 pr-4">Priority</th>
<th className="pb-2 pr-4">Kind</th>
<th className="pb-2 pr-4">Attempts</th>
<th className="pb-2">Created</th>
</tr>
</thead>
<tbody>
{visible.map(j => (
<tr key={j.id} className="border-b last:border-0 hover:bg-muted/40">
<td className="px-3 py-2">
{jobs.map(j => (
<tr key={j.id} className="border-b last:border-0 hover:bg-muted/50 cursor-pointer">
<td className="py-2 pr-4">
<Link
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}`}
>
{j.idempotencyKey}
</Link>
{j.kind !== 'leaf' && (
<span className="ml-2 text-[10px] text-muted-foreground">{j.kind}</span>
)}
</td>
<td className="px-3 py-2">
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${stageStyle(j.stage)}`}
>
{stageLabel(j.stage)}
<td className="py-2 pr-4">
<span className="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium bg-muted">
{j.stage}
</span>
</td>
<td className={`px-3 py-2 capitalize ${PRIORITY_STYLE[j.priority] ?? ''}`}>
{j.priority}
</td>
<td className="px-3 py-2 font-mono text-xs">
{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">
<td className="py-2 pr-4 capitalize">{j.priority}</td>
<td className="py-2 pr-4">{j.kind}</td>
<td className="py-2 pr-4">{j.attempts}</td>
<td className="py-2 text-xs text-muted-foreground">
{new Date(j.createdAt).toLocaleString()}
</td>
</tr>

View File

@ -4,15 +4,7 @@ import { useEffect, useState, useCallback } from 'react';
import Link from 'next/link';
import { PageHeader } from '@bytelyst/dashboard-components';
import { useAuth } from '@/lib/auth-context';
import {
listFactories,
listJobs,
getFleetMetrics,
operatorAction,
type FleetFactory,
type FleetJob,
type FleetMetrics,
} from '@/lib/fleet-client';
import { listFactories, listJobs, type FleetFactory, type FleetJob } from '@/lib/fleet-client';
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 }) {
const colors: Record<string, string> = {
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() {
const { token } = useAuth();
const [factories, setFactories] = useState<FleetFactory[]>([]);
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 refresh = useCallback(async () => {
try {
const [facRes, jobRes, metricsRes, dlRes] = await Promise.all([
listFactories(),
listJobs({ limit: 10 }),
getFleetMetrics().catch(() => null),
listJobs({ stage: 'dead_letter', limit: 50 }).catch(() => ({ jobs: [] as FleetJob[] })),
]);
const [facRes, jobRes] = await Promise.all([listFactories(), listJobs({ limit: 10 })]);
setFactories(facRes.factories);
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 {
/* degrade gracefully */
} 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(() => {
if (!token) return;
refresh();
@ -207,101 +78,6 @@ export default function FleetOverviewPage() {
<div className="p-6 space-y-8">
<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 */}
<section>
<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() {
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 (
<select
value={productId}

View File

@ -1,7 +1,6 @@
'use client';
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react';
import { PRODUCT_ID } from './product-constants';
interface User {
id: string;
@ -55,15 +54,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}, []);
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', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password, productId }),
body: JSON.stringify({ email, password }),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));

View File

@ -8,10 +8,6 @@ import { createApiClient } from '@bytelyst/api-client';
// ── 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 {
id: string;
productId: string;
@ -27,20 +23,6 @@ export interface FleetJob {
leaseEpoch: number;
createdAt: 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 {
@ -54,27 +36,6 @@ export interface FleetFactory {
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 {
id: string;
jobId: string;
@ -84,10 +45,7 @@ export interface FleetRun {
startedAt: string;
endedAt?: string;
result?: string;
insights: FleetRunInsights;
prUrl?: string;
branch?: string;
prState?: 'open' | 'merged';
insights: Record<string, unknown>;
}
export interface FleetEvent {
@ -119,20 +77,6 @@ export interface FleetBudget {
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 {
id: string;
idempotencyKey: string;
@ -143,33 +87,6 @@ export interface 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 ──────────────────────────────────────────────────────────────────
const fleetApi = createApiClient({
@ -217,404 +134,41 @@ export async function getJob(id: string): Promise<FleetJob | null> {
return apiFetchOptional(`/jobs/${id}`);
}
export interface SubmitJobBody {
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(
export async function patchJob(
id: string,
policy?: { requiredApprovals?: number; reviewers?: string[] }
body: { leaseEpoch: number; stage: string }
): Promise<FleetJob> {
return apiFetch(`/jobs/${id}/review/request`, {
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),
});
return apiFetch(`/jobs/${id}`, { method: 'PATCH', body: JSON.stringify(body) });
}
export async function getJobRuns(jobId: string): Promise<{ runs: FleetRun[] }> {
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[] }> {
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[] }> {
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> {
return apiFetchOptional(`/jobs/${jobId}/dag`);
}
export async function getJobExplain(jobId: string): Promise<JobExplain | null> {
return apiFetchOptional(`/jobs/${jobId}/explain`);
}
// ── Factories ───────────────────────────────────────────────────────────────
export async function listFactories(productId?: string): Promise<{ factories: FleetFactory[] }> {
const headers = productId ? { 'x-product-id': productId } : undefined;
export async function listFactories(): Promise<{ factories: FleetFactory[] }> {
try {
return await apiFetch('/factories', headers ? { headers } : undefined);
return await apiFetch('/factories');
} catch {
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 ─────────────────────────────────────────────────────────────────
/**
* 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> {
return apiFetchOptional(`/budgets/${productId}`);
}
@ -637,11 +191,3 @@ export async function pauseBudget(productId: string): Promise<FleetBudget> {
export async function resumeBudget(productId: string): Promise<FleetBudget> {
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();
// 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 DISPLAY_NAME = identity.displayName;
export const LICENSE_PREFIX = identity.licensePrefix;
@ -30,3 +22,11 @@ export const PACKAGE_NAME = identity.packageName;
export function getRequestProductId(req: NextRequest): string {
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
* 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 DISPLAY_NAME = process.env.NEXT_PUBLIC_DISPLAY_NAME || 'LysnrAI';
/** A selectable product/project the dashboard can be scoped to. */
export interface Product {
id: string;
name: string;
icon?: string;
}
/** Built-in default product set (the original ByteLyst ecosystem). */
export const DEFAULT_PRODUCTS: readonly Product[] = [
/** 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' },
];
/**
* 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
);
] as const;

View File

@ -1,7 +1,7 @@
'use client';
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 PRODUCT_CHANGED_EVENT = 'tracker:product-changed';
@ -10,7 +10,7 @@ interface ProductContextValue {
productId: string;
productName: string;
setProductId: (id: string) => void;
products: readonly Product[];
products: typeof KNOWN_PRODUCTS;
}
const ProductContext = createContext<ProductContextValue | null>(null);
@ -20,27 +20,8 @@ function getInitialProduct(): string {
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 }) {
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(() => {
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) => {
setProductIdState(id);
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;
return (
<ProductContext.Provider value={{ productId, productName, setProductId, products }}>
<ProductContext.Provider
value={{ productId, productName, setProductId, products: KNOWN_PRODUCTS }}
>
{children}
</ProductContext.Provider>
);

View File

@ -5,7 +5,6 @@ export default defineConfig({
test: {
environment: 'node',
globals: true,
setupFiles: ['./vitest.setup.ts'],
exclude: ['e2e/**', 'node_modules/**', '.next/**'],
coverage: {
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
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) ───────────────────────────────────
gateway:
image: traefik:v3.3
@ -299,4 +282,3 @@ volumes:
azurite-data:
loki-data:
grafana-data:
prometheus-data:

View File

@ -23,7 +23,7 @@ score = w.age * ageMinutes + w.priority * priorityOrder + w.retries * attempts +
### 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 })`
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 |
## 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
| Endpoint | Method | Phase | Notes |
| ---------------------------------- | ------ | ----- | --------------------------------------------------------- |
| `/fleet/jobs` | GET | 2 | List jobs (query: stage, productId, limit, offset) |
| `/fleet/jobs` | POST | 2 | Submit job (+ optional children[] for DAG) |
| `/fleet/jobs/:id` | GET | 2 | Get job |
| `/fleet/jobs/:id` | PATCH | 2 | Update stage (fenced) |
| `/fleet/jobs/:id/actions/:action` | POST | 3 | Operator action: `requeue` / `reject` / `cancel` / `ship` |
| `/fleet/jobs/:id/lease/release` | POST | 2 | Release lease (optional `stage`, `insights`, `result`) |
| `/fleet/claim` | POST | 2 | Factory claims the next best-fit job |
| `/fleet/jobs/:id/children` | POST | 3 | Add children to existing job |
| `/fleet/jobs/:id/dag` | GET | 3 | Get DAG subtree |
| `/fleet/factories/heartbeat` | POST | 2 | Factory heartbeat (register == first heartbeat) |
| `/fleet/factories/enroll` | POST | 2 | Enroll a factory → one-time scoped token |
| `/fleet/metrics` | GET | 3 | Utilization, health rollup, queue/starvation alerts |
| `/fleet/queue-state` | GET | 4 | M0 RU gate: per-product monotonic queue `version` |
| `/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 |
| Endpoint | Method | Phase | Notes |
| ---------------------------------- | ------ | ----- | -------------------------------------------------- |
| `/fleet/jobs` | GET | 2 | List jobs (query: stage, productId, limit, offset) |
| `/fleet/jobs` | POST | 2 | Submit job (+ optional children[] for DAG) |
| `/fleet/jobs/:id` | GET | 2 | Get job |
| `/fleet/jobs/:id` | PATCH | 2 | Update stage (fenced) |
| `/fleet/jobs/:id/claim` | POST | 2 | Factory claims next job |
| `/fleet/jobs/:id/children` | POST | 3 | Add children to existing job |
| `/fleet/jobs/:id/dag` | GET | 3 | Get DAG subtree |
| `/fleet/factories` | GET | 2 | List factories |
| `/fleet/factories/:id/heartbeat` | POST | 2 | Factory heartbeat |
| `/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

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
See `FLEET_CONTROL_PLANE.md` for the operational guide.
See `docs/FLEET_CONTROL_PLANE.md` for the operational guide.
## 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",
"@eslint/js": "^10.0.1",
"@size-limit/preset-small-lib": "^12.1.0",
"@types/bcryptjs": "^3.0.0",
"@types/node": "^25.9.1",
"@types/bcryptjs": "^2.4.6",
"@types/node": "^20.0.0",
"@types/react": "^19.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",
"eslint": "^9.0.0",
"husky": "^9.0.0",
"lint-staged": "^16.4.0",
"lint-staged": "^15.0.0",
"prettier": "^3.0.0",
"size-limit": "^12.1.0",
"typescript": "^5.7.0",

View File

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

View File

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

View File

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

View File

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

View File

@ -85,7 +85,7 @@ describe('CommandRegistryProvider', () => {
]);
return useCommands();
},
{ wrapper: wrapper() }
{ wrapper: wrapper() },
);
expect(result.current.map(c => c.id).sort()).toEqual(['temp', 'temp2']);
unmount();
@ -96,7 +96,9 @@ describe('CommandRegistryProvider', () => {
});
it('throws when used outside provider', () => {
expect(() => renderHook(() => useCommands())).toThrow(/CommandRegistryProvider/);
expect(() => renderHook(() => useCommands())).toThrow(
/CommandRegistryProvider/,
);
});
});
@ -126,7 +128,7 @@ describe('CommandPalette', () => {
render(
<CommandRegistryProvider initial={seed}>
<CommandPalette open={false} onClose={() => {}} />
</CommandRegistryProvider>
</CommandRegistryProvider>,
);
expect(screen.queryByTestId('bl-cmdk')).toBeNull();
});
@ -135,7 +137,7 @@ describe('CommandPalette', () => {
render(
<CommandRegistryProvider initial={seed}>
<CommandPalette open onClose={() => {}} />
</CommandRegistryProvider>
</CommandRegistryProvider>,
);
expect(screen.getByTestId('bl-cmdk-panel')).toBeDefined();
expect(screen.getByTestId('bl-cmdk-item-new-task')).toBeDefined();
@ -147,7 +149,7 @@ describe('CommandPalette', () => {
render(
<CommandRegistryProvider initial={seed}>
<CommandPalette open onClose={() => {}} />
</CommandRegistryProvider>
</CommandRegistryProvider>,
);
expect(screen.queryByTestId('bl-cmdk-item-gated')).toBeNull();
});
@ -156,7 +158,7 @@ describe('CommandPalette', () => {
render(
<CommandRegistryProvider initial={seed}>
<CommandPalette open onClose={() => {}} />
</CommandRegistryProvider>
</CommandRegistryProvider>,
);
const dialog = screen.getByTestId('bl-cmdk');
fireEvent.keyDown(dialog, { key: 'Tab' });
@ -169,7 +171,7 @@ describe('CommandPalette', () => {
render(
<CommandRegistryProvider initial={seed}>
<CommandPalette open onClose={() => {}} />
</CommandRegistryProvider>
</CommandRegistryProvider>,
);
fireEvent.change(screen.getByTestId('bl-cmdk-input'), {
target: { value: 'task' },
@ -182,10 +184,10 @@ describe('CommandPalette', () => {
render(
<CommandRegistryProvider initial={seed}>
<CommandPalette open onClose={onClose} />
</CommandRegistryProvider>
</CommandRegistryProvider>,
);
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();
});
@ -195,7 +197,7 @@ describe('CommandPalette', () => {
render(
<CommandRegistryProvider initial={seed}>
<CommandPalette open onClose={onClose} onNavigate={onNavigate} />
</CommandRegistryProvider>
</CommandRegistryProvider>,
);
fireEvent.keyDown(screen.getByTestId('bl-cmdk'), { key: 'Tab' });
fireEvent.keyDown(screen.getByTestId('bl-cmdk'), { key: 'Enter' });
@ -207,7 +209,7 @@ describe('CommandPalette', () => {
render(
<CommandRegistryProvider initial={seed}>
<CommandPalette open onClose={() => {}} />
</CommandRegistryProvider>
</CommandRegistryProvider>,
);
const dialog = screen.getByTestId('bl-cmdk');
fireEvent.keyDown(dialog, { key: 'ArrowDown' });
@ -223,7 +225,7 @@ describe('CommandPalette', () => {
render(
<CommandRegistryProvider initial={seed}>
<CommandPalette open onClose={onClose} />
</CommandRegistryProvider>
</CommandRegistryProvider>,
);
fireEvent.keyDown(document, { key: 'Escape' });
expect(onClose).toHaveBeenCalledOnce();
@ -233,7 +235,7 @@ describe('CommandPalette', () => {
const { rerender } = render(
<CommandRegistryProvider initial={seed}>
<CommandPalette open onClose={() => {}} initialMode="ask-ai" />
</CommandRegistryProvider>
</CommandRegistryProvider>,
);
expect(screen.getByTestId('bl-cmdk-ask-ai')).toBeDefined();
@ -245,7 +247,7 @@ describe('CommandPalette', () => {
initialMode="ask-ai"
askAiPanel={q => <div data-testid="custom-ai">{q || 'idle'}</div>}
/>
</CommandRegistryProvider>
</CommandRegistryProvider>,
);
expect(screen.getByTestId('custom-ai').textContent).toBe('idle');
});
@ -254,12 +256,12 @@ describe('CommandPalette', () => {
render(
<CommandRegistryProvider initial={seed}>
<CommandPalette open onClose={() => {}} />
</CommandRegistryProvider>
</CommandRegistryProvider>,
);
const row = screen.getByTestId('bl-cmdk-item-archive');
expect(row.getAttribute('aria-disabled')).toBe('true');
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', () => {
@ -292,7 +294,7 @@ describe('CommandPalette', () => {
render(
<CommandRegistryProvider initial={seed}>
<CommandPalette open onClose={() => {}} />
</CommandRegistryProvider>
</CommandRegistryProvider>,
);
fireEvent.click(screen.getByTestId('bl-cmdk-item-new-task'));
expect(mem['bl-cmdk-recents']).toContain('new-task');
@ -313,12 +315,16 @@ describe('useCommandPalette', () => {
expect(result.current.open).toBe(false);
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);
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);
@ -329,19 +335,6 @@ describe('useCommandPalette', () => {
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', () => {
const { result } = renderHook(() => useCommandPalette({ hotkey: null }));
act(() => result.current.show());
@ -355,7 +348,9 @@ describe('useCommandPalette', () => {
});
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);
});
});

View File

@ -32,9 +32,10 @@ export interface UseCommandPaletteHelpers {
* inputs unless the user explicitly holds the modifier.
*/
export function useCommandPalette(
options: UseCommandPaletteOptions = {}
options: UseCommandPaletteOptions = {},
): 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 show = useCallback(() => setOpen(true), []);
@ -44,9 +45,6 @@ export function useCommandPalette(
useEffect(() => {
if (!hotkey) return;
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;
const wantMeta = hotkey.meta ?? 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(
database: Database,
name: string,
config: ContainerConfig
): Promise<void> {
const indexingPolicy = buildIndexingPolicy(config);
const payload = {
id: name,
partitionKey: {
@ -156,27 +95,14 @@ async function createContainerSafe(
kind: 'Hash',
} as PartitionKeyDefinition,
...(config.defaultTtl != null && { defaultTtl: config.defaultTtl }),
...(indexingPolicy && { indexingPolicy }),
};
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
await database.containers.createIfNotExists(payload);
// createIfNotExists won't update an existing container's index policy.
if (indexingPolicy) await ensureIndexingPolicy(database, name, config);
return;
} catch (err) {
if (isCosmosConflict(err)) {
// 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;
}
if (isCosmosConflict(err)) return; // Container was created by another process.
// Sometimes the database/container metadata isn't immediately visible after creation.
if (isCosmosNotFound(err) && attempt < 2) {

View File

@ -5,4 +5,4 @@ export {
initializeAllContainers,
_resetRegistry,
} 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 {
partitionKeyPath: string;
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",
"types": "./dist/index.d.ts",
"files": [
"dist"
],
"files": ["dist"],
"scripts": {
"build": "tsc",
"test": "vitest run --pool forks",
@ -27,7 +25,7 @@
"@testing-library/react": "^16.3.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"happy-dom": "^20.9.0",
"happy-dom": "^18.0.1",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"typescript": "^5.7.3",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -25,7 +25,7 @@
"@testing-library/react": "^16.3.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"happy-dom": "^20.9.0",
"happy-dom": "^18.0.1",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"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",
"description": "DevOps scripts for the ByteLyst ecosystem (migration, audit, maintenance)",
"dependencies": {
"@azure/cosmos": "^4.9.3",
"@azure/cosmos": "^4.2.0",
"@bytelyst/field-encrypt": "workspace:*"
},
"devDependencies": {
"@types/node": "^25.9.1",
"@types/node": "^22.12.0",
"tsx": "^4.19.2",
"typescript": "^5.7.3"
}

View File

@ -18,21 +18,21 @@
"@bytelyst/backend-flags": "workspace:*",
"@bytelyst/backend-telemetry": "workspace:*",
"@bytelyst/config": "workspace:*",
"@bytelyst/errors": "workspace:*",
"@bytelyst/events": "workspace:*",
"@bytelyst/errors": "workspace:*",
"@bytelyst/fastify-auth": "workspace:*",
"@bytelyst/fastify-core": "workspace:*",
"@bytelyst/llm-router": "workspace:*",
"@bytelyst/logger": "workspace:*",
"@bytelyst/push": "workspace:*",
"@fastify/cors": "^11.2.0",
"@fastify/cors": "^10.0.2",
"fastify": "^5.2.1",
"jose": "^6.2.3",
"jose": "^6.0.11",
"zod": "^3.24.2"
},
"devDependencies": {
"@bytelyst/testing": "workspace:*",
"@types/node": "^25.9.1",
"@types/node": "^22.12.0",
"tsx": "^4.19.2",
"typescript": "^5.7.3",
"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"
},
"dependencies": {
"@azure/cosmos": "^4.9.3",
"@bytelyst/auth": "workspace:*",
"@bytelyst/config": "workspace:*",
"@bytelyst/cosmos": "workspace:*",
"@bytelyst/errors": "workspace:*",
"@bytelyst/fastify-core": "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/swagger": "^9.4.2",
"@fastify/swagger-ui": "^5.2.5",
"fastify": "^5.2.1",
"fastify-metrics": "^10.3.0",
"jose": "^6.2.3",
"jose": "^6.0.8",
"redis": "^4.7.0",
"zod": "^3.24.2"
},
"devDependencies": {
"@types/node": "^25.9.1",
"@types/node": "^22.12.0",
"tsx": "^4.19.2",
"typescript": "^5.7.3",
"vitest": "^3.0.5"

View File

@ -16,13 +16,13 @@
"@bytelyst/config": "workspace:*",
"@bytelyst/errors": "workspace:*",
"@bytelyst/fastify-core": "workspace:*",
"@fastify/cors": "^11.2.0",
"@fastify/cors": "^10.0.2",
"fastify": "^5.2.1",
"jose": "^5.10.0",
"jose": "^5.9.6",
"zod": "^3.24.2"
},
"devDependencies": {
"@types/node": "^25.9.1",
"@types/node": "^22.12.0",
"tsx": "^4.19.2",
"typescript": "^5.7.3",
"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:
- name: Prometheus
type: prometheus
uid: prometheus
access: proxy
url: http://prometheus:9090
editable: false

View File

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

View File

@ -8,21 +8,6 @@ scrape_configs:
- targets:
- 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
static_configs:
- 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/
dist/
# Local datastore (memory provider event log) — never commit
.data/

View File

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

View File

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

View File

@ -188,35 +188,14 @@ const CONTAINER_DEFS: Record<string, ContainerConfig> = {
translations: { partitionKeyPath: '/locale' },
i18n_locales: { partitionKeyPath: '/locale' },
// Agent Gigafactory — fleet coordinator (see modules/fleet/README.md)
fleet_jobs: {
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_jobs: { partitionKeyPath: '/productId' },
fleet_runs: { partitionKeyPath: '/jobId' },
// TTL backstop (2 days) for lease docs: a HELD lease is renewed continuously so
// 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_leases: { partitionKeyPath: '/jobId' },
fleet_factories: { partitionKeyPath: '/productId' },
fleet_profiles: { partitionKeyPath: '/productId' },
fleet_events: { partitionKeyPath: '/jobId' },
fleet_artifacts: { partitionKeyPath: '/jobId' },
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> {

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 { BadRequestError, ForbiddenError } from './errors.js';
import { BadRequestError } from './errors.js';
import { isValidProduct, getProduct } from '../modules/products/cache.js';
import type { ProductDoc } from '../modules/products/types.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). */
export interface JwtPayload {
@ -137,42 +136,3 @@ export function getRequestProductConfig(req: FastifyRequest): ProductDoc {
const id = getRequestProductId(req);
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,
productId: idp.productId,
plan: (user.plan ?? 'free') as 'free' | 'pro' | 'enterprise',
displayName: user.displayName,
});
const refreshToken = await jwt.createRefreshToken({
sub: user.id,
@ -333,7 +332,6 @@ export async function enterpriseRoutes(app: FastifyInstance) {
role: user.role as string,
productId: idp.productId,
plan: (user.plan ?? 'free') as 'free' | 'pro' | 'enterprise',
displayName: user.displayName,
});
const refreshToken = await jwt.createRefreshToken({
sub: user.id,

View File

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

View File

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

View File

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

View File

@ -247,7 +247,6 @@ export async function passkeyRoutes(app: FastifyInstance) {
role: user.role,
productId: body.productId,
plan: user.plan,
displayName: user.displayName,
});
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