From b6ec16a22d865674f2449e977820c384210b6896 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Jul 2026 07:19:18 +0000 Subject: [PATCH 01/15] Document Phase 9 launch plan for internal GA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mark M0–M6 production readiness complete and define the L0–L6 path from pilot-ready to day-to-day multi-team launch, with an operator checklist. Co-authored-by: Chiiz0 --- README.md | 5 + docs/README.md | 2 + docs/implementation/launch-plan.md | 299 ++++++++++++++++++ docs/implementation/mvp-status.md | 5 + .../production-readiness-plan.md | 32 +- docs/implementation/roadmap.md | 29 ++ docs/operations/launch-checklist.md | 68 ++++ docs/operations/pilot-runbook.md | 4 +- 8 files changed, 435 insertions(+), 9 deletions(-) create mode 100644 docs/implementation/launch-plan.md create mode 100644 docs/operations/launch-checklist.md diff --git a/README.md b/README.md index fd68c85..09c59ad 100644 --- a/README.md +++ b/README.md @@ -35,3 +35,8 @@ make migrate-dev ## Documentation Start with [docs/README.md](docs/README.md). The original technical plan is preserved in [multi-codex_technical_plan.md](multi-codex_technical_plan.md), while implementation notes are split into focused folders under `docs/`. + +Current delivery posture: + +- MVP and enterprise pilot readiness: complete through M0–M6. +- Next track: internal GA launch plan in [docs/implementation/launch-plan.md](docs/implementation/launch-plan.md). diff --git a/docs/README.md b/docs/README.md index 570d588..511f9f1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -14,6 +14,7 @@ This folder keeps implementation documentation split by responsibility instead o - [Roadmap](implementation/roadmap.md) - [MVP Status](implementation/mvp-status.md) - [Production Readiness Plan](implementation/production-readiness-plan.md) +- [Launch Plan](implementation/launch-plan.md) - [Docker Development](implementation/docker-development.md) ## Security and Operations @@ -26,6 +27,7 @@ This folder keeps implementation documentation split by responsibility instead o - [Observability and DR](operations/observability-and-dr.md) - [Product Pilot Runbook](operations/pilot-runbook.md) - [Pilot Evidence Template](operations/pilot-evidence-template.md) +- [Launch Checklist](operations/launch-checklist.md) - [OIDC Outage Runbook](operations/runbooks/oidc-outage.md) - [Database Outage Runbook](operations/runbooks/database-outage.md) - [Stuck Queue Runbook](operations/runbooks/stuck-queue.md) diff --git a/docs/implementation/launch-plan.md b/docs/implementation/launch-plan.md new file mode 100644 index 0000000..1d779bd --- /dev/null +++ b/docs/implementation/launch-plan.md @@ -0,0 +1,299 @@ +# Launch Plan: From Pilot-Ready to Production GA + +This plan continues after the completed Production Readiness milestones +([production-readiness-plan.md](production-readiness-plan.md), M0–M6) and the +2026-06-30 verification/pilot evidence +([verification-report.md](../operations/verification-report.md)). + +The platform is already **pilot-ready**: fail-closed production config, resource +authorization, worker isolation, release packaging, observability/DR runbooks, +and a governed dry-run → live PR workflow with auto-merge disabled. + +The remaining work turns that pilot into a system teams can run **day to day** +without specialist hand-holding. + +## Release Target + +`v1.0` should be a controlled internal GA for a small number of engineering +teams on company repositories: + +- OIDC-only authentication with enterprise IdP redirect URLs registered. +- One or more production Compose (or later Kubernetes) environments with TLS, + backups, alerts, retention, and audit ship on a schedule. +- Daily operator workflows in the Web Console and MCP Gateway without local-auth + shortcuts. +- Docker and/or SSH workers on dedicated hosts with capacity quotas. +- Live PR creation still gated by `pr_publish` approval and never auto-merge. +- Clear onboarding, capacity, and rollback playbooks for the first expanding + teams. + +Non-goals for `v1.0`: + +- Public multi-tenant SaaS. +- Automatic merge to protected main branches. +- Arbitrary worker shell or unrestricted network. +- Full Kubernetes feature parity before Compose GA is proven. +- Treating Skills as a security boundary. + +## Current Baseline Versus Gaps + +| Area | Baseline today | Gap for GA | +| --- | --- | --- | +| Auth / SSO | OIDC code flow, sessions, logout, back-channel logout, group maps | Register production IdP URLs; optional private-key JWT / mTLS client auth when IdP requires it | +| Authorization | Resource-scoped API/MCP checks, viewer deny, audited denials | Membership admin UX, invitation/revoke runbook, periodic access review | +| Executors | Mock, Docker, SSH/agentd with limits, secrets, timeouts, queue | Dedicated worker-host topology as default; quota per org/project; command allowlist UX | +| Git delivery | Dry-run default, live GitHub/GitLab after review, no auto-merge | Repo-scoped tokens by default; Forgejo optional; branch protection checklist | +| Console | Single TaskBoard covering core ops | Split operator journeys, empty states, error recovery, mobile/narrow layout QA | +| Observability | Metrics, alerts, Grafana skeleton, runbooks | Wire alerts to on-call; prove SLO burn; scheduled restore drills | +| Deploy | Compose, CI images, migrate profile, SBOM/Trivy | HA Compose topology or Helm; blue/green app rollout; secret rotation calendar | +| FinOps | Run duration metrics only | Token/cost attribution per org/project/task/run | +| Product workflow | Feature → scope → test → audit → approval → git sync | Docs Codex optional; notification/webhook for approvals; multi-repo task patterns | +| Evidence | Pilot verify CLI and templates | Recurring GA gate with sign-off owners | + +## Definition Of Done For Launch + +`v1.0` is launchable when all of the following are true: + +1. A production environment boots only in fail-closed mode and passes health, + metrics, audit ship, and backup smoke checks. +2. At least two internal teams complete onboarding using the launch runbook + without custom engineering patches. +3. Real Codex Docker (or SSH) workers complete governed tasks on real + repositories with scope, test, audit, and human approval enforced. +4. Live PR creation works with least-privilege provider credentials and + `auto_merge=false`. +5. Alerts page an on-call owner for API/MCP errors, stuck queue, worker failure + burst, audit ship stale, and database down. +6. Backup restore and audit verify have been drilled on a schedule, not only + once during the pilot. +7. Cost/runtime usage is visible enough for the service owner to set capacity + and budget limits. +8. Security and operations sign off against the GA checklist; open gaps are + tracked as issues, not hidden in one append-only note. + +## Launch Milestones + +### L0: Reconcile Status And Freeze The Launch Scope + +Goal: stop planning against stale “remaining MVP” language. + +Required work: + +- Mark M0–M6 complete in roadmap and readiness docs; point remaining work here. +- Publish a short `v1.0` scope freeze: what ships, what waits, and what is + explicitly out of scope. +- Convert open pilot findings into tracked issues grouped by security, + operations, console UX, and executor reliability. +- Refresh verification only when launch code changes; do not re-litigate M0–M6 + unless a regression appears. + +Exit criteria: + +- Docs agree that the platform is pilot-ready and launch work is L1+. +- A single issue board or label set owns GA blockers. + +### L1: Production Environment Cutover + +Goal: stand up the first durable online environment for real users. + +Required work: + +- Register OIDC redirect, logout, and back-channel URLs with the enterprise IdP. +- Fill `.env.production` from the example template using a secret manager; no + local auth, no wildcard CORS, secure cookies on. +- Terminate TLS at the reverse proxy/load balancer for Web, API, and MCP. +- Keep worker-agentd on a private network; require `MULTICODEX_AGENTD_TOKEN`. +- Enable scheduled retention (start dry-run, then approved delete) and scheduled + audit seal/ship to WORM/SIEM. +- Schedule nightly `mcxctl backup` and monthly restore drills. +- Wire Prometheus alerts to the on-call channel. +- Document the isolated worker host boundary when Docker mode is enabled. + +Exit criteria: + +- Production Compose (or equivalent) serves OIDC login end-to-end. +- Health, metrics, audit ship, backup, and alert routing are proven with + evidence attached to the release record. +- Git Sync remains `dry-run` until L3 sign-off for that environment. + +### L2: Operator Experience For Daily Use + +Goal: make the console and MCP path usable without reading source code. + +Required work: + +- Split the monolithic TaskBoard into clear operator journeys: Projects, + Tasks, Runs, Approvals, Queue, Nodes, Skills/Profiles, Orgs, Audit. +- Add empty states, permission-aware disabled actions, and recoverable error + banners for auth expiry, capacity pressure, and workflow blockers. +- Improve Run Detail for day-2 ops: live events, artifact tabs, scope + violations, gate timeline, and copyable task/run IDs. +- Add an in-console “start pilot/task” checklist that mirrors the runbook for + the first two teams. +- Verify keyboard and narrow-width layouts for the primary workflows. +- Keep MCP tools as the Main Codex path; ensure tool errors map to the same + human-readable blocker reasons shown in the console. + +Exit criteria: + +- A new tech lead can create a project, task, start workers, approve, and + inspect audit evidence using only docs + console. +- Viewer and cross-org denial remain visible and audited from the UI path. + +### L3: Multi-Team Execution Controls + +Goal: expand beyond one pilot repo without unbounded blast radius. + +Required work: + +- Enforce per-organization and per-project concurrency / queue quotas. +- Add API/MCP rate limits for mutating endpoints and worker starts. +- Default Git credentials to repository-scoped tokens; document rotation and + compromise runbooks as a recurring drill. +- Expand command policy UX so profiles declare allowlists clearly and denials + appear in run events. +- Add membership administration: list members, role changes, revoke access, + and audit those decisions. +- Support multiple executor pools (Docker host pool and SSH pool) with explicit + team assignment. +- Keep live PR mode behind environment review flags plus task-level + `pr_publish` approval. + +Exit criteria: + +- Two teams can run concurrently without starving each other or sharing + credentials. +- Quota and rate-limit denials are metrics-visible and audited. +- Live PR is enabled only for approved repositories. + +### L4: Availability And Deployment Topology + +Goal: make upgrades and failures routine. + +Required work: + +- Document and test a HA Compose topology: Postgres primary/replica or managed + DB, multiple API/MCP replicas behind the proxy, shared artifact/run volumes + or object storage. +- Add optional Helm/Kubernetes manifests when the target platform requires it; + do not block Compose GA on full K8s parity. +- Define blue/green or rolling app image rollout with migrate-before-serve. +- Add readiness probes that include production config validation side effects + already covered by fail-closed startup. +- Decide whether MCP fanout stays on PostgreSQL LISTEN/NOTIFY or needs + Redis/NATS based on measured replica count and session volume. +- Automate secret and host-key rotation calendars. + +Exit criteria: + +- An API or MCP replica can be stopped without losing accepted work beyond + documented RPO/RTO. +- Rollback of app images and restore of DB backups are drilled from the release + runbook. + +### L5: Usage, Cost, And Capacity Visibility + +Goal: give owners the numbers needed to fund and throttle the platform. + +Required work: + +- Persist per-run duration, executor, role, org, project, and task identifiers + in a queryable usage table or metrics export. +- Capture model/token usage when Codex workers report it; otherwise record + “unknown” explicitly rather than inventing numbers. +- Expose usage summaries in API and console: by org, project, role, and day. +- Add soft and hard budget thresholds that alert before hard-stopping workers. +- Feed capacity planning docs with concurrency, queue depth, and cost trends. + +Exit criteria: + +- Service owners can answer “who spent worker time last week?” without SQL. +- Budget alerts fire in staging before enabling hard limits in production. + +### L6: Broader Product Rollout And GA Sign-Off + +Goal: graduate from controlled pilot to declared internal GA. + +Required work: + +- Optional Docs Codex role and Skill for changelog/docs-only tasks. +- Optional Forgejo provider support if that is an official company Git forge. +- Approval notification hooks (webhook/email/chat) so reviewers are not polling. +- Multi-repository task patterns documented with explicit envelope examples. +- Security review checklist completion: IdP config, secret providers, worker + network policy, audit WORM retention, and RBAC sampling. +- Operations review checklist completion: alerts, backup/restore, retention + delete approval, runbooks, and on-call ownership. +- Publish `v1.0` release notes, upgrade notes, and a GA announcement for + internal teams. + +Exit criteria: + +- Security and operations owners sign the GA checklist. +- At least two teams have completed real repository workflows after L1 cutover. +- Remaining items are scheduled as post-GA work, not silent debt. + +## Immediate Next Sprint + +Implement in this order; do not start L5/L6 feature work first: + +1. **L0 docs freeze** — update roadmap/status pointers and open GA blocker + issues from pilot findings. +2. **L1 IdP + TLS + secrets cutover checklist** — executable environment bring-up + with evidence template fields for redirect URLs, alert routing, backup + schedule, and worker-host boundary. +3. **L2 console journey split** — Projects/Tasks/Runs/Approvals/Queue first; + keep visual language compatible with the existing console. +4. **L3 org/project quotas and membership admin API** — required before a second + team shares the same deployment. +5. **L1 scheduled backup/restore and alert paging proof** — attach receipts to + the environment launch record. + +This sequence prefers safety and operability over new agent roles. + +## Suggested Delivery Packages + +Keep changes small and reviewable: + +| Package | Primary paths | Notes | +| --- | --- | --- | +| Docs freeze | `docs/implementation/*`, `docs/README.md` | This plan and status sync | +| Env launch kit | `docs/operations/*`, `.env.production.example` | Cutover checklist + evidence | +| Console journeys | `apps/web/src/**` | Split views without redesigning the brand system arbitrarily | +| Quotas / rate limits | `internal/scheduler`, `internal/api`, `internal/mcp`, migrations | Fail closed with audit rows | +| Membership admin | `internal/store`, `internal/api`, web Orgs view | Audited role changes | +| Usage accounting | `internal/observability`, store, web | Prefer metrics+API before fancy charts | +| HA / Helm | `deployments/**`, ops docs | Optional until Compose GA is proven | + +## Risks And Controls + +| Risk | Why it matters | Control | +| --- | --- | --- | +| Second team shares pilot credentials | Credential blast radius | Per-repo tokens, membership revoke, compromise runbook drill | +| Console remains specialist-only | Adoption stalls | L2 journey split and checklist UX before marketing GA | +| Docker socket on shared hosts | Host takeover | Isolated worker host boundary only; prefer remote agentd/SSH | +| Live PR enabled too early | Unreviewed provider calls | Environment review flag + task approval + dry-run evidence | +| No usage visibility | Unbounded spend | L5 usage table and budget alerts before hard scale-out | +| K8s work blocks Compose GA | Delayed value | Compose GA first; Helm is L4 optional path | + +## Post-GA Backlog + +Track after `v1.0`, unless a launch team blocks on them: + +- Kubernetes operator-style deploy and autoscaling worker pools. +- High-throughput MCP fanout bus (Redis Streams / NATS). +- Provider-specific OIDC private-key JWT or mTLS client auth. +- Cross-region DR beyond the documented RPO/RTO. +- Deeper Fine-grained ABAC beyond org/role scoping. +- Public SaaS tenancy and billing. + +## Related Documents + +- [Production Readiness Plan](production-readiness-plan.md) +- [Implementation Roadmap](roadmap.md) +- [MVP Status](mvp-status.md) +- [Verification Report](../operations/verification-report.md) +- [Product Pilot Runbook](../operations/pilot-runbook.md) +- [Release And Deployment](../operations/release-and-deployment.md) +- [Observability And DR](../operations/observability-and-dr.md) +- [Enterprise Hardening](../security/enterprise-hardening.md) diff --git a/docs/implementation/mvp-status.md b/docs/implementation/mvp-status.md index db425c4..176ac08 100644 --- a/docs/implementation/mvp-status.md +++ b/docs/implementation/mvp-status.md @@ -2,6 +2,11 @@ Last verified: 2026-06-26. +Pilot readiness (M0–M6) was later verified on 2026-06-30, including a live +governed PR pilot. See [verification-report.md](../operations/verification-report.md). +Day-to-day internal GA work continues under +[launch-plan.md](launch-plan.md). + ## Completed Slice - Fixed Docker development image: `multi-codex/dev:go1.25-node25.9-pnpm11.7`. diff --git a/docs/implementation/production-readiness-plan.md b/docs/implementation/production-readiness-plan.md index cb6790d..b96b893 100644 --- a/docs/implementation/production-readiness-plan.md +++ b/docs/implementation/production-readiness-plan.md @@ -1,6 +1,17 @@ # Production Readiness Plan -This plan turns the current enterprise MVP into an operator-ready release. It is based on the current repository state and the 2026-06-26 verification report. Before starting implementation work, refresh verification from the fixed dev image because host-local Go/Node tooling is not part of the supported workflow. +Status: **M0–M6 complete** as of the 2026-06-30 verification and live pilot +evidence in [verification-report.md](../operations/verification-report.md). + +This plan turned the enterprise MVP into a pilot-ready release. Further work to +make the platform day-to-day launchable for multiple internal teams lives in +[launch-plan.md](launch-plan.md) and +[launch-checklist.md](../operations/launch-checklist.md). + +The historical milestones below remain the audit trail for how pilot readiness +was reached. Before new production hardening work, refresh verification from the +fixed dev image because host-local Go/Node tooling is not part of the supported +workflow. ## Current Baseline @@ -158,12 +169,17 @@ Exit criteria: ## Immediate Next Sprint -Start with these items in order: +M0–M6 are complete. Do not restart readiness work from M1 unless a regression +appears. + +Continue with the launch track in order: -1. Build the fixed dev image and refresh the full verification report. -2. Add production-mode readiness validation and tests. -3. Implement MCP tool-level permission checks. -4. Start resource-scoped API authorization with project, repository, task, run, artifact, and approval endpoints. -5. Document the production deployment checklist and the first pilot runbook. +1. Freeze `v1.0` scope and track GA blockers as issues (L0). +2. Cut over the first durable production environment with IdP, TLS, secrets, + backup, audit ship, and alert paging (L1). +3. Split console operator journeys for daily use (L2). +4. Add org/project quotas and membership administration before onboarding a + second team (L3). -This sequence intentionally protects the release path before expanding functionality. +Details: [launch-plan.md](launch-plan.md) and +[launch-checklist.md](../operations/launch-checklist.md). diff --git a/docs/implementation/roadmap.md b/docs/implementation/roadmap.md index b2167a4..cdc4cf8 100644 --- a/docs/implementation/roadmap.md +++ b/docs/implementation/roadmap.md @@ -177,3 +177,32 @@ Completed: Next work: - Register production IdP redirect URLs and add provider-specific private-key JWT or mTLS client authentication if the enterprise IdP requires it. +- Continue remaining enterprise/launch work under Phase 9 rather than reopening Phase 8 as incomplete MVP scope. + +## Phase 9: Launch And Internal GA + +Status: planning complete; implementation starts at L1 after L0 doc freeze. + +The Production Readiness track (M0–M6) proved a governed pilot path, including +dry-run → live PR creation with auto-merge disabled. Phase 9 turns that into an +internal `v1.0` GA. + +Milestones are defined in [launch-plan.md](launch-plan.md): + +- L0: reconcile status and freeze `v1.0` scope +- L1: production environment cutover (IdP, TLS, secrets, backup, alerts) +- L2: operator console journeys for daily use +- L3: multi-team quotas, rate limits, membership admin, scoped Git credentials +- L4: HA Compose / optional Helm, rolling deploy, fanout capacity decision +- L5: usage, cost, and capacity visibility +- L6: broader rollout, notifications, optional Docs/Forgejo, GA sign-off + +Operator gate: [launch-checklist.md](../operations/launch-checklist.md). + +Immediate next sprint: + +1. Keep docs and issue tracking aligned with the launch plan (L0). +2. Execute the first durable production cutover checklist (L1). +3. Split console operator journeys (L2). +4. Add org/project quotas and membership administration before a second team + shares the deployment (L3). diff --git a/docs/operations/launch-checklist.md b/docs/operations/launch-checklist.md new file mode 100644 index 0000000..0eb1e1b --- /dev/null +++ b/docs/operations/launch-checklist.md @@ -0,0 +1,68 @@ +# Launch Checklist + +Use this checklist when graduating a pilot-ready deployment to internal GA. +Evidence belongs with the environment release record, not in an append-only +notes file. + +Related plan: [Launch Plan](../implementation/launch-plan.md). + +## L0 Scope Freeze + +- [ ] M0–M6 readiness docs marked complete; launch work tracked under L1+ +- [ ] `v1.0` in-scope / out-of-scope list published +- [ ] Pilot findings converted to labeled issues (security, ops, UX, executor) + +## L1 Environment Cutover + +- [ ] OIDC redirect, logout, and back-channel URLs registered with the IdP +- [ ] Production env vars loaded from a secret manager; local auth disabled +- [ ] TLS terminated for Web, API, and MCP; secure cookies enabled +- [ ] worker-agentd private-only with `MULTICODEX_AGENTD_TOKEN` +- [ ] Docker worker host uses `isolated-worker-host` boundary when enabled +- [ ] Retention enabled; delete mode approved only after dry-run evidence +- [ ] Audit seal/ship scheduled to WORM/SIEM with a successful receipt +- [ ] Nightly backup scheduled; restore drill date booked +- [ ] Prometheus alerts route to an on-call channel +- [ ] Git Sync left in `dry-run` until repository live review + +## L2 Daily Operator UX + +- [ ] Console journeys cover Projects, Tasks, Runs, Approvals, Queue, Nodes +- [ ] Permission-denied and capacity errors are understandable in the UI +- [ ] New tech lead completes one governed task using docs + console only + +## L3 Multi-Team Controls + +- [ ] Org/project concurrency quotas enforced and audited +- [ ] Mutating API/MCP rate limits enabled +- [ ] Membership list/role/revoke administered and audited +- [ ] Provider tokens scoped per repository and rotation owners named +- [ ] Second team onboarded without sharing the first team's credentials + +## L4 Availability + +- [ ] App rollback procedure drilled with previous immutable image tags +- [ ] Database restore drill completed within documented RTO +- [ ] API/MCP replica failure behavior documented for the chosen topology + +## L5 Usage Visibility + +- [ ] Per-org/project/run duration (and token usage when available) queryable +- [ ] Budget or capacity alerts configured for the service owner + +## L6 GA Sign-Off + +- [ ] Security checklist signed +- [ ] Operations checklist signed +- [ ] Two teams completed real-repo governed PR workflows +- [ ] `v1.0` release notes and upgrade notes published +- [ ] Post-GA backlog filed; no silent launch blockers remain + +## Sign-Off + +| Role | Name | Date | Result | +| --- | --- | --- | --- | +| Service owner | | | | +| Security reviewer | | | | +| Operations owner | | | | +| Pilot / GA engineering lead | | | | diff --git a/docs/operations/pilot-runbook.md b/docs/operations/pilot-runbook.md index 208e12c..f7c4581 100644 --- a/docs/operations/pilot-runbook.md +++ b/docs/operations/pilot-runbook.md @@ -176,7 +176,9 @@ Record sign-off in a focused issue or pilot evidence document with: - follow-up issues for product, security, or operations gaps Broader rollout is blocked until the sign-off states that security, -operations, and product usability are acceptable for the next team. +operations, and product usability are acceptable for the next team. After +pilot sign-off, follow [launch-checklist.md](launch-checklist.md) and +[launch-plan.md](../implementation/launch-plan.md) for internal GA. ## Evidence Verification From 4abbfa5c56b69e4b67258f30d2f8c91dc915ecdb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Jul 2026 07:28:10 +0000 Subject: [PATCH 02/15] Plan frontend workbench maturity with a mature UI stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expand L2 into an F0–F6 console program: React Router, Tailwind, shadcn/ui, tables/forms standards, Run Cockpit evidence, and frontend CI quality gates. Co-authored-by: Chiiz0 --- README.md | 1 + docs/README.md | 1 + .../implementation/frontend-workbench-plan.md | 375 ++++++++++++++++++ docs/implementation/launch-plan.md | 54 ++- docs/implementation/mvp-status.md | 3 +- .../production-readiness-plan.md | 6 +- docs/implementation/roadmap.md | 27 +- docs/operations/design-qa.md | 5 +- docs/operations/launch-checklist.md | 11 +- 9 files changed, 452 insertions(+), 31 deletions(-) create mode 100644 docs/implementation/frontend-workbench-plan.md diff --git a/README.md b/README.md index 09c59ad..afba531 100644 --- a/README.md +++ b/README.md @@ -40,3 +40,4 @@ Current delivery posture: - MVP and enterprise pilot readiness: complete through M0–M6. - Next track: internal GA launch plan in [docs/implementation/launch-plan.md](docs/implementation/launch-plan.md). +- Frontend track: mature the console into a workbench via [docs/implementation/frontend-workbench-plan.md](docs/implementation/frontend-workbench-plan.md). diff --git a/docs/README.md b/docs/README.md index 511f9f1..333910a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -15,6 +15,7 @@ This folder keeps implementation documentation split by responsibility instead o - [MVP Status](implementation/mvp-status.md) - [Production Readiness Plan](implementation/production-readiness-plan.md) - [Launch Plan](implementation/launch-plan.md) +- [Frontend Workbench Plan](implementation/frontend-workbench-plan.md) - [Docker Development](implementation/docker-development.md) ## Security and Operations diff --git a/docs/implementation/frontend-workbench-plan.md b/docs/implementation/frontend-workbench-plan.md new file mode 100644 index 0000000..ee5df0c --- /dev/null +++ b/docs/implementation/frontend-workbench-plan.md @@ -0,0 +1,375 @@ +# Frontend Workbench Plan + +This plan matures the Web Console from a feature-complete operator shell into a +**reliable daily workbench**. It complements the backend launch track in +[launch-plan.md](launch-plan.md) and expands Phase 9 L2 into an explicit +frontend delivery program. + +Related: + +- Original stack intent: [multi-codex_technical_plan.md](../../multi-codex_technical_plan.md) §3.1 +- Current console behavior: [mvp-status.md](mvp-status.md), [design-qa.md](../operations/design-qa.md) +- Operator GA gate: [launch-checklist.md](../operations/launch-checklist.md) + +## Product Target + +`v1.0` console should feel like a governed engineering workbench, not a single +page of stacked panels: + +- URL-addressable journeys for tasks, runs, approvals, queue, nodes, skills, + orgs, admin, and audit. +- Predictable navigation, deep links, browser back/forward, and shareable run + evidence URLs. +- Mature interaction primitives: dialogs, drawers, tabs, toasts, comboboxes, + data tables, forms, and confirmations. +- First-class live evidence: SSE run events, log tail, diff review, artifact + tabs, workflow gate timeline. +- Permission-aware UI that matches API/MCP authorization. +- Testable frontend with unit, component, and critical-path browser coverage. + +Non-goals for the first workbench GA: + +- Public marketing site redesign. +- Replacing MCP as the Main Codex control plane. +- Building a general IDE or chat product. +- Visual novelty for its own sake; preserve the existing operational brand + tokens while upgrading structure and interaction quality. + +## Current Frontend Baseline + +| Layer | Today | Maturity gap | +| --- | --- | --- | +| Runtime | Vite 8, React 19, TypeScript, pnpm | Keep | +| Data | TanStack Query + Zod API schemas | Keep; add typed route loaders and mutation UX standards | +| Routing | Local `view` state / hash hints inside one board | No real router, weak deep links and browser history | +| Structure | `TaskBoard.tsx` (~3k lines) owns nearly all UI | Hard to test, review, or evolve by journey | +| UI system | Hand-written global CSS variables and classes | No accessible primitive library, no shared form/table kit | +| Icons | Text-only controls | Design QA already flagged missing icon set | +| Live ops | EventSource + polling fallback | Needs reusable stream hooks and reconnect UX | +| Evidence | Artifact/log/diff panels exist | Need dedicated diff/log viewers and virtualization | +| Testing | Production build only | No Vitest/Testing Library/Playwright gate | +| i18n / access | Custom providers | Keep behavior; isolate from page monolith | + +The backend and MCP surfaces are ahead of the console architecture. Frontend +maturity is now on the critical path for “can teams actually work here daily?” + +## Recommended Stack + +Stay inside the technical plan defaults, then add a small set of mature +libraries. Prefer composition over a heavy admin rewrite. + +### Keep + +- Vite 8 +- React 19 + TypeScript +- TanStack Query +- Zod +- Existing API client patterns and permission/i18n providers +- Existing brand CSS tokens (`--brand`, `--accent`, surfaces, status colors) + +### Adopt + +| Concern | Choice | Why | +| --- | --- | --- | +| Routing | **React Router 7** | Already named in the technical plan; mature deep links, loaders, nested layouts, error routes | +| Styling | **Tailwind CSS v4** | Utility speed for dense workbench layouts while mapping to current design tokens | +| Component primitives | **shadcn/ui + Radix UI** | Accessible Dialog/Sheet/Tabs/Dropdown/Toast/Popover without locking into Ant/MUI visual language | +| Icons | **lucide-react** | Lightweight, consistent, closes the Design QA icon gap | +| Tables | **TanStack Table** | Sorting, filtering, pagination, row selection for runs/approvals/audit | +| Forms | **react-hook-form + Zod resolver** | Mature form state for Task Envelope, Skills, Profiles, memberships | +| Virtualization | **TanStack Virtual** | Long worker logs and audit lists without DOM blowups | +| Diff review | Focused diff viewer (for example `@git-diff-view/react` or Monaco diff) | Reliable patch inspection for scope/audit/git sync | +| Unit/component tests | **Vitest + Testing Library + jsdom** | Fast regression net around permissions, forms, and run panels | +| E2E | **Playwright** | Critical operator paths against API or mocked API | +| Lint/format | **ESLint flat config + Prettier** | Required before the monolith split grows more files | + +### Explicitly deferred + +- Ant Design / MUI as the primary system: fastest admin look, but harder to keep + the current multi-codex operational brand and denser custom evidence panels. + Revisit only if the team prefers convention over brand control. +- Next.js / Remix: unnecessary for a Vite-hosted console behind the platform + reverse proxy. +- GraphQL client: REST + Zod already match the Go API. +- Full Storybook in sprint 1: add after the shared UI kit stabilizes. + +### Design system rules for the workbench + +- Treat the console as a **dashboard/workbench**, not a marketing landing page. +- Map Tailwind theme colors to the existing CSS variables; do not invent a new + purple/indigo or cream/terracotta theme. +- Prefer one shell composition: sidebar + top context bar + main journey. +- Cards only when they contain interaction or evidence grouping; avoid card + soup on the overview. +- Status color and typography should stay consistent with `StatusBadge` and + current semantic tokens. +- Motion is limited to purposeful transitions: route progress, stream + connection state, approval decision feedback. + +## Information Architecture + +Replace the single board with nested routes under an app shell: + +```text +/ Mission control dashboard +/projects Project and repository management +/projects/:projectId +/tasks Task list and filters +/tasks/:taskId Task workbench: envelope, gates, actions +/runs Run list +/runs/:runId Run cockpit: events, logs, artifacts, diff, result +/approvals Approval inbox +/queue Queue and backpressure +/nodes Executor nodes +/skills Skills and Agent Profiles +/organizations Org provisioning +/admin Users and project memberships +/audit Audit logs and MCP tool calls +/sign-in Auth entry +``` + +Each route owns one job. Cross-links are first-class: task → runs → artifact → +approval → audit row. + +## Workbench Surfaces + +### 1. Mission Control + +- Work queue, failing gates, capacity pressure, recent audit denials. +- One primary CTA into the next blocked task or approval. +- No secondary marketing chrome. + +### 2. Task Workbench + +- Structured Task Envelope editor with Zod-backed validation and policy hints. +- Workflow gate timeline with blocker reasons and next legal action. +- Start/retry/scope/test/audit/git actions permission-gated and audited by API. + +### 3. Run Cockpit + +- Live SSE event stream with reconnect, backoff, and polling fallback. +- Virtualized log tail, artifact tabs, result JSON, scope violations. +- Diff viewer with path list and forbidden-path highlighting. + +### 4. Approval Inbox + +- Filter by pending/approved/rejected, project, and urgency. +- Decision dialog with required rationale when policy demands it. +- Deep link from task/run evidence. + +### 5. Queue And Nodes + +- Backpressure snapshot, utilization, dispatch controls. +- Node verification and capacity editing with clear danger states. + +### 6. Skills, Orgs, Admin, Audit + +- Table-first management with drawer/detail patterns. +- Membership and role changes always confirmation-gated. +- Audit explorer with trace id / resource filters and copyable evidence IDs. + +## Delivery Milestones + +### F0: Frontend Baseline Freeze + +Goal: agree the stack and stop growing the monolith. + +- Adopt this plan as the L2 frontend source of truth. +- Freeze new features inside `TaskBoard.tsx` unless they are bugfixes. +- Add ESLint, Prettier, Vitest scaffolding, and a Playwright smoke skeleton. +- Document token → Tailwind theme mapping. + +Exit criteria: + +- `pnpm --dir apps/web lint`, `test`, and `build` scripts exist. +- No new major panels land in the monolith. + +### F1: App Shell And Routing + +Goal: make every primary journey URL-addressable. + +- Introduce React Router 7 with a persistent shell layout. +- Split route-level pages out of `TaskBoard` without changing API contracts. +- Preserve permission filtering in the sidebar. +- Add route-level error and not-found boundaries. +- Keep i18n and auth providers above the router outlet. + +Exit criteria: + +- Deep links work for task detail, run detail, approvals, and audit. +- Browser back/forward restores the same journey state. +- Production build and basic route smoke tests pass. + +### F2: Design System And Interaction Kit + +Goal: replace ad-hoc controls with mature accessible primitives. + +- Add Tailwind v4 and shadcn/ui primitives needed by the shell: + Button, Input, Textarea, Select, Tabs, Dialog, Sheet, Dropdown Menu, + Toast, Tooltip, Badge, Table scaffolding, Form helpers. +- Introduce `lucide-react` icons in navigation and primary actions. +- Migrate status badges and existing semantic colors onto shared components. +- Establish density rules for tables, forms, and evidence panels. + +Exit criteria: + +- Common interactions use shared primitives; one-off CSS for controls declines. +- Keyboard access works for dialogs, menus, and tabs in smoke tests. + +### F3: Data UX Standards + +Goal: make lists, forms, and mutations trustworthy under failure. + +- Standardize list pages on TanStack Table + shared pagination. +- Standardize create/edit flows on react-hook-form + Zod. +- Mutation UX: pending buttons, toast results, recoverable inline errors, + auth-expiry redirect/sign-in prompt. +- Query UX: skeletons, empty states, Retry-After/backpressure banners. +- Centralize API error mapping so console and future MCP-facing messages align. + +Exit criteria: + +- Tasks, runs, approvals, and audit share one table/pagination pattern. +- Envelope and profile forms reject invalid input before submit. + +### F4: Live Evidence Cockpit + +Goal: make run inspection the strongest part of the product. + +- Extract reusable `useRunEventStream` with reconnect and fallback. +- Virtualized log viewer with follow/pause and copy selection. +- Diff viewer with file list, hunk navigation, and scope violation markers. +- Artifact content viewer with size/truncation warnings. +- Gate timeline component shared by task and run pages. + +Exit criteria: + +- An operator can diagnose a failed run from `/runs/:id` alone. +- Large logs remain responsive under virtualization tests or manual evidence. + +### F5: Quality Gates And Accessibility + +Goal: prevent console regressions from blocking GA. + +- Vitest coverage for permissions, envelope validation, and critical reducers. +- Playwright paths: sign-in, create task, open run, decide approval, queue view. +- axe or equivalent checks on shell + task + run pages. +- Narrow viewport QA at `768px` and desktop at `1280px` / `1440px`. +- Design QA refresh against seeded data, not only empty states. + +Exit criteria: + +- CI runs frontend unit tests and a Playwright smoke job. +- No P0/P1 a11y or overflow findings on primary journeys. + +### F6: Workbench GA Polish + +Goal: declare the console ready for multi-team daily use. + +- In-app guided checklist for first task / pilot path. +- Saved filters and recent tasks/runs. +- Notification surface for pending approvals assigned to the current user. +- Performance budget: route transition and initial dashboard load targets + documented and checked in CI where practical. +- Remove dead monolith code after migration. + +Exit criteria: + +- Launch checklist L2 items can be signed from console evidence. +- Two pilot teams complete a governed workflow using deep links only. + +## Mapping To Launch Plan + +| Launch milestone | Frontend milestone | Notes | +| --- | --- | --- | +| L0 | F0 | Scope freeze includes frontend stack decision | +| L1 | — | Env cutover can proceed in parallel | +| L2 | F1–F4 | Routing, design system, data UX, run cockpit | +| L3 | F3 membership/admin forms | Quotas/membership UI on shared form/table kit | +| L4 | — | Mostly deploy topology | +| L5 | Usage views on table kit | Charts only if owners need them | +| L6 | F5–F6 | Quality gates and workbench GA polish | + +Immediate implementation order remains: + +1. F0 tooling freeze +2. F1 router + shell split +3. F2 shared primitives +4. F3 tables/forms standards +5. F4 run cockpit evidence +6. F5 CI quality gates + +Do not adopt Ant/MUI or rewrite visuals before F1 routing exists. + +## Package Boundaries + +Suggested `apps/web/src` layout after the split: + +```text +app/ router, shell, providers, error boundaries +routes/ route modules and loaders +features/ + dashboard/ + projects/ + tasks/ + runs/ + approvals/ + queue/ + nodes/ + skills/ + organizations/ + admin/ + audit/ + auth/ +shared/ + ui/ shadcn wrappers and tokens + api/ Zod schemas and clients + auth/ + i18n/ + permissions/ + lib/ stream hooks, formatting, testing utils +styles/ Tailwind entry + token bridge +``` + +Rules: + +- Features may depend on `shared/*`, not on each other. +- No feature may import from the old `TaskBoard` after its route is migrated. +- API schemas stay the single runtime contract boundary with Go. + +## Risks And Controls + +| Risk | Control | +| --- | --- | +| Big-bang rewrite stalls delivery | Migrate route-by-route behind the shell; keep API client stable | +| Design system churn fights brand | Token-bridge Tailwind theme; no new color story in F2 | +| Diff/log viewers become mega-deps | Start with focused libraries; adopt Monaco only if needed | +| Test tooling slows local loop | Keep Vitest local-fast; Playwright smoke in CI only | +| Permission bugs after split | Shared `AccessProvider` + unit tests for nav and action gates | + +## Verification Commands + +Once tooling lands, the frontend gate should include: + +```bash +pnpm --dir apps/web lint +pnpm --dir apps/web test +pnpm --dir apps/web build +pnpm --dir apps/web exec playwright test +``` + +Until those scripts exist, continue using: + +```bash +make frontend-build +``` + +and record Design QA against the seeded console, not only offline empty states. + +## Related Documents + +- [Launch Plan](launch-plan.md) +- [Implementation Roadmap](roadmap.md) +- [Design QA](../operations/design-qa.md) +- [Local Development](../operations/local-development.md) +- [Docker Development](docker-development.md) diff --git a/docs/implementation/launch-plan.md b/docs/implementation/launch-plan.md index 1d779bd..88c1050 100644 --- a/docs/implementation/launch-plan.md +++ b/docs/implementation/launch-plan.md @@ -43,7 +43,7 @@ Non-goals for `v1.0`: | Authorization | Resource-scoped API/MCP checks, viewer deny, audited denials | Membership admin UX, invitation/revoke runbook, periodic access review | | Executors | Mock, Docker, SSH/agentd with limits, secrets, timeouts, queue | Dedicated worker-host topology as default; quota per org/project; command allowlist UX | | Git delivery | Dry-run default, live GitHub/GitLab after review, no auto-merge | Repo-scoped tokens by default; Forgejo optional; branch protection checklist | -| Console | Single TaskBoard covering core ops | Split operator journeys, empty states, error recovery, mobile/narrow layout QA | +| Console | Single TaskBoard covering core ops; Vite/React/Query/Zod only | Mature workbench: router, design system, tables/forms, run cockpit, frontend CI | | Observability | Metrics, alerts, Grafana skeleton, runbooks | Wire alerts to on-call; prove SLO burn; scheduled restore drills | | Deploy | Compose, CI images, migrate profile, SBOM/Trivy | HA Compose topology or Helm; blue/green app rollout; secret rotation calendar | | FinOps | Run duration metrics only | Token/cost attribution per org/project/task/run | @@ -116,29 +116,38 @@ Exit criteria: evidence attached to the release record. - Git Sync remains `dry-run` until L3 sign-off for that environment. -### L2: Operator Experience For Daily Use +### L2: Operator Workbench Maturity -Goal: make the console and MCP path usable without reading source code. +Goal: make the console a reliable daily workbench, not a monolith operator +shell. Frontend detail lives in +[frontend-workbench-plan.md](frontend-workbench-plan.md) (milestones F0–F6). Required work: -- Split the monolithic TaskBoard into clear operator journeys: Projects, - Tasks, Runs, Approvals, Queue, Nodes, Skills/Profiles, Orgs, Audit. -- Add empty states, permission-aware disabled actions, and recoverable error - banners for auth expiry, capacity pressure, and workflow blockers. -- Improve Run Detail for day-2 ops: live events, artifact tabs, scope - violations, gate timeline, and copyable task/run IDs. -- Add an in-console “start pilot/task” checklist that mirrors the runbook for - the first two teams. -- Verify keyboard and narrow-width layouts for the primary workflows. -- Keep MCP tools as the Main Codex path; ensure tool errors map to the same +- Freeze new feature growth inside `TaskBoard.tsx` and adopt the workbench + stack: React Router 7, Tailwind CSS v4, shadcn/ui + Radix, lucide-react, + TanStack Table, react-hook-form + Zod, Vitest, and Playwright. +- Introduce an app shell with URL-addressable journeys: Projects, Tasks, Runs, + Approvals, Queue, Nodes, Skills/Profiles, Orgs, Admin, Audit. +- Replace ad-hoc controls with shared accessible primitives while preserving + current operational brand tokens. +- Standardize lists/forms/mutations: pagination, empty states, toasts, + permission-aware disabled actions, auth-expiry recovery, capacity and + workflow blocker banners. +- Build a Run Cockpit for day-2 ops: reusable SSE hook, virtualized logs, + diff viewer, artifact tabs, gate timeline, copyable task/run IDs. +- Add frontend quality gates: lint, unit/component tests, Playwright smoke, + and seeded Design QA on desktop plus narrow layouts. +- Keep MCP tools as the Main Codex path; map tool errors to the same human-readable blocker reasons shown in the console. Exit criteria: +- Deep links work for task detail, run detail, approvals, and audit evidence. - A new tech lead can create a project, task, start workers, approve, and - inspect audit evidence using only docs + console. + inspect audit evidence using only docs + console URLs. - Viewer and cross-org denial remain visible and audited from the UI path. +- Frontend lint/test/build/smoke jobs exist and pass in CI. ### L3: Multi-Team Execution Controls @@ -242,11 +251,13 @@ Implement in this order; do not start L5/L6 feature work first: 2. **L1 IdP + TLS + secrets cutover checklist** — executable environment bring-up with evidence template fields for redirect URLs, alert routing, backup schedule, and worker-host boundary. -3. **L2 console journey split** — Projects/Tasks/Runs/Approvals/Queue first; - keep visual language compatible with the existing console. -4. **L3 org/project quotas and membership admin API** — required before a second +3. **L2 / F0–F2 workbench foundation** — tooling freeze, React Router shell + split, then Tailwind + shadcn/ui primitives; keep current brand tokens. +4. **L2 / F3–F4 data UX and Run Cockpit** — shared tables/forms plus live log, + diff, and artifact evidence before declaring console GA. +5. **L3 org/project quotas and membership admin API** — required before a second team shares the same deployment. -5. **L1 scheduled backup/restore and alert paging proof** — attach receipts to +6. **L1 scheduled backup/restore and alert paging proof** — attach receipts to the environment launch record. This sequence prefers safety and operability over new agent roles. @@ -259,7 +270,9 @@ Keep changes small and reviewable: | --- | --- | --- | | Docs freeze | `docs/implementation/*`, `docs/README.md` | This plan and status sync | | Env launch kit | `docs/operations/*`, `.env.production.example` | Cutover checklist + evidence | -| Console journeys | `apps/web/src/**` | Split views without redesigning the brand system arbitrarily | +| Workbench foundation | `apps/web/**`, frontend docs | Router shell, Tailwind/shadcn kit, lint/test scripts | +| Run cockpit | `apps/web/src/features/runs/**` | SSE hook, virtualized logs, diff/artifact viewers | +| Console journeys | `apps/web/src/features/**` | Route-by-route split without a brand rewrite | | Quotas / rate limits | `internal/scheduler`, `internal/api`, `internal/mcp`, migrations | Fail closed with audit rows | | Membership admin | `internal/store`, `internal/api`, web Orgs view | Audited role changes | | Usage accounting | `internal/observability`, store, web | Prefer metrics+API before fancy charts | @@ -270,7 +283,8 @@ Keep changes small and reviewable: | Risk | Why it matters | Control | | --- | --- | --- | | Second team shares pilot credentials | Credential blast radius | Per-repo tokens, membership revoke, compromise runbook drill | -| Console remains specialist-only | Adoption stalls | L2 journey split and checklist UX before marketing GA | +| Console remains specialist-only | Adoption stalls | L2/F1–F4 workbench maturity before marketing GA | +| Frontend big-bang rewrite | Delivery stalls while backend is ready | Route-by-route migration; freeze monolith growth | | Docker socket on shared hosts | Host takeover | Isolated worker host boundary only; prefer remote agentd/SSH | | Live PR enabled too early | Unreviewed provider calls | Environment review flag + task approval + dry-run evidence | | No usage visibility | Unbounded spend | L5 usage table and budget alerts before hard scale-out | diff --git a/docs/implementation/mvp-status.md b/docs/implementation/mvp-status.md index 176ac08..3cc1978 100644 --- a/docs/implementation/mvp-status.md +++ b/docs/implementation/mvp-status.md @@ -5,7 +5,8 @@ Last verified: 2026-06-26. Pilot readiness (M0–M6) was later verified on 2026-06-30, including a live governed PR pilot. See [verification-report.md](../operations/verification-report.md). Day-to-day internal GA work continues under -[launch-plan.md](launch-plan.md). +[launch-plan.md](launch-plan.md). Frontend workbench maturity continues under +[frontend-workbench-plan.md](frontend-workbench-plan.md). ## Completed Slice diff --git a/docs/implementation/production-readiness-plan.md b/docs/implementation/production-readiness-plan.md index b96b893..3ea49d5 100644 --- a/docs/implementation/production-readiness-plan.md +++ b/docs/implementation/production-readiness-plan.md @@ -177,9 +177,11 @@ Continue with the launch track in order: 1. Freeze `v1.0` scope and track GA blockers as issues (L0). 2. Cut over the first durable production environment with IdP, TLS, secrets, backup, audit ship, and alert paging (L1). -3. Split console operator journeys for daily use (L2). +3. Mature the Web Console into a workbench: router shell, design system, + shared data UX, and Run Cockpit (L2 / F0–F4). 4. Add org/project quotas and membership administration before onboarding a second team (L3). -Details: [launch-plan.md](launch-plan.md) and +Details: [launch-plan.md](launch-plan.md), +[frontend-workbench-plan.md](frontend-workbench-plan.md), and [launch-checklist.md](../operations/launch-checklist.md). diff --git a/docs/implementation/roadmap.md b/docs/implementation/roadmap.md index cdc4cf8..9682a7d 100644 --- a/docs/implementation/roadmap.md +++ b/docs/implementation/roadmap.md @@ -100,7 +100,7 @@ Completed: ## Phase 6: Web Console -Status: completed for MVP operations. +Status: completed for MVP operations; workbench maturity continues in Phase 9 L2 / F0–F6. Completed: @@ -113,6 +113,13 @@ Completed: - Organization Management. - Skill/Profile Management. - Audit Log and MCP Tool Call query. +- Pagination, auth/session controls, permissions, and i18n in the operator shell. + +Next work: + +- Mature the console into a reliable workbench per + [frontend-workbench-plan.md](frontend-workbench-plan.md). +- Do not grow `TaskBoard.tsx` further except for bugfixes. ## Phase 7: SSH Executor @@ -191,18 +198,30 @@ Milestones are defined in [launch-plan.md](launch-plan.md): - L0: reconcile status and freeze `v1.0` scope - L1: production environment cutover (IdP, TLS, secrets, backup, alerts) -- L2: operator console journeys for daily use +- L2: operator workbench maturity (see frontend F0–F6) - L3: multi-team quotas, rate limits, membership admin, scoped Git credentials - L4: HA Compose / optional Helm, rolling deploy, fanout capacity decision - L5: usage, cost, and capacity visibility - L6: broader rollout, notifications, optional Docs/Forgejo, GA sign-off +Frontend workbench milestones are defined in +[frontend-workbench-plan.md](frontend-workbench-plan.md): + +- F0: lint/test tooling freeze; stop monolith growth +- F1: React Router 7 app shell and deep links +- F2: Tailwind v4 + shadcn/ui + Radix + lucide design kit +- F3: TanStack Table and react-hook-form + Zod standards +- F4: Run Cockpit with SSE, virtualized logs, and diff/artifact viewers +- F5: Vitest/Playwright/a11y CI gates +- F6: workbench GA polish and monolith removal + Operator gate: [launch-checklist.md](../operations/launch-checklist.md). Immediate next sprint: 1. Keep docs and issue tracking aligned with the launch plan (L0). 2. Execute the first durable production cutover checklist (L1). -3. Split console operator journeys (L2). -4. Add org/project quotas and membership administration before a second team +3. Start workbench foundation F0–F2 in parallel with L1 where staffing allows. +4. Complete F3–F4 Run Cockpit before declaring console GA. +5. Add org/project quotas and membership administration before a second team shares the deployment (L3). diff --git a/docs/operations/design-qa.md b/docs/operations/design-qa.md index c57efc1..e8ecfde 100644 --- a/docs/operations/design-qa.md +++ b/docs/operations/design-qa.md @@ -35,5 +35,8 @@ Last verified: 2026-06-26. ## Follow-Up Polish -- [P3] Source mock includes navigation/action icons; implementation keeps text-only controls because no icon library is currently installed in the Vite app. Add a project-approved icon package or design-system icon set before matching this detail. +- [P2] Adopt the frontend workbench plan before adding more panels to + `TaskBoard.tsx`. See [frontend-workbench-plan.md](../implementation/frontend-workbench-plan.md). +- [P3] Source mock includes navigation/action icons; implementation keeps text-only controls because no icon library is currently installed in the Vite app. Add `lucide-react` with the F2 design kit. - [P3] Capture a seeded-task state later to tune the operational dashboard against real data. +- [P3] Re-run Design QA after F1 routing and F2 primitives land, at `768px` and `1280px`/`1440px`. diff --git a/docs/operations/launch-checklist.md b/docs/operations/launch-checklist.md index 0eb1e1b..4e51365 100644 --- a/docs/operations/launch-checklist.md +++ b/docs/operations/launch-checklist.md @@ -25,11 +25,16 @@ Related plan: [Launch Plan](../implementation/launch-plan.md). - [ ] Prometheus alerts route to an on-call channel - [ ] Git Sync left in `dry-run` until repository live review -## L2 Daily Operator UX +## L2 Daily Operator Workbench -- [ ] Console journeys cover Projects, Tasks, Runs, Approvals, Queue, Nodes +- [ ] Frontend stack freeze recorded: React Router 7, Tailwind v4, shadcn/ui + Radix, lucide, TanStack Table, RHF + Zod, Vitest, Playwright +- [ ] App shell routes cover Projects, Tasks, Runs, Approvals, Queue, Nodes, Skills, Orgs, Admin, Audit +- [ ] Deep links work for task detail, run detail, approvals, and audit evidence +- [ ] Shared primitives replace ad-hoc dialogs/menus/tabs for primary journeys +- [ ] Run Cockpit provides live events, virtualized logs, diff, and artifacts - [ ] Permission-denied and capacity errors are understandable in the UI -- [ ] New tech lead completes one governed task using docs + console only +- [ ] Frontend lint/test/build/smoke pass in CI +- [ ] New tech lead completes one governed task using docs + console URLs only ## L3 Multi-Team Controls From c5fbb6b5341c4e6e5e46b30ebf53f160c2c7e4d4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Jul 2026 07:39:48 +0000 Subject: [PATCH 03/15] Implement Launch Plan L3 backend limits Co-authored-by: Chiiz0 --- .env.example | 8 + docs/operations/quotas-and-rate-limits.md | 21 ++ internal/api/limits_test.go | 160 ++++++++++++++ internal/api/router.go | 247 +++++++++++++++++++--- internal/config/config.go | 16 ++ internal/config/config_test.go | 17 ++ internal/config/production.go | 31 +++ internal/config/production_test.go | 11 + internal/executor/manager.go | 13 +- internal/limits/quotas.go | 132 ++++++++++++ internal/limits/rate.go | 62 ++++++ internal/limits/values.go | 35 +++ internal/mcp/server.go | 209 ++++++++++++++---- internal/observability/metrics.go | 100 ++++++++- internal/store/memory.go | 33 +++ internal/store/postgres.go | 65 ++++++ internal/store/store.go | 1 + 17 files changed, 1074 insertions(+), 87 deletions(-) create mode 100644 docs/operations/quotas-and-rate-limits.md create mode 100644 internal/api/limits_test.go create mode 100644 internal/limits/quotas.go create mode 100644 internal/limits/rate.go create mode 100644 internal/limits/values.go diff --git a/.env.example b/.env.example index 31e58cc..07962c7 100644 --- a/.env.example +++ b/.env.example @@ -78,6 +78,14 @@ MULTICODEX_RETENTION_MAX_AGE=720h MULTICODEX_RETENTION_DRY_RUN=true MULTICODEX_QUEUE_ENABLED=true MULTICODEX_QUEUE_DISPATCH_INTERVAL=5s +MULTICODEX_ORG_ACTIVE_RUN_LIMIT=100 +MULTICODEX_PROJECT_ACTIVE_RUN_LIMIT=20 +MULTICODEX_ORG_QUEUED_RUN_LIMIT=500 +MULTICODEX_PROJECT_QUEUED_RUN_LIMIT=100 +MULTICODEX_API_MUTATION_RATE_LIMIT=120 +MULTICODEX_API_MUTATION_RATE_WINDOW=1m +MULTICODEX_WORKER_START_RATE_LIMIT=60 +MULTICODEX_WORKER_START_RATE_WINDOW=1m MULTICODEX_TELEMETRY_PUSH_URL= MULTICODEX_TELEMETRY_PUSH_INTERVAL=1m MULTICODEX_AUDIT_EXPORT_PATH= diff --git a/docs/operations/quotas-and-rate-limits.md b/docs/operations/quotas-and-rate-limits.md new file mode 100644 index 0000000..77df3dc --- /dev/null +++ b/docs/operations/quotas-and-rate-limits.md @@ -0,0 +1,21 @@ +# Quotas and rate limits + +Multi-codex enforces launch limits before worker runs are started or queued. +Denials return HTTP 429 where the API handles the request and are recorded in +the audit log and `multi_codex_limit_denials_total` metrics. + +## Configuration + +| Variable | Default | Purpose | +| --- | ---: | --- | +| `MULTICODEX_ORG_ACTIVE_RUN_LIMIT` | `100` | Max running/preparing worker runs per organization. | +| `MULTICODEX_PROJECT_ACTIVE_RUN_LIMIT` | `20` | Max running/preparing worker runs per project. | +| `MULTICODEX_ORG_QUEUED_RUN_LIMIT` | `500` | Max queued worker runs per organization. | +| `MULTICODEX_PROJECT_QUEUED_RUN_LIMIT` | `100` | Max queued worker runs per project. | +| `MULTICODEX_API_MUTATION_RATE_LIMIT` | `120` | Authenticated mutating API requests per actor per window. | +| `MULTICODEX_API_MUTATION_RATE_WINDOW` | `1m` | Window for mutating API rate limits. | +| `MULTICODEX_WORKER_START_RATE_LIMIT` | `60` | Worker starts per organization per window. | +| `MULTICODEX_WORKER_START_RATE_WINDOW` | `1m` | Window for worker-start rate limits. | + +Set a limit to `0` only in non-production tests or local development to disable +that guard. Production validation requires positive quota and rate-limit values. diff --git a/internal/api/limits_test.go b/internal/api/limits_test.go new file mode 100644 index 0000000..d1d4872 --- /dev/null +++ b/internal/api/limits_test.go @@ -0,0 +1,160 @@ +package api + +import ( + "bytes" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/Chiiz0/multi-codex/internal/config" + "github.com/Chiiz0/multi-codex/internal/domain" + "github.com/Chiiz0/multi-codex/internal/observability" + "github.com/Chiiz0/multi-codex/internal/store" +) + +func TestAPIMutationRateLimitDeniesAndAudits(t *testing.T) { + st := store.NewMemoryStore() + server := NewServer(limitTestConfig(t, config.Config{ + APIMutationRateLimit: 1, + APIMutationRateWindow: time.Hour, + }), st, slog.New(slog.NewTextHandler(io.Discard, nil))) + handler := server.Handler() + cookie := localSessionCookie(t, handler) + + postJSON(t, handler, cookie, "/api/v1/organizations", map[string]any{"name": "Limit One", "slug": "limit-one"}, http.StatusCreated) + postJSON(t, handler, cookie, "/api/v1/organizations", map[string]any{"name": "Limit Two", "slug": "limit-two"}, http.StatusTooManyRequests) + + assertAuditAction(t, st, "api.rate_limit_denied") + assertLimitMetric(t, server, "api_mutation_rate_limit", "rate_limit_exceeded") +} + +func TestWorkerStartRateLimitDeniesAndAudits(t *testing.T) { + st := store.NewMemoryStore() + first := st.CreateTask(limitTestEnvelope("RATE-1")) + second := st.CreateTask(limitTestEnvelope("RATE-2")) + server := NewServer(limitTestConfig(t, config.Config{ + WorkerStartRateLimit: 1, + WorkerStartRateWindow: time.Hour, + }), st, slog.New(slog.NewTextHandler(io.Discard, nil))) + handler := server.Handler() + cookie := localSessionCookie(t, handler) + + postJSON(t, handler, cookie, "/api/v1/tasks/"+first.ID+"/start", map[string]any{}, http.StatusAccepted) + postJSON(t, handler, cookie, "/api/v1/tasks/"+second.ID+"/start", map[string]any{}, http.StatusTooManyRequests) + + assertAuditAction(t, st, "api.worker_start_rate_limit_denied") + assertLimitMetric(t, server, "rate_limit", "rate_limit_exceeded") +} + +func TestWorkerStartActiveQuotaDeniesAndAudits(t *testing.T) { + st := store.NewMemoryStore() + occupying := st.CreateTask(limitTestEnvelope("QUOTA-ACTIVE-1")) + if _, err := st.StartRun(occupying.ID, "feature", "docker"); err != nil { + t.Fatalf("start occupying run: %v", err) + } + task := st.CreateTask(limitTestEnvelope("QUOTA-ACTIVE-2")) + server := NewServer(limitTestConfig(t, config.Config{ + ProjectActiveRunLimit: 1, + }), st, slog.New(slog.NewTextHandler(io.Discard, nil))) + handler := server.Handler() + cookie := localSessionCookie(t, handler) + + postJSON(t, handler, cookie, "/api/v1/tasks/"+task.ID+"/start", map[string]any{}, http.StatusTooManyRequests) + + assertAuditAction(t, st, "api.worker_spawn_quota_denied") + assertLimitMetric(t, server, "quota", "active_run_quota_exceeded") +} + +func TestWorkerEnqueueQueueQuotaDeniesAndAudits(t *testing.T) { + st := store.NewMemoryStore() + occupying := st.CreateTask(limitTestEnvelope("QUEUE-OCCUPY")) + if _, err := st.StartRun(occupying.ID, "feature", "docker"); err != nil { + t.Fatalf("start occupying run: %v", err) + } + queuedTask := st.CreateTask(limitTestEnvelope("QUEUE-EXISTING")) + if _, err := st.EnqueueRun(queuedTask.ID, "feature", "docker", 0, 1, 1, "test_existing"); err != nil { + t.Fatalf("enqueue existing run: %v", err) + } + task := st.CreateTask(limitTestEnvelope("QUEUE-DENIED")) + server := NewServer(limitTestConfig(t, config.Config{ + ProjectQueuedRunLimit: 1, + }), st, slog.New(slog.NewTextHandler(io.Discard, nil))) + handler := server.Handler() + cookie := localSessionCookie(t, handler) + + postJSON(t, handler, cookie, "/api/v1/tasks/"+task.ID+"/start", map[string]any{}, http.StatusTooManyRequests) + + assertAuditAction(t, st, "api.worker_enqueue_quota_denied") + assertLimitMetric(t, server, "quota", "queue_quota_exceeded") +} + +func limitTestConfig(t *testing.T, overrides config.Config) config.Config { + t.Helper() + overrides.RunRoot = t.TempDir() + overrides.WorktreeRoot = t.TempDir() + overrides.RepoCacheRoot = t.TempDir() + return overrides +} + +func limitTestEnvelope(taskKey string) domain.TaskEnvelope { + return domain.TaskEnvelope{ + TaskID: taskKey, + ProjectID: "proj_demo", + RepositoryID: "repo_demo", + Title: "Limit test", + BaseBranch: "origin/main", + TargetBranch: "codex/" + taskKey, + Role: "feature", + Skill: "company-feature-worker", + AgentProfile: "feature-worker-go-node", + Executor: "docker", + AllowedPaths: []string{"internal/**"}, + ForbiddenPaths: []string{".env*"}, + AllowedCommands: []string{"go test ./..."}, + } +} + +func postJSON(t *testing.T, handler http.Handler, cookie *http.Cookie, path string, body any, wantStatus int) { + t.Helper() + data, err := json.Marshal(body) + if err != nil { + t.Fatalf("marshal request: %v", err) + } + req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(data)) + req.AddCookie(cookie) + req.Header.Set("Content-Type", "application/json") + resp := httptest.NewRecorder() + handler.ServeHTTP(resp, req) + if resp.Code != wantStatus { + t.Fatalf("POST %s status = %d, want %d, body = %s", path, resp.Code, wantStatus, resp.Body.String()) + } +} + +func assertAuditAction(t *testing.T, st store.Store, action string) { + t.Helper() + for _, entry := range st.ListAuditLogs() { + if entry.Action == action { + return + } + } + t.Fatalf("missing audit action %q; logs = %#v", action, st.ListAuditLogs()) +} + +func assertLimitMetric(t *testing.T, server *Server, kind string, reason string) { + t.Helper() + snapshot := server.metrics.Snapshot() + metrics, ok := snapshot["limit_denials"].([]observability.LimitDenialMetric) + if !ok { + t.Fatalf("limit metrics missing from snapshot: %#v", snapshot) + } + for _, metric := range metrics { + if metric.Kind == kind && metric.Reason == reason && metric.Count > 0 { + return + } + } + t.Fatalf("missing limit metric kind=%q reason=%q: %#v", kind, reason, metrics) +} diff --git a/internal/api/router.go b/internal/api/router.go index cfed829..3531be3 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -24,6 +24,7 @@ import ( "github.com/Chiiz0/multi-codex/internal/domain" "github.com/Chiiz0/multi-codex/internal/executor" "github.com/Chiiz0/multi-codex/internal/gitsync" + "github.com/Chiiz0/multi-codex/internal/limits" "github.com/Chiiz0/multi-codex/internal/observability" "github.com/Chiiz0/multi-codex/internal/policy" "github.com/Chiiz0/multi-codex/internal/retention" @@ -33,12 +34,14 @@ import ( ) type Server struct { - cfg config.Config - store store.Store - exec *executor.Manager - metrics *observability.Metrics - oidc *authn.OIDCVerifier - log *slog.Logger + cfg config.Config + store store.Store + exec *executor.Manager + metrics *observability.Metrics + oidc *authn.OIDCVerifier + log *slog.Logger + apiMutations *limits.RateLimiter + workerStarts *limits.RateLimiter } type apiAuthContextKey struct{} @@ -57,7 +60,7 @@ func NewServer(cfg config.Config, st store.Store, log *slog.Logger) *Server { if strings.EqualFold(cfg.AuthMode, "oidc") { verifier = authn.NewOIDCVerifier(cfg.OIDCIssuer, cfg.OIDCAudience, cfg.OIDCJWKSURL) } - server := &Server{cfg: cfg, store: st, exec: executor.NewManager(cfg, st), metrics: observability.NewMetrics("multi-codex-api"), oidc: verifier, log: log} + server := &Server{cfg: cfg, store: st, exec: executor.NewManager(cfg, st), metrics: observability.NewMetrics("multi-codex-api"), oidc: verifier, log: log, apiMutations: limits.NewRateLimiter(), workerStarts: limits.NewRateLimiter()} server.startRetentionWorker() server.startQueueWorker() server.startTelemetryPushWorker() @@ -373,7 +376,7 @@ func (s *Server) startQueueWorker() { func (s *Server) runQueueDispatch(trigger string) { for { - _, err := s.dispatchOneQueuedRun(trigger) + _, err := s.dispatchOneQueuedRun(trigger, nil) if errors.Is(err, store.ErrNotFound) || errors.Is(err, store.ErrNoCapacity) { return } @@ -383,33 +386,63 @@ func (s *Server) runQueueDispatch(trigger string) { } } -func (s *Server) dispatchOneQueuedRun(trigger string) (domain.Run, error) { - run, err := s.store.DispatchQueuedRun() - if err != nil { - if !errors.Is(err, store.ErrNotFound) && !errors.Is(err, store.ErrNoCapacity) { +func (s *Server) dispatchOneQueuedRun(trigger string, r *http.Request) (domain.Run, error) { + queuedRuns := limits.QueuedRuns(s.store) + if len(queuedRuns) == 0 { + return domain.Run{}, store.ErrNotFound + } + blockedByLimit := false + blockedByCapacity := false + for _, queuedRun := range queuedRuns { + task, err := s.store.GetTask(queuedRun.TaskID) + if err != nil { + s.log.Error("queue dispatch task lookup failed", "run_id", queuedRun.ID, "task_id", queuedRun.TaskID, "error", err) + s.audit("system", "api", "api.queue_dispatch_failed", "run", queuedRun.ID, map[string]any{"trigger": trigger, "task_id": queuedRun.TaskID, "error": err.Error()}) + return domain.Run{}, err + } + if decision := limits.CheckConcurrency(s.store, limits.QuotaConfigFromConfig(s.cfg), task); !decision.Allowed { + blockedByLimit = true + s.auditLimitDenial(r, "api.queue_dispatch_quota_denied", "quota", "run", queuedRun.ID, task, decision, map[string]any{"trigger": trigger}) + continue + } + if decision := s.allowWorkerStart(task); !decision.Allowed { + blockedByLimit = true + s.auditLimitDenial(r, "api.worker_start_rate_limit_denied", "rate_limit", "run", queuedRun.ID, task, decision, map[string]any{"trigger": trigger}) + continue + } + run, err := s.store.DispatchQueuedRunByID(queuedRun.ID) + if errors.Is(err, store.ErrNotFound) { + continue + } + if errors.Is(err, store.ErrNoCapacity) { + blockedByCapacity = true + continue + } + if err != nil { s.log.Error("queue dispatch failed", "error", err) s.audit("system", "api", "api.queue_dispatch_failed", "queue", "runs", map[string]any{"trigger": trigger, "error": err.Error()}) - } - return domain.Run{}, err + return domain.Run{}, err + } + s.audit("system", "api", "api.queue_dispatch", "run", run.ID, map[string]any{ + "trigger": trigger, + "task_id": run.TaskID, + "role": run.Role, + "executor": run.Executor, + "executor_node_id": run.ExecutorNodeID, + "priority": intFromMap(run.Result, "queue_priority", 0), + "attempt": intFromMap(run.Result, "retry_attempt", 1), + "max_attempts": intFromMap(run.Result, "max_attempts", 1), + }) + s.exec.Start(context.Background(), task, run) + return run, nil } - task, err := s.store.GetTask(run.TaskID) - if err != nil { - s.log.Error("queue dispatch task lookup failed", "run_id", run.ID, "task_id", run.TaskID, "error", err) - s.audit("system", "api", "api.queue_dispatch_failed", "run", run.ID, map[string]any{"trigger": trigger, "task_id": run.TaskID, "error": err.Error()}) - return run, err - } - s.audit("system", "api", "api.queue_dispatch", "run", run.ID, map[string]any{ - "trigger": trigger, - "task_id": run.TaskID, - "role": run.Role, - "executor": run.Executor, - "executor_node_id": run.ExecutorNodeID, - "priority": intFromMap(run.Result, "queue_priority", 0), - "attempt": intFromMap(run.Result, "retry_attempt", 1), - "max_attempts": intFromMap(run.Result, "max_attempts", 1), - }) - s.exec.Start(context.Background(), task, run) - return run, nil + if blockedByLimit { + return domain.Run{}, limits.ErrExceeded + } + if blockedByCapacity { + return domain.Run{}, store.ErrNoCapacity + } + return domain.Run{}, store.ErrNotFound } func (s *Server) queue(w http.ResponseWriter, r *http.Request) { @@ -433,7 +466,7 @@ func (s *Server) queueDispatch(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusForbidden, "manual queue dispatch is disabled in multi-organization mode") return } - run, err := s.dispatchOneQueuedRun("manual") + run, err := s.dispatchOneQueuedRun("manual", r) if err != nil { if errors.Is(err, store.ErrNotFound) { writeJSON(w, http.StatusNotFound, map[string]any{"error": "no queued run available", "queue": s.queueSnapshotForRequest(r)}) @@ -445,6 +478,11 @@ func (s *Server) queueDispatch(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusConflict, map[string]any{"error": "no executor capacity available", "queue": s.queueSnapshotForRequest(r)}) return } + if errors.Is(err, limits.ErrExceeded) { + w.Header().Set("Retry-After", fmt.Sprint(scheduler.DefaultRetryAfterSeconds)) + writeJSON(w, http.StatusTooManyRequests, map[string]any{"error": "queued run quota or rate limit exceeded", "queue": s.queueSnapshotForRequest(r)}) + return + } notFound(w, err) return } @@ -1438,6 +1476,9 @@ func (s *Server) startTask(w http.ResponseWriter, r *http.Request, taskID string writeJSON(w, http.StatusConflict, map[string]any{"workflow": state, "blocked_reasons": reasons}) return } + if !s.enforceWorkerStartLimits(w, r, task, "api.worker_spawn_quota_denied", "api.worker_start_rate_limit_denied") { + return + } run, err := s.store.StartRun(taskID, task.Envelope.Role, task.Envelope.Executor) if err != nil { s.startRunError(w, r, err, task, task.Envelope.Role) @@ -1517,6 +1558,9 @@ func (s *Server) workflowAction(w http.ResponseWriter, r *http.Request, taskID s } func (s *Server) startRoleRun(w http.ResponseWriter, r *http.Request, task domain.Task, role string) { + if !s.enforceWorkerStartLimits(w, r, task, "api.workflow_run_quota_denied", "api.worker_start_rate_limit_denied") { + return + } run, err := s.store.StartRun(task.ID, role, task.Envelope.Executor) if err != nil { s.startRunError(w, r, err, task, role) @@ -1805,6 +1849,9 @@ func (s *Server) preparePR(w http.ResponseWriter, r *http.Request, task domain.T writeJSON(w, http.StatusConflict, map[string]any{"workflow": state, "blocked_reasons": reasons}) return } + if !s.enforceWorkerStartLimits(w, r, task, "api.git_prepare_pr_quota_denied", "api.worker_start_rate_limit_denied") { + return + } run, err := s.store.StartRun(task.ID, "git_sync", task.Envelope.Executor) if err != nil { s.startRunError(w, r, err, task, "git_sync") @@ -1857,6 +1904,9 @@ func (s *Server) publishPR(w http.ResponseWriter, r *http.Request, task domain.T notFound(w, err) return } + if !s.enforceWorkerStartLimits(w, r, task, "api.git_publish_pr_quota_denied", "api.worker_start_rate_limit_denied") { + return + } run, err := s.store.StartRun(task.ID, "git_sync", task.Envelope.Executor) if err != nil { s.startRunError(w, r, err, task, "git_sync") @@ -2157,6 +2207,24 @@ func (s *Server) withRBAC(next http.Handler) http.Handler { return } if hasPermission(auth.Permissions, required) { + if decision := s.allowAPIMutation(auth, r); !decision.Allowed { + retryAfter := retryAfterSeconds(decision) + w.Header().Set("Retry-After", fmt.Sprint(retryAfter)) + s.metrics.RecordLimitDenial("api_mutation_rate_limit", decision.Scope, decision.Reason) + s.auditWithOrg(auth.Membership.OrgID, "human", auth.User.ID, "api.rate_limit_denied", "http_request", r.URL.Path, map[string]any{ + "limiter": "api_mutation", + "method": r.Method, + "path": r.URL.Path, + "scope": decision.Scope, + "scope_id": decision.ScopeID, + "limit": decision.Limit, + "current": decision.Current, + "retry_after_seconds": retryAfter, + "trace_id": observability.TraceID(r.Context()), + }) + writeError(w, http.StatusTooManyRequests, "API mutation rate limit exceeded") + return + } next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), apiAuthContextKey{}, auth))) return } @@ -2244,6 +2312,17 @@ func hasPermission(permissions []string, required string) bool { return false } +func (s *Server) allowAPIMutation(auth domain.AuthContext, r *http.Request) limits.Decision { + if r.Method == http.MethodGet || r.Method == http.MethodHead || r.Method == http.MethodOptions { + return limits.Decision{Allowed: true} + } + key := auth.User.ID + if key == "" { + key = "anonymous" + } + return s.apiMutations.Allow(key, s.cfg.APIMutationRateLimit, s.cfg.APIMutationRateWindow) +} + func (s *Server) requestAuth(r *http.Request) (domain.AuthContext, error) { if auth, ok := r.Context().Value(apiAuthContextKey{}).(domain.AuthContext); ok { return auth, nil @@ -2767,6 +2846,18 @@ func (s *Server) startRunError(w http.ResponseWriter, r *http.Request, err error priority := queuePriorityForTask(s.store, task) attempt := 1 maxAttempts := retryMaxAttemptsForTask(s.store, task) + if decision := limits.CheckQueue(s.store, limits.QuotaConfigFromConfig(s.cfg), task); !decision.Allowed { + s.auditLimitDenial(r, "api.worker_enqueue_quota_denied", "quota", "task", task.ID, task, decision, map[string]any{ + "role": role, + "executor": task.Envelope.Executor, + "priority": priority, + "attempt": attempt, + "max_attempts": maxAttempts, + "reason": "capacity_full", + }) + writeLimitError(w, "worker queue quota exceeded", decision) + return + } run, queueErr := s.store.EnqueueRun(task.ID, role, task.Envelope.Executor, priority, attempt, maxAttempts, "capacity_full") if queueErr != nil { notFound(w, queueErr) @@ -2953,3 +3044,93 @@ func (s *Server) auditHuman(r *http.Request, action string, resourceType string, } s.auditWithOrg(orgID, "human", actorID, action, resourceType, resourceID, payload) } + +func (s *Server) enforceWorkerStartLimits(w http.ResponseWriter, r *http.Request, task domain.Task, quotaAction string, rateAction string) bool { + if decision := limits.CheckConcurrency(s.store, limits.QuotaConfigFromConfig(s.cfg), task); !decision.Allowed { + s.auditLimitDenial(r, quotaAction, "quota", "task", task.ID, task, decision, nil) + writeLimitError(w, "worker concurrency quota exceeded", decision) + return false + } + if decision := s.allowWorkerStart(task); !decision.Allowed { + s.auditLimitDenial(r, rateAction, "rate_limit", "task", task.ID, task, decision, nil) + writeLimitError(w, "worker start rate limit exceeded", decision) + return false + } + return true +} + +func (s *Server) allowWorkerStart(task domain.Task) limits.Decision { + scopeID := task.ProjectID + if project, err := s.store.GetProject(task.ProjectID); err == nil && project.OrgID != "" { + scopeID = project.OrgID + } + return s.workerStarts.Allow(scopeID, s.cfg.WorkerStartRateLimit, s.cfg.WorkerStartRateWindow) +} + +func (s *Server) auditLimitDenial(r *http.Request, action string, kind string, resourceType string, resourceID string, task domain.Task, decision limits.Decision, extra map[string]any) { + scope := decision.Scope + if scope == "" { + scope = "unknown" + } + reason := decision.Reason + if reason == "" { + reason = "unknown" + } + s.metrics.RecordLimitDenial(kind, scope, reason) + payload := limitPayload(task, decision) + for key, value := range extra { + payload[key] = value + } + if r != nil { + s.auditHuman(r, action, resourceType, resourceID, payload) + return + } + orgID := "" + if project, err := s.store.GetProject(task.ProjectID); err == nil { + orgID = project.OrgID + } + s.auditWithOrg(orgID, "system", "api", action, resourceType, resourceID, payload) +} + +func limitPayload(task domain.Task, decision limits.Decision) map[string]any { + return map[string]any{ + "task_id": task.ID, + "project_id": task.ProjectID, + "scope": decision.Scope, + "scope_id": decision.ScopeID, + "reason": decision.Reason, + "limit": decision.Limit, + "current": decision.Current, + "retry_after_seconds": retryAfterSeconds(decision), + } +} + +func writeLimitError(w http.ResponseWriter, message string, decision limits.Decision) { + w.Header().Set("Retry-After", fmt.Sprint(retryAfterSeconds(decision))) + writeJSON(w, http.StatusTooManyRequests, map[string]any{ + "error": message, + "decision": limitDecisionResponse(decision), + }) +} + +func limitDecisionResponse(decision limits.Decision) map[string]any { + return map[string]any{ + "reason": decision.Reason, + "scope": decision.Scope, + "scope_id": decision.ScopeID, + "limit": decision.Limit, + "current": decision.Current, + "retry_after_seconds": retryAfterSeconds(decision), + } +} + +func retryAfterSeconds(decision limits.Decision) int { + if decision.RetryAfter <= 0 { + return scheduler.DefaultRetryAfterSeconds + } + seconds := int(decision.RetryAfter.Round(time.Second) / time.Second) + if seconds < 1 { + return 1 + } + return seconds +} diff --git a/internal/config/config.go b/internal/config/config.go index 3e10d09..a72ec12 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -77,6 +77,14 @@ type Config struct { RetentionDryRun bool QueueEnabled bool QueueDispatchInterval time.Duration + OrgActiveRunLimit int + ProjectActiveRunLimit int + OrgQueuedRunLimit int + ProjectQueuedRunLimit int + APIMutationRateLimit int + APIMutationRateWindow time.Duration + WorkerStartRateLimit int + WorkerStartRateWindow time.Duration TelemetryPushURL string TelemetryPushInterval time.Duration AuditShipEnabled bool @@ -169,6 +177,14 @@ func FromEnv() Config { RetentionDryRun: envBool("MULTICODEX_RETENTION_DRY_RUN", true), QueueEnabled: envBool("MULTICODEX_QUEUE_ENABLED", true), QueueDispatchInterval: envDuration("MULTICODEX_QUEUE_DISPATCH_INTERVAL", 5*time.Second), + OrgActiveRunLimit: envInt("MULTICODEX_ORG_ACTIVE_RUN_LIMIT", 100), + ProjectActiveRunLimit: envInt("MULTICODEX_PROJECT_ACTIVE_RUN_LIMIT", 20), + OrgQueuedRunLimit: envInt("MULTICODEX_ORG_QUEUED_RUN_LIMIT", 500), + ProjectQueuedRunLimit: envInt("MULTICODEX_PROJECT_QUEUED_RUN_LIMIT", 100), + APIMutationRateLimit: envInt("MULTICODEX_API_MUTATION_RATE_LIMIT", 120), + APIMutationRateWindow: envDuration("MULTICODEX_API_MUTATION_RATE_WINDOW", time.Minute), + WorkerStartRateLimit: envInt("MULTICODEX_WORKER_START_RATE_LIMIT", 60), + WorkerStartRateWindow: envDuration("MULTICODEX_WORKER_START_RATE_WINDOW", time.Minute), TelemetryPushURL: env("MULTICODEX_TELEMETRY_PUSH_URL", ""), TelemetryPushInterval: envDuration("MULTICODEX_TELEMETRY_PUSH_INTERVAL", time.Minute), AuditShipEnabled: envBool("MULTICODEX_AUDIT_SHIP_ENABLED", false), diff --git a/internal/config/config_test.go b/internal/config/config_test.go index abd62f0..1b8524a 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -16,6 +16,14 @@ func TestFromEnvParsesRetentionConfig(t *testing.T) { t.Setenv("MULTICODEX_SSH_KNOWN_HOSTS_PATH", "/run/secrets/known_hosts") t.Setenv("MULTICODEX_QUEUE_ENABLED", "false") t.Setenv("MULTICODEX_QUEUE_DISPATCH_INTERVAL", "3s") + t.Setenv("MULTICODEX_ORG_ACTIVE_RUN_LIMIT", "12") + t.Setenv("MULTICODEX_PROJECT_ACTIVE_RUN_LIMIT", "4") + t.Setenv("MULTICODEX_ORG_QUEUED_RUN_LIMIT", "40") + t.Setenv("MULTICODEX_PROJECT_QUEUED_RUN_LIMIT", "8") + t.Setenv("MULTICODEX_API_MUTATION_RATE_LIMIT", "30") + t.Setenv("MULTICODEX_API_MUTATION_RATE_WINDOW", "2m") + t.Setenv("MULTICODEX_WORKER_START_RATE_LIMIT", "10") + t.Setenv("MULTICODEX_WORKER_START_RATE_WINDOW", "30s") t.Setenv("MULTICODEX_TELEMETRY_PUSH_URL", "http://collector.example/v1/metrics") t.Setenv("MULTICODEX_TELEMETRY_PUSH_INTERVAL", "45s") t.Setenv("MULTICODEX_MCP_SESSION_TTL", "30m") @@ -81,6 +89,15 @@ func TestFromEnvParsesRetentionConfig(t *testing.T) { if cfg.QueueDispatchInterval != 3*time.Second { t.Fatalf("queue interval = %s", cfg.QueueDispatchInterval) } + if cfg.OrgActiveRunLimit != 12 || cfg.ProjectActiveRunLimit != 4 || cfg.OrgQueuedRunLimit != 40 || cfg.ProjectQueuedRunLimit != 8 { + t.Fatalf("quota config = %#v", cfg) + } + if cfg.APIMutationRateLimit != 30 || cfg.APIMutationRateWindow != 2*time.Minute { + t.Fatalf("api mutation rate config = %#v", cfg) + } + if cfg.WorkerStartRateLimit != 10 || cfg.WorkerStartRateWindow != 30*time.Second { + t.Fatalf("worker start rate config = %#v", cfg) + } if cfg.TelemetryPushURL != "http://collector.example/v1/metrics" { t.Fatalf("telemetry push url = %q", cfg.TelemetryPushURL) } diff --git a/internal/config/production.go b/internal/config/production.go index 5cd26ce..444bfd4 100644 --- a/internal/config/production.go +++ b/internal/config/production.go @@ -26,6 +26,7 @@ func ValidateProduction(c Config, service string) error { issues = append(issues, validateProductionCORS(c)...) issues = append(issues, validateProductionAgentDClient(c)...) issues = append(issues, validateProductionWorkerControls(c)...) + issues = append(issues, validateProductionQuotaAndRateLimits(c)...) issues = append(issues, validateProductionGitSync(c)...) issues = append(issues, validateProductionAuditAndRetention(c)...) case "mcp-gateway": @@ -33,6 +34,7 @@ func ValidateProduction(c Config, service string) error { issues = append(issues, validateProductionDatabase(c)...) issues = append(issues, validateProductionAgentDClient(c)...) issues = append(issues, validateProductionWorkerControls(c)...) + issues = append(issues, validateProductionQuotaAndRateLimits(c)...) issues = append(issues, validateProductionGitSync(c)...) case "worker-agentd": issues = append(issues, validateProductionAgentDServer(c)...) @@ -192,6 +194,35 @@ func validateProductionAuditAndRetention(c Config) []string { return issues } +func validateProductionQuotaAndRateLimits(c Config) []string { + var issues []string + if c.OrgActiveRunLimit <= 0 { + issues = append(issues, "MULTICODEX_ORG_ACTIVE_RUN_LIMIT must be positive") + } + if c.ProjectActiveRunLimit <= 0 { + issues = append(issues, "MULTICODEX_PROJECT_ACTIVE_RUN_LIMIT must be positive") + } + if c.OrgQueuedRunLimit <= 0 { + issues = append(issues, "MULTICODEX_ORG_QUEUED_RUN_LIMIT must be positive") + } + if c.ProjectQueuedRunLimit <= 0 { + issues = append(issues, "MULTICODEX_PROJECT_QUEUED_RUN_LIMIT must be positive") + } + if c.APIMutationRateLimit <= 0 { + issues = append(issues, "MULTICODEX_API_MUTATION_RATE_LIMIT must be positive") + } + if c.APIMutationRateWindow <= 0 { + issues = append(issues, "MULTICODEX_API_MUTATION_RATE_WINDOW must be positive") + } + if c.WorkerStartRateLimit <= 0 { + issues = append(issues, "MULTICODEX_WORKER_START_RATE_LIMIT must be positive") + } + if c.WorkerStartRateWindow <= 0 { + issues = append(issues, "MULTICODEX_WORKER_START_RATE_WINDOW must be positive") + } + return issues +} + func validateProductionGitSync(c Config) []string { mode := strings.ToLower(strings.TrimSpace(c.GitSyncMode)) if mode == "" { diff --git a/internal/config/production_test.go b/internal/config/production_test.go index db253aa..c35a7b1 100644 --- a/internal/config/production_test.go +++ b/internal/config/production_test.go @@ -39,6 +39,9 @@ func TestValidateProductionRejectsUnsafeAPIConfig(t *testing.T) { "MULTICODEX_AGENTD_TOKEN is required", "MULTICODEX_WORKER_DEFAULT_TIMEOUT must be positive", "MULTICODEX_WORKER_READ_ONLY_ROOTFS must be true", + "MULTICODEX_ORG_ACTIVE_RUN_LIMIT must be positive", + "MULTICODEX_API_MUTATION_RATE_LIMIT must be positive", + "MULTICODEX_WORKER_START_RATE_LIMIT must be positive", "MULTICODEX_RETENTION_ENABLED must be true", "MULTICODEX_AUDIT_SHIP_ENABLED must be true", "MULTICODEX_AUDIT_SHIP_TARGET is required", @@ -233,5 +236,13 @@ func strictProductionConfig() Config { OIDCClientAuthMethod: "client_secret_post", OIDCPostLoginRedirectURL: "/", OIDCDefaultRole: "viewer", + OrgActiveRunLimit: 100, + ProjectActiveRunLimit: 20, + OrgQueuedRunLimit: 500, + ProjectQueuedRunLimit: 100, + APIMutationRateLimit: 120, + APIMutationRateWindow: time.Minute, + WorkerStartRateLimit: 60, + WorkerStartRateWindow: time.Minute, } } diff --git a/internal/executor/manager.go b/internal/executor/manager.go index be4f13a..8faa1a4 100644 --- a/internal/executor/manager.go +++ b/internal/executor/manager.go @@ -22,6 +22,7 @@ import ( "github.com/Chiiz0/multi-codex/internal/config" "github.com/Chiiz0/multi-codex/internal/domain" + "github.com/Chiiz0/multi-codex/internal/limits" "github.com/Chiiz0/multi-codex/internal/policy" "github.com/Chiiz0/multi-codex/internal/secrets" "github.com/Chiiz0/multi-codex/internal/store" @@ -1117,7 +1118,6 @@ func (m *Manager) enqueueRetryIfAllowed(task domain.Task, run domain.Run, err er } nextAttempt := attempt + 1 priority := m.queuePriority(task) - queued, queueErr := m.store.EnqueueRun(task.ID, run.Role, run.Executor, priority, nextAttempt, maxAttempts, "retry_after_failure") payload := map[string]any{ "failed_run_id": run.ID, "attempt": nextAttempt, @@ -1125,6 +1125,17 @@ func (m *Manager) enqueueRetryIfAllowed(task domain.Task, run domain.Run, err er "priority": priority, "reason": "retry_after_failure", } + if decision := limits.CheckQueue(m.store, limits.QuotaConfigFromConfig(m.cfg), task); !decision.Allowed { + payload["quota_reason"] = decision.Reason + payload["scope"] = decision.Scope + payload["scope_id"] = decision.ScopeID + payload["limit"] = decision.Limit + payload["current"] = decision.Current + _, _ = m.store.AddEvent(run.ID, "warn", "worker_retry_enqueue_denied", "Worker retry enqueue was denied by queue quota", payload) + m.auditWorker(run.ID, "worker.retry_enqueue_quota_denied", "run", run.ID, payload) + return + } + queued, queueErr := m.store.EnqueueRun(task.ID, run.Role, run.Executor, priority, nextAttempt, maxAttempts, "retry_after_failure") if queueErr != nil { payload["error"] = redact(queueErr.Error()) _, _ = m.store.AddEvent(run.ID, "warn", "worker_retry_enqueue_failed", "Worker retry enqueue failed", payload) diff --git a/internal/limits/quotas.go b/internal/limits/quotas.go new file mode 100644 index 0000000..ff5c483 --- /dev/null +++ b/internal/limits/quotas.go @@ -0,0 +1,132 @@ +package limits + +import ( + "errors" + "sort" + "time" + + "github.com/Chiiz0/multi-codex/internal/config" + "github.com/Chiiz0/multi-codex/internal/domain" + "github.com/Chiiz0/multi-codex/internal/store" +) + +var ErrExceeded = errors.New("limit exceeded") + +type QuotaConfig struct { + OrgActiveRunLimit int + ProjectActiveRunLimit int + OrgQueuedRunLimit int + ProjectQueuedRunLimit int +} + +type Decision struct { + Allowed bool `json:"allowed"` + Reason string `json:"reason,omitempty"` + Scope string `json:"scope,omitempty"` + ScopeID string `json:"scope_id,omitempty"` + Limit int `json:"limit,omitempty"` + Current int `json:"current,omitempty"` + RetryAfter time.Duration `json:"-"` +} + +func QuotaConfigFromConfig(cfg config.Config) QuotaConfig { + return QuotaConfig{ + OrgActiveRunLimit: cfg.OrgActiveRunLimit, + ProjectActiveRunLimit: cfg.ProjectActiveRunLimit, + OrgQueuedRunLimit: cfg.OrgQueuedRunLimit, + ProjectQueuedRunLimit: cfg.ProjectQueuedRunLimit, + } +} + +func CheckConcurrency(st store.Store, cfg QuotaConfig, task domain.Task) Decision { + return checkRunQuota(st, cfg, task, activeRunStatus, "active_run_quota_exceeded", cfg.OrgActiveRunLimit, cfg.ProjectActiveRunLimit) +} + +func CheckQueue(st store.Store, cfg QuotaConfig, task domain.Task) Decision { + return checkRunQuota(st, cfg, task, queuedRunStatus, "queue_quota_exceeded", cfg.OrgQueuedRunLimit, cfg.ProjectQueuedRunLimit) +} + +func checkRunQuota(st store.Store, cfg QuotaConfig, task domain.Task, statusMatches func(string) bool, reason string, orgLimit int, projectLimit int) Decision { + project, err := st.GetProject(task.ProjectID) + if err != nil { + return Decision{Allowed: false, Reason: "project_lookup_failed", Scope: "project", ScopeID: task.ProjectID, RetryAfter: defaultRetryAfter()} + } + + if projectLimit > 0 { + current := countRunsForProject(st, project.ID, statusMatches) + if current >= projectLimit { + return Decision{Allowed: false, Reason: reason, Scope: "project", ScopeID: project.ID, Limit: projectLimit, Current: current, RetryAfter: defaultRetryAfter()} + } + } + if orgLimit > 0 && project.OrgID != "" { + current := countRunsForOrg(st, project.OrgID, statusMatches) + if current >= orgLimit { + return Decision{Allowed: false, Reason: reason, Scope: "organization", ScopeID: project.OrgID, Limit: orgLimit, Current: current, RetryAfter: defaultRetryAfter()} + } + } + return Decision{Allowed: true} +} + +func QueuedRuns(st store.Store) []domain.Run { + runs := []domain.Run{} + for _, run := range st.ListAllRuns() { + if run.Status == "queued" { + runs = append(runs, run) + } + } + sort.SliceStable(runs, func(i, j int) bool { + leftPriority := intFromMap(runs[i].Result, "queue_priority", 0) + rightPriority := intFromMap(runs[j].Result, "queue_priority", 0) + if leftPriority != rightPriority { + return leftPriority > rightPriority + } + return runs[i].CreatedAt.Before(runs[j].CreatedAt) + }) + return runs +} + +func countRunsForProject(st store.Store, projectID string, statusMatches func(string) bool) int { + count := 0 + for _, run := range st.ListAllRuns() { + if !statusMatches(run.Status) { + continue + } + task, err := st.GetTask(run.TaskID) + if err != nil || task.ProjectID != projectID { + continue + } + count++ + } + return count +} + +func countRunsForOrg(st store.Store, orgID string, statusMatches func(string) bool) int { + count := 0 + for _, run := range st.ListAllRuns() { + if !statusMatches(run.Status) { + continue + } + task, err := st.GetTask(run.TaskID) + if err != nil { + continue + } + project, err := st.GetProject(task.ProjectID) + if err != nil || project.OrgID != orgID { + continue + } + count++ + } + return count +} + +func activeRunStatus(status string) bool { + return status == "preparing" || status == "running" +} + +func queuedRunStatus(status string) bool { + return status == "queued" +} + +func defaultRetryAfter() time.Duration { + return 10 * time.Second +} diff --git a/internal/limits/rate.go b/internal/limits/rate.go new file mode 100644 index 0000000..569f829 --- /dev/null +++ b/internal/limits/rate.go @@ -0,0 +1,62 @@ +package limits + +import ( + "sync" + "time" +) + +type RateLimiter struct { + mu sync.Mutex + now func() time.Time + requests map[string][]time.Time +} + +func NewRateLimiter() *RateLimiter { + return &RateLimiter{ + now: time.Now, + requests: map[string][]time.Time{}, + } +} + +func (l *RateLimiter) Allow(key string, limit int, window time.Duration) Decision { + if limit == 0 { + return Decision{Allowed: true} + } + if key == "" { + key = "anonymous" + } + if limit < 0 || window <= 0 { + return Decision{Allowed: false, Reason: "rate_limit_invalid", Scope: "rate_limit", ScopeID: key, Limit: limit, RetryAfter: defaultRetryAfter()} + } + + l.mu.Lock() + defer l.mu.Unlock() + + now := l.now().UTC() + cutoff := now.Add(-window) + recent := l.requests[key][:0] + for _, at := range l.requests[key] { + if at.After(cutoff) { + recent = append(recent, at) + } + } + if len(recent) >= limit { + retryAfter := window + if oldest := recent[0]; oldest.Add(window).After(now) { + retryAfter = oldest.Add(window).Sub(now) + } + l.requests[key] = recent + return Decision{ + Allowed: false, + Reason: "rate_limit_exceeded", + Scope: "actor", + ScopeID: key, + Limit: limit, + Current: len(recent), + RetryAfter: retryAfter, + } + } + recent = append(recent, now) + l.requests[key] = recent + return Decision{Allowed: true} +} diff --git a/internal/limits/values.go b/internal/limits/values.go new file mode 100644 index 0000000..47c400d --- /dev/null +++ b/internal/limits/values.go @@ -0,0 +1,35 @@ +package limits + +import ( + "encoding/json" + "strconv" +) + +func intFromMap(values map[string]any, key string, fallback int) int { + if values == nil { + return fallback + } + value, ok := values[key] + if !ok { + return fallback + } + switch typed := value.(type) { + case int: + return typed + case int64: + return int(typed) + case float64: + return int(typed) + case json.Number: + parsed, err := typed.Int64() + if err == nil { + return int(parsed) + } + case string: + parsed, err := strconv.Atoi(typed) + if err == nil { + return parsed + } + } + return fallback +} diff --git a/internal/mcp/server.go b/internal/mcp/server.go index f4af8d2..f6174cd 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -17,6 +17,7 @@ import ( "github.com/Chiiz0/multi-codex/internal/domain" "github.com/Chiiz0/multi-codex/internal/executor" "github.com/Chiiz0/multi-codex/internal/gitsync" + "github.com/Chiiz0/multi-codex/internal/limits" "github.com/Chiiz0/multi-codex/internal/observability" "github.com/Chiiz0/multi-codex/internal/policy" "github.com/Chiiz0/multi-codex/internal/scheduler" @@ -25,12 +26,13 @@ import ( ) type Server struct { - cfg config.Config - store store.Store - exec *executor.Manager - metrics *observability.Metrics - oidc *authn.OIDCVerifier - log *slog.Logger + cfg config.Config + store store.Store + exec *executor.Manager + metrics *observability.Metrics + oidc *authn.OIDCVerifier + log *slog.Logger + workerStarts *limits.RateLimiter } type actorContextKey struct{} @@ -72,7 +74,7 @@ func NewServer(cfg config.Config, st store.Store, log *slog.Logger) *Server { if strings.EqualFold(cfg.AuthMode, "oidc") { verifier = authn.NewOIDCVerifier(cfg.OIDCIssuer, cfg.OIDCAudience, cfg.OIDCJWKSURL) } - server := &Server{cfg: cfg, store: st, exec: executor.NewManager(cfg, st), metrics: observability.NewMetrics("multi-codex-mcp-gateway"), oidc: verifier, log: log} + server := &Server{cfg: cfg, store: st, exec: executor.NewManager(cfg, st), metrics: observability.NewMetrics("multi-codex-mcp-gateway"), oidc: verifier, log: log, workerStarts: limits.NewRateLimiter()} server.startTelemetryPushWorker() return server } @@ -1053,6 +1055,10 @@ func (s *Server) invokeTool(ctx context.Context, name string, inputRaw json.RawM if input.Executor == "" { input.Executor = task.Envelope.Executor } + if output, toolStatus, httpStatus, denied := s.workerStartDeniedOutput(ctx, task, "mcp.worker_spawn_quota_denied", "mcp.worker_start_rate_limit_denied"); denied { + s.recordTool(ctx, name, inputRaw, output, toolStatus, "task", input.TaskID) + return output, httpStatus + } run, err := s.store.StartRun(input.TaskID, input.Role, input.Executor) if err != nil { output, toolStatus, httpStatus := s.startRunErrorOutput(ctx, err, task, input.Role, input.Executor) @@ -1151,6 +1157,11 @@ func (s *Server) invokeTool(ctx context.Context, name string, inputRaw json.RawM s.recordTool(ctx, name, inputRaw, output, "blocked", "queue", "runs") return output, http.StatusConflict } + if errors.Is(err, limits.ErrExceeded) { + output := map[string]any{"error": "queued run quota or rate limit exceeded", "queue": s.queueSnapshot()} + s.recordTool(ctx, name, inputRaw, output, "blocked", "queue", "runs") + return output, http.StatusTooManyRequests + } output := map[string]any{"error": err.Error(), "queue": s.queueSnapshot()} s.recordTool(ctx, name, inputRaw, output, "failed", "queue", "runs") return output, storeErrorStatus(err) @@ -1258,6 +1269,10 @@ func (s *Server) startWorkflowRun(ctx context.Context, name string, inputRaw jso s.recordTool(ctx, name, inputRaw, output, "blocked", "task", input.TaskID) return output, http.StatusConflict } + if output, toolStatus, httpStatus, denied := s.workerStartDeniedOutput(ctx, task, "mcp.workflow_run_quota_denied", "mcp.worker_start_rate_limit_denied"); denied { + s.recordTool(ctx, name, inputRaw, output, toolStatus, "task", input.TaskID) + return output, httpStatus + } run, err := s.store.StartRun(input.TaskID, role, task.Envelope.Executor) if err != nil { output, toolStatus, httpStatus := s.startRunErrorOutput(ctx, err, task, role, task.Envelope.Executor) @@ -1292,6 +1307,10 @@ func (s *Server) preparePR(ctx context.Context, name string, inputRaw json.RawMe s.recordTool(ctx, name, inputRaw, output, "blocked", "task", input.TaskID) return output, http.StatusConflict } + if output, toolStatus, httpStatus, denied := s.workerStartDeniedOutput(ctx, task, "mcp.git_prepare_pr_quota_denied", "mcp.worker_start_rate_limit_denied"); denied { + s.recordTool(ctx, name, inputRaw, output, toolStatus, "task", input.TaskID) + return output, httpStatus + } run, err := s.store.StartRun(input.TaskID, "git_sync", task.Envelope.Executor) if err != nil { output, toolStatus, httpStatus := s.startRunErrorOutput(ctx, err, task, "git_sync", task.Envelope.Executor) @@ -1364,6 +1383,10 @@ func (s *Server) publishPR(ctx context.Context, name string, inputRaw json.RawMe s.recordTool(ctx, name, inputRaw, output, "failed", "repository", task.RepositoryID) return output, http.StatusNotFound } + if output, toolStatus, httpStatus, denied := s.workerStartDeniedOutput(ctx, task, "mcp.git_publish_pr_quota_denied", "mcp.worker_start_rate_limit_denied"); denied { + s.recordTool(ctx, name, inputRaw, output, toolStatus, "task", input.TaskID) + return output, httpStatus + } run, err := s.store.StartRun(input.TaskID, "git_sync", task.Envelope.Executor) if err != nil { output, toolStatus, httpStatus := s.startRunErrorOutput(ctx, err, task, "git_sync", task.Envelope.Executor) @@ -1443,22 +1466,127 @@ func (s *Server) recordTool(ctx context.Context, name string, input json.RawMess }) } +func (s *Server) workerStartDeniedOutput(ctx context.Context, task domain.Task, quotaAction string, rateAction string) (map[string]any, string, int, bool) { + if decision := limits.CheckConcurrency(s.store, limits.QuotaConfigFromConfig(s.cfg), task); !decision.Allowed { + s.recordLimitDenial(ctx, quotaAction, "quota", "task", task.ID, task, decision) + return map[string]any{"error": "worker concurrency quota exceeded", "decision": limitDecisionResponse(decision)}, "blocked", http.StatusTooManyRequests, true + } + if decision := s.allowWorkerStart(task); !decision.Allowed { + s.recordLimitDenial(ctx, rateAction, "rate_limit", "task", task.ID, task, decision) + return map[string]any{"error": "worker start rate limit exceeded", "decision": limitDecisionResponse(decision)}, "blocked", http.StatusTooManyRequests, true + } + return nil, "", 0, false +} + +func (s *Server) allowWorkerStart(task domain.Task) limits.Decision { + scopeID := task.ProjectID + if project, err := s.store.GetProject(task.ProjectID); err == nil && project.OrgID != "" { + scopeID = project.OrgID + } + return s.workerStarts.Allow(scopeID, s.cfg.WorkerStartRateLimit, s.cfg.WorkerStartRateWindow) +} + +func (s *Server) recordLimitDenial(ctx context.Context, action string, kind string, resourceType string, resourceID string, task domain.Task, decision limits.Decision) { + scope := decision.Scope + if scope == "" { + scope = "unknown" + } + reason := decision.Reason + if reason == "" { + reason = "unknown" + } + s.metrics.RecordLimitDenial(kind, scope, reason) + auth := s.authContext(ctx) + payload := limitPayload(task, decision) + s.store.RecordAuditLog(domain.AuditLog{ + OrgID: auth.Membership.OrgID, + ActorType: "codex", + ActorID: mcpActorID(ctx), + Action: action, + ResourceType: resourceType, + ResourceID: resourceID, + Payload: payload, + }) +} + +func limitPayload(task domain.Task, decision limits.Decision) map[string]any { + return map[string]any{ + "task_id": task.ID, + "project_id": task.ProjectID, + "scope": decision.Scope, + "scope_id": decision.ScopeID, + "reason": decision.Reason, + "limit": decision.Limit, + "current": decision.Current, + "retry_after_seconds": retryAfterSeconds(decision), + } +} + +func limitDecisionResponse(decision limits.Decision) map[string]any { + return map[string]any{ + "reason": decision.Reason, + "scope": decision.Scope, + "scope_id": decision.ScopeID, + "limit": decision.Limit, + "current": decision.Current, + "retry_after_seconds": retryAfterSeconds(decision), + } +} + +func retryAfterSeconds(decision limits.Decision) int { + if decision.RetryAfter <= 0 { + return scheduler.DefaultRetryAfterSeconds + } + seconds := int(decision.RetryAfter.Round(time.Second) / time.Second) + if seconds < 1 { + return 1 + } + return seconds +} + func (s *Server) dispatchOneQueuedRun(ctx context.Context, trigger string) (domain.Run, error) { - run, err := s.store.DispatchQueuedRun() - if err != nil { - if errors.Is(err, store.ErrNoCapacity) { + queuedRuns := limits.QueuedRuns(s.store) + if len(queuedRuns) == 0 { + return domain.Run{}, store.ErrNotFound + } + blockedByLimit := false + blockedByCapacity := false + for _, queuedRun := range queuedRuns { + task, err := s.store.GetTask(queuedRun.TaskID) + if err != nil { s.store.RecordAuditLog(domain.AuditLog{ ActorType: "codex", ActorID: mcpActorID(ctx), - Action: "mcp.queue_dispatch_blocked", - ResourceType: "queue", - ResourceID: "runs", + Action: "mcp.queue_dispatch_failed", + ResourceType: "run", + ResourceID: queuedRun.ID, Payload: map[string]any{ "trigger": trigger, - "reason": "no_executor_capacity", + "task_id": queuedRun.TaskID, + "error": err.Error(), }, }) - } else if !errors.Is(err, store.ErrNotFound) { + return domain.Run{}, err + } + if decision := limits.CheckConcurrency(s.store, limits.QuotaConfigFromConfig(s.cfg), task); !decision.Allowed { + blockedByLimit = true + s.recordLimitDenial(ctx, "mcp.queue_dispatch_quota_denied", "quota", "run", queuedRun.ID, task, decision) + continue + } + if decision := s.allowWorkerStart(task); !decision.Allowed { + blockedByLimit = true + s.recordLimitDenial(ctx, "mcp.worker_start_rate_limit_denied", "rate_limit", "run", queuedRun.ID, task, decision) + continue + } + run, err := s.store.DispatchQueuedRunByID(queuedRun.ID) + if errors.Is(err, store.ErrNotFound) { + continue + } + if errors.Is(err, store.ErrNoCapacity) { + blockedByCapacity = true + continue + } + if err != nil { s.store.RecordAuditLog(domain.AuditLog{ ActorType: "codex", ActorID: mcpActorID(ctx), @@ -1470,44 +1598,35 @@ func (s *Server) dispatchOneQueuedRun(ctx context.Context, trigger string) (doma "error": err.Error(), }, }) + return domain.Run{}, err } - return domain.Run{}, err - } - task, err := s.store.GetTask(run.TaskID) - if err != nil { s.store.RecordAuditLog(domain.AuditLog{ ActorType: "codex", ActorID: mcpActorID(ctx), - Action: "mcp.queue_dispatch_failed", + Action: "mcp.queue_dispatch", ResourceType: "run", ResourceID: run.ID, Payload: map[string]any{ - "trigger": trigger, - "task_id": run.TaskID, - "error": err.Error(), + "trigger": trigger, + "task_id": run.TaskID, + "role": run.Role, + "executor": run.Executor, + "executor_node_id": run.ExecutorNodeID, + "priority": intFromConfig(run.Result, "queue_priority", 0), + "attempt": intFromConfig(run.Result, "retry_attempt", 1), + "max_attempts": intFromConfig(run.Result, "max_attempts", 1), }, }) - return run, err + s.exec.Start(ctx, task, run) + return run, nil } - s.store.RecordAuditLog(domain.AuditLog{ - ActorType: "codex", - ActorID: mcpActorID(ctx), - Action: "mcp.queue_dispatch", - ResourceType: "run", - ResourceID: run.ID, - Payload: map[string]any{ - "trigger": trigger, - "task_id": run.TaskID, - "role": run.Role, - "executor": run.Executor, - "executor_node_id": run.ExecutorNodeID, - "priority": intFromConfig(run.Result, "queue_priority", 0), - "attempt": intFromConfig(run.Result, "retry_attempt", 1), - "max_attempts": intFromConfig(run.Result, "max_attempts", 1), - }, - }) - s.exec.Start(ctx, task, run) - return run, nil + if blockedByLimit { + return domain.Run{}, limits.ErrExceeded + } + if blockedByCapacity { + return domain.Run{}, store.ErrNoCapacity + } + return domain.Run{}, store.ErrNotFound } func (s *Server) queueSnapshot() map[string]any { @@ -1623,6 +1742,10 @@ func (s *Server) startRunErrorOutput(ctx context.Context, err error, task domain priority := queuePriorityForTask(s.store, task) attempt := 1 maxAttempts := retryMaxAttemptsForTask(s.store, task) + if decision := limits.CheckQueue(s.store, limits.QuotaConfigFromConfig(s.cfg), task); !decision.Allowed { + s.recordLimitDenial(ctx, "mcp.worker_enqueue_quota_denied", "quota", "task", task.ID, task, decision) + return map[string]any{"error": "worker queue quota exceeded", "decision": limitDecisionResponse(decision)}, "blocked", http.StatusTooManyRequests + } run, queueErr := s.store.EnqueueRun(task.ID, role, executorName, priority, attempt, maxAttempts, "capacity_full") if queueErr != nil { return map[string]any{"error": queueErr.Error()}, "failed", storeErrorStatus(queueErr) diff --git a/internal/observability/metrics.go b/internal/observability/metrics.go index 9414d3b..1042b56 100644 --- a/internal/observability/metrics.go +++ b/internal/observability/metrics.go @@ -17,10 +17,11 @@ import ( type traceKey struct{} type Metrics struct { - mu sync.Mutex - service string - started time.Time - requests map[string]RequestMetric + mu sync.Mutex + service string + started time.Time + requests map[string]RequestMetric + limitDenials map[string]LimitDenialMetric } type RequestMetric struct { @@ -45,14 +46,23 @@ type RunMetric struct { DurationBuckets map[string]int64 `json:"duration_buckets,omitempty"` } +type LimitDenialMetric struct { + Kind string `json:"kind"` + Scope string `json:"scope"` + Reason string `json:"reason"` + Count int64 `json:"count"` + LastSeenAt time.Time `json:"last_seen_at"` +} + var requestDurationBuckets = []float64{0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10} var runDurationBuckets = []float64{1, 5, 10, 30, 60, 300, 900, 1800, 3600, 7200} func NewMetrics(service string) *Metrics { return &Metrics{ - service: service, - started: time.Now().UTC(), - requests: map[string]RequestMetric{}, + service: service, + started: time.Now().UTC(), + requests: map[string]RequestMetric{}, + limitDenials: map[string]LimitDenialMetric{}, } } @@ -89,6 +99,29 @@ func (m *Metrics) Record(method string, path string, status int, duration time.D m.requests[key] = metric } +func (m *Metrics) RecordLimitDenial(kind string, scope string, reason string) { + m.mu.Lock() + defer m.mu.Unlock() + + if kind == "" { + kind = "unknown" + } + if scope == "" { + scope = "unknown" + } + if reason == "" { + reason = "unknown" + } + key := kind + "\x00" + scope + "\x00" + reason + metric := m.limitDenials[key] + metric.Kind = kind + metric.Scope = scope + metric.Reason = reason + metric.Count++ + metric.LastSeenAt = time.Now().UTC() + m.limitDenials[key] = metric +} + func (m *Metrics) Snapshot() map[string]any { m.mu.Lock() defer m.mu.Unlock() @@ -103,10 +136,24 @@ func (m *Metrics) Snapshot() map[string]any { } return requests[i].Path < requests[j].Path }) + limitDenials := make([]LimitDenialMetric, 0, len(m.limitDenials)) + for _, metric := range m.limitDenials { + limitDenials = append(limitDenials, metric) + } + sort.Slice(limitDenials, func(i, j int) bool { + if limitDenials[i].Kind != limitDenials[j].Kind { + return limitDenials[i].Kind < limitDenials[j].Kind + } + if limitDenials[i].Scope != limitDenials[j].Scope { + return limitDenials[i].Scope < limitDenials[j].Scope + } + return limitDenials[i].Reason < limitDenials[j].Reason + }) return map[string]any{ - "service": m.service, - "started": m.started, - "requests": requests, + "service": m.service, + "started": m.started, + "requests": requests, + "limit_denials": limitDenials, } } @@ -135,6 +182,19 @@ func (m *Metrics) PrometheusText() string { for _, metric := range requests { fmt.Fprintf(&b, "multi_codex_http_requests_total{service=%q,method=%q,path=%q,status=%q} %d\n", m.service, metric.Method, metric.Path, fmt.Sprint(metric.LastStatus), metric.Count) } + limitDenials := make([]LimitDenialMetric, 0, len(m.limitDenials)) + for _, metric := range m.limitDenials { + limitDenials = append(limitDenials, metric) + } + sort.Slice(limitDenials, func(i, j int) bool { + if limitDenials[i].Kind != limitDenials[j].Kind { + return limitDenials[i].Kind < limitDenials[j].Kind + } + if limitDenials[i].Scope != limitDenials[j].Scope { + return limitDenials[i].Scope < limitDenials[j].Scope + } + return limitDenials[i].Reason < limitDenials[j].Reason + }) fmt.Fprintf(&b, "# HELP multi_codex_http_request_errors_total HTTP requests with status >= 400.\n") fmt.Fprintf(&b, "# TYPE multi_codex_http_request_errors_total counter\n") for _, metric := range requests { @@ -156,12 +216,18 @@ func (m *Metrics) PrometheusText() string { fmt.Fprintf(&b, "multi_codex_http_request_duration_seconds_sum{service=%q,method=%q,path=%q} %.6f\n", m.service, metric.Method, metric.Path, metric.TotalDuration.Seconds()) fmt.Fprintf(&b, "multi_codex_http_request_duration_seconds_count{service=%q,method=%q,path=%q} %d\n", m.service, metric.Method, metric.Path, metric.Count) } + fmt.Fprintf(&b, "# HELP multi_codex_limit_denials_total Quota and rate-limit denials by kind, scope, and reason.\n") + fmt.Fprintf(&b, "# TYPE multi_codex_limit_denials_total counter\n") + for _, metric := range limitDenials { + fmt.Fprintf(&b, "multi_codex_limit_denials_total{service=%q,kind=%q,scope=%q,reason=%q} %d\n", m.service, metric.Kind, metric.Scope, metric.Reason, metric.Count) + } return b.String() } func (m *Metrics) OTLPExport(runs []domain.Run) map[string]any { snapshot := m.Snapshot() requests, _ := snapshot["requests"].([]RequestMetric) + limitDenials, _ := snapshot["limit_denials"].([]LimitDenialMetric) now := time.Now().UTC() metrics := []map[string]any{ otlpGaugeMetric( @@ -200,6 +266,20 @@ func (m *Metrics) OTLPExport(runs []domain.Run) map[string]any { otlpHistogramMetric("multi_codex_http_request_duration_seconds", "HTTP request duration histogram in seconds.", "s", requestDurationHistogramPoints), ) + limitDenialPoints := make([]map[string]any, 0, len(limitDenials)) + for _, metric := range limitDenials { + attrs := map[string]string{ + "service": metricService(m.service), + "kind": metric.Kind, + "scope": metric.Scope, + "reason": metric.Reason, + } + limitDenialPoints = append(limitDenialPoints, otlpIntPoint(attrs, metric.Count, now)) + } + metrics = append(metrics, + otlpSumMetric("multi_codex_limit_denials_total", "Quota and rate-limit denials by kind, scope, and reason.", "1", limitDenialPoints, true), + ) + runMetrics := RunMetricsSnapshot(runs) runCountPoints := make([]map[string]any, 0, len(runMetrics)) activeRunPoints := make([]map[string]any, 0, len(runMetrics)) diff --git a/internal/store/memory.go b/internal/store/memory.go index c2bde2e..e5a3622 100644 --- a/internal/store/memory.go +++ b/internal/store/memory.go @@ -1125,6 +1125,39 @@ func (s *MemoryStore) DispatchQueuedRun() (domain.Run, error) { return domain.Run{}, ErrNoCapacity } +func (s *MemoryStore) DispatchQueuedRunByID(runID string) (domain.Run, error) { + s.mu.Lock() + defer s.mu.Unlock() + + run, ok := s.runs[runID] + if !ok || run.Status != "queued" { + return domain.Run{}, ErrNotFound + } + nodeID := s.selectExecutorNodeLocked(run.Executor) + if nodeID == "" { + return domain.Run{}, ErrNoCapacity + } + now := time.Now().UTC() + run.Status = "running" + run.ExecutorNodeID = nodeID + run.StartedAt = &now + s.runs[run.ID] = run + if task, ok := s.tasks[run.TaskID]; ok { + task.Status = "running" + task.UpdatedAt = now + s.tasks[run.TaskID] = task + } + s.appendEventLocked(run.ID, "info", "worker_spawn", "Queued worker run was dispatched", map[string]any{ + "role": run.Role, + "executor": run.Executor, + "executor_node_id": nodeID, + "priority": intFromMap(run.Result, "queue_priority", 0), + "attempt": intFromMap(run.Result, "retry_attempt", 1), + "max_attempts": intFromMap(run.Result, "max_attempts", 1), + }) + return run, nil +} + func (s *MemoryStore) selectExecutorNodeLocked(executor string) string { nodes := make([]domain.ExecutorNode, 0, len(s.nodes)) for _, node := range s.nodes { diff --git a/internal/store/postgres.go b/internal/store/postgres.go index e9bbb6e..db4ad01 100644 --- a/internal/store/postgres.go +++ b/internal/store/postgres.go @@ -542,6 +542,71 @@ SELECT $1, seq, 'info', 'worker_spawn', 'Queued worker run was dispatched', $2 F return domain.Run{}, ErrNoCapacity } +func (s *PostgresStore) DispatchQueuedRunByID(runID string) (domain.Run, error) { + ctx, cancel := storeContext() + defer cancel() + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return domain.Run{}, err + } + defer tx.Rollback() + + if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(7108001002)`); err != nil { + return domain.Run{}, err + } + var executor string + if err := tx.QueryRowContext(ctx, `SELECT executor::text FROM runs WHERE id = $1 AND status = 'queued'`, runID).Scan(&executor); errors.Is(err, sql.ErrNoRows) { + return domain.Run{}, ErrNotFound + } else if err != nil { + return domain.Run{}, err + } + nodeID, err := s.selectExecutorNode(ctx, tx, executor) + if err != nil { + return domain.Run{}, err + } + run, err := scanRun(tx.QueryRowContext(ctx, ` +UPDATE runs +SET status = 'running', + executor_node_id = $2, + started_at = now() +WHERE id = $1 AND status = 'queued' +RETURNING id::text, task_id::text, role::text, status::text, executor::text, COALESCE(executor_node_id::text, ''), branch, worktree_path, result, started_at, finished_at, created_at`, + runID, nullString(nodeID), + )) + if errors.Is(err, sql.ErrNoRows) { + return domain.Run{}, ErrNotFound + } + if err != nil { + return domain.Run{}, err + } + if _, err := tx.ExecContext(ctx, `UPDATE tasks SET status = 'running', updated_at = now() WHERE id = $1`, run.TaskID); err != nil { + return domain.Run{}, err + } + payloadBytes, _ := json.Marshal(map[string]any{ + "role": run.Role, + "executor": run.Executor, + "executor_node_id": nodeID, + "priority": intFromMap(run.Result, "queue_priority", 0), + "attempt": intFromMap(run.Result, "retry_attempt", 1), + "max_attempts": intFromMap(run.Result, "max_attempts", 1), + }) + if _, err := tx.ExecContext(ctx, ` +WITH next_seq AS ( + SELECT COALESCE(MAX(seq), 0) + 1 AS seq + FROM run_events + WHERE run_id = $1 +) +INSERT INTO run_events (run_id, seq, level, event_type, message, payload) +SELECT $1, seq, 'info', 'worker_spawn', 'Queued worker run was dispatched', $2 FROM next_seq`, run.ID, payloadBytes); err != nil { + return domain.Run{}, err + } + if err := tx.Commit(); err != nil { + return domain.Run{}, err + } + return run, nil +} + func (s *PostgresStore) selectExecutorNode(ctx context.Context, tx *sql.Tx, executor string) (string, error) { var nodeID string err := tx.QueryRowContext(ctx, ` diff --git a/internal/store/store.go b/internal/store/store.go index a8de63e..dd88c60 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -55,6 +55,7 @@ type Store interface { StartRun(taskID string, role string, executor string) (domain.Run, error) EnqueueRun(taskID string, role string, executor string, priority int, attempt int, maxAttempts int, reason string) (domain.Run, error) DispatchQueuedRun() (domain.Run, error) + DispatchQueuedRunByID(runID string) (domain.Run, error) GetRun(id string) (domain.Run, error) UpdateRunWorkspace(runID string, branch string, worktreePath string) (domain.Run, error) ListAllRuns() []domain.Run From fb6a9b132ce276bd93299c7c57f6830097869e61 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Jul 2026 07:47:51 +0000 Subject: [PATCH 04/15] Split web console into React Router workbench Co-authored-by: Chiiz0 --- apps/web/eslint.config.js | 25 + apps/web/package.json | 39 +- apps/web/playwright.config.ts | 18 + apps/web/pnpm-lock.yaml | 3122 ++++++++++++++++- apps/web/prettier.config.json | 6 + apps/web/src/app/AccessPanel.tsx | 23 + apps/web/src/app/App.tsx | 13 +- apps/web/src/app/RequirePermission.tsx | 23 + apps/web/src/app/router.tsx | 45 + apps/web/src/app/shell/AppShell.tsx | 134 + apps/web/src/app/shell/nav.ts | 44 + apps/web/src/app/workbenchContext.tsx | 296 ++ apps/web/src/features/admin/AdminPage.tsx | 311 ++ .../src/features/approvals/ApprovalsPage.tsx | 61 + apps/web/src/features/audit/AuditPage.tsx | 84 + apps/web/src/features/auth/AuthControls.tsx | 73 + apps/web/src/features/auth/LoginPage.tsx | 185 + .../src/features/dashboard/DashboardPage.tsx | 729 ++++ apps/web/src/features/nodes/NodesPage.tsx | 101 + .../organizations/OrganizationsPage.tsx | 75 + apps/web/src/features/queue/QueuePage.tsx | 137 + apps/web/src/features/runs/RunsPage.tsx | 238 ++ apps/web/src/features/skills/SkillsPage.tsx | 228 ++ apps/web/src/features/tasks/TaskBoard.tsx | 3054 +--------------- apps/web/src/features/tasks/TaskRow.tsx | 14 + apps/web/src/features/tasks/TasksPage.tsx | 306 ++ apps/web/src/main.tsx | 6 +- .../web/src/shared/hooks/useRunEventStream.ts | 59 + apps/web/src/shared/lib/cn.ts | 6 + apps/web/src/shared/lib/formatting.test.ts | 13 + apps/web/src/shared/lib/formatting.ts | 82 + apps/web/src/shared/lib/listPagination.tsx | 111 + apps/web/src/shared/lib/pollWhileHealthy.ts | 3 + apps/web/src/shared/ui/Button.tsx | 9 + apps/web/src/shared/ui/EmptyState.tsx | 6 + apps/web/src/shared/ui/Metric.tsx | 8 + apps/web/src/shared/ui/PageHeader.tsx | 13 + apps/web/src/shared/ui/Panel.tsx | 6 + apps/web/src/shared/ui/StatusBadge.tsx | 1 + apps/web/src/shared/ui/Tabs.tsx | 1 + apps/web/src/styles.css | 1978 +---------- apps/web/src/styles/tokens.css | 59 + apps/web/src/styles/workbench.css | 1981 +++++++++++ apps/web/src/test/setup.ts | 1 + apps/web/tsconfig.json | 9 +- apps/web/vite.config.ts | 17 +- 46 files changed, 8597 insertions(+), 5156 deletions(-) create mode 100644 apps/web/eslint.config.js create mode 100644 apps/web/playwright.config.ts create mode 100644 apps/web/prettier.config.json create mode 100644 apps/web/src/app/AccessPanel.tsx create mode 100644 apps/web/src/app/RequirePermission.tsx create mode 100644 apps/web/src/app/router.tsx create mode 100644 apps/web/src/app/shell/AppShell.tsx create mode 100644 apps/web/src/app/shell/nav.ts create mode 100644 apps/web/src/app/workbenchContext.tsx create mode 100644 apps/web/src/features/admin/AdminPage.tsx create mode 100644 apps/web/src/features/approvals/ApprovalsPage.tsx create mode 100644 apps/web/src/features/audit/AuditPage.tsx create mode 100644 apps/web/src/features/auth/AuthControls.tsx create mode 100644 apps/web/src/features/auth/LoginPage.tsx create mode 100644 apps/web/src/features/dashboard/DashboardPage.tsx create mode 100644 apps/web/src/features/nodes/NodesPage.tsx create mode 100644 apps/web/src/features/organizations/OrganizationsPage.tsx create mode 100644 apps/web/src/features/queue/QueuePage.tsx create mode 100644 apps/web/src/features/runs/RunsPage.tsx create mode 100644 apps/web/src/features/skills/SkillsPage.tsx create mode 100644 apps/web/src/features/tasks/TaskRow.tsx create mode 100644 apps/web/src/features/tasks/TasksPage.tsx create mode 100644 apps/web/src/shared/hooks/useRunEventStream.ts create mode 100644 apps/web/src/shared/lib/cn.ts create mode 100644 apps/web/src/shared/lib/formatting.test.ts create mode 100644 apps/web/src/shared/lib/formatting.ts create mode 100644 apps/web/src/shared/lib/listPagination.tsx create mode 100644 apps/web/src/shared/lib/pollWhileHealthy.ts create mode 100644 apps/web/src/shared/ui/Button.tsx create mode 100644 apps/web/src/shared/ui/EmptyState.tsx create mode 100644 apps/web/src/shared/ui/Metric.tsx create mode 100644 apps/web/src/shared/ui/PageHeader.tsx create mode 100644 apps/web/src/shared/ui/Panel.tsx create mode 100644 apps/web/src/shared/ui/StatusBadge.tsx create mode 100644 apps/web/src/shared/ui/Tabs.tsx create mode 100644 apps/web/src/styles/tokens.css create mode 100644 apps/web/src/styles/workbench.css create mode 100644 apps/web/src/test/setup.ts diff --git a/apps/web/eslint.config.js b/apps/web/eslint.config.js new file mode 100644 index 0000000..04331d0 --- /dev/null +++ b/apps/web/eslint.config.js @@ -0,0 +1,25 @@ +import js from "@eslint/js"; +import prettier from "eslint-config-prettier"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; +import tseslint from "typescript-eslint"; + +export default tseslint.config( + { ignores: ["dist", "coverage", "playwright-report", "test-results", "node_modules"] }, + js.configs.recommended, + ...tseslint.configs.recommended, + { + files: ["**/*.{ts,tsx}"], + plugins: { + "react-hooks": reactHooks, + "react-refresh": reactRefresh + }, + rules: { + ...reactHooks.configs.recommended.rules, + "react-hooks/set-state-in-effect": "off", + "react-refresh/only-export-components": ["warn", { allowConstantExport: true }], + "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }] + } + }, + prettier +); diff --git a/apps/web/package.json b/apps/web/package.json index 8354adb..dcef8fd 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -7,19 +7,54 @@ "scripts": { "dev": "vite --host 0.0.0.0", "build": "tsc --noEmit && vite build", - "preview": "vite preview --host 0.0.0.0" + "preview": "vite preview --host 0.0.0.0", + "lint": "eslint .", + "test": "vitest run", + "test:e2e": "playwright test", + "format": "prettier --write ." }, "dependencies": { + "@hookform/resolvers": "^5.4.0", + "@radix-ui/react-dialog": "^1.1.21", + "@radix-ui/react-dropdown-menu": "^2.1.22", + "@radix-ui/react-slot": "^1.3.1", + "@radix-ui/react-tabs": "^1.1.19", + "@radix-ui/react-toast": "^1.2.21", + "@radix-ui/react-tooltip": "^1.2.14", "@tanstack/react-query": "^5.0.0", + "@tanstack/react-table": "^8.21.3", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^1.26.0", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-hook-form": "^7.82.0", + "react-router-dom": "^7.18.1", + "tailwind-merge": "^3.6.0", "zod": "^4.0.0" }, "devDependencies": { + "@eslint/js": "^10.0.1", + "@playwright/test": "^1.61.1", + "@tailwindcss/vite": "^4.3.3", + "@testing-library/jest-dom": "^7.0.0", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", + "@types/node": "^26.1.1", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^5.0.0", + "@vitest/coverage-v8": "^4.1.10", + "eslint": "^10.7.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.3", + "jsdom": "^29.1.1", + "prettier": "^3.9.6", + "tailwindcss": "^4.3.3", "typescript": "^5.9.0", - "vite": "^8.0.0" + "typescript-eslint": "^8.65.0", + "vite": "^8.0.0", + "vitest": "^4.1.10" } } diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts new file mode 100644 index 0000000..f32c39e --- /dev/null +++ b/apps/web/playwright.config.ts @@ -0,0 +1,18 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./e2e", + fullyParallel: true, + forbidOnly: Boolean(process.env.CI), + retries: process.env.CI ? 1 : 0, + use: { + baseURL: process.env.PLAYWRIGHT_BASE_URL ?? "http://127.0.0.1:4173", + trace: "on-first-retry" + }, + projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }], + webServer: { + command: "pnpm preview --host 127.0.0.1 --port 4173", + port: 4173, + reuseExistingServer: !process.env.CI + } +}); diff --git a/apps/web/pnpm-lock.yaml b/apps/web/pnpm-lock.yaml index 7acac2a..09d97d2 100644 --- a/apps/web/pnpm-lock.yaml +++ b/apps/web/pnpm-lock.yaml @@ -8,19 +8,82 @@ importers: .: dependencies: + '@hookform/resolvers': + specifier: ^5.4.0 + version: 5.4.0(react-hook-form@7.82.0(react@19.2.7)) + '@radix-ui/react-dialog': + specifier: ^1.1.21 + version: 1.1.21(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-dropdown-menu': + specifier: ^2.1.22 + version: 2.1.22(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': + specifier: ^1.3.1 + version: 1.3.1(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-tabs': + specifier: ^1.1.19 + version: 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-toast': + specifier: ^1.2.21 + version: 1.2.21(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-tooltip': + specifier: ^1.2.14 + version: 1.2.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@tanstack/react-query': specifier: ^5.0.0 version: 5.101.1(react@19.2.7) + '@tanstack/react-table': + specifier: ^8.21.3 + version: 8.21.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + class-variance-authority: + specifier: ^0.7.1 + version: 0.7.1 + clsx: + specifier: ^2.1.1 + version: 2.1.1 + lucide-react: + specifier: ^1.26.0 + version: 1.26.0(react@19.2.7) react: specifier: ^19.0.0 version: 19.2.7 react-dom: specifier: ^19.0.0 version: 19.2.7(react@19.2.7) + react-hook-form: + specifier: ^7.82.0 + version: 7.82.0(react@19.2.7) + react-router-dom: + specifier: ^7.18.1 + version: 7.18.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + tailwind-merge: + specifier: ^3.6.0 + version: 3.6.0 zod: specifier: ^4.0.0 version: 4.4.3 devDependencies: + '@eslint/js': + specifier: ^10.0.1 + version: 10.0.1(eslint@10.7.0(jiti@2.7.0)) + '@playwright/test': + specifier: ^1.61.1 + version: 1.61.1 + '@tailwindcss/vite': + specifier: ^4.3.3 + version: 4.3.3(vite@8.1.0(@types/node@26.1.1)(jiti@2.7.0)) + '@testing-library/jest-dom': + specifier: ^7.0.0 + version: 7.0.0(@testing-library/dom@10.4.1) + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@testing-library/user-event': + specifier: ^14.6.1 + version: 14.6.1(@testing-library/dom@10.4.1) + '@types/node': + specifier: ^26.1.1 + version: 26.1.1 '@types/react': specifier: ^19.0.0 version: 19.2.17 @@ -29,16 +92,64 @@ importers: version: 19.2.3(@types/react@19.2.17) '@vitejs/plugin-react': specifier: ^5.0.0 - version: 5.2.0(vite@8.1.0) + version: 5.2.0(vite@8.1.0(@types/node@26.1.1)(jiti@2.7.0)) + '@vitest/coverage-v8': + specifier: ^4.1.10 + version: 4.1.10(vitest@4.1.10) + eslint: + specifier: ^10.7.0 + version: 10.7.0(jiti@2.7.0) + eslint-config-prettier: + specifier: ^10.1.8 + version: 10.1.8(eslint@10.7.0(jiti@2.7.0)) + eslint-plugin-react-hooks: + specifier: ^7.1.1 + version: 7.1.1(eslint@10.7.0(jiti@2.7.0)) + eslint-plugin-react-refresh: + specifier: ^0.5.3 + version: 0.5.3(eslint@10.7.0(jiti@2.7.0)) + jsdom: + specifier: ^29.1.1 + version: 29.1.1 + prettier: + specifier: ^3.9.6 + version: 3.9.6 + tailwindcss: + specifier: ^4.3.3 + version: 4.3.3 typescript: specifier: ^5.9.0 version: 5.9.3 + typescript-eslint: + specifier: ^8.65.0 + version: 8.65.0(eslint@10.7.0(jiti@2.7.0))(typescript@5.9.3) vite: specifier: ^8.0.0 - version: 8.1.0 + version: 8.1.0(@types/node@26.1.1)(jiti@2.7.0) + vitest: + specifier: ^4.1.10 + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1)(vite@8.1.0(@types/node@26.1.1)(jiti@2.7.0)) packages: + '@adobe/css-tools@4.5.0': + resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} + + '@asamuzakjp/css-color@5.1.11': + resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@7.1.1': + resolution: {integrity: sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/generational-cache@1.0.1': + resolution: {integrity: sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -110,6 +221,10 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + '@babel/template@7.29.7': resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} engines: {node: '>=6.9.0'} @@ -122,6 +237,50 @@ packages: resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + + '@csstools/color-helpers@6.1.0': + resolution: {integrity: sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.3.0': + resolution: {integrity: sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.1.10': + resolution: {integrity: sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.7': + resolution: {integrity: sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + '@emnapi/core@1.11.1': resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} @@ -131,6 +290,94 @@ packages: '@emnapi/wasi-threads@1.2.2': resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + '@eslint-community/eslint-utils@4.10.1': + resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.23.5': + resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/config-helpers@0.6.0': + resolution: {integrity: sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/core@1.2.1': + resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/js@10.0.1': + resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: ^10.0.0 + peerDependenciesMeta: + eslint: + optional: true + + '@eslint/object-schema@3.0.5': + resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/plugin-kit@0.7.2': + resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@exodus/bytes@1.15.1': + resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + + '@floating-ui/core@1.8.0': + resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==} + + '@floating-ui/dom@1.8.0': + resolution: {integrity: sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==} + + '@floating-ui/react-dom@2.1.9': + resolution: {integrity: sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/utils@0.2.12': + resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} + + '@hookform/resolvers@5.4.0': + resolution: {integrity: sha512-EIsqr/t/qbinPIhGjMdtvutIN1Kk4uwbROE9/UQ93CAVGR7GkA7Y92+fX80OzXi/OB67jVFYwKGO1WzkxmkFZw==} + peerDependencies: + react-hook-form: ^7.55.0 + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -156,6 +403,342 @@ packages: '@oxc-project/types@0.137.0': resolution: {integrity: sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==} + '@playwright/test@1.61.1': + resolution: {integrity: sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==} + engines: {node: '>=18'} + hasBin: true + + '@radix-ui/primitive@1.1.7': + resolution: {integrity: sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==} + + '@radix-ui/react-arrow@1.1.13': + resolution: {integrity: sha512-0Q310knIY0K+mkmncU9FxLggfW7V49Ok2oY+iu27KkERTsQXxYCIC8XW5QoK4w7Jp1z00vT5xj3oxTcn7QlcTg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collection@1.1.13': + resolution: {integrity: sha512-Q8xYqNRFObXPmi45bFSkGdM2JA5hjBeYGFqzg6lZR3wp6b+cciraVq8DdneTabtws+uOYqjmsvmKOufk/M9Cyg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-compose-refs@1.1.4': + resolution: {integrity: sha512-pWJo6lQAfR6uy1n7ii7PaCc9dLPwTXDYbQpORZU5B548Aqvl2pP1SM1vJGKyxIFqZMHRopRO4CQYX2iXAIB5jA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context@1.2.1': + resolution: {integrity: sha512-EraVbFjiIjibpLr6EjvEDmSCYJU2SlKDMiO+qEK/D9GOWnQoAQlpQo2occGYC1UM9MBeEx5Bek3UtW/Qi57vAg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dialog@1.1.21': + resolution: {integrity: sha512-h+7qMDDmZJ8qTSPrwNyKb/PACY0ehtN8QOBlCz+C2C1jgehKekdhmHddG9YQk8BF/sHJqglPjte+jA1Jrp9HcA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-direction@1.1.3': + resolution: {integrity: sha512-OdgAA/xb6WOQMKNn0mtbYoruMG6YMaBOnq+evWxSpMmiEMBdeFZawnIWRfPPeVXCRm+0lnN8jpJLjhD7/UIxWw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dismissable-layer@1.1.17': + resolution: {integrity: sha512-QAXwa38pG0xNAYh1pjdSaf86NrkqsMoDNmget/Y7X8O8E/C3Iqlj9GAPE4DfX9BPLXc7WH2TWSzMRnIoCdcjzQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-dropdown-menu@2.1.22': + resolution: {integrity: sha512-wZRLTjZaJf1HTxx0YepFKZUuo+SZMi+Cr4kJ2fuA5FxDSUE0vHDTscahUaU+jfz6LFk4OuguwqJs5K0JsIsIBA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-focus-guards@1.1.5': + resolution: {integrity: sha512-UQvlB7L/BYh3P8MLvwZnQkH521EDos40Rwnbt5+Qpg4Vbk0z3xJjRUmR6+aka4aT1IQQXFdO5bNPoE7cvFl5xQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-focus-scope@1.1.14': + resolution: {integrity: sha512-/x4htnJfmW53MplkrePaDpf1o/rN1C++g88WpVobULXbSyC19NtLkXmewuJ/HCaceSmfKDNL5gOXcBGnuAvnvQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-id@1.1.3': + resolution: {integrity: sha512-f/Wxm0ctyMymUJK0fqTSQlm85rbzdAkoNbPXJQ5+6caowVO8Yx+NWGjGz/oGhs/D+WIbbQpOrU0hU2Li2/42xQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-menu@2.1.22': + resolution: {integrity: sha512-xCFrVLttGDPwMjIPI6GS+tm3IGUNKsPXffrJQIPpQJuE9Qy1bU9GNUCkwranU/pEqyBlwMxcBIK/FUJFAZ9SqQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popper@1.3.5': + resolution: {integrity: sha512-6hLng2Rs45IZcvZ31BNvMeaFEMMEbtsog4xPcb8LZrGfyoV9fdAXqB9UKhLpTHtL0+E2DhSr6VW54Tehd6ksAQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-portal@1.1.15': + resolution: {integrity: sha512-kAfBVJUKNNKZuyGQXXG6rKolAV2KAmxxVkPXJgoq9dEFTl39286RufHQFNTL8rzha4vP8159BJ6hMGpB+bqv7A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-presence@1.1.9': + resolution: {integrity: sha512-LTi1v05bprIb8/GSY/GWusI0jfsYjQ3CD3Nin8o7jVxnpHzVQfzjOQJoJTQkE9bdmOnsS7SFdhkXiBv8PrYnxw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.1.8': + resolution: {integrity: sha512-DOlK1BdcIeYYUcFkSYFka4v1h95XTov93b0jCgW1EEiZuIhdwHY2NlE1teLIh+p0uBsuZI5A+voay+iVWpprfA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-roving-focus@1.1.17': + resolution: {integrity: sha512-YdJmjETw7Py+4Nziv71jgZ6fH7xQgB5p+pQ5rV8Iufyrm0RHSijec/UV1zPfQoK/YcfzB9nOl/2bi1zf1SjRMQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.3.1': + resolution: {integrity: sha512-Bu/aAQHFFh6/QAvXAeUMurJ9fbW0JUIqlojU/yBXZ7cAVqy75Y7JYYyuCr9zLNF0p4WWoJYV54CTUIf4l7FzTw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-tabs@1.1.19': + resolution: {integrity: sha512-4wbGf98pX2ZwYqHMVB/dakeMPyH4xSJ9MSDaCCWXlIpYt0VYY6mtZxKSTiGS/F3tk0yrEYmXE1V8uwG7TxDCcQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toast@1.2.21': + resolution: {integrity: sha512-/o8uHzH2z6ZM59Dt1UPFj8aYJ0DjPu0EuQXQ7uuYJTQLH5mNqxa1KuBfbpnklgXOOjyAtTp/tll0Nm+Nkz+bEg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-tooltip@1.2.14': + resolution: {integrity: sha512-C/JxCKJJac+wtHPW1yFBSN8Ssuaufy2jYQMgyiJYyW4Fw0WJfDWXQDAly1qsbd1YDznH+sYitaljhf8igPCvmA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-use-callback-ref@1.1.3': + resolution: {integrity: sha512-AUS7HoBBAncIsGMLNG+CcpLuJ+JIBbZzmyM8Qdb1eIThX0AlhSSC6wn40xfBlPE+ypx/vSSiRWnklUAjy3U3UA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-controllable-state@1.2.5': + resolution: {integrity: sha512-UB1dXpxvHjR48poyKdKdTm7jT0kp3elkUKdKQiOkirlbYumqXinSJtrjDsr9maXNPvL12bKI4CDSmydms/9Aeg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.4': + resolution: {integrity: sha512-XYcfa6wlXDCwQtePuEiPmXLSAhGL4DWtedSyRgGbG3y10mw+OnrLp6SyeY1gJFMiYF0Dx0nMAX9InylKbLEFQQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-is-hydrated@0.1.2': + resolution: {integrity: sha512-2+gAVu9uaSLbopCTvvuWX9MIgEOlyqXC1Ok+KLCO7cZPSHLENs7dL0KbFQUGFIcFKmDW/bmaQRJtd8E3cnMRUw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.3': + resolution: {integrity: sha512-rDiah9wvtqihWtWz02XreeRKIxt2EJF8y5D9rtY9l5A2zxePAtcPiOMpDugNRw5bFHz+1/8viVoc7ZVKiJknCw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-rect@1.1.3': + resolution: {integrity: sha512-W0GSYZFKEfi6raMiMEfJSngvVbFDIyxtW5JuVg5NoQBY59l0dVQVGLbhog/tOdwq0qtZ+TXuX1ikSNan4/IZXA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-size@1.1.3': + resolution: {integrity: sha512-jJq6tQQLvO/z4uWbztjwPV+1a/+H4rCaWypasLB/ac8DEdd6+p7dIm7o3F6P2KZPGkPMqD4s5424EdAdoU+GLA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-visually-hidden@1.2.9': + resolution: {integrity: sha512-seuZXNZVCz1kLSQMRO/TdNOSahBI3S5nXE4jzO7u7aFRWQlMAnwyrr8vKMkEU115fCLEyzR2YyjnP+aRMxK34w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/rect@1.1.3': + resolution: {integrity: sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==} + '@rolldown/binding-android-arm64@1.1.3': resolution: {integrity: sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==} engines: {node: ^20.19.0 || >=22.12.0} @@ -257,28 +840,191 @@ packages: '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} - '@tanstack/query-core@5.101.1': - resolution: {integrity: sha512-Y6Y92dkXtNqx67m2pMSxUsA3zOCwv862JexZRP8/EPwvKXMPu9m8rv43spiXWzOUIggQ3SQApttALStzhA8B4g==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@tanstack/react-query@5.101.1': - resolution: {integrity: sha512-ZnONUuQKJe1bJMStXUL1s5uKN9FcfC28j5cK+iDZcdSHtUv1wtin1cGc/Oewhf2Oc4eKY7lggtpvT/AbMmhHew==} - peerDependencies: - react: ^18 || ^19 + '@standard-schema/utils@0.3.0': + resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} - '@tybys/wasm-util@0.10.3': - resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + '@tailwindcss/node@4.3.3': + resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==} - '@types/babel__core@7.20.5': - resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + '@tailwindcss/oxide-android-arm64@4.3.3': + resolution: {integrity: sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] - '@types/babel__generator@7.27.0': - resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + '@tailwindcss/oxide-darwin-arm64@4.3.3': + resolution: {integrity: sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] - '@types/babel__template@7.4.4': - resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + '@tailwindcss/oxide-darwin-x64@4.3.3': + resolution: {integrity: sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] - '@types/babel__traverse@7.28.0': - resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@tailwindcss/oxide-freebsd-x64@4.3.3': + resolution: {integrity: sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': + resolution: {integrity: sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': + resolution: {integrity: sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-arm64-musl@4.3.3': + resolution: {integrity: sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-linux-x64-gnu@4.3.3': + resolution: {integrity: sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-x64-musl@4.3.3': + resolution: {integrity: sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-wasm32-wasi@4.3.3': + resolution: {integrity: sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': + resolution: {integrity: sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.3.3': + resolution: {integrity: sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.3.3': + resolution: {integrity: sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==} + engines: {node: '>= 20'} + + '@tailwindcss/vite@4.3.3': + resolution: {integrity: sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 || ^8 + + '@tanstack/query-core@5.101.1': + resolution: {integrity: sha512-Y6Y92dkXtNqx67m2pMSxUsA3zOCwv862JexZRP8/EPwvKXMPu9m8rv43spiXWzOUIggQ3SQApttALStzhA8B4g==} + + '@tanstack/react-query@5.101.1': + resolution: {integrity: sha512-ZnONUuQKJe1bJMStXUL1s5uKN9FcfC28j5cK+iDZcdSHtUv1wtin1cGc/Oewhf2Oc4eKY7lggtpvT/AbMmhHew==} + peerDependencies: + react: ^18 || ^19 + + '@tanstack/react-table@8.21.3': + resolution: {integrity: sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==} + engines: {node: '>=12'} + peerDependencies: + react: '>=16.8' + react-dom: '>=16.8' + + '@tanstack/table-core@8.21.3': + resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} + engines: {node: '>=12'} + + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@7.0.0': + resolution: {integrity: sha512-HKAH9C6mBo5yBG6yRO5i43L2iisencAo5z+o5P/saHUoY+miC5ivXRxHBJcFyB5ypPNxHJdK3BoF/3O4DIptMg==} + engines: {node: '>=22', npm: '>=6', yarn: '>=1'} + peerDependencies: + '@testing-library/dom': '>=10 <11' + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@testing-library/user-event@14.6.1': + resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + + '@tybys/wasm-util@0.10.3': + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/esrecurse@4.3.1': + resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/node@26.1.1': + resolution: {integrity: sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==} '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} @@ -288,17 +1034,164 @@ packages: '@types/react@19.2.17': resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} + '@typescript-eslint/eslint-plugin@8.65.0': + resolution: {integrity: sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.65.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.65.0': + resolution: {integrity: sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.65.0': + resolution: {integrity: sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.65.0': + resolution: {integrity: sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.65.0': + resolution: {integrity: sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.65.0': + resolution: {integrity: sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.65.0': + resolution: {integrity: sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.65.0': + resolution: {integrity: sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.65.0': + resolution: {integrity: sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.65.0': + resolution: {integrity: sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@vitejs/plugin-react@5.2.0': resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==} engines: {node: ^20.19.0 || >=22.12.0} peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + '@vitest/coverage-v8@4.1.10': + resolution: {integrity: sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==} + peerDependencies: + '@vitest/browser': 4.1.10 + vitest: 4.1.10 + peerDependenciesMeta: + '@vitest/browser': + optional: true + + '@vitest/expect@4.1.10': + resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} + + '@vitest/mocker@4.1.10': + resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.10': + resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} + + '@vitest/runner@4.1.10': + resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} + + '@vitest/snapshot@4.1.10': + resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} + + '@vitest/spy@4.1.10': + resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} + + '@vitest/utils@4.1.10': + resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.17.0: + resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + ast-v8-to-istanbul@1.0.5: + resolution: {integrity: sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + baseline-browser-mapping@2.10.38: resolution: {integrity: sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==} engines: {node: '>=6.0.0'} hasBin: true + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + + brace-expansion@5.0.8: + resolution: {integrity: sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==} + engines: {node: 20 || >=22} + browserslist@4.28.4: resolution: {integrity: sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} @@ -307,12 +1200,42 @@ packages: caniuse-lite@1.0.30001799: resolution: {integrity: sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + class-variance-authority@0.7.1: + resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -322,17 +1245,126 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + electron-to-chromium@1.5.378: resolution: {integrity: sha512-VinvOAuuPmdD1guEgGv5f2Qp7/vlfqOrUOMYNnOD4wj3pit8kRsQHzfIf6teyUGWo15Tg5+bOJaRunvyltpVWQ==} + enhanced-resolve@5.24.3: + resolution: {integrity: sha512-PwKooW9JUzh5chmYfHM3IQl5OkK2u2Nm011MgeZrss3JmFraUx/fqrf78kk8GUMYoibx/14MdwTl/1WKkG7TpQ==} + engines: {node: '>=10.13.0'} + + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + + es-module-lexer@2.3.1: + resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-config-prettier@10.1.8: + resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==} + hasBin: true + peerDependencies: + eslint: '>=7.0.0' + + eslint-plugin-react-hooks@7.1.1: + resolution: {integrity: sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==} + engines: {node: '>=18'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0 + + eslint-plugin-react-refresh@0.5.3: + resolution: {integrity: sha512-5EMmLCV98Pi4o/f/3DP/v/tNqLHMIc9I8LKClNDWhZ9JTho89/kQcitCXQBMG7sAfVRK0Ie3T2EDOzp1YXYiVA==} + peerDependencies: + eslint: ^9 || ^10 + + eslint-scope@9.1.2: + resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@10.7.0: + resolution: {integrity: sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@11.2.0: + resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -342,6 +1374,26 @@ packages: picomatch: optional: true + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.3: + resolution: {integrity: sha512-/zipXxyO6rGvuNGDiULY9MvEGSkb2gaG4GGH4ygMi0ZZzyMHdUZBmntJmx5x1G2VuPytCwGN4xsJP6cw+sK+vQ==} + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -351,19 +1403,121 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + hermes-estree@0.25.1: + resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} + + hermes-parser@0.25.1: + resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.6: + resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==} + engines: {node: '>= 4'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + jsdom@29.1.1: + resolution: {integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} hasBin: true + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + json5@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} engines: {node: '>=6'} hasBin: true + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + lightningcss-android-arm64@1.32.0: resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} @@ -438,9 +1592,47 @@ packages: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lucide-react@1.26.0: + resolution: {integrity: sha512-raglYVR2+VkMfJL158krjVmE+rV5ST2lzA/KQm1FRSjMHT4MnWaegHxoVEpmc2So3nOEhp9oGejJwAPX8MoAjg==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + magicast@0.5.3: + resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -449,10 +1641,43 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + node-releases@2.0.49: resolution: {integrity: sha512-f06bl1D+8ZDkn2oOQQKAh5/otFWqVnM1Q5oerA8Pex7UfT66Tx4IPHIqVVFKqFT3FUtaDstdgkM7yT7JWhqxfw==} engines: {node: '>=18'} + obug@2.1.4: + resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} + engines: {node: '>=12.20.0'} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -460,28 +1685,123 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + playwright-core@1.61.1: + resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.61.1: + resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==} + engines: {node: '>=18'} + hasBin: true + postcss@8.5.15: resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} - react-dom@19.2.7: + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier@3.9.6: + resolution: {integrity: sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==} + engines: {node: '>=14'} + hasBin: true + + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + react-dom@19.2.7: resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} peerDependencies: react: ^19.2.7 + react-hook-form@7.82.0: + resolution: {integrity: sha512-Zw/uFZ2dO+02GHlBn7JFGn8kZJ7LdM33B/0BXOovzFay+CMhf94JMw5BVu+F1tVkUKjNvBuaE3fz5BJhga10Tg==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^16.8.0 || ^17 || ^18 || ^19 + + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-refresh@0.18.0: resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} engines: {node: '>=0.10.0'} + react-remove-scroll-bar@2.3.8: + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-remove-scroll@2.7.2: + resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-router-dom@7.18.1: + resolution: {integrity: sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + + react-router@7.18.1: + resolution: {integrity: sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + peerDependenciesMeta: + react-dom: + optional: true + + react-style-singleton@2.2.3: + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + react@19.2.7: resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} engines: {node: '>=0.10.0'} + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + rolldown@1.1.3: resolution: {integrity: sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -489,28 +1809,147 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + tailwind-merge@3.6.0: + resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==} + + tailwindcss@4.3.3: + resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==} + + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + tinyglobby@0.2.17: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + + tldts-core@7.4.9: + resolution: {integrity: sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg==} + + tldts@7.4.9: + resolution: {integrity: sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA==} + hasBin: true + + tough-cookie@6.0.2: + resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + typescript-eslint@8.65.0: + resolution: {integrity: sha512-/ggrHAwyjENDusvyxbuqxAC2dTnZg/Z8F+fgQtYIz+L6n/9HfSlEZcFGV/NsMNa6CkGk0xUjUAFwC0vHOflvIA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} hasBin: true + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + + undici@7.28.0: + resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} + engines: {node: '>=20.18.1'} + update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true peerDependencies: browserslist: '>= 4.21.0' + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + use-callback-ref@1.3.3: + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sidecar@1.1.3: + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + vite@8.1.0: resolution: {integrity: sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==} engines: {node: ^20.19.0 || >=22.12.0} @@ -554,14 +1993,124 @@ packages: yaml: optional: true + vitest@4.1.10: + resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.10 + '@vitest/browser-preview': 4.1.10 + '@vitest/browser-webdriverio': 4.1.10 + '@vitest/coverage-istanbul': 4.1.10 + '@vitest/coverage-v8': 4.1.10 + '@vitest/ui': 4.1.10 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zod-validation-error@4.0.2: + resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + zod@4.4.3: resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} snapshots: + '@adobe/css-tools@4.5.0': {} + + '@asamuzakjp/css-color@5.1.11': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.10(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@asamuzakjp/dom-selector@7.1.1': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + + '@asamuzakjp/generational-cache@1.0.1': {} + + '@asamuzakjp/nwsapi@2.3.9': {} + '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -651,6 +2200,8 @@ snapshots: '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 + '@babel/runtime@7.29.7': {} + '@babel/template@7.29.7': dependencies: '@babel/code-frame': 7.29.7 @@ -674,6 +2225,36 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@bcoe/v8-coverage@1.0.2': {} + + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + + '@csstools/color-helpers@6.1.0': {} + + '@csstools/css-calc@3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.1.10(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.1.0 + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.7(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + '@emnapi/core@1.11.1': dependencies: '@emnapi/wasi-threads': 1.2.2 @@ -690,181 +2271,1184 @@ snapshots: tslib: 2.8.1 optional: true - '@jridgewell/gen-mapping@0.3.13': + '@eslint-community/eslint-utils@4.10.1(eslint@10.7.0(jiti@2.7.0))': dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - '@jridgewell/trace-mapping': 0.3.31 + eslint: 10.7.0(jiti@2.7.0) + eslint-visitor-keys: 3.4.3 - '@jridgewell/remapping@2.3.5': + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.23.5': dependencies: - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 + '@eslint/object-schema': 3.0.5 + debug: 4.4.3 + minimatch: 10.2.5 + transitivePeerDependencies: + - supports-color - '@jridgewell/resolve-uri@3.1.2': {} + '@eslint/config-helpers@0.6.0': + dependencies: + '@eslint/core': 1.2.1 - '@jridgewell/sourcemap-codec@1.5.5': {} + '@eslint/core@1.2.1': + dependencies: + '@types/json-schema': 7.0.15 - '@jridgewell/trace-mapping@0.3.31': + '@eslint/js@10.0.1(eslint@10.7.0(jiti@2.7.0))': + optionalDependencies: + eslint: 10.7.0(jiti@2.7.0) + + '@eslint/object-schema@3.0.5': {} + + '@eslint/plugin-kit@0.7.2': dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 + '@eslint/core': 1.2.1 + levn: 0.4.1 - '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + '@exodus/bytes@1.15.1': {} + + '@floating-ui/core@1.8.0': dependencies: - '@emnapi/core': 1.11.1 - '@emnapi/runtime': 1.11.1 - '@tybys/wasm-util': 0.10.3 - optional: true + '@floating-ui/utils': 0.2.12 - '@oxc-project/types@0.137.0': {} + '@floating-ui/dom@1.8.0': + dependencies: + '@floating-ui/core': 1.8.0 + '@floating-ui/utils': 0.2.12 - '@rolldown/binding-android-arm64@1.1.3': - optional: true + '@floating-ui/react-dom@2.1.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@floating-ui/dom': 1.8.0 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) - '@rolldown/binding-darwin-arm64@1.1.3': - optional: true + '@floating-ui/utils@0.2.12': {} - '@rolldown/binding-darwin-x64@1.1.3': - optional: true + '@hookform/resolvers@5.4.0(react-hook-form@7.82.0(react@19.2.7))': + dependencies: + '@standard-schema/utils': 0.3.0 + react-hook-form: 7.82.0(react@19.2.7) - '@rolldown/binding-freebsd-x64@1.1.3': - optional: true + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 - '@rolldown/binding-linux-arm-gnueabihf@1.1.3': - optional: true + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 - '@rolldown/binding-linux-arm64-gnu@1.1.3': - optional: true + '@humanfs/types@0.15.0': {} - '@rolldown/binding-linux-arm64-musl@1.1.3': - optional: true + '@humanwhocodes/module-importer@1.0.1': {} - '@rolldown/binding-linux-ppc64-gnu@1.1.3': - optional: true + '@humanwhocodes/retry@0.4.3': {} - '@rolldown/binding-linux-s390x-gnu@1.1.3': - optional: true + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 - '@rolldown/binding-linux-x64-gnu@1.1.3': - optional: true + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 - '@rolldown/binding-linux-x64-musl@1.1.3': - optional: true + '@jridgewell/resolve-uri@3.1.2': {} - '@rolldown/binding-openharmony-arm64@1.1.3': - optional: true + '@jridgewell/sourcemap-codec@1.5.5': {} - '@rolldown/binding-wasm32-wasi@1.1.3': + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': dependencies: '@emnapi/core': 1.11.1 '@emnapi/runtime': 1.11.1 - '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) - optional: true - - '@rolldown/binding-win32-arm64-msvc@1.1.3': - optional: true - - '@rolldown/binding-win32-x64-msvc@1.1.3': + '@tybys/wasm-util': 0.10.3 optional: true - '@rolldown/pluginutils@1.0.0-rc.3': {} + '@oxc-project/types@0.137.0': {} - '@rolldown/pluginutils@1.0.1': {} + '@playwright/test@1.61.1': + dependencies: + playwright: 1.61.1 - '@tanstack/query-core@5.101.1': {} + '@radix-ui/primitive@1.1.7': {} - '@tanstack/react-query@5.101.1(react@19.2.7)': + '@radix-ui/react-arrow@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@tanstack/query-core': 5.101.1 + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@tybys/wasm-util@0.10.3': + '@radix-ui/react-collection@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - tslib: 2.8.1 - optional: true + '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.1(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.3.1(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@types/babel__core@7.20.5': + '@radix-ui/react-compose-refs@1.1.4(@types/react@19.2.17)(react@19.2.7)': dependencies: - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 - '@types/babel__generator': 7.27.0 - '@types/babel__template': 7.4.4 - '@types/babel__traverse': 7.28.0 + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 - '@types/babel__generator@7.27.0': + '@radix-ui/react-context@1.2.1(@types/react@19.2.17)(react@19.2.7)': dependencies: - '@babel/types': 7.29.7 + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 - '@types/babel__template@7.4.4': + '@radix-ui/react-dialog@1.1.21(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.1(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-focus-guards': 1.1.5(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-focus-scope': 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-id': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-portal': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.3.1(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.5(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.17)(react@19.2.7) + aria-hidden: 1.2.6 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react-remove-scroll: 2.7.2(@types/react@19.2.17)(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@types/babel__traverse@7.28.0': + '@radix-ui/react-direction@1.1.3(@types/react@19.2.17)(react@19.2.7)': dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-dismissable-layer@1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-effect-event': 0.0.4(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-dropdown-menu@2.1.22(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.1(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-id': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-menu': 2.1.22(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.5(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-focus-guards@1.1.5(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-focus-scope@1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.3(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-id@1.1.3(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-menu@2.1.22(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-collection': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.1(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-direction': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-focus-guards': 1.1.5(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-focus-scope': 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-id': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-popper': 1.3.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-portal': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-roving-focus': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.3.1(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.3(@types/react@19.2.17)(react@19.2.7) + aria-hidden: 1.2.6 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react-remove-scroll: 2.7.2(@types/react@19.2.17)(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-popper@1.3.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@floating-ui/react-dom': 2.1.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-arrow': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.1(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-rect': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-size': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/rect': 1.1.3 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-portal@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-presence@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-primitive@2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-slot': 1.3.1(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-roving-focus@1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-collection': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.1(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-direction': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-id': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.5(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-is-hydrated': 0.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-slot@1.3.1(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-tabs@1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-context': 1.2.1(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-direction': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-id': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-presence': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-roving-focus': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.5(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-toast@1.2.21(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-collection': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.1(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-portal': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.5(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-visually-hidden': 1.2.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-tooltip@1.2.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.1(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-id': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-popper': 1.3.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-portal': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.3.1(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.5(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-visually-hidden': 1.2.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-use-callback-ref@1.1.3(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-controllable-state@1.2.5(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-use-effect-event': 0.0.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-effect-event@0.0.4(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-is-hydrated@0.1.2(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-layout-effect@1.1.3(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-rect@1.1.3(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/rect': 1.1.3 + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-size@1.1.3(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-visually-hidden@1.2.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/rect@1.1.3': {} + + '@rolldown/binding-android-arm64@1.1.3': + optional: true + + '@rolldown/binding-darwin-arm64@1.1.3': + optional: true + + '@rolldown/binding-darwin-x64@1.1.3': + optional: true + + '@rolldown/binding-freebsd-x64@1.1.3': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.1.3': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.1.3': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.1.3': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.1.3': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.1.3': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.1.3': + optional: true + + '@rolldown/binding-linux-x64-musl@1.1.3': + optional: true + + '@rolldown/binding-openharmony-arm64@1.1.3': + optional: true + + '@rolldown/binding-wasm32-wasi@1.1.3': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.1.3': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.1.3': + optional: true + + '@rolldown/pluginutils@1.0.0-rc.3': {} + + '@rolldown/pluginutils@1.0.1': {} + + '@standard-schema/spec@1.1.0': {} + + '@standard-schema/utils@0.3.0': {} + + '@tailwindcss/node@4.3.3': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.24.3 + jiti: 2.7.0 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.3.3 + + '@tailwindcss/oxide-android-arm64@4.3.3': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.3.3': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.3.3': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.3.3': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.3.3': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.3.3': + optional: true + + '@tailwindcss/oxide@4.3.3': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.3.3 + '@tailwindcss/oxide-darwin-arm64': 4.3.3 + '@tailwindcss/oxide-darwin-x64': 4.3.3 + '@tailwindcss/oxide-freebsd-x64': 4.3.3 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.3 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.3 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.3 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.3 + '@tailwindcss/oxide-linux-x64-musl': 4.3.3 + '@tailwindcss/oxide-wasm32-wasi': 4.3.3 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.3 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.3 + + '@tailwindcss/vite@4.3.3(vite@8.1.0(@types/node@26.1.1)(jiti@2.7.0))': + dependencies: + '@tailwindcss/node': 4.3.3 + '@tailwindcss/oxide': 4.3.3 + tailwindcss: 4.3.3 + vite: 8.1.0(@types/node@26.1.1)(jiti@2.7.0) + + '@tanstack/query-core@5.101.1': {} + + '@tanstack/react-query@5.101.1(react@19.2.7)': + dependencies: + '@tanstack/query-core': 5.101.1 + react: 19.2.7 + + '@tanstack/react-table@8.21.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@tanstack/table-core': 8.21.3 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + '@tanstack/table-core@8.21.3': {} + + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@7.0.0(@testing-library/dom@10.4.1)': + dependencies: + '@adobe/css-tools': 4.5.0 + '@testing-library/dom': 10.4.1 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@babel/runtime': 7.29.7 + '@testing-library/dom': 10.4.1 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': + dependencies: + '@testing-library/dom': 10.4.1 + + '@tybys/wasm-util@0.10.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/aria-query@5.0.4': {} + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.7 '@babel/types': 7.29.7 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.7 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.7 + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/esrecurse@4.3.1': {} + + '@types/estree@1.0.9': {} + + '@types/json-schema@7.0.15': {} + + '@types/node@26.1.1': + dependencies: + undici-types: 8.3.0 + + '@types/react-dom@19.2.3(@types/react@19.2.17)': + dependencies: + '@types/react': 19.2.17 + + '@types/react@19.2.17': + dependencies: + csstype: 3.2.3 + + '@typescript-eslint/eslint-plugin@8.65.0(@typescript-eslint/parser@8.65.0(eslint@10.7.0(jiti@2.7.0))(typescript@5.9.3))(eslint@10.7.0(jiti@2.7.0))(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.65.0(eslint@10.7.0(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.65.0 + '@typescript-eslint/type-utils': 8.65.0(eslint@10.7.0(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/utils': 8.65.0(eslint@10.7.0(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.65.0 + eslint: 10.7.0(jiti@2.7.0) + ignore: 7.0.6 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.65.0(eslint@10.7.0(jiti@2.7.0))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.65.0 + '@typescript-eslint/types': 8.65.0 + '@typescript-eslint/typescript-estree': 8.65.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.65.0 + debug: 4.4.3 + eslint: 10.7.0(jiti@2.7.0) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.65.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.65.0(typescript@5.9.3) + '@typescript-eslint/types': 8.65.0 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.65.0': + dependencies: + '@typescript-eslint/types': 8.65.0 + '@typescript-eslint/visitor-keys': 8.65.0 + + '@typescript-eslint/tsconfig-utils@8.65.0(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.65.0(eslint@10.7.0(jiti@2.7.0))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.65.0 + '@typescript-eslint/typescript-estree': 8.65.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.65.0(eslint@10.7.0(jiti@2.7.0))(typescript@5.9.3) + debug: 4.4.3 + eslint: 10.7.0(jiti@2.7.0) + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.65.0': {} + + '@typescript-eslint/typescript-estree@8.65.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.65.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.65.0(typescript@5.9.3) + '@typescript-eslint/types': 8.65.0 + '@typescript-eslint/visitor-keys': 8.65.0 + debug: 4.4.3 + minimatch: 10.2.5 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.65.0(eslint@10.7.0(jiti@2.7.0))(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@10.7.0(jiti@2.7.0)) + '@typescript-eslint/scope-manager': 8.65.0 + '@typescript-eslint/types': 8.65.0 + '@typescript-eslint/typescript-estree': 8.65.0(typescript@5.9.3) + eslint: 10.7.0(jiti@2.7.0) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.65.0': + dependencies: + '@typescript-eslint/types': 8.65.0 + eslint-visitor-keys: 5.0.1 + + '@vitejs/plugin-react@5.2.0(vite@8.1.0(@types/node@26.1.1)(jiti@2.7.0))': + dependencies: + '@babel/core': 7.29.7 + '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7) + '@rolldown/pluginutils': 1.0.0-rc.3 + '@types/babel__core': 7.20.5 + react-refresh: 0.18.0 + vite: 8.1.0(@types/node@26.1.1)(jiti@2.7.0) + transitivePeerDependencies: + - supports-color + + '@vitest/coverage-v8@4.1.10(vitest@4.1.10)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.1.10 + ast-v8-to-istanbul: 1.0.5 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.3 + obug: 2.1.4 + std-env: 4.2.0 + tinyrainbow: 3.1.0 + vitest: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1)(vite@8.1.0(@types/node@26.1.1)(jiti@2.7.0)) + + '@vitest/expect@4.1.10': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.10(vite@8.1.0(@types/node@26.1.1)(jiti@2.7.0))': + dependencies: + '@vitest/spy': 4.1.10 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.1.0(@types/node@26.1.1)(jiti@2.7.0) + + '@vitest/pretty-format@4.1.10': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.10': + dependencies: + '@vitest/utils': 4.1.10 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + '@vitest/utils': 4.1.10 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.10': {} + + '@vitest/utils@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + + acorn-jsx@5.3.2(acorn@8.17.0): + dependencies: + acorn: 8.17.0 + + acorn@8.17.0: {} + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ansi-regex@5.0.1: {} + + ansi-styles@5.2.0: {} + + aria-hidden@1.2.6: + dependencies: + tslib: 2.8.1 + + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + aria-query@5.3.2: {} + + assertion-error@2.0.1: {} + + ast-v8-to-istanbul@1.0.5: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + + balanced-match@4.0.4: {} + + baseline-browser-mapping@2.10.38: {} + + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + + brace-expansion@5.0.8: + dependencies: + balanced-match: 4.0.4 + + browserslist@4.28.4: + dependencies: + baseline-browser-mapping: 2.10.38 + caniuse-lite: 1.0.30001799 + electron-to-chromium: 1.5.378 + node-releases: 2.0.49 + update-browserslist-db: 1.2.3(browserslist@4.28.4) + + caniuse-lite@1.0.30001799: {} + + chai@6.2.2: {} + + class-variance-authority@0.7.1: + dependencies: + clsx: 2.1.1 + + clsx@2.1.1: {} + + convert-source-map@2.0.0: {} + + cookie@1.1.1: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + + css.escape@1.5.1: {} + + csstype@3.2.3: {} + + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + + debug@4.4.3: + dependencies: + ms: 2.1.3 - '@types/react-dom@19.2.3(@types/react@19.2.17)': + decimal.js@10.6.0: {} + + deep-is@0.1.4: {} + + dequal@2.0.3: {} + + detect-libc@2.1.2: {} + + detect-node-es@1.1.0: {} + + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + + electron-to-chromium@1.5.378: {} + + enhanced-resolve@5.24.3: dependencies: - '@types/react': 19.2.17 + graceful-fs: 4.2.11 + tapable: 2.3.3 - '@types/react@19.2.17': + entities@8.0.0: {} + + es-module-lexer@2.3.1: {} + + escalade@3.2.0: {} + + escape-string-regexp@4.0.0: {} + + eslint-config-prettier@10.1.8(eslint@10.7.0(jiti@2.7.0)): dependencies: - csstype: 3.2.3 + eslint: 10.7.0(jiti@2.7.0) - '@vitejs/plugin-react@5.2.0(vite@8.1.0)': + eslint-plugin-react-hooks@7.1.1(eslint@10.7.0(jiti@2.7.0)): dependencies: '@babel/core': 7.29.7 - '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7) - '@rolldown/pluginutils': 1.0.0-rc.3 - '@types/babel__core': 7.20.5 - react-refresh: 0.18.0 - vite: 8.1.0 + '@babel/parser': 7.29.7 + eslint: 10.7.0(jiti@2.7.0) + hermes-parser: 0.25.1 + zod: 4.4.3 + zod-validation-error: 4.0.2(zod@4.4.3) transitivePeerDependencies: - supports-color - baseline-browser-mapping@2.10.38: {} + eslint-plugin-react-refresh@0.5.3(eslint@10.7.0(jiti@2.7.0)): + dependencies: + eslint: 10.7.0(jiti@2.7.0) - browserslist@4.28.4: + eslint-scope@9.1.2: dependencies: - baseline-browser-mapping: 2.10.38 - caniuse-lite: 1.0.30001799 - electron-to-chromium: 1.5.378 - node-releases: 2.0.49 - update-browserslist-db: 1.2.3(browserslist@4.28.4) + '@types/esrecurse': 4.3.1 + '@types/estree': 1.0.9 + esrecurse: 4.3.0 + estraverse: 5.3.0 - caniuse-lite@1.0.30001799: {} + eslint-visitor-keys@3.4.3: {} - convert-source-map@2.0.0: {} + eslint-visitor-keys@5.0.1: {} - csstype@3.2.3: {} + eslint@10.7.0(jiti@2.7.0): + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@10.7.0(jiti@2.7.0)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.23.5 + '@eslint/config-helpers': 0.6.0 + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.7.2 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + minimatch: 10.2.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 2.7.0 + transitivePeerDependencies: + - supports-color - debug@4.4.3: + espree@11.2.0: dependencies: - ms: 2.1.3 + acorn: 8.17.0 + acorn-jsx: 5.3.2(acorn@8.17.0) + eslint-visitor-keys: 5.0.1 - detect-libc@2.1.2: {} + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 - electron-to-chromium@1.5.378: {} + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 - escalade@3.2.0: {} + estraverse@5.3.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + esutils@2.0.3: {} + + expect-type@1.4.0: {} + + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.3 + keyv: 4.5.4 + + flatted@3.4.3: {} + + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true gensync@1.0.0-beta.2: {} + get-nonce@1.0.1: {} + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + graceful-fs@4.2.11: {} + + has-flag@4.0.0: {} + + hermes-estree@0.25.1: {} + + hermes-parser@0.25.1: + dependencies: + hermes-estree: 0.25.1 + + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.15.1 + transitivePeerDependencies: + - '@noble/hashes' + + html-escaper@2.0.2: {} + + ignore@5.3.2: {} + + ignore@7.0.6: {} + + imurmurhash@0.1.4: {} + + indent-string@4.0.0: {} + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-potential-custom-element-name@1.0.1: {} + + isexe@2.0.0: {} + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + jiti@2.7.0: {} + + js-tokens@10.0.0: {} + js-tokens@4.0.0: {} + jsdom@29.1.1: + dependencies: + '@asamuzakjp/css-color': 5.1.11 + '@asamuzakjp/dom-selector': 7.1.1 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.7(css-tree@3.2.1) + '@exodus/bytes': 1.15.1 + css-tree: 3.2.1 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.2 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.2 + undici: 7.28.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + jsesc@3.1.0: {} + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + json5@2.2.3: {} + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + lightningcss-android-arm64@1.32.0: optional: true @@ -914,35 +3498,174 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lru-cache@11.5.2: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 + lucide-react@1.26.0(react@19.2.7): + dependencies: + react: 19.2.7 + + lz-string@1.5.0: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + magicast@0.5.3: + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.8.5 + + mdn-data@2.27.1: {} + + min-indent@1.0.1: {} + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.8 + ms@2.1.3: {} nanoid@3.3.15: {} + natural-compare@1.4.0: {} + node-releases@2.0.49: {} + obug@2.1.4: {} + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + parse5@8.0.1: + dependencies: + entities: 8.0.0 + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + pathe@2.0.3: {} + picocolors@1.1.1: {} picomatch@4.0.4: {} + playwright-core@1.61.1: {} + + playwright@1.61.1: + dependencies: + playwright-core: 1.61.1 + optionalDependencies: + fsevents: 2.3.2 + postcss@8.5.15: dependencies: nanoid: 3.3.15 picocolors: 1.1.1 source-map-js: 1.2.1 + prelude-ls@1.2.1: {} + + prettier@3.9.6: {} + + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + + punycode@2.3.1: {} + react-dom@19.2.7(react@19.2.7): dependencies: react: 19.2.7 scheduler: 0.27.0 + react-hook-form@7.82.0(react@19.2.7): + dependencies: + react: 19.2.7 + + react-is@17.0.2: {} + react-refresh@0.18.0: {} + react-remove-scroll-bar@2.3.8(@types/react@19.2.17)(react@19.2.7): + dependencies: + react: 19.2.7 + react-style-singleton: 2.2.3(@types/react@19.2.17)(react@19.2.7) + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.17 + + react-remove-scroll@2.7.2(@types/react@19.2.17)(react@19.2.7): + dependencies: + react: 19.2.7 + react-remove-scroll-bar: 2.3.8(@types/react@19.2.17)(react@19.2.7) + react-style-singleton: 2.2.3(@types/react@19.2.17)(react@19.2.7) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@19.2.17)(react@19.2.7) + use-sidecar: 1.1.3(@types/react@19.2.17)(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + + react-router-dom@7.18.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react-router: 7.18.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + + react-router@7.18.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + cookie: 1.1.1 + react: 19.2.7 + set-cookie-parser: 2.7.2 + optionalDependencies: + react-dom: 19.2.7(react@19.2.7) + + react-style-singleton@2.2.3(@types/react@19.2.17)(react@19.2.7): + dependencies: + get-nonce: 1.0.1 + react: 19.2.7 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.17 + react@19.2.7: {} + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + + require-from-string@2.0.2: {} + rolldown@1.1.3: dependencies: '@oxc-project/types': 0.137.0 @@ -964,29 +3687,126 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.1.3 '@rolldown/binding-win32-x64-msvc': 1.1.3 + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.27.0: {} semver@6.3.1: {} + semver@7.8.5: {} + + set-cookie-parser@2.7.2: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + siginfo@2.0.0: {} + source-map-js@1.2.1: {} + stackback@0.0.2: {} + + std-env@4.2.0: {} + + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + symbol-tree@3.2.4: {} + + tailwind-merge@3.6.0: {} + + tailwindcss@4.3.3: {} + + tapable@2.3.3: {} + + tinybench@2.9.0: {} + + tinyexec@1.2.4: {} + tinyglobby@0.2.17: dependencies: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 - tslib@2.8.1: - optional: true + tinyrainbow@3.1.0: {} + + tldts-core@7.4.9: {} + + tldts@7.4.9: + dependencies: + tldts-core: 7.4.9 + + tough-cookie@6.0.2: + dependencies: + tldts: 7.4.9 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + + ts-api-utils@2.5.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + tslib@2.8.1: {} + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + typescript-eslint@8.65.0(eslint@10.7.0(jiti@2.7.0))(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.65.0(@typescript-eslint/parser@8.65.0(eslint@10.7.0(jiti@2.7.0))(typescript@5.9.3))(eslint@10.7.0(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/parser': 8.65.0(eslint@10.7.0(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.65.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.65.0(eslint@10.7.0(jiti@2.7.0))(typescript@5.9.3) + eslint: 10.7.0(jiti@2.7.0) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color typescript@5.9.3: {} + undici-types@8.3.0: {} + + undici@7.28.0: {} + update-browserslist-db@1.2.3(browserslist@4.28.4): dependencies: browserslist: 4.28.4 escalade: 3.2.0 picocolors: 1.1.1 - vite@8.1.0: + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + use-callback-ref@1.3.3(@types/react@19.2.17)(react@19.2.7): + dependencies: + react: 19.2.7 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.17 + + use-sidecar@1.1.3(@types/react@19.2.17)(react@19.2.7): + dependencies: + detect-node-es: 1.1.0 + react: 19.2.7 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.17 + + vite@8.1.0(@types/node@26.1.1)(jiti@2.7.0): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 @@ -994,8 +3814,76 @@ snapshots: rolldown: 1.1.3 tinyglobby: 0.2.17 optionalDependencies: + '@types/node': 26.1.1 fsevents: 2.3.3 + jiti: 2.7.0 + + vitest@4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1)(vite@8.1.0(@types/node@26.1.1)(jiti@2.7.0)): + dependencies: + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(vite@8.1.0(@types/node@26.1.1)(jiti@2.7.0)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + es-module-lexer: 2.3.1 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 8.1.0(@types/node@26.1.1)(jiti@2.7.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 26.1.1 + '@vitest/coverage-v8': 4.1.10(vitest@4.1.10) + jsdom: 29.1.1 + transitivePeerDependencies: + - msw + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@8.0.1: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1: + dependencies: + '@exodus/bytes': 1.15.1 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + word-wrap@1.2.5: {} + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} yallist@3.1.1: {} + yocto-queue@0.1.0: {} + + zod-validation-error@4.0.2(zod@4.4.3): + dependencies: + zod: 4.4.3 + zod@4.4.3: {} diff --git a/apps/web/prettier.config.json b/apps/web/prettier.config.json new file mode 100644 index 0000000..73b0c99 --- /dev/null +++ b/apps/web/prettier.config.json @@ -0,0 +1,6 @@ +{ + "singleQuote": false, + "semi": true, + "trailingComma": "none", + "printWidth": 140 +} diff --git a/apps/web/src/app/AccessPanel.tsx b/apps/web/src/app/AccessPanel.tsx new file mode 100644 index 0000000..a60902b --- /dev/null +++ b/apps/web/src/app/AccessPanel.tsx @@ -0,0 +1,23 @@ +import { useI18n } from "../lib/i18n"; + +export function AccessPanel({ permission }: { permission: string }) { + const { t } = useI18n(); + return ( +
+

