Compare commits

...

96 Commits

Author SHA1 Message Date
11f609eb3d chore: ignore local graphify outputs
Some checks failed
CI — Common Platform / Build, Test & Typecheck (push) Successful in 1m32s
Publish @bytelyst/* packages / publish (push) Failing after 13s
Size limit / size-limit (push) Failing after 1m56s
CI — Common Platform / Fleet E2E (Playwright) (push) Failing after 2m52s
2026-06-06 02:57:05 +00:00
saravanakumardb1
1b6e644ea6 docs(fleet): add TODO to validate exactly-once claim under true contention
Capture the plan to close the known gap: existing CAS/fencing tests run on
MemoryDatastoreProvider (exact only for sequential calls), so single-winner
behavior is not yet demonstrated under a true interleaved read->write window
or against Cosmos _etag/If-Match. No production change expected; Cosmos
provider already conditions writes with IfMatch. Documents Option B
(adversarial interleaving test, no infra) and Option A (emulator-gated
integration test), acceptance criteria, and downstream doc updates.
2026-06-03 09:55:34 -07:00
saravanakumardb1
4ac5a747d1 feat(scripts): fleet-logs.sh to tail/inspect a Devin fleet job's logs
Convenience CLI over the agent-queue factory logs: resolves the agent-queue
checkout (AQ override or sibling default), takes a full/partial job id (defaults
to newest), and exposes ls/status/tail/steps/watch/full/path over the runner
.log and the live Devin transcript (.devin-export.json steps[]). Referenced from
the §8 Observe section of the fleet run runbook.

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-02 01:09:19 -07:00
saravanakumardb1
e6611cae1a docs(fleet): runbook to run a Devin fleet job end-to-end (local)
Add docs/runbooks/FLEET_DEVIN_LOCAL_RUN.md: how developers and coding agents
spin up platform-service + tracker-web + an agent-queue factory so a submitted
job is claimed and run autonomously by the Devin CLI against a target repo
(worked example: learning_ai_notes), pushing a branch and opening a real PR.

Covers: architecture + lifecycle, prerequisites incl. fresh-machine setup
(clone both repos, .env/Cosmos, pnpm -r build so host-run resolves @bytelyst/*
from dist/), all-localhost (no Docker) path as primary + Docker as the
Grafana/Prometheus option, local JWT minting, job submit, factory launch, observe,
PR-state reconcile, safety/cost, teardown, troubleshooting, and a copy-paste
quickstart.

Calls out two gotchas learned in practice: set AQ_FLEET_LEASE_RENEW_SEC < 90 so
the factory heartbeat beats the coordinator's 90s stale-factory reclaim window
(else a busy single-slot factory's in-flight lease is reclaimed mid-run and the
final report is fenced), and a WSL-on-Windows differences section (run inside
WSL, repos off /mnt/c, LF endings, gh/devin/node in WSL, localhost forwarding).

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-02 00:50:29 -07:00
saravanakumardb1
6bddc88f0f feat(fleet): reconcile PR state against GitHub (detect externally-merged PR)
A PR merged in the GitHub UI was invisible to the platform — prState only
flipped to `merged` when the platform merged it (ghMergePr on ship) or a runner
reported it, so the job details page kept showing the PR as open. This adds a
simple, pull-based reconcile (no inbound webhook / public ingress needed).

- coordinator.reconcileJobPrState(jobId, productId, fetcher?): finds the latest
  run carrying a prUrl and, when `gh pr view --json state` reports MERGED, flips
  the run's prState to `merged` and appends a `pr_merged` event (data.via:
  'reconcile'). The GitHub lookup is injectable for tests; pure `mapGhPrState`
  maps MERGED/OPEN/other. Best-effort: any gh failure is a no-op.
- POST /fleet/jobs/:id/pr/reconcile route; echoes the outcome to the tracker
  Item when a merge is detected.
- tracker-web: reconcilePrState() client + a "Refresh PR status" button on the
  job details PR section (shown until the PR is merged) that reconciles then
  refreshes the view.

Tests: +5 (mapGhPrState, reconcile merged/open/no_pr/not_found, route wiring);
full suite 1861 green; lint + tsc clean (service + tracker-web). Deployed: rebuilt
the docker platform-service; POST .../pr/reconcile returns 401 (wired), not 404.

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-01 23:58:13 -07:00
saravanakumardb1
c63736459b feat(fleet): anti-flap hysteresis + autoscale Prometheus series & dashboard (ops #5)
Make the capacity autoscaling signal safe to act on automatically and observable
in Grafana.

Anti-flap hysteresis:
- New pure applyHysteresis: suppresses a direction reversal (scale_in after
  scale_out, or vice versa) within a cooldown window so a consumer cannot thrash
  capacity. A critical scale-out (queued work, zero usable capacity) always
  bypasses the cooldown. Cooldown anchor only advances on an emitted action, so a
  suppressed signal keeps counting down from the real last action.
- Process-wide per-product cooldown state (mirrors reaper/breaker in-mem state)
  with a test seam; cooldown tunable via FLEET_AUTOSCALE_COOLDOWN_SEC (default 300).
- GET /fleet/autoscale[/all] now serve the debounced (stateful) recommendation.

Observability:
- Prometheus exposition emits the RAW recommendation per product
  (fleet_autoscale_recommended_seats/delta/pressure + one-hot fleet_autoscale_action
  {action}). RAW (not stateful) so a scrape never mutates the cooldown anchors.
- Grafana "Fleet Overview" gains two panels: products recommending scale-out
  (stat) + recommended seat delta vs backlog (timeseries).

Docs: FLEET_AUTOSCALE_COOLDOWN_SEC in .env.example.

Tests: +10 (hysteresis/stateful/cooldown + prom autoscale series); full suite 1856
green; lint + tsc clean. Verified live: a throwaway Prometheus scraped the running
service and the dashboard PromQL returned real scale-out/scale-in recommendations
across products.

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-01 23:02:08 -07:00
saravanakumardb1
321cfe7546 feat(fleet): correlation-id tracing + capacity autoscaling signal (ops #4/#5)
Thread a trace-context correlation id across the coordinator<->runner boundary
so a logical work-unit (job -> claim -> run -> ship) is stitchable end to end,
and add an advisory capacity autoscaling signal an external scaler can consume.

Tracing (#4):
- Mint/propagate a correlationId at submit from the inbound
  x-correlation-id/traceparent/x-request-id (else generate ftr_<uuid>); persist
  it on the job, inherit onto the run + lease at claim, and stamp every
  lifecycle event (submitted/assigned/transition/lease_renewed/lease_released/
  retry_scheduled/dead_letter). Children of a composite job share the parent id.
- Echo it back on the x-correlation-id response header (submit/claim/renew/
  release/patch) so a factory can carry it forward, and bind it to req.log.
- New pure trace.ts (header resolution incl. W3C traceparent trace-id).

Autoscaling signal (#5):
- New pure autoscaler.ts turns a product FleetMetrics + saturation alerts
  (no_live_capacity/saturated/queue_starvation) into an auditable scale
  recommendation (action/recommendedSeats/delta/urgency/signals).
  budget_exhausted suppresses scale-out; idle slack reclaims down to a floor.
  Thresholds tunable via FLEET_AUTOSCALE_* env.
- GET /fleet/autoscale (per-product) + GET /fleet/autoscale/all (global, admin
  or scrape token). Documented the env vars in .env.example.

Tests: +29 (trace 10, tracing 7, autoscaler 12); full suite 1846 green; lint + tsc clean.

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-01 22:43:56 -07:00
saravanakumardb1
93d1caf4a2 feat(fleet): Prometheus metrics export + Grafana dashboard (ops #4)
Exports fleet observability to Prometheus/Grafana (previously JSON-only).

- GET /api/fleet/metrics/prom: global, product-labelled Prometheus exposition
  (queue depth, blocked/active, per-stage histogram, factory health/seats/
  utilization, active alerts, budget spent/ceiling/projected) plus process-wide
  reaper/GC counters and engine circuit-breaker state. Pure renderer
  (renderFleetMetricsProm) is unit-tested; route auth accepts a FLEET_METRICS_TOKEN
  bearer (scrape path) or an admin JWT — never world-readable by default.
- Infra: add a prometheus container to docker-compose + a platform-service-fleet
  scrape job; pin the Prometheus Grafana datasource uid; add a provisioned
  "Fleet Overview" dashboard (breakers, dead-letter, stale factories, alerts,
  queue depth, utilization, budget burn, reaper rate) with a product template var.
- Document FLEET_METRICS_TOKEN + the fleet feature flags in .env.example.

No default behavior change: the endpoint is additive and the new container is
opt-in via the compose stack.

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-01 22:24:03 -07:00
saravanakumardb1
acf7c36cda feat(tracker-web): dynamic, owner-scoped project switcher via /products/mine
Wires the switcher to real per-user data instead of a static list:

- New /api/products/[...path] proxy to platform-service (mirrors the fleet
  proxy), exposing GET /api/products/mine.
- ProductProvider fetches the caller's owner-scoped projects on mount (when a
  tracker_token is present) and uses them as the switcher list. Best-effort:
  any failure / empty result / unauthenticated state keeps the configured
  fallback list, so dev and logged-out rendering still work.

Combined with the earlier config-driven list + auto-hide, the switcher now
reflects the authenticated user's projects on a generic platform.

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-01 16:48:27 -07:00
saravanakumardb1
42c63dcc6e feat(platform): product ownership + owner-scoped "my projects" + tenant guard
Foundation for a generic, multi-tenant platform (any developer, not just the
built-in products).

- Products carry an optional ownerId (set on create + auto-register), so a
  product has a tenant. GET /products/mine returns the caller's owner-scoped
  list; admins/super_admins see all. productsForUser() is pure + unit-tested.
- requireProductAccess(): a flag-gated tenant authorization guard
  (FLEET_TENANT_ENFORCEMENT, default OFF). OFF = byte-for-byte current behavior;
  ON = a non-admin may only act on products they own (others -> 403; owner-less
  legacy products keep a grace allowance until migrated). Fleet routes now
  resolve productId through it in place of getRequestProductId.

ownerId is additive/optional; enforcement is off by default, so this is a
no-op for existing deployments until explicitly enabled.

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-01 16:47:05 -07:00
saravanakumardb1
2c4357b71b feat(tracker-web): make product switcher generic — configurable list + auto-hide
Steps toward a tenant-neutral platform (not hardcoded to the ByteLyst products):

- The selectable product list is now configurable via NEXT_PUBLIC_PRODUCTS
  (JSON array of { id, name, icon? }), defaulting to the built-in set. A pure,
  defensive parser (parseProductsEnv) falls back to the default on any malformed
  value so a bad env can never blank the switcher.
- The sidebar switcher auto-hides when there is <= 1 product, so a solo / freelance
  / single-tenant deployment shows no switcher clutter.
- Dedupe: the server product-config now re-exports the single client-safe list
  instead of keeping a second hardcoded copy.

NOTE: true per-user "only your projects" scoping + server-side tenant
authorization still requires an ownership/membership model that does not exist
yet (ProductDoc has no owner/members; products are a global registry). That is a
deliberate, separate effort needing a product decision and is not included here.

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-01 16:27:14 -07:00
saravanakumardb1
705d8e8eaa feat(tracker-web): consolidated fleet overview — breaker panel, budget guardrail, dead-letter triage
Surfaces the §2/§3 signal on the fleet overview page (read-only over existing
endpoints):

- Budget guardrail card: spend vs ceiling bar, projected end-of-window burn
  (highlighted when over ceiling), and per-engine sub-ceiling breakdown — from
  the new /fleet/metrics budget summary.
- Engine circuit-breaker panel: lists only tripped/probing (factory, engine)
  pairs from metrics.engineBreakers.
- Dead-letter triage table with a Re-drive button wired to the redrive operator
  action; filtered client-side so it is correct regardless of server filtering.

All panels render only when their data is present, so the page is unchanged for
fleets without budgets/breakers/dead-letters. Adds a happy-dom page test.

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-01 13:29:52 -07:00
saravanakumardb1
bcd806c6ff feat(fleet): complete budget enforcement — per-engine ceilings + overspend projection
Builds on the existing product-level hard claim gate + idempotent accrual.

- Per-engine sub-ceilings (engineCeilingsUsd) with per-engine accrual
  (spentByEngineUsd). An engine at its sub-ceiling is routed around at claim
  time via the same per-engine availability gate as the circuit breaker — it
  never pauses the whole product, so other engines keep flowing. Gated by
  FLEET_BUDGETS (defaults off).
- /fleet/metrics now surfaces a budget summary (ceiling/spend/status/projection
  + per-engine breakdown) and derives guardrail alerts: budget_overspend_projected
  (burn-rate extrapolation, guarded against early-window false alarms),
  budget_exhausted, and engine_budget_exhausted. Surfaced whenever a budget exists,
  independent of the enforcement flag, so operators see the burn in dry-run.

projectBudgetSpend is pure + unit-tested; per-engine spend follows the same
idempotent accrual path as the total, so spentUsd and spentByEngineUsd agree.

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-01 13:23:44 -07:00
saravanakumardb1
bdbb0a8ce4 feat(fleet): cost/latency-aware engine routing + per-engine circuit breaker
Adds two additive, flag-gated routing refinements on top of the §7 scoring
core; both default OFF so the deterministic claim path is unchanged.

- FLEET_COST_ROUTING: soft engineQuality term (weight 0.4) biases routing
  toward the historically cheaper/faster engine, derived from per-engine
  insights.costUsd + run duration. No-history engines stay neutral, so the
  nudge can only demote demonstrably costly engines, never penalise new ones.
- FLEET_ENGINE_BREAKER: per-(factory, engine) circuit breaker. releaseLease
  always records outcomes (observable via /fleet/metrics engineBreakers);
  when enabled, an OPEN pair is routed around. Only ever restricts the
  candidate set — never forces a route.

The scheduler stays pure: history lookup + availability gate are injected
predicates. New engineQuality term contributes 0 unless a lookup is supplied,
preserving every existing score/breakdown.

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-01 13:17:25 -07:00
saravanakumardb1
5413c0e789 feat(fleet): reaper/GC telemetry in /fleet/metrics + lease TTL backstop
Observability + defense-in-depth for the recovery/GC machinery:

- The reaper now accumulates process-wide telemetry (getReaperStats): cumulative
  expired/stale reclaims and per-container GC deletions, plus startedAt/last-run
  timestamps. GET /fleet/metrics returns it under a `reaper` field so operators
  can see recovery activity (dead_letter counts/alerts were already added).
- Cosmos TTL backstop on fleet_leases (2 days): a held lease is renewed
  continuously so it never expires while active; only finished leases age out,
  matching the ~24h app GC. Purely defense-in-depth behind the reaper, which still
  OWNS recovery (requeue + epoch bump + checkpoint). TTL is deliberately NOT set on
  fleet_events (ids are <jobId>:evt:<seq> with seq=count, so partial TTL deletion
  could collide ids); events/runs/jobs are pruned by the cascade GC instead.

Memory provider ignores defaultTtl, so tests/dev are unaffected.

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-01 12:18:22 -07:00
saravanakumardb1
141435fe95 feat(fleet): bind advertised capabilities to the enrolled token scope
The claim path already constrained a factory to its enrolled scope, but the
heartbeat trusted self-reported capabilities — so (with enforcement on) a factory
could advertise e.g. engine:codex it was never granted, polluting the engine
picker (GET /fleet/factories) and routing/explain decisions even though a codex
job still couldn't be claimed by it.

Heartbeat now intersects the factory's self-reported capabilities with the token
scope when enforcement is ON: it may report FEWER (an engine temporarily
unavailable) but never MORE than enrolled. Enforcement OFF is unchanged
(self-reported caps pass through verbatim). Covered by new route tests.

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-01 12:14:09 -07:00
saravanakumardb1
a6adaee835 feat(fleet): operator re-drive for dead-letter jobs + dead-letter alert/UI
Closes the loop on the retry automation — a job that exhausts its retries lands
in dead_letter with no way to recover it:

- New `redrive` operator action: requeues the job AND grants a fresh retry budget
  by anchoring a new `attemptsBase` to the current `attempts` (and clearing any
  retryNotBefore backoff). `attempts` stays monotonic so run ids never collide; a
  plain `requeue` leaves the budget exhausted and would instantly re-dead-letter.
  The retry policy now measures used budget as `attempts - attemptsBase`.
- fleetMetrics raises a `dead_letter` warning alert when any job is dead-lettered.
- tracker-web: a "Re-drive" button on dead_letter/failed jobs; the timeline already
  renders the retry_scheduled / dead_letter / pr_merged / pr_merge_failed /
  factory_stale events generically.

Backward compatible: attemptsBase defaults to 0 and old docs without it read as 0.

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-01 12:11:46 -07:00
saravanakumardb1
9e0afc23d2 test(fleet): end-to-end lifecycle/chaos guardrails across coordinator features
Adds cross-feature integration tests that drive the coordinator the way a real
factory does, asserting the newly-wired paths COMPOSE (the per-feature unit
suites only cover them in isolation):

- retry: fail then auto-requeue then fail again then dead_letter, one claim per attempt;
- chaos: factory dies mid-build, stale reclaim fences the zombie, a late
  transition from the dead holder is rejected, survivor claims with a higher
  epoch, builds, and ships with the run result/engine recorded;
- no double-assignment under a true claim race;
- expiry reaper recovers a vanished factory's job (requeue + epoch bump + lease expired).

Test-only; deterministic and offline (memory provider, injected time, no network).

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-01 12:05:07 -07:00
saravanakumardb1
6770bbeef2 feat(fleet): honor job.autoMerge on ship and surface PR-merge failures
job.autoMerge was persisted but ignored — PR merging fired only when the host
set FLEET_SHIP_MERGES_PR=1, and a failed merge was silent (PR left open, no
signal). Now:

- mergeRunPrOnShip merges when EITHER the job opted in (job.autoMerge) OR the
  global flag is set (new pure, unit-tested shouldMergePrOnShip gate). Existing
  global-flag behavior is preserved.
- Merge outcomes are surfaced as job events: pr_merged on success (inline or via
  background retry) and pr_merge_failed when the inline attempt + 4 background
  retries all fail, so a stuck PR shows on the timeline instead of vanishing.

Still fully best-effort and gated (no merge attempted unless opted in), so the
real-world side effect only happens when explicitly requested.

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-01 11:47:33 -07:00
saravanakumardb1
493027fbad feat(fleet): factory-token expiry, prod-default enforcement, token GC
Hardens the factory credential lifecycle (§12):

- Token expiry: tokens now carry an absolute expiresAt (FLEET_TOKEN_TTL_DAYS,
  default 90; 0 disables). verifyToken rejects an expired token regardless of
  status, bounding the blast radius of a leak.
- Enforcement default: factoryTokenEnforcementEnabled now defaults ON in
  production and OFF in development/test (an explicit FLEET_REQUIRE_FACTORY_TOKEN
  still wins) — real deployments are secure by default while the local prototype
  and the test suite keep working without enrollment.
- Token GC: pruneInvalidatedTokens deletes revoked, expired, and rotating-past-
  grace tokens; wired into the hourly fleet GC sweep (SweepResult.tokensDeleted)
  so the credential store stays bounded.

Covered by new enrollment.test.ts cases (expiry, TTL=0, enforcement default
matrix, prune) and the reaper/sweep accounting.

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-01 11:45:17 -07:00
saravanakumardb1
42d27d8a4f feat(fleet): enforce job.retry — auto-requeue, backoff, and dead-letter
job.retry (max / on / backoff) was persisted but never enforced: a failed
attempt just went to `failed` and required a manual operator requeue. Now, when
a factory releases a lease reporting a failure, the coordinator applies the policy:

- retryable result (matches a retry.on class) with attempts remaining ⇒ requeue
  (queued, or blocked if deps are now unmet) with a retry backoff;
- retryable but attempts exhausted ⇒ dead_letter;
- no policy or non-retryable result (capability_mismatch/no_engine) ⇒ failed,
  exactly as before (behavior-preserving).

Backoff is honored via a new job.retryNotBefore timestamp; the scheduler skips a
queued job until it elapses (new pure isAwaitingRetryBackoff gate in selectJob).
parseBackoffMs supports "<n>", "<n>s|m|h", "<n>ms", and "exp" (30s·2^(n-1), capped
1h). retry_scheduled / dead_letter audit events are emitted. decideFailureOutcome
and parseBackoffMs are pure and unit-tested (retry.test.ts), plus scheduler-gate
and end-to-end releaseLease coverage.

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-01 11:40:33 -07:00
saravanakumardb1
68bfa3dbd8 feat(fleet): stale-factory lease reclaim + bounded GC sweep
Two recovery/cleanup gaps left the coordinator's containers growing without
bound and jobs stuck longer than necessary:

- reclaimStaleFactoryLeases: a crashed/partitioned factory stops heartbeating
  ~90s before its 900s lease TTL expires; the reaper now reclaims held leases of
  stale (or vanished) holders within one stale window, via the same fence +
  checkpoint-preserving path as the expiry reaper (refactored into reclaimLeaseJob).

- sweepFleetGarbage: deletes ephemeral coordination state on by default (finished
  expired/released leases past a 24h TTL; factory docs with no heartbeat for 7d —
  a live host just re-registers). Terminal-job retention (jobs + their runs/events/
  artifacts+blobs) is OPT-IN only via FLEET_GC_RETENTION_DAYS (default 0 = never
  delete history). Every delete is best-effort so one failure can't stall the sweep.

Both are wired into the existing reaper loop: recovery scans run every 30s, the
deletion sweep is throttled to hourly. New repo helpers (listHeldLeases,
listFinishedLeasesOlderThan, deleteLease, listAllFactories, deleteFactory,
listTerminalJobsOlderThan, deleteRun, deleteEvent) back the new coordinator
functions. Covered by cleanup.test.ts + expanded reaper.test.ts.

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-01 11:34:14 -07:00
saravanakumardb1
0bf8be9be5 fix(fleet): schedule the lease reaper so dead-factory jobs are recovered
reapExpiredLeases implements the full section-25 recovery (fence the zombie
holder via a leaseEpoch bump, return the job to queued/blocked, preserve the
checkpoint) but nothing ever called it: no route, no cron, no timer. So when a
factory crashed, lost network, or shut down, its in-flight job stayed stuck in
an active stage forever and was never requeued — the recovery code was dormant.

Add a process-wide background reaper (leases are queried across all products)
that runs reapExpiredLeases every 30s, started at server boot and stopped on
graceful shutdown, mirroring the diagnostics trigger-job pattern. A failing pass
is logged and retried on the next tick rather than crashing the service.

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-01 11:11:14 -07:00
saravanakumardb1
2ed19464c5 fix(tracker-web): exclude stale factories from the engine picker
availableEnginesForProduct skipped only health:down factories, so an engine
advertised solely by a host that had stopped heartbeating could still be offered
in the picker. Also skip factories whose lastHeartbeatAt is older than 90s
(mirrors the coordinator's DEFAULT_STALE_FACTORY_MS), and treat an unparseable
timestamp as stale. Adds unit coverage for the engine-collection, down, stale,
and graceful-degradation paths.

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-01 11:02:56 -07:00
saravanakumardb1
f0a30b8356 fix(fleet): enforce the job's concrete engine in routing
The engine picker only constrained the UI; the router never gated on the
chosen engine, so a job pinned to e.g. engine:codex could be claimed by a
factory that doesn't run codex (the runner's resolve_engine honors an explicit
engine with no availability check, so it would then fail at execution time).

Add a pure engineEligible(job, caps) hard gate to the section-7 scheduler filter
(and preemption): a concrete-engine job runs only on a factory advertising the
matching engine:<e> cap. Gated only against engine-aware factories (those that
advertise any engine:* token); engine-agnostic/legacy factories stay eligible,
mirroring the picker's "engine set unknown => offer all" fallback. explainJob now
surfaces the mismatch reason. No DB migration; behavior-preserving for legacy.

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-01 10:59:57 -07:00
saravanakumardb1
d318e0fa2a feat(fleet): engine picker offers only engines the factory advertises
Add GET /fleet/factories (lists a product's factory docs with capabilities) —
also fixes the fleet map's empty factory cards (listFactories had no route and
silently returned []). The New-Job form now loads the selected factory's
engine:* capabilities and constrains the engine dropdown to those (e.g. hides
codex when the host doesn't have it), keeping the current pick valid; falls back
to all engines when capabilities are unknown.

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-01 02:38:35 -07:00
saravanakumardb1
6c31577cf2 feat(fleet): per-job engine picker (devin/claude/codex/copilot, default devin)
Add an optional concrete `engine` to a job (overrides engineClass; resolved by
the runner's resolve_engine where an explicit engine wins). All additive +
optional, so existing engineless jobs keep falling back to the factory default.

- types: FLEET_ENGINES enum; engine on SubmitJob/FleetJobDoc/UpdateDraft.
- coordinator: store engine on create/supersede/updateDraft; run.engine at claim
  prefers job.engine, then engineClass, then 'unknown'.
- tracker-web: Engine dropdown on the New-Job form (default devin) + editable on
  draft/queued jobs; shown in the detail config grid.

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-01 02:30:22 -07:00
saravanakumardb1
928edad0af feat(fleet): surface engine + agent session, editable config, timeline filter
Backend: insights now carry engine + sessionId/sessionUrl; releaseLease promotes
the reported engine onto the run (was created with the abstract engineClass,
usually 'unknown').

tracker-web job detail:
- Runs: show the concrete engine (insights.engine, falls back off 'unknown') and
  the agent session (Devin session id with a `devin --resume <id>` hint, or a
  link when a sessionUrl is present).
- PromptCard: edit repo/baseBranch/verify/autoMerge (not just the prompt) while
  draft/queued/blocked.
- Timeline: filter by event type (default collapses heartbeat runs).
- Show a "no PR — needs verify / not PR mode" hint when parked in review.

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-01 02:19:38 -07:00
saravanakumardb1
5262583e8b feat(tracker-web): collapse heartbeat noise + surface PR on job detail
The job detail timeline was buried under hundreds of lease_renewed rows on
long-running jobs. Collapse consecutive high-frequency events (lease_renewed)
into one "type xN - over Nm" summary row; everything else renders verbatim. Add
a prominent Pull Request banner (link + state) sourced from whichever run opened
the PR, instead of only the per-attempt Runs column.

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-01 02:06:33 -07:00
saravanakumardb1
5ad521ad4c feat(tracker-web): macOS LaunchAgent keep-alive for the fleet web tracker
Adds dashboards/tracker-web/launchd/ (boot script + install.sh + README) so
tracker-web (:3003) auto-starts on login and restarts on crash/reboot, instead
of dying silently between sessions. Mirrors agent-queue/launchd: boot script
repairs PATH, loads JWT_SECRET from platform-service/.env (+ ~/.tracker-web.env
overrides), points at the local platform-service, and execs `pnpm dev`. plist
uses unconditional KeepAlive (restart on any exit, incl. a clean SIGTERM) + a 10s
throttle; install.sh frees :3003 first to avoid a clash with deploy-gigafactory.

Verified: killing the process respawns it and :3003 returns.

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-01 01:46:53 -07:00
saravanakumardb1
e4c84acf29 fix(fleet): bump the M0 queue gate when a draft/queued job is edited
updateDraft changes caps/deps/priority (and body) without a stage change, so it
did not bump fleet_queue_state — gated factories (AQ_FLEET_GATE=1) would not
re-evaluate claimability until the safety interval. Bump the gate on edit so an
edit that makes a job claimable wakes factories promptly.

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-01 00:58:45 -07:00
saravanakumardb1
14e982d04f fix(tracker-web): make New-Job form submissions routable to live factories
The form defaulted capabilities to "build" — a token no agent-queue factory
advertises (caps are os:* / engine:* / node:* / has:*), so every default UI
submission was unroutable and stranded in queued (queue_starvation). Default
capabilities to empty (any capable factory claims it), and replace the stale
hardcoded mac-1/mac-2 factory dropdown with the 4 live factories (lysnrai /
chronomind / mindlyst / nomgap, ids matching AQ_FACTORY_ID).

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-01 00:54:02 -07:00
saravanakumardb1
b7df779e1d feat(fleet): draft jobs + editable prompt (save-as-draft, submit, lock on pickup)
Backend (platform-service):
- New `draft` stage (not claimable; scheduler only takes queued/blocked).
- submitJob accepts `draft: true` → parks a new/superseded job as a draft.
- updateDraft(): edit prompt/config in place while draft/queued/blocked;
  recomputes contentHash; rejected (conflict) once picked up (assigned+).
- submitDraft(): promote draft → queued (or blocked on unmet deps); idempotent.
- Routes: PATCH /fleet/jobs/:id/draft, POST /fleet/jobs/:id/submit.
- tracker-bridge: map draft → item status `open`. Tests + FLEET_STAGES updated.

Frontend (tracker-web):
- New-Job form: add "Save as draft" alongside "Submit".
- Job detail: edit the prompt + Save while draft/queued/blocked, "Submit" a
  draft, and lock it read-only once a factory picks it up.

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-01 00:44:10 -07:00
saravanakumardb1
cb4f7a7606 feat(tracker-web): show job prompt + PR/target config on the detail page
The fleet job detail page never rendered the prompt (bodyMd) or the repo/
verify/auto-merge/capabilities/deps config. Add a Prompt card (verbatim body,
scrollable) + a target/config grid, with a read-only badge once the job leaves
queued/draft (a factory may already be acting on it). Expose verify/autoMerge/
deps on the FleetJob client type.

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-01 00:29:29 -07:00
saravanakumardb1
78c4e47460 docs(gigafactory): fix stale/incorrect fleet docs
- fleet module README: add fleet_queue_state container + GET /fleet/queue-state
  and /fleet/metrics; note the heartbeat cadence must stay under the 90s stale
  threshold (AQ_FLEET_LEASE_RENEW_SEC).
- FLEET_CONTROL_PLANE: correct wrong endpoint paths (/fleet/claim and
  /fleet/factories/heartbeat were documented as /fleet/jobs/:id/claim and
  /fleet/factories/:id/heartbeat; removed a non-existent GET /fleet/factories);
  add enroll, metrics, and the M0 queue-state endpoint.
- ROADMAP_COMPLETION_AUDIT: dated status banner — roadmap §0 now reconciled and
  Phase-4 M0 shipped, superseding the older "stale §0 / not started" findings.
- README: point to FLEET_DISPATCH_REDESIGN.md + the M0 gate.

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-01 00:03:05 -07:00
saravanakumardb1
ba7db0008d feat(fleet): M0 RU gate — cheap per-product queue version + skip-claim
Adds fleet_queue_state (monotonic version per product), bumped on job create +
every stage change in the repository layer (best-effort, never fails a job
write), and a GET /fleet/queue-state read endpoint. Lets a polling factory
detect "work changed" with a ~1 RU point read instead of a full listJobs scan
on every claim. Registers the container; tests cover the bump + endpoint.

See agent-queue docs/GIGAFACTORY/FLEET_DISPATCH_REDESIGN.md §8/§12 (M0).

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-05-31 23:18:27 -07:00
Saravanakumar D
5bc72cf221 chore: enforce LF line endings via .gitattributes
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-31 20:23:18 -07:00
Saravanakumar Dhandapani
65c7d09584 feat(auth): include displayName claim in platform access token
Adds an optional displayName claim to the platform access token so
downstream product backends can source the user's display name from the
JWT (single source of truth = platform auth), not from per-product DB
copies. verifyToken already exposes displayName; this populates it at all
token-minting sites (password login, register, refresh, SSO, OAuth,
magic-link, passkeys, QR, enterprise SAML/OIDC). Additive and backward
compatible.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-31 20:23:15 -07:00
saravanakumardb1
6709862c1a feat(tracker-web): redesign fleet jobs list — stage chips, filters, hide-shipped
- Clickable stage chips with live counts (color-coded) replace the plain dropdown;
  click to filter, click again to clear.
- Search box (key / repo / id) + 'Hide shipped' checkbox (persisted) + 'N of M shown'.
- Redesigned table: color-coded stage badges, priority emphasis, a Repo column
  (PR target), newest-first sort, bordered/zebra layout.
- All filtering is client-side over a 100-job window (instant + accurate counts);
  list/links/New-Job form unchanged. FleetJob gains repo/baseBranch.

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-05-31 16:58:12 -07:00
saravanakumardb1
2bd97791c9 feat(fleet): resilient PR merge on ship (inline attempt + background retry)
The corporate proxy intermittently 407s GitHub's API, so a single gh pr merge can
fail transiently. Try once inline (fast path), then retry in the background with
backoff (3s/8s/20s/45s) without blocking the ship; mark prState=merged when one
lands. Best-effort throughout.
2026-05-31 16:13:52 -07:00
saravanakumardb1
740335a149 feat(fleet): Ship can also merge the linked PR (gh pr merge)
On ship (Ship button / operator action / autoship PATCH), when the run has an open
PR and FLEET_SHIP_MERGES_PR=1, the coordinator squash-merges it via gh (best-effort,
where gh is authed) and marks the run prState=merged. UI button reads 'Ship & merge
PR' when an open PR exists; Ship refreshes runs.
2026-05-31 16:08:28 -07:00
saravanakumardb1
37d049eb69 fix(tracker-web): telemetry ingest auth (X-Install-Token) + show cost as approx
- telemetry proxy attaches an X-Install-Token (derived from the payload, with a
  fallback) so the backend ingest auth gate stops returning 401 on browser beacons.
- job-detail Cost shows ~$x.xx approx when the figure is estimated (token-based).
2026-05-31 15:58:26 -07:00
saravanakumardb1
696ee4189e feat(fleet): track + show PR status (open/merged) on the run
Runs now carry prState (open when the PR is opened, merged when auto-merge
succeeds), reported on lease release. Job-detail Runs table shows a status badge
next to the PR link.
2026-05-31 13:56:33 -07:00
saravanakumardb1
e9c1714c13 feat(tracker-web): factory dropdown routes job to the selected factory's product
New Job form: pick a Factory (2 hardcoded for this machine) -> the job is submitted
to that factory's product (submitJob productId override) and the dashboard view
switches to it so the job is visible. Confirmation shows factory + product.

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-05-31 06:28:28 -07:00
saravanakumardb1
c239abeec9 feat(fleet): per-repo verify + auto-merge options for PR jobs
Add job-level verify (command run in the PR checkout before opening the PR) and
autoMerge (squash-merge the PR once opened). Surfaced in the New Job form as a
Verify-command field + Auto-merge checkbox (PR mode only); confirmation now shows
PR-mode/repo. More repos added to the dropdown.

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-05-31 06:17:19 -07:00
saravanakumardb1
2adddce754 feat(tracker-web): hardcoded repo dropdown for PR-mode jobs (base=main)
MVP: the New Job form picks a PR target from a fixed dropdown of local repos; base
branch is fixed to main. Empty selection = no PR (plain job).

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-05-31 05:36:50 -07:00
saravanakumardb1
d350159025 Merge feat/fleet-pr-deliverable: PR deliverables for fleet jobs 2026-05-31 05:27:16 -07:00
saravanakumardb1
883cf329e5 feat(fleet): PR deliverables — jobs target a repo, factory opens a PR, link recorded
Make "shipped" produce a real artifact. A job can now carry an optional repo
(owner/name or clone URL) + baseBranch; the factory's PR mode runs the agent in an
isolated checkout, opens a PR, and records the link.

Backend:
- SubmitJobSchema + FleetJobDoc: optional repo/baseBranch (recorded on submit).
- FleetRunDoc: optional prUrl/branch.
- ReleaseLease report carries prUrl/branch -> stored on the run.
- +2 coordinator tests.

UI (tracker-web):
- New Job form gains optional Repo + Base branch fields (and fixes the priority
  options to the valid critical/high/medium/low; "normal" was rejected by the API).
- Job detail Runs table shows a PR ↗ link from run.prUrl.
- fleet-client: submitJob repo/baseBranch; FleetRun prUrl/branch; OperatorAction +ship.

Docs: FLEET_CONTROL_PLANE.md "PR deliverable (PR mode)" section.

Verified: tsc clean; fleet suite 182; tracker-web 230.

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-05-31 05:27:11 -07:00
saravanakumardb1
8b9ca4fee2 Merge feat/fleet-ui-submit-job: submit fleet jobs from the dashboard 2026-05-31 04:53:28 -07:00
saravanakumardb1
176b778a1f feat(tracker-web): submit fleet jobs from the dashboard
Add a collapsible 'New Job' form on the fleet jobs page (task body, priority,
capabilities) wired to a new fleet-client submitJob() -> POST /fleet/jobs, with
inline success/error and auto-refresh. Also add 'ship' to the OperatorAction type
for parity with the coordinator. The existing job-detail 'Ship' button already
drives the human-gate testing -> shipped transition.

Verified: tsc clean; tracker-web suite 230/230.

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-05-31 04:53:23 -07:00
saravanakumardb1
c773742658 Merge fix/command-palette-undefined-key 2026-05-31 04:47:45 -07:00
saravanakumardb1
de7e0dcb84 fix(command-palette): guard keydown with undefined key (no crash)
Some keydown events (autofill, IME/composition, certain extensions) arrive with
e.key === undefined, crashing the hotkey handler at e.key.toLowerCase(). Guard
e.key (and hotkey.key) before comparing. +1 test (undefined-key keydown is
ignored without throwing). 27 pass.

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-05-31 04:47:40 -07:00
saravanakumardb1
60989d7b62 Merge fix/fleet-run-result-on-ship: run result mirrors shipped + docs 2026-05-31 04:31:51 -07:00
saravanakumardb1
bcdb5dcdf3 fix(fleet): run result mirrors a shipped job + document testing->shipped
A job can reach `shipped` via autoship PATCH, the `ship` operator action, or a
terminal lease release, but the run-level `result` was left at whatever the
factory last reported (e.g. `review`), so the dashboard showed a shipped job with
a non-terminal run result.

- Add markLatestRunShipped(): on any transition to `shipped`, set the latest run
  result to `shipped` (+ endedAt if unset). Idempotent, best-effort.
- Wire it into patchJobFenced (ungated; budget accrual stays flag-gated) and the
  `ship` operator action.
- Document the testing->shipped paths (factory autoship vs `ship` operator action)
  and the run-mirroring in docs/GIGAFACTORY/FLEET_CONTROL_PLANE.md.

Tests: +2 (patchJobFenced->shipped and operator ship both set run.result=shipped).
Fleet suite 180 pass; tsc clean.

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-05-31 04:31:46 -07:00
saravanakumardb1
f7740f13e9 Merge fix/tracker-web-test-env: stable DOM env + Node-25 localStorage fix 2026-05-31 04:12:50 -07:00
saravanakumardb1
06d7d881a0 fix(tracker-web): stable test DOM env + working localStorage (Node 25)
product-context.test.tsx failed with "localStorage.clear is not a function".
Root cause: Node 25 ships a global `localStorage` Web Storage stub that is
non-functional without --localstorage-file, and it shadows the test DOM
environment's storage. The two DOM tests also relied on `jsdom`, which was only
present transitively (not a tracker-web dependency) while the rest of the
monorepo standardizes on happy-dom.

- Add happy-dom as a tracker-web devDependency; switch the two `@vitest-environment
  jsdom` tests (product-context, command-menu) to happy-dom.
- Add vitest.setup.ts that installs a real in-memory Web Storage over Node 25's
  non-functional stub when the active localStorage/sessionStorage lacks the
  Storage API; wire it via test.setupFiles.

Verified: full tracker-web suite 230/230 (was 228/2).

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-05-31 04:12:34 -07:00
saravanakumardb1
eb702137de Merge chore/types-node-25: bump @types/node 22 -> 25 2026-05-31 04:03:12 -07:00
saravanakumardb1
8fe26027e7 chore(deps): bump @types/node 22 -> 25 (dev types)
Verified: full workspace build (tsc) green across all packages/services/dashboards;
fleet+items tests pass. Compile-time only.

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-05-31 04:02:56 -07:00
saravanakumardb1
fa419e3b80 Merge chore/stripe-v20-migration: Stripe SDK 17 -> 20 + API migration 2026-05-31 04:00:32 -07:00
saravanakumardb1
3022e634b8 feat(billing): migrate to Stripe SDK v20 (API shape changes)
Bump stripe 17 -> 20 and adapt to the breaking API changes:
- PromotionCode: coupon moved under `promotion` ({ type: 'coupon', coupon }).
  mapPromo now reads p.promotion.coupon; create now passes
  promotion: { type: 'coupon', coupon: id } instead of a top-level coupon.
- Subscription.current_period_end removed (now per subscription item). Add
  getSubscriptionPeriodEnd() = max(items[].current_period_end) and use it in the
  customer.subscription.updated webhook handler.
- Update the promos route test fixture to the new promotion.coupon shape.

Verified: platform-service build (tsc) clean; promos (14) + stripe/subscriptions/
billing tests pass; full suite 1692/1692.

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-05-31 04:00:13 -07:00
saravanakumardb1
58ed096ac8 Merge chore/dep-bumps-majors: happy-dom 20, lint-staged 16, @fastify/cors 11, bcryptjs 3 2026-05-31 03:51:31 -07:00
saravanakumardb1
0079274bff chore(deps): bump bcryptjs 2 -> 3 (+ @types/bcryptjs 3)
Verified: auth + platform-service build (tsc, default import still resolves);
packages/auth (31) + platform-service auth/api-key (65) pass (hash/compare work).

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-05-31 03:46:19 -07:00
saravanakumardb1
66a4a74aa6 chore(deps): bump @fastify/cors 10 -> 11
Verified: fastify-core + platform-service build (tsc); createServiceApp boot smoke
handles a CORS preflight (OPTIONS -> 204 with access-control-allow-origin).

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-05-31 03:44:54 -07:00
saravanakumardb1
168bff6e27 chore(deps): bump lint-staged 15 -> 16 (pre-commit tooling)
Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-05-31 03:43:42 -07:00
saravanakumardb1
83745b2fee chore(deps): bump happy-dom 18 -> 20 (test env)
Verified: UI package suites (ui, dashboard-components, data-table, react-auth)
+ tracker-web all green; no new failures.

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-05-31 03:42:57 -07:00
saravanakumardb1
4c974b94e3 Merge chore/dep-bumps-safe: bump @azure/cosmos, jose, @typescript-eslint/parser 2026-05-31 03:28:56 -07:00
saravanakumardb1
808f615124 chore(deps): bump @azure/cosmos, jose, @typescript-eslint/parser
Applied fresh on current main (the matching dependabot branches were 350-430
commits behind and would have conflicted on the lockfile):
- @azure/cosmos 4.9.1 -> 4.9.3
- jose 6.1.3 -> 6.2.3 (mcp-server stays on the 5.x line: 5.9.6 -> 5.10.0)
- @typescript-eslint/parser 8.0 -> 8.60.0

Verified: full workspace build green; platform-service suite 1684 pass (only the
pre-existing single-fork migration-isolation flake, passes isolated); tracker-web
228 pass (only the pre-existing happy-dom product-context failures). No new
regressions. Major bumps (fastify/cors, happy-dom, lint-staged, stripe,
types/node) deferred for separate review.

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-05-31 03:28:40 -07:00
saravanakumardb1
a528ffea1d Merge fix/fleet-phase3-hardening: budget authz, idempotent accrual, cycle detection, artifact cleanup 2026-05-31 02:46:18 -07:00
saravanakumardb1
1503ef2e19 fix(fleet): Phase 3 hardening — budget authz, idempotent accrual, cycle detection, artifact
Re-applies 4 defects merged with Phase 3 (still present on main), ported from the
stale reference branch and adapted to current main.

FIX 1 (BLOCKER): all 5 budget routes (GET/burndown/PUT/pause/resume) read
productId from the URL with no caller check, so a caller for product A could
read or modify product B budget. Add requireOwnProduct() -> 403 on mismatch.

FIX 2: stop tracking services/platform-service/.data/platform-events.json (the
EVENT_BUS_FILE runtime log); gitignore .data/.

FIX 3: accrueSpend was definition-only (budgets never accrued) and not
idempotent. Add accruedRunIds to FleetBudgetDoc; accrueSpend(productId, costUsd,
runId) no-ops on a seen runId; wire it into patchJobFenced on stage shipped,
flag-gated + best-effort + idempotent via jobId:leaseEpoch. Accrue the run ACTUAL
insights.costUsd so spentUsd and costBurndown agree.

FIX 4: submitChildren cycle detection is now batch-aware — rejects duplicate
child keys and walks each child declared deps across BOTH the unpersisted batch
and existing jobs, rejecting child-self, child-parent and sibling cycles.

Tests: +2 budget-authz (403 on all 5 verbs), +3 accrual (idempotent runId, ship
accrues insights.costUsd once, flag-off no accrual), +5 cycle detection. Gates
green: tsc/build, fleet+items (204), full suite (only the unrelated single-fork
migration-isolation file flakes, passes isolated). Flags stay default-OFF.

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-05-31 02:45:52 -07:00
saravanakumardb1
c1aad8a819 fix(tracker-web): do not send Content-Type on bodyless fleet proxy POSTs
Operator actions (ship/requeue/cancel) are bodyless POSTs. The proxy always set
Content-Type: application/json, so the backend rejected them with
FST_ERR_CTP_EMPTY_JSON_BODY (500). Only declare the JSON content type when a
body is actually forwarded. Fixes the fleet dashboard action buttons.

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-05-31 01:50:11 -07:00
saravanakumardb1
32e426d423 feat(fleet): run-insights reporting + ship action; complete the lifecycle
Make the Agent Gigafactory fully drivable end-to-end via the API:

- lease/release now accepts `insights` (model/tokens/cost) + `result`, recorded
  on the current run with endedAt — factories report cost/token metrics on
  completion (previously no API existed; runs stayed insights:{}).
- add `ship` operator action so a job in `testing` (where the review gate left
  no lease holder) can reach the terminal `shipped` stage. Idempotent.
- operatorAction now retries on optimistic-concurrency conflict with backoff
  (mirrors submitReview) so a ship right after approve survives real-Cosmos
  read-after-write lag instead of a spurious 409.

Tests: +2 coordinator (ship idempotent, release records insights) and +2 route
integration (gated submit->...->ship->metrics; release-with-insights). 170 pass.

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-05-31 01:49:59 -07:00
saravanakumardb1
939c7b4621 fix(tracker-web): include productId in login (LoginSchema requires it)
The login form posted {email,password} but platform-service LoginSchema
requires productId, so real logins returned 400 (only the mocked e2e passed).
Send the selected product (tracker_selected_product) or the default PRODUCT_ID.

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-05-31 01:21:58 -07:00
saravanakumardb1
2253f888c7 feat(tracker-web): per-run cost/token/time metrics + log download; fix fleet proxy
Job detail Runs table now shows Duration, Model, Tokens (in/out + cached) and
Cost per run, plus a per-job totals header (cost / tokens / wall-time). Artifacts
get a view/download button via a fresh signed URL. Also fix the fleet API proxy
to forward to /api/fleet/* (backend mounts fleet under /api) so a live backend
resolves; previously it returned 404 and only the mocked e2e passed.

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-05-31 01:19:57 -07:00
saravanakumardb1
8158efacf7 chore(local-llms): refresh chat-history snapshot (mirrors verified diff-free)
Ran refresh.sh; only .last-refresh.log changed — all mirrored repo docs/
workflows already match their source bytes, confirming the .prettierignore
fix ended the formatter churn. Future refreshes now only move the periodic log.

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-05-30 23:32:42 -07:00
Saravanakumar D
6d66355a22 feat(scripts): add Cosmos DB cost report tooling (.sh + .ps1)
Reports billed cost, RU by database, RU by container drill-down, and
storage for the cosmos-mywisprai account. Auto-detects serverless vs
provisioned billing mode.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-30 23:30:22 -07:00
saravanakumardb1
cad75b1c02 chore: stop prettier churn on __LOCAL_LLMs mirrors + refresh snapshot
Add .prettierignore excluding __LOCAL_LLMs/ (and dist/build/generated). Those
are mirrored copies refreshed by the chat-history sync; prettier was rewriting
them on every commit, fighting the generator and producing permanent diff
churn. Also commits the latest refresh snapshot in its native format.

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-05-30 23:25:34 -07:00
saravanakumardb1
e0a904c7ea fix(cosmos): support composite indexes; add fleet_jobs (priorityOrder, createdAt)
Azure Cosmos cannot serve a multi-field ORDER BY without a matching composite
index (the local emulator is lenient, real Cosmos returns HTTP 400). The fleet
listJobs() query orders by (priorityOrder, createdAt), which broke
GET /api/fleet/metrics and /api/fleet/jobs on real Cosmos.

- ContainerConfig gains an optional `compositeIndexes` field
- container init applies it on create AND reconciles it onto existing
  containers (createIfNotExists never updates an existing index policy)
- fleet_jobs declares the (priorityOrder ASC, createdAt ASC) composite index

Verified live against Azure Cosmos: both endpoints now return 200.

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-05-30 23:07:06 -07:00
saravanakumardb1
c1f85050a0 chore(local-llms): update WINDSURF chat-history, env audit, and workflow logs
Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-05-30 22:25:13 -07:00
Saravanakumar D
20bcbd9f19 docs(gigafactory): uppercase GIGAFACTORY folder + add index README
Rename docs/gigafactory/ to docs/GIGAFACTORY/ and update the cross-repo
source-of-truth references in the fleet README and types.ts comment. Add an
index README listing the platform docs and pointing to the canonical spec in
learning_ai_devops_tools. Docs/comment only; no behavior change.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-30 21:22:21 -07:00
Saravanakumar D
fc000c4c20 docs(gigafactory): consolidate gigafactory docs into docs/gigafactory/
Move ROADMAP_COMPLETION_AUDIT.md, TASKS_TO_COMPLETE.md,
gigafactory-phase3-progress.md and FLEET_CONTROL_PLANE.md under
docs/gigafactory/ so the scattered Gigafactory docs are easy to discover.
Update intra-doc and cross-repo source-of-truth references (fleet README
and types.ts comment) to the new agent-queue/docs/gigafactory/ path.
Pure docs/comment move; no behavior change.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-30 21:07:20 -07:00
Saravanakumar D
0799f69c30 feat(fleet-web): harden budget bar, surface SSE polling, allow checkpoint in patchJob
- budget page: guard spend bar against missing/zero ceiling (no NaN width);
  show an explicit "no ceiling set" state. Add pure budgetUsagePct() helper.
- job detail: replace silent live/poll toggle with an explicit stream-mode
  badge (Live vs Polling) so operators see when SSE degrades to polling.
- fleet-client: extend patchJob to carry optional checkpoint/blockedReason
  matching the server PatchJobSchema; add FleetCheckpoint type.
- tests: unit cover budgetUsagePct + patchJob checkpoint forwarding; e2e
  asserts the polling indicator appears when the stream is unavailable.
- ci: add a Gitea Playwright e2e job that runs the fleet control-plane specs.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-30 20:35:05 -07:00
saravanakumardb1
3de2a797b9 docs(cheatsheets): document longrun helper in long-running-jobs guide 2026-05-30 19:26:06 -07:00
saravanakumardb1
5df580d999 docs(cheatsheets): add long-running/overnight jobs cheat sheet 2026-05-30 19:20:08 -07:00
Saravanakumar D
932951dbaf docs: update roadmap audit to reflect completed Phase 3 slices
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-30 19:05:33 -07:00
Saravanakumar D
4ac499f301 feat: add multi-reviewer human gate (review-policy routing)
Implements the §14 Phase 3 review gate. requestReview() routes a building
job into the review stage (fencing any worker), carrying a normalized policy
(requiredApprovals + reviewer allowlist) and clearing prior decisions.
submitReview() records one decision per reviewer (last-write-wins, identity-
normalized), advances the job to testing once distinct approvals reach the
quorum, and treats any reject as a veto that returns the job to queued for
rework. Adds POST /fleet/jobs/:id/review/request and POST /fleet/jobs/:id/review,
a typed client, and a review-gate card on the job-detail page.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-30 19:04:24 -07:00
Saravanakumar D
c9c2c174db feat: add fleet metrics + alerting (GET /fleet/metrics)
Adds coordinator.fleetMetrics() computing queue depth, stage histogram,
oldest-queued age (starvation signal), factory health and seat utilization,
plus derived alerts (no_live_capacity, all_factories_down, queue_starvation,
saturated, stale_factories). Exposed via GET /fleet/metrics and surfaced as a
metrics+alerts panel on the fleet overview. Thresholds injectable for tests.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-30 18:51:59 -07:00
Saravanakumar D
d780739cbe test: add fleet control-plane Playwright e2e coverage
New e2e/fleet.spec.ts with a method- and URL-aware /api/fleet/** mock that
holds mutable state so operator actions and budget toggles reflect in
follow-up GETs. Covers: fleet overview (factory cards + recent jobs), jobs
table + stage filter, job detail requeue (stage building->queued) with the
SSE-driven Live badge, and budget pause/resume. All 4 specs green.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-30 18:44:53 -07:00
Saravanakumar D
ea42602407 feat: add resumable SSE live event stream for fleet jobs
Backend: GET /fleet/jobs/:id/events/stream emits a snapshot (seq > Last-Event-ID)
then long-polls the append-only event log, closing after a bounded window so
EventSource-style clients reconnect cleanly. Honors Last-Event-ID resume,
keepalive comments, and a terminal error frame.

Frontend: subscribeJobEvents uses fetch streaming (to send auth + product
headers) with parseSseFrames, Last-Event-ID resume, reconnect backoff, and a
fatal-on-error-frame fallback to polling. Job detail page subscribes live
(deduped by seq), falls back to 4s polling on failure, and shows a Live badge;
refresh() now merges events so a slow snapshot can't clobber streamed ones.

Tests: +3 route (snapshot, resume cursor, append-after-connect), +5 client
(parseSseFrames x2, subscribe deliver/error/resume/error-frame). fleet 150, web 222.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-30 18:38:50 -07:00
Saravanakumar D
1ae15a7755 docs: mark cost burndown complete
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-30 18:25:46 -07:00
Saravanakumar D
89860e39f9 feat: add fleet cost burndown chart
- coordinator.costBurndown() aggregates completed run cost (insights.costUsd)
  by UTC day over a window, returning a gap-free cumulative series + ceiling
- repository.listRunsByProduct() cross-partition run query
- GET /fleet/budgets/:productId/burndown?days=N route
- fleet-client.getBudgetBurndown() + CostBurndown/BurndownPoint types
- BurndownChart on the budget page: cumulative daily bars with a dashed
  ceiling overlay; bars turn red past the ceiling; degrades gracefully
- Tests: +2 coordinator, +1 routes, +2 fleet-client (fleet 147, web 216)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-30 18:25:27 -07:00
Saravanakumar D
3f850b7b6f docs: mark scoring explainability complete
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-30 18:21:35 -07:00
Saravanakumar D
2d5f9be642 feat: surface scoring explainability in fleet control plane
Adds 'why does this job route here?' to the §7 scheduler:
- coordinator.explainJob() re-runs scoreCandidate against every live factory,
  returning per-factory weighted breakdown, eligibility + reasons, deps state,
  and the best eligible factory (read-only, side-effect free)
- GET /fleet/jobs/:id/explain route (404 when job missing)
- fleet-client.getJobExplain() + JobExplain/ScoreBreakdown types
- ExplainPanel on the job detail page: score table per factory with the six
  weighted terms, eligibility, and unmet-deps note; degrades gracefully
- Tests: +2 coordinator, +1 routes, +2 fleet-client (fleet 144 green,
  tracker-web 214 green)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-30 18:21:14 -07:00
Saravanakumar D
69f553d432 docs: mark operator job actions complete in TASKS_TO_COMPLETE
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-30 18:15:53 -07:00
Saravanakumar D
c8ab43d3ae fix: harden operator action lease release + idempotent terminal actions
- release lease with fenced epoch (leaseEpoch+1, clear holder) so a stale
  renewal cannot resurrect a held lease after operator displacement
- reject on dead_letter / cancel on failed are now idempotent no-ops
  (no epoch bump, no duplicate event)
- add coordinator test for terminal idempotency

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-30 18:15:26 -07:00
Saravanakumar D
283383561c feat: complete operator job actions (requeue/reject/cancel)
Adds lease-free operator lifecycle control to the fleet control plane:
- coordinator.operatorAction() fences any current factory holder by bumping
  leaseEpoch (mirrors the reaper), preserves checkpoint, and routes the job:
  requeue -> queued (or blocked if deps unmet), reject -> dead_letter,
  cancel -> failed. Shipped jobs are terminal (invalid_state).
- POST /fleet/jobs/:id/actions/:action route (400 on unknown action)
- fleet-client.operatorAction() + Requeue/Cancel/Reject buttons on job detail
- Tests: +5 coordinator, +1 routes, +2 fleet-client (platform fleet 140 green,
  tracker-web 212 green)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-30 18:12:11 -07:00
Saravanakumar D
0f903b935a audit: document current Gigafactory completion state
- ROADMAP_COMPLETION_AUDIT.md: verified state vs GIGAFACTORY_ROADMAP source of truth
- TASKS_TO_COMPLETE.md: prioritized remaining work with acceptance criteria
- Key finding: roadmap §0 tracker is stale (P2 ~95%, P3 ~70% actual vs 80%/0% claimed)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-30 18:06:33 -07:00
144 changed files with 14005 additions and 1227 deletions

View File

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

2
.gitattributes vendored
View File

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

View File

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

1
.gitignore vendored
View File

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

15
.prettierignore Normal file
View 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/

View File

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

View 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`)._

View File

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

View File

@ -10,7 +10,7 @@
## 1. Project → Env File Map ## 1. Project → Env File Map
| # | Project | Env File | Port | | # | Project | Env File | Port |
| --- | ----------------------------------------------- | ---------------------------------- | ---- | |---|---------|----------|------|
| 1 | Desktop app (`src/`) | `.env` (root) | — | | 1 | Desktop app (`src/`) | `.env` (root) | — |
| 2 | Backend API (`backend/`) | `backend/.env` | 8000 | | 2 | Backend API (`backend/`) | `backend/.env` | 8000 |
| 3 | Admin Dashboard (`admin-dashboard-web/`) | `admin-dashboard-web/.env.local` | 3001 | | 3 | Admin Dashboard (`admin-dashboard-web/`) | `admin-dashboard-web/.env.local` | 3001 |
@ -37,14 +37,14 @@ grep -rn 'MISSING_ENV_VALUE' --include='.env*' --include='*.env' . | grep -v nod
### 3.1 Root `.env` (Desktop App) ### 3.1 Root `.env` (Desktop App)
| Variable | Status | Action | | Variable | Status | Action |
| --------------------------------------- | ---------------------------------- | ----------------------------------------------------------------------- | |----------|--------|--------|
| `APPLICATIONINSIGHTS_CONNECTION_STRING` | ❌ Empty | Get from Azure Portal → Application Insights → Overview | | `APPLICATIONINSIGHTS_CONNECTION_STRING` | ❌ Empty | Get from Azure Portal → Application Insights → Overview |
| `ANH_CONNECTION_STRING` | ⚠️ Has `YOUR_KEY_HERE` placeholder | Replace with real SharedAccessKey from Azure Portal → Notification Hubs | | `ANH_CONNECTION_STRING` | ⚠️ Has `YOUR_KEY_HERE` placeholder | Replace with real SharedAccessKey from Azure Portal → Notification Hubs |
### 3.2 `backend/.env` ### 3.2 `backend/.env`
| Variable | Status | Action | | Variable | Status | Action |
| ------------------------------- | -------- | ------------------------------------------------------------------------ | |----------|--------|--------|
| `AZURE_EMAIL_CONNECTION_STRING` | ❌ Empty | Get from Azure Portal → Communication Services → Keys | | `AZURE_EMAIL_CONNECTION_STRING` | ❌ Empty | Get from Azure Portal → Communication Services → Keys |
| `SMTP_HOST` | ❌ Empty | Configure if using SMTP fallback instead of Azure Communication Services | | `SMTP_HOST` | ❌ Empty | Configure if using SMTP fallback instead of Azure Communication Services |
| `SMTP_USER` | ❌ Empty | Configure if using SMTP fallback | | `SMTP_USER` | ❌ Empty | Configure if using SMTP fallback |
@ -53,14 +53,14 @@ grep -rn 'MISSING_ENV_VALUE' --include='.env*' --include='*.env' . | grep -v nod
### 3.3 `admin-dashboard-web/.env.local` ### 3.3 `admin-dashboard-web/.env.local`
| Variable | Status | Action | | Variable | Status | Action |
| -------------------------- | -------- | ---------------------------------------------------------- | |----------|--------|--------|
| `NEXT_PUBLIC_POSTHOG_KEY` | ❌ Empty | Get from PostHog → Project Settings (optional — analytics) | | `NEXT_PUBLIC_POSTHOG_KEY` | ❌ Empty | Get from PostHog → Project Settings (optional — analytics) |
| `NEXT_PUBLIC_POSTHOG_HOST` | ❌ Empty | Get from PostHog → Project Settings (optional — analytics) | | `NEXT_PUBLIC_POSTHOG_HOST` | ❌ Empty | Get from PostHog → Project Settings (optional — analytics) |
### 3.4 `user-dashboard-web/.env.local` ### 3.4 `user-dashboard-web/.env.local`
| Variable | Status | Action | | Variable | Status | Action |
| -------------------------- | -------- | ------------------------------------------------------------------- | |----------|--------|--------|
| `ENTERPRISE_EMAIL_DOMAINS` | ❌ Empty | Set comma-separated list of domains that qualify for Enterprise SSO | | `ENTERPRISE_EMAIL_DOMAINS` | ❌ Empty | Set comma-separated list of domains that qualify for Enterprise SSO |
| `MICROSOFT_CLIENT_ID` | ❌ Empty | Register app in Azure Portal → Entra ID → App registrations | | `MICROSOFT_CLIENT_ID` | ❌ Empty | Register app in Azure Portal → Entra ID → App registrations |
| `MICROSOFT_CLIENT_SECRET` | ❌ Empty | Same as above | | `MICROSOFT_CLIENT_SECRET` | ❌ Empty | Same as above |
@ -72,27 +72,27 @@ grep -rn 'MISSING_ENV_VALUE' --include='.env*' --include='*.env' . | grep -v nod
### 3.5 `tracker-dashboard-web/.env.local` ### 3.5 `tracker-dashboard-web/.env.local`
| Variable | Status | Action | | Variable | Status | Action |
| -------------------------- | -------- | ---------------------------- | |----------|--------|--------|
| `NEXT_PUBLIC_POSTHOG_KEY` | ❌ Empty | PostHog analytics (optional) | | `NEXT_PUBLIC_POSTHOG_KEY` | ❌ Empty | PostHog analytics (optional) |
| `NEXT_PUBLIC_POSTHOG_HOST` | ❌ Empty | PostHog analytics (optional) | | `NEXT_PUBLIC_POSTHOG_HOST` | ❌ Empty | PostHog analytics (optional) |
### 3.6 `services/growth-service/.env` ### 3.6 `services/growth-service/.env`
| Variable | Status | Action | | Variable | Status | Action |
| --------------------------------- | -------- | ------------------------------------------------------------ | |----------|--------|--------|
| `WEBHOOK_INVITATION_REDEEMED_URL` | ❌ Empty | Set to backend or platform-service webhook callback endpoint | | `WEBHOOK_INVITATION_REDEEMED_URL` | ❌ Empty | Set to backend or platform-service webhook callback endpoint |
| `WEBHOOK_REFERRAL_STATUS_URL` | ❌ Empty | Set to backend or platform-service webhook callback endpoint | | `WEBHOOK_REFERRAL_STATUS_URL` | ❌ Empty | Set to backend or platform-service webhook callback endpoint |
### 3.7 `services/billing-service/.env` ### 3.7 `services/billing-service/.env`
| Variable | Status | Action | | Variable | Status | Action |
| ------------------ | -------- | --------------------------------------------------------------- | |----------|--------|--------|
| `PLAN_LIMITS_JSON` | ❌ Empty | Optional — set JSON with per-plan limits if overriding defaults | | `PLAN_LIMITS_JSON` | ❌ Empty | Optional — set JSON with per-plan limits if overriding defaults |
### 3.8 `services/platform-service/.env` ### 3.8 `services/platform-service/.env`
| Variable | Status | Action | | Variable | Status | Action |
| ------------------------ | -------- | ------------------------------------------------------------------------ | |----------|--------|--------|
| `RATE_LIMIT_CONFIG_JSON` | ❌ Empty | Optional — set JSON with per-endpoint rate limits if overriding defaults | | `RATE_LIMIT_CONFIG_JSON` | ❌ Empty | Optional — set JSON with per-endpoint rate limits if overriding defaults |
### 3.9 `services/tracker-service/.env` ### 3.9 `services/tracker-service/.env`
@ -106,7 +106,7 @@ grep -rn 'MISSING_ENV_VALUE' --include='.env*' --include='*.env' . | grep -v nod
These were missing from `.env` files but had known values, so they were filled in: These were missing from `.env` files but had known values, so they were filled in:
| Project | Variable | Value Added | | Project | Variable | Value Added |
| -------------------------------- | -------------------------- | --------------------------------------- | |---------|----------|-------------|
| Root `.env` | `PLATFORM_SERVICE_URL` | `http://localhost:4003` | | Root `.env` | `PLATFORM_SERVICE_URL` | `http://localhost:4003` |
| Root `.env` | `LYSNR_API_URL` | `http://localhost:8000` | | Root `.env` | `LYSNR_API_URL` | `http://localhost:8000` |
| Root `.env` | `LYSNR_ADMIN_URL` | `http://localhost:3001` | | Root `.env` | `LYSNR_ADMIN_URL` | `http://localhost:3001` |
@ -135,7 +135,7 @@ These were missing from `.env` files but had known values, so they were filled i
These values **must be identical** across all services that use them: These values **must be identical** across all services that use them:
| Secret | Used By | | Secret | Used By |
| ------------------- | -------------------------------------------------------------------- | |--------|---------|
| `JWT_SECRET` | All 4 Fastify services + all 3 dashboards + backend | | `JWT_SECRET` | All 4 Fastify services + all 3 dashboards + backend |
| `COSMOS_ENDPOINT` | All 4 Fastify services + admin + user dashboards + backend + desktop | | `COSMOS_ENDPOINT` | All 4 Fastify services + admin + user dashboards + backend + desktop |
| `COSMOS_KEY` | Same as above | | `COSMOS_KEY` | Same as above |
@ -148,7 +148,6 @@ These values **must be identical** across all services that use them:
## 7. Production Deployment Notes ## 7. Production Deployment Notes
When deploying to the current stack: When deploying to the current stack:
- **Vercel** for public/front-end surfaces where applicable - **Vercel** for public/front-end surfaces where applicable
- **Azure VM / shared infra** for backend and internal service hosting - **Azure VM / shared infra** for backend and internal service hosting

View File

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

View File

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

View File

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

View File

@ -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();
});
});

View 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.

View 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)."

View 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

View File

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

View File

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

View File

@ -15,15 +15,25 @@ import {
listJobs, listJobs,
getJob, getJob,
patchJob, patchJob,
operatorAction,
requestReview,
submitReview,
getJobRuns, getJobRuns,
getJobEvents, getJobEvents,
getJobArtifacts, getJobArtifacts,
getJobDag, getJobDag,
getJobExplain,
listFactories, listFactories,
availableEnginesForProduct,
getFleetMetrics,
getBudget, getBudget,
getBudgetBurndown,
upsertBudget, upsertBudget,
pauseBudget, pauseBudget,
resumeBudget, resumeBudget,
budgetUsagePct,
parseSseFrames,
subscribeJobEvents,
} from '@/lib/fleet-client'; } from '@/lib/fleet-client';
describe('fleet-client', () => { describe('fleet-client', () => {
@ -74,6 +84,99 @@ describe('fleet-client', () => {
expect.objectContaining({ method: 'PATCH' }) expect.objectContaining({ method: 'PATCH' })
); );
}); });
it('forwards an optional checkpoint in the PATCH body', async () => {
fetchSpy.mockResolvedValue({ id: 'j1', stage: 'building' });
await patchJob('j1', {
leaseEpoch: 3,
stage: 'building',
checkpoint: { wipBranch: 'feat/x', wipCommit: 'abc123' },
});
expect(fetchSpy).toHaveBeenCalledWith(
'/jobs/j1',
expect.objectContaining({
method: 'PATCH',
body: JSON.stringify({
leaseEpoch: 3,
stage: 'building',
checkpoint: { wipBranch: 'feat/x', wipCommit: 'abc123' },
}),
})
);
});
});
describe('budgetUsagePct', () => {
it('computes a clamped percentage for a normal ceiling', () => {
expect(budgetUsagePct(25, 100)).toBe(25);
expect(budgetUsagePct(150, 100)).toBe(100); // clamps over-budget to 100
});
it('returns 0 for a zero, missing, or non-finite ceiling (no NaN bar)', () => {
expect(budgetUsagePct(10, 0)).toBe(0);
expect(budgetUsagePct(0, 0)).toBe(0);
expect(budgetUsagePct(10, NaN)).toBe(0);
expect(budgetUsagePct(10, undefined as unknown as number)).toBe(0);
expect(budgetUsagePct(10, -5)).toBe(0);
});
});
describe('operatorAction', () => {
it('sends POST to /jobs/:id/actions/:action', async () => {
fetchSpy.mockResolvedValue({ id: 'j1', stage: 'queued' });
const res = await operatorAction('j1', 'requeue');
expect(res.stage).toBe('queued');
expect(fetchSpy).toHaveBeenCalledWith(
'/jobs/j1/actions/requeue',
expect.objectContaining({ method: 'POST' })
);
});
it('supports reject and cancel actions', async () => {
fetchSpy.mockResolvedValue({ id: 'j1', stage: 'dead_letter' });
await operatorAction('j1', 'reject');
expect(fetchSpy).toHaveBeenCalledWith(
'/jobs/j1/actions/reject',
expect.objectContaining({ method: 'POST' })
);
});
});
describe('review gate', () => {
it('requestReview POSTs the policy to /jobs/:id/review/request', async () => {
fetchSpy.mockResolvedValue({ id: 'j1', stage: 'review' });
const res = await requestReview('j1', { requiredApprovals: 2, reviewers: ['a@x.com'] });
expect(res.stage).toBe('review');
expect(fetchSpy).toHaveBeenCalledWith(
'/jobs/j1/review/request',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ requiredApprovals: 2, reviewers: ['a@x.com'] }),
})
);
});
it('requestReview sends an empty object when no policy given', async () => {
fetchSpy.mockResolvedValue({ id: 'j1', stage: 'review' });
await requestReview('j1');
expect(fetchSpy).toHaveBeenCalledWith(
'/jobs/j1/review/request',
expect.objectContaining({ method: 'POST', body: '{}' })
);
});
it('submitReview POSTs the decision to /jobs/:id/review', async () => {
fetchSpy.mockResolvedValue({ id: 'j1', stage: 'testing', gate: 'approved' });
const res = await submitReview('j1', { reviewer: 'a@x.com', decision: 'approve' });
expect(res.gate).toBe('approved');
expect(fetchSpy).toHaveBeenCalledWith(
'/jobs/j1/review',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ reviewer: 'a@x.com', decision: 'approve' }),
})
);
});
}); });
describe('getJobRuns', () => { describe('getJobRuns', () => {
@ -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', () => { describe('listFactories', () => {
it('returns factories on success', async () => { it('returns factories on success', async () => {
fetchSpy.mockResolvedValue({ factories: [{ id: 'f1' }] }); 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', () => { describe('budget operations', () => {
it('getBudget returns budget or null', async () => { it('getBudget returns budget or null', async () => {
fetchSpy.mockResolvedValue({ id: 'lysnrai', ceilingUsd: 100, spentUsd: 25 }); fetchSpy.mockResolvedValue({ id: 'lysnrai', ceilingUsd: 100, spentUsd: 25 });
@ -167,5 +387,155 @@ describe('fleet-client', () => {
expect.objectContaining({ method: 'POST' }) expect.objectContaining({ method: 'POST' })
); );
}); });
it('getBudgetBurndown fetches the series with a days query', async () => {
fetchSpy.mockResolvedValue({
productId: 'p1',
ceilingUsd: 50,
window: 'monthly',
totalUsd: 10,
days: [{ date: '2024-01-01', costUsd: 10, cumulativeUsd: 10 }],
});
const res = await getBudgetBurndown('p1', 30);
expect(res?.totalUsd).toBe(10);
expect(fetchSpy).toHaveBeenCalledWith('/budgets/p1/burndown?days=30', expect.anything());
});
it('getBudgetBurndown returns null on 404', async () => {
fetchSpy.mockRejectedValue(new Error('404 Not Found'));
const res = await getBudgetBurndown('p1');
expect(res).toBeNull();
});
});
describe('parseSseFrames', () => {
it('parses complete frames and skips keepalive comments', () => {
const buf =
'id: 0\nevent: fleet-event\ndata: {"seq":0,"type":"submitted"}\n\n' +
': keepalive\n\n' +
'id: 1\nevent: fleet-event\ndata: {"seq":1,"type":"progress"}\n\n';
const { events, rest } = parseSseFrames(buf);
expect(events).toHaveLength(2);
expect(events[0]).toMatchObject({ id: '0', event: 'fleet-event' });
expect(JSON.parse(events[0].data).type).toBe('submitted');
expect(events[1].id).toBe('1');
expect(rest).toBe('');
});
it('returns a trailing partial frame as rest', () => {
const buf = 'id: 0\nevent: fleet-event\ndata: {"seq":0}\n\nid: 1\ndata: {"seq"';
const { events, rest } = parseSseFrames(buf);
expect(events).toHaveLength(1);
expect(rest).toBe('id: 1\ndata: {"seq"');
});
});
describe('subscribeJobEvents', () => {
function streamResponse(chunks: string[]): Response {
const encoder = new TextEncoder();
const body = new ReadableStream<Uint8Array>({
start(controller) {
for (const c of chunks) controller.enqueue(encoder.encode(c));
controller.close();
},
});
return new Response(body, {
status: 200,
headers: { 'content-type': 'text/event-stream' },
});
}
afterEach(() => {
vi.unstubAllGlobals();
});
it('delivers parsed fleet-events to onEvent', async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
streamResponse([
'retry: 3000\n\n',
'id: 0\nevent: fleet-event\ndata: {"seq":0,"type":"submitted"}\n\n',
': keepalive\n\n',
'id: 1\nevent: fleet-event\ndata: {"seq":1,"type":"progress"}\n\n',
])
)
// never resolves → prevents a tight reconnect loop after the first close
.mockReturnValue(new Promise(() => {}));
vi.stubGlobal('fetch', fetchMock);
const received: number[] = [];
await new Promise<void>(resolve => {
const sub = subscribeJobEvents(
'j1',
{
onEvent: e => {
received.push(e.seq);
if (received.length === 2) {
sub.close();
resolve();
}
},
},
{ reconnectMs: 60_000 }
);
});
expect(received).toEqual([0, 1]);
expect(fetchMock).toHaveBeenCalledWith(
'/api/fleet/jobs/j1/events/stream',
expect.objectContaining({
headers: expect.objectContaining({ accept: 'text/event-stream' }),
})
);
});
it('invokes onError and stops when the stream is unavailable', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response('nope', { status: 500 }));
vi.stubGlobal('fetch', fetchMock);
const err = await new Promise<unknown>(resolve => {
subscribeJobEvents('j1', { onEvent: () => {}, onError: resolve });
});
expect(err).toBeInstanceOf(Error);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('treats a terminal error frame as fatal (onError, no reconnect)', async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
streamResponse([
'id: 0\nevent: fleet-event\ndata: {"seq":0,"type":"submitted"}\n\n',
'event: error\ndata: {"message":"stream interrupted"}\n\n',
])
)
.mockReturnValue(new Promise(() => {}));
vi.stubGlobal('fetch', fetchMock);
const seen: number[] = [];
const err = await new Promise<unknown>(resolve => {
subscribeJobEvents('j1', { onEvent: e => seen.push(e.seq), onError: resolve });
});
expect(seen).toEqual([0]);
expect(err).toBeInstanceOf(Error);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('sends Last-Event-ID header when resuming from a cursor', async () => {
const fetchMock = vi.fn().mockReturnValue(new Promise(() => {}));
vi.stubGlobal('fetch', fetchMock);
const sub = subscribeJobEvents('j1', { onEvent: () => {} }, { lastEventId: 5 });
await Promise.resolve();
sub.close();
expect(fetchMock).toHaveBeenCalledWith(
'/api/fleet/jobs/j1/events/stream',
expect.objectContaining({ headers: expect.objectContaining({ 'last-event-id': '5' }) })
);
});
}); });
}); });

View File

@ -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();
});
});

View File

@ -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);
});
});

View File

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

View File

@ -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());
});
});

View 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' });
});
});

View File

@ -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[] }> }) { async function proxy(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
const { path } = await params; 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); const url = new URL(targetPath, PLATFORM_API);
req.nextUrl.searchParams.forEach((value, key) => { req.nextUrl.searchParams.forEach((value, key) => {
@ -17,7 +18,7 @@ async function proxy(req: NextRequest, { params }: { params: Promise<{ path: str
}); });
try { try {
const headers: Record<string, string> = { 'Content-Type': 'application/json' }; const headers: Record<string, string> = {};
const auth = req.headers.get('authorization'); const auth = req.headers.get('authorization');
if (auth) headers['Authorization'] = auth; 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') { if (req.method !== 'GET' && req.method !== 'HEAD') {
const body = await req.text(); 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); const res = await fetch(url.toString(), fetchOptions);

View File

@ -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;

View File

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

View File

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

View File

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

View File

@ -4,7 +4,15 @@ import { useEffect, useState, useCallback } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { PageHeader } from '@bytelyst/dashboard-components'; import { PageHeader } from '@bytelyst/dashboard-components';
import { useAuth } from '@/lib/auth-context'; import { useAuth } from '@/lib/auth-context';
import { 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; 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 }) { function StageBadge({ stage }: { stage: string }) {
const colors: Record<string, string> = { const colors: Record<string, string> = {
queued: 'bg-blue-500/20 text-blue-700 dark:text-blue-400', queued: 'bg-blue-500/20 text-blue-700 dark:text-blue-400',
@ -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() { export default function FleetOverviewPage() {
const { token } = useAuth(); const { token } = useAuth();
const [factories, setFactories] = useState<FleetFactory[]>([]); const [factories, setFactories] = useState<FleetFactory[]>([]);
const [jobs, setJobs] = useState<FleetJob[]>([]); const [jobs, setJobs] = useState<FleetJob[]>([]);
const [metrics, setMetrics] = useState<FleetMetrics | null>(null);
const [deadLetters, setDeadLetters] = useState<FleetJob[]>([]);
const [redriving, setRedriving] = useState<string | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
try { try {
const [facRes, jobRes] = 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); setFactories(facRes.factories);
setJobs(jobRes.jobs); setJobs(jobRes.jobs);
setMetrics(metricsRes);
// Filter client-side too, so the triage list is correct even if the server
// ignores the stage filter.
setDeadLetters(dlRes.jobs.filter(j => j.stage === 'dead_letter'));
} catch { } catch {
/* degrade gracefully */ /* degrade gracefully */
} finally { } finally {
@ -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(() => { useEffect(() => {
if (!token) return; if (!token) return;
refresh(); refresh();
@ -78,6 +207,101 @@ export default function FleetOverviewPage() {
<div className="p-6 space-y-8"> <div className="p-6 space-y-8">
<PageHeader title="Fleet Control Plane" /> <PageHeader title="Fleet Control Plane" />
{/* Metrics + alerts */}
{metrics && (
<section aria-label="Fleet metrics" data-testid="fleet-metrics">
{metrics.alerts.length > 0 && (
<div className="space-y-2 mb-4" data-testid="fleet-alerts">
{metrics.alerts.map(a => (
<div
key={a.code}
role="alert"
className={`rounded-md border px-3 py-2 text-sm ${
a.level === 'critical'
? 'border-red-500/40 bg-red-500/10 text-red-700 dark:text-red-400'
: 'border-yellow-500/40 bg-yellow-500/10 text-yellow-700 dark:text-yellow-400'
}`}
>
<span className="font-medium uppercase mr-2">{a.level}</span>
{a.message}
</div>
))}
</div>
)}
<div className="grid gap-4 grid-cols-2 sm:grid-cols-3 lg:grid-cols-5">
<MetricCard label="Queue depth" value={metrics.jobs.queueDepth} />
<MetricCard label="Active" value={metrics.jobs.active} />
<MetricCard label="Blocked" value={metrics.jobs.blocked} />
<MetricCard
label="Live factories"
value={`${metrics.factories.live}/${metrics.factories.total}`}
/>
<MetricCard label="Utilization" value={`${metrics.factories.utilizationPct}%`} />
</div>
</section>
)}
{/* Budget guardrail */}
{metrics?.budget && <BudgetGuardrail budget={metrics.budget} />}
{/* Engine circuit breakers */}
{metrics?.engineBreakers && metrics.engineBreakers.length > 0 && (
<BreakerPanel breakers={metrics.engineBreakers} />
)}
{/* Dead-letter triage */}
{deadLetters.length > 0 && (
<section aria-label="Dead-letter triage" data-testid="fleet-dead-letter">
<h2 className="text-lg font-semibold mb-3">
Dead-letter triage{' '}
<span className="text-sm font-normal text-muted-foreground">
({deadLetters.length})
</span>
</h2>
<div className="overflow-x-auto">
<table className="w-full text-sm" aria-label="Dead-letter jobs">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-2 pr-4">Key</th>
<th className="pb-2 pr-4">Engine</th>
<th className="pb-2 pr-4">Attempts</th>
<th className="pb-2">Action</th>
</tr>
</thead>
<tbody>
{deadLetters.map(j => (
<tr key={j.id} className="border-b last:border-0 hover:bg-muted/50">
<td className="py-2 pr-4">
<Link
href={`/dashboard/fleet/jobs/${j.id}`}
className="hover:underline font-mono text-xs"
>
{j.idempotencyKey}
</Link>
</td>
<td className="py-2 pr-4 font-mono text-xs">
{j.engine ?? j.engineClass ?? '—'}
</td>
<td className="py-2 pr-4 tabular-nums">{j.attempts}</td>
<td className="py-2">
<button
type="button"
onClick={() => handleRedrive(j.id)}
disabled={redriving === j.id}
aria-label={`Re-drive job ${j.idempotencyKey}`}
className="rounded-md border px-2 py-1 text-xs font-medium hover:bg-muted disabled:opacity-50"
>
{redriving === j.id ? 'Re-driving…' : 'Re-drive'}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
)}
{/* Factory cards */} {/* Factory cards */}
<section> <section>
<h2 className="text-lg font-semibold mb-3">Factories</h2> <h2 className="text-lg font-semibold mb-3">Factories</h2>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,7 @@
'use client'; 'use client';
import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from 'react'; import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from 'react';
import { KNOWN_PRODUCTS, PRODUCT_ID } from '@/lib/product-constants'; import { KNOWN_PRODUCTS, PRODUCT_ID, type Product } from '@/lib/product-constants';
const STORAGE_KEY = 'tracker_selected_product'; const STORAGE_KEY = 'tracker_selected_product';
const PRODUCT_CHANGED_EVENT = 'tracker:product-changed'; const PRODUCT_CHANGED_EVENT = 'tracker:product-changed';
@ -10,7 +10,7 @@ interface ProductContextValue {
productId: string; productId: string;
productName: string; productName: string;
setProductId: (id: string) => void; setProductId: (id: string) => void;
products: typeof KNOWN_PRODUCTS; products: readonly Product[];
} }
const ProductContext = createContext<ProductContextValue | null>(null); const ProductContext = createContext<ProductContextValue | null>(null);
@ -20,8 +20,27 @@ function getInitialProduct(): string {
return localStorage.getItem(STORAGE_KEY) || PRODUCT_ID; return localStorage.getItem(STORAGE_KEY) || PRODUCT_ID;
} }
/** Map the platform-service product docs to the switcher's lightweight shape. */
function toProducts(docs: unknown): Product[] {
if (!Array.isArray(docs)) return [];
const out: Product[] = [];
for (const d of docs) {
if (!d || typeof d !== 'object') continue;
const { productId, id, displayName, name } = d as Record<string, unknown>;
const pid = typeof productId === 'string' ? productId : typeof id === 'string' ? id : null;
if (!pid) continue;
const label =
typeof displayName === 'string' ? displayName : typeof name === 'string' ? name : pid;
out.push({ id: pid, name: label });
}
return out;
}
export function ProductProvider({ children }: { children: ReactNode }) { export function ProductProvider({ children }: { children: ReactNode }) {
const [productId, setProductIdState] = useState<string>(getInitialProduct); const [productId, setProductIdState] = useState<string>(getInitialProduct);
// Start from the configured list (env/default); replace with the caller's
// owner-scoped projects once fetched. Falling back keeps dev/unauth working.
const [products, setProducts] = useState<readonly Product[]>(KNOWN_PRODUCTS);
useEffect(() => { useEffect(() => {
function syncSelectedProduct() { function syncSelectedProduct() {
@ -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) => { const setProductId = useCallback((id: string) => {
setProductIdState(id); setProductIdState(id);
if (typeof window !== 'undefined') { 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; const productName = product?.name ?? productId;
return ( return (
<ProductContext.Provider <ProductContext.Provider value={{ productId, productName, setProductId, products }}>
value={{ productId, productName, setProductId, products: KNOWN_PRODUCTS }}
>
{children} {children}
</ProductContext.Provider> </ProductContext.Provider>
); );

View File

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

View File

@ -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,
});
}
}

View File

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

View File

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

View 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`).

View 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`).

View 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

View File

@ -77,7 +77,7 @@
## Slice 5 — Docs + roadmap ## 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 ## Follow-ups

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -83,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( async function createContainerSafe(
database: Database, database: Database,
name: string, name: string,
config: ContainerConfig config: ContainerConfig
): Promise<void> { ): Promise<void> {
const indexingPolicy = buildIndexingPolicy(config);
const payload = { const payload = {
id: name, id: name,
partitionKey: { partitionKey: {
@ -95,14 +156,27 @@ async function createContainerSafe(
kind: 'Hash', kind: 'Hash',
} as PartitionKeyDefinition, } as PartitionKeyDefinition,
...(config.defaultTtl != null && { defaultTtl: config.defaultTtl }), ...(config.defaultTtl != null && { defaultTtl: config.defaultTtl }),
...(indexingPolicy && { indexingPolicy }),
}; };
for (let attempt = 0; attempt < 3; attempt += 1) { for (let attempt = 0; attempt < 3; attempt += 1) {
try { try {
await database.containers.createIfNotExists(payload); await database.containers.createIfNotExists(payload);
// createIfNotExists won't update an existing container's index policy.
if (indexingPolicy) await ensureIndexingPolicy(database, name, config);
return; return;
} catch (err) { } catch (err) {
if (isCosmosConflict(err)) 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. // Sometimes the database/container metadata isn't immediately visible after creation.
if (isCosmosNotFound(err) && attempt < 2) { if (isCosmosNotFound(err) && attempt < 2) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

1546
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View 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
View 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
View 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

View File

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

View File

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

View File

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

View File

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

View File

@ -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"
}
]
}
]
}

View File

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

View File

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

View File

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

View File

@ -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"
}
]
}

View File

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

View File

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

View File

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

View File

@ -188,14 +188,35 @@ const CONTAINER_DEFS: Record<string, ContainerConfig> = {
translations: { partitionKeyPath: '/locale' }, translations: { partitionKeyPath: '/locale' },
i18n_locales: { partitionKeyPath: '/locale' }, i18n_locales: { partitionKeyPath: '/locale' },
// Agent Gigafactory — fleet coordinator (see modules/fleet/README.md) // Agent Gigafactory — fleet coordinator (see modules/fleet/README.md)
fleet_jobs: { 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_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_factories: { partitionKeyPath: '/productId' },
fleet_profiles: { partitionKeyPath: '/productId' }, fleet_profiles: { partitionKeyPath: '/productId' },
fleet_events: { partitionKeyPath: '/jobId' }, fleet_events: { partitionKeyPath: '/jobId' },
fleet_artifacts: { partitionKeyPath: '/jobId' }, fleet_artifacts: { partitionKeyPath: '/jobId' },
fleet_factory_tokens: { partitionKeyPath: '/productId' }, fleet_factory_tokens: { partitionKeyPath: '/productId' },
// M0 RU gate: per-product monotonic "work changed" counter (see
// docs/GIGAFACTORY/FLEET_DISPATCH_REDESIGN.md §8/§12).
fleet_queue_state: { partitionKeyPath: '/productId' },
}; };
export async function initCosmosIfNeeded(): Promise<void> { export async function initCosmosIfNeeded(): Promise<void> {

View File

@ -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');
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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