diff --git a/.env.example b/.env.example
index 31e58cc..72f7f0a 100644
--- a/.env.example
+++ b/.env.example
@@ -78,6 +78,17 @@ 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_USAGE_BUDGET_ENABLED=false
+MULTICODEX_USAGE_BUDGET_SECONDS_PER_DAY=0
+MULTICODEX_USAGE_BUDGET_WARN_RATIO=0.8
MULTICODEX_TELEMETRY_PUSH_URL=
MULTICODEX_TELEMETRY_PUSH_INTERVAL=1m
MULTICODEX_AUDIT_EXPORT_PATH=
diff --git a/.env.production.example b/.env.production.example
new file mode 100644
index 0000000..922fb80
--- /dev/null
+++ b/.env.production.example
@@ -0,0 +1,100 @@
+# Production environment template
+# Copy to .env.production and inject secrets from a secret manager.
+# Do not commit filled values.
+
+POSTGRES_DB=multi_codex
+POSTGRES_USER=multi_codex
+POSTGRES_PASSWORD=
+
+MULTICODEX_ENV=production
+MULTICODEX_PRODUCTION=true
+MULTICODEX_CORS_ALLOWED_ORIGINS=https://multi-codex.example.com
+
+MULTICODEX_API_LISTEN=:8080
+MULTICODEX_MCP_LISTEN=:8090
+MULTICODEX_AGENTD_LISTEN=:7070
+MULTICODEX_AGENTD_URL=http://worker-agentd:7070
+MULTICODEX_AGENTD_TOKEN=
+
+MULTICODEX_DATABASE_URL=postgres://multi_codex:CHANGE_ME@postgres:5432/multi_codex?sslmode=require
+MULTICODEX_ARTIFACT_ROOT=/var/lib/multi-codex/artifacts
+MULTICODEX_RUN_ROOT=/var/lib/multi-codex/runs
+MULTICODEX_WORKTREE_ROOT=/var/lib/multi-codex/worktrees
+MULTICODEX_REPO_CACHE_ROOT=/var/lib/multi-codex/repos
+
+MULTICODEX_API_IMAGE=ghcr.io/acme/multi-codex/api:v0.1.0
+MULTICODEX_WEB_IMAGE=ghcr.io/acme/multi-codex/web:v0.1.0
+MULTICODEX_WORKER_IMAGE=ghcr.io/acme/multi-codex/codex-worker:v0.1.0
+
+MULTICODEX_EXECUTOR_MODE=docker
+MULTICODEX_WORKER_DOCKER_SOCKET_ENABLED=true
+MULTICODEX_WORKER_DOCKER_SOCKET_BOUNDARY=isolated-worker-host
+MULTICODEX_WORKER_DEFAULT_TIMEOUT=1h
+MULTICODEX_WORKER_CPUS=1
+MULTICODEX_WORKER_MEMORY=2g
+MULTICODEX_WORKER_PIDS_LIMIT=256
+MULTICODEX_WORKER_READ_ONLY_ROOTFS=true
+MULTICODEX_WORKER_TMPFS_SIZE=256m
+MULTICODEX_WORKER_NO_NEW_PRIVILEGES=true
+MULTICODEX_WORKER_CAP_DROP=ALL
+MULTICODEX_WORKER_COMMAND_DENYLIST=rm -rf /,curl | sh,wget | sh,chmod 777
+MULTICODEX_WORKER_COMMAND_ALLOWLIST=
+MULTICODEX_WORKER_SECRET_ENV_ALLOWLIST=OPENAI_API_KEY,CODEX_AUTH_TOKEN
+MULTICODEX_WORKER_SECRET_PROVIDER=file
+MULTICODEX_WORKER_SECRET_FILE_PATH=/run/secrets/multi-codex-worker.json
+
+MULTICODEX_AUTH_MODE=oidc
+MULTICODEX_AUTH_SESSION_TTL=12h
+MULTICODEX_AUTH_COOKIE_SECURE=true
+MULTICODEX_AUTH_LOGIN_STATE_TTL=10m
+MULTICODEX_OIDC_ISSUER=https://issuer.example
+MULTICODEX_OIDC_AUDIENCE=multi-codex
+MULTICODEX_OIDC_JWKS_URL=https://issuer.example/.well-known/jwks.json
+MULTICODEX_OIDC_CLIENT_ID=multi-codex-web
+MULTICODEX_OIDC_CLIENT_SECRET=
+MULTICODEX_OIDC_CLIENT_AUTH_METHOD=client_secret_post
+MULTICODEX_OIDC_REDIRECT_URL=https://multi-codex.example.com/api/v1/auth/callback
+MULTICODEX_OIDC_AUTHORIZATION_URL=
+MULTICODEX_OIDC_TOKEN_URL=
+MULTICODEX_OIDC_POST_LOGIN_REDIRECT_URL=/
+MULTICODEX_OIDC_DEFAULT_ROLE=viewer
+MULTICODEX_OIDC_DEFAULT_ORG_ID=
+MULTICODEX_OIDC_GROUP_ROLE_MAP=engineering=operator;security=auditor
+MULTICODEX_OIDC_GROUP_ORG_MAP=
+
+MULTICODEX_RETENTION_ENABLED=true
+MULTICODEX_RETENTION_INTERVAL=1h
+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_USAGE_BUDGET_ENABLED=false
+MULTICODEX_USAGE_BUDGET_SECONDS_PER_DAY=864000
+MULTICODEX_USAGE_BUDGET_WARN_RATIO=0.8
+
+MULTICODEX_TELEMETRY_PUSH_URL=
+MULTICODEX_TELEMETRY_PUSH_INTERVAL=1m
+
+MULTICODEX_AUDIT_EXPORT_PATH=
+MULTICODEX_AUDIT_SHIP_ENABLED=true
+MULTICODEX_AUDIT_SHIP_INTERVAL=24h
+MULTICODEX_AUDIT_SEAL_ROOT=/var/lib/multi-codex/audit-seals/scheduled
+MULTICODEX_AUDIT_SHIP_TARGET=file:///mnt/worm/multi-codex
+MULTICODEX_AUDIT_SHIP_ALLOW_LEGACY_HASH_MISMATCH=false
+
+MULTICODEX_GIT_SYNC_MODE=dry-run
+MULTICODEX_GIT_SYNC_LIVE_REVIEWED=false
+MULTICODEX_GIT_CREDENTIAL_PROVIDER=file
+MULTICODEX_GIT_CREDENTIAL_FILE_PATH=/run/secrets/multi-codex-git.json
+MULTICODEX_GITHUB_API_URL=https://api.github.com
+MULTICODEX_GITLAB_API_URL=https://gitlab.com/api/v4
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 40f645d..a0b603a 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -29,7 +29,13 @@ jobs:
run: docker compose -f deployments/docker/compose.dev.yaml run --rm dev go test ./...
- name: Backend build
- run: docker compose -f deployments/docker/compose.dev.yaml run --rm dev go build ./cmd/api ./cmd/mcp-gateway ./cmd/worker-agentd ./cmd/mcxctl
+ run: docker compose -f deployments/docker/compose.dev.yaml run --rm -e GOFLAGS=-buildvcs=false dev go build ./cmd/api ./cmd/mcp-gateway ./cmd/worker-agentd ./cmd/mcxctl
+
+ - name: Frontend lint
+ run: docker compose -f deployments/docker/compose.dev.yaml run --rm dev bash -c 'pnpm --dir apps/web install --frozen-lockfile && pnpm --dir apps/web lint'
+
+ - name: Frontend unit tests
+ run: docker compose -f deployments/docker/compose.dev.yaml run --rm dev bash -c 'pnpm --dir apps/web install --frozen-lockfile && pnpm --dir apps/web test'
- name: Frontend build
run: docker compose -f deployments/docker/compose.dev.yaml run --rm dev bash -c 'pnpm --dir apps/web install --frozen-lockfile && pnpm --dir apps/web build'
@@ -40,6 +46,7 @@ jobs:
run: |
docker compose -f deployments/docker/compose.dev.yaml config >/dev/null
docker compose -f deployments/docker/compose.yaml config >/dev/null
+ docker compose -f deployments/docker/compose.yaml -f deployments/docker/compose.ha.yaml config >/dev/null
- name: Migration check
run: |
diff --git a/.gitignore b/.gitignore
index 5d50991..431b846 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,6 +2,7 @@
.env
.env.*
!.env.example
+!.env.production.example
# Go
bin/
@@ -20,6 +21,9 @@ apps/web/.vite/
apps/web/.pnpm-store/
apps/web/vite.config.d.ts
apps/web/vite.config.js
+apps/web/test-results/
+apps/web/playwright-report/
+apps/web/coverage/
# Local runtime data
.cache/
diff --git a/Makefile b/Makefile
index 24eb6e8..e691ce0 100644
--- a/Makefile
+++ b/Makefile
@@ -7,7 +7,7 @@ COMPOSE ?= docker compose
COMPOSE_DEV := $(COMPOSE) -f deployments/docker/compose.dev.yaml
COMPOSE_PROD := $(COMPOSE) -f deployments/docker/compose.yaml
-.PHONY: dev-image worker-image ensure-dev-image dev-shell dev-up dev-down backend-test backend-build frontend-install frontend-build compose-config migrate-dev
+.PHONY: dev-image worker-image ensure-dev-image dev-shell dev-up dev-down backend-test backend-build frontend-install frontend-lint frontend-test frontend-build compose-config migrate-dev
dev-image:
docker build -f deployments/docker/Dockerfile.dev -t $(DEV_IMAGE_TAG) .
@@ -31,17 +31,24 @@ backend-test:
GOCACHE=$${GOCACHE:-$(PWD)/.cache/go-build} go test ./...
backend-build:
- GOCACHE=$${GOCACHE:-$(PWD)/.cache/go-build} go build ./cmd/api ./cmd/mcp-gateway ./cmd/worker-agentd ./cmd/mcxctl
+ GOCACHE=$${GOCACHE:-$(PWD)/.cache/go-build} go build -buildvcs=false ./cmd/api ./cmd/mcp-gateway ./cmd/worker-agentd ./cmd/mcxctl
frontend-install: ensure-dev-image
MULTICODEX_DEV_IMAGE=$(DEV_IMAGE_TAG) $(COMPOSE_DEV) run --rm dev pnpm --dir apps/web install
+frontend-lint: ensure-dev-image
+ MULTICODEX_DEV_IMAGE=$(DEV_IMAGE_TAG) $(COMPOSE_DEV) run --rm dev bash -lc 'pnpm --dir apps/web install --frozen-lockfile && pnpm --dir apps/web lint'
+
+frontend-test: ensure-dev-image
+ MULTICODEX_DEV_IMAGE=$(DEV_IMAGE_TAG) $(COMPOSE_DEV) run --rm dev bash -lc 'pnpm --dir apps/web install --frozen-lockfile && pnpm --dir apps/web test'
+
frontend-build: ensure-dev-image
MULTICODEX_DEV_IMAGE=$(DEV_IMAGE_TAG) $(COMPOSE_DEV) run --rm dev bash -lc 'pnpm --dir apps/web install --frozen-lockfile && pnpm --dir apps/web build'
compose-config:
MULTICODEX_DEV_IMAGE=$(DEV_IMAGE_TAG) $(COMPOSE_DEV) config >/dev/null
POSTGRES_PASSWORD="$${POSTGRES_PASSWORD:-$$(openssl rand -hex 16)}" $(COMPOSE_PROD) config >/dev/null
+ POSTGRES_PASSWORD="$${POSTGRES_PASSWORD:-$$(openssl rand -hex 16)}" $(COMPOSE) -f deployments/docker/compose.yaml -f deployments/docker/compose.ha.yaml config >/dev/null
migrate-dev:
scripts/migrate-dev.sh
diff --git a/README.md b/README.md
index fd68c85..afba531 100644
--- a/README.md
+++ b/README.md
@@ -35,3 +35,9 @@ 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).
+- Frontend track: mature the console into a workbench via [docs/implementation/frontend-workbench-plan.md](docs/implementation/frontend-workbench-plan.md).
diff --git a/apps/web/e2e/smoke.spec.ts b/apps/web/e2e/smoke.spec.ts
new file mode 100644
index 0000000..0e05939
--- /dev/null
+++ b/apps/web/e2e/smoke.spec.ts
@@ -0,0 +1,12 @@
+import { test, expect } from "@playwright/test";
+
+test("login route renders workbench sign-in shell", async ({ page }) => {
+ await page.goto("/login");
+ await expect(page.getByRole("heading", { name: "multi-codex" }).first()).toBeVisible();
+ await expect(page.locator("body")).toContainText(/sign in|登录|Connect|连接/i);
+});
+
+test("unknown protected path redirects unauthenticated users to login", async ({ page }) => {
+ await page.goto("/runs");
+ await expect(page).toHaveURL(/\/login/);
+});
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/index.html b/apps/web/index.html
index 2ba2580..f6fb0f6 100644
--- a/apps/web/index.html
+++ b/apps/web/index.html
@@ -4,6 +4,9 @@
+
+
+
multi-codex
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..99ec9dd 100644
--- a/apps/web/src/app/App.tsx
+++ b/apps/web/src/app/App.tsx
@@ -1,10 +1,18 @@
-import { TaskBoard } from "../features/tasks/TaskBoard";
+import { QueryClientProvider } from "@tanstack/react-query";
+import { RouterProvider } from "react-router-dom";
import { I18nProvider } from "../lib/i18n";
+import { ToastProvider } from "../shared/ui/Toast";
+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..bfcb103
--- /dev/null
+++ b/apps/web/src/app/router.tsx
@@ -0,0 +1,47 @@
+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";
+import { UsagePage } from "../features/usage/UsagePage";
+
+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: "usage", element: protectedElement(protectedRoutePermissions.usage, ) },
+ { 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..0c7bd4c
--- /dev/null
+++ b/apps/web/src/app/shell/AppShell.tsx
@@ -0,0 +1,150 @@
+import { useMemo } from "react";
+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 { ApprovalBell } from "../../shared/ui/ApprovalBell";
+import { navItems } from "./nav";
+
+export function AppShell() {
+ const { t } = useI18n();
+ const {
+ activeProject,
+ activeProjectRole,
+ activeRepository,
+ approvals,
+ auth,
+ authIsError,
+ projects,
+ queue,
+ refreshAll,
+ repositories,
+ runs,
+ selectedProjectId,
+ selectedRepositoryId,
+ setSelectedProjectId,
+ setSelectedRepositoryId,
+ tasks,
+ usersCount
+ } = useWorkbench();
+ const pendingApprovals = useMemo(() => approvals.filter((approval) => approval.status === "pending").length, [approvals]);
+
+ if (!auth && authIsError) {
+ return ;
+ }
+
+ return (
+
+
+
+
+
+
+ {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..22a10c0
--- /dev/null
+++ b/apps/web/src/app/shell/nav.ts
@@ -0,0 +1,46 @@
+import { Activity, BarChart3, 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" | "usage";
+
+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: "usage", labelKey: "nav.usage", path: "/usage", permission: "runs:read", icon: BarChart3 },
+ { 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",
+ usage: "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}
+
+
+ {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}
+
+
+ {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..a1d38d0
--- /dev/null
+++ b/apps/web/src/features/approvals/ApprovalsPage.tsx
@@ -0,0 +1,83 @@
+import { useMemo } from "react";
+import { useMutation, useQuery } from "@tanstack/react-query";
+import { Link } from "react-router-dom";
+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";
+import { useSavedFilter } from "../../shared/lib/useSavedFilter";
+
+const approvalFilters = ["pending", "approved", "rejected", "all"] as const;
+type ApprovalFilter = (typeof approvalFilters)[number];
+
+export function ApprovalsPage() {
+ const { t } = useI18n();
+ const access = useAccess();
+ const [filter, setFilter] = useSavedFilter("approvals-filter", "pending", approvalFilters);
+ const approvals = useQuery({ queryKey: ["approvals"], queryFn: listApprovals, enabled: access.has("projects:read"), refetchInterval: pollWhileHealthy(3_000) });
+ const approvalList = useMemo(() => {
+ const items = approvals.data ?? [];
+ if (filter === "all") {
+ return items;
+ }
+ return items.filter((approval) => approval.status === filter);
+ }, [approvals.data, filter]);
+ 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")}
+
+
+ {approvalFilters.map((option) => (
+
+ ))}
+
+
{t("common.requests", { count: approvalList.length })}
+
+
+
+
+ {approvalList.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 ? (
+
+ ) : (
+
+ )}
+ {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..450372b
--- /dev/null
+++ b/apps/web/src/features/dashboard/DashboardPage.tsx
@@ -0,0 +1,733 @@
+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";
+import { GettingStartedChecklist } from "./GettingStartedChecklist";
+import { useSavedFilter } from "../../shared/lib/useSavedFilter";
+
+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";
+const taskFilters = ["all", "queued", "running", "blocked", "done"] as const;
+
+function ActiveTasksPanel({
+ onSelectTask,
+ selectedTask,
+ tasks
+}: {
+ onSelectTask: (taskId: string) => void;
+ selectedTask?: Task;
+ tasks: Task[];
+}) {
+ const { t } = useI18n();
+ const [filter, setFilter] = useSavedFilter("dashboard-task-filter", "all", taskFilters);
+ 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")}
+
+
+
+
+ {scopeResult ? (
+
+ {t("tasks.filesChecked", { count: scopeResult.changed_files.length })}
+ {scopeResult.violations.length ? scopeResult.violations.join(", ") : t("tasks.noViolations")}
+
+ ) : null}
+
+
+
+
+
+ );
+}
+
+function LifecycleStep({
+ detail,
+ rows,
+ status,
+ title
+}: {
+ detail: string;
+ rows: Array<[string, string]>;
+ status: string;
+ title: string;
+}) {
+ return (
+
+
+
+
+
{title}
+ {detail}
+
+
+
+ {rows.map(([label, value]) => (
+
+
- {label}
+ - {value}
+
+ ))}
+
+
+ );
+}
+
+function EvidenceColumn({
+ auditLogs,
+ latestRun,
+ onSelectView,
+ toolCalls
+}: {
+ auditLogs: AuditLog[];
+ latestRun?: Run;
+ onSelectView: (view: WorkbenchView) => void;
+ toolCalls: ToolCall[];
+}) {
+ const { t } = useI18n();
+ const toolPagination = useListPagination(toolCalls);
+ const auditPagination = useListPagination(auditLogs);
+ return (
+
+
+
+
+
+
{t("dashboard.gateway")}
+
{t("dashboard.toolCalls")}
+
+
+
+
+
+
+
+ {toolCalls.length ? (
+ toolPagination.items.map((call) => (
+
+
+ {call.tool_name}
+ {call.caller}
+
+
+
{call.run_id ?? t("dashboard.gateway")}
+
+ ))
+ ) : (
+
{t("dashboard.noToolCalls")}
+ )}
+
+
+
+
+
+
+
{t("dashboard.evidence")}
+
{t("dashboard.auditTrail")}
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function LiveRunCard({ run }: { run?: Run }) {
+ const { t } = useI18n();
+ const events = useRunEvents(run?.id, run?.status);
+ const eventPagination = useListPagination(events.events, { fromEnd: true });
+
+ return (
+
+
+
+
{t("dashboard.worker")}
+
{run ? t("runs.runTitle", { role: roleLabel(run.role, t) }) : t("dashboard.liveRun")}
+
+
+
+
+
+
+ {run ? (
+ <>
+
+
+
- {t("tasks.executor")}
+ - {run.executor}
+
+
+
- {t("runs.started")}
+ - {formatDate(run.started_at ?? run.created_at, t)}
+
+
+
- {t("lifecycle.node")}
+ - {run.executor_node_id ?? t("common.notAssigned")}
+
+
+
- {t("runs.branch")}
+ - {run.branch ?? t("status.pending")}
+
+
+
+ >
+ ) : (
+ {t("runs.noRuns")}
+ )}
+
+ );
+}
+
+function ArtifactSummary({ run }: { run?: Run }) {
+ const { t } = useI18n();
+ const artifacts = useQuery({
+ queryKey: ["artifacts", run?.id],
+ queryFn: () => listArtifacts(run!.id),
+ enabled: Boolean(run),
+ refetchInterval: pollWhileHealthy(run?.status === "running" ? 2_000 : 5_000)
+ });
+ const artifactList = artifacts.data ?? [];
+ const pagination = useListPagination(artifactList);
+
+ return (
+
+
+
+
{t("dashboard.outputs")}
+
{t("dashboard.artifacts")}
+
+
+ {t("common.files", { count: artifactList.length })}
+
+
+
+
+ {artifactList.length ? (
+ pagination.items.map((artifact) => (
+
+
+ {artifact.name}
+ {artifact.kind}
+
+
{t("common.bytes", { count: artifact.size_bytes ?? 0 })}
+
{artifact.sha256?.slice(0, 12) ?? t("common.noHash")}
+
+ ))
+ ) : (
+
{run ? t("artifacts.noArtifactsYet") : t("artifacts.noRunSelected")}
+ )}
+
+
+ );
+}
+
+function ProjectRepoPanel({ activeProjectId }: { activeProjectId?: string }) {
+ const { t } = useI18n();
+ const access = useAccess();
+ const [projectName, setProjectName] = useState("Platform Engineering");
+ const [projectSlug, setProjectSlug] = useState("platform-engineering");
+ const [repoName, setRepoName] = useState("platform-api");
+ const [remoteURL, setRemoteURL] = useState("file:///workspace/platform-api.git");
+ const createProjectMutation = useMutation({
+ mutationFn: () => createProject({ name: projectName, slug: projectSlug, description: "Managed by multi-codex." }),
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: ["projects"] })
+ });
+ const createRepoMutation = useMutation({
+ mutationFn: () =>
+ createRepository(activeProjectId!, {
+ name: repoName,
+ provider: remoteURL.startsWith("file://") ? "local" : "git",
+ remote_url: remoteURL,
+ default_branch: "main"
+ }),
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: ["repositories", activeProjectId] })
+ });
+
+ return (
+
+
+
{t("dashboard.projectSetup")}
+
+ {!access.has("projects:write") ?
: null}
+
+ {!access.has("repositories:write") ?
: null}
+
+
+ );
+}
+
+function refreshTaskQueries(task?: Task, runId?: string) {
+ void queryClient.invalidateQueries({ queryKey: ["runs"] });
+ void queryClient.invalidateQueries({ queryKey: ["runs", task?.id] });
+ void queryClient.invalidateQueries({ queryKey: ["workflow", task?.id] });
+ void queryClient.invalidateQueries({ queryKey: ["tasks", task?.project_id] });
+ void queryClient.invalidateQueries({ queryKey: ["audit-logs"] });
+ void queryClient.invalidateQueries({ queryKey: ["approvals"] });
+ if (runId) {
+ void queryClient.invalidateQueries({ queryKey: ["run-events", runId] });
+ void queryClient.invalidateQueries({ queryKey: ["artifacts", runId] });
+ }
+}
diff --git a/apps/web/src/features/dashboard/GettingStartedChecklist.tsx b/apps/web/src/features/dashboard/GettingStartedChecklist.tsx
new file mode 100644
index 0000000..def160f
--- /dev/null
+++ b/apps/web/src/features/dashboard/GettingStartedChecklist.tsx
@@ -0,0 +1,74 @@
+import { useMemo, useState } from "react";
+import { Link } from "react-router-dom";
+import { CheckCircle2, Circle } from "lucide-react";
+import { useWorkbench } from "../../app/workbenchContext";
+import { useI18n } from "../../lib/i18n";
+import { readSavedJSON, writeSavedJSON } from "../../shared/lib/savedState";
+import { Button } from "../../shared/ui/Button";
+import { Panel } from "../../shared/ui/Panel";
+
+type ChecklistItem = {
+ id: string;
+ labelKey: string;
+ href: string;
+ done: boolean;
+};
+
+export function GettingStartedChecklist() {
+ const { t } = useI18n();
+ const { activeProject, activeRepository, approvals, runs, tasks } = useWorkbench();
+ const [hidden, setHidden] = useState(() => Boolean(readSavedJSON("getting-started-dismissed", false)));
+
+ const items = useMemo(
+ () => [
+ { id: "project", labelKey: "checklist.project", href: "/", done: Boolean(activeProject) },
+ { id: "repository", labelKey: "checklist.repository", href: "/", done: Boolean(activeRepository) },
+ { id: "task", labelKey: "checklist.task", href: "/tasks", done: tasks.length > 0 },
+ { id: "run", labelKey: "checklist.run", href: "/runs", done: runs.length > 0 },
+ {
+ id: "approval",
+ labelKey: "checklist.approval",
+ href: "/approvals",
+ done: approvals.length > 0
+ },
+ { id: "usage", labelKey: "checklist.usage", href: "/usage", done: runs.some((run) => Boolean(run.finished_at)) }
+ ],
+ [activeProject, activeRepository, approvals.length, runs, tasks.length]
+ );
+
+ if (hidden) {
+ return null;
+ }
+
+ const completed = items.filter((item) => item.done).length;
+
+ return (
+
+
+
+
{t("checklist.eyebrow")}
+
{t("checklist.title")}
+
{t("checklist.progress", { done: completed, total: items.length })}
+
+
+
+
+ {items.map((item) => (
+ -
+ {item.done ? : }
+ {t(item.labelKey)}
+
+ ))}
+
+
+ );
+}
diff --git a/apps/web/src/features/nodes/NodesPage.tsx b/apps/web/src/features/nodes/NodesPage.tsx
new file mode 100644
index 0000000..1cdf1ba
--- /dev/null
+++ b/apps/web/src/features/nodes/NodesPage.tsx
@@ -0,0 +1,101 @@
+import { useState } from "react";
+import { useMutation, useQuery } from "@tanstack/react-query";
+import { queryClient } from "../../app/queryClient";
+import { AccessNotice } from "../../app/AccessPanel";
+import { listExecutorNodes, registerExecutorNode, verifyExecutorNodeHostKey } 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 { submit } from "../../shared/lib/formatting";
+
+export function NodesPage() {
+ const { t } = useI18n();
+ const access = useAccess();
+ const nodes = useQuery({ queryKey: ["executor-nodes"], queryFn: listExecutorNodes, enabled: access.has("nodes:read") });
+ const nodeList = nodes.data ?? [];
+ const pagination = useListPagination(nodeList);
+ const [name, setName] = useState("ssh-worker-1");
+ const [address, setAddress] = useState("codex-worker@example.invalid:22");
+ const [agentDURL, setAgentDURL] = useState("http://worker-agentd-dev:7070");
+ const [fingerprint, setFingerprint] = useState("SHA256:multi-codex-agentd-dev");
+ const [forcedCommand, setForcedCommand] = useState("multi-codex-worker-agentd --forced-command");
+ const mutation = useMutation({
+ mutationFn: () =>
+ registerExecutorNode({
+ kind: "ssh",
+ name,
+ address,
+ agentd_url: agentDURL,
+ host_key_fingerprint: fingerprint,
+ forced_command: forcedCommand,
+ status: "registered"
+ }),
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: ["executor-nodes"] })
+ });
+ const verifyMutation = useMutation({
+ mutationFn: ({ nodeId, observed }: { nodeId: string; observed: string }) => verifyExecutorNodeHostKey(nodeId, observed),
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: ["executor-nodes"] })
+ });
+
+ return (
+
+
+
{t("nodes.executorNodes")}
+
+ {t("common.available", { count: nodeList.length })}
+
+
+
+ {!access.has("nodes:write") ? : null}
+
+
+ {pagination.items.map((node) => (
+
+
+ {node.name}
+ {node.id}
+
+
+
{node.kind}
+
+
+
+
+
{node.agentd_url ?? node.address ?? t("common.noEndpoint")}
+
+ ))}
+
+
+ );
+}
diff --git a/apps/web/src/features/organizations/OrganizationsPage.tsx b/apps/web/src/features/organizations/OrganizationsPage.tsx
new file mode 100644
index 0000000..172ac4b
--- /dev/null
+++ b/apps/web/src/features/organizations/OrganizationsPage.tsx
@@ -0,0 +1,75 @@
+import { useState } from "react";
+import { useMutation } from "@tanstack/react-query";
+import { queryClient } from "../../app/queryClient";
+import { useWorkbench } from "../../app/workbenchContext";
+import { AccessNotice } from "../../app/AccessPanel";
+import { createOrganization } from "../../lib/api";
+import type { Organization } 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 { submit } from "../../shared/lib/formatting";
+
+export function OrganizationsPage() {
+ const { organizations } = useWorkbench();
+ return ;
+}
+
+function OrganizationsView({ organizations }: { organizations: Organization[] }) {
+ const { t } = useI18n();
+ const access = useAccess();
+ const pagination = useListPagination(organizations);
+ const [name, setName] = useState("Engineering");
+ const [slug, setSlug] = useState("engineering");
+ const mutation = useMutation({
+ mutationFn: () => createOrganization({ name, slug }),
+ onSuccess: () => {
+ void queryClient.invalidateQueries({ queryKey: ["organizations"] });
+ void queryClient.invalidateQueries({ queryKey: ["audit-logs"] });
+ }
+ });
+
+ return (
+
+
+
{t("organizations.organizations")}
+
+ {t("common.provisioned", { count: organizations.length })}
+
+
+
+ {!access.has("organizations:write") ? : null}
+
+
+ {organizations.length ? (
+ pagination.items.map((org) => (
+
+
+ {org.name}
+ {org.id}
+
+
+
{org.slug}
+
{new Date(org.created_at).toLocaleString()}
+
+ ))
+ ) : (
+
{t("organizations.empty")}
+ )}
+
+
+ );
+}
diff --git a/apps/web/src/features/queue/QueuePage.tsx b/apps/web/src/features/queue/QueuePage.tsx
new file mode 100644
index 0000000..368062e
--- /dev/null
+++ b/apps/web/src/features/queue/QueuePage.tsx
@@ -0,0 +1,137 @@
+import { useMutation, useQuery } from "@tanstack/react-query";
+import { queryClient } from "../../app/queryClient";
+import { dispatchQueue, getQueueStatus } from "../../lib/api";
+import type { Backpressure, Run } 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";
+import { roleLabel } from "../../shared/lib/formatting";
+
+export function QueuePage() {
+ const { t } = useI18n();
+ const access = useAccess();
+ const queue = useQuery({ queryKey: ["queue"], queryFn: getQueueStatus, enabled: access.has("runs:read"), refetchInterval: pollWhileHealthy(2_000) });
+ 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: ["audit-logs"] });
+ void queryClient.invalidateQueries({ queryKey: ["run-events", payload.run.id] });
+ }
+ });
+ const queuedRuns = queue.data?.queued_runs ?? [];
+ const backpressure = queue.data?.backpressure ?? {};
+ const pagination = useListPagination(queuedRuns);
+
+ return (
+
+
+
+
{t("queue.workerQueue")}
+
+ {queue.isLoading ? t("common.loading") : t("common.recent", { count: queuedRuns.length })}
+
+
+
+
+
+ {queuedRuns.length ? (
+ pagination.items.map((run) => (
+
+
+ {roleLabel(run.role, t)}
+ {run.id}
+
+
+
{run.executor}
+
{queueValue(run, "queued_reason", "queued")}
+
+ {t("queue.priorityAttempt", {
+ priority: queueValue(run, "queue_priority", "0"),
+ attempt: queueValue(run, "retry_attempt", "1"),
+ max: queueValue(run, "max_attempts", "1")
+ })}
+
+
+ ))
+ ) : (
+
{t("queue.noQueued")}
+ )}
+
+
+
+
+
+
{t("queue.backpressure")}
+
+
+ {dispatchMutation.isError ? (
+
{dispatchMutation.error instanceof Error ? dispatchMutation.error.message : t("queue.dispatchFailed")}
+ ) : null}
+
+
+
+
+ );
+}
+
+function BackpressureSection({ title, snapshot }: { title: string; snapshot?: Backpressure }) {
+ const { t } = useI18n();
+ const pagination = useListPagination(snapshot?.nodes ?? []);
+ return (
+
+
+
{title}
+ {snapshot ? (
+
+ {t("common.free", { count: snapshot.available_slots })}
+ {t("common.retrySeconds", { count: snapshot.retry_after_seconds })}
+
+
+ ) : null}
+
+ {snapshot?.nodes.length ? (
+
+ {pagination.items.map((node) => (
+
+
+ {node.name}
+ {node.selection_rank ? `#${node.selection_rank}` : node.ineligible_reason || node.status}
+
+
+
+ {node.active_runs}/{node.concurrency}
+
+ {t("common.free", { count: node.available_slots })}
+ {Math.round(node.utilization * 100)}%
+
+
+
+
+
+
+ ))}
+
+ ) : (
+
{t("queue.noNodes")}
+ )}
+
+ );
+}
+
+function queueValue(run: Run, key: string, fallback: string) {
+ const value = run.result?.[key];
+ if (typeof value === "number" || typeof value === "boolean") {
+ return String(value);
+ }
+ if (typeof value === "string" && value.length > 0) {
+ return value;
+ }
+ return fallback;
+}
diff --git a/apps/web/src/features/runs/EvidenceViewers.tsx b/apps/web/src/features/runs/EvidenceViewers.tsx
new file mode 100644
index 0000000..39903af
--- /dev/null
+++ b/apps/web/src/features/runs/EvidenceViewers.tsx
@@ -0,0 +1,76 @@
+import { useMemo, useRef, useState } from "react";
+import { useI18n } from "../../lib/i18n";
+import { Button } from "../../shared/ui/Button";
+import { cn } from "../../shared/lib/cn";
+
+export function VirtualizedText({
+ text,
+ className,
+ follow = true,
+ empty
+}: {
+ text: string;
+ className?: string;
+ follow?: boolean;
+ empty: string;
+}) {
+ const containerRef = useRef(null);
+ const lines = useMemo(() => (text ? text.split("\n") : []), [text]);
+ const [paused, setPaused] = useState(false);
+ const { t } = useI18n();
+
+ const visible = lines.length > 2000 && !paused ? lines.slice(-2000) : lines;
+
+ return (
+
+
+
{t("common.lines", { count: lines.length })}
+
+
+
+
+
+
{
+ const target = event.currentTarget;
+ const atBottom = target.scrollHeight - target.scrollTop - target.clientHeight < 24;
+ setPaused(!atBottom);
+ }}
+ >
+ {visible.length ? visible.join("\n") : empty}
+
+
+ );
+}
+
+export function DiffViewer({ text, empty }: { text: string; empty: string }) {
+ const lines = useMemo(() => (text ? text.split("\n") : []), [text]);
+ if (!lines.length) {
+ return {empty}
;
+ }
+ return (
+
+ {lines.map((line, index) => {
+ const tone = line.startsWith("+") ? "add" : line.startsWith("-") ? "del" : line.startsWith("@@") ? "hunk" : "ctx";
+ return (
+
+ {line || " "}
+ {"\n"}
+
+ );
+ })}
+
+ );
+}
diff --git a/apps/web/src/features/runs/RunsPage.tsx b/apps/web/src/features/runs/RunsPage.tsx
new file mode 100644
index 0000000..bf97549
--- /dev/null
+++ b/apps/web/src/features/runs/RunsPage.tsx
@@ -0,0 +1,369 @@
+import { useEffect, useMemo, useState } from "react";
+import { useQuery } from "@tanstack/react-query";
+import type { ColumnDef } from "@tanstack/react-table";
+import { Copy } from "lucide-react";
+import { useNavigate, useParams } from "react-router-dom";
+import * as Tabs from "@radix-ui/react-tabs";
+import { getArtifactContent, listAllRuns, listArtifacts } from "../../lib/api";
+import type { Run, RunEvent } from "../../lib/api";
+import { useI18n } from "../../lib/i18n";
+import { useAccess } from "../../lib/permissions";
+import { StatusBadge } from "../../shared/ui/StatusBadge";
+import { Button } from "../../shared/ui/Button";
+import { DataTable } from "../../shared/ui/DataTable";
+import { ListPaginationControl, useListPagination } from "../../shared/lib/listPagination";
+import { roleLabel } from "../../shared/lib/formatting";
+import { pollWhileHealthy } from "../../shared/lib/pollWhileHealthy";
+import { useRunEvents } from "../../shared/hooks/useRunEventStream";
+import type { EventStreamState } from "../../shared/hooks/useRunEventStream";
+import { useToast } from "../../shared/ui/Toast";
+import { useSavedFilter } from "../../shared/lib/useSavedFilter";
+import { DiffViewer, VirtualizedText } from "./EvidenceViewers";
+
+const runFilters = ["all", "running", "queued", "failed", "succeeded"] as const;
+type RunFilter = (typeof runFilters)[number];
+
+export function RunsPage() {
+ const { t } = useI18n();
+ const access = useAccess();
+ const [filter, setFilter] = useSavedFilter("runs-filter", "all", runFilters);
+ const runs = useQuery({ queryKey: ["runs"], queryFn: listAllRuns, enabled: access.has("runs:read"), refetchInterval: pollWhileHealthy(2_000) });
+ const params = useParams();
+ const navigate = useNavigate();
+ const selectedRunId = params.runId;
+ const runList = useMemo(() => {
+ const items = runs.data ?? [];
+ if (filter === "all") {
+ return items;
+ }
+ return items.filter((run) => {
+ if (filter === "running") {
+ return ["running", "preparing", "started"].includes(run.status);
+ }
+ if (filter === "queued") {
+ return ["queued", "pending"].includes(run.status);
+ }
+ if (filter === "failed") {
+ return ["failed", "timed_out", "blocked", "error"].includes(run.status);
+ }
+ return ["succeeded", "completed", "finished"].includes(run.status);
+ });
+ }, [filter, runs.data]);
+ const pagination = useListPagination(runList);
+ const selectedRun = runList.find((run) => run.id === selectedRunId) ?? (selectedRunId ? undefined : runList[0]);
+
+ useEffect(() => {
+ if (!runList.length || selectedRunId) {
+ return;
+ }
+ navigate(`/runs/${runList[0].id}`, { replace: true });
+ }, [navigate, runList, selectedRunId]);
+
+ const columns = useMemo[]>(
+ () => [
+ {
+ id: "role",
+ cell: ({ row }) => (
+
+ {roleLabel(row.original.role, t)}
+ {row.original.id}
+
+ )
+ },
+ {
+ id: "status",
+ cell: ({ row }) =>
+ },
+ {
+ id: "executor",
+ cell: ({ row }) => {row.original.executor}
+ },
+ {
+ id: "meta",
+ cell: ({ row }) => {row.original.executor_node_id || row.original.worktree_path || row.original.task_id}
+ }
+ ],
+ [t]
+ );
+
+ return (
+
+
+
+
{t("nav.runs")}
+
+
+ {runFilters.map((option) => (
+
+ ))}
+
+
{runs.isLoading ? t("common.loading") : t("common.recent", { count: runList.length })}
+
+
+
+
run.id}
+ selectedId={selectedRun?.id}
+ onRowClick={(run) => navigate(`/runs/${run.id}`)}
+ />
+
+
+
+ );
+}
+
+export function RunDetail({ run }: { run?: Run }) {
+ const { t } = useI18n();
+ const toast = useToast();
+ const events = useRunEvents(run?.id, run?.status);
+ const eventPagination = useListPagination(events.events, { fromEnd: true });
+ const artifacts = useQuery({
+ queryKey: ["artifacts", run?.id],
+ queryFn: () => listArtifacts(run!.id),
+ enabled: Boolean(run?.id),
+ refetchInterval: pollWhileHealthy(run?.status === "running" ? 2_000 : 5_000)
+ });
+ const logArtifact = artifacts.data?.find((artifact) => artifact.kind === "worker_log" || artifact.kind === "remote_log");
+ const diffArtifact = artifacts.data?.find((artifact) => artifact.kind === "diff");
+ const logContent = useQuery({
+ queryKey: ["artifact-content", logArtifact?.id],
+ queryFn: () => getArtifactContent(logArtifact!.id),
+ enabled: Boolean(logArtifact?.id),
+ refetchInterval: run?.status === "running" ? pollWhileHealthy(2_000) : false
+ });
+ const diffContent = useQuery({
+ queryKey: ["artifact-content", diffArtifact?.id],
+ queryFn: () => getArtifactContent(diffArtifact!.id),
+ enabled: Boolean(diffArtifact?.id)
+ });
+
+ if (!run) {
+ return (
+
+
{t("runs.selectRun")}
+
+ );
+ }
+
+ const copyId = async (value: string, label: string) => {
+ await navigator.clipboard.writeText(value);
+ toast.push({ title: t("common.copied"), description: label, tone: "success" });
+ };
+
+ return (
+
+
+
+
{t("runs.cockpit")}
+
{t("runs.runTitle", { role: roleLabel(run.role, t) })}
+
+
+
+
+
+
- {t("runs.runId")}
+ -
+
{run.id}
+
+
+
+
+
- {t("runs.taskId")}
+ -
+
{run.task_id}
+
+
+
+
+
- {t("tasks.executor")}
+ - {run.executor}
+
+
+
- {t("runs.executorNode")}
+ - {run.executor_node_id || t("common.notAssigned")}
+
+
+
- {t("runs.branch")}
+ - {run.branch ?? t("common.notAssigned")}
+
+
+
+
+
+
+ {t("runs.tabEvents")}
+
+
+ {t("runs.tabLogs")}
+
+
+ {t("runs.tabDiff")}
+
+
+ {t("runs.tabArtifacts")}
+
+
+ {t("runs.tabResult")}
+
+
+
+
+
{t("runs.events")}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {JSON.stringify(run.result ?? {}, null, 2)}
+
+
+
+ );
+}
+
+export function RunArtifactInspector({ runId, runStatus }: { runId?: string; runStatus?: string }) {
+ const { t } = useI18n();
+ const access = useAccess();
+ const [selectedArtifactId, setSelectedArtifactId] = useState();
+ const artifacts = useQuery({
+ queryKey: ["artifacts", runId],
+ queryFn: () => listArtifacts(runId!),
+ enabled: Boolean(runId) && access.has("runs:read"),
+ refetchInterval: pollWhileHealthy(runStatus === "running" ? 2_000 : 5_000)
+ });
+ const artifactList = useMemo(() => artifacts.data ?? [], [artifacts.data]);
+ const pagination = useListPagination(artifactList);
+ const selectedArtifact = artifactList.find((artifact) => artifact.id === selectedArtifactId);
+ const artifactContent = useQuery({
+ queryKey: ["artifact-content", selectedArtifactId],
+ queryFn: () => getArtifactContent(selectedArtifactId!),
+ enabled: Boolean(selectedArtifactId) && access.has("runs:read"),
+ refetchInterval: selectedArtifact?.kind === "worker_log" && runStatus === "running" ? pollWhileHealthy(2_000) : false
+ });
+
+ useEffect(() => {
+ if (!runId || artifactList.length === 0) {
+ setSelectedArtifactId(undefined);
+ return;
+ }
+ if (selectedArtifactId && artifactList.some((artifact) => artifact.id === selectedArtifactId)) {
+ return;
+ }
+ const preferred =
+ artifactList.find((artifact) => ["worker_log", "diff", "result", "remote_result", "pr_body"].includes(artifact.kind)) ?? artifactList[0];
+ setSelectedArtifactId(preferred.id);
+ }, [artifactList, runId, selectedArtifactId]);
+
+ return (
+
+
+ {t("common.files", { count: artifactList.length })}
+
+
+
+ {artifactList.length ? (
+ pagination.items.map((artifact) => (
+
+ ))
+ ) : (
+
{runId ? t("artifacts.noArtifactsYet") : t("artifacts.noRunSelected")}
+ )}
+
+ {selectedArtifact ? (
+
+
+ {selectedArtifact.name}
+ {artifactContent.data?.content_type ?? selectedArtifact.kind}
+ {artifactContent.data?.truncated ? {t("artifacts.truncated", { bytes: artifactContent.data.limit_bytes })} : null}
+
+ {selectedArtifact.kind === "diff" ? (
+
+ ) : (
+
+ )}
+
+ ) : null}
+
+ );
+}
+
+export function EventList({
+ events,
+ streamState
+}: {
+ events: RunEvent[];
+ streamState?: EventStreamState;
+}) {
+ const { t } = useI18n();
+ const stateLabel = streamState === "live" ? t("runs.live") : streamState === "connecting" ? t("runs.connecting") : streamState === "fallback" ? t("runs.polling") : "";
+ return (
+
+ {stateLabel ?
{stateLabel}
: null}
+ {events.length ? (
+ events.map((event) => (
+
+
{event.seq}
+
{event.event_type}
+
{event.message}
+
+ ))
+ ) : (
+
{t("runs.noEvents")}
+ )}
+
+ );
+}
diff --git a/apps/web/src/features/skills/SkillsPage.tsx b/apps/web/src/features/skills/SkillsPage.tsx
new file mode 100644
index 0000000..220160e
--- /dev/null
+++ b/apps/web/src/features/skills/SkillsPage.tsx
@@ -0,0 +1,228 @@
+import { useEffect, useMemo, 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 { createAgentProfile, createSkill, listAgentProfiles, listSkillVersions, listSkills } from "../../lib/api";
+import type { AgentProfile, SkillVersion } 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 { submit } from "../../shared/lib/formatting";
+
+export function SkillsPage() {
+ const { activeProject } = useWorkbench();
+ return ;
+}
+
+function SkillsView({ projectId }: { projectId?: string }) {
+ const { t } = useI18n();
+ const access = useAccess();
+ const skills = useQuery({ queryKey: ["skills"], queryFn: listSkills, enabled: access.has("projects:read") });
+ const skillList = useMemo(() => skills.data ?? [], [skills.data]);
+ const [selectedSkillId, setSelectedSkillId] = useState();
+ const skillPagination = useListPagination(skillList);
+ const selectedSkill = skillList.find((skill) => skill.id === selectedSkillId) ?? skillList[0];
+ const versions = useQuery({
+ queryKey: ["skill-versions", selectedSkill?.id],
+ queryFn: () => listSkillVersions(selectedSkill!.id),
+ enabled: Boolean(selectedSkill) && access.has("projects:read")
+ });
+ const profiles = useQuery({
+ queryKey: ["agent-profiles", projectId],
+ queryFn: () => listAgentProfiles(projectId!),
+ enabled: Boolean(projectId) && access.has("projects:read")
+ });
+ const profilePagination = useListPagination(profiles.data ?? []);
+ const [skillName, setSkillName] = useState("company-docs-worker");
+ const [skillRole, setSkillRole] = useState("docs");
+ const [profileName, setProfileName] = useState("docs-worker");
+ const [profileRole, setProfileRole] = useState("docs");
+ const [profileNetworkEnabled, setProfileNetworkEnabled] = useState(false);
+ const [profileSecretEnv, setProfileSecretEnv] = useState("");
+ const requestedSecretEnv = parseListInput(profileSecretEnv);
+ const skillMutation = useMutation({
+ mutationFn: () =>
+ createSkill({
+ name: skillName,
+ role: skillRole,
+ description: "Registered from the Web Console.",
+ version: "local",
+ path: `skills/${skillName}/SKILL.md`
+ }),
+ onSuccess: () => {
+ void queryClient.invalidateQueries({ queryKey: ["skills"] });
+ void queryClient.invalidateQueries({ queryKey: ["skill-versions"] });
+ }
+ });
+ const profileMutation = useMutation({
+ mutationFn: () =>
+ createAgentProfile(projectId!, {
+ name: profileName,
+ role: profileRole,
+ model: "gpt-5",
+ sandbox_mode: "workspace-write",
+ approval_policy: "never",
+ executor: "docker",
+ image: "multi-codex/codex-worker:go1.25-node-vite8",
+ network_enabled: profileNetworkEnabled,
+ config: requestedSecretEnv.length ? { worker_secret_env: requestedSecretEnv } : {}
+ } as Omit),
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: ["agent-profiles", projectId] })
+ });
+
+ useEffect(() => {
+ if (skillList.length === 0) {
+ setSelectedSkillId(undefined);
+ return;
+ }
+ if (!selectedSkillId || !skillList.some((skill) => skill.id === selectedSkillId)) {
+ setSelectedSkillId(skillList[0].id);
+ }
+ }, [skillList, selectedSkillId]);
+
+ return (
+
+
+
+
{t("skills.skills")}
+
+ {t("common.registered", { count: skills.data?.length ?? 0 })}
+
+
+
+ {!access.has("skills:write") ?
: null}
+
+
+ {skillPagination.items.map((skill) => (
+
+ ))}
+
+
+
+
+
+
+
{t("skills.agentProfiles")}
+
+ {t("common.profiles", { count: profiles.data?.length ?? 0 })}
+
+
+
+ {!access.has("projects:write") ?
: null}
+
+
+ {profilePagination.items.map((profile) => (
+
+
+ {profile.name}
+ {profile.model}
+
+
+
{profile.role}
+
{profile.network_enabled ? t("skills.networkOn") : t("skills.networkOff")}
+
{profileSecretEnvLabel(profile, t)}
+
+ ))}
+
+
+
+ );
+}
+
+function SkillVersionList({ versions }: { versions: SkillVersion[] }) {
+ const { t } = useI18n();
+ const pagination = useListPagination(versions);
+ return (
+
+
+ {t("common.total", { count: versions.length })}
+
+
+
+ {versions.length ? (
+ pagination.items.map((version) => (
+
+ {version.version}
+ {version.content_hash}
+ {version.path}
+
+ ))
+ ) : (
+
{t("skills.noVersions")}
+ )}
+
+
+ );
+}
+
+function parseListInput(value: string): string[] {
+ const seen = new Set();
+ const values: string[] = [];
+ for (const item of value.split(/[,\s;]+/)) {
+ const trimmed = item.trim();
+ if (!trimmed || seen.has(trimmed)) {
+ continue;
+ }
+ seen.add(trimmed);
+ values.push(trimmed);
+ }
+ return values;
+}
+
+function profileSecretEnvLabel(profile: AgentProfile, t: (key: string) => string): string {
+ const value = profile.config["worker_secret_env"] ?? profile.config["secret_env"];
+ if (Array.isArray(value)) {
+ const names = value.filter((item): item is string => typeof item === "string");
+ return names.length ? names.join(", ") : t("common.noSecretRefs");
+ }
+ if (typeof value === "string" && value.trim()) {
+ return value;
+ }
+ return t("common.noSecretRefs");
+}
diff --git a/apps/web/src/features/tasks/TaskBoard.tsx b/apps/web/src/features/tasks/TaskBoard.tsx
deleted file mode 100644
index 9b5194d..0000000
--- a/apps/web/src/features/tasks/TaskBoard.tsx
+++ /dev/null
@@ -1,3052 +0,0 @@
-import { FormEvent, useEffect, useMemo, useState } from "react";
-import { useMutation, useQuery } from "@tanstack/react-query";
-import { queryClient } from "../../app/queryClient";
-import { StatusBadge } from "../../components/StatusBadge";
-import {
- Backpressure,
- beginOIDCLogin,
- clearAuthToken,
- createAgentProfile,
- createBrowserSession,
- createOrganization,
- createProject,
- createRepository,
- createSkill,
- createUser,
- createTask,
- decideApproval,
- dispatchQueue,
- getArtifactContent,
- getAuthCapabilities,
- getAuthContext,
- getAuthToken,
- getQueueStatus,
- getWorkflow,
- listAgentProfiles,
- listAllRuns,
- listApprovals,
- listArtifacts,
- listAuditLogs,
- listExecutorNodes,
- listOrganizations,
- listProjectMembers,
- listProjects,
- listRepositories,
- listRunEvents,
- listRuns,
- listSkillVersions,
- listSkills,
- listTasks,
- listToolCalls,
- listUsers,
- loginWithPassword,
- logout,
- parseRunEventPayload,
- registerExecutorNode,
- requestApproval,
- runEventStreamURL,
- runWorkflowAction,
- scopeCheck,
- startTask,
- setAuthToken,
- upsertProjectMember,
- verifyExecutorNodeHostKey
-} from "../../lib/api";
-import type {
- AgentProfile,
- Approval,
- AuditLog,
- AuthCapabilities,
- AuthContext,
- Organization,
- Project,
- ProjectMembership,
- QueueSnapshot,
- Repository,
- Run,
- RunEvent,
- SkillVersion,
- Task,
- ToolCall,
- UserDirectory
-} from "../../lib/api";
-import { LanguageToggle, useI18n } from "../../lib/i18n";
-import { AccessProvider, hasPermission, projectRole, useAccess, visiblePermissions } from "../../lib/permissions";
-import type { Permission } from "../../lib/permissions";
-
-type View = "dashboard" | "tasks" | "runs" | "queue" | "approvals" | "nodes" | "organizations" | "skills" | "admin" | "audit";
-type ListLimit = 10 | 20 | 50;
-type ListPager = {
- hasNext: boolean;
- hasPrevious: boolean;
- limit: ListLimit;
- page: number;
- pageCount: number;
- setLimit: (value: ListLimit) => void;
- goNext: () => void;
- goPrevious: () => void;
- total: number;
-};
-type ListPagination = ListPager & { items: T[] };
-
-const listLimitOptions = [10, 20, 50] as const;
-
-const navItems: Array<{ id: View; labelKey: string; permission: Permission }> = [
- { id: "dashboard", labelKey: "nav.dashboard", permission: "projects:read" },
- { id: "tasks", labelKey: "nav.tasks", permission: "projects:read" },
- { id: "runs", labelKey: "nav.runs", permission: "runs:read" },
- { id: "queue", labelKey: "nav.queue", permission: "runs:read" },
- { id: "approvals", labelKey: "nav.approvals", permission: "projects:read" },
- { id: "nodes", labelKey: "nav.nodes", permission: "nodes:read" },
- { id: "organizations", labelKey: "nav.organizations", permission: "organizations:read" },
- { id: "skills", labelKey: "nav.skills", permission: "projects:read" },
- { id: "admin", labelKey: "nav.admin", permission: "users:read" },
- { id: "audit", labelKey: "nav.audit", permission: "audit:read" }
-];
-
-function ListLimitControl({
- label,
- onChange,
- value
-}: {
- label?: string;
- onChange: (value: ListLimit) => void;
- value: ListLimit;
-}) {
- const { t } = useI18n();
- return (
-
- {t("common.show")}
- {listLimitOptions.map((option) => (
-
- ))}
-
- );
-}
-
-function ListPaginationControl({ label, pagination }: { label?: string; pagination: ListPager }) {
- const { t } = useI18n();
- return (
-
-
-
-
- {t("common.pageStatus", { page: pagination.page, pages: pagination.pageCount })}
-
-
-
- );
-}
-
-function useListPagination(items: T[], options: { fromEnd?: boolean } = {}): ListPagination {
- const [limit, setLimitState] = useState(10);
- const [page, setPage] = useState(1);
- const total = items.length;
- const pageCount = Math.max(1, Math.ceil(total / limit));
- const normalizedPage = Math.min(page, pageCount);
-
- useEffect(() => {
- setPage((current) => Math.min(Math.max(current, 1), pageCount));
- }, [pageCount]);
-
- const visibleItems = useMemo(() => {
- if (!total) {
- return [];
- }
- if (options.fromEnd) {
- const end = Math.max(total - (normalizedPage - 1) * limit, 0);
- const start = Math.max(end - limit, 0);
- return items.slice(start, end);
- }
- const start = (normalizedPage - 1) * limit;
- return items.slice(start, start + limit);
- }, [items, limit, normalizedPage, options.fromEnd, total]);
-
- return {
- hasNext: normalizedPage < pageCount,
- hasPrevious: normalizedPage > 1,
- items: visibleItems,
- limit,
- page: normalizedPage,
- pageCount,
- setLimit: (nextLimit: ListLimit) => {
- setLimitState(nextLimit);
- setPage(1);
- },
- goNext: () => setPage((current) => Math.min(current + 1, pageCount)),
- goPrevious: () => setPage((current) => Math.max(current - 1, 1)),
- total
- };
-}
-
-export function TaskBoard() {
- const { t } = useI18n();
- const [view, setView] = useHashView();
- const [path, setPath] = useState(() => window.location.pathname);
- 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 isLoginRoute = path === "/login";
- 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 navigate = (nextPath: string) => {
- if (window.location.pathname !== nextPath) {
- window.history.pushState(null, "", nextPath);
- }
- setPath(window.location.pathname);
- };
- const logoutMutation = useMutation({
- mutationFn: logout,
- onSettled: () => {
- clearAuthToken();
- setTokenDraft("");
- queryClient.invalidateQueries();
- navigate("/login");
- }
- });
- const connectMutation = useMutation({
- mutationFn: createBrowserSession,
- onSuccess: (nextAuth) => {
- clearAuthToken();
- setTokenDraft("");
- queryClient.setQueryData(["auth"], nextAuth);
- queryClient.invalidateQueries();
- navigate("/");
- }
- });
- const passwordLoginMutation = useMutation({
- mutationFn: loginWithPassword,
- onSuccess: (nextAuth) => {
- clearAuthToken();
- setTokenDraft("");
- queryClient.setQueryData(["auth"], nextAuth);
- queryClient.invalidateQueries();
- navigate("/");
- }
- });
- 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(() => {
- const onPopState = () => setPath(window.location.pathname);
- window.addEventListener("popstate", onPopState);
- return () => window.removeEventListener("popstate", onPopState);
- }, []);
-
- 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]);
-
- useEffect(() => {
- if (auth.data && isLoginRoute) {
- navigate("/");
- }
- if (auth.isError && !isLoginRoute) {
- navigate("/login");
- }
- }, [auth.data, auth.isError, isLoginRoute]);
-
- const activeNavItem = navItems.find((item) => item.id === view) ?? navItems[0];
- const canViewActive = isAuthenticated && hasPermission(auth.data, activeNavItem.permission);
- const activeProjectRole = projectRole(auth.data, activeProject?.id);
- const saveToken = () => {
- const trimmed = tokenDraft.trim();
- if (trimmed) {
- connectMutation.mutate(trimmed);
- } else {
- setAuthToken("");
- queryClient.invalidateQueries();
- }
- };
-
- return (
-
- {isLoginRoute ? (
- passwordLoginMutation.mutate({ email: loginEmail, password: loginPassword })}
- onOpenConsole={() => navigate("/")}
- onSaveToken={saveToken}
- tokenDraft={tokenDraft}
- onTokenDraftChange={setTokenDraft}
- />
- ) : (
-
-
-
-
-
-
-
-
-
- {activeProjectRole ? t("topbar.projectRole") : t("topbar.branch")}
- {activeProjectRole ? roleLabel(activeProjectRole, t) : activeRepository?.default_branch || "main"}
-
-
-
-
-
-
-
-
- {auth.data ? (
-
- ) : null}
-
-
- {!auth.data ? (
-
- ) : !canViewActive ? (
-
- ) : view === "dashboard" ? (
-
- ) : null}
-
- {auth.data && canViewActive && view === "tasks" ? (
-
- ) : null}
-
- {auth.data && canViewActive && view === "runs" ?
: null}
- {auth.data && canViewActive && view === "queue" ?
: null}
- {auth.data && canViewActive && view === "approvals" ?
: null}
- {auth.data && canViewActive && view === "nodes" ?
: null}
- {auth.data && canViewActive && view === "organizations" ?
: null}
- {auth.data && canViewActive && view === "skills" ?
: null}
- {auth.data && canViewActive && view === "admin" ? (
-
- ) : null}
- {auth.data && canViewActive && view === "audit" ?
: null}
-
-
-
- )}
-
- );
-}
-
-function AuthControls({
- auth,
- capabilities,
- authError,
- isConnecting,
- isLoggingOut,
- onLogout,
- onOIDCLogin,
- onSaveToken,
- tokenDraft,
- onTokenDraftChange
-}: {
- auth?: AuthContext;
- capabilities?: AuthCapabilities;
- authError: Error | null;
- isConnecting: boolean;
- isLoggingOut: boolean;
- onLogout: () => void;
- onOIDCLogin: () => void;
- onSaveToken: () => void;
- tokenDraft: string;
- onTokenDraftChange: (value: string) => void;
-}) {
- const { t } = useI18n();
- 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 ? (
-
onTokenDraftChange(event.target.value)}
- />
- ) : null}
-
- {isOIDC ? (
- <>
-
-
- >
- ) : null}
-
-
-
- );
-}
-
-function LoginPage({
- auth,
- capabilities,
- authError,
- isConnecting,
- isPasswordLoginPending,
- loginEmail,
- loginPassword,
- onLoginEmailChange,
- onLoginPasswordChange,
- onPasswordLogin,
- onOIDCLogin,
- onOpenConsole,
- onSaveToken,
- tokenDraft,
- onTokenDraftChange
-}: {
- auth?: AuthContext;
- capabilities?: 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;
-}) {
- 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
-}: {
- auth?: AuthContext;
- capabilities?: 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;
-}) {
- 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 ? (
-
- ) : (
-
- )}
- {isOIDC ? (
-
- {t("auth.advancedLogin")}
-
-
-
-
-
- ) : null}
-
-
- );
-}
-
-function AccessPanel({ permission }: { permission: Permission | string }) {
- const { t } = useI18n();
- return (
-
- {t("access.lockedTitle")}
- {t("access.lockedTitle")}
- {t("access.lockedBody")}
- {t("access.missing", { permission })}
-
- );
-}
-
-function AccessNotice({ permission }: { permission: Permission | string }) {
- const { t } = useI18n();
- return (
-
- {t("access.writeLocked")}
- {t("access.missing", { permission })}
-
- );
-}
-
-function useHashView(): [View, (view: View) => void] {
- const readHash = () => normalizeView(window.location.hash.replace("#", ""));
- const [view, setViewState] = useState(readHash);
-
- useEffect(() => {
- const onHashChange = () => setViewState(readHash());
- window.addEventListener("hashchange", onHashChange);
- if (!window.location.hash) {
- window.history.replaceState(null, "", "#dashboard");
- }
- return () => window.removeEventListener("hashchange", onHashChange);
- }, []);
-
- const setView = (nextView: View) => {
- setViewState(nextView);
- if (window.location.hash !== `#${nextView}`) {
- window.location.hash = nextView;
- }
- };
-
- return [view, setView];
-}
-
-function normalizeView(value: string): View {
- return navItems.some((item) => item.id === value) ? (value as View) : "dashboard";
-}
-
-function Metric({ label, value }: { label: string; value: number }) {
- return (
-
- {label}
- {value}
-
- );
-}
-
-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: View) => 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: View) => 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: View) => 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")}
-
-
-
-
- {scopeResult ? (
-
- {t("tasks.filesChecked", { count: scopeResult.changed_files.length })}
- {scopeResult.violations.length ? scopeResult.violations.join(", ") : t("tasks.noViolations")}
-
- ) : null}
-
-
-
-
-
- );
-}
-
-function LifecycleStep({
- detail,
- rows,
- status,
- title
-}: {
- detail: string;
- rows: Array<[string, string]>;
- status: string;
- title: string;
-}) {
- return (
-
-
-
-
-
{title}
- {detail}
-
-
-
- {rows.map(([label, value]) => (
-
-
- {label}
- - {value}
-
- ))}
-
-
- );
-}
-
-function EvidenceColumn({
- auditLogs,
- latestRun,
- onSelectView,
- toolCalls
-}: {
- auditLogs: AuditLog[];
- latestRun?: Run;
- onSelectView: (view: View) => void;
- toolCalls: ToolCall[];
-}) {
- const { t } = useI18n();
- const toolPagination = useListPagination(toolCalls);
- const auditPagination = useListPagination(auditLogs);
- return (
-
-
-
-
-
-
{t("dashboard.gateway")}
-
{t("dashboard.toolCalls")}
-
-
-
-
-
-
-
- {toolCalls.length ? (
- toolPagination.items.map((call) => (
-
-
- {call.tool_name}
- {call.caller}
-
-
-
{call.run_id ?? t("dashboard.gateway")}
-
- ))
- ) : (
-
{t("dashboard.noToolCalls")}
- )}
-
-
-
-
-
-
-
{t("dashboard.evidence")}
-
{t("dashboard.auditTrail")}
-
-
-
-
-
-
-
-
-
- );
-}
-
-function LiveRunCard({ run }: { run?: Run }) {
- const { t } = useI18n();
- const events = useRunEvents(run?.id, run?.status);
- const eventPagination = useListPagination(events.events, { fromEnd: true });
-
- return (
-
-
-
-
{t("dashboard.worker")}
-
{run ? t("runs.runTitle", { role: roleLabel(run.role, t) }) : t("dashboard.liveRun")}
-
-
-
-
-
-
- {run ? (
- <>
-
-
-
- {t("tasks.executor")}
- - {run.executor}
-
-
-
- {t("runs.started")}
- - {formatDate(run.started_at ?? run.created_at, t)}
-
-
-
- {t("lifecycle.node")}
- - {run.executor_node_id ?? t("common.notAssigned")}
-
-
-
- {t("runs.branch")}
- - {run.branch ?? t("status.pending")}
-
-
-
- >
- ) : (
- {t("runs.noRuns")}
- )}
-
- );
-}
-
-function ArtifactSummary({ run }: { run?: Run }) {
- const { t } = useI18n();
- const artifacts = useQuery({
- queryKey: ["artifacts", run?.id],
- queryFn: () => listArtifacts(run!.id),
- enabled: Boolean(run),
- refetchInterval: pollWhileHealthy(run?.status === "running" ? 2_000 : 5_000)
- });
- const artifactList = artifacts.data ?? [];
- const pagination = useListPagination(artifactList);
-
- return (
-
-
-
-
{t("dashboard.outputs")}
-
{t("dashboard.artifacts")}
-
-
- {t("common.files", { count: artifactList.length })}
-
-
-
-
- {artifactList.length ? (
- pagination.items.map((artifact) => (
-
-
- {artifact.name}
- {artifact.kind}
-
-
{t("common.bytes", { count: artifact.size_bytes ?? 0 })}
-
{artifact.sha256?.slice(0, 12) ?? t("common.noHash")}
-
- ))
- ) : (
-
{run ? t("artifacts.noArtifactsYet") : t("artifacts.noRunSelected")}
- )}
-
-
- );
-}
-
-function ProjectRepoPanel({ activeProjectId }: { activeProjectId?: string }) {
- const { t } = useI18n();
- const access = useAccess();
- const [projectName, setProjectName] = useState("Platform Engineering");
- const [projectSlug, setProjectSlug] = useState("platform-engineering");
- const [repoName, setRepoName] = useState("platform-api");
- const [remoteURL, setRemoteURL] = useState("file:///workspace/platform-api.git");
- const createProjectMutation = useMutation({
- mutationFn: () => createProject({ name: projectName, slug: projectSlug, description: "Managed by multi-codex." }),
- onSuccess: () => queryClient.invalidateQueries({ queryKey: ["projects"] })
- });
- const createRepoMutation = useMutation({
- mutationFn: () =>
- createRepository(activeProjectId!, {
- name: repoName,
- provider: remoteURL.startsWith("file://") ? "local" : "git",
- remote_url: remoteURL,
- default_branch: "main"
- }),
- onSuccess: () => queryClient.invalidateQueries({ queryKey: ["repositories", activeProjectId] })
- });
-
- return (
-
-
-
{t("dashboard.projectSetup")}
-
- {!access.has("projects:write") ?
: null}
-
- {!access.has("repositories:write") ?
: null}
-
-
- );
-}
-
-function TasksView({
- activeProject,
- activeRepository,
- onSelectTask,
- selectedTask,
- tasks
-}: {
- activeProject?: Project;
- activeRepository?: Repository;
- onSelectTask: (taskId: string) => void;
- selectedTask?: Task;
- tasks: Task[];
-}) {
- const { t } = useI18n();
- const pagination = useListPagination(tasks);
- return (
-
-
-
-
{t("nav.tasks")}
-
- {t("common.total", { count: tasks.length })}
-
-
-
-
-
- {tasks.length ? (
- pagination.items.map((task) => (
-
onSelectTask(task.id)} />
- ))
- ) : (
- {t("tasks.noTasks")}
- )}
-
-
-
-
-
- );
-}
-
-function TaskCreateForm({ activeProject, activeRepository }: { activeProject?: Project; activeRepository?: Repository }) {
- const { t } = useI18n();
- const access = useAccess();
- const [title, setTitle] = useState(t("tasks.defaultTitle"));
- const [allowedPaths, setAllowedPaths] = useState("internal/**\napps/web/src/**\ndocs/**");
- const [objective, setObjective] = useState(t("tasks.defaultObjective"));
- const mutation = useMutation({
- mutationFn: () =>
- createTask(activeProject!, activeRepository!, {
- title,
- allowed_paths: lines(allowedPaths),
- objective
- }),
- onSuccess: (task) => {
- void queryClient.invalidateQueries({ queryKey: ["tasks", activeProject?.id] });
- void queryClient.invalidateQueries({ queryKey: ["audit-logs"] });
- window.location.hash = "tasks";
- return task;
- }
- });
-
- return (
-
- );
-}
-
-function TaskRow({ task, active, onSelect }: { task: Task; active: boolean; onSelect: () => void }) {
- return (
-
- );
-}
-
-function TaskDetail({ task }: { task?: Task }) {
- const { t } = useI18n();
- const access = useAccess();
- const [scopeInput, setScopeInput] = useState("internal/policy/scope.go\ndocs/implementation/roadmap.md");
- const [scopeResult, setScopeResult] = useState<{ status: string; changed_files: string[]; violations: string[] } | undefined>();
- const runs = useQuery({
- queryKey: ["runs", task?.id],
- queryFn: () => listRuns(task!.id),
- enabled: Boolean(task) && access.has("runs:read"),
- refetchInterval: pollWhileHealthy(2_000)
- });
- const workflow = useQuery({
- queryKey: ["workflow", task?.id],
- queryFn: () => getWorkflow(task!.id),
- enabled: Boolean(task) && access.has("projects:read"),
- refetchInterval: pollWhileHealthy(2_000)
- });
- const latestRun = runs.data?.[runs.data.length - 1];
- const events = useRunEvents(latestRun?.id, latestRun?.status);
- const runPagination = useListPagination(runs.data ?? []);
- const eventPagination = useListPagination(events.events, { fromEnd: true });
- 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)
- });
- const publishApprovalMutation = useMutation({
- mutationFn: () => requestApproval(task!.id, { approval_type: "pr_publish", reason: "PR publish preparation requires explicit human approval." }),
- onSuccess: () => refreshTaskQueries(task)
- });
-
- if (!task) {
- return (
-
-
{t("tasks.selectTask")}
-
- );
- }
-
- return (
-
-
-
{task.title}
-
-
-
-
-
-
-
-
- {t("tasks.taskKey")}
- - {task.task_key}
-
-
-
- {t("tasks.repository")}
- - {task.repository_id}
-
-
-
- {t("tasks.role")}
- - {roleLabel(String(task.envelope.role ?? "feature"), t)}
-
-
-
- {t("tasks.executor")}
- - {String(task.envelope.executor ?? "docker")}
-
-
-
-
{t("tasks.workflowGates")}
-
- {workflow.data?.blocked_reasons?.length ? (
- workflow.data.blocked_reasons.map((reason) => {reason})
- ) : (
- {t("tasks.noActiveBlockers")}
- )}
- {workflow.data?.next_actions?.map((action) => (
-
- ))}
-
-
-
-
-
{t("tasks.scopeCheck")}
-
-
- {scopeResult ? (
-
-
- {t("tasks.filesChecked", { count: scopeResult.changed_files.length })}
- {scopeResult.violations.length ? {scopeResult.violations.join(", ")} : {t("tasks.noViolations")}}
-
- ) : null}
-
-
-
{t("tasks.runs")}
-
-
-
- {!access.has("runs:read") ?
: null}
- {runs.data?.length ? (
- runPagination.items.map((run) => (
-
- {roleLabel(run.role, t)}
-
- {run.executor}
- {run.executor_node_id || t("common.notAssigned")}
-
- ))
- ) : (
-
{t("runs.noTaskRuns")}
- )}
-
-
-
-
{t("tasks.latestRunEvents")}
-
-
-
-
-
{t("tasks.latestRunArtifacts")}
-
-
-
{t("tasks.envelope")}
-
{JSON.stringify(task.envelope, null, 2)}
-
- );
-}
-
-function RunsView() {
- const { t } = useI18n();
- const access = useAccess();
- const runs = useQuery({ queryKey: ["runs"], queryFn: listAllRuns, enabled: access.has("runs:read"), refetchInterval: pollWhileHealthy(2_000) });
- const [selectedRunId, setSelectedRunId] = useState();
- const runList = useMemo(() => runs.data ?? [], [runs.data]);
- const pagination = useListPagination(runList);
- const selectedRun = runList.find((run) => run.id === selectedRunId) ?? runList[0];
-
- useEffect(() => {
- if (runList.length === 0) {
- setSelectedRunId(undefined);
- return;
- }
- if (!selectedRunId || !runList.some((run) => run.id === selectedRunId)) {
- setSelectedRunId(runList[0].id);
- }
- }, [runList, selectedRunId]);
-
- return (
-
-
-
-
{t("nav.runs")}
-
- {runs.isLoading ? t("common.loading") : t("common.recent", { count: runList.length })}
-
-
-
-
- {runList.length ? (
- pagination.items.map((run) => (
-
- ))
- ) : (
-
{t("runs.noRuns")}
- )}
-
-
-
-
- );
-}
-
-function QueueView() {
- const { t } = useI18n();
- const access = useAccess();
- const queue = useQuery({ queryKey: ["queue"], queryFn: getQueueStatus, enabled: access.has("runs:read"), refetchInterval: pollWhileHealthy(2_000) });
- 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: ["audit-logs"] });
- void queryClient.invalidateQueries({ queryKey: ["run-events", payload.run.id] });
- }
- });
- const queuedRuns = queue.data?.queued_runs ?? [];
- const backpressure = queue.data?.backpressure ?? {};
- const pagination = useListPagination(queuedRuns);
-
- return (
-
-
-
-
{t("queue.workerQueue")}
-
- {queue.isLoading ? t("common.loading") : t("common.recent", { count: queuedRuns.length })}
-
-
-
-
-
- {queuedRuns.length ? (
- pagination.items.map((run) => (
-
-
- {roleLabel(run.role, t)}
- {run.id}
-
-
-
{run.executor}
-
{queueValue(run, "queued_reason", "queued")}
-
- {t("queue.priorityAttempt", {
- priority: queueValue(run, "queue_priority", "0"),
- attempt: queueValue(run, "retry_attempt", "1"),
- max: queueValue(run, "max_attempts", "1")
- })}
-
-
- ))
- ) : (
-
{t("queue.noQueued")}
- )}
-
-
-
-
-
-
{t("queue.backpressure")}
-
-
- {dispatchMutation.isError ? (
-
{dispatchMutation.error instanceof Error ? dispatchMutation.error.message : t("queue.dispatchFailed")}
- ) : null}
-
-
-
-
- );
-}
-
-function BackpressureSection({ title, snapshot }: { title: string; snapshot?: Backpressure }) {
- const { t } = useI18n();
- const pagination = useListPagination(snapshot?.nodes ?? []);
- return (
-
-
-
{title}
- {snapshot ? (
-
- {t("common.free", { count: snapshot.available_slots })}
- {t("common.retrySeconds", { count: snapshot.retry_after_seconds })}
-
-
- ) : null}
-
- {snapshot?.nodes.length ? (
-
- {pagination.items.map((node) => (
-
-
- {node.name}
- {node.selection_rank ? `#${node.selection_rank}` : node.ineligible_reason || node.status}
-
-
-
- {node.active_runs}/{node.concurrency}
-
- {t("common.free", { count: node.available_slots })}
- {Math.round(node.utilization * 100)}%
-
-
-
-
-
-
- ))}
-
- ) : (
-
{t("queue.noNodes")}
- )}
-
- );
-}
-
-function queueValue(run: Run, key: string, fallback: string) {
- const value = run.result?.[key];
- if (typeof value === "number" || typeof value === "boolean") {
- return String(value);
- }
- if (typeof value === "string" && value.length > 0) {
- return value;
- }
- return fallback;
-}
-
-function RunDetail({ run }: { run?: Run }) {
- const { t } = useI18n();
- const events = useRunEvents(run?.id, run?.status);
- const eventPagination = useListPagination(events.events, { fromEnd: true });
-
- if (!run) {
- return (
-
-
{t("runs.selectRun")}
-
- );
- }
-
- return (
-
-
-
{t("runs.runTitle", { role: roleLabel(run.role, t) })}
-
-
-
-
-
- {t("runs.runId")}
- - {run.id}
-
-
-
- {t("runs.taskId")}
- - {run.task_id}
-
-
-
- {t("tasks.executor")}
- - {run.executor}
-
-
-
- {t("runs.executorNode")}
- - {run.executor_node_id || t("common.notAssigned")}
-
-
-
- {t("runs.branch")}
- - {run.branch ?? t("common.notAssigned")}
-
-
-
-
-
{t("runs.events")}
-
-
-
-
-
{t("runs.artifacts")}
-
-
-
{t("runs.result")}
-
{JSON.stringify(run.result ?? {}, null, 2)}
-
- );
-}
-
-function RunArtifactInspector({ runId, runStatus }: { runId?: string; runStatus?: string }) {
- const { t } = useI18n();
- const access = useAccess();
- const [selectedArtifactId, setSelectedArtifactId] = useState();
- const artifacts = useQuery({
- queryKey: ["artifacts", runId],
- queryFn: () => listArtifacts(runId!),
- enabled: Boolean(runId) && access.has("runs:read"),
- refetchInterval: pollWhileHealthy(runStatus === "running" ? 2_000 : 5_000)
- });
- const artifactList = useMemo(() => artifacts.data ?? [], [artifacts.data]);
- const pagination = useListPagination(artifactList);
- const selectedArtifact = artifactList.find((artifact) => artifact.id === selectedArtifactId);
- const artifactContent = useQuery({
- queryKey: ["artifact-content", selectedArtifactId],
- queryFn: () => getArtifactContent(selectedArtifactId!),
- enabled: Boolean(selectedArtifactId) && access.has("runs:read"),
- refetchInterval:
- selectedArtifact?.kind === "worker_log" && runStatus === "running" ? pollWhileHealthy(2_000) : false
- });
-
- useEffect(() => {
- if (!runId || artifactList.length === 0) {
- setSelectedArtifactId(undefined);
- return;
- }
- if (selectedArtifactId && artifactList.some((artifact) => artifact.id === selectedArtifactId)) {
- return;
- }
- const preferred =
- artifactList.find((artifact) => ["worker_log", "diff", "result", "remote_result", "pr_body"].includes(artifact.kind)) ?? artifactList[0];
- setSelectedArtifactId(preferred.id);
- }, [artifactList, runId, selectedArtifactId]);
-
- return (
-
-
- {t("common.files", { count: artifactList.length })}
-
-
-
- {artifactList.length ? (
- pagination.items.map((artifact) => (
-
- ))
- ) : (
-
{runId ? t("artifacts.noArtifactsYet") : t("artifacts.noRunSelected")}
- )}
-
- {selectedArtifact ? (
-
-
- {selectedArtifact.name}
- {artifactContent.data?.content_type ?? selectedArtifact.kind}
- {artifactContent.data?.truncated ? {t("artifacts.truncated", { bytes: artifactContent.data.limit_bytes })} : null}
-
-
- {artifactContent.isLoading
- ? t("artifacts.loading")
- : artifactContent.isError
- ? artifactContent.error instanceof Error
- ? artifactContent.error.message
- : t("artifacts.failed")
- : artifactContent.data?.content ?? ""}
-
-
- ) : null}
-
- );
-}
-
-function SkillsView({ projectId }: { projectId?: string }) {
- const { t } = useI18n();
- const access = useAccess();
- const skills = useQuery({ queryKey: ["skills"], queryFn: listSkills, enabled: access.has("projects:read") });
- const skillList = useMemo(() => skills.data ?? [], [skills.data]);
- const [selectedSkillId, setSelectedSkillId] = useState();
- const skillPagination = useListPagination(skillList);
- const selectedSkill = skillList.find((skill) => skill.id === selectedSkillId) ?? skillList[0];
- const versions = useQuery({
- queryKey: ["skill-versions", selectedSkill?.id],
- queryFn: () => listSkillVersions(selectedSkill!.id),
- enabled: Boolean(selectedSkill) && access.has("projects:read")
- });
- const profiles = useQuery({
- queryKey: ["agent-profiles", projectId],
- queryFn: () => listAgentProfiles(projectId!),
- enabled: Boolean(projectId) && access.has("projects:read")
- });
- const profilePagination = useListPagination(profiles.data ?? []);
- const [skillName, setSkillName] = useState("company-docs-worker");
- const [skillRole, setSkillRole] = useState("docs");
- const [profileName, setProfileName] = useState("docs-worker");
- const [profileRole, setProfileRole] = useState("docs");
- const [profileNetworkEnabled, setProfileNetworkEnabled] = useState(false);
- const [profileSecretEnv, setProfileSecretEnv] = useState("");
- const requestedSecretEnv = parseListInput(profileSecretEnv);
- const skillMutation = useMutation({
- mutationFn: () =>
- createSkill({
- name: skillName,
- role: skillRole,
- description: "Registered from the Web Console.",
- version: "local",
- path: `skills/${skillName}/SKILL.md`
- }),
- onSuccess: () => {
- void queryClient.invalidateQueries({ queryKey: ["skills"] });
- void queryClient.invalidateQueries({ queryKey: ["skill-versions"] });
- }
- });
- const profileMutation = useMutation({
- mutationFn: () =>
- createAgentProfile(projectId!, {
- name: profileName,
- role: profileRole,
- model: "gpt-5",
- sandbox_mode: "workspace-write",
- approval_policy: "never",
- executor: "docker",
- image: "multi-codex/codex-worker:go1.25-node-vite8",
- network_enabled: profileNetworkEnabled,
- config: requestedSecretEnv.length ? { worker_secret_env: requestedSecretEnv } : {}
- } as Omit),
- onSuccess: () => queryClient.invalidateQueries({ queryKey: ["agent-profiles", projectId] })
- });
-
- useEffect(() => {
- if (skillList.length === 0) {
- setSelectedSkillId(undefined);
- return;
- }
- if (!selectedSkillId || !skillList.some((skill) => skill.id === selectedSkillId)) {
- setSelectedSkillId(skillList[0].id);
- }
- }, [skillList, selectedSkillId]);
-
- return (
-
-
-
-
{t("skills.skills")}
-
- {t("common.registered", { count: skills.data?.length ?? 0 })}
-
-
-
- {!access.has("skills:write") ?
: null}
-
-
- {skillPagination.items.map((skill) => (
-
- ))}
-
-
-
-
-
-
-
{t("skills.agentProfiles")}
-
- {t("common.profiles", { count: profiles.data?.length ?? 0 })}
-
-
-
- {!access.has("projects:write") ?
: null}
-
-
- {profilePagination.items.map((profile) => (
-
-
- {profile.name}
- {profile.model}
-
-
-
{profile.role}
-
{profile.network_enabled ? t("skills.networkOn") : t("skills.networkOff")}
-
{profileSecretEnvLabel(profile, t)}
-
- ))}
-
-
-
- );
-}
-
-function SkillVersionList({ versions }: { versions: SkillVersion[] }) {
- const { t } = useI18n();
- const pagination = useListPagination(versions);
- return (
-
-
- {t("common.total", { count: versions.length })}
-
-
-
- {versions.length ? (
- pagination.items.map((version) => (
-
- {version.version}
- {version.content_hash}
- {version.path}
-
- ))
- ) : (
-
{t("skills.noVersions")}
- )}
-
-
- );
-}
-
-function parseListInput(value: string): string[] {
- const seen = new Set();
- const values: string[] = [];
- for (const item of value.split(/[,\s;]+/)) {
- const trimmed = item.trim();
- if (!trimmed || seen.has(trimmed)) {
- continue;
- }
- seen.add(trimmed);
- values.push(trimmed);
- }
- return values;
-}
-
-function profileSecretEnvLabel(profile: AgentProfile, t: (key: string) => string): string {
- const value = profile.config["worker_secret_env"] ?? profile.config["secret_env"];
- if (Array.isArray(value)) {
- const names = value.filter((item): item is string => typeof item === "string");
- return names.length ? names.join(", ") : t("common.noSecretRefs");
- }
- if (typeof value === "string" && value.trim()) {
- return value;
- }
- return t("common.noSecretRefs");
-}
-
-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}
-
-
- {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}
-
-
- {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));
-}
-
-function roleLabel(role: string, t?: (key: string) => string) {
- const normalized = role.toLowerCase().replaceAll("_", "-");
- if (t) {
- const translated = t(`status.${normalized}`);
- if (translated !== `status.${normalized}`) {
- return translated;
- }
- }
- return labelize(role);
-}
-
-function ApprovalsView() {
- 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")}
- )}
-
-
- );
-}
-
-function NodesView() {
- const { t } = useI18n();
- const access = useAccess();
- const nodes = useQuery({ queryKey: ["executor-nodes"], queryFn: listExecutorNodes, enabled: access.has("nodes:read") });
- const nodeList = nodes.data ?? [];
- const pagination = useListPagination(nodeList);
- const [name, setName] = useState("ssh-worker-1");
- const [address, setAddress] = useState("codex-worker@example.invalid:22");
- const [agentDURL, setAgentDURL] = useState("http://worker-agentd-dev:7070");
- const [fingerprint, setFingerprint] = useState("SHA256:multi-codex-agentd-dev");
- const [forcedCommand, setForcedCommand] = useState("multi-codex-worker-agentd --forced-command");
- const mutation = useMutation({
- mutationFn: () =>
- registerExecutorNode({
- kind: "ssh",
- name,
- address,
- agentd_url: agentDURL,
- host_key_fingerprint: fingerprint,
- forced_command: forcedCommand,
- status: "registered"
- }),
- onSuccess: () => queryClient.invalidateQueries({ queryKey: ["executor-nodes"] })
- });
- const verifyMutation = useMutation({
- mutationFn: ({ nodeId, observed }: { nodeId: string; observed: string }) => verifyExecutorNodeHostKey(nodeId, observed),
- onSuccess: () => queryClient.invalidateQueries({ queryKey: ["executor-nodes"] })
- });
-
- return (
-
- );
-}
-
-function OrganizationsView({ organizations }: { organizations: Organization[] }) {
- const { t } = useI18n();
- const access = useAccess();
- const pagination = useListPagination(organizations);
- const [name, setName] = useState("Engineering");
- const [slug, setSlug] = useState("engineering");
- const mutation = useMutation({
- mutationFn: () => createOrganization({ name, slug }),
- onSuccess: () => {
- void queryClient.invalidateQueries({ queryKey: ["organizations"] });
- void queryClient.invalidateQueries({ queryKey: ["audit-logs"] });
- }
- });
-
- return (
-
-
-
{t("organizations.organizations")}
-
- {t("common.provisioned", { count: organizations.length })}
-
-
-
- {!access.has("organizations:write") ? : null}
-
-
- {organizations.length ? (
- pagination.items.map((org) => (
-
-
- {org.name}
- {org.id}
-
-
-
{org.slug}
-
{new Date(org.created_at).toLocaleString()}
-
- ))
- ) : (
-
{t("organizations.empty")}
- )}
-
-
- );
-}
-
-function AuditView() {
- 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()}
-
- ))}
-
-
-
- );
-}
-
-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")}
- )}
-
- );
-}
-
-type EventStreamState = "idle" | "connecting" | "live" | "fallback";
-
-function useRunEvents(runId?: string, runStatus?: string) {
- const [streamEvents, setStreamEvents] = useState([]);
- const [streamState, setStreamState] = useState("idle");
- const fallback = useQuery({
- queryKey: ["run-events", runId],
- queryFn: () => listRunEvents(runId!),
- enabled: Boolean(runId),
- refetchInterval: streamState === "live" ? false : pollWhileHealthy(runStatus === "running" ? 2_000 : 5_000)
- });
-
- useEffect(() => {
- setStreamEvents([]);
- if (!runId || typeof EventSource === "undefined") {
- setStreamState(runId ? "fallback" : "idle");
- return;
- }
- setStreamState("connecting");
- const source = new EventSource(runEventStreamURL(runId), { withCredentials: true });
- source.onopen = () => setStreamState("live");
- source.onmessage = (message) => {
- const event = parseRunEventPayload(message.data);
- if (!event) {
- return;
- }
- setStreamEvents((current) => mergeRunEvents(current, [event]));
- };
- source.onerror = () => {
- source.close();
- setStreamState("fallback");
- };
- return () => source.close();
- }, [runId]);
-
- return {
- events: mergeRunEvents(fallback.data ?? [], streamEvents),
- streamState
- };
-}
-
-function mergeRunEvents(first: RunEvent[], second: RunEvent[]) {
- const byID = new Map();
- for (const event of first) {
- byID.set(event.id, event);
- }
- for (const event of second) {
- byID.set(event.id, event);
- }
- return Array.from(byID.values()).sort((a, b) => a.seq - b.seq);
-}
-
-function EventList({
- events,
- streamState
-}: {
- events: RunEvent[];
- streamState?: EventStreamState;
-}) {
- const { t } = useI18n();
- const stateLabel = streamState === "live" ? t("runs.live") : streamState === "connecting" ? t("runs.connecting") : streamState === "fallback" ? t("runs.polling") : "";
- return (
-
- {stateLabel ?
{stateLabel}
: null}
- {events.length ? (
- events.map((event) => (
-
-
{event.seq}
-
{event.event_type}
-
{event.message}
-
- ))
- ) : (
-
{t("runs.noEvents")}
- )}
-
- );
-}
-
-function labelize(value: string) {
- return value
- .replaceAll("_", " ")
- .replaceAll("-", " ")
- .replace(/\b\w/g, (match) => match.toUpperCase());
-}
-
-function workflowActionLabel(action: string, t: (key: string) => string) {
- const key = `workflowAction.${action}`;
- const translated = t(key);
- return translated === key ? labelize(action) : translated;
-}
-
-function pollWhileHealthy(interval: number) {
- return (query: { state: { error: unknown } }) => (query.state.error ? false : interval);
-}
-
-function formatDate(value: string | undefined, t: (key: string) => string) {
- if (!value) {
- return t("common.notRecorded");
- }
- return new Date(value).toLocaleString();
-}
-
-function envelopeValue(envelope: Record, key: string, fallback: string) {
- const value = envelope[key];
- if (typeof value === "string" && value.trim()) {
- return value;
- }
- if (typeof value === "number" || typeof value === "boolean") {
- return String(value);
- }
- return fallback;
-}
-
-function envelopeList(envelope: Record, key: string) {
- const value = envelope[key];
- if (!Array.isArray(value)) {
- return [];
- }
- return value.filter((item): item is string => typeof item === "string");
-}
-
-function policyLabel(envelope: Record, key: string, t: (key: string) => string) {
- const policy = envelope.policy;
- if (!policy || typeof policy !== "object" || Array.isArray(policy)) {
- return t("policy.notSet");
- }
- const value = (policy as Record)[key];
- return typeof value === "boolean" ? (value ? t("policy.allowed") : t("policy.blocked")) : t("policy.notSet");
-}
-
-function capacityWidth(snapshot: Backpressure) {
- const total = snapshot.nodes.reduce((sum, node) => sum + node.concurrency, 0);
- const used = snapshot.nodes.reduce((sum, node) => sum + node.active_runs, 0);
- if (!total) {
- return 0;
- }
- return Math.min(100, Math.round((used / total) * 100));
-}
-
-function refreshTaskQueries(task?: Task, runId?: string) {
- void queryClient.invalidateQueries({ queryKey: ["runs"] });
- void queryClient.invalidateQueries({ queryKey: ["runs", task?.id] });
- void queryClient.invalidateQueries({ queryKey: ["workflow", task?.id] });
- void queryClient.invalidateQueries({ queryKey: ["tasks", task?.project_id] });
- void queryClient.invalidateQueries({ queryKey: ["audit-logs"] });
- void queryClient.invalidateQueries({ queryKey: ["approvals"] });
- if (runId) {
- void queryClient.invalidateQueries({ queryKey: ["run-events", runId] });
- void queryClient.invalidateQueries({ queryKey: ["artifacts", runId] });
- }
-}
-
-function lines(value: string) {
- return value
- .split("\n")
- .map((line) => line.trim())
- .filter(Boolean);
-}
-
-function submit(event: FormEvent, fn: () => void) {
- event.preventDefault();
- fn();
-}
diff --git a/apps/web/src/features/tasks/TaskRow.tsx b/apps/web/src/features/tasks/TaskRow.tsx
new file mode 100644
index 0000000..ab84f87
--- /dev/null
+++ b/apps/web/src/features/tasks/TaskRow.tsx
@@ -0,0 +1,14 @@
+import type { Task } from "../../lib/api";
+import { StatusBadge } from "../../shared/ui/StatusBadge";
+
+export function TaskRow({ task, active, onSelect }: { task: Task; active: boolean; onSelect: () => void }) {
+ return (
+
+ );
+}
diff --git a/apps/web/src/features/tasks/TasksPage.tsx b/apps/web/src/features/tasks/TasksPage.tsx
new file mode 100644
index 0000000..0bd8786
--- /dev/null
+++ b/apps/web/src/features/tasks/TasksPage.tsx
@@ -0,0 +1,329 @@
+import { useEffect, useState } from "react";
+import { useMutation, useQuery } from "@tanstack/react-query";
+import { useForm } from "react-hook-form";
+import { z } from "zod";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useNavigate, useParams } from "react-router-dom";
+import { queryClient } from "../../app/queryClient";
+import { useWorkbench } from "../../app/workbenchContext";
+import { AccessNotice } from "../../app/AccessPanel";
+import { createTask, getWorkflow, listRuns, requestApproval, runWorkflowAction, scopeCheck, startTask } from "../../lib/api";
+import type { Project, Repository, Task } from "../../lib/api";
+import { useI18n } from "../../lib/i18n";
+import { useAccess } from "../../lib/permissions";
+import { StatusBadge } from "../../shared/ui/StatusBadge";
+import { Button } from "../../shared/ui/Button";
+import { useToast } from "../../shared/ui/Toast";
+import { ListPaginationControl, useListPagination } from "../../shared/lib/listPagination";
+import { lines, roleLabel, workflowActionLabel } from "../../shared/lib/formatting";
+import { pollWhileHealthy } from "../../shared/lib/pollWhileHealthy";
+import { useRunEvents } from "../../shared/hooks/useRunEventStream";
+import { EventList, RunArtifactInspector } from "../runs/RunsPage";
+import { TaskRow } from "./TaskRow";
+
+const taskCreateSchema = z.object({
+ title: z.string().trim().min(1),
+ objective: z.string().trim().min(1),
+ allowedPaths: z.string().trim().min(1)
+});
+
+type TaskCreateValues = z.infer;
+
+export function TasksPage() {
+ const params = useParams();
+ const navigate = useNavigate();
+ const { activeProject, activeRepository, selectedTask, setSelectedTaskId, tasks } = useWorkbench();
+ const selected = params.taskId ? tasks.find((task) => task.id === params.taskId) : selectedTask;
+
+ useEffect(() => {
+ if (params.taskId) {
+ setSelectedTaskId(params.taskId);
+ }
+ }, [params.taskId, setSelectedTaskId]);
+
+ return (
+ {
+ setSelectedTaskId(taskId);
+ navigate(`/tasks/${taskId}`);
+ }}
+ selectedTask={selected}
+ tasks={tasks}
+ />
+ );
+}
+
+function TasksView({
+ activeProject,
+ activeRepository,
+ onSelectTask,
+ selectedTask,
+ tasks
+}: {
+ activeProject?: Project;
+ activeRepository?: Repository;
+ onSelectTask: (taskId: string) => void;
+ selectedTask?: Task;
+ tasks: Task[];
+}) {
+ const { t } = useI18n();
+ const pagination = useListPagination(tasks);
+ return (
+
+
+
+
{t("nav.tasks")}
+
+ {t("common.total", { count: tasks.length })}
+
+
+
+
+
+ {tasks.length ? (
+ pagination.items.map((task) => (
+
onSelectTask(task.id)} />
+ ))
+ ) : (
+ {t("tasks.noTasks")}
+ )}
+
+
+
+
+
+ );
+}
+
+function TaskCreateForm({ activeProject, activeRepository }: { activeProject?: Project; activeRepository?: Repository }) {
+ const navigate = useNavigate();
+ const { t } = useI18n();
+ const access = useAccess();
+ const toast = useToast();
+ const form = useForm({
+ resolver: zodResolver(taskCreateSchema),
+ defaultValues: {
+ title: t("tasks.defaultTitle"),
+ objective: t("tasks.defaultObjective"),
+ allowedPaths: "internal/**\napps/web/src/**\ndocs/**"
+ }
+ });
+ const mutation = useMutation({
+ mutationFn: (values: TaskCreateValues) =>
+ createTask(activeProject!, activeRepository!, {
+ title: values.title,
+ allowed_paths: lines(values.allowedPaths),
+ objective: values.objective
+ }),
+ onSuccess: (task) => {
+ void queryClient.invalidateQueries({ queryKey: ["tasks", activeProject?.id] });
+ void queryClient.invalidateQueries({ queryKey: ["audit-logs"] });
+ toast.push({ title: t("tasks.create"), description: task.task_key, tone: "success" });
+ navigate(`/tasks/${task.id}`);
+ },
+ onError: (error) => {
+ toast.push({
+ title: t("tasks.create"),
+ description: error instanceof Error ? error.message : t("access.writeLocked"),
+ tone: "danger"
+ });
+ }
+ });
+
+ return (
+
+ );
+}
+
+function TaskDetail({ task }: { task?: Task }) {
+ const { t } = useI18n();
+ const access = useAccess();
+ const [scopeInput, setScopeInput] = useState("internal/policy/scope.go\ndocs/implementation/roadmap.md");
+ const [scopeResult, setScopeResult] = useState<{ status: string; changed_files: string[]; violations: string[] } | undefined>();
+ const runs = useQuery({
+ queryKey: ["runs", task?.id],
+ queryFn: () => listRuns(task!.id),
+ enabled: Boolean(task) && access.has("runs:read"),
+ refetchInterval: pollWhileHealthy(2_000)
+ });
+ const workflow = useQuery({
+ queryKey: ["workflow", task?.id],
+ queryFn: () => getWorkflow(task!.id),
+ enabled: Boolean(task) && access.has("projects:read"),
+ refetchInterval: pollWhileHealthy(2_000)
+ });
+ const latestRun = runs.data?.[runs.data.length - 1];
+ const events = useRunEvents(latestRun?.id, latestRun?.status);
+ const runPagination = useListPagination(runs.data ?? []);
+ const eventPagination = useListPagination(events.events, { fromEnd: true });
+ 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)
+ });
+ const publishApprovalMutation = useMutation({
+ mutationFn: () => requestApproval(task!.id, { approval_type: "pr_publish", reason: "PR publish preparation requires explicit human approval." }),
+ onSuccess: () => refreshTaskQueries(task)
+ });
+
+ if (!task) {
+ return (
+
+
{t("tasks.selectTask")}
+
+ );
+ }
+
+ return (
+
+
+
{task.title}
+
+
+
+
+
+
+
+
- {t("tasks.taskKey")}
+ - {task.task_key}
+
+
+
- {t("tasks.repository")}
+ - {task.repository_id}
+
+
+
- {t("tasks.role")}
+ - {roleLabel(String(task.envelope.role ?? "feature"), t)}
+
+
+
- {t("tasks.executor")}
+ - {String(task.envelope.executor ?? "docker")}
+
+
+
+
{t("tasks.workflowGates")}
+
+ {workflow.data?.blocked_reasons?.length ? (
+ workflow.data.blocked_reasons.map((reason) => {reason})
+ ) : (
+ {t("tasks.noActiveBlockers")}
+ )}
+ {workflow.data?.next_actions?.map((action) => (
+
+ ))}
+
+
+
+
+
{t("tasks.scopeCheck")}
+
+
+ {scopeResult ? (
+
+
+ {t("tasks.filesChecked", { count: scopeResult.changed_files.length })}
+ {scopeResult.violations.length ? {scopeResult.violations.join(", ")} : {t("tasks.noViolations")}}
+
+ ) : null}
+
+
+
{t("tasks.runs")}
+
+
+
+ {!access.has("runs:read") ?
: null}
+ {runs.data?.length ? (
+ runPagination.items.map((run) => (
+
+ {roleLabel(run.role, t)}
+
+ {run.executor}
+ {run.executor_node_id || t("common.notAssigned")}
+
+ ))
+ ) : (
+
{t("runs.noTaskRuns")}
+ )}
+
+
+
+
{t("tasks.latestRunEvents")}
+
+
+
+
+
{t("tasks.latestRunArtifacts")}
+
+
+
{t("tasks.envelope")}
+
{JSON.stringify(task.envelope, null, 2)}
+
+ );
+}
+
+function refreshTaskQueries(task?: Task, runId?: string) {
+ void queryClient.invalidateQueries({ queryKey: ["runs"] });
+ void queryClient.invalidateQueries({ queryKey: ["runs", task?.id] });
+ void queryClient.invalidateQueries({ queryKey: ["workflow", task?.id] });
+ void queryClient.invalidateQueries({ queryKey: ["tasks", task?.project_id] });
+ void queryClient.invalidateQueries({ queryKey: ["audit-logs"] });
+ void queryClient.invalidateQueries({ queryKey: ["approvals"] });
+ if (runId) {
+ void queryClient.invalidateQueries({ queryKey: ["run-events", runId] });
+ void queryClient.invalidateQueries({ queryKey: ["artifacts", runId] });
+ }
+}
diff --git a/apps/web/src/features/tasks/taskCreateSchema.test.ts b/apps/web/src/features/tasks/taskCreateSchema.test.ts
new file mode 100644
index 0000000..9c34859
--- /dev/null
+++ b/apps/web/src/features/tasks/taskCreateSchema.test.ts
@@ -0,0 +1,24 @@
+import { describe, expect, it } from "vitest";
+import { z } from "zod";
+
+const taskCreateSchema = z.object({
+ title: z.string().trim().min(1),
+ objective: z.string().trim().min(1),
+ allowedPaths: z.string().trim().min(1)
+});
+
+describe("taskCreateSchema", () => {
+ it("rejects blank fields", () => {
+ const result = taskCreateSchema.safeParse({ title: " ", objective: "", allowedPaths: "docs/**" });
+ expect(result.success).toBe(false);
+ });
+
+ it("accepts a valid envelope draft", () => {
+ const result = taskCreateSchema.safeParse({
+ title: "Pilot docs change",
+ objective: "Update pilot evidence docs",
+ allowedPaths: "docs/**"
+ });
+ expect(result.success).toBe(true);
+ });
+});
diff --git a/apps/web/src/features/usage/UsagePage.tsx b/apps/web/src/features/usage/UsagePage.tsx
new file mode 100644
index 0000000..71f8183
--- /dev/null
+++ b/apps/web/src/features/usage/UsagePage.tsx
@@ -0,0 +1,95 @@
+import { useMemo } from "react";
+import { useQuery } from "@tanstack/react-query";
+import { useWorkbench } from "../../app/workbenchContext";
+import { getUsageSummary } from "../../lib/api";
+import { useI18n } from "../../lib/i18n";
+import { useAccess } from "../../lib/permissions";
+import { roleLabel } from "../../shared/lib/formatting";
+import { Metric } from "../../shared/ui/Metric";
+import { EmptyState } from "../../shared/ui/EmptyState";
+import { Banner } from "../../shared/ui/Dialog";
+import { pollWhileHealthy } from "../../shared/lib/pollWhileHealthy";
+
+function formatDuration(seconds: number) {
+ if (seconds < 60) {
+ return `${Math.round(seconds)}s`;
+ }
+ if (seconds < 3600) {
+ return `${(seconds / 60).toFixed(1)}m`;
+ }
+ return `${(seconds / 3600).toFixed(1)}h`;
+}
+
+export function UsagePage() {
+ const { t } = useI18n();
+ const access = useAccess();
+ const { activeProject } = useWorkbench();
+ const summary = useQuery({
+ queryKey: ["usage-summary", activeProject?.id],
+ queryFn: () => getUsageSummary({ projectId: activeProject?.id }),
+ enabled: access.has("runs:read"),
+ refetchInterval: pollWhileHealthy(15_000)
+ });
+
+ const rows = useMemo(() => summary.data?.rows ?? [], [summary.data?.rows]);
+ const total = summary.data?.total;
+ const budget = summary.data?.budget;
+ const budgetTone = budget?.state === "hard" ? "danger" : budget?.state === "warn" ? "warning" : budget?.state === "ok" ? "success" : "info";
+
+ return (
+
+
+
+
+
{t("nav.usage")}
+
{t("usage.subtitle")}
+
+
+ {budget && budget.enabled ? (
+
+
+ {t(`usage.budget.${budget.state}`)}
+
+ {t("usage.budgetDetail", {
+ used: formatDuration(budget.usage_seconds),
+ warn: formatDuration(budget.warn_threshold_seconds),
+ hard: formatDuration(budget.hard_threshold_seconds),
+ day: budget.day
+ })}
+
+
+
+ ) : (
+
{t("usage.budgetDisabled")}
+ )}
+
+
+
+
+
+
+ {summary.data?.tokens_unknown ?
{t("usage.tokensUnknown")}
: null}
+
+ {summary.isLoading ? (
+
{t("common.loading")}
+ ) : rows.length ? (
+ rows.map((row) => (
+
+
+ {row.project_name || row.project_id}
+ {row.day}
+
+
{roleLabel(row.role, t)}
+
{t("usage.runCount", { count: row.run_count })}
+
{formatDuration(row.total_duration_seconds)}
+
{row.token_usage ? t("usage.tokenCount", { count: row.token_usage.total_tokens }) : t("usage.tokensUnknownShort")}
+
+ ))
+ ) : (
+
{t("usage.empty")}
+ )}
+
+
+
+ );
+}
diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts
index 2c5358e..57704c6 100644
--- a/apps/web/src/lib/api.ts
+++ b/apps/web/src/lib/api.ts
@@ -302,6 +302,75 @@ export type QueueSnapshot = z.infer;
export type AuthContext = z.infer;
export type AuthCapabilities = z.infer;
+const tokenUsageSchema = z.object({
+ prompt_tokens: z.number().optional(),
+ completion_tokens: z.number().optional(),
+ total_tokens: z.number()
+});
+
+const usageSummaryRowSchema = z.object({
+ organization_id: z.string(),
+ organization_name: z.string(),
+ project_id: z.string(),
+ project_name: z.string(),
+ role: z.string(),
+ day: z.string(),
+ run_count: z.number(),
+ completed_run_count: z.number(),
+ total_duration_seconds: z.number(),
+ token_usage: tokenUsageSchema.nullable(),
+ tokens_unknown: z.boolean()
+});
+
+const usageBudgetStatusSchema = z.object({
+ enabled: z.boolean(),
+ day: z.string(),
+ usage_seconds: z.number(),
+ warn_threshold_seconds: z.number(),
+ hard_threshold_seconds: z.number(),
+ warn_ratio: z.number(),
+ state: z.string()
+});
+
+const usageSummarySchema = z.object({
+ rows: z.array(usageSummaryRowSchema),
+ total: z.object({
+ run_count: z.number(),
+ completed_run_count: z.number(),
+ total_duration_seconds: z.number(),
+ token_usage: tokenUsageSchema.nullable()
+ }),
+ tokens_unknown: z.boolean(),
+ budget: usageBudgetStatusSchema.optional()
+});
+
+const usageRunSchema = z.object({
+ organization_id: z.string(),
+ organization_name: z.string(),
+ project_id: z.string(),
+ project_name: z.string(),
+ task_id: z.string(),
+ run_id: z.string(),
+ role: z.string(),
+ status: z.string(),
+ day: z.string(),
+ duration_seconds: z.number().nullable(),
+ token_usage: tokenUsageSchema.nullable(),
+ tokens_unknown: z.boolean(),
+ started_at: z.string().optional(),
+ finished_at: z.string().optional(),
+ created_at: z.string()
+});
+
+const usageRunsSchema = z.object({
+ runs: z.array(usageRunSchema),
+ tokens_unknown: z.boolean()
+});
+
+export type TokenUsage = z.infer;
+export type UsageSummary = z.infer;
+export type UsageRunsResponse = z.infer;
+
async function getJSON(path: string, schema: z.ZodType): Promise {
const response = await apiFetch(path, { credentials: "include", headers: authHeaders() });
return parseResponse(response, schema);
@@ -622,3 +691,42 @@ export function requestApproval(taskId: string, input: { approval_type: string;
export function decideApproval(approvalId: string, status: "approved" | "rejected", reason: string) {
return postJSON(`/api/v1/approvals/${approvalId}/decision`, { status, reason }, approvalSchema);
}
+
+export function getUsageSummary(input: { orgId?: string; projectId?: string; from?: string; to?: string } = {}) {
+ const params = new URLSearchParams();
+ if (input.orgId) {
+ params.set("org_id", input.orgId);
+ }
+ if (input.projectId) {
+ params.set("project_id", input.projectId);
+ }
+ if (input.from) {
+ params.set("from", input.from);
+ }
+ if (input.to) {
+ params.set("to", input.to);
+ }
+ const query = params.toString();
+ return getJSON(`/api/v1/usage/summary${query ? `?${query}` : ""}`, usageSummarySchema);
+}
+
+export function getUsageRuns(input: { orgId?: string; projectId?: string; from?: string; to?: string; limit?: number } = {}) {
+ const params = new URLSearchParams();
+ if (input.orgId) {
+ params.set("org_id", input.orgId);
+ }
+ if (input.projectId) {
+ params.set("project_id", input.projectId);
+ }
+ if (input.from) {
+ params.set("from", input.from);
+ }
+ if (input.to) {
+ params.set("to", input.to);
+ }
+ if (input.limit) {
+ params.set("limit", String(input.limit));
+ }
+ const query = params.toString();
+ return getJSON(`/api/v1/usage/runs${query ? `?${query}` : ""}`, usageRunsSchema);
+}
diff --git a/apps/web/src/lib/i18n.tsx b/apps/web/src/lib/i18n.tsx
index 74b2d8d..1ace68f 100644
--- a/apps/web/src/lib/i18n.tsx
+++ b/apps/web/src/lib/i18n.tsx
@@ -19,6 +19,7 @@ const en: Dictionary = {
"nav.skills": "Skills",
"nav.admin": "Admin",
"nav.audit": "Audit",
+ "nav.usage": "Usage",
"nav.primary": "Primary navigation",
"language.label": "Language",
"language.en": "EN",
@@ -118,6 +119,19 @@ const en: Dictionary = {
"common.previousPage": "Previous page",
"common.nextPage": "Next page",
"common.pageStatus": "{page}/{pages}",
+ "common.copy": "Copy",
+ "common.copied": "Copied",
+ "common.lines": "{count} lines",
+ "runs.followLive": "Following",
+ "runs.followPaused": "Paused",
+ "runs.cockpit": "Run Cockpit",
+ "runs.tabEvents": "Events",
+ "runs.tabLogs": "Logs",
+ "runs.tabDiff": "Diff",
+ "runs.tabArtifacts": "Artifacts",
+ "runs.tabResult": "Result",
+ "runs.emptyLog": "No worker log content yet.",
+ "runs.emptyDiff": "No diff artifact yet.",
"dashboard.workQueue": "Work Queue",
"dashboard.activeTasks": "Active Tasks",
"dashboard.capacity": "Capacity",
@@ -279,6 +293,47 @@ const en: Dictionary = {
"approvals.approve": "Approve",
"approvals.reject": "Reject",
"approvals.empty": "No approval requests are pending.",
+ "approvals.filter": "Approval filter",
+ "approvals.filter.pending": "Pending",
+ "approvals.filter.approved": "Approved",
+ "approvals.filter.rejected": "Rejected",
+ "approvals.filter.all": "All",
+ "approvals.pendingCount": "{count} pending",
+ "approvals.pendingToastTitle": "Approvals waiting",
+ "checklist.eyebrow": "First workflow",
+ "checklist.title": "Getting started",
+ "checklist.progress": "{done}/{total} complete",
+ "checklist.dismiss": "Dismiss",
+ "checklist.project": "Select or create a project",
+ "checklist.repository": "Attach a repository",
+ "checklist.task": "Create a governed task",
+ "checklist.run": "Start a worker run",
+ "checklist.approval": "Review an approval request",
+ "checklist.usage": "Inspect usage duration",
+ "usage.subtitle": "Worker duration by project, role, and day.",
+ "usage.totals": "Usage totals",
+ "usage.runs": "Runs",
+ "usage.completed": "Completed",
+ "usage.duration": "Duration (s)",
+ "usage.tokens": "Tokens",
+ "usage.tokensUnknown": "Token counts are unknown until workers report them.",
+ "usage.tokensUnknownShort": "unknown",
+ "usage.runCount": "{count} runs",
+ "usage.tokenCount": "{count} tokens",
+ "usage.empty": "No completed usage is available for this project yet.",
+ "usage.budgetDisabled": "Daily duration budgets are disabled for this deployment.",
+ "usage.budget.ok": "Budget OK",
+ "usage.budget.warn": "Budget warning",
+ "usage.budget.hard": "Budget hard limit reached",
+ "usage.budget.unknown": "Budget status unknown",
+ "usage.budget.disabled": "Budget disabled",
+ "usage.budgetDetail": "{used} used on {day} · warn {warn} · hard {hard}",
+ "runs.filter": "Run filter",
+ "runs.filter.all": "All",
+ "runs.filter.running": "Running",
+ "runs.filter.queued": "Queued",
+ "runs.filter.failed": "Failed",
+ "runs.filter.succeeded": "Succeeded",
"audit.logs": "Audit Logs",
"audit.empty": "No audit entries yet.",
"policy.notSet": "not set",
@@ -359,6 +414,7 @@ const zh: Dictionary = {
"nav.skills": "技能",
"nav.admin": "管理",
"nav.audit": "审计",
+ "nav.usage": "用量",
"nav.primary": "主导航",
"language.label": "语言",
"language.en": "EN",
@@ -458,6 +514,19 @@ const zh: Dictionary = {
"common.previousPage": "上一页",
"common.nextPage": "下一页",
"common.pageStatus": "{page}/{pages}",
+ "common.copy": "复制",
+ "common.copied": "已复制",
+ "common.lines": "{count} 行",
+ "runs.followLive": "跟随中",
+ "runs.followPaused": "已暂停",
+ "runs.cockpit": "运行驾驶舱",
+ "runs.tabEvents": "事件",
+ "runs.tabLogs": "日志",
+ "runs.tabDiff": "Diff",
+ "runs.tabArtifacts": "产物",
+ "runs.tabResult": "结果",
+ "runs.emptyLog": "还没有 worker 日志。",
+ "runs.emptyDiff": "还没有 diff 产物。",
"dashboard.workQueue": "工作队列",
"dashboard.activeTasks": "活跃任务",
"dashboard.capacity": "容量",
@@ -619,6 +688,47 @@ const zh: Dictionary = {
"approvals.approve": "通过",
"approvals.reject": "拒绝",
"approvals.empty": "没有待处理审批。",
+ "approvals.filter": "审批筛选",
+ "approvals.filter.pending": "待处理",
+ "approvals.filter.approved": "已批准",
+ "approvals.filter.rejected": "已拒绝",
+ "approvals.filter.all": "全部",
+ "approvals.pendingCount": "{count} 个待处理",
+ "approvals.pendingToastTitle": "有审批待处理",
+ "checklist.eyebrow": "首次流程",
+ "checklist.title": "开始使用",
+ "checklist.progress": "已完成 {done}/{total}",
+ "checklist.dismiss": "关闭",
+ "checklist.project": "选择或创建项目",
+ "checklist.repository": "关联仓库",
+ "checklist.task": "创建受治理任务",
+ "checklist.run": "启动 worker 运行",
+ "checklist.approval": "处理审批请求",
+ "checklist.usage": "查看用量时长",
+ "usage.subtitle": "按项目、角色和日期统计 worker 时长。",
+ "usage.totals": "用量合计",
+ "usage.runs": "运行数",
+ "usage.completed": "已完成",
+ "usage.duration": "时长(秒)",
+ "usage.tokens": "Token",
+ "usage.tokensUnknown": "在 worker 上报之前,Token 数量未知。",
+ "usage.tokensUnknownShort": "未知",
+ "usage.runCount": "{count} 次运行",
+ "usage.tokenCount": "{count} tokens",
+ "usage.empty": "当前项目还没有可展示的用量。",
+ "usage.budgetDisabled": "当前部署未启用每日时长预算。",
+ "usage.budget.ok": "预算正常",
+ "usage.budget.warn": "预算告警",
+ "usage.budget.hard": "已达预算硬阈值",
+ "usage.budget.unknown": "预算状态未知",
+ "usage.budget.disabled": "预算未启用",
+ "usage.budgetDetail": "{day} 已用 {used} · 警告 {warn} · 硬阈值 {hard}",
+ "runs.filter": "运行筛选",
+ "runs.filter.all": "全部",
+ "runs.filter.running": "运行中",
+ "runs.filter.queued": "排队",
+ "runs.filter.failed": "失败",
+ "runs.filter.succeeded": "成功",
"audit.logs": "审计日志",
"audit.empty": "还没有审计记录。",
"policy.notSet": "未设置",
diff --git a/apps/web/src/lib/permissions.test.ts b/apps/web/src/lib/permissions.test.ts
new file mode 100644
index 0000000..4636051
--- /dev/null
+++ b/apps/web/src/lib/permissions.test.ts
@@ -0,0 +1,33 @@
+import { describe, expect, it } from "vitest";
+import { hasPermission, visiblePermissions } from "../lib/permissions";
+import type { AuthContext } from "../lib/api";
+
+const baseAuth = {
+ user: {
+ id: "u1",
+ email: "ops@example.com",
+ display_name: "Ops",
+ created_at: "2026-01-01T00:00:00Z"
+ },
+ membership: {
+ org_id: "o1",
+ user_id: "u1",
+ role: "operator",
+ created_at: "2026-01-01T00:00:00Z"
+ },
+ project_memberships: [],
+ permissions: ["projects:read", "runs:read"]
+} satisfies AuthContext;
+
+describe("permissions", () => {
+ it("allows exact and wildcard permissions", () => {
+ expect(hasPermission(baseAuth, "projects:read")).toBe(true);
+ expect(hasPermission(baseAuth, "tasks:write")).toBe(false);
+ expect(hasPermission({ ...baseAuth, permissions: ["*"] }, "tasks:write")).toBe(true);
+ });
+
+ it("summarizes visible permissions", () => {
+ expect(visiblePermissions(baseAuth)).toEqual(["projects:read", "runs:read"]);
+ expect(visiblePermissions({ ...baseAuth, permissions: ["*"] })).toEqual(["*"]);
+ });
+});
diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx
index c38ba8c..49465cf 100644
--- a/apps/web/src/main.tsx
+++ b/apps/web/src/main.tsx
@@ -1,14 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
-import { QueryClientProvider } from "@tanstack/react-query";
import { App } from "./app/App";
-import { queryClient } from "./app/queryClient";
import "./styles.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
-
-
-
+
);
diff --git a/apps/web/src/shared/hooks/useRunEventStream.test.ts b/apps/web/src/shared/hooks/useRunEventStream.test.ts
new file mode 100644
index 0000000..95f8a97
--- /dev/null
+++ b/apps/web/src/shared/hooks/useRunEventStream.test.ts
@@ -0,0 +1,21 @@
+import { describe, expect, it } from "vitest";
+import { mergeRunEvents } from "./useRunEventStream";
+import type { RunEvent } from "../../lib/api";
+
+const event = (id: number, seq: number): RunEvent => ({
+ id,
+ run_id: "r1",
+ seq,
+ level: "info",
+ event_type: "log",
+ message: `m${seq}`,
+ payload: {},
+ created_at: "2026-01-01T00:00:00Z"
+});
+
+describe("mergeRunEvents", () => {
+ it("deduplicates by id and sorts by seq", () => {
+ const merged = mergeRunEvents([event(1, 2), event(2, 1)], [event(2, 1), event(3, 3)]);
+ expect(merged.map((item) => item.id)).toEqual([2, 1, 3]);
+ });
+});
diff --git a/apps/web/src/shared/hooks/useRunEventStream.ts b/apps/web/src/shared/hooks/useRunEventStream.ts
new file mode 100644
index 0000000..8f74110
--- /dev/null
+++ b/apps/web/src/shared/hooks/useRunEventStream.ts
@@ -0,0 +1,82 @@
+import { useEffect, useRef, useState } from "react";
+import { useQuery } from "@tanstack/react-query";
+import { listRunEvents, parseRunEventPayload, runEventStreamURL } from "../../lib/api";
+import type { RunEvent } from "../../lib/api";
+import { pollWhileHealthy } from "../lib/pollWhileHealthy";
+
+export type EventStreamState = "idle" | "connecting" | "live" | "fallback";
+
+export function useRunEventStream(runId?: string, runStatus?: string) {
+ const [streamEvents, setStreamEvents] = useState([]);
+ const [streamState, setStreamState] = useState("idle");
+ const [reconnectAttempt, setReconnectAttempt] = useState(0);
+ const retryTimer = useRef(null);
+ const fallback = useQuery({
+ queryKey: ["run-events", runId],
+ queryFn: () => listRunEvents(runId!),
+ enabled: Boolean(runId),
+ refetchInterval: streamState === "live" ? false : pollWhileHealthy(runStatus === "running" ? 2_000 : 5_000)
+ });
+
+ useEffect(() => {
+ setStreamEvents([]);
+ setReconnectAttempt(0);
+ if (retryTimer.current) {
+ window.clearTimeout(retryTimer.current);
+ retryTimer.current = null;
+ }
+ }, [runId]);
+
+ useEffect(() => {
+ if (!runId || typeof EventSource === "undefined") {
+ setStreamState(runId ? "fallback" : "idle");
+ return;
+ }
+ setStreamState("connecting");
+ const source = new EventSource(runEventStreamURL(runId), { withCredentials: true });
+ source.onopen = () => {
+ setStreamState("live");
+ setReconnectAttempt(0);
+ };
+ source.onmessage = (message) => {
+ const event = parseRunEventPayload(message.data);
+ if (!event) {
+ return;
+ }
+ setStreamEvents((current) => mergeRunEvents(current, [event]));
+ };
+ source.onerror = () => {
+ source.close();
+ setStreamState("fallback");
+ const delay = Math.min(15_000, 1_000 * 2 ** Math.min(reconnectAttempt, 4));
+ retryTimer.current = window.setTimeout(() => {
+ setReconnectAttempt((value) => value + 1);
+ }, delay);
+ };
+ return () => {
+ source.close();
+ if (retryTimer.current) {
+ window.clearTimeout(retryTimer.current);
+ retryTimer.current = null;
+ }
+ };
+ }, [reconnectAttempt, runId]);
+
+ return {
+ events: mergeRunEvents(fallback.data ?? [], streamEvents),
+ streamState
+ };
+}
+
+export const useRunEvents = useRunEventStream;
+
+export function mergeRunEvents(first: RunEvent[], second: RunEvent[]) {
+ const byID = new Map();
+ for (const event of first) {
+ byID.set(event.id, event);
+ }
+ for (const event of second) {
+ byID.set(event.id, event);
+ }
+ return Array.from(byID.values()).sort((a, b) => a.seq - b.seq);
+}
diff --git a/apps/web/src/shared/lib/cn.ts b/apps/web/src/shared/lib/cn.ts
new file mode 100644
index 0000000..a5ef193
--- /dev/null
+++ b/apps/web/src/shared/lib/cn.ts
@@ -0,0 +1,6 @@
+import { clsx, type ClassValue } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
diff --git a/apps/web/src/shared/lib/formatting.test.ts b/apps/web/src/shared/lib/formatting.test.ts
new file mode 100644
index 0000000..caf8b82
--- /dev/null
+++ b/apps/web/src/shared/lib/formatting.test.ts
@@ -0,0 +1,13 @@
+import { describe, expect, it } from "vitest";
+import { labelize, lines, roleLabel } from "./formatting";
+
+describe("workbench formatting helpers", () => {
+ it("normalizes labels and newline lists", () => {
+ expect(labelize("project_admin")).toBe("Project Admin");
+ expect(lines(" internal/**\n\n docs/** ")).toEqual(["internal/**", "docs/**"]);
+ });
+
+ it("uses translated role labels when available", () => {
+ expect(roleLabel("project_admin", (key) => (key === "status.project-admin" ? "Project administrator" : key))).toBe("Project administrator");
+ });
+});
diff --git a/apps/web/src/shared/lib/formatting.ts b/apps/web/src/shared/lib/formatting.ts
new file mode 100644
index 0000000..7388e3a
--- /dev/null
+++ b/apps/web/src/shared/lib/formatting.ts
@@ -0,0 +1,82 @@
+import type { FormEvent } from "react";
+import type { Backpressure } from "../../lib/api";
+
+export function labelize(value: string) {
+ return value
+ .replaceAll("_", " ")
+ .replaceAll("-", " ")
+ .replace(/\b\w/g, (match) => match.toUpperCase());
+}
+
+export function workflowActionLabel(action: string, t: (key: string) => string) {
+ const key = `workflowAction.${action}`;
+ const translated = t(key);
+ return translated === key ? labelize(action) : translated;
+}
+
+export function formatDate(value: string | undefined, t: (key: string) => string) {
+ if (!value) {
+ return t("common.notRecorded");
+ }
+ return new Date(value).toLocaleString();
+}
+
+export function envelopeValue(envelope: Record, key: string, fallback: string) {
+ const value = envelope[key];
+ if (typeof value === "string" && value.trim()) {
+ return value;
+ }
+ if (typeof value === "number" || typeof value === "boolean") {
+ return String(value);
+ }
+ return fallback;
+}
+
+export function envelopeList(envelope: Record, key: string) {
+ const value = envelope[key];
+ if (!Array.isArray(value)) {
+ return [];
+ }
+ return value.filter((item): item is string => typeof item === "string");
+}
+
+export function policyLabel(envelope: Record, key: string, t: (key: string) => string) {
+ const policy = envelope.policy;
+ if (!policy || typeof policy !== "object" || Array.isArray(policy)) {
+ return t("policy.notSet");
+ }
+ const value = (policy as Record)[key];
+ return typeof value === "boolean" ? (value ? t("policy.allowed") : t("policy.blocked")) : t("policy.notSet");
+}
+
+export function capacityWidth(snapshot: Backpressure) {
+ const total = snapshot.nodes.reduce((sum, node) => sum + node.concurrency, 0);
+ const used = snapshot.nodes.reduce((sum, node) => sum + node.active_runs, 0);
+ if (!total) {
+ return 0;
+ }
+ return Math.min(100, Math.round((used / total) * 100));
+}
+
+export function lines(value: string) {
+ return value
+ .split("\n")
+ .map((line) => line.trim())
+ .filter(Boolean);
+}
+
+export function submit(event: FormEvent, fn: () => void) {
+ event.preventDefault();
+ fn();
+}
+
+export function roleLabel(role: string, t?: (key: string) => string) {
+ const normalized = role.toLowerCase().replaceAll("_", "-");
+ if (t) {
+ const translated = t(`status.${normalized}`);
+ if (translated !== `status.${normalized}`) {
+ return translated;
+ }
+ }
+ return labelize(role);
+}
diff --git a/apps/web/src/shared/lib/listPagination.tsx b/apps/web/src/shared/lib/listPagination.tsx
new file mode 100644
index 0000000..0e6ebc5
--- /dev/null
+++ b/apps/web/src/shared/lib/listPagination.tsx
@@ -0,0 +1,111 @@
+import { useEffect, useMemo, useState } from "react";
+import { useI18n } from "../../lib/i18n";
+
+export type ListLimit = 10 | 20 | 50;
+export type ListPager = {
+ hasNext: boolean;
+ hasPrevious: boolean;
+ limit: ListLimit;
+ page: number;
+ pageCount: number;
+ setLimit: (value: ListLimit) => void;
+ goNext: () => void;
+ goPrevious: () => void;
+ total: number;
+};
+export type ListPagination = ListPager & { items: T[] };
+
+const listLimitOptions = [10, 20, 50] as const;
+
+export function ListLimitControl({
+ label,
+ onChange,
+ value
+}: {
+ label?: string;
+ onChange: (value: ListLimit) => void;
+ value: ListLimit;
+}) {
+ const { t } = useI18n();
+ return (
+
+ {t("common.show")}
+ {listLimitOptions.map((option) => (
+
+ ))}
+
+ );
+}
+
+export function ListPaginationControl({ label, pagination }: { label?: string; pagination: ListPager }) {
+ const { t } = useI18n();
+ return (
+
+
+
+
+ {t("common.pageStatus", { page: pagination.page, pages: pagination.pageCount })}
+
+
+
+ );
+}
+
+export function useListPagination(items: T[], options: { fromEnd?: boolean } = {}): ListPagination {
+ const [limit, setLimitState] = useState(10);
+ const [page, setPage] = useState(1);
+ const total = items.length;
+ const pageCount = Math.max(1, Math.ceil(total / limit));
+ const normalizedPage = Math.min(page, pageCount);
+
+ useEffect(() => {
+ setPage((current) => Math.min(Math.max(current, 1), pageCount));
+ }, [pageCount]);
+
+ const visibleItems = useMemo(() => {
+ if (!total) {
+ return [];
+ }
+ if (options.fromEnd) {
+ const end = Math.max(total - (normalizedPage - 1) * limit, 0);
+ const start = Math.max(end - limit, 0);
+ return items.slice(start, end);
+ }
+ const start = (normalizedPage - 1) * limit;
+ return items.slice(start, start + limit);
+ }, [items, limit, normalizedPage, options.fromEnd, total]);
+
+ return {
+ hasNext: normalizedPage < pageCount,
+ hasPrevious: normalizedPage > 1,
+ items: visibleItems,
+ limit,
+ page: normalizedPage,
+ pageCount,
+ setLimit: (nextLimit: ListLimit) => {
+ setLimitState(nextLimit);
+ setPage(1);
+ },
+ goNext: () => setPage((current) => Math.min(current + 1, pageCount)),
+ goPrevious: () => setPage((current) => Math.max(current - 1, 1)),
+ total
+ };
+}
diff --git a/apps/web/src/shared/lib/pollWhileHealthy.ts b/apps/web/src/shared/lib/pollWhileHealthy.ts
new file mode 100644
index 0000000..a673827
--- /dev/null
+++ b/apps/web/src/shared/lib/pollWhileHealthy.ts
@@ -0,0 +1,3 @@
+export function pollWhileHealthy(interval: number) {
+ return (query: { state: { error: unknown } }) => (query.state.error ? false : interval);
+}
diff --git a/apps/web/src/shared/lib/savedState.test.ts b/apps/web/src/shared/lib/savedState.test.ts
new file mode 100644
index 0000000..4355366
--- /dev/null
+++ b/apps/web/src/shared/lib/savedState.test.ts
@@ -0,0 +1,13 @@
+import { describe, expect, it, beforeEach } from "vitest";
+import { readSavedString, writeSavedString } from "./savedState";
+
+describe("savedState", () => {
+ beforeEach(() => {
+ window.localStorage.clear();
+ });
+
+ it("round-trips string filters", () => {
+ writeSavedString("runs-filter", "failed");
+ expect(readSavedString("runs-filter", "all")).toBe("failed");
+ });
+});
diff --git a/apps/web/src/shared/lib/savedState.ts b/apps/web/src/shared/lib/savedState.ts
new file mode 100644
index 0000000..552c4ad
--- /dev/null
+++ b/apps/web/src/shared/lib/savedState.ts
@@ -0,0 +1,39 @@
+const prefix = "multi-codex.workbench.";
+
+export function readSavedString(key: string, fallback: string) {
+ if (typeof window === "undefined") {
+ return fallback;
+ }
+ try {
+ return window.localStorage.getItem(prefix + key) ?? fallback;
+ } catch {
+ return fallback;
+ }
+}
+
+export function writeSavedString(key: string, value: string) {
+ if (typeof window === "undefined") {
+ return;
+ }
+ try {
+ window.localStorage.setItem(prefix + key, value);
+ } catch {
+ // Ignore quota / private-mode failures; filters remain session-local.
+ }
+}
+
+export function readSavedJSON(key: string, fallback: T): T {
+ const raw = readSavedString(key, "");
+ if (!raw) {
+ return fallback;
+ }
+ try {
+ return JSON.parse(raw) as T;
+ } catch {
+ return fallback;
+ }
+}
+
+export function writeSavedJSON(key: string, value: unknown) {
+ writeSavedString(key, JSON.stringify(value));
+}
diff --git a/apps/web/src/shared/lib/useSavedFilter.ts b/apps/web/src/shared/lib/useSavedFilter.ts
new file mode 100644
index 0000000..9065bdf
--- /dev/null
+++ b/apps/web/src/shared/lib/useSavedFilter.ts
@@ -0,0 +1,15 @@
+import { useEffect, useState } from "react";
+import { readSavedString, writeSavedString } from "./savedState";
+
+export function useSavedFilter(key: string, fallback: T, allowed: readonly T[]) {
+ const [value, setValueState] = useState(() => {
+ const initial = readSavedString(key, fallback);
+ return (allowed.includes(initial as T) ? initial : fallback) as T;
+ });
+
+ useEffect(() => {
+ writeSavedString(key, value);
+ }, [key, value]);
+
+ return [value, setValueState] as const;
+}
diff --git a/apps/web/src/shared/ui/ApprovalBell.tsx b/apps/web/src/shared/ui/ApprovalBell.tsx
new file mode 100644
index 0000000..c090249
--- /dev/null
+++ b/apps/web/src/shared/ui/ApprovalBell.tsx
@@ -0,0 +1,36 @@
+import { useEffect, useRef } from "react";
+import { Link } from "react-router-dom";
+import { Bell } from "lucide-react";
+import { useI18n } from "../../lib/i18n";
+import { useToast } from "../ui/Toast";
+import { readSavedJSON, writeSavedJSON } from "../lib/savedState";
+
+export function ApprovalBell({ pendingCount }: { pendingCount: number }) {
+ const { t } = useI18n();
+ const toast = useToast();
+ const lastNotified = useRef(readSavedJSON("approvals-notified-count", 0));
+
+ useEffect(() => {
+ if (pendingCount > lastNotified.current) {
+ toast.push({
+ title: t("approvals.pendingToastTitle"),
+ description: t("approvals.pendingCount", { count: pendingCount }),
+ tone: "warning"
+ });
+ lastNotified.current = pendingCount;
+ writeSavedJSON("approvals-notified-count", pendingCount);
+ }
+ if (pendingCount < lastNotified.current) {
+ lastNotified.current = pendingCount;
+ writeSavedJSON("approvals-notified-count", pendingCount);
+ }
+ }, [pendingCount, t, toast]);
+
+ return (
+
+
+ {t("nav.approvals")}
+ {pendingCount > 0 ? {pendingCount} : null}
+
+ );
+}
diff --git a/apps/web/src/shared/ui/Button.tsx b/apps/web/src/shared/ui/Button.tsx
new file mode 100644
index 0000000..e282721
--- /dev/null
+++ b/apps/web/src/shared/ui/Button.tsx
@@ -0,0 +1,9 @@
+import { cn } from "../lib/cn";
+import type { ButtonHTMLAttributes } from "react";
+
+type ButtonVariant = "primary" | "secondary" | "link" | "danger";
+
+export function Button({ className, variant = "secondary", ...props }: ButtonHTMLAttributes & { variant?: ButtonVariant }) {
+ const variantClass = variant === "primary" ? "primary-button" : variant === "link" ? "link-button" : variant === "danger" ? "secondary-button danger" : "secondary-button";
+ return ;
+}
diff --git a/apps/web/src/shared/ui/DataTable.tsx b/apps/web/src/shared/ui/DataTable.tsx
new file mode 100644
index 0000000..7eef810
--- /dev/null
+++ b/apps/web/src/shared/ui/DataTable.tsx
@@ -0,0 +1,54 @@
+import {
+ flexRender,
+ getCoreRowModel,
+ useReactTable,
+ type ColumnDef
+} from "@tanstack/react-table";
+import { EmptyState } from "./EmptyState";
+
+export function DataTable({
+ columns,
+ data,
+ empty,
+ onRowClick,
+ selectedId,
+ getRowId
+}: {
+ columns: ColumnDef[];
+ data: T[];
+ empty: string;
+ onRowClick?: (row: T) => void;
+ selectedId?: string;
+ getRowId?: (row: T) => string;
+}) {
+ const table = useReactTable({
+ data,
+ columns,
+ getCoreRowModel: getCoreRowModel(),
+ getRowId: getRowId ? (row) => getRowId(row) : undefined
+ });
+
+ if (!data.length) {
+ return {empty};
+ }
+
+ return (
+
+ {table.getRowModel().rows.map((row) => {
+ const id = row.id;
+ return (
+
+ );
+ })}
+
+ );
+}
diff --git a/apps/web/src/shared/ui/Dialog.tsx b/apps/web/src/shared/ui/Dialog.tsx
new file mode 100644
index 0000000..71808dc
--- /dev/null
+++ b/apps/web/src/shared/ui/Dialog.tsx
@@ -0,0 +1,56 @@
+import * as DialogPrimitive from "@radix-ui/react-dialog";
+import type { ReactNode } from "react";
+import { X } from "lucide-react";
+import { cn } from "../lib/cn";
+import { Button } from "./Button";
+
+export function Dialog({
+ open,
+ onOpenChange,
+ title,
+ description,
+ children,
+ footer
+}: {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ title: string;
+ description?: string;
+ children: ReactNode;
+ footer?: ReactNode;
+}) {
+ return (
+
+
+
+
+
+
+ {title}
+ {description ? {description} : null}
+
+
+
+
+
+ {children}
+ {footer ? {footer}
: null}
+
+
+
+ );
+}
+
+export function Banner({
+ children,
+ className,
+ tone = "info"
+}: {
+ children: ReactNode;
+ className?: string;
+ tone?: "info" | "warning" | "danger" | "success";
+}) {
+ return {children}
;
+}
diff --git a/apps/web/src/shared/ui/EmptyState.tsx b/apps/web/src/shared/ui/EmptyState.tsx
new file mode 100644
index 0000000..c128845
--- /dev/null
+++ b/apps/web/src/shared/ui/EmptyState.tsx
@@ -0,0 +1,6 @@
+import type { ReactNode } from "react";
+import { cn } from "../lib/cn";
+
+export function EmptyState({ children, compact = false }: { children: ReactNode; compact?: boolean }) {
+ return {children}
;
+}
diff --git a/apps/web/src/shared/ui/Metric.tsx b/apps/web/src/shared/ui/Metric.tsx
new file mode 100644
index 0000000..578b973
--- /dev/null
+++ b/apps/web/src/shared/ui/Metric.tsx
@@ -0,0 +1,8 @@
+export function Metric({ label, value }: { label: string; value: number }) {
+ return (
+
+ {label}
+ {value}
+
+ );
+}
diff --git a/apps/web/src/shared/ui/PageHeader.tsx b/apps/web/src/shared/ui/PageHeader.tsx
new file mode 100644
index 0000000..39245a1
--- /dev/null
+++ b/apps/web/src/shared/ui/PageHeader.tsx
@@ -0,0 +1,13 @@
+import type { ReactNode } from "react";
+
+export function PageHeader({ actions, eyebrow, title }: { actions?: ReactNode; eyebrow?: ReactNode; title: ReactNode }) {
+ return (
+
+
+ {eyebrow ?
{eyebrow}
: null}
+
{title}
+
+ {actions ?
{actions}
: null}
+
+ );
+}
diff --git a/apps/web/src/shared/ui/Panel.tsx b/apps/web/src/shared/ui/Panel.tsx
new file mode 100644
index 0000000..b591d5e
--- /dev/null
+++ b/apps/web/src/shared/ui/Panel.tsx
@@ -0,0 +1,6 @@
+import type { HTMLAttributes } from "react";
+import { cn } from "../lib/cn";
+
+export function Panel({ className, ...props }: HTMLAttributes) {
+ return ;
+}
diff --git a/apps/web/src/components/StatusBadge.tsx b/apps/web/src/shared/ui/StatusBadge.tsx
similarity index 65%
rename from apps/web/src/components/StatusBadge.tsx
rename to apps/web/src/shared/ui/StatusBadge.tsx
index 23f2fd6..776e188 100644
--- a/apps/web/src/components/StatusBadge.tsx
+++ b/apps/web/src/shared/ui/StatusBadge.tsx
@@ -1,4 +1,5 @@
-import { useI18n } from "../lib/i18n";
+import { useI18n } from "../../lib/i18n";
+import { labelize } from "../lib/formatting";
type StatusBadgeProps = {
status: string;
@@ -11,10 +12,3 @@ export function StatusBadge({ status }: StatusBadgeProps) {
const label = t(key);
return {label === key ? labelize(status) : label};
}
-
-function labelize(value: string) {
- return value
- .replaceAll("_", " ")
- .replaceAll("-", " ")
- .replace(/\b\w/g, (match) => match.toUpperCase());
-}
diff --git a/apps/web/src/shared/ui/Tabs.tsx b/apps/web/src/shared/ui/Tabs.tsx
new file mode 100644
index 0000000..6802185
--- /dev/null
+++ b/apps/web/src/shared/ui/Tabs.tsx
@@ -0,0 +1 @@
+export * from "@radix-ui/react-tabs";
diff --git a/apps/web/src/shared/ui/Toast.tsx b/apps/web/src/shared/ui/Toast.tsx
new file mode 100644
index 0000000..65d3ef3
--- /dev/null
+++ b/apps/web/src/shared/ui/Toast.tsx
@@ -0,0 +1,57 @@
+import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
+import * as ToastPrimitive from "@radix-ui/react-toast";
+import { cn } from "../lib/cn";
+
+type ToastTone = "info" | "success" | "warning" | "danger";
+
+type ToastItem = {
+ id: string;
+ title: string;
+ description?: string;
+ tone: ToastTone;
+};
+
+type ToastContextValue = {
+ push: (toast: Omit) => void;
+};
+
+const ToastContext = createContext({
+ push: () => undefined
+});
+
+export function ToastProvider({ children }: { children: ReactNode }) {
+ const [items, setItems] = useState([]);
+ const push = useCallback((toast: Omit) => {
+ const id = crypto.randomUUID();
+ setItems((current) => [...current, { ...toast, id }]);
+ }, []);
+ const value = useMemo(() => ({ push }), [push]);
+
+ return (
+
+
+ {children}
+ {items.map((item) => (
+ {
+ if (!open) {
+ setItems((current) => current.filter((entry) => entry.id !== item.id));
+ }
+ }}
+ >
+ {item.title}
+ {item.description ? {item.description} : null}
+
+ ))}
+
+
+
+ );
+}
+
+export function useToast() {
+ return useContext(ToastContext);
+}
diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css
index 50109d5..2759488 100644
--- a/apps/web/src/styles.css
+++ b/apps/web/src/styles.css
@@ -1,1975 +1,3 @@
-:root {
- color-scheme: light;
- font-family:
- Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
- background: #f4f6f8;
- color: #17212c;
- font-synthesis: none;
- text-rendering: optimizeLegibility;
- --bg: #f4f6f8;
- --surface: #ffffff;
- --surface-soft: #f8fafb;
- --surface-tint: #eef7f5;
- --line: #dce3ea;
- --line-soft: #e8edf2;
- --line-strong: #c8d3dd;
- --text: #17212c;
- --muted: #607082;
- --muted-strong: #415063;
- --ink: #0f1b26;
- --brand: #183044;
- --accent: #286f76;
- --success: #1e7b55;
- --success-bg: #e5f4eb;
- --warning: #a66a00;
- --warning-bg: #fff3d6;
- --danger: #b8423a;
- --danger-bg: #fde8e5;
- --blue: #2f5f8f;
- --blue-bg: #e6f0fb;
- --radius: 8px;
- --radius-sm: 6px;
- --shadow-soft: 0 8px 28px rgba(28, 44, 62, 0.06);
- --shadow-panel: 0 1px 2px rgba(28, 44, 62, 0.04), 0 10px 26px rgba(28, 44, 62, 0.05);
-}
-
-* {
- box-sizing: border-box;
-}
-
-body {
- min-width: 320px;
- min-height: 100vh;
- margin: 0;
- background: #eef2f5;
-}
-
-a {
- color: inherit;
- text-decoration: none;
-}
-
-button,
-input,
-textarea,
-select {
- font: inherit;
-}
-
-button {
- appearance: none;
-}
-
-code,
-pre {
- font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
-}
-
-.shell {
- display: grid;
- grid-template-columns: 240px minmax(0, 1fr);
- min-height: 100vh;
-}
-
-.sidebar {
- position: sticky;
- top: 0;
- display: flex;
- flex-direction: column;
- min-width: 0;
- height: 100vh;
- border-right: 1px solid var(--line);
- background: var(--surface);
- padding: 18px 14px;
- overflow-y: auto;
- scrollbar-gutter: stable;
-}
-
-.brand {
- display: flex;
- align-items: center;
- gap: 12px;
- padding: 6px 4px 18px;
- border-bottom: 1px solid var(--line-soft);
-}
-
-.brand > div {
- min-width: 0;
-}
-
-.brand-mark {
- display: grid;
- place-items: center;
- flex: 0 0 auto;
- width: 46px;
- height: 46px;
- border-radius: var(--radius);
- background: var(--brand);
- color: #ffffff;
- font-size: 13px;
- font-weight: 800;
-}
-
-.brand h1,
-.brand p,
-.eyebrow,
-.panel h3,
-.task-row h4,
-.section-title,
-.lifecycle-step h4,
-.empty-lifecycle h4 {
- margin: 0;
-}
-
-.brand h1 {
- font-size: 18px;
- line-height: 1.1;
- white-space: nowrap;
-}
-
-.brand p {
- margin-top: 4px;
- color: var(--muted);
- font-size: 12px;
- line-height: 1.35;
-}
-
-.nav-list {
- display: grid;
- gap: 3px;
- margin-top: 18px;
- padding-bottom: 12px;
-}
-
-.nav-list a {
- display: grid;
- gap: 2px;
- min-height: 38px;
- border-radius: var(--radius-sm);
- color: var(--muted-strong);
- font-size: 14px;
- font-weight: 700;
- line-height: 18px;
- padding: 10px 12px;
-}
-
-.nav-list a.active,
-.nav-list a:hover {
- background: var(--surface-tint);
- color: #123a38;
-}
-
-.nav-list a.locked {
- color: #8a98a7;
-}
-
-.nav-list a small {
- color: var(--muted);
- font-size: 10px;
- font-weight: 700;
- text-transform: uppercase;
-}
-
-.auth-controls {
- display: grid;
- gap: 9px;
- margin-top: auto;
- border-top: 1px solid var(--line-soft);
- padding-top: 14px;
-}
-
-.language-toggle {
- display: grid;
- grid-template-columns: repeat(2, minmax(0, 1fr));
- gap: 5px;
- border: 1px solid var(--line);
- border-radius: var(--radius-sm);
- background: var(--surface-soft);
- padding: 4px;
-}
-
-.language-toggle button {
- min-height: 28px;
- border: 0;
- border-radius: 5px;
- background: transparent;
- color: var(--muted-strong);
- cursor: pointer;
- font-size: 12px;
- font-weight: 850;
-}
-
-.language-toggle button.active {
- background: #ffffff;
- box-shadow: 0 1px 4px rgba(28, 44, 62, 0.08);
- color: var(--ink);
-}
-
-.auth-status {
- display: grid;
- gap: 4px;
-}
-
-.auth-status span,
-.topbar-selectors span,
-.branch-pill span,
-.metric span,
-.eyebrow {
- color: var(--muted);
- font-size: 11px;
- font-weight: 800;
- text-transform: uppercase;
-}
-
-.auth-status strong {
- color: var(--muted-strong);
- font-size: 12px;
- line-height: 1.35;
- overflow-wrap: anywhere;
-}
-
-.permission-stack {
- display: flex;
- flex-wrap: wrap;
- gap: 5px;
-}
-
-.permission-stack code,
-.access-notice code,
-.login-meta code,
-.access-panel code {
- border: 1px solid var(--line);
- border-radius: 999px;
- background: var(--surface-soft);
- color: var(--muted-strong);
- font-size: 11px;
- padding: 4px 7px;
-}
-
-.auth-controls input,
-.form-grid input,
-.form-grid textarea,
-.scope-form textarea,
-.title-select {
- width: 100%;
- min-width: 0;
- border: 1px solid #c7d0da;
- border-radius: var(--radius-sm);
- background: #ffffff;
- color: var(--text);
- outline: none;
-}
-
-.auth-controls input {
- padding: 8px 9px;
-}
-
-.auth-controls input:focus,
-.form-grid input:focus,
-.form-grid textarea:focus,
-.scope-form textarea:focus,
-.title-select:focus {
- border-color: #7ca9ae;
- box-shadow: 0 0 0 3px rgba(40, 111, 118, 0.12);
-}
-
-.auth-actions {
- display: grid;
- grid-template-columns: 1fr;
- gap: 7px;
-}
-
-.workspace {
- min-width: 0;
- padding: 18px 20px 26px;
-}
-
-.topbar {
- display: flex;
- align-items: end;
- justify-content: space-between;
- flex-wrap: wrap;
- gap: 18px;
- margin-bottom: 12px;
-}
-
-.topbar-selectors {
- display: grid;
- grid-template-columns: minmax(190px, 1fr) minmax(190px, 1fr) minmax(128px, 0.48fr);
- gap: 12px;
- width: min(840px, 100%);
-}
-
-.topbar-selectors label,
-.branch-pill {
- display: grid;
- gap: 6px;
- min-width: 0;
-}
-
-.title-select {
- min-height: 38px;
- padding: 8px 11px;
- color: var(--ink);
- font-size: 14px;
- font-weight: 750;
-}
-
-.branch-pill {
- min-height: 59px;
- align-content: end;
- border: 1px solid var(--line);
- border-radius: var(--radius-sm);
- background: var(--surface);
- padding: 8px 11px;
-}
-
-.branch-pill strong {
- overflow: hidden;
- font-size: 14px;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.system-strip,
-.actions,
-.action-bar,
-.gate-strip,
-.scope-result,
-.task-meta,
-.workflow-actions,
-.step-heading,
-.section-title-row,
-.capacity-summary,
-.node-metrics {
- display: flex;
- align-items: center;
-}
-
-.system-strip {
- flex-wrap: wrap;
- justify-content: flex-end;
- gap: 8px;
-}
-
-.summary-strip {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(132px, 1fr));
- gap: 10px;
- margin-bottom: 12px;
-}
-
-.metric {
- min-width: 0;
- border: 1px solid var(--line);
- border-radius: var(--radius);
- background: var(--surface);
- padding: 10px 12px;
-}
-
-.metric strong {
- display: block;
- margin-top: 3px;
- color: var(--ink);
- font-size: 22px;
- line-height: 1.1;
-}
-
-.view-host {
- min-width: 0;
-}
-
-.login-page {
- display: grid;
- grid-template-columns: minmax(320px, 0.9fr) minmax(320px, 520px);
- gap: 28px;
- align-items: center;
- width: min(1120px, calc(100% - 32px));
- min-height: 100dvh;
- margin: 0 auto;
- padding: 32px 0;
-}
-
-.login-hero {
- display: grid;
- gap: 28px;
- min-width: 0;
-}
-
-.login-brand {
- display: flex;
- align-items: center;
- gap: 12px;
-}
-
-.login-brand h1,
-.login-brand p,
-.login-copy h2,
-.login-copy p {
- margin: 0;
-}
-
-.login-brand h1 {
- color: var(--ink);
- font-size: 22px;
- line-height: 1.1;
-}
-
-.login-brand p {
- margin-top: 4px;
- color: var(--muted);
- font-size: 13px;
- font-weight: 750;
-}
-
-.login-copy {
- display: grid;
- gap: 12px;
- max-width: 620px;
-}
-
-.login-copy h2 {
- color: var(--ink);
- font-size: 42px;
- line-height: 1.08;
- max-width: 12ch;
-}
-
-.login-copy p:not(.eyebrow) {
- max-width: 560px;
- color: var(--muted-strong);
- font-size: 16px;
- line-height: 1.65;
-}
-
-.login-assurance {
- display: flex;
- flex-wrap: wrap;
- gap: 8px;
-}
-
-.login-assurance span {
- border: 1px solid var(--line);
- border-radius: var(--radius-sm);
- background: var(--surface);
- color: var(--muted-strong);
- font-size: 12px;
- font-weight: 850;
- padding: 7px 9px;
-}
-
-.login-panel,
-.access-panel {
- display: grid;
- gap: 16px;
- max-width: 720px;
- padding: 22px;
-}
-
-.login-panel h2,
-.access-panel h2 {
- margin: 5px 0 8px;
- color: var(--ink);
- font-size: 24px;
- line-height: 1.2;
-}
-
-.login-panel p,
-.access-panel p {
- margin: 0;
- color: var(--muted);
- font-size: 14px;
- line-height: 1.55;
-}
-
-.login-meta,
-.login-actions,
-.credential-login,
-.advanced-login,
-.token-exchange {
- display: grid;
- gap: 9px;
-}
-
-.login-meta {
- align-items: start;
-}
-
-.login-meta span {
- color: var(--muted-strong);
- font-size: 13px;
- line-height: 1.45;
-}
-
-.token-exchange {
- grid-template-columns: minmax(220px, 1fr) auto;
- align-items: end;
-}
-
-.credential-login {
- grid-template-columns: 1fr;
-}
-
-.credential-login label,
-.token-exchange label {
- display: grid;
- gap: 6px;
- color: var(--muted-strong);
- font-size: 12px;
- font-weight: 800;
-}
-
-.credential-login input,
-.token-exchange input {
- width: 100%;
- min-height: 38px;
- border: 1px solid #c7d0da;
- border-radius: var(--radius-sm);
- color: var(--text);
- padding: 8px 10px;
-}
-
-.advanced-login {
- border-top: 1px solid var(--line-soft);
- padding-top: 4px;
-}
-
-.advanced-login summary {
- color: var(--muted-strong);
- cursor: pointer;
- font-size: 12px;
- font-weight: 850;
-}
-
-.access-notice {
- display: grid;
- gap: 6px;
- margin: 12px 14px 0;
- border: 1px solid #edd69a;
- border-radius: var(--radius-sm);
- background: var(--warning-bg);
- color: var(--warning);
- font-size: 12px;
- font-weight: 750;
- padding: 9px 10px;
-}
-
-.access-notice code {
- justify-self: start;
- border-color: #e6cd88;
- background: #fff8e8;
-}
-
-.cockpit-grid {
- display: grid;
- grid-template-columns: minmax(260px, 0.78fr) minmax(380px, 1.14fr) minmax(280px, 0.9fr);
- gap: 12px;
- align-items: start;
-}
-
-.cockpit-stack {
- display: grid;
- gap: 12px;
- min-width: 0;
-}
-
-.panel {
- min-width: 0;
- border: 1px solid var(--line);
- border-radius: var(--radius);
- background: var(--surface);
- box-shadow: var(--shadow-panel);
-}
-
-.cockpit-panel {
- min-width: 0;
-}
-
-.panel-heading {
- display: flex;
- align-items: center;
- justify-content: space-between;
- flex-wrap: wrap;
- gap: 12px;
- min-height: 54px;
- border-bottom: 1px solid var(--line-soft);
- padding: 12px 14px;
-}
-
-.panel-heading > div:first-child {
- min-width: 0;
-}
-
-.panel-heading h3 {
- min-width: 0;
- overflow-wrap: anywhere;
- color: var(--ink);
- font-size: 15px;
- line-height: 1.2;
-}
-
-.panel-heading span {
- color: var(--muted);
- font-size: 12px;
-}
-
-.panel-heading-actions,
-.inline-list-toolbar {
- display: flex;
- align-items: center;
- justify-content: flex-end;
- flex-wrap: wrap;
- gap: 8px;
- min-width: 0;
-}
-
-.panel-heading-actions {
- margin-left: auto;
-}
-
-.inline-list-toolbar {
- justify-content: space-between;
- border-top: 1px solid var(--line-soft);
- padding: 10px 14px;
-}
-
-.inline-list-toolbar span {
- color: var(--muted);
- font-size: 12px;
-}
-
-.list-pagination-control {
- display: inline-flex;
- align-items: center;
- flex-wrap: wrap;
- gap: 5px;
- min-width: 0;
-}
-
-.list-limit-control {
- display: inline-flex;
- align-items: center;
- flex: 0 0 auto;
- gap: 2px;
- min-height: 28px;
- border: 1px solid var(--line);
- border-radius: var(--radius-sm);
- background: var(--surface-soft);
- padding: 2px;
-}
-
-.list-limit-control span {
- padding: 0 6px;
- color: var(--muted);
- font-size: 11px;
- font-weight: 800;
-}
-
-.list-limit-control button {
- min-width: 30px;
- min-height: 22px;
- border: 1px solid transparent;
- border-radius: 4px;
- background: transparent;
- color: var(--muted-strong);
- cursor: pointer;
- font-size: 11px;
- font-weight: 850;
- line-height: 1;
- padding: 4px 6px;
-}
-
-.list-limit-control button:hover,
-.list-limit-control button.active {
- border-color: #b9d8d5;
- background: #ffffff;
- color: #163d3b;
-}
-
-.pager-control {
- display: inline-flex;
- align-items: center;
- flex: 0 0 auto;
- gap: 4px;
- min-height: 28px;
- border: 1px solid var(--line);
- border-radius: var(--radius-sm);
- background: #ffffff;
- padding: 2px;
-}
-
-.pager-control button {
- min-height: 22px;
- border: 1px solid transparent;
- border-radius: 4px;
- background: transparent;
- color: var(--muted-strong);
- cursor: pointer;
- font-size: 11px;
- font-weight: 850;
- line-height: 1;
- padding: 4px 6px;
-}
-
-.pager-control button:hover:not(:disabled) {
- border-color: #c7d0da;
- background: var(--surface-soft);
-}
-
-.pager-control button:disabled {
- cursor: not-allowed;
- opacity: 0.45;
-}
-
-.pager-control span {
- min-width: 36px;
- color: var(--muted);
- font-size: 11px;
- font-weight: 850;
- text-align: center;
-}
-
-.segment-group {
- display: grid;
- grid-template-columns: repeat(5, minmax(0, 1fr));
- gap: 5px;
- padding: 10px 10px 8px;
-}
-
-.segment-button {
- display: grid;
- gap: 3px;
- min-width: 0;
- min-height: 48px;
- border: 1px solid transparent;
- border-radius: var(--radius-sm);
- background: var(--surface-soft);
- color: var(--muted-strong);
- cursor: pointer;
- font-size: 11px;
- font-weight: 750;
- padding: 7px 4px;
- text-align: center;
-}
-
-.segment-button strong {
- color: var(--ink);
- font-size: 14px;
-}
-
-.segment-button.active {
- border-color: #b9d8d5;
- background: var(--surface-tint);
- color: #163d3b;
-}
-
-.task-list,
-.table-list,
-.run-list,
-.audit-list,
-.event-list,
-.node-list,
-.version-list,
-.capacity-list {
- display: grid;
-}
-
-.dashboard-list,
-.bounded-list,
-.cockpit-task-list {
- overflow: auto;
- overscroll-behavior: contain;
- scrollbar-gutter: stable;
-}
-
-.dashboard-list {
- max-height: clamp(260px, 34vh, 390px);
-}
-
-.bounded-list {
- max-height: min(640px, calc(100vh - 260px));
-}
-
-.compact-bounded-list {
- max-height: min(360px, calc(100vh - 340px));
-}
-
-.cockpit-task-list {
- max-height: clamp(320px, 44vh, 520px);
-}
-
-.table-list.bounded-list,
-.task-list.bounded-list,
-.run-list.bounded-list,
-.audit-list.bounded-list,
-.project-access-list.bounded-list,
-.permission-stack.bounded-list {
- min-height: 180px;
-}
-
-.table-list.dashboard-list,
-.audit-list.dashboard-list,
-.capacity-list.dashboard-list,
-.cockpit-task-list.dashboard-list {
- min-height: 180px;
-}
-
-.event-list {
- min-height: 140px;
-}
-
-.version-shell {
- border-top: 1px solid var(--line-soft);
-}
-
-.version-shell .version-list {
- border-top: 0;
-}
-
-.artifact-shell .inline-list-toolbar {
- border-bottom: 1px solid var(--line-soft);
-}
-
-.artifact-list.bounded-list {
- min-height: 140px;
-}
-
-.event-list {
- max-height: min(420px, calc(100vh - 340px));
- overflow: auto;
- overscroll-behavior: contain;
- scrollbar-gutter: stable;
-}
-
-.task-row {
- display: grid;
- grid-template-columns: minmax(0, 1fr) auto;
- align-items: center;
- gap: 10px;
- width: 100%;
- border: 0;
- border-top: 1px solid var(--line-soft);
- background: transparent;
- color: inherit;
- cursor: pointer;
- padding: 11px 12px;
- text-align: left;
-}
-
-.task-row.active,
-.task-row:hover,
-.selectable-row:hover,
-.selectable-row.is-selected,
-.artifact-row:hover,
-.artifact-row.is-selected {
- background: #f1f7f6;
-}
-
-.task-key {
- color: var(--muted);
- font-size: 11px;
- font-weight: 800;
-}
-
-.task-row h4 {
- margin-top: 3px;
- color: var(--ink);
- font-size: 13px;
- line-height: 1.35;
- display: -webkit-box;
- overflow: hidden;
- -webkit-box-orient: vertical;
- -webkit-line-clamp: 2;
- white-space: normal;
-}
-
-.queue-stats,
-.metric-grid {
- display: grid;
- grid-template-columns: repeat(2, minmax(0, 1fr));
- gap: 8px;
- padding: 12px;
-}
-
-.queue-stats .metric,
-.metric-grid .metric {
- box-shadow: none;
-}
-
-.capacity-list {
- gap: 8px;
- padding: 0 12px 12px;
-}
-
-.capacity-row {
- display: grid;
- grid-template-columns: minmax(0, 1fr) minmax(76px, 100px) auto;
- align-items: center;
- gap: 9px;
- border-top: 1px solid var(--line-soft);
- padding-top: 9px;
-}
-
-.capacity-row strong,
-.capacity-row span {
- display: block;
- min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.capacity-row span,
-.capacity-row code {
- color: var(--muted);
- font-size: 11px;
-}
-
-.meter,
-.node-meter {
- height: 7px;
- overflow: hidden;
- border-radius: 999px;
- background: #dfe6ed;
-}
-
-.meter span,
-.node-meter span {
- display: block;
- height: 100%;
- border-radius: inherit;
- background: var(--success);
-}
-
-.primary-button,
-.secondary-button,
-.link-button {
- min-height: 34px;
- border-radius: var(--radius-sm);
- cursor: pointer;
- font-size: 13px;
- font-weight: 800;
- line-height: 1.2;
- padding: 7px 11px;
- text-align: center;
-}
-
-.primary-button {
- border: 1px solid var(--brand);
- background: var(--brand);
- color: #ffffff;
-}
-
-.primary-button:hover {
- background: #102437;
-}
-
-.secondary-button,
-.link-button {
- border: 1px solid #c7d0da;
- background: #ffffff;
- color: var(--muted-strong);
-}
-
-.secondary-button:hover,
-.link-button:hover {
- border-color: #aab7c4;
- background: var(--surface-soft);
-}
-
-.secondary-button.compact,
-.link-button {
- min-height: 30px;
- padding: 5px 9px;
- font-size: 12px;
-}
-
-.secondary-button.danger {
- border-color: #efb9b3;
- color: var(--danger);
-}
-
-.primary-button:disabled,
-.secondary-button:disabled {
- cursor: not-allowed;
- opacity: 0.55;
-}
-
-.compact-actions {
- justify-content: space-between;
- gap: 8px;
- border-top: 1px solid var(--line-soft);
- padding: 12px;
-}
-
-.lifecycle-panel {
- min-height: 0;
-}
-
-.lifecycle-header {
- display: flex;
- justify-content: space-between;
- gap: 14px;
- border-bottom: 1px solid var(--line-soft);
- padding: 14px;
-}
-
-.task-title-line {
- display: flex;
- align-items: center;
- gap: 10px;
- min-width: 0;
- margin-top: 5px;
-}
-
-.task-title-line code {
- flex: 0 0 auto;
- border: 1px solid var(--line);
- border-radius: var(--radius-sm);
- background: var(--surface-soft);
- color: var(--muted-strong);
- font-size: 12px;
- padding: 4px 7px;
-}
-
-.task-title-line h3 {
- min-width: 0;
- overflow-wrap: anywhere;
- color: var(--ink);
- font-size: 18px;
- line-height: 1.2;
-}
-
-.task-meta,
-.workflow-actions {
- flex-wrap: wrap;
- gap: 8px;
- margin-top: 9px;
-}
-
-.task-meta span {
- color: var(--muted);
- font-size: 12px;
-}
-
-.actions {
- flex-wrap: wrap;
- justify-content: flex-end;
- gap: 8px;
-}
-
-.blocker-strip,
-.ready-strip {
- margin: 12px 14px 0;
- border-radius: var(--radius-sm);
- padding: 9px 10px;
- font-size: 12px;
- font-weight: 750;
-}
-
-.blocker-strip {
- display: flex;
- flex-wrap: wrap;
- gap: 7px;
- background: var(--danger-bg);
- color: var(--danger);
-}
-
-.ready-strip {
- background: var(--success-bg);
- color: var(--success);
-}
-
-.workflow-actions {
- padding: 0 14px 12px;
-}
-
-.lifecycle-steps {
- display: grid;
- gap: 10px;
- padding: 0 14px 14px;
-}
-
-.lifecycle-step {
- border: 1px solid var(--line);
- border-radius: var(--radius);
- background: #ffffff;
- overflow: hidden;
-}
-
-.step-heading {
- justify-content: space-between;
- gap: 10px;
- border-bottom: 1px solid var(--line-soft);
- background: var(--surface-soft);
- padding: 10px 11px;
-}
-
-.step-heading > div {
- min-width: 0;
- margin-right: auto;
-}
-
-.step-heading h4 {
- color: var(--ink);
- font-size: 14px;
-}
-
-.step-heading span {
- display: block;
- margin-top: 2px;
- color: var(--muted);
- font-size: 12px;
- overflow-wrap: anywhere;
-}
-
-.step-rows,
-.run-facts,
-.detail-list {
- display: grid;
- grid-template-columns: repeat(2, minmax(0, 1fr));
- gap: 9px;
- margin: 0;
- padding: 11px;
-}
-
-.step-rows div,
-.run-facts div,
-.detail-list div {
- min-width: 0;
-}
-
-.step-rows dt,
-.run-facts dt,
-.detail-list dt {
- color: var(--muted);
- font-size: 11px;
- font-weight: 800;
-}
-
-.step-rows dd,
-.run-facts dd,
-.detail-list dd {
- margin: 3px 0 0;
- color: var(--muted-strong);
- font-size: 12px;
- overflow-wrap: anywhere;
-}
-
-.scope-form {
- display: grid;
- grid-template-columns: minmax(0, 1fr) auto;
- gap: 9px;
- align-items: end;
- padding: 11px;
-}
-
-.cockpit-scope-form {
- padding-bottom: 0;
-}
-
-.scope-form textarea {
- min-height: 72px;
- resize: vertical;
- padding: 9px 10px;
-}
-
-.scope-result {
- flex-wrap: wrap;
- gap: 8px;
- padding: 10px 11px 11px;
-}
-
-.scope-result span,
-.scope-result code {
- color: var(--muted);
- font-size: 12px;
-}
-
-.empty-lifecycle {
- display: grid;
- align-content: center;
- justify-items: start;
- min-height: 420px;
- padding: 22px;
-}
-
-.empty-lifecycle h4 {
- color: var(--ink);
- font-size: 18px;
-}
-
-.empty-lifecycle p {
- max-width: 460px;
- margin: 8px 0 16px;
- color: var(--muted);
- font-size: 14px;
- line-height: 1.55;
-}
-
-.run-facts {
- border-bottom: 1px solid var(--line-soft);
-}
-
-.event-list,
-.audit-list {
- gap: 8px;
- padding: 11px;
-}
-
-.event-row,
-.audit-row,
-.run-row,
-.version-row,
-.node-row {
- min-width: 0;
- border: 1px solid var(--line-soft);
- border-radius: var(--radius-sm);
- background: #ffffff;
-}
-
-.event-row {
- display: grid;
- grid-template-columns: 34px minmax(96px, 0.5fr) minmax(0, 1fr);
- gap: 9px;
- align-items: center;
- padding: 8px 9px;
-}
-
-.event-row span,
-.event-row strong,
-.event-row p,
-.audit-row span,
-.audit-row code {
- min-width: 0;
-}
-
-.event-row span {
- color: var(--muted);
- font-size: 11px;
-}
-
-.event-row strong {
- overflow: hidden;
- color: var(--muted-strong);
- font-size: 12px;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.event-row p {
- margin: 0;
- overflow-wrap: anywhere;
- color: var(--text);
- font-size: 12px;
-}
-
-.stream-state {
- justify-self: start;
- border: 1px solid var(--line);
- border-radius: 999px;
- color: var(--muted-strong);
- font-size: 11px;
- font-weight: 800;
- padding: 3px 8px;
-}
-
-.stream-state.live {
- border-color: #bddfc9;
- background: var(--success-bg);
- color: var(--success);
-}
-
-.stream-state.fallback {
- border-color: #edd69a;
- background: var(--warning-bg);
- color: var(--warning);
-}
-
-.compact-evidence .data-row {
- grid-template-columns: minmax(0, 1fr) auto minmax(80px, 0.65fr);
-}
-
-.compact-evidence .data-row code,
-.compact-evidence .data-row span {
- font-size: 11px;
-}
-
-.evidence-row {
- padding-block: 9px;
-}
-
-.content-grid {
- display: grid;
- grid-template-columns: minmax(300px, 400px) minmax(0, 1fr);
- gap: 12px;
- align-items: start;
-}
-
-.tasks-layout,
-.runs-layout {
- grid-template-columns: minmax(300px, 380px) minmax(0, 1fr);
-}
-
-.queue-layout,
-.audit-layout {
- grid-template-columns: minmax(0, 1fr) minmax(340px, 0.72fr);
-}
-
-.skills-layout {
- grid-template-columns: repeat(2, minmax(0, 1fr));
-}
-
-.admin-grid {
- display: grid;
- grid-template-columns: minmax(0, 1fr) minmax(300px, 360px);
- gap: 12px;
- align-items: start;
-}
-
-.admin-main,
-.admin-side {
- display: grid;
- gap: 12px;
- min-width: 0;
-}
-
-.admin-user-form {
- grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
- align-items: end;
-}
-
-.admin-member-form {
- grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
- align-items: end;
-}
-
-.admin-user-row {
- grid-template-columns: minmax(220px, 1.3fr) auto minmax(96px, 0.45fr) minmax(120px, 0.55fr);
-}
-
-.admin-member-row {
- grid-template-columns: minmax(220px, 1.2fr) auto minmax(150px, 0.7fr) minmax(110px, 0.45fr);
-}
-
-.project-access-list {
- display: grid;
- gap: 8px;
- padding: 12px;
-}
-
-.project-access-row {
- display: grid;
- grid-template-columns: minmax(0, 1fr) auto;
- align-items: center;
- gap: 10px;
- width: 100%;
- border: 1px solid var(--line-soft);
- border-radius: var(--radius-sm);
- background: #ffffff;
- color: inherit;
- cursor: pointer;
- padding: 10px;
- text-align: left;
-}
-
-.project-access-row:hover,
-.project-access-row.is-selected {
- border-color: #b9d8d5;
- background: var(--surface-tint);
-}
-
-.project-access-row span,
-.project-access-row strong,
-.project-access-row code {
- display: block;
- min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.project-access-row strong {
- color: var(--ink);
- font-size: 13px;
-}
-
-.project-access-row code,
-.project-access-row em {
- color: var(--muted);
- font-size: 12px;
- font-style: normal;
-}
-
-.user-access-card {
- display: grid;
- gap: 10px;
-}
-
-.user-project-stack {
- padding: 0 14px 14px;
-}
-
-.dashboard-grid {
- display: grid;
- grid-template-columns: minmax(320px, 0.9fr) minmax(320px, 1.1fr);
- gap: 12px;
-}
-
-.form-grid {
- display: grid;
- gap: 11px;
- padding: 14px;
-}
-
-.form-grid.bordered {
- border-top: 1px solid var(--line-soft);
-}
-
-.inline-form {
- grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
- align-items: end;
-}
-
-.node-form {
- grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
-}
-
-.node-form button {
- align-self: end;
- justify-self: start;
-}
-
-.form-grid label,
-.scope-form label {
- display: grid;
- gap: 6px;
- min-width: 0;
- color: var(--muted-strong);
- font-size: 12px;
- font-weight: 800;
-}
-
-.form-grid input,
-.form-grid textarea {
- padding: 9px 10px;
-}
-
-.form-grid textarea {
- min-height: 88px;
- resize: vertical;
-}
-
-.form-grid .checkbox-label {
- display: flex;
- align-items: center;
- gap: 8px;
-}
-
-.form-grid .checkbox-label input {
- width: auto;
-}
-
-.data-row {
- display: grid;
- grid-template-columns: minmax(210px, 1.3fr) auto minmax(90px, 0.5fr) minmax(170px, 1fr);
- align-items: center;
- gap: 10px;
- min-width: 0;
- border-bottom: 1px solid var(--line-soft);
- padding: 12px 14px;
-}
-
-.data-row:last-child {
- border-bottom: 0;
-}
-
-.data-row strong,
-.data-row span,
-.data-row code {
- min-width: 0;
-}
-
-.data-row strong {
- display: block;
- overflow: hidden;
- color: var(--ink);
- font-size: 13px;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.data-row span,
-.data-row code {
- display: block;
- color: var(--muted);
- font-size: 12px;
- overflow-wrap: anywhere;
-}
-
-.data-row:has(> :nth-child(5)) {
- grid-template-columns: minmax(190px, 1.2fr) auto minmax(90px, 0.45fr) minmax(150px, 0.9fr) minmax(130px, 0.7fr);
-}
-
-.artifact-row,
-.selectable-row {
- width: 100%;
- border-left: 0;
- border-right: 0;
- border-top: 0;
- background: transparent;
- color: inherit;
- cursor: pointer;
- font: inherit;
- text-align: left;
-}
-
-.compact-table .data-row {
- grid-template-columns: minmax(180px, 1fr) minmax(90px, 0.4fr) minmax(120px, 0.6fr) minmax(180px, 1fr);
-}
-
-.artifact-shell {
- display: grid;
- gap: 12px;
- padding-bottom: 14px;
-}
-
-.artifact-list {
- border-bottom: 1px solid var(--line-soft);
-}
-
-.artifact-content-panel {
- margin: 0 14px;
- border: 1px solid var(--line);
- border-radius: var(--radius-sm);
- overflow: hidden;
-}
-
-.artifact-content-heading {
- display: flex;
- align-items: center;
- flex-wrap: wrap;
- gap: 10px;
- border-bottom: 1px solid var(--line);
- background: var(--surface-soft);
- padding: 9px 11px;
-}
-
-.artifact-content-heading strong {
- min-width: 0;
- overflow: hidden;
- color: var(--ink);
- font-size: 13px;
- text-overflow: ellipsis;
-}
-
-.artifact-content-heading code,
-.artifact-content-heading span {
- color: var(--muted);
- font-size: 12px;
-}
-
-.artifact-view {
- max-height: 430px;
- overflow: auto;
- margin: 0;
- background: #101720;
- color: #dce6f2;
- font-size: 12px;
- line-height: 1.55;
- padding: 12px;
- white-space: pre-wrap;
- word-break: break-word;
-}
-
-.diff-view {
- color: #dff3e4;
-}
-
-.detail-panel {
- min-width: 0;
-}
-
-.detail-list {
- padding: 14px;
-}
-
-.section-title {
- border-top: 1px solid var(--line-soft);
- color: var(--ink);
- font-size: 13px;
- padding: 13px 14px 0;
-}
-
-.run-list {
- gap: 8px;
- padding: 11px 14px 14px;
-}
-
-.run-row {
- display: grid;
- grid-template-columns: minmax(80px, 1fr) auto minmax(80px, 0.6fr) minmax(120px, 0.9fr);
- align-items: center;
- gap: 9px;
- padding: 9px 10px;
-}
-
-.run-row code {
- min-width: 0;
- color: var(--muted);
- font-size: 12px;
- overflow-wrap: anywhere;
-}
-
-.gate-strip {
- flex-wrap: wrap;
- gap: 8px;
- padding: 11px 14px 14px;
-}
-
-.gate-ok,
-.gate-blocked {
- border-radius: 999px;
- font-size: 12px;
- font-weight: 800;
- padding: 5px 9px;
-}
-
-.gate-ok {
- background: var(--success-bg);
- color: var(--success);
-}
-
-.gate-blocked {
- background: var(--danger-bg);
- color: var(--danger);
-}
-
-.audit-panel {
- margin-top: 12px;
-}
-
-.audit-row {
- display: grid;
- grid-template-columns: minmax(130px, 1fr) minmax(0, 1.7fr);
- gap: 9px;
- padding: 8px 9px;
-}
-
-.audit-row span,
-.audit-row div,
-.audit-row code {
- display: block;
- min-width: 0;
- overflow-wrap: anywhere;
-}
-
-.audit-row code {
- color: var(--muted);
- font-size: 12px;
-}
-
-.json-view {
- max-height: 360px;
- overflow: auto;
- margin: 11px 14px 14px;
- border: 1px solid var(--line);
- border-radius: var(--radius-sm);
- background: var(--surface-soft);
- color: var(--muted-strong);
- font-size: 12px;
- padding: 12px;
-}
-
-.version-list {
- gap: 8px;
- border-top: 1px solid var(--line-soft);
- padding: 11px 14px 14px;
-}
-
-.version-row {
- display: grid;
- grid-template-columns: minmax(70px, 0.4fr) minmax(140px, 0.8fr) minmax(180px, 1fr);
- align-items: center;
- gap: 9px;
- padding: 9px 10px;
-}
-
-.version-row strong,
-.version-row code {
- min-width: 0;
- overflow-wrap: anywhere;
-}
-
-.node-section {
- margin: 12px 14px 14px;
-}
-
-.section-title-row {
- justify-content: space-between;
- gap: 12px;
-}
-
-.capacity-summary,
-.node-metrics {
- flex-wrap: wrap;
- gap: 8px;
- color: var(--muted);
- font-size: 12px;
-}
-
-.node-list {
- gap: 8px;
-}
-
-.node-row {
- display: grid;
- grid-template-columns: minmax(0, 1fr) auto minmax(72px, 96px) auto;
- gap: 10px;
- align-items: center;
- border-width: 1px 0 0;
- border-radius: 0;
- padding: 10px 0;
-}
-
-.node-primary {
- min-width: 0;
-}
-
-.node-primary strong,
-.node-primary code {
- display: block;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.node-primary code {
- color: var(--muted);
- font-size: 12px;
-}
-
-.empty-state {
- color: var(--muted);
- font-size: 13px;
- line-height: 1.45;
- padding: 20px 14px;
-}
-
-.empty-state.compact {
- padding: 10px 0;
-}
-
-.status {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- min-height: 22px;
- max-width: 180px;
- border-radius: 999px;
- background: #edf2f6;
- color: var(--muted-strong);
- font-size: 11px;
- font-weight: 850;
- line-height: 1;
- overflow: hidden;
- padding: 4px 8px;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.status-running,
-.status-started,
-.status-validating,
-.status-validated,
-.status-ready,
-.status-operational,
-.status-enabled,
-.status-active,
-.status-completed,
-.status-succeeded,
-.status-approved {
- background: var(--success-bg);
- color: var(--success);
-}
-
-.status-blocked,
-.status-failed,
-.status-rejected,
-.status-auth-required {
- background: var(--danger-bg);
- color: var(--danger);
-}
-
-.status-disabled,
-.status-pending,
-.status-queued,
-.status-created,
-.status-setup,
-.status-waiting,
-.status-checking {
- background: var(--warning-bg);
- color: var(--warning);
-}
-
-.status-idle {
- background: #eef2f5;
- color: var(--muted-strong);
-}
-
-.status-docker,
-.status-ssh {
- background: var(--blue-bg);
- color: var(--blue);
-}
-
-@media (max-width: 1360px) {
- .shell {
- grid-template-columns: 216px minmax(0, 1fr);
- }
-}
-
-@media (max-width: 1180px) {
- .cockpit-grid {
- grid-template-columns: minmax(270px, 0.9fr) minmax(0, 1.2fr);
- }
-
- .evidence-stack {
- grid-column: 1 / -1;
- grid-template-columns: repeat(2, minmax(0, 1fr));
- }
-}
-
-@media (max-width: 980px) {
- .shell {
- grid-template-columns: 1fr;
- }
-
- .sidebar {
- position: static;
- height: auto;
- border-right: 0;
- border-bottom: 1px solid var(--line);
- }
-
- .auth-controls {
- grid-template-columns: repeat(2, minmax(0, 1fr));
- margin-top: 12px;
- }
-
- .language-toggle,
- .permission-stack,
- .auth-actions {
- grid-column: 1 / -1;
- }
-
- .nav-list {
- display: flex;
- max-width: 100%;
- overflow-x: auto;
- padding-bottom: 4px;
- }
-
- .nav-list a {
- flex: 0 0 auto;
- white-space: nowrap;
- }
-
- .topbar,
- .system-strip {
- align-items: stretch;
- flex-direction: column;
- }
-
- .topbar-selectors,
- .summary-strip,
- .cockpit-grid,
- .content-grid,
- .admin-grid,
- .admin-user-form,
- .admin-member-form,
- .dashboard-grid,
- .inline-form,
- .node-form,
- .scope-form,
- .token-exchange,
- .metric-grid,
- .evidence-stack {
- grid-template-columns: 1fr;
- }
-
- .lifecycle-header {
- flex-direction: column;
- }
-
- .actions {
- justify-content: flex-start;
- }
-
- .login-page {
- align-items: stretch;
- grid-template-columns: 1fr;
- min-height: auto;
- }
-
- .login-copy h2 {
- max-width: 760px;
- font-size: 34px;
- }
-}
-
-@media (max-width: 680px) {
- .workspace {
- padding: 12px;
- }
-
- .summary-strip {
- grid-template-columns: repeat(2, minmax(0, 1fr));
- }
-
- .queue-stats,
- .step-rows,
- .run-facts,
- .detail-list {
- grid-template-columns: 1fr;
- }
-
- .segment-group {
- grid-template-columns: repeat(2, minmax(0, 1fr));
- }
-
- .event-row,
- .audit-row,
- .version-row,
- .data-row,
- .run-row,
- .capacity-row,
- .node-row,
- .admin-user-row,
- .admin-member-row,
- .compact-evidence .data-row {
- grid-template-columns: 1fr;
- }
-
- .login-page {
- width: min(100% - 24px, 560px);
- padding: 18px 0;
- }
-
- .login-copy h2 {
- font-size: 28px;
- }
-
- .project-access-row {
- grid-template-columns: 1fr;
- }
-
- .task-title-line {
- align-items: flex-start;
- flex-direction: column;
- }
-
- .task-title-line h3 {
- white-space: normal;
- }
-}
+@import "tailwindcss";
+@import "./styles/tokens.css";
+@import "./styles/workbench.css";
diff --git a/apps/web/src/styles/tokens.css b/apps/web/src/styles/tokens.css
new file mode 100644
index 0000000..c2aa80c
--- /dev/null
+++ b/apps/web/src/styles/tokens.css
@@ -0,0 +1,59 @@
+@theme {
+ --font-sans: "IBM Plex Sans", "Segoe UI", sans-serif;
+ --font-mono: "IBM Plex Mono", "SFMono-Regular", Consolas, monospace;
+ --color-bg: #eef2f5;
+ --color-surface: #ffffff;
+ --color-surface-soft: #f8fafb;
+ --color-surface-tint: #eef7f5;
+ --color-line: #dce3ea;
+ --color-line-soft: #e8edf2;
+ --color-line-strong: #c8d3dd;
+ --color-text: #17212c;
+ --color-muted: #607082;
+ --color-muted-strong: #415063;
+ --color-ink: #0f1b26;
+ --color-brand: #183044;
+ --color-accent: #286f76;
+ --color-success: #1e7b55;
+ --color-success-bg: #e5f4eb;
+ --color-warning: #a66a00;
+ --color-warning-bg: #fff3d6;
+ --color-danger: #b8423a;
+ --color-danger-bg: #fde8e5;
+ --color-blue: #2f5f8f;
+ --color-blue-bg: #e6f0fb;
+ --radius-sm: 6px;
+ --radius-md: 8px;
+}
+
+:root {
+ color-scheme: light;
+ font-family: var(--font-sans);
+ background: var(--color-bg);
+ color: var(--color-text);
+ --bg: var(--color-bg);
+ --surface: var(--color-surface);
+ --surface-soft: var(--color-surface-soft);
+ --surface-tint: var(--color-surface-tint);
+ --line: var(--color-line);
+ --line-soft: var(--color-line-soft);
+ --line-strong: var(--color-line-strong);
+ --text: var(--color-text);
+ --muted: var(--color-muted);
+ --muted-strong: var(--color-muted-strong);
+ --ink: var(--color-ink);
+ --brand: var(--color-brand);
+ --accent: var(--color-accent);
+ --success: var(--color-success);
+ --success-bg: var(--color-success-bg);
+ --warning: var(--color-warning);
+ --warning-bg: var(--color-warning-bg);
+ --danger: var(--color-danger);
+ --danger-bg: var(--color-danger-bg);
+ --blue: var(--color-blue);
+ --blue-bg: var(--color-blue-bg);
+ --radius: var(--radius-md);
+ --radius-sm: var(--radius-sm);
+ --shadow-soft: 0 8px 28px rgba(28, 44, 62, 0.06);
+ --shadow-panel: 0 1px 2px rgba(28, 44, 62, 0.04), 0 10px 26px rgba(28, 44, 62, 0.05);
+}
diff --git a/apps/web/src/styles/workbench.css b/apps/web/src/styles/workbench.css
new file mode 100644
index 0000000..81158c1
--- /dev/null
+++ b/apps/web/src/styles/workbench.css
@@ -0,0 +1,2234 @@
+:root {
+ color-scheme: light;
+ font-family:
+ Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ background: #f4f6f8;
+ color: #17212c;
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ --bg: #f4f6f8;
+ --surface: #ffffff;
+ --surface-soft: #f8fafb;
+ --surface-tint: #eef7f5;
+ --line: #dce3ea;
+ --line-soft: #e8edf2;
+ --line-strong: #c8d3dd;
+ --text: #17212c;
+ --muted: #607082;
+ --muted-strong: #415063;
+ --ink: #0f1b26;
+ --brand: #183044;
+ --accent: #286f76;
+ --success: #1e7b55;
+ --success-bg: #e5f4eb;
+ --warning: #a66a00;
+ --warning-bg: #fff3d6;
+ --danger: #b8423a;
+ --danger-bg: #fde8e5;
+ --blue: #2f5f8f;
+ --blue-bg: #e6f0fb;
+ --radius: 8px;
+ --radius-sm: 6px;
+ --shadow-soft: 0 8px 28px rgba(28, 44, 62, 0.06);
+ --shadow-panel: 0 1px 2px rgba(28, 44, 62, 0.04), 0 10px 26px rgba(28, 44, 62, 0.05);
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ min-width: 320px;
+ min-height: 100vh;
+ margin: 0;
+ background: #eef2f5;
+}
+
+a {
+ color: inherit;
+ text-decoration: none;
+}
+
+button,
+input,
+textarea,
+select {
+ font: inherit;
+}
+
+button {
+ appearance: none;
+}
+
+code,
+pre {
+ font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
+}
+
+.shell {
+ display: grid;
+ grid-template-columns: 240px minmax(0, 1fr);
+ min-height: 100vh;
+}
+
+.sidebar {
+ position: sticky;
+ top: 0;
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ height: 100vh;
+ border-right: 1px solid var(--line);
+ background: var(--surface);
+ padding: 18px 14px;
+ overflow-y: auto;
+ scrollbar-gutter: stable;
+}
+
+.brand {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 6px 4px 18px;
+ border-bottom: 1px solid var(--line-soft);
+}
+
+.brand > div {
+ min-width: 0;
+}
+
+.brand-mark {
+ display: grid;
+ place-items: center;
+ flex: 0 0 auto;
+ width: 46px;
+ height: 46px;
+ border-radius: var(--radius);
+ background: var(--brand);
+ color: #ffffff;
+ font-size: 13px;
+ font-weight: 800;
+}
+
+.brand h1,
+.brand p,
+.eyebrow,
+.panel h3,
+.task-row h4,
+.section-title,
+.lifecycle-step h4,
+.empty-lifecycle h4 {
+ margin: 0;
+}
+
+.brand h1 {
+ font-size: 18px;
+ line-height: 1.1;
+ white-space: nowrap;
+}
+
+.brand p {
+ margin-top: 4px;
+ color: var(--muted);
+ font-size: 12px;
+ line-height: 1.35;
+}
+
+.nav-list {
+ display: grid;
+ gap: 3px;
+ margin-top: 18px;
+ padding-bottom: 12px;
+}
+
+.nav-list a {
+ display: grid;
+ gap: 2px;
+ min-height: 38px;
+ border-radius: var(--radius-sm);
+ color: var(--muted-strong);
+ font-size: 14px;
+ font-weight: 700;
+ line-height: 18px;
+ padding: 10px 12px;
+}
+
+.nav-list a.active,
+.nav-list a:hover {
+ background: var(--surface-tint);
+ color: #123a38;
+}
+
+.nav-item-label {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.nav-list a.locked {
+ color: #8a98a7;
+}
+
+.nav-list a small {
+ color: var(--muted);
+ font-size: 10px;
+ font-weight: 700;
+ text-transform: uppercase;
+}
+
+.auth-controls {
+ display: grid;
+ gap: 9px;
+ margin-top: auto;
+ border-top: 1px solid var(--line-soft);
+ padding-top: 14px;
+}
+
+.language-toggle {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 5px;
+ border: 1px solid var(--line);
+ border-radius: var(--radius-sm);
+ background: var(--surface-soft);
+ padding: 4px;
+}
+
+.language-toggle button {
+ min-height: 28px;
+ border: 0;
+ border-radius: 5px;
+ background: transparent;
+ color: var(--muted-strong);
+ cursor: pointer;
+ font-size: 12px;
+ font-weight: 850;
+}
+
+.language-toggle button.active {
+ background: #ffffff;
+ box-shadow: 0 1px 4px rgba(28, 44, 62, 0.08);
+ color: var(--ink);
+}
+
+.auth-status {
+ display: grid;
+ gap: 4px;
+}
+
+.auth-status span,
+.topbar-selectors span,
+.branch-pill span,
+.metric span,
+.eyebrow {
+ color: var(--muted);
+ font-size: 11px;
+ font-weight: 800;
+ text-transform: uppercase;
+}
+
+.auth-status strong {
+ color: var(--muted-strong);
+ font-size: 12px;
+ line-height: 1.35;
+ overflow-wrap: anywhere;
+}
+
+.permission-stack {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 5px;
+}
+
+.permission-stack code,
+.access-notice code,
+.login-meta code,
+.access-panel code {
+ border: 1px solid var(--line);
+ border-radius: 999px;
+ background: var(--surface-soft);
+ color: var(--muted-strong);
+ font-size: 11px;
+ padding: 4px 7px;
+}
+
+.auth-controls input,
+.form-grid input,
+.form-grid textarea,
+.scope-form textarea,
+.title-select {
+ width: 100%;
+ min-width: 0;
+ border: 1px solid #c7d0da;
+ border-radius: var(--radius-sm);
+ background: #ffffff;
+ color: var(--text);
+ outline: none;
+}
+
+.auth-controls input {
+ padding: 8px 9px;
+}
+
+.auth-controls input:focus,
+.form-grid input:focus,
+.form-grid textarea:focus,
+.scope-form textarea:focus,
+.title-select:focus {
+ border-color: #7ca9ae;
+ box-shadow: 0 0 0 3px rgba(40, 111, 118, 0.12);
+}
+
+.auth-actions {
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: 7px;
+}
+
+.workspace {
+ min-width: 0;
+ padding: 18px 20px 26px;
+}
+
+.topbar {
+ display: flex;
+ align-items: end;
+ justify-content: space-between;
+ flex-wrap: wrap;
+ gap: 18px;
+ margin-bottom: 12px;
+}
+
+.topbar-selectors {
+ display: grid;
+ grid-template-columns: minmax(190px, 1fr) minmax(190px, 1fr) minmax(128px, 0.48fr);
+ gap: 12px;
+ width: min(840px, 100%);
+}
+
+.topbar-selectors label,
+.branch-pill {
+ display: grid;
+ gap: 6px;
+ min-width: 0;
+}
+
+.title-select {
+ min-height: 38px;
+ padding: 8px 11px;
+ color: var(--ink);
+ font-size: 14px;
+ font-weight: 750;
+}
+
+.branch-pill {
+ min-height: 59px;
+ align-content: end;
+ border: 1px solid var(--line);
+ border-radius: var(--radius-sm);
+ background: var(--surface);
+ padding: 8px 11px;
+}
+
+.branch-pill strong {
+ overflow: hidden;
+ font-size: 14px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.system-strip,
+.actions,
+.action-bar,
+.gate-strip,
+.scope-result,
+.task-meta,
+.workflow-actions,
+.step-heading,
+.section-title-row,
+.capacity-summary,
+.node-metrics {
+ display: flex;
+ align-items: center;
+}
+
+.system-strip {
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ gap: 8px;
+}
+
+.summary-strip {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(132px, 1fr));
+ gap: 10px;
+ margin-bottom: 12px;
+}
+
+.metric {
+ min-width: 0;
+ border: 1px solid var(--line);
+ border-radius: var(--radius);
+ background: var(--surface);
+ padding: 10px 12px;
+}
+
+.metric strong {
+ display: block;
+ margin-top: 3px;
+ color: var(--ink);
+ font-size: 22px;
+ line-height: 1.1;
+}
+
+.view-host {
+ min-width: 0;
+}
+
+.login-page {
+ display: grid;
+ grid-template-columns: minmax(320px, 0.9fr) minmax(320px, 520px);
+ gap: 28px;
+ align-items: center;
+ width: min(1120px, calc(100% - 32px));
+ min-height: 100dvh;
+ margin: 0 auto;
+ padding: 32px 0;
+}
+
+.login-hero {
+ display: grid;
+ gap: 28px;
+ min-width: 0;
+}
+
+.login-brand {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.login-brand h1,
+.login-brand p,
+.login-copy h2,
+.login-copy p {
+ margin: 0;
+}
+
+.login-brand h1 {
+ color: var(--ink);
+ font-size: 22px;
+ line-height: 1.1;
+}
+
+.login-brand p {
+ margin-top: 4px;
+ color: var(--muted);
+ font-size: 13px;
+ font-weight: 750;
+}
+
+.login-copy {
+ display: grid;
+ gap: 12px;
+ max-width: 620px;
+}
+
+.login-copy h2 {
+ color: var(--ink);
+ font-size: 42px;
+ line-height: 1.08;
+ max-width: 12ch;
+}
+
+.login-copy p:not(.eyebrow) {
+ max-width: 560px;
+ color: var(--muted-strong);
+ font-size: 16px;
+ line-height: 1.65;
+}
+
+.login-assurance {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.login-assurance span {
+ border: 1px solid var(--line);
+ border-radius: var(--radius-sm);
+ background: var(--surface);
+ color: var(--muted-strong);
+ font-size: 12px;
+ font-weight: 850;
+ padding: 7px 9px;
+}
+
+.login-panel,
+.access-panel {
+ display: grid;
+ gap: 16px;
+ max-width: 720px;
+ padding: 22px;
+}
+
+.login-panel h2,
+.access-panel h2 {
+ margin: 5px 0 8px;
+ color: var(--ink);
+ font-size: 24px;
+ line-height: 1.2;
+}
+
+.login-panel p,
+.access-panel p {
+ margin: 0;
+ color: var(--muted);
+ font-size: 14px;
+ line-height: 1.55;
+}
+
+.login-meta,
+.login-actions,
+.credential-login,
+.advanced-login,
+.token-exchange {
+ display: grid;
+ gap: 9px;
+}
+
+.login-meta {
+ align-items: start;
+}
+
+.login-meta span {
+ color: var(--muted-strong);
+ font-size: 13px;
+ line-height: 1.45;
+}
+
+.token-exchange {
+ grid-template-columns: minmax(220px, 1fr) auto;
+ align-items: end;
+}
+
+.credential-login {
+ grid-template-columns: 1fr;
+}
+
+.credential-login label,
+.token-exchange label {
+ display: grid;
+ gap: 6px;
+ color: var(--muted-strong);
+ font-size: 12px;
+ font-weight: 800;
+}
+
+.credential-login input,
+.token-exchange input {
+ width: 100%;
+ min-height: 38px;
+ border: 1px solid #c7d0da;
+ border-radius: var(--radius-sm);
+ color: var(--text);
+ padding: 8px 10px;
+}
+
+.advanced-login {
+ border-top: 1px solid var(--line-soft);
+ padding-top: 4px;
+}
+
+.advanced-login summary {
+ color: var(--muted-strong);
+ cursor: pointer;
+ font-size: 12px;
+ font-weight: 850;
+}
+
+.access-notice {
+ display: grid;
+ gap: 6px;
+ margin: 12px 14px 0;
+ border: 1px solid #edd69a;
+ border-radius: var(--radius-sm);
+ background: var(--warning-bg);
+ color: var(--warning);
+ font-size: 12px;
+ font-weight: 750;
+ padding: 9px 10px;
+}
+
+.access-notice code {
+ justify-self: start;
+ border-color: #e6cd88;
+ background: #fff8e8;
+}
+
+.cockpit-grid {
+ display: grid;
+ grid-template-columns: minmax(260px, 0.78fr) minmax(380px, 1.14fr) minmax(280px, 0.9fr);
+ gap: 12px;
+ align-items: start;
+}
+
+.cockpit-stack {
+ display: grid;
+ gap: 12px;
+ min-width: 0;
+}
+
+.panel {
+ min-width: 0;
+ border: 1px solid var(--line);
+ border-radius: var(--radius);
+ background: var(--surface);
+ box-shadow: var(--shadow-panel);
+}
+
+.cockpit-panel {
+ min-width: 0;
+}
+
+.panel-heading {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ flex-wrap: wrap;
+ gap: 12px;
+ min-height: 54px;
+ border-bottom: 1px solid var(--line-soft);
+ padding: 12px 14px;
+}
+
+.panel-heading > div:first-child {
+ min-width: 0;
+}
+
+.panel-heading h3 {
+ min-width: 0;
+ overflow-wrap: anywhere;
+ color: var(--ink);
+ font-size: 15px;
+ line-height: 1.2;
+}
+
+.panel-heading span {
+ color: var(--muted);
+ font-size: 12px;
+}
+
+.panel-heading-actions,
+.inline-list-toolbar {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ flex-wrap: wrap;
+ gap: 8px;
+ min-width: 0;
+}
+
+.panel-heading-actions {
+ margin-left: auto;
+}
+
+.inline-list-toolbar {
+ justify-content: space-between;
+ border-top: 1px solid var(--line-soft);
+ padding: 10px 14px;
+}
+
+.inline-list-toolbar span {
+ color: var(--muted);
+ font-size: 12px;
+}
+
+.list-pagination-control {
+ display: inline-flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 5px;
+ min-width: 0;
+}
+
+.list-limit-control {
+ display: inline-flex;
+ align-items: center;
+ flex: 0 0 auto;
+ gap: 2px;
+ min-height: 28px;
+ border: 1px solid var(--line);
+ border-radius: var(--radius-sm);
+ background: var(--surface-soft);
+ padding: 2px;
+}
+
+.list-limit-control span {
+ padding: 0 6px;
+ color: var(--muted);
+ font-size: 11px;
+ font-weight: 800;
+}
+
+.list-limit-control button {
+ min-width: 30px;
+ min-height: 22px;
+ border: 1px solid transparent;
+ border-radius: 4px;
+ background: transparent;
+ color: var(--muted-strong);
+ cursor: pointer;
+ font-size: 11px;
+ font-weight: 850;
+ line-height: 1;
+ padding: 4px 6px;
+}
+
+.list-limit-control button:hover,
+.list-limit-control button.active {
+ border-color: #b9d8d5;
+ background: #ffffff;
+ color: #163d3b;
+}
+
+.pager-control {
+ display: inline-flex;
+ align-items: center;
+ flex: 0 0 auto;
+ gap: 4px;
+ min-height: 28px;
+ border: 1px solid var(--line);
+ border-radius: var(--radius-sm);
+ background: #ffffff;
+ padding: 2px;
+}
+
+.pager-control button {
+ min-height: 22px;
+ border: 1px solid transparent;
+ border-radius: 4px;
+ background: transparent;
+ color: var(--muted-strong);
+ cursor: pointer;
+ font-size: 11px;
+ font-weight: 850;
+ line-height: 1;
+ padding: 4px 6px;
+}
+
+.pager-control button:hover:not(:disabled) {
+ border-color: #c7d0da;
+ background: var(--surface-soft);
+}
+
+.pager-control button:disabled {
+ cursor: not-allowed;
+ opacity: 0.45;
+}
+
+.pager-control span {
+ min-width: 36px;
+ color: var(--muted);
+ font-size: 11px;
+ font-weight: 850;
+ text-align: center;
+}
+
+.segment-group {
+ display: grid;
+ grid-template-columns: repeat(5, minmax(0, 1fr));
+ gap: 5px;
+ padding: 10px 10px 8px;
+}
+
+.segment-button {
+ display: grid;
+ gap: 3px;
+ min-width: 0;
+ min-height: 48px;
+ border: 1px solid transparent;
+ border-radius: var(--radius-sm);
+ background: var(--surface-soft);
+ color: var(--muted-strong);
+ cursor: pointer;
+ font-size: 11px;
+ font-weight: 750;
+ padding: 7px 4px;
+ text-align: center;
+}
+
+.segment-button strong {
+ color: var(--ink);
+ font-size: 14px;
+}
+
+.segment-button.active {
+ border-color: #b9d8d5;
+ background: var(--surface-tint);
+ color: #163d3b;
+}
+
+.task-list,
+.table-list,
+.run-list,
+.audit-list,
+.event-list,
+.node-list,
+.version-list,
+.capacity-list {
+ display: grid;
+}
+
+.dashboard-list,
+.bounded-list,
+.cockpit-task-list {
+ overflow: auto;
+ overscroll-behavior: contain;
+ scrollbar-gutter: stable;
+}
+
+.dashboard-list {
+ max-height: clamp(260px, 34vh, 390px);
+}
+
+.bounded-list {
+ max-height: min(640px, calc(100vh - 260px));
+}
+
+.compact-bounded-list {
+ max-height: min(360px, calc(100vh - 340px));
+}
+
+.cockpit-task-list {
+ max-height: clamp(320px, 44vh, 520px);
+}
+
+.table-list.bounded-list,
+.task-list.bounded-list,
+.run-list.bounded-list,
+.audit-list.bounded-list,
+.project-access-list.bounded-list,
+.permission-stack.bounded-list {
+ min-height: 180px;
+}
+
+.table-list.dashboard-list,
+.audit-list.dashboard-list,
+.capacity-list.dashboard-list,
+.cockpit-task-list.dashboard-list {
+ min-height: 180px;
+}
+
+.event-list {
+ min-height: 140px;
+}
+
+.version-shell {
+ border-top: 1px solid var(--line-soft);
+}
+
+.version-shell .version-list {
+ border-top: 0;
+}
+
+.artifact-shell .inline-list-toolbar {
+ border-bottom: 1px solid var(--line-soft);
+}
+
+.artifact-list.bounded-list {
+ min-height: 140px;
+}
+
+.event-list {
+ max-height: min(420px, calc(100vh - 340px));
+ overflow: auto;
+ overscroll-behavior: contain;
+ scrollbar-gutter: stable;
+}
+
+.task-row {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 10px;
+ width: 100%;
+ border: 0;
+ border-top: 1px solid var(--line-soft);
+ background: transparent;
+ color: inherit;
+ cursor: pointer;
+ padding: 11px 12px;
+ text-align: left;
+}
+
+.task-row.active,
+.task-row:hover,
+.selectable-row:hover,
+.selectable-row.is-selected,
+.artifact-row:hover,
+.artifact-row.is-selected {
+ background: #f1f7f6;
+}
+
+.task-key {
+ color: var(--muted);
+ font-size: 11px;
+ font-weight: 800;
+}
+
+.task-row h4 {
+ margin-top: 3px;
+ color: var(--ink);
+ font-size: 13px;
+ line-height: 1.35;
+ display: -webkit-box;
+ overflow: hidden;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 2;
+ white-space: normal;
+}
+
+.queue-stats,
+.metric-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 8px;
+ padding: 12px;
+}
+
+.queue-stats .metric,
+.metric-grid .metric {
+ box-shadow: none;
+}
+
+.capacity-list {
+ gap: 8px;
+ padding: 0 12px 12px;
+}
+
+.capacity-row {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(76px, 100px) auto;
+ align-items: center;
+ gap: 9px;
+ border-top: 1px solid var(--line-soft);
+ padding-top: 9px;
+}
+
+.capacity-row strong,
+.capacity-row span {
+ display: block;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.capacity-row span,
+.capacity-row code {
+ color: var(--muted);
+ font-size: 11px;
+}
+
+.meter,
+.node-meter {
+ height: 7px;
+ overflow: hidden;
+ border-radius: 999px;
+ background: #dfe6ed;
+}
+
+.meter span,
+.node-meter span {
+ display: block;
+ height: 100%;
+ border-radius: inherit;
+ background: var(--success);
+}
+
+.primary-button,
+.secondary-button,
+.link-button {
+ min-height: 34px;
+ border-radius: var(--radius-sm);
+ cursor: pointer;
+ font-size: 13px;
+ font-weight: 800;
+ line-height: 1.2;
+ padding: 7px 11px;
+ text-align: center;
+}
+
+.primary-button {
+ border: 1px solid var(--brand);
+ background: var(--brand);
+ color: #ffffff;
+}
+
+.primary-button:hover {
+ background: #102437;
+}
+
+.secondary-button,
+.link-button {
+ border: 1px solid #c7d0da;
+ background: #ffffff;
+ color: var(--muted-strong);
+}
+
+.secondary-button:hover,
+.link-button:hover {
+ border-color: #aab7c4;
+ background: var(--surface-soft);
+}
+
+.secondary-button.compact,
+.link-button {
+ min-height: 30px;
+ padding: 5px 9px;
+ font-size: 12px;
+}
+
+.secondary-button.danger {
+ border-color: #efb9b3;
+ color: var(--danger);
+}
+
+.primary-button:disabled,
+.secondary-button:disabled {
+ cursor: not-allowed;
+ opacity: 0.55;
+}
+
+.compact-actions {
+ justify-content: space-between;
+ gap: 8px;
+ border-top: 1px solid var(--line-soft);
+ padding: 12px;
+}
+
+.lifecycle-panel {
+ min-height: 0;
+}
+
+.lifecycle-header {
+ display: flex;
+ justify-content: space-between;
+ gap: 14px;
+ border-bottom: 1px solid var(--line-soft);
+ padding: 14px;
+}
+
+.task-title-line {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ min-width: 0;
+ margin-top: 5px;
+}
+
+.task-title-line code {
+ flex: 0 0 auto;
+ border: 1px solid var(--line);
+ border-radius: var(--radius-sm);
+ background: var(--surface-soft);
+ color: var(--muted-strong);
+ font-size: 12px;
+ padding: 4px 7px;
+}
+
+.task-title-line h3 {
+ min-width: 0;
+ overflow-wrap: anywhere;
+ color: var(--ink);
+ font-size: 18px;
+ line-height: 1.2;
+}
+
+.task-meta,
+.workflow-actions {
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-top: 9px;
+}
+
+.task-meta span {
+ color: var(--muted);
+ font-size: 12px;
+}
+
+.actions {
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ gap: 8px;
+}
+
+.blocker-strip,
+.ready-strip {
+ margin: 12px 14px 0;
+ border-radius: var(--radius-sm);
+ padding: 9px 10px;
+ font-size: 12px;
+ font-weight: 750;
+}
+
+.blocker-strip {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 7px;
+ background: var(--danger-bg);
+ color: var(--danger);
+}
+
+.ready-strip {
+ background: var(--success-bg);
+ color: var(--success);
+}
+
+.workflow-actions {
+ padding: 0 14px 12px;
+}
+
+.lifecycle-steps {
+ display: grid;
+ gap: 10px;
+ padding: 0 14px 14px;
+}
+
+.lifecycle-step {
+ border: 1px solid var(--line);
+ border-radius: var(--radius);
+ background: #ffffff;
+ overflow: hidden;
+}
+
+.step-heading {
+ justify-content: space-between;
+ gap: 10px;
+ border-bottom: 1px solid var(--line-soft);
+ background: var(--surface-soft);
+ padding: 10px 11px;
+}
+
+.step-heading > div {
+ min-width: 0;
+ margin-right: auto;
+}
+
+.step-heading h4 {
+ color: var(--ink);
+ font-size: 14px;
+}
+
+.step-heading span {
+ display: block;
+ margin-top: 2px;
+ color: var(--muted);
+ font-size: 12px;
+ overflow-wrap: anywhere;
+}
+
+.step-rows,
+.run-facts,
+.detail-list {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 9px;
+ margin: 0;
+ padding: 11px;
+}
+
+.step-rows div,
+.run-facts div,
+.detail-list div {
+ min-width: 0;
+}
+
+.step-rows dt,
+.run-facts dt,
+.detail-list dt {
+ color: var(--muted);
+ font-size: 11px;
+ font-weight: 800;
+}
+
+.step-rows dd,
+.run-facts dd,
+.detail-list dd {
+ margin: 3px 0 0;
+ color: var(--muted-strong);
+ font-size: 12px;
+ overflow-wrap: anywhere;
+}
+
+.scope-form {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 9px;
+ align-items: end;
+ padding: 11px;
+}
+
+.cockpit-scope-form {
+ padding-bottom: 0;
+}
+
+.scope-form textarea {
+ min-height: 72px;
+ resize: vertical;
+ padding: 9px 10px;
+}
+
+.scope-result {
+ flex-wrap: wrap;
+ gap: 8px;
+ padding: 10px 11px 11px;
+}
+
+.scope-result span,
+.scope-result code {
+ color: var(--muted);
+ font-size: 12px;
+}
+
+.empty-lifecycle {
+ display: grid;
+ align-content: center;
+ justify-items: start;
+ min-height: 420px;
+ padding: 22px;
+}
+
+.empty-lifecycle h4 {
+ color: var(--ink);
+ font-size: 18px;
+}
+
+.empty-lifecycle p {
+ max-width: 460px;
+ margin: 8px 0 16px;
+ color: var(--muted);
+ font-size: 14px;
+ line-height: 1.55;
+}
+
+.run-facts {
+ border-bottom: 1px solid var(--line-soft);
+}
+
+.event-list,
+.audit-list {
+ gap: 8px;
+ padding: 11px;
+}
+
+.event-row,
+.audit-row,
+.run-row,
+.version-row,
+.node-row {
+ min-width: 0;
+ border: 1px solid var(--line-soft);
+ border-radius: var(--radius-sm);
+ background: #ffffff;
+}
+
+.event-row {
+ display: grid;
+ grid-template-columns: 34px minmax(96px, 0.5fr) minmax(0, 1fr);
+ gap: 9px;
+ align-items: center;
+ padding: 8px 9px;
+}
+
+.event-row span,
+.event-row strong,
+.event-row p,
+.audit-row span,
+.audit-row code {
+ min-width: 0;
+}
+
+.event-row span {
+ color: var(--muted);
+ font-size: 11px;
+}
+
+.event-row strong {
+ overflow: hidden;
+ color: var(--muted-strong);
+ font-size: 12px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.event-row p {
+ margin: 0;
+ overflow-wrap: anywhere;
+ color: var(--text);
+ font-size: 12px;
+}
+
+.stream-state {
+ justify-self: start;
+ border: 1px solid var(--line);
+ border-radius: 999px;
+ color: var(--muted-strong);
+ font-size: 11px;
+ font-weight: 800;
+ padding: 3px 8px;
+}
+
+.stream-state.live {
+ border-color: #bddfc9;
+ background: var(--success-bg);
+ color: var(--success);
+}
+
+.stream-state.fallback {
+ border-color: #edd69a;
+ background: var(--warning-bg);
+ color: var(--warning);
+}
+
+.compact-evidence .data-row {
+ grid-template-columns: minmax(0, 1fr) auto minmax(80px, 0.65fr);
+}
+
+.compact-evidence .data-row code,
+.compact-evidence .data-row span {
+ font-size: 11px;
+}
+
+.evidence-row {
+ padding-block: 9px;
+}
+
+.content-grid {
+ display: grid;
+ grid-template-columns: minmax(300px, 400px) minmax(0, 1fr);
+ gap: 12px;
+ align-items: start;
+}
+
+.tasks-layout,
+.runs-layout {
+ grid-template-columns: minmax(300px, 380px) minmax(0, 1fr);
+}
+
+.queue-layout,
+.audit-layout {
+ grid-template-columns: minmax(0, 1fr) minmax(340px, 0.72fr);
+}
+
+.skills-layout {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+.admin-grid {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(300px, 360px);
+ gap: 12px;
+ align-items: start;
+}
+
+.admin-main,
+.admin-side {
+ display: grid;
+ gap: 12px;
+ min-width: 0;
+}
+
+.admin-user-form {
+ grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
+ align-items: end;
+}
+
+.admin-member-form {
+ grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
+ align-items: end;
+}
+
+.admin-user-row {
+ grid-template-columns: minmax(220px, 1.3fr) auto minmax(96px, 0.45fr) minmax(120px, 0.55fr);
+}
+
+.admin-member-row {
+ grid-template-columns: minmax(220px, 1.2fr) auto minmax(150px, 0.7fr) minmax(110px, 0.45fr);
+}
+
+.project-access-list {
+ display: grid;
+ gap: 8px;
+ padding: 12px;
+}
+
+.project-access-row {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 10px;
+ width: 100%;
+ border: 1px solid var(--line-soft);
+ border-radius: var(--radius-sm);
+ background: #ffffff;
+ color: inherit;
+ cursor: pointer;
+ padding: 10px;
+ text-align: left;
+}
+
+.project-access-row:hover,
+.project-access-row.is-selected {
+ border-color: #b9d8d5;
+ background: var(--surface-tint);
+}
+
+.project-access-row span,
+.project-access-row strong,
+.project-access-row code {
+ display: block;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.project-access-row strong {
+ color: var(--ink);
+ font-size: 13px;
+}
+
+.project-access-row code,
+.project-access-row em {
+ color: var(--muted);
+ font-size: 12px;
+ font-style: normal;
+}
+
+.user-access-card {
+ display: grid;
+ gap: 10px;
+}
+
+.user-project-stack {
+ padding: 0 14px 14px;
+}
+
+.dashboard-grid {
+ display: grid;
+ grid-template-columns: minmax(320px, 0.9fr) minmax(320px, 1.1fr);
+ gap: 12px;
+}
+
+.form-grid {
+ display: grid;
+ gap: 11px;
+ padding: 14px;
+}
+
+.form-grid.bordered {
+ border-top: 1px solid var(--line-soft);
+}
+
+.inline-form {
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+ align-items: end;
+}
+
+.node-form {
+ grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
+}
+
+.node-form button {
+ align-self: end;
+ justify-self: start;
+}
+
+.form-grid label,
+.scope-form label {
+ display: grid;
+ gap: 6px;
+ min-width: 0;
+ color: var(--muted-strong);
+ font-size: 12px;
+ font-weight: 800;
+}
+
+.form-grid input,
+.form-grid textarea {
+ padding: 9px 10px;
+}
+
+.form-grid textarea {
+ min-height: 88px;
+ resize: vertical;
+}
+
+.form-grid .checkbox-label {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.form-grid .checkbox-label input {
+ width: auto;
+}
+
+.data-row {
+ display: grid;
+ grid-template-columns: minmax(210px, 1.3fr) auto minmax(90px, 0.5fr) minmax(170px, 1fr);
+ align-items: center;
+ gap: 10px;
+ min-width: 0;
+ border-bottom: 1px solid var(--line-soft);
+ padding: 12px 14px;
+}
+
+.data-row:last-child {
+ border-bottom: 0;
+}
+
+.data-row strong,
+.data-row span,
+.data-row code {
+ min-width: 0;
+}
+
+.data-row strong {
+ display: block;
+ overflow: hidden;
+ color: var(--ink);
+ font-size: 13px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.data-row span,
+.data-row code {
+ display: block;
+ color: var(--muted);
+ font-size: 12px;
+ overflow-wrap: anywhere;
+}
+
+.data-row:has(> :nth-child(5)) {
+ grid-template-columns: minmax(190px, 1.2fr) auto minmax(90px, 0.45fr) minmax(150px, 0.9fr) minmax(130px, 0.7fr);
+}
+
+.artifact-row,
+.selectable-row {
+ width: 100%;
+ border-left: 0;
+ border-right: 0;
+ border-top: 0;
+ background: transparent;
+ color: inherit;
+ cursor: pointer;
+ font: inherit;
+ text-align: left;
+}
+
+.compact-table .data-row {
+ grid-template-columns: minmax(180px, 1fr) minmax(90px, 0.4fr) minmax(120px, 0.6fr) minmax(180px, 1fr);
+}
+
+.artifact-shell {
+ display: grid;
+ gap: 12px;
+ padding-bottom: 14px;
+}
+
+.artifact-list {
+ border-bottom: 1px solid var(--line-soft);
+}
+
+.artifact-content-panel {
+ margin: 0 14px;
+ border: 1px solid var(--line);
+ border-radius: var(--radius-sm);
+ overflow: hidden;
+}
+
+.artifact-content-heading {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 10px;
+ border-bottom: 1px solid var(--line);
+ background: var(--surface-soft);
+ padding: 9px 11px;
+}
+
+.artifact-content-heading strong {
+ min-width: 0;
+ overflow: hidden;
+ color: var(--ink);
+ font-size: 13px;
+ text-overflow: ellipsis;
+}
+
+.artifact-content-heading code,
+.artifact-content-heading span {
+ color: var(--muted);
+ font-size: 12px;
+}
+
+.artifact-view {
+ max-height: 430px;
+ overflow: auto;
+ margin: 0;
+ background: #101720;
+ color: #dce6f2;
+ font-size: 12px;
+ line-height: 1.55;
+ padding: 12px;
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+
+.diff-view {
+ color: #dff3e4;
+}
+
+.detail-panel {
+ min-width: 0;
+}
+
+.detail-list {
+ padding: 14px;
+}
+
+.section-title {
+ border-top: 1px solid var(--line-soft);
+ color: var(--ink);
+ font-size: 13px;
+ padding: 13px 14px 0;
+}
+
+.run-list {
+ gap: 8px;
+ padding: 11px 14px 14px;
+}
+
+.run-row {
+ display: grid;
+ grid-template-columns: minmax(80px, 1fr) auto minmax(80px, 0.6fr) minmax(120px, 0.9fr);
+ align-items: center;
+ gap: 9px;
+ padding: 9px 10px;
+}
+
+.run-row code {
+ min-width: 0;
+ color: var(--muted);
+ font-size: 12px;
+ overflow-wrap: anywhere;
+}
+
+.gate-strip {
+ flex-wrap: wrap;
+ gap: 8px;
+ padding: 11px 14px 14px;
+}
+
+.gate-ok,
+.gate-blocked {
+ border-radius: 999px;
+ font-size: 12px;
+ font-weight: 800;
+ padding: 5px 9px;
+}
+
+.gate-ok {
+ background: var(--success-bg);
+ color: var(--success);
+}
+
+.gate-blocked {
+ background: var(--danger-bg);
+ color: var(--danger);
+}
+
+.audit-panel {
+ margin-top: 12px;
+}
+
+.audit-row {
+ display: grid;
+ grid-template-columns: minmax(130px, 1fr) minmax(0, 1.7fr);
+ gap: 9px;
+ padding: 8px 9px;
+}
+
+.audit-row span,
+.audit-row div,
+.audit-row code {
+ display: block;
+ min-width: 0;
+ overflow-wrap: anywhere;
+}
+
+.audit-row code {
+ color: var(--muted);
+ font-size: 12px;
+}
+
+.json-view {
+ max-height: 360px;
+ overflow: auto;
+ margin: 11px 14px 14px;
+ border: 1px solid var(--line);
+ border-radius: var(--radius-sm);
+ background: var(--surface-soft);
+ color: var(--muted-strong);
+ font-size: 12px;
+ padding: 12px;
+}
+
+.version-list {
+ gap: 8px;
+ border-top: 1px solid var(--line-soft);
+ padding: 11px 14px 14px;
+}
+
+.version-row {
+ display: grid;
+ grid-template-columns: minmax(70px, 0.4fr) minmax(140px, 0.8fr) minmax(180px, 1fr);
+ align-items: center;
+ gap: 9px;
+ padding: 9px 10px;
+}
+
+.version-row strong,
+.version-row code {
+ min-width: 0;
+ overflow-wrap: anywhere;
+}
+
+.node-section {
+ margin: 12px 14px 14px;
+}
+
+.section-title-row {
+ justify-content: space-between;
+ gap: 12px;
+}
+
+.capacity-summary,
+.node-metrics {
+ flex-wrap: wrap;
+ gap: 8px;
+ color: var(--muted);
+ font-size: 12px;
+}
+
+.node-list {
+ gap: 8px;
+}
+
+.node-row {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto minmax(72px, 96px) auto;
+ gap: 10px;
+ align-items: center;
+ border-width: 1px 0 0;
+ border-radius: 0;
+ padding: 10px 0;
+}
+
+.node-primary {
+ min-width: 0;
+}
+
+.node-primary strong,
+.node-primary code {
+ display: block;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.node-primary code {
+ color: var(--muted);
+ font-size: 12px;
+}
+
+.empty-state {
+ color: var(--muted);
+ font-size: 13px;
+ line-height: 1.45;
+ padding: 20px 14px;
+}
+
+.empty-state.compact {
+ padding: 10px 0;
+}
+
+.status {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 22px;
+ max-width: 180px;
+ border-radius: 999px;
+ background: #edf2f6;
+ color: var(--muted-strong);
+ font-size: 11px;
+ font-weight: 850;
+ line-height: 1;
+ overflow: hidden;
+ padding: 4px 8px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.status-running,
+.status-started,
+.status-validating,
+.status-validated,
+.status-ready,
+.status-operational,
+.status-enabled,
+.status-active,
+.status-completed,
+.status-succeeded,
+.status-approved {
+ background: var(--success-bg);
+ color: var(--success);
+}
+
+.status-blocked,
+.status-failed,
+.status-rejected,
+.status-auth-required {
+ background: var(--danger-bg);
+ color: var(--danger);
+}
+
+.status-disabled,
+.status-pending,
+.status-queued,
+.status-created,
+.status-setup,
+.status-waiting,
+.status-checking {
+ background: var(--warning-bg);
+ color: var(--warning);
+}
+
+.status-idle {
+ background: #eef2f5;
+ color: var(--muted-strong);
+}
+
+.status-docker,
+.status-ssh {
+ background: var(--blue-bg);
+ color: var(--blue);
+}
+
+@media (max-width: 1360px) {
+ .shell {
+ grid-template-columns: 216px minmax(0, 1fr);
+ }
+}
+
+@media (max-width: 1180px) {
+ .cockpit-grid {
+ grid-template-columns: minmax(270px, 0.9fr) minmax(0, 1.2fr);
+ }
+
+ .evidence-stack {
+ grid-column: 1 / -1;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+
+@media (max-width: 980px) {
+ .shell {
+ grid-template-columns: 1fr;
+ }
+
+ .sidebar {
+ position: static;
+ height: auto;
+ border-right: 0;
+ border-bottom: 1px solid var(--line);
+ }
+
+ .auth-controls {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ margin-top: 12px;
+ }
+
+ .language-toggle,
+ .permission-stack,
+ .auth-actions {
+ grid-column: 1 / -1;
+ }
+
+ .nav-list {
+ display: flex;
+ max-width: 100%;
+ overflow-x: auto;
+ padding-bottom: 4px;
+ }
+
+ .nav-list a {
+ flex: 0 0 auto;
+ white-space: nowrap;
+ }
+
+ .topbar,
+ .system-strip {
+ align-items: stretch;
+ flex-direction: column;
+ }
+
+ .topbar-selectors,
+ .summary-strip,
+ .cockpit-grid,
+ .content-grid,
+ .admin-grid,
+ .admin-user-form,
+ .admin-member-form,
+ .dashboard-grid,
+ .inline-form,
+ .node-form,
+ .scope-form,
+ .token-exchange,
+ .metric-grid,
+ .evidence-stack {
+ grid-template-columns: 1fr;
+ }
+
+ .lifecycle-header {
+ flex-direction: column;
+ }
+
+ .actions {
+ justify-content: flex-start;
+ }
+
+ .login-page {
+ align-items: stretch;
+ grid-template-columns: 1fr;
+ min-height: auto;
+ }
+
+ .login-copy h2 {
+ max-width: 760px;
+ font-size: 34px;
+ }
+}
+
+@media (max-width: 680px) {
+ .workspace {
+ padding: 12px;
+ }
+
+ .summary-strip {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .queue-stats,
+ .step-rows,
+ .run-facts,
+ .detail-list {
+ grid-template-columns: 1fr;
+ }
+
+ .segment-group {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .event-row,
+ .audit-row,
+ .version-row,
+ .data-row,
+ .run-row,
+ .capacity-row,
+ .node-row,
+ .admin-user-row,
+ .admin-member-row,
+ .compact-evidence .data-row {
+ grid-template-columns: 1fr;
+ }
+
+ .login-page {
+ width: min(100% - 24px, 560px);
+ padding: 18px 0;
+ }
+
+ .login-copy h2 {
+ font-size: 28px;
+ }
+
+ .project-access-row {
+ grid-template-columns: 1fr;
+ }
+
+ .task-title-line {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+
+ .task-title-line h3 {
+ white-space: normal;
+ }
+}
+
+/* Workbench design-system additions */
+.nav-item-label {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.wb-tabs {
+ display: grid;
+ gap: 12px;
+}
+
+.wb-tabs-list {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ border-bottom: 1px solid var(--line);
+ padding-bottom: 8px;
+}
+
+.wb-tabs-trigger {
+ appearance: none;
+ border: 1px solid transparent;
+ background: transparent;
+ color: var(--muted-strong);
+ border-radius: 999px;
+ padding: 6px 12px;
+ cursor: pointer;
+}
+
+.wb-tabs-trigger[data-state="active"] {
+ background: var(--surface-tint);
+ border-color: var(--line-strong);
+ color: var(--ink);
+}
+
+.wb-tabs-content {
+ min-width: 0;
+}
+
+.wb-dialog-overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(15, 27, 38, 0.45);
+ z-index: 40;
+}
+
+.wb-dialog-content {
+ position: fixed;
+ top: 50%;
+ left: 50%;
+ z-index: 50;
+ width: min(92vw, 480px);
+ transform: translate(-50%, -50%);
+ background: var(--surface);
+ border: 1px solid var(--line);
+ border-radius: var(--radius);
+ box-shadow: var(--shadow-panel);
+ padding: 16px;
+}
+
+.wb-dialog-header,
+.wb-dialog-footer,
+.id-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+}
+
+.wb-dialog-title {
+ margin: 0;
+ font-size: 1.05rem;
+}
+
+.wb-dialog-description,
+.muted-copy {
+ margin: 4px 0 0;
+ color: var(--muted);
+}
+
+.wb-dialog-body {
+ margin-top: 12px;
+}
+
+.wb-dialog-footer {
+ margin-top: 16px;
+}
+
+.wb-banner {
+ border-radius: var(--radius-sm);
+ border: 1px solid var(--line);
+ padding: 10px 12px;
+ background: var(--surface-soft);
+}
+
+.wb-banner.tone-warning {
+ background: var(--warning-bg);
+ color: var(--warning);
+}
+
+.wb-banner.tone-danger {
+ background: var(--danger-bg);
+ color: var(--danger);
+}
+
+.wb-banner.tone-success {
+ background: var(--success-bg);
+ color: var(--success);
+}
+
+.wb-toast-viewport {
+ position: fixed;
+ right: 16px;
+ bottom: 16px;
+ z-index: 60;
+ display: grid;
+ gap: 8px;
+ width: min(360px, calc(100vw - 24px));
+}
+
+.wb-toast {
+ background: var(--surface);
+ border: 1px solid var(--line);
+ border-radius: var(--radius);
+ box-shadow: var(--shadow-soft);
+ padding: 12px 14px;
+}
+
+.wb-toast.tone-success {
+ border-color: color-mix(in srgb, var(--success) 35%, var(--line));
+}
+
+.wb-toast.tone-danger {
+ border-color: color-mix(in srgb, var(--danger) 35%, var(--line));
+}
+
+.wb-toast-title {
+ font-weight: 600;
+}
+
+.wb-toast-description {
+ color: var(--muted);
+ margin-top: 4px;
+}
+
+.wb-log-shell {
+ display: grid;
+ gap: 8px;
+}
+
+.wb-log-view,
+.wb-diff-view {
+ max-height: 420px;
+ overflow: auto;
+}
+
+.wb-diff-line {
+ display: block;
+ white-space: pre-wrap;
+}
+
+.wb-diff-line.tone-add {
+ background: color-mix(in srgb, var(--success-bg) 80%, transparent);
+}
+
+.wb-diff-line.tone-del {
+ background: color-mix(in srgb, var(--danger-bg) 80%, transparent);
+}
+
+.wb-diff-line.tone-hunk {
+ color: var(--blue);
+}
+
+.field-error {
+ color: var(--danger);
+}
+
+.secondary-button.danger {
+ color: var(--danger);
+ border-color: color-mix(in srgb, var(--danger) 35%, var(--line));
+}
+
+.getting-started-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: grid;
+ gap: 10px;
+}
+
+.getting-started-list li {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ color: var(--muted-strong);
+}
+
+.getting-started-list li.is-done {
+ color: var(--success);
+}
+
+.getting-started-list a {
+ text-decoration: underline;
+ text-underline-offset: 2px;
+}
+
+.approval-bell {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ border: 1px solid var(--line);
+ border-radius: 999px;
+ padding: 6px 10px;
+ background: var(--surface);
+}
+
+.approval-bell.has-pending {
+ border-color: color-mix(in srgb, var(--warning) 40%, var(--line));
+ background: var(--warning-bg);
+}
+
+.approval-bell strong,
+.nav-badge {
+ display: inline-flex;
+ min-width: 1.25rem;
+ justify-content: center;
+ border-radius: 999px;
+ background: var(--warning);
+ color: #fff;
+ font-size: 12px;
+ padding: 0 5px;
+}
+
+.usage-summary-strip {
+ margin-bottom: 12px;
+}
+
+.segment-group button.is-active {
+ background: var(--surface-tint);
+ border-color: var(--line-strong);
+ color: var(--ink);
+}
+
+.usage-budget-banner {
+ margin-bottom: 12px;
+}
+
+.usage-budget-copy {
+ display: grid;
+ gap: 4px;
+}
diff --git a/apps/web/src/test/setup.ts b/apps/web/src/test/setup.ts
new file mode 100644
index 0000000..e81d63b
--- /dev/null
+++ b/apps/web/src/test/setup.ts
@@ -0,0 +1,43 @@
+import "@testing-library/jest-dom/vitest";
+
+// Node's experimental localStorage can shadow jsdom's Storage API and omit
+// methods like clear(); provide a deterministic in-memory implementation.
+class MemoryStorage implements Storage {
+ #map = new Map();
+
+ get length() {
+ return this.#map.size;
+ }
+
+ clear() {
+ this.#map.clear();
+ }
+
+ getItem(key: string) {
+ return this.#map.has(key) ? this.#map.get(key)! : null;
+ }
+
+ key(index: number) {
+ return [...this.#map.keys()][index] ?? null;
+ }
+
+ removeItem(key: string) {
+ this.#map.delete(key);
+ }
+
+ setItem(key: string, value: string) {
+ this.#map.set(String(key), String(value));
+ }
+}
+
+const storage = new MemoryStorage();
+Object.defineProperty(globalThis, "localStorage", {
+ value: storage,
+ configurable: true,
+ writable: true
+});
+Object.defineProperty(window, "localStorage", {
+ value: storage,
+ configurable: true,
+ writable: true
+});
diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json
index e935337..ef61279 100644
--- a/apps/web/tsconfig.json
+++ b/apps/web/tsconfig.json
@@ -14,7 +14,12 @@
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
- "jsx": "react-jsx"
+ "jsx": "react-jsx",
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["src/*"]
+ },
+ "types": ["vitest/globals", "@testing-library/jest-dom"]
},
- "include": ["src"]
+ "include": ["src", "vite.config.ts", "playwright.config.ts", "eslint.config.js"]
}
diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts
index 97f228e..2e6d8c9 100644
--- a/apps/web/vite.config.ts
+++ b/apps/web/vite.config.ts
@@ -1,10 +1,17 @@
-import { defineConfig } from "vite";
+import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
+import tailwindcss from "@tailwindcss/vite";
+import path from "node:path";
const apiProxyTarget = process.env.VITE_API_PROXY_TARGET ?? process.env.VITE_API_BASE_URL ?? "http://127.0.0.1:8080";
export default defineConfig({
- plugins: [react()],
+ plugins: [react(), tailwindcss()],
+ resolve: {
+ alias: {
+ "@": path.resolve(__dirname, "./src")
+ }
+ },
server: {
port: 3000,
proxy: {
@@ -12,5 +19,12 @@ export default defineConfig({
"/healthz": apiProxyTarget,
"/readyz": apiProxyTarget
}
+ },
+ test: {
+ environment: "jsdom",
+ setupFiles: ["./src/test/setup.ts"],
+ css: true,
+ globals: false,
+ exclude: ["**/node_modules/**", "**/dist/**", "**/e2e/**"]
}
});
diff --git a/deployments/docker/compose.dev.yaml b/deployments/docker/compose.dev.yaml
index dcbf4e8..c40ce95 100644
--- a/deployments/docker/compose.dev.yaml
+++ b/deployments/docker/compose.dev.yaml
@@ -27,6 +27,9 @@ services:
- pnpm_store:/root/.local/share/pnpm/store
- web_node_modules:/workspace/apps/web/node_modules
environment:
+ # Avoid hard failures when the bind-mounted git tree is owned by a
+ # different UID than the container user (common in CI).
+ GOFLAGS: ${GOFLAGS:--buildvcs=false}
MULTICODEX_DATABASE_URL: ${MULTICODEX_DATABASE_URL:-postgres://${POSTGRES_USER:-multi_codex}@postgres:5432/${POSTGRES_DB:-multi_codex}?sslmode=disable}
MULTICODEX_AUTH_MODE: ${MULTICODEX_AUTH_MODE:-local}
MULTICODEX_AUTH_SESSION_TTL: ${MULTICODEX_AUTH_SESSION_TTL:-12h}
diff --git a/deployments/docker/compose.ha.yaml b/deployments/docker/compose.ha.yaml
new file mode 100644
index 0000000..8e506ec
--- /dev/null
+++ b/deployments/docker/compose.ha.yaml
@@ -0,0 +1,43 @@
+# High-availability overlay for the production Compose stack.
+# Usage:
+# docker compose -f deployments/docker/compose.yaml -f deployments/docker/compose.ha.yaml \
+# --env-file .env.production up -d --scale api=2 --scale mcp-gateway=2
+#
+# The edge proxy becomes the only published entrypoint for Web/API/MCP.
+# PostgreSQL remains a single primary in this overlay; use a managed HA database
+# when the deployment requires automatic failover.
+
+services:
+ api:
+ ports: !reset []
+ environment:
+ MULTICODEX_API_LISTEN: ":8080"
+
+ mcp-gateway:
+ ports: !reset []
+ environment:
+ MULTICODEX_MCP_LISTEN: ":8090"
+
+ worker-agentd:
+ ports: !reset []
+
+ web:
+ ports: !reset []
+
+ edge:
+ image: nginx:1.27-alpine
+ depends_on:
+ - api
+ - mcp-gateway
+ - web
+ volumes:
+ - ./nginx.ha.conf:/etc/nginx/conf.d/default.conf:ro
+ ports:
+ - "${MULTICODEX_API_HOST_PORT:-8080}:8080"
+ - "${MULTICODEX_MCP_HOST_PORT:-8090}:8090"
+ - "${MULTICODEX_WEB_HOST_PORT:-3000}:80"
+ healthcheck:
+ test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/healthz >/dev/null || exit 1"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
diff --git a/deployments/docker/nginx.ha.conf b/deployments/docker/nginx.ha.conf
new file mode 100644
index 0000000..f6098cd
--- /dev/null
+++ b/deployments/docker/nginx.ha.conf
@@ -0,0 +1,82 @@
+upstream multicodex_api {
+ least_conn;
+ server api:8080;
+ keepalive 32;
+}
+
+upstream multicodex_mcp {
+ least_conn;
+ server mcp-gateway:8090;
+ keepalive 16;
+}
+
+upstream multicodex_web {
+ server web:80;
+}
+
+server {
+ listen 8080;
+ server_name _;
+
+ location /healthz {
+ proxy_pass http://multicodex_api;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header Connection "";
+ }
+
+ location / {
+ proxy_pass http://multicodex_api;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header X-Forwarded-Host $host;
+ proxy_set_header Connection "";
+ }
+}
+
+server {
+ listen 8090;
+ server_name _;
+
+ location /healthz {
+ proxy_pass http://multicodex_mcp;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header Connection "";
+ }
+
+ location / {
+ proxy_pass http://multicodex_mcp;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header Connection "";
+ proxy_buffering off;
+ proxy_read_timeout 3600s;
+ }
+}
+
+server {
+ listen 80;
+ server_name _;
+
+ location /api/ {
+ proxy_pass http://multicodex_api;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header Connection "";
+ }
+
+ location / {
+ proxy_pass http://multicodex_web;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+}
diff --git a/deployments/observability/prometheus-alerts.yaml b/deployments/observability/prometheus-alerts.yaml
index b95c8b7..7afdedb 100644
--- a/deployments/observability/prometheus-alerts.yaml
+++ b/deployments/observability/prometheus-alerts.yaml
@@ -55,6 +55,44 @@ groups:
summary: multi-codex has elevated terminal worker failures
runbook_url: docs/operations/runbooks/runaway-worker.md
+ - alert: MultiCodexUsageBudgetSoftBreached
+ expr: |
+ multi_codex_usage_budget_enabled{service="multi-codex-api"} == 1
+ and on(service)
+ (
+ multi_codex_usage_current_day_worker_duration_seconds{service="multi-codex-api"}
+ >= on(service)
+ multi_codex_usage_budget_seconds_per_day{service="multi-codex-api",level="warn"}
+ )
+ and on(service)
+ (
+ multi_codex_usage_current_day_worker_duration_seconds{service="multi-codex-api"}
+ < on(service)
+ multi_codex_usage_budget_seconds_per_day{service="multi-codex-api",level="hard"}
+ )
+ for: 10m
+ labels:
+ severity: warn
+ annotations:
+ summary: multi-codex daily worker-duration usage has crossed the warning budget
+ runbook_url: docs/operations/usage-visibility.md
+
+ - alert: MultiCodexUsageBudgetHardBreached
+ expr: |
+ multi_codex_usage_budget_enabled{service="multi-codex-api"} == 1
+ and on(service)
+ (
+ multi_codex_usage_current_day_worker_duration_seconds{service="multi-codex-api"}
+ >= on(service)
+ multi_codex_usage_budget_seconds_per_day{service="multi-codex-api",level="hard"}
+ )
+ for: 5m
+ labels:
+ severity: page
+ annotations:
+ summary: multi-codex daily worker-duration usage has crossed the hard budget
+ runbook_url: docs/operations/usage-visibility.md
+
- name: multi-codex-operations
rules:
- alert: MultiCodexAuditShipFailed
diff --git a/docs/README.md b/docs/README.md
index 570d588..71d3128 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -14,6 +14,8 @@ 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)
+- [Frontend Workbench Plan](implementation/frontend-workbench-plan.md)
- [Docker Development](implementation/docker-development.md)
## Security and Operations
@@ -26,6 +28,12 @@ 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)
+- [GA Sign-Off Template](operations/ga-signoff-template.md)
+- [High Availability Topology](operations/ha-topology.md)
+- [Environment Cutover](operations/environment-cutover.md)
+- [Quotas And Rate Limits](operations/quotas-and-rate-limits.md)
+- [Usage Visibility](operations/usage-visibility.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/frontend-workbench-plan.md b/docs/implementation/frontend-workbench-plan.md
new file mode 100644
index 0000000..a327281
--- /dev/null
+++ b/docs/implementation/frontend-workbench-plan.md
@@ -0,0 +1,386 @@
+# 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)
+
+## Status
+
+Implementation progress as of 2026-07-25:
+
+- **F0–F6 landed** on the workbench branch: React Router shell, Tailwind token
+ bridge, shared UI primitives, TanStack Table / RHF+Zod standards, Run Cockpit
+ evidence tabs, guided checklist, saved filters, approval notification surface,
+ Vitest/Playwright coverage, and CI lint/test gates.
+- Backend **L3 quotas/rate limits** and **L5 usage APIs** landed alongside this
+ track. The console Usage page consumes `/api/v1/usage/summary`.
+- Remaining launch work is mostly environment cutover evidence (L1), HA drills
+ (L4), budget alerts, and GA sign-off (L6).
+
+`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
new file mode 100644
index 0000000..d204d6c
--- /dev/null
+++ b/docs/implementation/launch-plan.md
@@ -0,0 +1,328 @@
+# 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; 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 |
+| 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 Workbench Maturity
+
+Status: **F0–F6 implemented**.
+
+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:
+
+- 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 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
+
+Status: **backend quotas and rate limits implemented**; membership admin already
+exists in API/console. Remaining work is operator UX polish for quota visibility
+and recurring credential-rotation drills.
+
+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
+
+Status: **HA Compose overlay and failure/rollback docs implemented**; live
+environment drills remain.
+
+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
+
+Status: **usage APIs, console page, and advisory budget alerts implemented**;
+threshold tuning remains per environment.
+
+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 / 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.
+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.
+
+## 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 |
+| 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 |
+| 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/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 |
+| 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)
+- [Frontend Workbench Plan](frontend-workbench-plan.md)
+- [Implementation Roadmap](roadmap.md)
+- [MVP Status](mvp-status.md)
+- [Verification Report](../operations/verification-report.md)
+- [Product Pilot Runbook](../operations/pilot-runbook.md)
+- [Launch Checklist](../operations/launch-checklist.md)
+- [Release And Deployment](../operations/release-and-deployment.md)
+- [Observability And DR](../operations/observability-and-dr.md)
+- [Enterprise Hardening](../security/enterprise-hardening.md)
+- [Design QA](../operations/design-qa.md)
diff --git a/docs/implementation/mvp-status.md b/docs/implementation/mvp-status.md
index db425c4..3cc1978 100644
--- a/docs/implementation/mvp-status.md
+++ b/docs/implementation/mvp-status.md
@@ -2,6 +2,12 @@
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). Frontend workbench maturity continues under
+[frontend-workbench-plan.md](frontend-workbench-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..3ea49d5 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,19 @@ 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. 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).
-This sequence intentionally protects the release path before expanding functionality.
+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 b2167a4..2850b5b 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: MVP complete; workbench maturity F0–F6 implemented.
Completed:
@@ -113,6 +113,18 @@ Completed:
- Organization Management.
- Skill/Profile Management.
- Audit Log and MCP Tool Call query.
+- Pagination, auth/session controls, permissions, and i18n in the operator shell.
+- React Router 7 app shell with deep links for primary journeys.
+- Tailwind v4 token bridge, shared UI primitives, lucide icons.
+- TanStack Table list pattern and react-hook-form + Zod task create validation.
+- Run Cockpit tabs for events, virtualized logs, diff, artifacts, and result.
+- Guided checklist, saved filters, approval notification surface, and Usage page.
+- Vitest unit tests, Playwright smoke, and CI frontend lint/test gates.
+
+Next work:
+
+- Continue remaining launch milestones under Phase 9 (L1 evidence, L4 HA drills,
+ L5 budget alerts, L6 GA sign-off).
## Phase 7: SSH Executor
@@ -177,3 +189,48 @@ 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 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
+ (**HA Compose overlay + failure docs landed**; live drills remain)
+- L5: usage, cost, and capacity visibility
+ (**usage APIs, console, and advisory budget alerts landed**)
+- L6: broader rollout, notifications, optional Docs/Forgejo, GA sign-off
+ (**sign-off template landed**; cohort evidence remains)
+
+
+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. 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/environment-cutover.md b/docs/operations/environment-cutover.md
new file mode 100644
index 0000000..d7a7d57
--- /dev/null
+++ b/docs/operations/environment-cutover.md
@@ -0,0 +1,75 @@
+# Environment Cutover
+
+Use this page when bringing up the first durable production environment.
+Record evidence in the environment release record. Related checklist:
+[launch-checklist.md](launch-checklist.md).
+
+## Prerequisites
+
+- Immutable API/Web/worker image tags available from CI or a local release build
+- Secret manager entries ready for database password, OIDC client secret,
+ agentd token, worker secrets, and Git credentials
+- TLS certificates or managed load balancer ready for Web, API, and MCP
+- On-call channel ready to receive Prometheus alerts
+
+## Cutover Sequence
+
+1. Copy [.env.production.example](../../.env.production.example) to an untracked
+ `.env.production` and fill secrets from the secret manager.
+2. Register IdP redirect, logout, and back-channel logout URLs using the final
+ HTTPS hostnames.
+3. Validate Compose:
+
+```bash
+POSTGRES_PASSWORD="${POSTGRES_PASSWORD:?}" docker compose --env-file .env.production -f deployments/docker/compose.yaml --profile migrate config >/dev/null
+```
+
+4. Migrate, then start services:
+
+```bash
+docker compose --env-file .env.production -f deployments/docker/compose.yaml --profile migrate run --rm migrate
+docker compose --env-file .env.production -f deployments/docker/compose.yaml up -d postgres api mcp-gateway worker-agentd web
+```
+
+5. Smoke check health endpoints through TLS hostnames.
+6. Confirm OIDC browser login creates an HttpOnly session and `/api/v1/auth/me`
+ returns the mapped role.
+7. Leave `MULTICODEX_GIT_SYNC_MODE=dry-run` until the pilot dry-run evidence is
+ reviewed.
+8. Enable retention in dry-run, schedule audit seal/ship, and book the first
+ restore drill.
+
+## Evidence Fields
+
+| Item | Value |
+| --- | --- |
+| Environment name | |
+| Cutover date (UTC) | |
+| API image digest | |
+| Web image digest | |
+| Worker image digest | |
+| OIDC issuer | |
+| Redirect URL | |
+| Back-channel logout URL | |
+| CORS origins | |
+| Worker host boundary | |
+| Audit ship target | |
+| Audit ship receipt path | |
+| Backup schedule | |
+| Next restore drill date | |
+| Alert route / on-call | |
+| Git Sync mode | dry-run |
+| Operator | |
+| Security reviewer | |
+
+## Fail-Closed Checks
+
+Confirm production startup refuses unsafe combinations before serving traffic:
+
+```bash
+MULTICODEX_ENV=production MULTICODEX_AUTH_MODE=local ./api # must exit non-zero
+```
+
+Repeat for missing OIDC settings, insecure cookies, open CORS, missing agentd
+token, and live Git Sync without review acknowledgement. See
+[production-configuration.md](production-configuration.md).
diff --git a/docs/operations/ga-signoff-template.md b/docs/operations/ga-signoff-template.md
new file mode 100644
index 0000000..f35c18a
--- /dev/null
+++ b/docs/operations/ga-signoff-template.md
@@ -0,0 +1,46 @@
+# GA Sign-Off Template
+
+Use this template after L1–L5 are evidenced for the first internal GA cohort.
+Attach artifacts to the release record rather than appending notes here.
+
+## Scope
+
+- Environment:
+- Teams:
+- Repositories:
+- Image tags / digests:
+- Cutover date (UTC):
+
+## Evidence Links
+
+| Gate | Evidence path / URL | Result |
+| --- | --- | --- |
+| L1 environment cutover | | |
+| L2 workbench usability | | |
+| L3 quotas / membership | | |
+| L4 HA / rollback | | |
+| L5 usage / budget | | |
+| Pilot/live PR with auto-merge disabled | | |
+| Backup + restore drill | | |
+| Audit seal/ship receipt | | |
+
+## Decisions
+
+- Security reviewer:
+- Operations owner:
+- Service owner:
+- Engineering lead:
+
+## Open Follow-Ups
+
+- [ ]
+- [ ]
+
+## Sign-Off
+
+| Role | Name | Date | Approve? |
+| --- | --- | --- | --- |
+| Service owner | | | |
+| Security reviewer | | | |
+| Operations owner | | | |
+| GA engineering lead | | | |
diff --git a/docs/operations/ha-topology.md b/docs/operations/ha-topology.md
new file mode 100644
index 0000000..934afc6
--- /dev/null
+++ b/docs/operations/ha-topology.md
@@ -0,0 +1,96 @@
+# High Availability Topology
+
+This page describes the first HA Compose path for multi-codex. It keeps the
+control plane available across API/MCP replicas while documenting the remaining
+single-primary database boundary.
+
+Related files:
+
+- [compose.yaml](../../deployments/docker/compose.yaml)
+- [compose.ha.yaml](../../deployments/docker/compose.ha.yaml)
+- [nginx.ha.conf](../../deployments/docker/nginx.ha.conf)
+- [Release And Deployment](release-and-deployment.md)
+
+## What This Topology Provides
+
+- Two or more API replicas behind an edge nginx proxy
+- Two or more MCP Gateway replicas behind the same edge proxy
+- Shared Docker volumes for artifacts, runs, worktrees, repo cache, and audit
+ seals so any replica can serve existing evidence
+- Unpublished direct container ports; only the edge proxy is exposed
+
+## What This Topology Does Not Provide
+
+- Automatic PostgreSQL failover. Keep a managed primary/replica database or an
+ external HA Postgres operator when RPO/RTO require it.
+- Cross-region DR. Use the backup/restore drills in
+ [observability-and-dr.md](observability-and-dr.md).
+- Autoscaling workers. Executor capacity remains node/queue based.
+
+## Bring-Up
+
+```bash
+POSTGRES_PASSWORD="${POSTGRES_PASSWORD:?}" \
+docker compose \
+ -f deployments/docker/compose.yaml \
+ -f deployments/docker/compose.ha.yaml \
+ --env-file .env.production \
+ --profile migrate run --rm migrate
+
+POSTGRES_PASSWORD="${POSTGRES_PASSWORD:?}" \
+docker compose \
+ -f deployments/docker/compose.yaml \
+ -f deployments/docker/compose.ha.yaml \
+ --env-file .env.production \
+ up -d --scale api=2 --scale mcp-gateway=2
+```
+
+Smoke through the edge ports:
+
+```bash
+curl -fsS http://127.0.0.1:${MULTICODEX_API_HOST_PORT:-8080}/healthz
+curl -fsS http://127.0.0.1:${MULTICODEX_MCP_HOST_PORT:-8090}/healthz
+curl -fsS http://127.0.0.1:${MULTICODEX_WEB_HOST_PORT:-3000}/
+```
+
+## Replica Failure Behavior
+
+| Failure | Expected behavior | Operator action |
+| --- | --- | --- |
+| One API replica stops | Edge continues serving through remaining API replica(s). In-flight requests on the dead replica fail once and should be retried by clients. | `docker compose ... up -d --scale api=2` to restore capacity |
+| One MCP Gateway replica stops | Active SSE clients on that replica reconnect; durable MCP session events remain in PostgreSQL and resume through another replica. | Restore scale; confirm `/metrics` and a test `initialize` |
+| Edge proxy stops | Web/API/MCP become unreachable even if replicas are healthy. | Restart `edge`; keep TLS termination upstream if used |
+| PostgreSQL primary unavailable | API/MCP fail readiness and stop useful work. Queue and auth state are unavailable. | Follow [database-outage.md](runbooks/database-outage.md) |
+| Shared volume unavailable | Artifact/log reads and worker outputs fail while metadata may still exist in Postgres. | Restore volume or promote from backup archives |
+
+Accepted work already persisted in PostgreSQL survives API/MCP replica loss.
+Unfinished HTTP requests and live SSE streams attached to a dead replica do not.
+
+## Rolling App Image Update
+
+1. Keep the previous immutable image tags available.
+2. Update `MULTICODEX_API_IMAGE` / `MULTICODEX_WEB_IMAGE`.
+3. Run migrate once.
+4. Recreate API/MCP replicas gradually:
+
+```bash
+docker compose -f deployments/docker/compose.yaml -f deployments/docker/compose.ha.yaml --env-file .env.production up -d --no-deps --scale api=2 api
+docker compose -f deployments/docker/compose.yaml -f deployments/docker/compose.ha.yaml --env-file .env.production up -d --no-deps --scale mcp-gateway=2 mcp-gateway
+docker compose -f deployments/docker/compose.yaml -f deployments/docker/compose.ha.yaml --env-file .env.production up -d --no-deps web edge
+```
+
+5. Re-run health checks and watch `/metrics` error rates and queue depth.
+
+## Rollback Drill Checklist
+
+- [ ] Previous API/Web/worker digests recorded
+- [ ] Stop serving by setting images back to previous tags
+- [ ] Recreate api/mcp-gateway/web/edge
+- [ ] Health checks pass
+- [ ] No rise in queue depth or worker terminal failures for 15 minutes
+
+## Database Note
+
+For production HA beyond this overlay, point `MULTICODEX_DATABASE_URL` at a
+managed Postgres service with automated backups and failover. Keep Compose
+Postgres only for small controlled environments or disposable drills.
diff --git a/docs/operations/launch-checklist.md b/docs/operations/launch-checklist.md
new file mode 100644
index 0000000..0e7dc6d
--- /dev/null
+++ b/docs/operations/launch-checklist.md
@@ -0,0 +1,82 @@
+# 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
+
+- [x] Production env template published as `.env.production.example`
+- [x] Cutover sequence and evidence fields documented in `environment-cutover.md`
+- [ ] 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 Workbench
+
+- [x] Frontend stack freeze recorded: React Router 7, Tailwind v4, shadcn/ui + Radix, lucide, TanStack Table, RHF + Zod, Vitest, Playwright
+- [x] App shell routes cover Projects, Tasks, Runs, Approvals, Queue, Nodes, Skills, Orgs, Admin, Audit
+- [x] Deep links work for task detail, run detail, approvals, and audit evidence
+- [x] Shared primitives replace ad-hoc dialogs/menus/tabs for primary journeys
+- [x] Run Cockpit provides live events, virtualized logs, diff, and artifacts
+- [x] Permission-denied and capacity errors are understandable in the UI
+- [x] Frontend lint/test/build/smoke pass in CI
+- [x] Guided checklist, saved filters, and approval notification surface available
+- [ ] New tech lead completes one governed task using docs + console URLs only
+
+## L3 Multi-Team Controls
+
+- [x] Org/project concurrency quotas enforced and audited
+- [x] Mutating API/MCP rate limits enabled
+- [x] 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
+
+- [x] HA Compose overlay and edge proxy published (`compose.ha.yaml`)
+- [x] API/MCP replica failure behavior documented
+- [x] App rollback procedure documented with previous immutable image tags
+- [ ] Database restore drill completed within documented RTO
+- [ ] HA topology smoke exercised in the target environment
+
+## L5 Usage Visibility
+
+- [x] Per-org/project/run duration (and token usage when available) queryable
+- [x] Console usage page shows duration summaries
+- [x] Soft/hard daily duration budget status exposed in API, metrics, and alerts
+- [x] Console budget banner rendered from usage summary
+- [ ] Budget thresholds tuned for the first production team
+
+## L6 GA Sign-Off
+
+- [x] GA sign-off template published
+- [ ] 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/observability-and-dr.md b/docs/operations/observability-and-dr.md
index 840a9fa..a509d0e 100644
--- a/docs/operations/observability-and-dr.md
+++ b/docs/operations/observability-and-dr.md
@@ -42,6 +42,7 @@ The alert rules cover:
- API/MCP latency and error rate
- queue depth
- worker terminal failures
+- daily worker-duration budget soft/hard breaches
- audit ship failures and stale successful audit ship
- retention cleanup failures
- telemetry push failures
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
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/docs/operations/release-and-deployment.md b/docs/operations/release-and-deployment.md
index 6bca8e8..f20d8fb 100644
--- a/docs/operations/release-and-deployment.md
+++ b/docs/operations/release-and-deployment.md
@@ -69,6 +69,15 @@ docker compose -f deployments/docker/compose.yaml --profile migrate run --rm mig
docker compose -f deployments/docker/compose.yaml up -d postgres api mcp-gateway worker-agentd web
```
+For the HA overlay with scaled API/MCP replicas behind an edge proxy, see
+[ha-topology.md](ha-topology.md):
+
+```bash
+POSTGRES_PASSWORD="${POSTGRES_PASSWORD:?}" \
+docker compose -f deployments/docker/compose.yaml -f deployments/docker/compose.ha.yaml \
+ --env-file .env.production up -d --scale api=2 --scale mcp-gateway=2
+```
+
Smoke checks:
```bash
diff --git a/docs/operations/usage-visibility.md b/docs/operations/usage-visibility.md
new file mode 100644
index 0000000..1b215f9
--- /dev/null
+++ b/docs/operations/usage-visibility.md
@@ -0,0 +1,118 @@
+# Usage visibility API
+
+The API exposes run duration usage for console summaries without requiring new
+configuration. Optional daily worker-duration budgets can add API banner data
+and Prometheus alerts without blocking workers.
+
+## Endpoints
+
+### `GET /api/v1/usage/summary`
+
+Returns usage aggregated by organization, project, role, and UTC day.
+
+Query parameters:
+
+- `org_id` optional organization filter.
+- `project_id` optional project filter.
+- `from` optional inclusive lower bound, as RFC3339 or `YYYY-MM-DD`.
+- `to` optional exclusive upper bound, as RFC3339. When `YYYY-MM-DD` is used,
+ the bound is the end of that UTC day.
+
+Response shape:
+
+```json
+{
+ "rows": [
+ {
+ "organization_id": "org_default",
+ "organization_name": "Default Organization",
+ "project_id": "proj_demo",
+ "project_name": "Demo Engineering",
+ "role": "feature",
+ "day": "2026-01-02",
+ "run_count": 2,
+ "completed_run_count": 2,
+ "total_duration_seconds": 120,
+ "token_usage": null,
+ "tokens_unknown": true
+ }
+ ],
+ "total": {
+ "run_count": 2,
+ "completed_run_count": 2,
+ "total_duration_seconds": 120,
+ "token_usage": null
+ },
+ "tokens_unknown": true,
+ "budget": {
+ "enabled": true,
+ "day": "2026-01-02",
+ "usage_seconds": 120,
+ "warn_threshold_seconds": 28800,
+ "hard_threshold_seconds": 36000,
+ "warn_ratio": 0.8,
+ "state": "ok"
+ }
+}
+```
+
+### `GET /api/v1/usage/runs`
+
+Returns scoped per-run usage records. It accepts the same filters plus `limit`
+(default `100`, maximum `500`).
+
+## Duration and tokens
+
+Duration is derived from run timestamps already stored by multi-codex:
+`finished_at - started_at`, or `finished_at - created_at` when `started_at` is
+missing. Runs without `finished_at` report `duration_seconds: null` and do not
+contribute to completed duration totals.
+
+There are no dedicated token columns today. When a run result contains common
+usage fields such as `token_usage.prompt_tokens`,
+`token_usage.completion_tokens`, and `token_usage.total_tokens`, the API sums
+those values. Otherwise it returns `token_usage: null` and
+`tokens_unknown: true`; it never invents token counts.
+
+## Daily worker-duration budget
+
+Budget visibility is disabled by default and is advisory only. It does not
+hard-block worker starts. Configure it with:
+
+- `MULTICODEX_USAGE_BUDGET_ENABLED=false`
+- `MULTICODEX_USAGE_BUDGET_SECONDS_PER_DAY=0`
+- `MULTICODEX_USAGE_BUDGET_WARN_RATIO=0.8`
+
+`MULTICODEX_USAGE_BUDGET_SECONDS_PER_DAY=0` disables budget evaluation even if
+the enabled flag is accidentally set. When enabled with a positive hard
+threshold, the warning threshold is `seconds_per_day * warn_ratio`. Invalid warn
+ratios fall back to `0.8`.
+
+The usage summary response includes `budget` for the caller's authorized query
+scope and the current UTC day:
+
+- `state: "disabled"` when advisory budgets are off or unset.
+- `state: "ok"` when current-day completed worker duration is below warning.
+- `state: "warn"` when usage is at or above the warning threshold.
+- `state: "hard"` when usage is at or above the hard daily threshold.
+- `state: "unknown"` if the budget status query fails while budgets are enabled.
+
+Prometheus metrics exposed by `GET /metrics`:
+
+- `multi_codex_usage_budget_enabled`
+- `multi_codex_usage_current_day_worker_duration_seconds`
+- `multi_codex_usage_budget_seconds_per_day{level="warn|hard"}`
+
+Alert rules in `deployments/observability/prometheus-alerts.yaml`:
+
+- `MultiCodexUsageBudgetSoftBreached` (`severity: warn`) fires when current-day
+ usage reaches the warning threshold but remains below the hard threshold.
+- `MultiCodexUsageBudgetHardBreached` (`severity: page`) fires when current-day
+ usage reaches the hard threshold.
+
+## Authorization
+
+Usage endpoints use the same resource-scoped authorization as project and run
+APIs. A caller can query only projects visible through their organization role
+or project memberships. Filtering by an inaccessible `project_id` or `org_id`
+returns `403`.
diff --git a/internal/api/limits_test.go b/internal/api/limits_test.go
new file mode 100644
index 0000000..d3d43c0
--- /dev/null
+++ b/internal/api/limits_test.go
@@ -0,0 +1,172 @@
+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,
+ // Deny the task's allowed command so the async executor exits in
+ // enforceWorkerPlanPolicy before writing under t.TempDir().
+ WorkerCommandDenylist: []string{"go test"},
+ }), 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")
+
+ runs := st.ListRuns(first.ID)
+ if len(runs) == 0 {
+ t.Fatal("expected accepted start to create a run")
+ }
+ waitForAPIQueueRunTerminal(t, st, runs[0].ID)
+}
+
+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()
+ if overrides.ExecutorMode == "" {
+ overrides.ExecutorMode = "mock"
+ }
+ 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..8166781 100644
--- a/internal/api/router.go
+++ b/internal/api/router.go
@@ -24,21 +24,25 @@ 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"
"github.com/Chiiz0/multi-codex/internal/scheduler"
"github.com/Chiiz0/multi-codex/internal/store"
+ "github.com/Chiiz0/multi-codex/internal/usagebudget"
"github.com/Chiiz0/multi-codex/internal/workflow"
)
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 +61,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()
@@ -166,6 +170,10 @@ func (s *Server) routeAPI(w http.ResponseWriter, r *http.Request) {
s.toolCalls(w, r)
case len(parts) == 1 && parts[0] == "audit-logs":
writeJSON(w, http.StatusOK, s.filterAuditLogsForAuth(r, s.store.ListAuditLogs()))
+ case len(parts) == 2 && parts[0] == "usage" && parts[1] == "summary":
+ s.usageSummary(w, r)
+ case len(parts) == 2 && parts[0] == "usage" && parts[1] == "runs":
+ s.usageRuns(w, r)
default:
writeError(w, http.StatusNotFound, "route not found")
}
@@ -373,7 +381,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 +391,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 +471,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 +483,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
}
@@ -560,12 +603,14 @@ func (s *Server) metricsEndpoint(w http.ResponseWriter, r *http.Request) {
runs := s.store.ListAllRuns()
_, _ = w.Write([]byte(observability.RunsPrometheusText("multi-codex-api", runs)))
_, _ = w.Write([]byte(observability.OperationsPrometheusText("multi-codex-api", runs, s.store.ListAuditLogs())))
+ _, _ = w.Write([]byte(observability.UsageBudgetPrometheusText("multi-codex-api", usagebudget.EvaluateRuns(runs, usagebudget.SettingsFromConfig(s.cfg), time.Now().UTC()))))
return
}
snapshot := s.metrics.Snapshot()
runs := s.store.ListAllRuns()
snapshot["runs"] = observability.RunMetricsSnapshot(runs)
snapshot["operations"] = observability.OperationsSnapshot(runs, s.store.ListAuditLogs())
+ snapshot["usage_budget"] = usagebudget.EvaluateRuns(runs, usagebudget.SettingsFromConfig(s.cfg), time.Now().UTC())
writeJSON(w, http.StatusOK, snapshot)
}
@@ -1438,6 +1483,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 +1565,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)
@@ -1547,6 +1598,67 @@ func (s *Server) allRuns(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, s.filterRunsForAuth(r, s.store.ListAllRuns()))
}
+func (s *Server) usageSummary(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ methodNotAllowed(w)
+ return
+ }
+ filter, ok := s.usageFilter(w, r)
+ if !ok {
+ return
+ }
+ summary, err := s.store.GetUsageSummary(filter)
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "usage summary query failed")
+ return
+ }
+ summary.Budget = s.usageBudgetStatus(filter)
+ writeJSON(w, http.StatusOK, summary)
+}
+
+func (s *Server) usageRuns(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ methodNotAllowed(w)
+ return
+ }
+ filter, ok := s.usageFilter(w, r)
+ if !ok {
+ return
+ }
+ filter.Limit = usageLimit(r)
+ runs, err := s.store.ListUsageRuns(filter)
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "usage runs query failed")
+ return
+ }
+ tokensUnknown := false
+ for _, run := range runs {
+ if run.TokensUnknown {
+ tokensUnknown = true
+ break
+ }
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"runs": runs, "tokens_unknown": tokensUnknown})
+}
+
+func (s *Server) usageBudgetStatus(filter domain.UsageFilter) *domain.UsageBudgetStatus {
+ now := time.Now().UTC()
+ settings := usagebudget.SettingsFromConfig(s.cfg)
+ from, to := usagebudget.CurrentDayRange(now)
+ budgetFilter := filter
+ budgetFilter.From = &from
+ budgetFilter.To = &to
+ budgetFilter.Limit = 0
+ summary, err := s.store.GetUsageSummary(budgetFilter)
+ if err != nil {
+ s.log.Warn("usage budget summary query failed", "error", err)
+ status := usagebudget.Unknown(settings, now)
+ return &status
+ }
+ status := usagebudget.Evaluate(summary.Total.TotalDurationSeconds, settings, now)
+ return &status
+}
+
func (s *Server) scopeCheck(w http.ResponseWriter, r *http.Request, taskID string) {
if r.Method != http.MethodPost {
methodNotAllowed(w)
@@ -1805,6 +1917,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 +1972,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 +2275,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 +2380,107 @@ func hasPermission(permissions []string, required string) bool {
return false
}
+func (s *Server) usageFilter(w http.ResponseWriter, r *http.Request) (domain.UsageFilter, bool) {
+ auth, err := s.requestAuth(r)
+ if err != nil {
+ writeError(w, http.StatusUnauthorized, "authentication required")
+ return domain.UsageFilter{}, false
+ }
+
+ filter := domain.UsageFilter{}
+ if from, ok := parseUsageTimeParam(w, r, "from"); !ok {
+ return domain.UsageFilter{}, false
+ } else {
+ filter.From = from
+ }
+ if to, ok := parseUsageTimeParam(w, r, "to"); !ok {
+ return domain.UsageFilter{}, false
+ } else {
+ filter.To = to
+ }
+ if filter.From != nil && filter.To != nil && !filter.From.Before(*filter.To) {
+ writeError(w, http.StatusBadRequest, "from must be before to")
+ return domain.UsageFilter{}, false
+ }
+
+ orgID := strings.TrimSpace(r.URL.Query().Get("org_id"))
+ projectID := strings.TrimSpace(r.URL.Query().Get("project_id"))
+ if projectID != "" {
+ project, ok := s.authorizeProject(w, r, projectID, "api.usage_read")
+ if !ok {
+ return domain.UsageFilter{}, false
+ }
+ if orgID != "" && project.OrgID != orgID {
+ writeError(w, http.StatusBadRequest, "project_id is not in org_id")
+ return domain.UsageFilter{}, false
+ }
+ filter.ProjectIDs = []string{project.ID}
+ return filter, true
+ }
+
+ if orgID != "" && !canAccessOrg(auth, orgID) {
+ s.denyResource(w, r, auth, "api.usage_read", "organization", orgID, orgID)
+ return domain.UsageFilter{}, false
+ }
+
+ projects := s.store.ListProjects()
+ for _, project := range projects {
+ if orgID != "" && project.OrgID != orgID {
+ continue
+ }
+ if canAccessProject(auth, project) {
+ filter.ProjectIDs = append(filter.ProjectIDs, project.ID)
+ }
+ }
+ return filter, true
+}
+
+func parseUsageTimeParam(w http.ResponseWriter, r *http.Request, name string) (*time.Time, bool) {
+ value := strings.TrimSpace(r.URL.Query().Get(name))
+ if value == "" {
+ return nil, true
+ }
+ if parsed, err := time.Parse(time.RFC3339Nano, value); err == nil {
+ utc := parsed.UTC()
+ return &utc, true
+ }
+ if parsed, err := time.Parse("2006-01-02", value); err == nil {
+ utc := parsed.UTC()
+ if name == "to" {
+ utc = utc.Add(24 * time.Hour)
+ }
+ return &utc, true
+ }
+ writeError(w, http.StatusBadRequest, name+" must be RFC3339 or YYYY-MM-DD")
+ return nil, false
+}
+
+func usageLimit(r *http.Request) int {
+ value := strings.TrimSpace(r.URL.Query().Get("limit"))
+ if value == "" {
+ return 100
+ }
+ limit, err := strconv.Atoi(value)
+ if err != nil || limit < 1 {
+ return 100
+ }
+ if limit > 500 {
+ return 500
+ }
+ return limit
+}
+
+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 +3004,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 +3202,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/api/usage_test.go b/internal/api/usage_test.go
new file mode 100644
index 0000000..6ea483d
--- /dev/null
+++ b/internal/api/usage_test.go
@@ -0,0 +1,101 @@
+package api
+
+import (
+ "encoding/json"
+ "io"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/Chiiz0/multi-codex/internal/config"
+ "github.com/Chiiz0/multi-codex/internal/domain"
+ "github.com/Chiiz0/multi-codex/internal/store"
+)
+
+func TestUsageAPIRespectsProjectScopedAuthorization(t *testing.T) {
+ st := store.NewMemoryStore()
+ member, err := st.UpsertUser(domain.User{Email: "usage-viewer@example.com", DisplayName: "Usage Viewer"}, "org_default", "viewer")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ allowedProject := st.CreateProject(domain.Project{OrgID: "org_default", Name: "Allowed Usage", Slug: "allowed-usage"})
+ allowedRepo := st.CreateRepository(domain.Repository{ProjectID: allowedProject.ID, Name: "repo", Provider: "local", RemoteURL: "file:///allowed.git"})
+ allowedTask := st.CreateTask(domain.TaskEnvelope{TaskID: "USAGE-ALLOWED", ProjectID: allowedProject.ID, RepositoryID: allowedRepo.ID, Title: "Allowed usage", Role: "feature", Executor: "docker"})
+ allowedRun, err := st.StartRun(allowedTask.ID, "feature", "docker")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := st.FinishRun(allowedRun.ID, "succeeded", nil); err != nil {
+ t.Fatal(err)
+ }
+
+ deniedProject := st.CreateProject(domain.Project{OrgID: "org_default", Name: "Denied Usage", Slug: "denied-usage"})
+ deniedRepo := st.CreateRepository(domain.Repository{ProjectID: deniedProject.ID, Name: "repo", Provider: "local", RemoteURL: "file:///denied.git"})
+ deniedTask := st.CreateTask(domain.TaskEnvelope{TaskID: "USAGE-DENIED", ProjectID: deniedProject.ID, RepositoryID: deniedRepo.ID, Title: "Denied usage", Role: "feature", Executor: "docker"})
+ deniedRun, err := st.StartRun(deniedTask.ID, "feature", "docker")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := st.FinishRun(deniedRun.ID, "succeeded", nil); err != nil {
+ t.Fatal(err)
+ }
+
+ if _, err := st.UpsertProjectMembership(domain.ProjectMembership{ProjectID: allowedProject.ID, UserID: member.User.ID, Role: "viewer"}); err != nil {
+ t.Fatal(err)
+ }
+ memberAuth, err := st.UpsertUser(domain.User{Email: "usage-viewer@example.com", DisplayName: "Usage Viewer"}, "org_default", "viewer")
+ if err != nil {
+ t.Fatal(err)
+ }
+ server := NewServer(config.Config{AuthMode: "local"}, authOverrideStore{Store: st, auth: memberAuth}, slog.New(slog.NewTextHandler(io.Discard, nil)))
+ cookie := localSessionCookie(t, server.Handler())
+
+ var summary domain.UsageSummary
+ usageAPIDo(t, server.Handler(), http.MethodGet, "/api/v1/usage/summary", cookie, http.StatusOK, &summary)
+ if len(summary.Rows) != 1 {
+ t.Fatalf("summary rows = %#v", summary.Rows)
+ }
+ if summary.Rows[0].ProjectID != allowedProject.ID {
+ t.Fatalf("summary exposed wrong project: %#v", summary.Rows)
+ }
+ if !summary.Rows[0].TokensUnknown || !summary.TokensUnknown || summary.Rows[0].TokenUsage != nil {
+ t.Fatalf("expected explicit unknown token usage: %#v", summary)
+ }
+ if summary.Budget == nil || summary.Budget.Enabled || summary.Budget.State != "disabled" {
+ t.Fatalf("expected disabled budget status: %#v", summary.Budget)
+ }
+
+ var runsResp struct {
+ Runs []domain.UsageRun `json:"runs"`
+ TokensUnknown bool `json:"tokens_unknown"`
+ }
+ usageAPIDo(t, server.Handler(), http.MethodGet, "/api/v1/usage/runs", cookie, http.StatusOK, &runsResp)
+ if len(runsResp.Runs) != 1 || runsResp.Runs[0].RunID != allowedRun.ID {
+ t.Fatalf("usage runs exposed unexpected runs: allowed=%s denied=%s response=%#v", allowedRun.ID, deniedRun.ID, runsResp.Runs)
+ }
+ if !runsResp.TokensUnknown || !runsResp.Runs[0].TokensUnknown || runsResp.Runs[0].TokenUsage != nil {
+ t.Fatalf("usage runs missing unknown token marker: %#v", runsResp)
+ }
+
+ usageAPIDo(t, server.Handler(), http.MethodGet, "/api/v1/usage/summary?project_id="+deniedProject.ID, cookie, http.StatusForbidden, nil)
+}
+
+func usageAPIDo(t *testing.T, handler http.Handler, method string, path string, cookie *http.Cookie, wantStatus int, out any) {
+ t.Helper()
+ req := httptest.NewRequest(method, path, nil)
+ if cookie != nil {
+ req.AddCookie(cookie)
+ }
+ resp := httptest.NewRecorder()
+ handler.ServeHTTP(resp, req)
+ if resp.Code != wantStatus {
+ t.Fatalf("%s %s status = %d, want %d, body = %s", method, path, resp.Code, wantStatus, resp.Body.String())
+ }
+ if out != nil {
+ if err := json.Unmarshal(resp.Body.Bytes(), out); err != nil {
+ t.Fatalf("decode response: %v; body = %s", err, resp.Body.String())
+ }
+ }
+}
diff --git a/internal/config/config.go b/internal/config/config.go
index 3e10d09..d110527 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -77,6 +77,17 @@ 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
+ UsageBudgetEnabled bool
+ UsageBudgetSecondsPerDay float64
+ UsageBudgetWarnRatio float64
TelemetryPushURL string
TelemetryPushInterval time.Duration
AuditShipEnabled bool
@@ -169,6 +180,17 @@ 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),
+ UsageBudgetEnabled: envBool("MULTICODEX_USAGE_BUDGET_ENABLED", false),
+ UsageBudgetSecondsPerDay: envFloat("MULTICODEX_USAGE_BUDGET_SECONDS_PER_DAY", 0),
+ UsageBudgetWarnRatio: envRatio("MULTICODEX_USAGE_BUDGET_WARN_RATIO", 0.8),
TelemetryPushURL: env("MULTICODEX_TELEMETRY_PUSH_URL", ""),
TelemetryPushInterval: envDuration("MULTICODEX_TELEMETRY_PUSH_INTERVAL", time.Minute),
AuditShipEnabled: envBool("MULTICODEX_AUDIT_SHIP_ENABLED", false),
@@ -236,6 +258,26 @@ func envInt(key string, fallback int) int {
return parsed
}
+func envFloat(key string, fallback float64) float64 {
+ value := os.Getenv(key)
+ if value == "" {
+ return fallback
+ }
+ parsed, err := strconv.ParseFloat(value, 64)
+ if err != nil {
+ return fallback
+ }
+ return parsed
+}
+
+func envRatio(key string, fallback float64) float64 {
+ value := envFloat(key, fallback)
+ if value <= 0 || value > 1 {
+ return fallback
+ }
+ return value
+}
+
func envList(key string) []string {
value := os.Getenv(key)
if value == "" {
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
index abd62f0..31f79f0 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)
}
@@ -181,6 +198,32 @@ func TestFromEnvParsesOIDCClaimMappings(t *testing.T) {
}
}
+func TestFromEnvParsesUsageBudgetConfig(t *testing.T) {
+ t.Setenv("MULTICODEX_USAGE_BUDGET_ENABLED", "true")
+ t.Setenv("MULTICODEX_USAGE_BUDGET_SECONDS_PER_DAY", "7200")
+ t.Setenv("MULTICODEX_USAGE_BUDGET_WARN_RATIO", "0.6")
+
+ cfg := FromEnv()
+ if !cfg.UsageBudgetEnabled {
+ t.Fatalf("usage budget should be enabled")
+ }
+ if cfg.UsageBudgetSecondsPerDay != 7200 {
+ t.Fatalf("usage budget seconds = %v", cfg.UsageBudgetSecondsPerDay)
+ }
+ if cfg.UsageBudgetWarnRatio != 0.6 {
+ t.Fatalf("usage budget warn ratio = %v", cfg.UsageBudgetWarnRatio)
+ }
+}
+
+func TestFromEnvFallsBackForInvalidUsageBudgetWarnRatio(t *testing.T) {
+ t.Setenv("MULTICODEX_USAGE_BUDGET_WARN_RATIO", "1.5")
+
+ cfg := FromEnv()
+ if cfg.UsageBudgetWarnRatio != 0.8 {
+ t.Fatalf("usage budget warn ratio = %v, want 0.8", cfg.UsageBudgetWarnRatio)
+ }
+}
+
func TestFromEnvParsesWorkerSecretEnvAllowlist(t *testing.T) {
t.Setenv("MULTICODEX_WORKER_SECRET_ENV_ALLOWLIST", "OPENAI_API_KEY,CODEX_AUTH_TOKEN; GITHUB_TOKEN\nOPENAI_API_KEY")
t.Setenv("MULTICODEX_WORKER_SECRET_PROVIDER", "vault")
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/domain/types.go b/internal/domain/types.go
index 9d838d5..cea8979 100644
--- a/internal/domain/types.go
+++ b/internal/domain/types.go
@@ -194,6 +194,83 @@ type RunEvent struct {
CreatedAt time.Time `json:"created_at"`
}
+type TokenUsage struct {
+ PromptTokens int64 `json:"prompt_tokens,omitempty"`
+ CompletionTokens int64 `json:"completion_tokens,omitempty"`
+ TotalTokens int64 `json:"total_tokens"`
+}
+
+type UsageFilter struct {
+ ProjectIDs []string
+ From *time.Time
+ To *time.Time
+ Limit int
+}
+
+type UsageRun struct {
+ OrganizationID string `json:"organization_id"`
+ OrganizationName string `json:"organization_name"`
+ ProjectID string `json:"project_id"`
+ ProjectName string `json:"project_name"`
+ TaskID string `json:"task_id"`
+ RunID string `json:"run_id"`
+ Role string `json:"role"`
+ Status string `json:"status"`
+ Day string `json:"day"`
+ DurationSeconds *float64 `json:"duration_seconds"`
+ TokenUsage *TokenUsage `json:"token_usage"`
+ TokensUnknown bool `json:"tokens_unknown"`
+ StartedAt *time.Time `json:"started_at,omitempty"`
+ FinishedAt *time.Time `json:"finished_at,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+}
+
+type UsageSummary struct {
+ Rows []UsageSummaryRow `json:"rows"`
+ Total UsageSummaryTotal `json:"total"`
+ TokensUnknown bool `json:"tokens_unknown"`
+ Budget *UsageBudgetStatus `json:"budget,omitempty"`
+}
+
+const DefaultUsageBudgetWarnRatio = 0.8
+
+type UsageBudgetSettings struct {
+ Enabled bool
+ SecondsPerDay float64
+ WarnRatio float64
+}
+
+type UsageBudgetStatus struct {
+ Enabled bool `json:"enabled"`
+ Day string `json:"day"`
+ UsageSeconds float64 `json:"usage_seconds"`
+ WarnThresholdSeconds float64 `json:"warn_threshold_seconds"`
+ HardThresholdSeconds float64 `json:"hard_threshold_seconds"`
+ WarnRatio float64 `json:"warn_ratio"`
+ State string `json:"state"`
+}
+
+type UsageSummaryRow struct {
+ OrganizationID string `json:"organization_id"`
+ OrganizationName string `json:"organization_name"`
+ ProjectID string `json:"project_id"`
+ ProjectName string `json:"project_name"`
+ Role string `json:"role"`
+ Day string `json:"day"`
+ RunCount int64 `json:"run_count"`
+ CompletedRunCount int64 `json:"completed_run_count"`
+ TotalDurationSeconds float64 `json:"total_duration_seconds"`
+ TokenUsage *TokenUsage `json:"token_usage"`
+ TokensUnknown bool `json:"tokens_unknown"`
+}
+
+type UsageSummaryTotal struct {
+ RunCount int64 `json:"run_count"`
+ CompletedRunCount int64 `json:"completed_run_count"`
+ TotalDurationSeconds float64 `json:"total_duration_seconds"`
+ TokenUsage *TokenUsage `json:"token_usage"`
+}
+
type Artifact struct {
ID string `json:"id"`
RunID string `json:"run_id"`
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/observability/metrics_test.go b/internal/observability/metrics_test.go
index cb68943..3a59c28 100644
--- a/internal/observability/metrics_test.go
+++ b/internal/observability/metrics_test.go
@@ -119,3 +119,24 @@ func TestOperationsPrometheusTextIncludesQueueAndAuditSignals(t *testing.T) {
}
}
}
+
+func TestUsageBudgetPrometheusTextIncludesUsageAndThresholds(t *testing.T) {
+ text := UsageBudgetPrometheusText("multi-codex-test", domain.UsageBudgetStatus{
+ Enabled: true,
+ UsageSeconds: 80,
+ WarnThresholdSeconds: 75,
+ HardThresholdSeconds: 100,
+ State: "warn",
+ })
+
+ for _, needle := range []string{
+ `multi_codex_usage_budget_enabled{service="multi-codex-test"} 1`,
+ `multi_codex_usage_current_day_worker_duration_seconds{service="multi-codex-test"} 80.000000`,
+ `multi_codex_usage_budget_seconds_per_day{service="multi-codex-test",level="warn"} 75.000000`,
+ `multi_codex_usage_budget_seconds_per_day{service="multi-codex-test",level="hard"} 100.000000`,
+ } {
+ if !strings.Contains(text, needle) {
+ t.Fatalf("usage budget metrics missing %q:\n%s", needle, text)
+ }
+ }
+}
diff --git a/internal/observability/operations.go b/internal/observability/operations.go
index a00a6d4..00216cf 100644
--- a/internal/observability/operations.go
+++ b/internal/observability/operations.go
@@ -80,6 +80,25 @@ func OperationsPrometheusText(service string, runs []domain.Run, auditLogs []dom
return b.String()
}
+func UsageBudgetPrometheusText(service string, status domain.UsageBudgetStatus) string {
+ var enabled int
+ if status.Enabled {
+ enabled = 1
+ }
+ var b strings.Builder
+ fmt.Fprintf(&b, "# HELP multi_codex_usage_budget_enabled Daily worker-duration budget configuration enabled as 1 or disabled as 0.\n")
+ fmt.Fprintf(&b, "# TYPE multi_codex_usage_budget_enabled gauge\n")
+ fmt.Fprintf(&b, "multi_codex_usage_budget_enabled{service=%q} %d\n", service, enabled)
+ fmt.Fprintf(&b, "# HELP multi_codex_usage_current_day_worker_duration_seconds Current UTC-day completed worker duration usage in seconds.\n")
+ fmt.Fprintf(&b, "# TYPE multi_codex_usage_current_day_worker_duration_seconds gauge\n")
+ fmt.Fprintf(&b, "multi_codex_usage_current_day_worker_duration_seconds{service=%q} %.6f\n", service, status.UsageSeconds)
+ fmt.Fprintf(&b, "# HELP multi_codex_usage_budget_seconds_per_day Daily worker-duration budget thresholds in seconds.\n")
+ fmt.Fprintf(&b, "# TYPE multi_codex_usage_budget_seconds_per_day gauge\n")
+ fmt.Fprintf(&b, "multi_codex_usage_budget_seconds_per_day{service=%q,level=%q} %.6f\n", service, "warn", status.WarnThresholdSeconds)
+ fmt.Fprintf(&b, "multi_codex_usage_budget_seconds_per_day{service=%q,level=%q} %.6f\n", service, "hard", status.HardThresholdSeconds)
+ return b.String()
+}
+
func auditActionMetrics(auditLogs []domain.AuditLog) []AuditActionMetric {
interesting := map[string]bool{}
for _, action := range operationalAuditActions {
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..1ed9a86 100644
--- a/internal/store/store.go
+++ b/internal/store/store.go
@@ -55,10 +55,13 @@ 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
ListRuns(taskID string) []domain.Run
+ ListUsageRuns(filter domain.UsageFilter) ([]domain.UsageRun, error)
+ GetUsageSummary(filter domain.UsageFilter) (domain.UsageSummary, error)
FinishRun(runID string, status string, result map[string]any) (domain.Run, error)
ListEvents(runID string) []domain.RunEvent
AddEvent(runID string, level string, eventType string, message string, payload map[string]any) (domain.RunEvent, error)
diff --git a/internal/store/usage.go b/internal/store/usage.go
new file mode 100644
index 0000000..8160d66
--- /dev/null
+++ b/internal/store/usage.go
@@ -0,0 +1,407 @@
+package store
+
+import (
+ "encoding/json"
+ "fmt"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/Chiiz0/multi-codex/internal/domain"
+)
+
+func (s *MemoryStore) ListUsageRuns(filter domain.UsageFilter) ([]domain.UsageRun, error) {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+
+ projectIDs := stringSet(filter.ProjectIDs)
+ if len(projectIDs) == 0 {
+ return []domain.UsageRun{}, nil
+ }
+
+ runs := []domain.UsageRun{}
+ for _, run := range s.runs {
+ task, ok := s.tasks[run.TaskID]
+ if !ok {
+ continue
+ }
+ if _, ok := projectIDs[task.ProjectID]; !ok {
+ continue
+ }
+ project, ok := s.projects[task.ProjectID]
+ if !ok {
+ continue
+ }
+ if !usageRunInRange(run, filter) {
+ continue
+ }
+ org := s.organizations[project.OrgID]
+ runs = append(runs, usageRunFromRun(org, project, run))
+ }
+ sortUsageRuns(runs)
+ return limitUsageRuns(runs, filter.Limit), nil
+}
+
+func (s *MemoryStore) GetUsageSummary(filter domain.UsageFilter) (domain.UsageSummary, error) {
+ runs, err := s.ListUsageRuns(filter)
+ if err != nil {
+ return domain.UsageSummary{}, err
+ }
+ return summarizeUsageRuns(runs), nil
+}
+
+func (s *PostgresStore) ListUsageRuns(filter domain.UsageFilter) ([]domain.UsageRun, error) {
+ if len(filter.ProjectIDs) == 0 {
+ return []domain.UsageRun{}, nil
+ }
+ ctx, cancel := storeContext()
+ defer cancel()
+
+ args := []any{}
+ where := []string{}
+ projectPlaceholders := make([]string, 0, len(filter.ProjectIDs))
+ for _, projectID := range filter.ProjectIDs {
+ args = append(args, projectID)
+ projectPlaceholders = append(projectPlaceholders, fmt.Sprintf("$%d", len(args)))
+ }
+ where = append(where, "p.id::text IN ("+strings.Join(projectPlaceholders, ", ")+")")
+ if filter.From != nil {
+ args = append(args, *filter.From)
+ where = append(where, fmt.Sprintf("COALESCE(r.started_at, r.created_at) >= $%d", len(args)))
+ }
+ if filter.To != nil {
+ args = append(args, *filter.To)
+ where = append(where, fmt.Sprintf("COALESCE(r.started_at, r.created_at) < $%d", len(args)))
+ }
+
+ limitClause := ""
+ if filter.Limit > 0 {
+ args = append(args, filter.Limit)
+ limitClause = fmt.Sprintf(" LIMIT $%d", len(args))
+ }
+
+ rows, err := s.db.QueryContext(ctx, `
+SELECT o.id::text,
+ o.name,
+ p.id::text,
+ p.name,
+ t.id::text,
+ r.id::text,
+ r.role::text,
+ r.status::text,
+ r.started_at,
+ r.finished_at,
+ r.created_at,
+ r.result
+FROM runs r
+JOIN tasks t ON t.id = r.task_id
+JOIN projects p ON p.id = t.project_id
+JOIN organizations o ON o.id = p.org_id
+WHERE `+strings.Join(where, " AND ")+`
+ORDER BY COALESCE(r.started_at, r.created_at) DESC, r.created_at DESC`+limitClause, args...)
+ if err != nil {
+ s.log.Error("list usage runs failed", "error", err)
+ return nil, err
+ }
+ defer rows.Close()
+
+ runs := []domain.UsageRun{}
+ for rows.Next() {
+ usageRun, err := scanUsageRun(rows)
+ if err != nil {
+ s.log.Error("scan usage run failed", "error", err)
+ return runs, err
+ }
+ runs = append(runs, usageRun)
+ }
+ return runs, rows.Err()
+}
+
+func (s *PostgresStore) GetUsageSummary(filter domain.UsageFilter) (domain.UsageSummary, error) {
+ summaryFilter := filter
+ summaryFilter.Limit = 0
+ runs, err := s.ListUsageRuns(summaryFilter)
+ if err != nil {
+ return domain.UsageSummary{}, err
+ }
+ return summarizeUsageRuns(runs), nil
+}
+
+func scanUsageRun(row rowScanner) (domain.UsageRun, error) {
+ var usageRun domain.UsageRun
+ var resultBytes []byte
+ err := row.Scan(
+ &usageRun.OrganizationID,
+ &usageRun.OrganizationName,
+ &usageRun.ProjectID,
+ &usageRun.ProjectName,
+ &usageRun.TaskID,
+ &usageRun.RunID,
+ &usageRun.Role,
+ &usageRun.Status,
+ &usageRun.StartedAt,
+ &usageRun.FinishedAt,
+ &usageRun.CreatedAt,
+ &resultBytes,
+ )
+ if err != nil {
+ return domain.UsageRun{}, err
+ }
+ result := map[string]any{}
+ if len(resultBytes) > 0 {
+ _ = json.Unmarshal(resultBytes, &result)
+ }
+ usageRun.Day = usageDay(usageRun.StartedAt, usageRun.CreatedAt)
+ usageRun.DurationSeconds = durationSeconds(usageRun.StartedAt, usageRun.FinishedAt, usageRun.CreatedAt)
+ tokenUsage := tokenUsageFromResult(result)
+ usageRun.TokenUsage = tokenUsage
+ usageRun.TokensUnknown = tokenUsage == nil
+ return usageRun, nil
+}
+
+func usageRunFromRun(org domain.Organization, project domain.Project, run domain.Run) domain.UsageRun {
+ tokenUsage := tokenUsageFromResult(run.Result)
+ return domain.UsageRun{
+ OrganizationID: project.OrgID,
+ OrganizationName: org.Name,
+ ProjectID: project.ID,
+ ProjectName: project.Name,
+ TaskID: run.TaskID,
+ RunID: run.ID,
+ Role: run.Role,
+ Status: run.Status,
+ Day: usageDay(run.StartedAt, run.CreatedAt),
+ DurationSeconds: durationSeconds(run.StartedAt, run.FinishedAt, run.CreatedAt),
+ TokenUsage: tokenUsage,
+ TokensUnknown: tokenUsage == nil,
+ StartedAt: run.StartedAt,
+ FinishedAt: run.FinishedAt,
+ CreatedAt: run.CreatedAt,
+ }
+}
+
+func summarizeUsageRuns(runs []domain.UsageRun) domain.UsageSummary {
+ type key struct {
+ orgID string
+ projectID string
+ role string
+ day string
+ }
+ rowsByKey := map[key]*domain.UsageSummaryRow{}
+ for _, run := range runs {
+ k := key{orgID: run.OrganizationID, projectID: run.ProjectID, role: run.Role, day: run.Day}
+ row := rowsByKey[k]
+ if row == nil {
+ row = &domain.UsageSummaryRow{
+ OrganizationID: run.OrganizationID,
+ OrganizationName: run.OrganizationName,
+ ProjectID: run.ProjectID,
+ ProjectName: run.ProjectName,
+ Role: run.Role,
+ Day: run.Day,
+ }
+ rowsByKey[k] = row
+ }
+ row.RunCount++
+ if run.DurationSeconds != nil {
+ row.CompletedRunCount++
+ row.TotalDurationSeconds += *run.DurationSeconds
+ }
+ if run.TokenUsage != nil {
+ row.TokenUsage = addTokenUsage(row.TokenUsage, run.TokenUsage)
+ }
+ if run.TokensUnknown {
+ row.TokensUnknown = true
+ }
+ }
+
+ rows := make([]domain.UsageSummaryRow, 0, len(rowsByKey))
+ total := domain.UsageSummaryTotal{}
+ tokensUnknown := false
+ for _, row := range rowsByKey {
+ rows = append(rows, *row)
+ total.RunCount += row.RunCount
+ total.CompletedRunCount += row.CompletedRunCount
+ total.TotalDurationSeconds += row.TotalDurationSeconds
+ if row.TokenUsage != nil {
+ total.TokenUsage = addTokenUsage(total.TokenUsage, row.TokenUsage)
+ }
+ if row.TokensUnknown {
+ tokensUnknown = true
+ }
+ }
+ sort.Slice(rows, func(i, j int) bool {
+ if rows[i].Day == rows[j].Day {
+ if rows[i].OrganizationName == rows[j].OrganizationName {
+ if rows[i].ProjectName == rows[j].ProjectName {
+ return rows[i].Role < rows[j].Role
+ }
+ return rows[i].ProjectName < rows[j].ProjectName
+ }
+ return rows[i].OrganizationName < rows[j].OrganizationName
+ }
+ return rows[i].Day > rows[j].Day
+ })
+ return domain.UsageSummary{Rows: rows, Total: total, TokensUnknown: tokensUnknown}
+}
+
+func usageRunInRange(run domain.Run, filter domain.UsageFilter) bool {
+ at := usageAt(run.StartedAt, run.CreatedAt)
+ if filter.From != nil && at.Before(*filter.From) {
+ return false
+ }
+ if filter.To != nil && !at.Before(*filter.To) {
+ return false
+ }
+ return true
+}
+
+func usageAt(startedAt *time.Time, createdAt time.Time) time.Time {
+ if startedAt != nil {
+ return startedAt.UTC()
+ }
+ return createdAt.UTC()
+}
+
+func usageDay(startedAt *time.Time, createdAt time.Time) string {
+ return usageAt(startedAt, createdAt).Format("2006-01-02")
+}
+
+func durationSeconds(startedAt *time.Time, finishedAt *time.Time, createdAt time.Time) *float64 {
+ if finishedAt == nil {
+ return nil
+ }
+ start := createdAt
+ if startedAt != nil {
+ start = *startedAt
+ }
+ duration := finishedAt.Sub(start)
+ if duration < 0 {
+ return nil
+ }
+ seconds := duration.Seconds()
+ return &seconds
+}
+
+func tokenUsageFromResult(result map[string]any) *domain.TokenUsage {
+ if result == nil {
+ return nil
+ }
+ for _, key := range []string{"token_usage", "usage", "tokens"} {
+ if nested, ok := mapValue(result[key]); ok {
+ if usage := tokenUsageFromMap(nested); usage != nil {
+ return usage
+ }
+ }
+ }
+ return tokenUsageFromMap(result)
+}
+
+func tokenUsageFromMap(values map[string]any) *domain.TokenUsage {
+ prompt, hasPrompt := int64Value(values, "prompt_tokens", "input_tokens")
+ completion, hasCompletion := int64Value(values, "completion_tokens", "output_tokens")
+ total, hasTotal := int64Value(values, "total_tokens")
+ if !hasTotal {
+ if value, ok := int64Value(values, "tokens"); ok {
+ total = value
+ hasTotal = true
+ }
+ }
+ if !hasPrompt && !hasCompletion && !hasTotal {
+ return nil
+ }
+ if !hasTotal {
+ total = prompt + completion
+ }
+ return &domain.TokenUsage{PromptTokens: prompt, CompletionTokens: completion, TotalTokens: total}
+}
+
+func mapValue(value any) (map[string]any, bool) {
+ switch typed := value.(type) {
+ case map[string]any:
+ return typed, true
+ case map[string]int64:
+ out := map[string]any{}
+ for key, value := range typed {
+ out[key] = value
+ }
+ return out, true
+ case map[string]int:
+ out := map[string]any{}
+ for key, value := range typed {
+ out[key] = value
+ }
+ return out, true
+ default:
+ return nil, false
+ }
+}
+
+func int64Value(values map[string]any, keys ...string) (int64, bool) {
+ for _, key := range keys {
+ value, ok := values[key]
+ if !ok {
+ continue
+ }
+ switch typed := value.(type) {
+ case int:
+ return int64(typed), true
+ case int64:
+ return typed, true
+ case int32:
+ return int64(typed), true
+ case float64:
+ return int64(typed), true
+ case float32:
+ return int64(typed), true
+ case json.Number:
+ if parsed, err := typed.Int64(); err == nil {
+ return parsed, true
+ }
+ }
+ }
+ return 0, false
+}
+
+func addTokenUsage(existing *domain.TokenUsage, next *domain.TokenUsage) *domain.TokenUsage {
+ if next == nil {
+ return existing
+ }
+ if existing == nil {
+ copy := *next
+ return ©
+ }
+ existing.PromptTokens += next.PromptTokens
+ existing.CompletionTokens += next.CompletionTokens
+ existing.TotalTokens += next.TotalTokens
+ return existing
+}
+
+func sortUsageRuns(runs []domain.UsageRun) {
+ sort.Slice(runs, func(i, j int) bool {
+ left := usageAt(runs[i].StartedAt, runs[i].CreatedAt)
+ right := usageAt(runs[j].StartedAt, runs[j].CreatedAt)
+ if left.Equal(right) {
+ return runs[i].RunID < runs[j].RunID
+ }
+ return left.After(right)
+ })
+}
+
+func limitUsageRuns(runs []domain.UsageRun, limit int) []domain.UsageRun {
+ if limit <= 0 || len(runs) <= limit {
+ return runs
+ }
+ return runs[:limit]
+}
+
+func stringSet(values []string) map[string]struct{} {
+ out := map[string]struct{}{}
+ for _, value := range values {
+ value = strings.TrimSpace(value)
+ if value != "" {
+ out[value] = struct{}{}
+ }
+ }
+ return out
+}
diff --git a/internal/store/usage_test.go b/internal/store/usage_test.go
new file mode 100644
index 0000000..86701b3
--- /dev/null
+++ b/internal/store/usage_test.go
@@ -0,0 +1,150 @@
+package store
+
+import (
+ "testing"
+ "time"
+
+ "github.com/Chiiz0/multi-codex/internal/domain"
+)
+
+func TestMemoryUsageSummaryAggregatesDurationAndKnownTokens(t *testing.T) {
+ st := NewMemoryStore()
+ project := st.CreateProject(domain.Project{OrgID: "org_default", Name: "Usage Project", Slug: "usage-project"})
+ repo := st.CreateRepository(domain.Repository{ProjectID: project.ID, Name: "repo", Provider: "local", RemoteURL: "file:///repo.git"})
+ task := st.CreateTask(domain.TaskEnvelope{
+ TaskID: "USAGE-1",
+ ProjectID: project.ID,
+ RepositoryID: repo.ID,
+ Title: "Usage task",
+ Role: "feature",
+ Executor: "docker",
+ })
+
+ runWithTokens, err := st.StartRun(task.ID, "feature", "docker")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := st.FinishRun(runWithTokens.ID, "succeeded", map[string]any{
+ "token_usage": map[string]any{
+ "prompt_tokens": 10,
+ "completion_tokens": 20,
+ "total_tokens": 30,
+ },
+ }); err != nil {
+ t.Fatal(err)
+ }
+ setMemoryRunTimes(t, st, runWithTokens.ID, mustTime("2026-01-02T03:04:05Z"), mustTime("2026-01-02T03:05:35Z"))
+
+ runWithoutTokens, err := st.StartRun(task.ID, "feature", "docker")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := st.FinishRun(runWithoutTokens.ID, "failed", map[string]any{"error": "boom"}); err != nil {
+ t.Fatal(err)
+ }
+ setMemoryRunTimes(t, st, runWithoutTokens.ID, mustTime("2026-01-02T04:00:00Z"), mustTime("2026-01-02T04:00:30Z"))
+
+ otherProject := st.CreateProject(domain.Project{OrgID: "org_default", Name: "Other Usage Project", Slug: "other-usage-project"})
+ otherRepo := st.CreateRepository(domain.Repository{ProjectID: otherProject.ID, Name: "repo", Provider: "local", RemoteURL: "file:///other.git"})
+ otherTask := st.CreateTask(domain.TaskEnvelope{TaskID: "USAGE-2", ProjectID: otherProject.ID, RepositoryID: otherRepo.ID, Title: "Other task", Role: "audit", Executor: "docker"})
+ otherRun, err := st.StartRun(otherTask.ID, "audit", "docker")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := st.FinishRun(otherRun.ID, "succeeded", nil); err != nil {
+ t.Fatal(err)
+ }
+ setMemoryRunTimes(t, st, otherRun.ID, mustTime("2026-01-03T00:00:00Z"), mustTime("2026-01-03T00:10:00Z"))
+
+ summary, err := st.GetUsageSummary(domain.UsageFilter{ProjectIDs: []string{project.ID}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(summary.Rows) != 1 {
+ t.Fatalf("rows = %#v", summary.Rows)
+ }
+ row := summary.Rows[0]
+ if row.OrganizationID != "org_default" || row.ProjectID != project.ID || row.Role != "feature" || row.Day != "2026-01-02" {
+ t.Fatalf("unexpected row dimensions: %#v", row)
+ }
+ if row.RunCount != 2 || row.CompletedRunCount != 2 {
+ t.Fatalf("run counts = %#v", row)
+ }
+ if row.TotalDurationSeconds != 120 {
+ t.Fatalf("duration seconds = %v", row.TotalDurationSeconds)
+ }
+ if row.TokenUsage == nil || row.TokenUsage.PromptTokens != 10 || row.TokenUsage.CompletionTokens != 20 || row.TokenUsage.TotalTokens != 30 {
+ t.Fatalf("token usage = %#v", row.TokenUsage)
+ }
+ if !row.TokensUnknown || !summary.TokensUnknown {
+ t.Fatalf("expected unknown token marker on partial token data: row=%v summary=%v", row.TokensUnknown, summary.TokensUnknown)
+ }
+ if summary.Total.RunCount != 2 || summary.Total.TotalDurationSeconds != 120 {
+ t.Fatalf("summary total = %#v", summary.Total)
+ }
+}
+
+func TestMemoryUsageRunsFiltersByRangeAndLimit(t *testing.T) {
+ st := NewMemoryStore()
+ task := st.CreateTask(domain.TaskEnvelope{
+ TaskID: "USAGE-RANGE-1",
+ ProjectID: "proj_demo",
+ RepositoryID: "repo_demo",
+ Title: "Usage range task",
+ Role: "feature",
+ Executor: "docker",
+ })
+
+ first, err := st.StartRun(task.ID, "feature", "docker")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := st.FinishRun(first.ID, "succeeded", nil); err != nil {
+ t.Fatal(err)
+ }
+ setMemoryRunTimes(t, st, first.ID, mustTime("2026-01-02T00:00:00Z"), mustTime("2026-01-02T00:01:00Z"))
+
+ second, err := st.StartRun(task.ID, "feature", "docker")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := st.FinishRun(second.ID, "succeeded", nil); err != nil {
+ t.Fatal(err)
+ }
+ setMemoryRunTimes(t, st, second.ID, mustTime("2026-01-03T00:00:00Z"), mustTime("2026-01-03T00:01:00Z"))
+
+ from := mustTime("2026-01-02T00:00:00Z")
+ to := mustTime("2026-01-04T00:00:00Z")
+ runs, err := st.ListUsageRuns(domain.UsageFilter{ProjectIDs: []string{"proj_demo"}, From: &from, To: &to, Limit: 1})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(runs) != 1 {
+ t.Fatalf("runs = %#v", runs)
+ }
+ if runs[0].RunID != second.ID || runs[0].Day != "2026-01-03" {
+ t.Fatalf("expected newest in range, got %#v", runs[0])
+ }
+}
+
+func setMemoryRunTimes(t *testing.T, st *MemoryStore, runID string, started time.Time, finished time.Time) {
+ t.Helper()
+ st.mu.Lock()
+ defer st.mu.Unlock()
+ run, ok := st.runs[runID]
+ if !ok {
+ t.Fatalf("run %s missing", runID)
+ }
+ run.StartedAt = &started
+ run.FinishedAt = &finished
+ run.CreatedAt = started
+ st.runs[runID] = run
+}
+
+func mustTime(value string) time.Time {
+ parsed, err := time.Parse(time.RFC3339, value)
+ if err != nil {
+ panic(err)
+ }
+ return parsed
+}
diff --git a/internal/usagebudget/budget.go b/internal/usagebudget/budget.go
new file mode 100644
index 0000000..2786156
--- /dev/null
+++ b/internal/usagebudget/budget.go
@@ -0,0 +1,123 @@
+package usagebudget
+
+import (
+ "time"
+
+ "github.com/Chiiz0/multi-codex/internal/config"
+ "github.com/Chiiz0/multi-codex/internal/domain"
+)
+
+const (
+ StateDisabled = "disabled"
+ StateOK = "ok"
+ StateWarn = "warn"
+ StateHard = "hard"
+ StateUnknown = "unknown"
+)
+
+func SettingsFromConfig(cfg config.Config) domain.UsageBudgetSettings {
+ return domain.UsageBudgetSettings{
+ Enabled: cfg.UsageBudgetEnabled,
+ SecondsPerDay: cfg.UsageBudgetSecondsPerDay,
+ WarnRatio: cfg.UsageBudgetWarnRatio,
+ }
+}
+
+func Evaluate(usageSeconds float64, settings domain.UsageBudgetSettings, now time.Time) domain.UsageBudgetStatus {
+ if usageSeconds < 0 {
+ usageSeconds = 0
+ }
+ day := now.UTC().Format("2006-01-02")
+ warnRatio := normalizedWarnRatio(settings.WarnRatio)
+ enabled := settings.Enabled && settings.SecondsPerDay > 0
+ if !enabled {
+ return domain.UsageBudgetStatus{
+ Enabled: false,
+ Day: day,
+ UsageSeconds: usageSeconds,
+ WarnRatio: warnRatio,
+ State: StateDisabled,
+ }
+ }
+
+ hardThreshold := settings.SecondsPerDay
+ warnThreshold := hardThreshold * warnRatio
+ state := StateOK
+ switch {
+ case usageSeconds >= hardThreshold:
+ state = StateHard
+ case usageSeconds >= warnThreshold:
+ state = StateWarn
+ }
+ return domain.UsageBudgetStatus{
+ Enabled: true,
+ Day: day,
+ UsageSeconds: usageSeconds,
+ WarnThresholdSeconds: warnThreshold,
+ HardThresholdSeconds: hardThreshold,
+ WarnRatio: warnRatio,
+ State: state,
+ }
+}
+
+func Unknown(settings domain.UsageBudgetSettings, now time.Time) domain.UsageBudgetStatus {
+ status := Evaluate(0, settings, now)
+ if status.Enabled {
+ status.State = StateUnknown
+ }
+ return status
+}
+
+func EvaluateRuns(runs []domain.Run, settings domain.UsageBudgetSettings, now time.Time) domain.UsageBudgetStatus {
+ return Evaluate(CurrentDayUsageSeconds(runs, now), settings, now)
+}
+
+func CurrentDayUsageSeconds(runs []domain.Run, now time.Time) float64 {
+ from, to := CurrentDayRange(now)
+ var total float64
+ for _, run := range runs {
+ at := runUsageAt(run)
+ if at.Before(from) || !at.Before(to) {
+ continue
+ }
+ if seconds, ok := runDurationSeconds(run); ok {
+ total += seconds
+ }
+ }
+ return total
+}
+
+func CurrentDayRange(now time.Time) (time.Time, time.Time) {
+ utc := now.UTC()
+ from := time.Date(utc.Year(), utc.Month(), utc.Day(), 0, 0, 0, 0, time.UTC)
+ return from, from.Add(24 * time.Hour)
+}
+
+func normalizedWarnRatio(value float64) float64 {
+ if value <= 0 || value > 1 {
+ return domain.DefaultUsageBudgetWarnRatio
+ }
+ return value
+}
+
+func runUsageAt(run domain.Run) time.Time {
+ if run.StartedAt != nil {
+ return run.StartedAt.UTC()
+ }
+ return run.CreatedAt.UTC()
+}
+
+func runDurationSeconds(run domain.Run) (float64, bool) {
+ if run.FinishedAt == nil {
+ return 0, false
+ }
+ start := run.CreatedAt
+ if run.StartedAt != nil {
+ start = *run.StartedAt
+ }
+ duration := run.FinishedAt.Sub(start)
+ if duration < 0 {
+ return 0, false
+ }
+ return duration.Seconds(), true
+}
diff --git a/internal/usagebudget/budget_test.go b/internal/usagebudget/budget_test.go
new file mode 100644
index 0000000..985ab4d
--- /dev/null
+++ b/internal/usagebudget/budget_test.go
@@ -0,0 +1,71 @@
+package usagebudget
+
+import (
+ "testing"
+ "time"
+
+ "github.com/Chiiz0/multi-codex/internal/domain"
+)
+
+func TestEvaluateBudgetStates(t *testing.T) {
+ now := mustBudgetTime("2026-07-25T08:00:00Z")
+ settings := domain.UsageBudgetSettings{Enabled: true, SecondsPerDay: 100, WarnRatio: 0.75}
+
+ tests := []struct {
+ name string
+ usage float64
+ want string
+ }{
+ {name: "ok", usage: 74.9, want: StateOK},
+ {name: "warn", usage: 75, want: StateWarn},
+ {name: "hard", usage: 100, want: StateHard},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ status := Evaluate(tt.usage, settings, now)
+ if status.State != tt.want {
+ t.Fatalf("state = %q, want %q: %#v", status.State, tt.want, status)
+ }
+ if !status.Enabled || status.Day != "2026-07-25" || status.WarnThresholdSeconds != 75 || status.HardThresholdSeconds != 100 {
+ t.Fatalf("unexpected status = %#v", status)
+ }
+ })
+ }
+}
+
+func TestEvaluateDisablesNonPositiveBudget(t *testing.T) {
+ status := Evaluate(99, domain.UsageBudgetSettings{Enabled: true, SecondsPerDay: 0, WarnRatio: 0.8}, mustBudgetTime("2026-07-25T08:00:00Z"))
+ if status.Enabled || status.State != StateDisabled {
+ t.Fatalf("expected disabled status, got %#v", status)
+ }
+ if status.WarnThresholdSeconds != 0 || status.HardThresholdSeconds != 0 {
+ t.Fatalf("disabled thresholds should be zero: %#v", status)
+ }
+}
+
+func TestCurrentDayUsageSecondsUsesCompletedRunsInCurrentUTCDay(t *testing.T) {
+ now := mustBudgetTime("2026-07-25T08:00:00Z")
+ todayStart := mustBudgetTime("2026-07-25T01:00:00Z")
+ todayFinish := todayStart.Add(90 * time.Second)
+ yesterdayStart := mustBudgetTime("2026-07-24T23:00:00Z")
+ yesterdayFinish := yesterdayStart.Add(120 * time.Second)
+ runningStart := mustBudgetTime("2026-07-25T02:00:00Z")
+
+ usage := CurrentDayUsageSeconds([]domain.Run{
+ {StartedAt: &todayStart, FinishedAt: &todayFinish, CreatedAt: todayStart},
+ {StartedAt: &yesterdayStart, FinishedAt: &yesterdayFinish, CreatedAt: yesterdayStart},
+ {StartedAt: &runningStart, CreatedAt: runningStart},
+ }, now)
+
+ if usage != 90 {
+ t.Fatalf("usage = %v, want 90", usage)
+ }
+}
+
+func mustBudgetTime(value string) time.Time {
+ parsed, err := time.Parse(time.RFC3339, value)
+ if err != nil {
+ panic(err)
+ }
+ return parsed
+}