{t("access.lockedTitle")}

+

{t("access.lockedTitle")}

+

{t("access.lockedBody")}

+ {t("access.missing", { permission })} +
+ ); +} + +export function AccessNotice({ permission }: { permission: string }) { + const { t } = useI18n(); + return ( +
+ {t("access.writeLocked")} + {t("access.missing", { permission })} +
+ ); +} diff --git a/apps/web/src/app/App.tsx b/apps/web/src/app/App.tsx index 4c6a54f..9655707 100644 --- a/apps/web/src/app/App.tsx +++ b/apps/web/src/app/App.tsx @@ -1,10 +1,15 @@ -import { TaskBoard } from "../features/tasks/TaskBoard"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { RouterProvider } from "react-router-dom"; import { I18nProvider } from "../lib/i18n"; +import { queryClient } from "./queryClient"; +import { router } from "./router"; export function App() { return ( - - - + + + + + ); } diff --git a/apps/web/src/app/RequirePermission.tsx b/apps/web/src/app/RequirePermission.tsx new file mode 100644 index 0000000..e3aa60e --- /dev/null +++ b/apps/web/src/app/RequirePermission.tsx @@ -0,0 +1,23 @@ +import type { ReactNode } from "react"; +import { Navigate } from "react-router-dom"; +import { AccessPanel } from "./AccessPanel"; +import { useWorkbench } from "./workbenchContext"; +import { hasPermission } from "../lib/permissions"; + +export function RequirePermission({ children, permission }: { children: ReactNode; permission: string }) { + const { auth, authIsError, authIsLoading } = useWorkbench(); + + if (authIsLoading) { + return ; + } + if (!auth && authIsError) { + return ; + } + if (!auth) { + return ; + } + if (!hasPermission(auth, permission)) { + return ; + } + return children; +} diff --git a/apps/web/src/app/router.tsx b/apps/web/src/app/router.tsx new file mode 100644 index 0000000..05ac329 --- /dev/null +++ b/apps/web/src/app/router.tsx @@ -0,0 +1,45 @@ +import type { ReactElement } from "react"; +import { createBrowserRouter } from "react-router-dom"; +import { WorkbenchProvider } from "./workbenchContext"; +import { AppShell } from "./shell/AppShell"; +import { protectedRoutePermissions } from "./shell/nav"; +import { RequirePermission } from "./RequirePermission"; +import { LoginPage } from "../features/auth/LoginPage"; +import { DashboardPage } from "../features/dashboard/DashboardPage"; +import { TasksPage } from "../features/tasks/TasksPage"; +import { RunsPage } from "../features/runs/RunsPage"; +import { ApprovalsPage } from "../features/approvals/ApprovalsPage"; +import { QueuePage } from "../features/queue/QueuePage"; +import { NodesPage } from "../features/nodes/NodesPage"; +import { SkillsPage } from "../features/skills/SkillsPage"; +import { OrganizationsPage } from "../features/organizations/OrganizationsPage"; +import { AdminPage } from "../features/admin/AdminPage"; +import { AuditPage } from "../features/audit/AuditPage"; + +const protectedElement = (permission: string, element: ReactElement) => {element}; + +export const router = createBrowserRouter([ + { + element: , + children: [ + { path: "/login", element: }, + { + element: , + children: [ + { index: true, element: protectedElement(protectedRoutePermissions.dashboard, ) }, + { path: "tasks", element: protectedElement(protectedRoutePermissions.tasks, ) }, + { path: "tasks/:taskId", element: protectedElement(protectedRoutePermissions.taskDetail, ) }, + { path: "runs", element: protectedElement(protectedRoutePermissions.runs, ) }, + { path: "runs/:runId", element: protectedElement(protectedRoutePermissions.runDetail, ) }, + { path: "approvals", element: protectedElement(protectedRoutePermissions.approvals, ) }, + { path: "queue", element: protectedElement(protectedRoutePermissions.queue, ) }, + { path: "nodes", element: protectedElement(protectedRoutePermissions.nodes, ) }, + { path: "skills", element: protectedElement(protectedRoutePermissions.skills, ) }, + { path: "organizations", element: protectedElement(protectedRoutePermissions.organizations, ) }, + { path: "admin", element: protectedElement(protectedRoutePermissions.admin, ) }, + { path: "audit", element: protectedElement(protectedRoutePermissions.audit, ) } + ] + } + ] + } +]); diff --git a/apps/web/src/app/shell/AppShell.tsx b/apps/web/src/app/shell/AppShell.tsx new file mode 100644 index 0000000..ca153db --- /dev/null +++ b/apps/web/src/app/shell/AppShell.tsx @@ -0,0 +1,134 @@ +import { Navigate, NavLink, Outlet } from "react-router-dom"; +import { queryClient } from "../queryClient"; +import { useWorkbench } from "../workbenchContext"; +import { AuthControls } from "../../features/auth/AuthControls"; +import { useI18n } from "../../lib/i18n"; +import { hasPermission } from "../../lib/permissions"; +import { roleLabel } from "../../shared/lib/formatting"; +import { Metric } from "../../shared/ui/Metric"; +import { StatusBadge } from "../../shared/ui/StatusBadge"; +import { navItems } from "./nav"; + +export function AppShell() { + const { t } = useI18n(); + const { + activeProject, + activeProjectRole, + activeRepository, + auth, + authIsError, + projects, + queue, + refreshAll, + repositories, + runs, + selectedProjectId, + selectedRepositoryId, + setSelectedProjectId, + setSelectedRepositoryId, + tasks, + usersCount + } = useWorkbench(); + + if (!auth && authIsError) { + return ; + } + + return ( +
+ + +
+
+
+ + +
+ {activeProjectRole ? t("topbar.projectRole") : t("topbar.branch")} + {activeProjectRole ? roleLabel(activeProjectRole, t) : activeRepository?.default_branch || "main"} +
+
+
+ + +
+
+ + {auth ? ( +
+ + + + + + +
+ ) : null} + +
+ +
+
+
+ ); +} diff --git a/apps/web/src/app/shell/nav.ts b/apps/web/src/app/shell/nav.ts new file mode 100644 index 0000000..7099a5c --- /dev/null +++ b/apps/web/src/app/shell/nav.ts @@ -0,0 +1,44 @@ +import { Activity, CheckCircle2, ClipboardList, Gauge, LayoutDashboard, LucideIcon, Network, ScrollText, Settings, UsersRound, WandSparkles } from "lucide-react"; +import type { Permission } from "../../lib/permissions"; + +export type WorkbenchView = "dashboard" | "tasks" | "runs" | "queue" | "approvals" | "nodes" | "organizations" | "skills" | "admin" | "audit"; + +export type NavItem = { + id: WorkbenchView; + labelKey: string; + path: string; + permission: Permission; + icon: LucideIcon; +}; + +export const navItems: NavItem[] = [ + { id: "dashboard", labelKey: "nav.dashboard", path: "/", permission: "projects:read", icon: LayoutDashboard }, + { id: "tasks", labelKey: "nav.tasks", path: "/tasks", permission: "projects:read", icon: ClipboardList }, + { id: "runs", labelKey: "nav.runs", path: "/runs", permission: "runs:read", icon: Activity }, + { id: "queue", labelKey: "nav.queue", path: "/queue", permission: "runs:read", icon: Gauge }, + { id: "approvals", labelKey: "nav.approvals", path: "/approvals", permission: "projects:read", icon: CheckCircle2 }, + { id: "nodes", labelKey: "nav.nodes", path: "/nodes", permission: "nodes:read", icon: Network }, + { id: "organizations", labelKey: "nav.organizations", path: "/organizations", permission: "organizations:read", icon: UsersRound }, + { id: "skills", labelKey: "nav.skills", path: "/skills", permission: "projects:read", icon: WandSparkles }, + { id: "admin", labelKey: "nav.admin", path: "/admin", permission: "users:read", icon: Settings }, + { id: "audit", labelKey: "nav.audit", path: "/audit", permission: "audit:read", icon: ScrollText } +]; + +export function pathForView(view: WorkbenchView) { + return navItems.find((item) => item.id === view)?.path ?? "/"; +} + +export const protectedRoutePermissions = { + dashboard: "projects:read", + tasks: "projects:read", + taskDetail: "projects:read", + runs: "runs:read", + runDetail: "runs:read", + approvals: "projects:read", + queue: "runs:read", + nodes: "nodes:read", + skills: "projects:read", + organizations: "organizations:read", + admin: "users:read", + audit: "audit:read" +} as const satisfies Record; diff --git a/apps/web/src/app/workbenchContext.tsx b/apps/web/src/app/workbenchContext.tsx new file mode 100644 index 0000000..e482f3e --- /dev/null +++ b/apps/web/src/app/workbenchContext.tsx @@ -0,0 +1,296 @@ +import { createContext, ReactNode, useContext, useEffect, useMemo, useState } from "react"; +import { Outlet, useNavigate } from "react-router-dom"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { queryClient } from "./queryClient"; +import { AccessProvider, hasPermission, projectRole } from "../lib/permissions"; +import { + beginOIDCLogin, + clearAuthToken, + createBrowserSession, + getAuthCapabilities, + getAuthContext, + getAuthToken, + getQueueStatus, + listAllRuns, + listApprovals, + listAuditLogs, + listOrganizations, + listProjects, + listRepositories, + listTasks, + listToolCalls, + listUsers, + loginWithPassword, + logout, + setAuthToken +} from "../lib/api"; +import type { + Approval, + AuditLog, + AuthCapabilities, + AuthContext, + Organization, + Project, + QueueSnapshot, + Repository, + Run, + Task, + ToolCall, + UserDirectory +} from "../lib/api"; +import { pollWhileHealthy } from "../shared/lib/pollWhileHealthy"; + +type WorkbenchContextValue = { + auth?: AuthContext; + authError: Error | null; + authIsError: boolean; + authIsLoading: boolean; + capabilities?: AuthCapabilities; + activeProject?: Project; + activeProjectRole: string; + activeRepository?: Repository; + selectedTask?: Task; + selectedProjectId?: string; + selectedRepositoryId?: string; + selectedTaskId?: string; + setSelectedProjectId: (projectId: string | undefined) => void; + setSelectedRepositoryId: (repositoryId: string | undefined) => void; + setSelectedTaskId: (taskId: string | undefined) => void; + projects: Project[]; + repositories: Repository[]; + organizations: Organization[]; + tasks: Task[]; + runs: Run[]; + queue?: QueueSnapshot; + approvals: Approval[]; + auditLogs: AuditLog[]; + toolCalls: ToolCall[]; + users?: UserDirectory; + usersCount: number; + queueIsLoading: boolean; + tokenDraft: string; + setTokenDraft: (token: string) => void; + loginEmail: string; + loginPassword: string; + setLoginEmail: (email: string) => void; + setLoginPassword: (password: string) => void; + isConnecting: boolean; + isLoggingOut: boolean; + isPasswordLoginPending: boolean; + passwordLoginError: Error | null; + beginOIDCLogin: () => void; + loginWithPassword: () => void; + logoutSession: () => void; + saveToken: () => void; + refreshAll: () => void; +}; + +const WorkbenchContext = createContext(undefined); + +export function WorkbenchProvider({ children }: { children?: ReactNode }) { + const navigate = useNavigate(); + const [selectedProjectId, setSelectedProjectId] = useState(); + const [selectedRepositoryId, setSelectedRepositoryId] = useState(); + const [selectedTaskId, setSelectedTaskId] = useState(); + const [tokenDraft, setTokenDraft] = useState(getAuthToken); + const [loginEmail, setLoginEmail] = useState("local-dev@multi-codex.invalid"); + const [loginPassword, setLoginPassword] = useState("admin123"); + + const capabilities = useQuery({ queryKey: ["auth-capabilities"], queryFn: getAuthCapabilities, staleTime: 60_000 }); + const auth = useQuery({ queryKey: ["auth"], queryFn: getAuthContext, retry: false }); + const isAuthenticated = Boolean(auth.data); + const canProjectsRead = isAuthenticated && hasPermission(auth.data, "projects:read"); + const canOrganizationsRead = isAuthenticated && hasPermission(auth.data, "organizations:read"); + const canRunsRead = isAuthenticated && hasPermission(auth.data, "runs:read"); + const canUsersRead = isAuthenticated && hasPermission(auth.data, "users:read"); + const canAuditRead = isAuthenticated && hasPermission(auth.data, "audit:read"); + + const logoutMutation = useMutation({ + mutationFn: logout, + onSettled: () => { + clearAuthToken(); + setTokenDraft(""); + queryClient.invalidateQueries(); + navigate("/login", { replace: true }); + } + }); + const connectMutation = useMutation({ + mutationFn: createBrowserSession, + onSuccess: (nextAuth) => { + clearAuthToken(); + setTokenDraft(""); + queryClient.setQueryData(["auth"], nextAuth); + queryClient.invalidateQueries(); + navigate("/", { replace: true }); + } + }); + const passwordLoginMutation = useMutation({ + mutationFn: loginWithPassword, + onSuccess: (nextAuth) => { + clearAuthToken(); + setTokenDraft(""); + queryClient.setQueryData(["auth"], nextAuth); + queryClient.invalidateQueries(); + navigate("/", { replace: true }); + } + }); + + const organizations = useQuery({ queryKey: ["organizations"], queryFn: listOrganizations, enabled: canOrganizationsRead }); + const projects = useQuery({ queryKey: ["projects"], queryFn: listProjects, enabled: canProjectsRead }); + const users = useQuery({ queryKey: ["users"], queryFn: listUsers, enabled: canUsersRead }); + const activeProject = useMemo(() => { + if (!projects.data?.length) { + return undefined; + } + return projects.data.find((project) => project.id === selectedProjectId) ?? projects.data[0]; + }, [projects.data, selectedProjectId]); + const repositories = useQuery({ + queryKey: ["repositories", activeProject?.id], + queryFn: () => listRepositories(activeProject!.id), + enabled: canProjectsRead && Boolean(activeProject) + }); + const tasks = useQuery({ + queryKey: ["tasks", activeProject?.id], + queryFn: () => listTasks(activeProject!.id), + enabled: canProjectsRead && Boolean(activeProject), + refetchInterval: pollWhileHealthy(2_000) + }); + const runs = useQuery({ queryKey: ["runs"], queryFn: listAllRuns, enabled: canRunsRead, refetchInterval: pollWhileHealthy(2_000) }); + const queue = useQuery({ queryKey: ["queue"], queryFn: getQueueStatus, enabled: canRunsRead, refetchInterval: pollWhileHealthy(2_000) }); + const approvals = useQuery({ queryKey: ["approvals"], queryFn: listApprovals, enabled: canProjectsRead, refetchInterval: pollWhileHealthy(4_000) }); + const auditLogs = useQuery({ queryKey: ["audit-logs"], queryFn: listAuditLogs, enabled: canAuditRead, refetchInterval: pollWhileHealthy(5_000) }); + const toolCalls = useQuery({ queryKey: ["tool-calls"], queryFn: listToolCalls, enabled: canAuditRead, refetchInterval: pollWhileHealthy(5_000) }); + + const activeRepository = useMemo(() => { + if (!repositories.data?.length) { + return undefined; + } + return repositories.data.find((repository) => repository.id === selectedRepositoryId) ?? repositories.data[0]; + }, [repositories.data, selectedRepositoryId]); + const selectedTask = useMemo(() => { + if (!tasks.data?.length) { + return undefined; + } + return tasks.data.find((task) => task.id === selectedTaskId) ?? tasks.data[0]; + }, [selectedTaskId, tasks.data]); + + useEffect(() => { + if (!selectedProjectId && projects.data?.[0]) { + setSelectedProjectId(projects.data[0].id); + } + }, [projects.data, selectedProjectId]); + + useEffect(() => { + if (!selectedRepositoryId && repositories.data?.[0]) { + setSelectedRepositoryId(repositories.data[0].id); + } + }, [repositories.data, selectedRepositoryId]); + + useEffect(() => { + if (!auth.data && capabilities.data?.auth_mode !== "oidc" && capabilities.data?.local_admin_email) { + setLoginEmail((current) => (current === "local-dev@multi-codex.invalid" ? capabilities.data!.local_admin_email! : current)); + } + }, [auth.data, capabilities.data]); + + const saveToken = () => { + const trimmed = tokenDraft.trim(); + if (trimmed) { + connectMutation.mutate(trimmed); + } else { + setAuthToken(""); + queryClient.invalidateQueries(); + } + }; + + const value = useMemo( + () => ({ + auth: auth.data, + authError: auth.error instanceof Error ? auth.error : null, + authIsError: auth.isError, + authIsLoading: auth.isLoading, + capabilities: capabilities.data, + activeProject, + activeProjectRole: projectRole(auth.data, activeProject?.id), + activeRepository, + selectedTask, + selectedProjectId, + selectedRepositoryId, + selectedTaskId, + setSelectedProjectId, + setSelectedRepositoryId, + setSelectedTaskId, + projects: projects.data ?? [], + repositories: repositories.data ?? [], + organizations: organizations.data ?? [], + tasks: tasks.data ?? [], + runs: runs.data ?? [], + queue: queue.data, + approvals: approvals.data ?? [], + auditLogs: auditLogs.data ?? [], + toolCalls: toolCalls.data ?? [], + users: users.data, + usersCount: users.data?.users.length ?? 0, + queueIsLoading: queue.isLoading, + tokenDraft, + setTokenDraft, + loginEmail, + loginPassword, + setLoginEmail, + setLoginPassword, + isConnecting: connectMutation.isPending, + isLoggingOut: logoutMutation.isPending, + isPasswordLoginPending: passwordLoginMutation.isPending, + passwordLoginError: passwordLoginMutation.error instanceof Error ? passwordLoginMutation.error : null, + beginOIDCLogin, + loginWithPassword: () => passwordLoginMutation.mutate({ email: loginEmail, password: loginPassword }), + logoutSession: () => logoutMutation.mutate(), + saveToken, + refreshAll: () => queryClient.invalidateQueries() + }), + [ + activeProject, + activeRepository, + approvals.data, + auditLogs.data, + auth.data, + auth.error, + auth.isError, + auth.isLoading, + capabilities.data, + connectMutation.isPending, + loginEmail, + loginPassword, + logoutMutation.isPending, + organizations.data, + passwordLoginMutation.error, + passwordLoginMutation.isPending, + projects.data, + queue.data, + queue.isLoading, + repositories.data, + runs.data, + selectedProjectId, + selectedRepositoryId, + selectedTask, + selectedTaskId, + tasks.data, + tokenDraft, + toolCalls.data, + users.data + ] + ); + + return ( + + {children ?? } + + ); +} + +export function useWorkbench() { + const value = useContext(WorkbenchContext); + if (!value) { + throw new Error("useWorkbench must be used inside WorkbenchProvider"); + } + return value; +} diff --git a/apps/web/src/features/admin/AdminPage.tsx b/apps/web/src/features/admin/AdminPage.tsx new file mode 100644 index 0000000..b9920bd --- /dev/null +++ b/apps/web/src/features/admin/AdminPage.tsx @@ -0,0 +1,311 @@ +import { useEffect, useState } from "react"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { queryClient } from "../../app/queryClient"; +import { useWorkbench } from "../../app/workbenchContext"; +import { AccessNotice } from "../../app/AccessPanel"; +import { createUser, listProjectMembers, upsertProjectMember } from "../../lib/api"; +import type { Project, ProjectMembership, UserDirectory } from "../../lib/api"; +import { useI18n } from "../../lib/i18n"; +import { useAccess } from "../../lib/permissions"; +import { StatusBadge } from "../../shared/ui/StatusBadge"; +import { ListPaginationControl, useListPagination } from "../../shared/lib/listPagination"; +import { formatDate, roleLabel, submit } from "../../shared/lib/formatting"; + +export function AdminPage() { + const { activeProject, projects, setSelectedProjectId, users } = useWorkbench(); + return ; +} + +function AdminView({ + activeProject, + onSelectProject, + projects, + users +}: { + activeProject?: Project; + onSelectProject: (projectId: string) => void; + projects: Project[]; + users?: UserDirectory; +}) { + const { t } = useI18n(); + const access = useAccess(); + const [email, setEmail] = useState("teammate@example.com"); + const [displayName, setDisplayName] = useState("Teammate"); + const [orgRole, setOrgRole] = useState("viewer"); + const [password, setPassword] = useState("ChangeMe123"); + const [selectedUserId, setSelectedUserId] = useState(""); + const [projectRoleDraft, setProjectRoleDraft] = useState("developer"); + const directoryUsers = users?.users ?? []; + const memberships = users?.memberships ?? []; + const projectMemberships = users?.project_memberships ?? []; + const activeProjectMembers = useQuery({ + queryKey: ["project-members", activeProject?.id], + queryFn: () => listProjectMembers(activeProject!.id), + enabled: Boolean(activeProject) && access.has("projects:read") + }); + const projectMembers = activeProjectMembers.data ?? []; + const selectedUser = directoryUsers.find((user) => user.id === selectedUserId) ?? directoryUsers[0]; + const selectedUserProjectMemberships = selectedUser ? projectMembershipsForUser(projectMemberships, selectedUser.id) : []; + const userPagination = useListPagination(directoryUsers); + const memberPagination = useListPagination(projectMembers); + const projectPagination = useListPagination(projects); + const accessPagination = useListPagination(selectedUserProjectMemberships); + + const createUserMutation = useMutation({ + mutationFn: () => createUser({ email, display_name: displayName, role: orgRole, password: password.trim() || undefined }), + onSuccess: (created) => { + setSelectedUserId(created.user.id); + setPassword(""); + void queryClient.invalidateQueries({ queryKey: ["users"] }); + void queryClient.invalidateQueries({ queryKey: ["auth"] }); + void queryClient.invalidateQueries({ queryKey: ["audit-logs"] }); + } + }); + const projectMemberMutation = useMutation({ + mutationFn: () => upsertProjectMember(activeProject!.id, { user_id: selectedUser!.id, role: projectRoleDraft }), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ["project-members", activeProject?.id] }); + void queryClient.invalidateQueries({ queryKey: ["users"] }); + void queryClient.invalidateQueries({ queryKey: ["projects"] }); + void queryClient.invalidateQueries({ queryKey: ["auth"] }); + void queryClient.invalidateQueries({ queryKey: ["audit-logs"] }); + } + }); + + useEffect(() => { + if (!selectedUserId && directoryUsers[0]) { + setSelectedUserId(directoryUsers[0].id); + } + }, [directoryUsers, selectedUserId]); + + return ( +
+
+
+
+
+

{t("admin.identity")}

+

{t("admin.users")}

+
+
+ {t("common.total", { count: directoryUsers.length })} + +
+
+ {!access.has("users:write") ? : null} +
submit(event, () => createUserMutation.mutate())}> + + + + + +
+
+ {directoryUsers.length ? ( + userPagination.items.map((user) => { + const role = orgRoleForUser(memberships, user.id); + const projectCount = projectMemberships.filter((membership) => membership.user_id === user.id).length; + return ( + + ); + }) + ) : ( +
{t("admin.noUsers")}
+ )} +
+
+ +
+
+
+

