Compare commits
96 Commits
4777b28698
...
11f609eb3d
| Author | SHA1 | Date | |
|---|---|---|---|
| 11f609eb3d | |||
|
|
1b6e644ea6 | ||
|
|
4ac5a747d1 | ||
|
|
e6611cae1a | ||
|
|
6bddc88f0f | ||
|
|
c63736459b | ||
|
|
321cfe7546 | ||
|
|
93d1caf4a2 | ||
|
|
acf7c36cda | ||
|
|
42c63dcc6e | ||
|
|
2c4357b71b | ||
|
|
705d8e8eaa | ||
|
|
bcd806c6ff | ||
|
|
bdbb0a8ce4 | ||
|
|
5413c0e789 | ||
|
|
141435fe95 | ||
|
|
a6adaee835 | ||
|
|
9e0afc23d2 | ||
|
|
6770bbeef2 | ||
|
|
493027fbad | ||
|
|
42d27d8a4f | ||
|
|
68bfa3dbd8 | ||
|
|
0bf8be9be5 | ||
|
|
2ed19464c5 | ||
|
|
f0a30b8356 | ||
|
|
d318e0fa2a | ||
|
|
6c31577cf2 | ||
|
|
928edad0af | ||
|
|
5262583e8b | ||
|
|
5ad521ad4c | ||
|
|
e4c84acf29 | ||
|
|
14e982d04f | ||
|
|
b7df779e1d | ||
|
|
cb4f7a7606 | ||
|
|
78c4e47460 | ||
|
|
ba7db0008d | ||
|
|
5bc72cf221 | ||
|
|
65c7d09584 | ||
|
|
6709862c1a | ||
|
|
2bd97791c9 | ||
|
|
740335a149 | ||
|
|
37d049eb69 | ||
|
|
696ee4189e | ||
|
|
e9c1714c13 | ||
|
|
c239abeec9 | ||
|
|
2adddce754 | ||
|
|
d350159025 | ||
|
|
883cf329e5 | ||
|
|
8b9ca4fee2 | ||
|
|
176b778a1f | ||
|
|
c773742658 | ||
|
|
de7e0dcb84 | ||
|
|
60989d7b62 | ||
|
|
bcdb5dcdf3 | ||
|
|
f7740f13e9 | ||
|
|
06d7d881a0 | ||
|
|
eb702137de | ||
|
|
8fe26027e7 | ||
|
|
fa419e3b80 | ||
|
|
3022e634b8 | ||
|
|
58ed096ac8 | ||
|
|
0079274bff | ||
|
|
66a4a74aa6 | ||
|
|
168bff6e27 | ||
|
|
83745b2fee | ||
|
|
4c974b94e3 | ||
|
|
808f615124 | ||
|
|
a528ffea1d | ||
|
|
1503ef2e19 | ||
|
|
c1aad8a819 | ||
|
|
32e426d423 | ||
|
|
939c7b4621 | ||
|
|
2253f888c7 | ||
|
|
8158efacf7 | ||
|
|
6d66355a22 | ||
|
|
cad75b1c02 | ||
|
|
e0a904c7ea | ||
|
|
c1f85050a0 | ||
|
|
20bcbd9f19 | ||
|
|
fc000c4c20 | ||
|
|
0799f69c30 | ||
|
|
3de2a797b9 | ||
|
|
5df580d999 | ||
|
|
932951dbaf | ||
|
|
4ac499f301 | ||
|
|
c9c2c174db | ||
|
|
d780739cbe | ||
|
|
ea42602407 | ||
|
|
1ae15a7755 | ||
|
|
89860e39f9 | ||
|
|
3f850b7b6f | ||
|
|
2d5f9be642 | ||
|
|
69f553d432 | ||
|
|
c8ab43d3ae | ||
|
|
283383561c | ||
|
|
0f903b935a |
24
.env.example
24
.env.example
@ -99,3 +99,27 @@ 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
2
.gitattributes
vendored
@ -1,2 +1,4 @@
|
||||
* text=auto eol=lf
|
||||
|
||||
# Bash scripts must use LF so they run in WSL/Linux
|
||||
*.sh text eol=lf
|
||||
|
||||
@ -48,3 +48,30 @@ 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
1
.gitignore
vendored
@ -3,6 +3,7 @@ dist/
|
||||
coverage/
|
||||
.DS_Store
|
||||
*.tsbuildinfo
|
||||
graphify-out/
|
||||
|
||||
# Env / Secrets
|
||||
.env
|
||||
|
||||
15
.prettierignore
Normal file
15
.prettierignore
Normal file
@ -0,0 +1,15 @@
|
||||
# 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/
|
||||
@ -12,15 +12,17 @@
|
||||
|
||||
## What's here
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
| 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) |
|
||||
|
||||
---
|
||||
|
||||
@ -95,4 +97,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-28_
|
||||
_Last updated: 2026-05-30_
|
||||
|
||||
190
AI.dev/CHEATSHEETS/long-running-jobs.md
Normal file
190
AI.dev/CHEATSHEETS/long-running-jobs.md
Normal file
@ -0,0 +1,190 @@
|
||||
# 🌙 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`)._
|
||||
@ -1,9 +1,9 @@
|
||||
Last refresh: 2026-05-29T17:09:25Z (2026-05-29 10:09:25 PDT)
|
||||
Cascade conversations: 50 (502M)
|
||||
Last refresh: 2026-05-31T06:31:25Z (2026-05-30 23:31:25 PDT)
|
||||
Cascade conversations: 50 (537M)
|
||||
Memories: 138
|
||||
Implicit context: 20
|
||||
Code tracker dirs: 43
|
||||
File edit history: 5395 entries
|
||||
Code tracker dirs: 64
|
||||
File edit history: 5408 entries
|
||||
Workspace storage: 52 workspaces
|
||||
Repo docs: 7 files across 2 repos
|
||||
Repo workflows: 56 files across 13 repos
|
||||
|
||||
@ -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,21 +134,20 @@ 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
|
||||
|
||||
|
||||
@ -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,14 +72,12 @@ 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`)
|
||||
@ -90,25 +88,21 @@ 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`)
|
||||
|
||||
@ -21,7 +21,7 @@
|
||||
"prepare": "husky install"
|
||||
},
|
||||
"dependencies": {
|
||||
"@azure/cosmos": "^4.9.1",
|
||||
"@azure/cosmos": "^4.9.3",
|
||||
"@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.1.3",
|
||||
"jose": "^6.2.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": "^2.4.6",
|
||||
"@types/node": "^20",
|
||||
"@types/bcryptjs": "^3.0.0",
|
||||
"@types/node": "^25.9.1",
|
||||
"@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": "^15.0.0",
|
||||
"lint-staged": "^16.4.0",
|
||||
"prettier": "^3.0.0",
|
||||
"shadcn": "^3.8.4",
|
||||
"tailwindcss": "^4",
|
||||
|
||||
@ -7,6 +7,12 @@
|
||||
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
|
||||
|
||||
350
dashboards/tracker-web/e2e/fleet.spec.ts
Normal file
350
dashboards/tracker-web/e2e/fleet.spec.ts
Normal file
@ -0,0 +1,350 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
37
dashboards/tracker-web/launchd/README.md
Normal file
37
dashboards/tracker-web/launchd/README.md
Normal file
@ -0,0 +1,37 @@
|
||||
# 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.
|
||||
110
dashboards/tracker-web/launchd/install.sh
Executable file
110
dashboards/tracker-web/launchd/install.sh
Executable file
@ -0,0 +1,110 @@
|
||||
#!/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)."
|
||||
44
dashboards/tracker-web/launchd/tracker-web-boot.sh
Executable file
44
dashboards/tracker-web/launchd/tracker-web-boot.sh
Executable file
@ -0,0 +1,44 @@
|
||||
#!/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
|
||||
@ -34,10 +34,10 @@
|
||||
"@bytelyst/data-viz": "workspace:*",
|
||||
"@bytelyst/design-tokens": "workspace:*",
|
||||
"@bytelyst/errors": "workspace:*",
|
||||
"@bytelyst/notifications-ui": "workspace:*",
|
||||
"@bytelyst/telemetry-client": "workspace:*",
|
||||
"@bytelyst/logger": "workspace:*",
|
||||
"@bytelyst/motion": "workspace:*",
|
||||
"@bytelyst/notifications-ui": "workspace:*",
|
||||
"@bytelyst/telemetry-client": "workspace:*",
|
||||
"@bytelyst/ui": "workspace:*",
|
||||
"clsx": "^2.1.1",
|
||||
"next": "16.1.6",
|
||||
@ -49,15 +49,16 @@
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/node": "^25.9.1",
|
||||
"@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": "^15.0.0",
|
||||
"lint-staged": "^16.4.0",
|
||||
"prettier": "^3.0.0",
|
||||
"tailwindcss": "^4",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
// @vitest-environment jsdom
|
||||
// @vitest-environment happy-dom
|
||||
/**
|
||||
* UX-5.3 — command palette behaviour.
|
||||
*
|
||||
|
||||
@ -15,15 +15,25 @@ 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', () => {
|
||||
@ -74,6 +84,99 @@ 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', () => {
|
||||
@ -114,6 +217,65 @@ 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' }] });
|
||||
@ -128,6 +290,64 @@ 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 });
|
||||
@ -167,5 +387,155 @@ 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' }) })
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
178
dashboards/tracker-web/src/__tests__/fleet-overview.test.tsx
Normal file
178
dashboards/tracker-web/src/__tests__/fleet-overview.test.tsx
Normal file
@ -0,0 +1,178 @@
|
||||
// @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();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,51 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { describe, expect, it, beforeAll, beforeEach, vi } from 'vitest';
|
||||
import { describe, expect, it, beforeAll, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { act, createElement } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
|
||||
@ -14,6 +14,10 @@ beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function renderProductHarness() {
|
||||
const container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
@ -73,4 +77,51 @@ 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());
|
||||
});
|
||||
});
|
||||
|
||||
@ -0,0 +1,60 @@
|
||||
// @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());
|
||||
});
|
||||
});
|
||||
77
dashboards/tracker-web/src/__tests__/products-proxy.test.ts
Normal file
77
dashboards/tracker-web/src/__tests__/products-proxy.test.ts
Normal file
@ -0,0 +1,77 @@
|
||||
/**
|
||||
* 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' });
|
||||
});
|
||||
});
|
||||
@ -9,7 +9,8 @@ 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;
|
||||
const targetPath = `/fleet/${path.join('/')}`;
|
||||
// platform-service mounts fleet routes under the /api prefix.
|
||||
const targetPath = `/api/fleet/${path.join('/')}`;
|
||||
const url = new URL(targetPath, PLATFORM_API);
|
||||
|
||||
req.nextUrl.searchParams.forEach((value, key) => {
|
||||
@ -17,7 +18,7 @@ async function proxy(req: NextRequest, { params }: { params: Promise<{ path: str
|
||||
});
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
const headers: Record<string, string> = {};
|
||||
const auth = req.headers.get('authorization');
|
||||
if (auth) headers['Authorization'] = auth;
|
||||
|
||||
@ -36,7 +37,13 @@ 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;
|
||||
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';
|
||||
}
|
||||
}
|
||||
|
||||
const res = await fetch(url.toString(), fetchOptions);
|
||||
|
||||
@ -0,0 +1,56 @@
|
||||
/**
|
||||
* 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;
|
||||
@ -11,11 +11,23 @@ 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,
|
||||
});
|
||||
|
||||
@ -4,11 +4,20 @@ 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, pauseBudget, resumeBudget, type FleetBudget } from '@/lib/fleet-client';
|
||||
import {
|
||||
getBudget,
|
||||
getBudgetBurndown,
|
||||
pauseBudget,
|
||||
resumeBudget,
|
||||
budgetUsagePct,
|
||||
type FleetBudget,
|
||||
type CostBurndown,
|
||||
} 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 =
|
||||
@ -20,8 +29,9 @@ export default function FleetBudgetPage() {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const b = await getBudget(productId);
|
||||
const [b, bd] = await Promise.all([getBudget(productId), getBudgetBurndown(productId, 30)]);
|
||||
setBudget(b);
|
||||
setBurndown(bd);
|
||||
} catch {
|
||||
setBudget(null);
|
||||
}
|
||||
@ -103,23 +113,34 @@ export default function FleetBudgetPage() {
|
||||
</span>
|
||||
</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>
|
||||
{/* 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>
|
||||
);
|
||||
})()}
|
||||
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<p>Window: {budget.window}</p>
|
||||
@ -152,6 +173,62 @@ 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>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -3,11 +3,18 @@
|
||||
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, type FleetJob } from '@/lib/fleet-client';
|
||||
import {
|
||||
listJobs,
|
||||
submitJob,
|
||||
availableEnginesForProduct,
|
||||
FLEET_ENGINES,
|
||||
type FleetJob,
|
||||
type FleetEngine,
|
||||
} from '@/lib/fleet-client';
|
||||
|
||||
const STAGES = [
|
||||
'',
|
||||
'queued',
|
||||
'blocked',
|
||||
'assigned',
|
||||
@ -17,27 +24,107 @@ 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 params: Record<string, string> = { limit: '50' };
|
||||
if (stage) params.stage = stage;
|
||||
const res = await listJobs(params as never);
|
||||
const res = await listJobs({ limit: '100' } as never);
|
||||
setJobs(res.jobs);
|
||||
} catch {
|
||||
/* degrade */
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [stage]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
@ -46,69 +133,399 @@ 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" />
|
||||
|
||||
{/* Filters */}
|
||||
<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"
|
||||
{/* 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}
|
||||
>
|
||||
{STAGES.map(s => (
|
||||
<option key={s} value={s}>
|
||||
{s || 'All'}
|
||||
</option>
|
||||
<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>
|
||||
))}
|
||||
</select>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
{loading ? (
|
||||
<p className="text-muted-foreground">Loading jobs...</p>
|
||||
) : jobs.length === 0 ? (
|
||||
<p className="text-muted-foreground">No jobs match the current filter.</p>
|
||||
) : visible.length === 0 ? (
|
||||
<p className="text-muted-foreground">
|
||||
{jobs.length === 0 ? 'No jobs yet — submit one above.' : 'No jobs match the filters.'}
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<div className="overflow-x-auto rounded-lg border">
|
||||
<table className="w-full text-sm" aria-label="Fleet jobs table">
|
||||
<thead>
|
||||
<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 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>
|
||||
</thead>
|
||||
<tbody>
|
||||
{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">
|
||||
{visible.map(j => (
|
||||
<tr key={j.id} className="border-b last:border-0 hover:bg-muted/40">
|
||||
<td className="px-3 py-2">
|
||||
<Link
|
||||
href={`/dashboard/fleet/jobs/${j.id}`}
|
||||
className="hover:underline font-mono text-xs"
|
||||
className="font-mono text-xs hover:underline"
|
||||
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="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}
|
||||
<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)}
|
||||
</span>
|
||||
</td>
|
||||
<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">
|
||||
<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">
|
||||
{new Date(j.createdAt).toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@ -4,7 +4,15 @@ 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, type FleetFactory, type FleetJob } from '@/lib/fleet-client';
|
||||
import {
|
||||
listFactories,
|
||||
listJobs,
|
||||
getFleetMetrics,
|
||||
operatorAction,
|
||||
type FleetFactory,
|
||||
type FleetJob,
|
||||
type FleetMetrics,
|
||||
} from '@/lib/fleet-client';
|
||||
|
||||
const POLL_INTERVAL = 30_000;
|
||||
|
||||
@ -23,6 +31,15 @@ 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',
|
||||
@ -40,17 +57,114 @@ 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] = await Promise.all([listFactories(), listJobs({ limit: 10 })]);
|
||||
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[] })),
|
||||
]);
|
||||
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 {
|
||||
@ -58,6 +172,21 @@ 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();
|
||||
@ -78,6 +207,101 @@ 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>
|
||||
|
||||
@ -5,6 +5,10 @@ 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}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react';
|
||||
import { PRODUCT_ID } from './product-constants';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
@ -54,10 +55,15 @@ 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 }),
|
||||
body: JSON.stringify({ email, password, productId }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
|
||||
@ -8,6 +8,10 @@ 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;
|
||||
@ -23,6 +27,20 @@ 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 {
|
||||
@ -36,6 +54,27 @@ 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;
|
||||
@ -45,7 +84,10 @@ export interface FleetRun {
|
||||
startedAt: string;
|
||||
endedAt?: string;
|
||||
result?: string;
|
||||
insights: Record<string, unknown>;
|
||||
insights: FleetRunInsights;
|
||||
prUrl?: string;
|
||||
branch?: string;
|
||||
prState?: 'open' | 'merged';
|
||||
}
|
||||
|
||||
export interface FleetEvent {
|
||||
@ -77,6 +119,20 @@ 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;
|
||||
@ -87,6 +143,33 @@ 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({
|
||||
@ -134,41 +217,404 @@ export async function getJob(id: string): Promise<FleetJob | null> {
|
||||
return apiFetchOptional(`/jobs/${id}`);
|
||||
}
|
||||
|
||||
export async function patchJob(
|
||||
id: string,
|
||||
body: { leaseEpoch: number; stage: string }
|
||||
): Promise<FleetJob> {
|
||||
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(
|
||||
id: string,
|
||||
policy?: { requiredApprovals?: number; reviewers?: 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),
|
||||
});
|
||||
}
|
||||
|
||||
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(): Promise<{ factories: FleetFactory[] }> {
|
||||
export async function listFactories(productId?: string): Promise<{ factories: FleetFactory[] }> {
|
||||
const headers = productId ? { 'x-product-id': productId } : undefined;
|
||||
try {
|
||||
return await apiFetch('/factories');
|
||||
return await apiFetch('/factories', headers ? { headers } : undefined);
|
||||
} 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 0–100 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}`);
|
||||
}
|
||||
@ -191,3 +637,11 @@ 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}`);
|
||||
}
|
||||
|
||||
@ -10,6 +10,14 @@ 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;
|
||||
@ -22,11 +30,3 @@ 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;
|
||||
|
||||
@ -3,15 +3,62 @@
|
||||
*
|
||||
* 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';
|
||||
|
||||
/** All known products in the ByteLyst ecosystem. */
|
||||
export const KNOWN_PRODUCTS = [
|
||||
/** 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[] = [
|
||||
{ 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;
|
||||
];
|
||||
|
||||
/**
|
||||
* 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
|
||||
);
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from 'react';
|
||||
import { KNOWN_PRODUCTS, PRODUCT_ID } from '@/lib/product-constants';
|
||||
import { KNOWN_PRODUCTS, PRODUCT_ID, type Product } 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: typeof KNOWN_PRODUCTS;
|
||||
products: readonly Product[];
|
||||
}
|
||||
|
||||
const ProductContext = createContext<ProductContextValue | null>(null);
|
||||
@ -20,8 +20,27 @@ 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() {
|
||||
@ -36,6 +55,32 @@ 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') {
|
||||
@ -44,13 +89,11 @@ export function ProductProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const product = KNOWN_PRODUCTS.find(p => p.id === productId);
|
||||
const product = products.find(p => p.id === productId);
|
||||
const productName = product?.name ?? productId;
|
||||
|
||||
return (
|
||||
<ProductContext.Provider
|
||||
value={{ productId, productName, setProductId, products: KNOWN_PRODUCTS }}
|
||||
>
|
||||
<ProductContext.Provider value={{ productId, productName, setProductId, products }}>
|
||||
{children}
|
||||
</ProductContext.Provider>
|
||||
);
|
||||
|
||||
@ -5,6 +5,7 @@ export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
globals: true,
|
||||
setupFiles: ['./vitest.setup.ts'],
|
||||
exclude: ['e2e/**', 'node_modules/**', '.next/**'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
|
||||
47
dashboards/tracker-web/vitest.setup.ts
Normal file
47
dashboards/tracker-web/vitest.setup.ts
Normal file
@ -0,0 +1,47 @@
|
||||
/**
|
||||
* 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -96,6 +96,23 @@ 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
|
||||
@ -282,3 +299,4 @@ volumes:
|
||||
azurite-data:
|
||||
loki-data:
|
||||
grafana-data:
|
||||
prometheus-data:
|
||||
|
||||
@ -23,7 +23,7 @@ score = w.age * ageMinutes + w.priority * priorityOrder + w.retries * attempts +
|
||||
|
||||
### Weight Resolution Order
|
||||
|
||||
1. **Per-request override** — `weights` field in `POST /fleet/jobs/:id/claim` body
|
||||
1. **Per-request override** — `weights` field in the `POST /fleet/claim` body
|
||||
2. **Product registry** — set via `setWeightRegistry({ [productId]: weights })`
|
||||
3. **Defaults** — `{ age: 1, priority: 10, retries: -2, capabilities: 5 }`
|
||||
|
||||
@ -121,23 +121,68 @@ 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/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 |
|
||||
| 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 |
|
||||
|
||||
## Architecture Decisions
|
||||
|
||||
22
docs/GIGAFACTORY/README.md
Normal file
22
docs/GIGAFACTORY/README.md
Normal file
@ -0,0 +1,22 @@
|
||||
# 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`).
|
||||
140
docs/GIGAFACTORY/ROADMAP_COMPLETION_AUDIT.md
Normal file
140
docs/GIGAFACTORY/ROADMAP_COMPLETION_AUDIT.md
Normal file
@ -0,0 +1,140 @@
|
||||
# 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`).
|
||||
86
docs/GIGAFACTORY/TASKS_TO_COMPLETE.md
Normal file
86
docs/GIGAFACTORY/TASKS_TO_COMPLETE.md
Normal file
@ -0,0 +1,86 @@
|
||||
# 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
|
||||
@ -77,7 +77,7 @@
|
||||
|
||||
## Slice 5 — Docs + roadmap
|
||||
|
||||
See `docs/FLEET_CONTROL_PLANE.md` for the operational guide.
|
||||
See `FLEET_CONTROL_PLANE.md` for the operational guide.
|
||||
|
||||
## Follow-ups
|
||||
|
||||
519
docs/runbooks/FLEET_DEVIN_LOCAL_RUN.md
Normal file
519
docs/runbooks/FLEET_DEVIN_LOCAL_RUN.md
Normal file
@ -0,0 +1,519 @@
|
||||
# Runbook — Run a Devin Fleet Job End‑to‑End (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, cost‑incurring, side‑effecting 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 side‑by‑side | 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 First‑time 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 long‑lived 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 PR‑state **reconcile** (§8)
|
||||
and ship‑time `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** → coordinator‑side `gh pr merge` and PR‑reconcile (§8)
|
||||
> are no‑ops 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 host‑running).
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
PR‑mode 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. Sanity‑check 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/
|
||||
> lease‑renew cadence. The coordinator's reaper marks a factory **stale after 90s**
|
||||
> (`DEFAULT_STALE_FACTORY_MS`, a constant — no env knob) and **reclaims its in‑flight
|
||||
> lease**. The default cadence is **300s**, so a busy single‑slot factory looks stale
|
||||
> for most of every cycle and its running job gets requeued mid‑run (`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`.
|
||||
|
||||
### PR‑state reconcile (externally‑merged 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 no‑op in the Docker container (no `gh`).
|
||||
|
||||
---
|
||||
|
||||
## 9. Safety & cost
|
||||
|
||||
- **Billable + autonomous + long‑running.** Each run consumes Devin credits and can
|
||||
run for a long time unattended. Scope jobs deliberately; very large multi‑workstream
|
||||
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** mid‑run 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 git‑ignored; 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/mis‑signed → re‑mint (§5); ensure same `JWT_SECRET` as the running service. |
|
||||
| Job claimed then flips back to `queued` mid‑run; `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` (X‑Product‑Id) for `0 live`. |
|
||||
| `POST …/pr/reconcile` or ship auto‑merge 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 non‑zero with `kill EPERM` after all suites pass | worker‑pool teardown artifact (sandbox), not a test failure → re‑run; all suites already passed. |
|
||||
|
||||
---
|
||||
|
||||
## 12. Copy‑paste 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 file‑watching 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 / mirrored‑networking 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`
|
||||
@ -29,15 +29,15 @@
|
||||
"@changesets/cli": "^2.28.1",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@size-limit/preset-small-lib": "^12.1.0",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/bcryptjs": "^3.0.0",
|
||||
"@types/node": "^25.9.1",
|
||||
"@types/react": "^19.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
||||
"@typescript-eslint/parser": "^8.0.0",
|
||||
"@typescript-eslint/parser": "^8.60.0",
|
||||
"@vitest/coverage-v8": "^3.0.0",
|
||||
"eslint": "^9.0.0",
|
||||
"husky": "^9.0.0",
|
||||
"lint-staged": "^15.0.0",
|
||||
"lint-staged": "^16.4.0",
|
||||
"prettier": "^3.0.0",
|
||||
"size-limit": "^12.1.0",
|
||||
"typescript": "^5.7.0",
|
||||
|
||||
@ -27,7 +27,7 @@
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"happy-dom": "^18.0.1",
|
||||
"happy-dom": "^20.9.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"typescript": "^5.7.3",
|
||||
|
||||
@ -26,7 +26,7 @@
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"happy-dom": "^18.0.1",
|
||||
"happy-dom": "^20.9.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4"
|
||||
}
|
||||
|
||||
@ -27,7 +27,7 @@
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"happy-dom": "^18.0.1",
|
||||
"happy-dom": "^20.9.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"typescript": "^5.7.3",
|
||||
|
||||
@ -27,7 +27,7 @@
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"happy-dom": "^18.0.1",
|
||||
"happy-dom": "^20.9.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"typescript": "^5.7.3",
|
||||
|
||||
@ -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,9 +96,7 @@ describe('CommandRegistryProvider', () => {
|
||||
});
|
||||
|
||||
it('throws when used outside provider', () => {
|
||||
expect(() => renderHook(() => useCommands())).toThrow(
|
||||
/CommandRegistryProvider/,
|
||||
);
|
||||
expect(() => renderHook(() => useCommands())).toThrow(/CommandRegistryProvider/);
|
||||
});
|
||||
});
|
||||
|
||||
@ -128,7 +126,7 @@ describe('CommandPalette', () => {
|
||||
render(
|
||||
<CommandRegistryProvider initial={seed}>
|
||||
<CommandPalette open={false} onClose={() => {}} />
|
||||
</CommandRegistryProvider>,
|
||||
</CommandRegistryProvider>
|
||||
);
|
||||
expect(screen.queryByTestId('bl-cmdk')).toBeNull();
|
||||
});
|
||||
@ -137,7 +135,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();
|
||||
@ -149,7 +147,7 @@ describe('CommandPalette', () => {
|
||||
render(
|
||||
<CommandRegistryProvider initial={seed}>
|
||||
<CommandPalette open onClose={() => {}} />
|
||||
</CommandRegistryProvider>,
|
||||
</CommandRegistryProvider>
|
||||
);
|
||||
expect(screen.queryByTestId('bl-cmdk-item-gated')).toBeNull();
|
||||
});
|
||||
@ -158,7 +156,7 @@ describe('CommandPalette', () => {
|
||||
render(
|
||||
<CommandRegistryProvider initial={seed}>
|
||||
<CommandPalette open onClose={() => {}} />
|
||||
</CommandRegistryProvider>,
|
||||
</CommandRegistryProvider>
|
||||
);
|
||||
const dialog = screen.getByTestId('bl-cmdk');
|
||||
fireEvent.keyDown(dialog, { key: 'Tab' });
|
||||
@ -171,7 +169,7 @@ describe('CommandPalette', () => {
|
||||
render(
|
||||
<CommandRegistryProvider initial={seed}>
|
||||
<CommandPalette open onClose={() => {}} />
|
||||
</CommandRegistryProvider>,
|
||||
</CommandRegistryProvider>
|
||||
);
|
||||
fireEvent.change(screen.getByTestId('bl-cmdk-input'), {
|
||||
target: { value: 'task' },
|
||||
@ -184,10 +182,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();
|
||||
});
|
||||
|
||||
@ -197,7 +195,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' });
|
||||
@ -209,7 +207,7 @@ describe('CommandPalette', () => {
|
||||
render(
|
||||
<CommandRegistryProvider initial={seed}>
|
||||
<CommandPalette open onClose={() => {}} />
|
||||
</CommandRegistryProvider>,
|
||||
</CommandRegistryProvider>
|
||||
);
|
||||
const dialog = screen.getByTestId('bl-cmdk');
|
||||
fireEvent.keyDown(dialog, { key: 'ArrowDown' });
|
||||
@ -225,7 +223,7 @@ describe('CommandPalette', () => {
|
||||
render(
|
||||
<CommandRegistryProvider initial={seed}>
|
||||
<CommandPalette open onClose={onClose} />
|
||||
</CommandRegistryProvider>,
|
||||
</CommandRegistryProvider>
|
||||
);
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
@ -235,7 +233,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();
|
||||
|
||||
@ -247,7 +245,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');
|
||||
});
|
||||
@ -256,12 +254,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', () => {
|
||||
@ -294,7 +292,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');
|
||||
@ -315,16 +313,12 @@ 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);
|
||||
|
||||
@ -335,6 +329,19 @@ 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());
|
||||
@ -348,9 +355,7 @@ 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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -32,10 +32,9 @@ 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), []);
|
||||
@ -45,6 +44,9 @@ 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;
|
||||
|
||||
@ -83,11 +83,72 @@ 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: {
|
||||
@ -95,14 +156,27 @@ 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)) return; // Container was created by another process.
|
||||
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;
|
||||
}
|
||||
|
||||
// Sometimes the database/container metadata isn't immediately visible after creation.
|
||||
if (isCosmosNotFound(err) && attempt < 2) {
|
||||
|
||||
@ -5,4 +5,4 @@ export {
|
||||
initializeAllContainers,
|
||||
_resetRegistry,
|
||||
} from './containers.js';
|
||||
export type { ContainerConfig } from './types.js';
|
||||
export type { ContainerConfig, CompositeIndexPath } from './types.js';
|
||||
|
||||
@ -1,4 +1,19 @@
|
||||
/** 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[][];
|
||||
}
|
||||
|
||||
@ -11,7 +11,9 @@
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": ["dist"],
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "vitest run --pool forks",
|
||||
@ -25,7 +27,7 @@
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"happy-dom": "^18.0.1",
|
||||
"happy-dom": "^20.9.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"typescript": "^5.7.3",
|
||||
|
||||
@ -27,7 +27,7 @@
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"happy-dom": "^18.0.1",
|
||||
"happy-dom": "^20.9.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"typescript": "^5.7.3",
|
||||
|
||||
@ -27,7 +27,7 @@
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"happy-dom": "^18.0.1",
|
||||
"happy-dom": "^20.9.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"typescript": "^5.7.3",
|
||||
|
||||
@ -32,7 +32,7 @@
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"happy-dom": "^18.0.1",
|
||||
"happy-dom": "^20.9.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"typescript": "^5.7.3",
|
||||
|
||||
@ -11,7 +11,9 @@
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": ["dist"],
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "vitest run --pool forks",
|
||||
@ -25,7 +27,7 @@
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"happy-dom": "^18.0.1",
|
||||
"happy-dom": "^20.9.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"typescript": "^5.7.3",
|
||||
|
||||
@ -25,7 +25,7 @@
|
||||
"zod": "^3.22.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/node": "^25.9.1",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
|
||||
@ -21,7 +21,7 @@
|
||||
"@bytelyst/queue": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.12.0",
|
||||
"@types/node": "^25.9.1",
|
||||
"vitest": "^3.0.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@ -26,7 +26,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"fastify": "^5.2.1",
|
||||
"jose": "^6.0.8",
|
||||
"jose": "^6.2.3",
|
||||
"typescript": "^5.7.3",
|
||||
"vitest": "^3.0.5"
|
||||
},
|
||||
|
||||
@ -11,7 +11,9 @@
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": ["dist"],
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "vitest run --pool forks",
|
||||
@ -25,7 +27,7 @@
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"happy-dom": "^18.0.1",
|
||||
"happy-dom": "^20.9.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"typescript": "^5.7.3",
|
||||
|
||||
@ -11,7 +11,9 @@
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": ["dist"],
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "vitest run --pool forks",
|
||||
@ -25,7 +27,7 @@
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"happy-dom": "^18.0.1",
|
||||
"happy-dom": "^20.9.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"typescript": "^5.7.3",
|
||||
|
||||
@ -11,7 +11,9 @@
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": ["dist"],
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "vitest run --pool forks",
|
||||
@ -25,7 +27,7 @@
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"happy-dom": "^18.0.1",
|
||||
"happy-dom": "^20.9.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"typescript": "^5.7.3",
|
||||
|
||||
@ -11,7 +11,9 @@
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": ["dist"],
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "vitest run --pool forks",
|
||||
@ -25,7 +27,7 @@
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"happy-dom": "^18.0.1",
|
||||
"happy-dom": "^20.9.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"typescript": "^5.7.3",
|
||||
|
||||
@ -19,7 +19,7 @@
|
||||
"test": "vitest run --pool forks"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.12.0",
|
||||
"@types/node": "^25.9.1",
|
||||
"vitest": "^3.0.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@ -19,7 +19,7 @@
|
||||
"test": "vitest run --pool forks"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.12.0",
|
||||
"@types/node": "^25.9.1",
|
||||
"vitest": "^3.0.5"
|
||||
}
|
||||
}
|
||||
|
||||
@ -27,7 +27,7 @@
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"happy-dom": "^18.0.1",
|
||||
"happy-dom": "^20.9.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4"
|
||||
}
|
||||
|
||||
@ -39,7 +39,7 @@
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"happy-dom": "^18.0.1",
|
||||
"happy-dom": "^20.9.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"typescript": "^5.7.3",
|
||||
|
||||
@ -21,7 +21,7 @@
|
||||
"@bytelyst/telemetry-client": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.12.0",
|
||||
"@types/node": "^25.9.1",
|
||||
"typescript": "^5.7.3",
|
||||
"vitest": "^3.0.5"
|
||||
},
|
||||
|
||||
@ -224,7 +224,7 @@
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"happy-dom": "^18.0.1",
|
||||
"happy-dom": "^20.9.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"storybook": "^8.5.0",
|
||||
|
||||
@ -25,7 +25,7 @@
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"happy-dom": "^18.0.1",
|
||||
"happy-dom": "^20.9.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"vitest": "^3.0.0"
|
||||
|
||||
@ -25,7 +25,7 @@
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"happy-dom": "^18.0.1",
|
||||
"happy-dom": "^20.9.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"vitest": "^3.0.0"
|
||||
|
||||
1546
pnpm-lock.yaml
generated
1546
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
194
scripts/cosmos-cost-report.ps1
Normal file
194
scripts/cosmos-cost-report.ps1
Normal file
@ -0,0 +1,194 @@
|
||||
#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 '════════════════════════════════════════════════════════════════'
|
||||
200
scripts/cosmos-cost-report.sh
Executable file
200
scripts/cosmos-cost-report.sh
Executable file
@ -0,0 +1,200 @@
|
||||
#!/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 "════════════════════════════════════════════════════════════════"
|
||||
141
scripts/fleet-logs.sh
Executable file
141
scripts/fleet-logs.sh
Executable file
@ -0,0 +1,141 @@
|
||||
#!/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
|
||||
@ -5,11 +5,11 @@
|
||||
"type": "module",
|
||||
"description": "DevOps scripts for the ByteLyst ecosystem (migration, audit, maintenance)",
|
||||
"dependencies": {
|
||||
"@azure/cosmos": "^4.2.0",
|
||||
"@azure/cosmos": "^4.9.3",
|
||||
"@bytelyst/field-encrypt": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.12.0",
|
||||
"@types/node": "^25.9.1",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
|
||||
@ -18,21 +18,21 @@
|
||||
"@bytelyst/backend-flags": "workspace:*",
|
||||
"@bytelyst/backend-telemetry": "workspace:*",
|
||||
"@bytelyst/config": "workspace:*",
|
||||
"@bytelyst/events": "workspace:*",
|
||||
"@bytelyst/errors": "workspace:*",
|
||||
"@bytelyst/events": "workspace:*",
|
||||
"@bytelyst/fastify-auth": "workspace:*",
|
||||
"@bytelyst/fastify-core": "workspace:*",
|
||||
"@bytelyst/llm-router": "workspace:*",
|
||||
"@bytelyst/logger": "workspace:*",
|
||||
"@bytelyst/push": "workspace:*",
|
||||
"@fastify/cors": "^10.0.2",
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"fastify": "^5.2.1",
|
||||
"jose": "^6.0.11",
|
||||
"jose": "^6.2.3",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@bytelyst/testing": "workspace:*",
|
||||
"@types/node": "^22.12.0",
|
||||
"@types/node": "^25.9.1",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.3",
|
||||
"vitest": "^3.0.5"
|
||||
|
||||
@ -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:*",
|
||||
"@azure/cosmos": "^4.2.0",
|
||||
"@fastify/cors": "^10.0.2",
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"@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.0.8",
|
||||
"jose": "^6.2.3",
|
||||
"redis": "^4.7.0",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.12.0",
|
||||
"@types/node": "^25.9.1",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.3",
|
||||
"vitest": "^3.0.5"
|
||||
|
||||
@ -16,13 +16,13 @@
|
||||
"@bytelyst/config": "workspace:*",
|
||||
"@bytelyst/errors": "workspace:*",
|
||||
"@bytelyst/fastify-core": "workspace:*",
|
||||
"@fastify/cors": "^10.0.2",
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"fastify": "^5.2.1",
|
||||
"jose": "^5.9.6",
|
||||
"jose": "^5.10.0",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.12.0",
|
||||
"@types/node": "^25.9.1",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.3",
|
||||
"vitest": "^3.0.5"
|
||||
|
||||
234
services/monitoring/grafana/dashboards/fleet-overview.json
Normal file
234
services/monitoring/grafana/dashboards/fleet-overview.json
Normal file
@ -0,0 +1,234 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -3,6 +3,7 @@ apiVersion: 1
|
||||
datasources:
|
||||
- name: Prometheus
|
||||
type: prometheus
|
||||
uid: prometheus
|
||||
access: proxy
|
||||
url: http://prometheus:9090
|
||||
editable: false
|
||||
|
||||
@ -10,7 +10,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@bytelyst/monitoring": "workspace:*",
|
||||
"@types/node": "^22.12.0",
|
||||
"@types/node": "^25.9.1",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
|
||||
@ -8,6 +8,21 @@ 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:
|
||||
|
||||
@ -1,67 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
3
services/platform-service/.gitignore
vendored
3
services/platform-service/.gitignore
vendored
@ -1,2 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
|
||||
# Local datastore (memory provider event log) — never commit
|
||||
.data/
|
||||
|
||||
@ -15,7 +15,7 @@
|
||||
"gen:module": "tsx scripts/gen-module.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@azure/cosmos": "^4.2.0",
|
||||
"@azure/cosmos": "^4.9.3",
|
||||
"@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": "^10.0.2",
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"@fastify/rate-limit": "^10.3.0",
|
||||
"@fastify/swagger": "^9.4.2",
|
||||
"@fastify/swagger-ui": "^5.2.5",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"fastify": "^5.2.1",
|
||||
"fastify-metrics": "^10.3.0",
|
||||
"fastify-zod-openapi": "^5.5.0",
|
||||
"jose": "^6.0.8",
|
||||
"jose": "^6.2.3",
|
||||
"nodemailer": "^6.10.1",
|
||||
"stripe": "^17.5.0",
|
||||
"stripe": "^20.4.1",
|
||||
"yaml": "^2.8.2",
|
||||
"zod": "^3.24.2",
|
||||
"zod-openapi": "^5.4.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/node": "^22.12.0",
|
||||
"@types/bcryptjs": "^3.0.0",
|
||||
"@types/node": "^25.9.1",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.3",
|
||||
"vitest": "^3.0.5"
|
||||
|
||||
@ -105,6 +105,7 @@ export async function autoRegisterProduct(
|
||||
deviceLimits: { free: 1, pro: 3, enterprise: 10 },
|
||||
websiteUrl: '',
|
||||
status: 'active',
|
||||
ownerId: userId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
@ -188,14 +188,35 @@ 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' },
|
||||
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_runs: { partitionKeyPath: '/jobId' },
|
||||
fleet_leases: { 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_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> {
|
||||
|
||||
54
services/platform-service/src/lib/request-context.test.ts
Normal file
54
services/platform-service/src/lib/request-context.test.ts
Normal file
@ -0,0 +1,54 @@
|
||||
/**
|
||||
* 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');
|
||||
});
|
||||
});
|
||||
@ -10,10 +10,11 @@
|
||||
*/
|
||||
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import { BadRequestError } from './errors.js';
|
||||
import { BadRequestError, ForbiddenError } 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 {
|
||||
@ -136,3 +137,42 @@ 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;
|
||||
}
|
||||
|
||||
@ -223,6 +223,7 @@ 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,
|
||||
@ -332,6 +333,7 @@ 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,
|
||||
|
||||
@ -17,6 +17,7 @@ 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' })
|
||||
@ -44,6 +45,7 @@ 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(), {
|
||||
@ -55,6 +57,7 @@ export async function verifyToken(token: string): Promise<{
|
||||
role?: string;
|
||||
productId?: string;
|
||||
plan?: 'free' | 'pro' | 'enterprise';
|
||||
displayName?: string;
|
||||
type?: string;
|
||||
};
|
||||
}
|
||||
|
||||
@ -121,6 +121,7 @@ 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,
|
||||
|
||||
@ -175,6 +175,7 @@ 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 });
|
||||
|
||||
|
||||
@ -247,6 +247,7 @@ 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
Loading…
Reference in New Issue
Block a user