{t("admin.activeProject")}

+

{activeProject?.name ?? t("admin.noProject")}

+
+
+ {t("common.total", { count: projectMembers.length })} + +
+
+ {!access.has("projects:write") ? : null} +
submit(event, () => projectMemberMutation.mutate())}> + + + +
+
+ {projectMembers.length ? ( + memberPagination.items.map((membership) => ( +
+
+ {membership.user_name || membership.user_email || membership.user_id} + {membership.user_email || membership.user_id} +
+ + {membership.project_name || activeProject?.name || membership.project_id} + {new Date(membership.created_at).toLocaleDateString()} +
+ )) + ) : ( +
{activeProject ? t("admin.noMembers") : t("admin.noProjectMembers")}
+ )} +
+
+
+ + +
+ ); +} + +const orgRoles = ["owner", "admin", "tech_lead", "operator", "reviewer", "auditor", "viewer"]; +const projectRoles = ["owner", "project_admin", "maintainer", "developer", "reviewer", "auditor", "viewer"]; + +function orgRoleForUser(memberships: UserDirectory["memberships"], userId: string) { + return memberships.find((membership) => membership.user_id === userId)?.role ?? "viewer"; +} + +function projectMembershipsForUser(memberships: ProjectMembership[], userId: string) { + return memberships + .filter((membership) => membership.user_id === userId) + .sort((first, second) => (first.project_name || first.project_id).localeCompare(second.project_name || second.project_id)); +} + diff --git a/apps/web/src/features/approvals/ApprovalsPage.tsx b/apps/web/src/features/approvals/ApprovalsPage.tsx new file mode 100644 index 0000000..5ccdf27 --- /dev/null +++ b/apps/web/src/features/approvals/ApprovalsPage.tsx @@ -0,0 +1,61 @@ +import { useMutation, useQuery } from "@tanstack/react-query"; +import { queryClient } from "../../app/queryClient"; +import { decideApproval, listApprovals } from "../../lib/api"; +import type { Approval } from "../../lib/api"; +import { useI18n } from "../../lib/i18n"; +import { useAccess } from "../../lib/permissions"; +import { StatusBadge } from "../../shared/ui/StatusBadge"; +import { ListPaginationControl, useListPagination } from "../../shared/lib/listPagination"; +import { pollWhileHealthy } from "../../shared/lib/pollWhileHealthy"; + +export function ApprovalsPage() { + const { t } = useI18n(); + const access = useAccess(); + const approvals = useQuery({ queryKey: ["approvals"], queryFn: listApprovals, enabled: access.has("projects:read"), refetchInterval: pollWhileHealthy(3_000) }); + const approvalList = approvals.data ?? []; + const pagination = useListPagination(approvalList); + const mutation = useMutation({ + mutationFn: ({ approval, status }: { approval: Approval; status: "approved" | "rejected" }) => + decideApproval(approval.id, status, status === "approved" ? "Approved in Web Console." : "Rejected in Web Console."), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ["approvals"] }); + void queryClient.invalidateQueries({ queryKey: ["audit-logs"] }); + } + }); + + return ( +
+
+

{t("approvals.center")}

+
+ {t("common.requests", { count: approvalList.length })} + +
+
+
+ {approvals.data?.length ? ( + pagination.items.map((approval) => ( +
+
+ {approval.approval_type} + {approval.task_id} +
+ + {approval.reason || t("approvals.noReason")} +
+ + +
+
+ )) + ) : ( +
{t("approvals.empty")}
+ )} +
+
+ ); +} diff --git a/apps/web/src/features/audit/AuditPage.tsx b/apps/web/src/features/audit/AuditPage.tsx new file mode 100644 index 0000000..7da86ce --- /dev/null +++ b/apps/web/src/features/audit/AuditPage.tsx @@ -0,0 +1,84 @@ +import { useQuery } from "@tanstack/react-query"; +import { listAuditLogs, listToolCalls } from "../../lib/api"; +import { useI18n } from "../../lib/i18n"; +import { useAccess } from "../../lib/permissions"; +import { StatusBadge } from "../../shared/ui/StatusBadge"; +import { ListPaginationControl, useListPagination } from "../../shared/lib/listPagination"; +import { pollWhileHealthy } from "../../shared/lib/pollWhileHealthy"; + +export function AuditPage() { + const { t } = useI18n(); + const access = useAccess(); + const auditLogs = useQuery({ queryKey: ["audit-logs"], queryFn: listAuditLogs, enabled: access.has("audit:read"), refetchInterval: pollWhileHealthy(5_000) }); + const toolCalls = useQuery({ queryKey: ["tool-calls"], queryFn: listToolCalls, enabled: access.has("audit:read"), refetchInterval: pollWhileHealthy(5_000) }); + const auditList = auditLogs.data ?? []; + const toolCallList = toolCalls.data ?? []; + const auditPagination = useListPagination(auditList); + const toolPagination = useListPagination(toolCallList); + + return ( +
+
+
+

{t("audit.logs")}

+
+ {t("common.recent", { count: auditList.length })} + +
+
+ +
+
+
+

{t("dashboard.toolCalls")}

+
+ {t("common.recent", { count: toolCallList.length })} + +
+
+
+ {toolPagination.items.map((call) => ( +
+
+ {call.tool_name} + {call.caller} +
+ + {call.run_id || t("common.noRun")} + {new Date(call.created_at).toLocaleString()} +
+ ))} +
+
+
+ ); +} + +export function CompactAuditList({ + className, + entries +}: { + className?: string; + entries: Array<{ id: string; action: string; resource_type: string; resource_id: string; entry_hash?: string; prev_hash?: string }>; +}) { + const { t } = useI18n(); + return ( +
+ {entries.length ? ( + entries.map((entry) => ( +
+ {entry.action} +
+ + {entry.resource_type}:{entry.resource_id} + + {entry.entry_hash ? hash:{entry.entry_hash.slice(0, 16)} : null} +
+
+ )) + ) : ( +
{t("audit.empty")}
+ )} +
+ ); +} diff --git a/apps/web/src/features/auth/AuthControls.tsx b/apps/web/src/features/auth/AuthControls.tsx new file mode 100644 index 0000000..8eb459f --- /dev/null +++ b/apps/web/src/features/auth/AuthControls.tsx @@ -0,0 +1,73 @@ +import { LanguageToggle, useI18n } from "../../lib/i18n"; +import { visiblePermissions } from "../../lib/permissions"; +import { roleLabel } from "../../shared/lib/formatting"; +import { useWorkbench } from "../../app/workbenchContext"; + +export function AuthControls() { + const { t } = useI18n(); + const { + auth, + authError, + capabilities, + beginOIDCLogin, + isConnecting, + isLoggingOut, + logoutSession, + saveToken, + setTokenDraft, + tokenDraft + } = useWorkbench(); + const permissions = visiblePermissions(auth); + const sessionLabel = auth + ? t("auth.sessionUserRole", { user: auth.user.email, role: roleLabel(auth.membership.role, t) }) + : authError + ? t("auth.required") + : t("auth.checking"); + const authMode = capabilities ? (capabilities.auth_mode === "oidc" ? t("auth.oidc") : t("auth.local")) : t("auth.checking"); + const isOIDC = capabilities?.auth_mode === "oidc"; + return ( +
+ +
+ {t("auth.session")} + {sessionLabel} +
+
+ {t("auth.mode")} + {authMode} +
+ {auth ? ( +
+ {permissions.slice(0, 5).map((permission) => ( + {permission === "*" ? t("auth.allPermissions") : permission} + ))} + {permissions.length > 5 ? +{permissions.length - 5} : null} +
+ ) : null} + {isOIDC ? ( + setTokenDraft(event.target.value)} + /> + ) : null} +
+ {isOIDC ? ( + <> + + + + ) : null} + +
+
+ ); +} diff --git a/apps/web/src/features/auth/LoginPage.tsx b/apps/web/src/features/auth/LoginPage.tsx new file mode 100644 index 0000000..f3b013e --- /dev/null +++ b/apps/web/src/features/auth/LoginPage.tsx @@ -0,0 +1,185 @@ +import { useEffect } from "react"; +import { useNavigate } from "react-router-dom"; +import { useI18n } from "../../lib/i18n"; +import { roleLabel, submit } from "../../shared/lib/formatting"; +import { useWorkbench } from "../../app/workbenchContext"; + +export function LoginPage() { + const navigate = useNavigate(); + const workbench = useWorkbench(); + + useEffect(() => { + if (workbench.auth) { + navigate("/", { replace: true }); + } + }, [navigate, workbench.auth]); + + return ( + navigate("/")} + onSaveToken={workbench.saveToken} + tokenDraft={workbench.tokenDraft} + onTokenDraftChange={workbench.setTokenDraft} + /> + ); +} + +type LoginContentProps = { + auth?: import("../../lib/api").AuthContext; + capabilities?: import("../../lib/api").AuthCapabilities; + authError: Error | null; + isConnecting: boolean; + isPasswordLoginPending: boolean; + loginEmail: string; + loginPassword: string; + onLoginEmailChange: (value: string) => void; + onLoginPasswordChange: (value: string) => void; + onPasswordLogin: () => void; + onOIDCLogin: () => void; + onOpenConsole: () => void; + onSaveToken: () => void; + tokenDraft: string; + onTokenDraftChange: (value: string) => void; +}; + +function LoginContent(props: LoginContentProps) { + const { t } = useI18n(); + return ( +
+
+
+ mcx +
+

multi-codex

+

{t("auth.enterpriseSubtitle")}

+
+
+
+

{t("auth.secureWorkspace")}

+

{t("auth.loginHeroTitle")}

+

{t("auth.loginHeroBody")}

+
+
+ {t("auth.assuranceEnvelope")} + {t("auth.assuranceRbac")} + {t("auth.assuranceAudit")} +
+
+ +
+ ); +} + +function LoginPanel({ + auth, + capabilities, + authError, + isConnecting, + isPasswordLoginPending, + loginEmail, + loginPassword, + onLoginEmailChange, + onLoginPasswordChange, + onPasswordLogin, + onOIDCLogin, + onOpenConsole, + onSaveToken, + tokenDraft, + onTokenDraftChange +}: LoginContentProps) { + const { t } = useI18n(); + const isOIDC = capabilities?.auth_mode === "oidc"; + const helperText = !capabilities + ? t("auth.apiUnavailable") + : isOIDC + ? capabilities?.oidc_configured + ? t("auth.loginOidcReady") + : t("auth.loginOidcMissing") + : t("auth.loginLocal"); + const ttlHours = capabilities?.session_ttl_seconds ? Math.round(capabilities.session_ttl_seconds / 3600) : undefined; + + return ( +
+
+

{t("auth.session")}

+

{t("auth.loginTitle")}

+

{t("auth.loginBody")}

+
+
+ {helperText} + {ttlHours ? {t("auth.sessionTtl", { hours: ttlHours })} : null} + {capabilities?.default_role ? {t("auth.defaultRole", { role: roleLabel(capabilities.default_role, t) })} : null} + {authError ? {authError.message} : null} +
+
+ {auth ? ( + + ) : !isOIDC ? ( +
submit(event, onPasswordLogin)}> + + + +
+ ) : ( + + )} + {isOIDC ? ( +
+ {t("auth.advancedLogin")} +
+ + +
+
+ ) : null} +
+
+ ); +} diff --git a/apps/web/src/features/dashboard/DashboardPage.tsx b/apps/web/src/features/dashboard/DashboardPage.tsx new file mode 100644 index 0000000..aba3d8d --- /dev/null +++ b/apps/web/src/features/dashboard/DashboardPage.tsx @@ -0,0 +1,729 @@ +import { useMemo, useState } from "react"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { useNavigate } from "react-router-dom"; +import { queryClient } from "../../app/queryClient"; +import { useWorkbench } from "../../app/workbenchContext"; +import { AccessNotice } from "../../app/AccessPanel"; +import { pathForView, type WorkbenchView } from "../../app/shell/nav"; +import { + createProject, + createRepository, + dispatchQueue, + getWorkflow, + listArtifacts, + requestApproval, + runWorkflowAction, + scopeCheck, + startTask +} from "../../lib/api"; +import type { Approval, AuditLog, Project, QueueSnapshot, Repository, Run, Task, ToolCall } from "../../lib/api"; +import { useI18n } from "../../lib/i18n"; +import { useAccess } from "../../lib/permissions"; +import { StatusBadge } from "../../shared/ui/StatusBadge"; +import { Metric } from "../../shared/ui/Metric"; +import { ListPaginationControl, useListPagination } from "../../shared/lib/listPagination"; +import { capacityWidth, envelopeList, envelopeValue, formatDate, labelize, lines, policyLabel, roleLabel, workflowActionLabel } from "../../shared/lib/formatting"; +import { pollWhileHealthy } from "../../shared/lib/pollWhileHealthy"; +import { useRunEvents } from "../../shared/hooks/useRunEventStream"; +import { CompactAuditList } from "../audit/AuditPage"; +import { EventList } from "../runs/RunsPage"; +import { TaskRow } from "../tasks/TaskRow"; + +export function DashboardPage() { + const navigate = useNavigate(); + const { activeProject, activeRepository, approvals, auditLogs, queue, runs, selectedTask, setSelectedTaskId, tasks, toolCalls } = useWorkbench(); + + return ( + { + setSelectedTaskId(taskId); + navigate(`/tasks/${taskId}`); + }} + onSelectView={(view) => navigate(pathForView(view))} + queue={queue} + runs={runs} + selectedTask={selectedTask} + tasks={tasks} + toolCalls={toolCalls} + /> + ); +} + +function DashboardView({ + activeProject, + activeProjectId, + activeRepository, + approvals, + auditLogs, + onSelectTask, + onSelectView, + queue, + runs, + selectedTask, + tasks, + toolCalls +}: { + activeProject?: Project; + activeProjectId?: string; + activeRepository?: Repository; + approvals: Approval[]; + auditLogs: AuditLog[]; + onSelectTask: (taskId: string) => void; + onSelectView: (view: WorkbenchView) => void; + queue?: QueueSnapshot; + runs: Run[]; + selectedTask?: Task; + tasks: Task[]; + toolCalls: ToolCall[]; +}) { + const selectedRuns = useMemo(() => runs.filter((run) => run.task_id === selectedTask?.id), [runs, selectedTask?.id]); + const latestRun = selectedRuns[selectedRuns.length - 1]; + + return ( +
+
+ + + {!activeProject || !activeRepository ? : null} +
+ + + + +
+ ); +} + +type TaskFilter = "all" | "queued" | "running" | "blocked" | "done"; + +function ActiveTasksPanel({ + onSelectTask, + selectedTask, + tasks +}: { + onSelectTask: (taskId: string) => void; + selectedTask?: Task; + tasks: Task[]; +}) { + const { t } = useI18n(); + const [filter, setFilter] = useState("all"); + const buckets = useMemo( + () => ({ + all: tasks.length, + queued: tasks.filter((task) => ["queued", "pending", "created"].includes(task.status)).length, + running: tasks.filter((task) => ["running", "started", "validating"].includes(task.status)).length, + blocked: tasks.filter((task) => ["blocked", "failed", "rejected"].includes(task.status)).length, + done: tasks.filter((task) => ["done", "completed", "succeeded", "approved"].includes(task.status)).length + }), + [tasks] + ); + const visibleTasks = useMemo(() => { + const sorted = [...tasks].sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()); + if (filter === "all") { + return sorted; + } + return sorted.filter((task) => { + if (filter === "queued") { + return ["queued", "pending", "created"].includes(task.status); + } + if (filter === "running") { + return ["running", "started", "validating"].includes(task.status); + } + if (filter === "blocked") { + return ["blocked", "failed", "rejected"].includes(task.status); + } + return ["done", "completed", "succeeded", "approved"].includes(task.status); + }); + }, [filter, tasks]); + const pagination = useListPagination(visibleTasks); + + return ( +
+
+
+

{t("dashboard.workQueue")}

+

{t("dashboard.activeTasks")}

+
+
+ {t("common.total", { count: tasks.length })} + +
+
+
+ {(["all", "queued", "running", "blocked", "done"] as TaskFilter[]).map((item) => ( + + ))} +
+
+ {visibleTasks.length ? ( + pagination.items.map((task) => ( + onSelectTask(task.id)} /> + )) + ) : ( +
{t("tasks.noFilterMatch")}
+ )} +
+
+ ); +} + +function QueueHealthCard({ onSelectView, queue, runs }: { onSelectView: (view: WorkbenchView) => void; queue?: QueueSnapshot; runs: Run[] }) { + const { t } = useI18n(); + const access = useAccess(); + const dispatchMutation = useMutation({ + mutationFn: dispatchQueue, + onSuccess: (payload) => { + void queryClient.invalidateQueries({ queryKey: ["queue"] }); + void queryClient.invalidateQueries({ queryKey: ["runs"] }); + void queryClient.invalidateQueries({ queryKey: ["tasks"] }); + void queryClient.invalidateQueries({ queryKey: ["run-events", payload.run.id] }); + } + }); + const running = runs.filter((run) => run.status === "running").length; + const queued = queue?.queued_runs.length ?? 0; + const blocked = runs.filter((run) => ["blocked", "failed"].includes(run.status)).length; + const completed = runs.filter((run) => ["completed", "succeeded"].includes(run.status)).length; + const snapshots = Object.values(queue?.backpressure ?? {}); + const pagination = useListPagination(snapshots); + + return ( +
+
+
+

{t("dashboard.capacity")}

+

{t("dashboard.queueHealth")}

+
+
+ {new Date().toLocaleTimeString()} + +
+
+
+ + + + +
+
+ {snapshots.length ? ( + pagination.items.map((snapshot) => ( +
+
+ {labelize(snapshot.executor)} + {t("common.freeSlots", { count: snapshot.available_slots })} +
+ + {t("common.retrySeconds", { count: snapshot.retry_after_seconds })} +
+ )) + ) : ( +
{t("queue.noCapacity")}
+ )} +
+
+ + +
+
+ ); +} + +function TaskLifecyclePanel({ + activeProject, + activeRepository, + approvals, + latestRun, + onSelectView, + runs, + task +}: { + activeProject?: Project; + activeRepository?: Repository; + approvals: Approval[]; + latestRun?: Run; + onSelectView: (view: WorkbenchView) => void; + runs: Run[]; + task?: Task; +}) { + const { t } = useI18n(); + const access = useAccess(); + const [scopeInput, setScopeInput] = useState("internal/**\napps/web/src/**\ndocs/**"); + const [scopeResult, setScopeResult] = useState<{ status: string; changed_files: string[]; violations: string[] } | undefined>(); + const workflow = useQuery({ + queryKey: ["workflow", task?.id], + queryFn: () => getWorkflow(task!.id), + enabled: Boolean(task) && access.has("projects:read"), + refetchInterval: pollWhileHealthy(2_000) + }); + const startMutation = useMutation({ + mutationFn: async () => startTask(task!.id), + onSuccess: (run) => refreshTaskQueries(task, run.id) + }); + const scopeMutation = useMutation({ + mutationFn: async () => scopeCheck(task!.id, lines(scopeInput)), + onSuccess: (result) => { + setScopeResult(result); + refreshTaskQueries(task); + } + }); + const workflowMutation = useMutation({ + mutationFn: (action: string) => runWorkflowAction(task!.id, action), + onSuccess: () => refreshTaskQueries(task) + }); + const approvalMutation = useMutation({ + mutationFn: () => requestApproval(task!.id, { approval_type: "pr_prepare", reason: "PR body preparation requires human approval." }), + onSuccess: () => refreshTaskQueries(task) + }); + + if (!task) { + return ( +
+
+
+

{t("dashboard.delivery")}

+

{t("dashboard.taskLifecycle")}

+
+ +
+
+

{activeProject && activeRepository ? t("tasks.createTitle") : t("tasks.connectFirst")}

+

{t("tasks.lifecycleHelp")}

+ +
+
+ ); + } + + const taskApprovals = approvals.filter((approval) => approval.task_id === task.id); + const pendingApprovals = taskApprovals.filter((approval) => approval.status === "pending").length; + const blockedReasons = workflow.data?.blocked_reasons ?? []; + const nextActions = workflow.data?.next_actions ?? []; + const role = envelopeValue(task.envelope, "role", "feature"); + const executor = envelopeValue(task.envelope, "executor", latestRun?.executor ?? "docker"); + const allowedPaths = envelopeList(task.envelope, "allowed_paths"); + + return ( +
+
+
+

{t("dashboard.selectedTask")}

+
+ {task.task_key} +

{task.title}

+
+
+ + {roleLabel(role, t)} + {executor} + {t("common.recent", { count: runs.length })} +
+
+
+ + +
+
+ + {blockedReasons.length ? ( +
+ {blockedReasons.map((reason) => ( + {reason} + ))} +
+ ) : ( +
{t("tasks.readyStrip")}
+ )} + +
+ {nextActions.slice(0, 5).map((action) => ( + + ))} +
+ +
+ + +
+
+ +
+

{t("tasks.scopeCheck")}

+ {scopeResult?.violations.length ? t("tasks.violations", { count: scopeResult.violations.length }) : t("tasks.pathsConstrained")} +
+
+
+