diff --git a/.env.example b/.env.example index d3114254..97ca375c 100644 --- a/.env.example +++ b/.env.example @@ -43,7 +43,7 @@ APO_PUBLIC_URL= # e.g. 262144 pages ~= 1 GiB. Writes fail once the cap is hit. #APO_MAX_DB_PAGES=0 -# --- Task Run Deliverables / Artifacts (SPEC-140) --- +# --- Task Run Deliverables / Artifacts --- # # Deliverable metadata stays in the database; large JSON bodies and file # Artifacts go through an ArtifactStore. The default `local` backend writes diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d1933351..2b36c1b1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,28 +20,39 @@ jobs: node-version: 22 cache: pnpm - run: pnpm install --frozen-lockfile - - name: Build @apo/sdk + - name: Build @apo-ai/sdk # The SDK's exports map points external consumers at dist/. Internal dev # routes back to src/ via the `development` condition, but the build must # still succeed for any external/publish path. This catches build breakage # on every PR — previously no CI step built the SDK, which is how the # broken `build: tsc` script survived unnoticed. - run: pnpm --filter @apo/sdk build - - name: Verify SDK dist entry points - # Belt-and-suspenders: assert each declared exports target actually - # exists on disk after the build. - run: | - for f in index.js index.d.ts otel/index.js otel/index.d.ts \ - agent-task/public.js agent-task/public.d.ts \ - agent-task/integrations/ai-sdk.js agent-task/integrations/ai-sdk.d.ts \ - agent-task/integrations/openai.js agent-task/integrations/openai.d.ts \ - agent-task/integrations/anthropic.js agent-task/integrations/anthropic.d.ts; do - test -f "packages/sdk/dist/$f" || { echo "MISSING dist/$f"; exit 1; } - done - echo "All SDK dist entry points present." + run: pnpm --filter @apo-ai/sdk build + - name: Clean consumer package gate (SDK) + # The single source of truth for the publish boundary: pack the SDK + # exactly as a registry release would, install it into a clean + # directory, import/compile every entry point, and run publint. + run: pnpm --filter @apo-ai/sdk package:check + - name: Build @apo-ai/cli + run: pnpm --filter @apo-ai/cli build + - name: Test @apo-ai/cli + # No job ran the CLI's unit suite, so guards on resolution and entry + # behaviour could only fail on a user's machine. Same selection the + # publish workflow uses (excludes the long connect scene test). + run: pnpm --filter @apo-ai/cli test:unit + - name: Clean consumer package gate (CLI) + run: pnpm --filter @apo-ai/cli package:check - run: pnpm lint - run: pnpm typecheck + - name: Test dashboard + run: pnpm --filter dashboard test + - name: Build docs + publication gate + # Builds the Astro site, publishes versioned schemas, and verifies the + # publication invariants (no retired origin, every start.md link + # resolves, Copy Prompt uses the live origin, schema $ids map). + run: pnpm --filter docs build - run: pnpm test:public-ingress + - name: Public docs deployment contract + run: pnpm test:public-docs backend: name: Typecheck + Test (Backend) diff --git a/.github/workflows/publish-cli.yml b/.github/workflows/publish-cli.yml new file mode 100644 index 00000000..442172a8 --- /dev/null +++ b/.github/workflows/publish-cli.yml @@ -0,0 +1,99 @@ +name: Publish CLI + +# Releases the @apo-ai/cli npm package. Parallel to publish-sdk.yml but +# triggered by `cli-v*` tags and using the `npm-cli-release` environment. +# Authentication is OIDC trusted publishing only — no stored npm token. +# Requires a separate trusted-publisher registration on the npm side for +# @apo-ai/cli (per-package, not per-org). + +on: + push: + tags: + - "cli-v*" + +permissions: + contents: read + id-token: write + +jobs: + publish: + name: Publish @apo-ai/cli + runs-on: ubuntu-latest + environment: npm-cli-release + defaults: + run: + working-directory: packages/cli + steps: + - uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 11.3.0 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + registry-url: https://registry.npmjs.org/ + + - name: Install npm (trusted-publishing-aware) + run: npm install -g npm@latest + + - name: Install dependencies + working-directory: . + run: pnpm install --frozen-lockfile + + - name: Verify tag matches package version + run: | + set -euo pipefail + PKG_VERSION=$(node -p "require('./package.json').version") + TAG_VERSION="${GITHUB_REF#refs/tags/cli-v}" + echo "package version: $PKG_VERSION" + echo "tag version: $TAG_VERSION" + if [ "$PKG_VERSION" != "$TAG_VERSION" ]; then + echo "::error::Tag cli-v$TAG_VERSION does not match packages/cli/package.json version $PKG_VERSION" + exit 1 + fi + + - name: CLI tests + run: pnpm --filter @apo-ai/cli test:unit + + - name: CLI typecheck + run: pnpm --filter @apo-ai/cli typecheck + + - name: Clean consumer package gate + run: pnpm --filter @apo-ai/cli package:check + + - name: Build release tarball + working-directory: . + run: | + set -euo pipefail + rm -rf release + mkdir -p release + pnpm --filter @apo-ai/cli pack --pack-destination ./release + TARBALL_PATH="$(realpath release/*.tgz)" + echo "TARBALL_PATH=$TARBALL_PATH" >> "$GITHUB_ENV" + echo "Packed $TARBALL_PATH" + + - name: Publish to npm (OIDC, no token) + env: + TARBALL: ${{ env.TARBALL_PATH }} + run: | + set -euo pipefail + npm publish --access public --provenance "$TARBALL" + + - name: Report publication + if: success() + working-directory: . + run: | + PKG_VERSION=$(node -p "require('./packages/cli/package.json').version") + { + echo "## @apo-ai/cli published" + echo "" + echo "- **Package**: [\`@apo-ai/cli@${PKG_VERSION}\`](https://www.npmjs.com/package/@apo-ai/cli/v/${PKG_VERSION})" + echo "- **Tag**: \`$GITHUB_REF_NAME\`" + echo "- **Tarball**: \`${TARBALL_PATH}\`" + echo "- **Auth**: GitHub OIDC trusted publishing (no npm token secret)" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/publish-sdk.yml b/.github/workflows/publish-sdk.yml new file mode 100644 index 00000000..68cf95da --- /dev/null +++ b/.github/workflows/publish-sdk.yml @@ -0,0 +1,115 @@ +name: Publish SDK + +# Releases the @apo-ai/sdk npm package. Decoupled from the Docker release +# workflow (.github/workflows/release.yml, triggered by `v*` tags): SDK +# versions follow their own cadence under `sdk-v*` tags. +# +# Authentication is OIDC trusted publishing only — no long-lived npm +# credential is stored as a GitHub secret. The trusted publisher +# (repo `samikuikka/apo`, workflow `publish-sdk.yml`, environment +# `npm-sdk-release`) must be configured once on the npm side by a +# maintainer with publishing rights to the `apo` scope. + +on: + push: + tags: + - "sdk-v*" + +permissions: + contents: read + id-token: write + +jobs: + publish: + name: Publish @apo-ai/sdk + runs-on: ubuntu-latest + environment: npm-sdk-release + defaults: + run: + working-directory: packages/sdk + steps: + - uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 11.3.0 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + registry-url: https://registry.npmjs.org/ + + - name: Install npm (trusted-publishing-aware) + # npm >=11.5.1 understands the OIDC exchange used by trusted publishing. + run: npm install -g npm@latest + + - name: Install dependencies + working-directory: . + run: pnpm install --frozen-lockfile + + - name: Verify tag matches package version + # The Git tag must be exactly `sdk-v`. + # Any mismatch is a hard stop — we never publish a tarball whose + # reported version disagrees with the immutable tag. + run: | + set -euo pipefail + PKG_VERSION=$(node -p "require('./package.json').version") + TAG_VERSION="${GITHUB_REF#refs/tags/sdk-v}" + echo "package version: $PKG_VERSION" + echo "tag version: $TAG_VERSION" + if [ "$PKG_VERSION" != "$TAG_VERSION" ]; then + echo "::error::Tag sdk-v$TAG_VERSION does not match packages/sdk/package.json version $PKG_VERSION" + exit 1 + fi + + - name: SDK tests + run: pnpm --filter @apo-ai/sdk test + + - name: SDK typecheck + run: pnpm --filter @apo-ai/sdk typecheck + + - name: Clean consumer package gate + run: pnpm --filter @apo-ai/sdk package:check + + - name: Build release tarball + # `pnpm pack` is the only correct artifact source — it applies the + # publishConfig rewrite (strips `development` conditions, locks the + # six dist-only exports). We pack into a fresh dir to guarantee no + # stale local tarball is reused. Do NOT capture pnpm's stdout into + # $GITHUB_ENV — it carries ANSI color codes that the file command + # rejects. Resolve the tarball path from disk instead. + working-directory: . + run: | + set -euo pipefail + rm -rf release + mkdir -p release + pnpm --filter @apo-ai/sdk pack --pack-destination ./release + TARBALL_PATH="$(realpath release/*.tgz)" + echo "TARBALL_PATH=$TARBALL_PATH" >> "$GITHUB_ENV" + echo "Packed $TARBALL_PATH" + + - name: Publish to npm (OIDC, no token) + # Trusted publishing exchanges the GitHub OIDC JWT for short-lived + # npm credentials. There is no NODE_AUTH_TOKEN and no NPM_TOKEN. + env: + TARBALL: ${{ env.TARBALL_PATH }} + run: | + set -euo pipefail + npm publish --access public --provenance "$TARBALL" + + - name: Report publication + if: success() + working-directory: . + run: | + PKG_VERSION=$(node -p "require('./packages/sdk/package.json').version") + { + echo "## @apo-ai/sdk published" + echo "" + echo "- **Package**: [\`@apo-ai/sdk@${PKG_VERSION}\`](https://www.npmjs.com/package/@apo-ai/sdk/v/${PKG_VERSION})" + echo "- **Tag**: \`$GITHUB_REF_NAME\`" + echo "- **Tarball**: \`${TARBALL_PATH}\`" + echo "- **Auth**: GitHub OIDC trusted publishing (no \`NPM_TOKEN\` secret)" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.gitignore b/.gitignore index 6ed40dae..5f9ceb1f 100644 --- a/.gitignore +++ b/.gitignore @@ -79,6 +79,7 @@ CLAUDE.md /.codex/ /.opencode/ /.ncode/ +/.zcode/ /.agent-state/ /.personal/ /wayfinder/ diff --git a/.zcode/plans/plan-sess_f59c18f1-3a5c-4fda-8a86-537bbe653bea.md b/.zcode/plans/plan-sess_f59c18f1-3a5c-4fda-8a86-537bbe653bea.md deleted file mode 100644 index a2912dba..00000000 --- a/.zcode/plans/plan-sess_f59c18f1-3a5c-4fda-8a86-537bbe653bea.md +++ /dev/null @@ -1,85 +0,0 @@ -# SPEC-136 Cost System Redesign — TDD Implementation Plan - -## Resolved decisions (before coding) - -1. **Reprice transport = HTTP endpoint + poll** (you said "choose what's best"). `reprice_calls()` stays a pure Python DB-level callable in `services/reprice.py` (directly unit-testable, mirroring `reproject.py`). A thin FastAPI route `POST /v1/admin/reprice` (admin-scoped, like the retention endpoints — re-pricing is a history rewrite) wraps it. The CLI command (`commands/reprice.ts`) kicks off the job, then polls `GET /v1/admin/reprice/{job_id}` every 2s for status, printing progress — exactly the proven `task-run.ts:226-255` pattern that already solves "long CLI op under the 15s timeout." The job state lives in a small in-memory dict on the backend (re-price is an operator action; if the process dies mid-run, re-running is idempotent per the spec). - -2. **Price units (spec has a contradiction).** Tickets 02/06/07 agree: DB stores **micro-USD per 1M tokens** (= USD-per-1M × 1e6; e.g. $2.50/1M → `2_500_000`), and `cost_micro = round(price_stored × tokens / 1_000_000)` per dimension. Ticket 02's int64 bound ("≈1e16 for the un-divided product") confirms the `/1e6`. The ticket-10 API example (`"input": 3000`) is the lone outlier — it's a typo (should be `3_000_000` for $3/1M, or `3.0` if the wire unit is USD-per-1M). I'll honor **"API call = seed entry verbatim"** (ticket 10) + **"JSON is human-readable USD-per-1M"** (ticket 07): both the JSON file and `ModelDocumentCreate` use **USD-per-1M as a number** (e.g. `3.0`); the loader and the create/replace routes convert × 1e6 to micro-USD-per-1M for the `prices.price_per_1m` column. `MatchResponse` returns `cost_breakdown` in micro-USD int (same as stored on the call). - -## TDD ordering (tests-first per layer; each phase is RED → GREEN) - -Dependency order means lower layers are built/tested first and never re-imported by upper layers. Every backend test file starts with the pyright-suppression header (`# pyright: reportUnusedImport=false, reportUnusedCallResult=false, reportAny=false`). - -### Phase 1 — Canonical usage keys (ticket 01) -- **Create** `backend/apo/models/usage_keys.py`: `UsageKey(str, Enum)` (6 values), `INPUT_FAMILY`, `OUTPUT_FAMILY` frozensets — verbatim from spec lines 84-99. -- *No tests needed for a pure enum; covered transitively.* - -### Phase 2 — Data model + schemas (ticket 02, 06) ← tests-first -- **Write** `backend/tests/test_pricing_models.py` (RED): assert the 3 tables exist post-`create_all`, `TierCondition` validates operators, `ModelDocumentCreate`/`MatchResponse`/`ModelDocument` shapes parse. -- **Rewrite** `backend/apo/models/pricing.py` entirely: drop `ModelDefinitionDB` + flat schemas; add `ModelRowDB`, `PricingTierDB`, `PriceDB` (table=True) + `TierCondition`, `ModelDocumentCreate`, `ModelDocument`, `MatchResponse` (SQLModel, not table). -- **Modify** `backend/apo/models/schemas.py` `LoggedCallBase`: `cost`/`provided_cost` `float→int` (micro-USD); **drop** `calculated_cost`; add `cost_breakdown: dict[str,int]|None`, `raw_usage: dict[str,int]|None`, `internal_model_id` (already exists, add FK), `matched_tier_id`, `matched_tier_name`, `cost_provenance: "provided"|"computed"|None`. -- **Modify** `backend/apo/models/db.py` `LoggedCallDB`: map `cost_breakdown`/`raw_usage` to JSON columns (mirror existing `meta`→`metadata` pattern at db.py:315). - -### Phase 3 — Migration (ticket 09) ← tests-first -- **Write** `backend/tests/test_cost_migration.py` (RED): fresh `create_engine("sqlite://")`, hand-build old schema (`model_definitions` + `logged_calls` with float costs + `calculated_cost`) via `exec_driver_sql`/`text()` (mirror `test_projection_identity_migration.py`), insert legacy rows, call migration, assert: costs now micro-USD int (`round(v×1e6)`), `calculated_cost` gone, new columns present, `model_definitions` dropped, new 3 tables created. -- **Modify** `backend/apo/db.py`: add `_migrate_to_v10()` (the cost migration) to `_SCHEMA_MIGRATIONS`, bump `LATEST_SCHEMA_VERSION=10`. Reuse existing helpers `_add_column_if_missing`, `_drop_column_if_exists`, `_create_index_if_not_exists`. The float→int transform uses `UPDATE ... SET cost = ROUND(cost * 1000000)` (SQLite `ROUND` returns float; cast to int via the column affinity — verify with a guarded backfill). `calculated_cost` drop via `_drop_column_if_exists`. - -### Phase 4 — Usage normalization (ticket 03) ← tests-first -- **Write** `backend/tests/fixtures/usage/*.json` (~12 fixtures in `{description, source, input, expected}` shape: OpenAI/Anthropic/Bedrock/Gemini/generic × cached/reasoning/plain). Non-overlap invariant asserted in every `expected`. -- **Write** `backend/tests/test_usage_normalization.py` (RED): parametrize over fixtures; assert `normalize_usage(input, provider) == expected` and the non-overlap invariant (cache/reasoning subtracted). -- **Create** `backend/apo/services/usage_normalization/` package: `__init__.py` (detect_provider hierarchy + dispatch), `openai.py`, `anthropic.py`, `bedrock.py`, `gemini.py`, `generic.py` — each `normalize(attributes) -> dict[str,int]`. Provider detection: `ai.response.providerMetadata` key-membership → `gen_ai.system` → model-name prefix → generic. Non-overlap applied uniformly. Based on `wayfinder/assets/03-normalizer-research.md` mappings. - -### Phase 5 — Pricing resolution + compute + validation (tickets 04, 05, 06) ← tests-first -- **Write** `backend/tests/test_time_windowing.py` (RED): era selection `[start,end)`, `start_date IS NULL` legacy match, late-arriving span, one-era-active. -- **Write** `backend/tests/test_tier_matching.py` (RED): golden Gemini-2.5-Pro large-context model; `input+cache_read` sum crosses 200k threshold; default fallback; priority order. -- **Write** `backend/tests/test_cost_compute.py` (RED): 4 golden models (flat/large-context/cache-tiered/reasoning); `breakdown[k]=round(price×tokens/1e6)`; `total=sum(breakdown)`; reconciliation fixture; unpriced-key skip; no-match→all-None. -- **Create** `backend/apo/services/pricing/validation.py`: validate `TierCondition` operators; exactly-one-default; same usage_key set across tiers; non-overlapping eras. Raises `ValueError` with detail for 422s. -- **Create** `backend/apo/services/pricing/resolution.py`: `resolve_model_era(session, model_name, project, at_time)` (fullmatch + temporal predicate, project-shadows-global), `match_tier(model, usage)` (priority-ascending, first-all-pass, else default). -- **Create** `backend/apo/services/pricing/compute.py`: `compute_cost(...)` → `ComputedCost(model_id, tier_id, tier_name, breakdown, total)`. Negative-clamp-to-0 + warn. No-match → all None. - -### Phase 6 — JSON defaults loader + bundled JSON (ticket 07) -- **Write** `backend/tests/test_pricing_loader.py` (RED): load JSON, assert 3-table rows; idempotency (reload = no writes via `updated_at` equality); malformed JSON raises hard; globals-absent-from-file get deleted; per-project rows untouched. -- **Create** `backend/apo/data/default-model-prices.json`: array of model entries (port current `DEFAULT_MODELS` set + the 4 golden shapes) in ticket-07 shape (`matchPattern`, `provider`, `pricingTiers[]` with `conditions` + `prices` in USD-per-1M, `updatedAt`, `last_reviewed`). -- **Create** `backend/apo/services/pricing/loader.py`: `load_default_prices(session)` — full reconciliation toward JSON for `__global__` only; ×1e6 conversion; `updated_at`-exact-equality idempotency; fail-hard on malformed. -- **Modify** `backend/apo/db.py` `init_db`: replace `seed_default_models` call with `load_default_prices`. - -### Phase 7 — Wire compute into ingestion (both paths) (ticket 06) ← extend existing tests -- **Extend** `backend/tests/test_trace_projector.py` (RED): add cost assertions to a priced-model span — after `project()`, `LoggedCallDB` has `cost`, `cost_breakdown`, `raw_usage`, `matched_tier_id`, `cost_provenance="computed"`. -- **Extend** `backend/tests/test_ingestion_canonical_adapter.py` (RED): same frozen field set after `POST /api/v1/ingestion`. -- **Rewrite** `backend/apo/services/trace_projector.py::_apply_cost`: `normalize_usage(span.attributes, provider)` → `raw_usage`; if SDK provided cost → freeze verbatim (`provenance="provided"`, breakdown from SDK map or null); else `compute_cost(...)` → freeze (`provenance="computed"`). Still GENERATION-only. -- **Modify** `backend/apo/services/trace_projector.py::_upsert_call`: also persist `raw_usage` from the normalizer onto the call. -- **Modify** `backend/apo/services/ingestion.py`: `process_call_create`/`process_call_update` call `normalize_usage` + `compute_cost` (same pipeline). -- **Delete** `backend/apo/services/cost_calculation.py` and `backend/tests/test_cost_calculation.py` (functions gone; rewire any stragglers). - -### Phase 8 — Models API rewrite (ticket 10) ← tests-first -- **Write** `backend/tests/test_models_api.py` (RED): nested CRUD (list/get/create/put/delete), `__global__` write→409, tier validation→422, `?effective=true` merge. -- **Write** `backend/tests/test_models_match.py` (RED): `GET /api/v1/models/match?model=&usage=&start_time=` → tier + breakdown; anchored fullmatch (`gpt-4o` ≠ `gpt-4o-mini`); era by start_time. -- **Rewrite** `backend/apo/routes/models.py`: nested-document CRUD + match endpoint; remove `/seed-defaults`; 409 on `__global__` writes; validation via `pricing/validation.py`. -- Confirm `backend/apo/routes/__init__.py` + `api.py` still register the models router (path `/api/v1/models` unchanged). - -### Phase 9 — Reprice service + endpoint + CLI (ticket 12) ← tests-first -- **Write** `backend/tests/test_reprice.py` (RED): computed calls repriced, provided skipped, pre-migration (no raw_usage) skipped+reported, no-match skipped+reported, dry-run commits nothing, idempotent, rollups refreshed. -- **Create** `backend/apo/services/reprice.py`: `reprice_calls(session, *, project, model_id, since, until, dry_run, batch_size) -> RepriceSummary` — inline streamed-batch, per-row try/except (mirror `reproject_trace`), reads raw_usage+current tiers, overwrites in place when not dry_run, then refreshes run/session rollups (`aggregate_costs`/`calculate_and_store_aggregate_metrics`). -- **Add** `POST /v1/admin/reprice` (kick-off) + `GET /v1/admin/reprice/{job_id}` (poll) to `backend/apo/routes/admin.py` (admin-scoped). In-memory job dict; background thread runs `reprice_calls`. -- **Create** `packages/cli/src/commands/reprice.ts`: parse `--project/--model/--since/--until/--dry-run`, kick off + poll (mirror `task-run.ts`), print summary, `--json` support. -- **Write** `packages/cli/tests/reprice.test.ts` (RED): mock `globalThis.fetch` (kick-off + poll responses), assert exit code + summary output. -- **Modify** `packages/cli/src/main.ts`: add `"reprice"` to the `commands` record with full `CommandEntry` (help/args/options/examples). Verify `node --experimental-strip-types packages/cli/src/main.ts reprice --help`. - -### Phase 10 — Frontend: delete client-side pricing (ticket 11) ← tests-first -- **Write** `apps/dashboard/src/components/trace-detail/__tests__/DimensionBreakdownTooltip.test.tsx` (RED): render stored `cost_breakdown` (6 dims, mixed families); hover → family groups, sorted by magnitude, zero hidden, unpriced amber, provenance footer, legacy footer; **no fetch**. -- **Delete** `apps/dashboard/src/lib/model-pricing.ts` + `apps/dashboard/src/lib/__tests__/model-pricing.test.ts`. -- **Create** `apps/dashboard/src/components/trace-detail/DimensionBreakdownTooltip.tsx` (rename+rewrite): reads stored `cost_breakdown`/`raw_usage`/`cost_provenance`; family groups, magnitude sort, zero-hide, unpriced amber (`--warning`), provenance footer, legacy footer. Monochrome shades per dimension (design.md). -- **Create** `apps/dashboard/src/components/trace-detail/DimensionMixBar.tsx`: 3px monochrome bar. -- **Modify** `apps/dashboard/src/components/trace-detail/TraceDataContext.tsx`: `LoggedCall` cost fields → `cost?: number|null` (now micro-USD), add `cost_breakdown`/`raw_usage`/`cost_provenance`, drop `calculated_cost`. Add `usdFormat` to `apps/dashboard/src/lib/format.ts` (≥1→2dp, ≥0.01→4dp, else 6dp; divides micro-USD by 1e6). -- **Modify** `apps/dashboard/src/app/project/[projectId]/traces/columns.tsx`: `UsageCell` — drop `pricingCache`/`fetchModelPricing`/inline recompute; read stored breakdown. -- **Modify** `apps/dashboard/src/components/trace-detail/TraceDetailTabs.tsx`: costs tab — `DimensionMixBar` per model, `usdFormat`. -- **Modify** `apps/dashboard/src/components/trace-detail/TraceDetailView.tsx`: cost pills → `usdFormat(call.cost)`, swap tooltip import. -- **Delete** old `CostBreakdownTooltip.tsx` + its test. - -### Phase 11 — Verify & document -- Run gates: `pnpm lint`, `pnpm typecheck`, `cd backend && uv run basedpyright`, `cd backend && uv run pytest -x -q`, `pnpm --filter @apo/cli test`. -- Update `docs/architecture.md` (cost system section), `project/status.md` (check off SPEC-136). -- Grep confirms: `model-pricing.ts` gone, `cost_calculation.py` gone, no `calculated_cost` remains. - -## Execution note -This is a large spec (~35 files). I'll execute phases 1–11 in order, committing per phase with descriptive jj messages. Tests are written RED before each layer's implementation. If context runs short mid-spec, the carry-forward summary preserves the dependency map and the unit decisions above so work resumes cleanly. \ No newline at end of file diff --git a/README.md b/README.md index b8de42f3..907d9fa6 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ data-extraction/ A real task from this repo, lightly trimmed. It layers three kinds of test: a deterministic trajectory check, a deterministic fact check, and an LLM judge. ```typescript -import { task, test, includes, satisfies, filePaths } from "@apo/sdk/agent-task"; +import { task, test, includes, satisfies, filePaths } from "@apo-ai/sdk/agent-task"; import { invoiceAgentAdapter } from "./adapter"; task("data-extraction", { diff --git a/apps/dashboard/e2e/alpha-project-setup-and-run.spec.ts b/apps/dashboard/e2e/alpha-project-setup-and-run.spec.ts index d723e915..cf2bd927 100644 --- a/apps/dashboard/e2e/alpha-project-setup-and-run.spec.ts +++ b/apps/dashboard/e2e/alpha-project-setup-and-run.spec.ts @@ -1,7 +1,7 @@ import { expect, test } from "@playwright/test"; /** - * SPEC-126: internal alpha release gate — project setup + run flow. + * internal alpha release gate — project setup + run flow. * * Validates the canonical operator surfaces render correctly for the * agent-testing product. These tests are deliberately structural — they @@ -50,7 +50,7 @@ test.describe("Alpha: project setup and run surfaces @alpha", () => { return; } - // Auth disabled — assert the SPEC-124 + SPEC-125 panels are present. + // Auth disabled — assert the alpha readiness panels are present. await expect( page.getByText("Deployment Topology", { exact: true }), ).toBeVisible(); diff --git a/apps/dashboard/e2e/alpha-schedule-lifecycle.spec.ts b/apps/dashboard/e2e/alpha-schedule-lifecycle.spec.ts index 29ecd1c4..a1e6cf52 100644 --- a/apps/dashboard/e2e/alpha-schedule-lifecycle.spec.ts +++ b/apps/dashboard/e2e/alpha-schedule-lifecycle.spec.ts @@ -1,7 +1,7 @@ import { expect, test } from "@playwright/test"; /** - * SPEC-126: internal alpha release gate — schedule lifecycle. + * internal alpha release gate — schedule lifecycle. * * Validates the schedule surface renders a clean operator view and does * not display misleading copy for schedules whose next_run_at is "now" diff --git a/apps/dashboard/e2e/alpha-trace-drilldown.spec.ts b/apps/dashboard/e2e/alpha-trace-drilldown.spec.ts index e6dfebc6..c169a557 100644 --- a/apps/dashboard/e2e/alpha-trace-drilldown.spec.ts +++ b/apps/dashboard/e2e/alpha-trace-drilldown.spec.ts @@ -1,7 +1,7 @@ import { expect, test } from "@playwright/test"; /** - * SPEC-126: internal alpha release gate — trace drilldown. + * internal alpha release gate — trace drilldown. * * Validates that the canonical trace navigation surfaces render. The * alpha product contract is: diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 6fdb0dd7..6fa5e4bb 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -18,7 +18,7 @@ "e2e:report": "playwright show-report" }, "dependencies": { - "@apo/sdk": "workspace:*", + "@apo-ai/sdk": "workspace:*", "@base-ui/react": "latest", "@codemirror/lang-javascript": "^6.2.5", "@codemirror/lang-python": "^6.2.1", diff --git a/apps/dashboard/src/app/project/[projectId]/runs/[batchRunId]/page.tsx b/apps/dashboard/src/app/project/[projectId]/runs/[batchRunId]/page.tsx index 57e3b427..b9ffd658 100644 --- a/apps/dashboard/src/app/project/[projectId]/runs/[batchRunId]/page.tsx +++ b/apps/dashboard/src/app/project/[projectId]/runs/[batchRunId]/page.tsx @@ -15,6 +15,7 @@ import { TaskRunListHeader, TaskRunRow } from "@/components/task-run-list"; import { BatchRunAutoRefresh } from "@/components/agent-task-execution/batch-run-auto-refresh"; import { OutcomeSummary, FailuresByType, conclusionStyle } from "@/components/run-outcome"; import { ExecutionAttemptPanel } from "@/components/agent-task-execution/execution-attempt-panel"; +import { BatchRunCancelButton } from "@/components/agent-task-execution/batch-run-cancel-button"; export const dynamic = "force-dynamic"; @@ -141,6 +142,9 @@ export default async function BatchRunDetailPage({ {statusConf.label}

Run

+ {isRunning && ( + + )}
{batchRun.selection_type} diff --git a/apps/dashboard/src/app/project/[projectId]/runs/compare/compare-client.tsx b/apps/dashboard/src/app/project/[projectId]/runs/compare/compare-client.tsx index c4a647f8..54a45357 100644 --- a/apps/dashboard/src/app/project/[projectId]/runs/compare/compare-client.tsx +++ b/apps/dashboard/src/app/project/[projectId]/runs/compare/compare-client.tsx @@ -7,6 +7,7 @@ import { ChevronRight, Clock, GitCompare, + Hash, } from "lucide-react"; import { @@ -16,7 +17,7 @@ import { type AgentTaskSummary, } from "@/lib/agent-task-api"; import { cn } from "@/lib/utils"; -import { formatDuration, formatRelativeTime, runDurationMs, formatCostMicro } from "@/lib/format"; +import { formatDuration, formatRelativeTime, runDurationMs, formatCostMicro, formatTokenTotal } from "@/lib/format"; import { formatBatchExecution } from "@/lib/run-configuration"; import { useUrlParamSet } from "@/hooks/use-url-state"; import { conclusionStyle } from "@/components/run-outcome"; @@ -53,7 +54,7 @@ function batchLabel(batch: AgentTaskBatchRunDetail | AgentTaskBatchRunSummary): } /** - * SPEC-148: name the configuration dimensions that differ between two + * name the configuration dimensions that differ between two * uniform batches. Returns a label like `"Changed: effort"` or `null` when * the two are equivalent, or when either side is not a single uniform pair * (mixed/partial/unknown are already labeled honestly on each header). @@ -119,7 +120,7 @@ export function CompareClient({ {batchA && batchB && (
- {/* SPEC-148: which configuration dimension changed between runs. */} + {/* which configuration dimension changed between runs. */} {configurationDelta(batchA, batchB) && ( {configurationDelta(batchA, batchB)} @@ -252,7 +253,7 @@ function BatchSlot({ errored: batch.errored_tasks, total: batch.total_tasks, }); - // SPEC-148: the header shows the adapter-reported Run Configuration summary + // the header shows the adapter-reported Run Configuration summary // (uniform/mixed/partial/unknown), replacing the old dominantModel // heuristic that guessed a single model from observed trace data. const executionLabel = formatBatchExecution(batch.configuration); @@ -328,6 +329,12 @@ function BatchSlot({ {batch.total_cost != null && batch.total_cost > 0 && ( {formatCostMicro(batch.total_cost)} )} + {batch.total_tokens != null && batch.total_tokens > 0 && ( + + + {formatTokenTotal(batch.total_tokens)} + + )}
diff --git a/apps/dashboard/src/app/project/[projectId]/runs/compare/components/CompareTaskRow.tsx b/apps/dashboard/src/app/project/[projectId]/runs/compare/components/CompareTaskRow.tsx index 243897f0..13656b73 100644 --- a/apps/dashboard/src/app/project/[projectId]/runs/compare/components/CompareTaskRow.tsx +++ b/apps/dashboard/src/app/project/[projectId]/runs/compare/components/CompareTaskRow.tsx @@ -7,20 +7,18 @@ import { ChevronRight, ExternalLink } from "lucide-react"; import { getAgentTaskRun, + readTaskDefinitionSource, readTaskFile, type AgentTaskRunDetail, type AgentTaskRunSummary, type CheckAssertionResult, type CheckResult, } from "@/lib/agent-task-api"; +import { loadCheckSource, type DefinitionRef } from "@/lib/load-check-source"; import { cn } from "@/lib/utils"; -import { formatDuration, runDurationMs, formatCostMicro } from "@/lib/format"; +import { formatDuration, runDurationMs, formatCostMicro, formatTokenTotal } from "@/lib/format"; import { extractJudgeReasoning } from "@/lib/judge-reasoning"; import { extractCheckBlock } from "@/lib/extract-check-block"; -import { - buildSourceCandidates, - shouldAcceptSource, -} from "@/lib/check-source-candidates"; import { locateAssertionsInBlock } from "@/lib/locate-assertion"; import type { LineAssertion } from "./compare-markers"; @@ -516,6 +514,18 @@ function CheckDiff({ }; }, [taskId, leftId, rightId]); + // SPEC-169: the pinned Task Definition is the authoritative source and — unlike + // the filesystem resolver — resolves wherever the task executed. Both sides are + // offered because either run's revision renders the same source; two runs of one + // task normally pin the same digest. Memoized so the per-check source effect + // isn't re-fired by a fresh array identity on every render. + const definitionRefs = useMemo(() => { + if (state.status !== "ready") return []; + return [state.left, state.right] + .map((run) => ({ runId: run?.id ?? "", filePath: run?.task_definition?.files[0]?.path ?? "" })) + .filter((ref) => ref.runId && ref.filePath); + }, [state]); + if (state.status === "loading") { return
Loading checks…
; } @@ -550,7 +560,9 @@ function CheckDiff({ const rightCost = right?.total_cost != null && right.total_cost > 0 ? right.total_cost : null; const leftTime = runDurationMs(left?.started_at ?? null, left?.completed_at ?? null); const rightTime = runDurationMs(right?.started_at ?? null, right?.completed_at ?? null); - const hasMetrics = (leftCost ?? rightCost) != null || (leftTime ?? rightTime) != null; + const leftTokens = left?.total_tokens != null && left.total_tokens > 0 ? left.total_tokens : null; + const rightTokens = right?.total_tokens != null && right.total_tokens > 0 ? right.total_tokens : null; + const hasMetrics = (leftCost ?? rightCost) != null || (leftTime ?? rightTime) != null || (leftTokens ?? rightTokens) != null; // The grid is the single container for header + metrics + checks. Columns // are wide enough that cost/time values (e.g. "$0.0122") don't ellipsize — @@ -618,6 +630,15 @@ function CheckDiff({ formatRight={rightTime != null ? formatDuration(rightTime) : "—"} /> )} + {(leftTokens != null || rightTokens != null) && ( + + )} )} @@ -646,6 +667,7 @@ function CheckDiff({ taskId={taskId} projectId={projectId} commitSha={commitSha} + definitionRefs={definitionRefs} /> ); }) @@ -675,6 +697,7 @@ function CheckRow({ taskId, projectId, commitSha, + definitionRefs, }: { id: string; left: CheckResult | undefined; @@ -684,6 +707,7 @@ function CheckRow({ taskId: string; projectId: string; commitSha: string | null; + definitionRefs: DefinitionRef[]; }) { const [open, setOpen] = useState(false); const ref = left ?? right; @@ -730,6 +754,7 @@ function CheckRow({ taskId={taskId} projectId={projectId} commitSha={commitSha} + definitionRefs={definitionRefs} leftCheck={left} rightCheck={right} /> @@ -768,6 +793,7 @@ function CheckSourceWithResults({ taskId, projectId, commitSha, + definitionRefs, leftCheck, rightCheck, }: { @@ -776,6 +802,7 @@ function CheckSourceWithResults({ taskId: string; projectId: string; commitSha: string | null; + definitionRefs: DefinitionRef[]; leftCheck: CheckResult | undefined; rightCheck: CheckResult | undefined; }) { @@ -795,50 +822,33 @@ function CheckSourceWithResults({ let cancelled = false; const controller = new AbortController(); - const candidates = buildSourceCandidates(sourceFile, taskId); - (async () => { - let lastError: unknown; - for (const candidate of candidates) { - try { - let source: { content: string; language: string }; - try { - source = await readTaskFile( - taskId, candidate, undefined, projectId, - commitSha ?? undefined, controller.signal, - ); - } catch { - source = await readTaskFile( - taskId, candidate, undefined, undefined, - undefined, controller.signal, - ); - } - const block = extractCheckBlock(source.content, { id: checkId }); - if ( - shouldAcceptSource({ - candidate, - recordedSourceFile: sourceFile, - containsKnownCheck: block !== null, - isLastCandidate: candidate === candidates[candidates.length - 1], - }) - ) { - if (cancelled) return; - setState({ - status: "ready", - code: block?.code ?? source.content, - language: source.language ?? "typescript", - }); - return; - } - } catch (error) { - if (controller.signal.aborted) return; - lastError = error; - } - } - if (!cancelled) { + try { + const source = await loadCheckSource({ + taskId, + recordedSourceFile: sourceFile, + commitSha, + definitionRefs, + containsKnownCheck: (content) => extractCheckBlock(content, { id: checkId }) !== null, + deps: { + readDefinitionSource: (runId, filePath) => + readTaskDefinitionSource(runId, filePath, controller.signal), + readTaskFile: (id, candidate, sha) => + readTaskFile(id, candidate, undefined, projectId, sha, controller.signal), + }, + }); + if (cancelled) return; + const block = extractCheckBlock(source.content, { id: checkId }); + setState({ + status: "ready", + code: block?.code ?? source.content, + language: source.language ?? "typescript", + }); + } catch (error) { + if (cancelled || controller.signal.aborted) return; setState({ status: "error", - message: lastError instanceof Error ? lastError.message : "Could not load check source.", + message: error instanceof Error ? error.message : "Could not load check source.", }); } })(); @@ -847,7 +857,7 @@ function CheckSourceWithResults({ cancelled = true; controller.abort(); }; - }, [checkId, sourceFile, taskId, projectId, commitSha]); + }, [checkId, sourceFile, taskId, projectId, commitSha, definitionRefs]); // Locate each run's assertions onto lines of the source block. Builds a // map: line number → { left?, right? } assertion results. Empty when the diff --git a/apps/dashboard/src/app/project/[projectId]/runs/page.tsx b/apps/dashboard/src/app/project/[projectId]/runs/page.tsx index 16a62d96..2be32d7d 100644 --- a/apps/dashboard/src/app/project/[projectId]/runs/page.tsx +++ b/apps/dashboard/src/app/project/[projectId]/runs/page.tsx @@ -1,6 +1,7 @@ import { listAgentTaskBatchRuns, type AgentTaskBatchRunSummary, + type ModelFacetOption, } from "@/lib/agent-task-api"; import { getProject, type ProjectTaskSource } from "@/lib/projects-api"; import { Suspense } from "react"; @@ -12,31 +13,62 @@ export const metadata = { title: "Runs" }; export default async function RunsPage({ params, + searchParams, }: { params: Promise<{ projectId: string }>; + searchParams: Promise>; }) { const { projectId } = await params; + const query = await searchParams; + + const page = query.page ? Math.max(0, Number(query.page)) : 0; + const pageSize = query.page_size ? Number(query.page_size) : 20; + const q = typeof query.q === "string" ? query.q : undefined; + const modelParam = typeof query.model === "string" ? query.model : undefined; + const models = modelParam ? modelParam.split(",").filter(Boolean) : undefined; let batchRuns: AgentTaskBatchRunSummary[] = []; + let totalCount = 0; + let totalPages = 0; + let modelFacets: ModelFacetOption[] = []; let error: string | null = null; let taskSource: ProjectTaskSource | null = null; + // Fetch runs list and project in parallel — they're independent. + const projectPromise = getProject(projectId).catch(() => null); + try { - batchRuns = await listAgentTaskBatchRuns(projectId); - try { - const project = await getProject(projectId); - taskSource = project.task_source; - } catch { - // Non-fatal: source awareness is a progressive enhancement. - } + const paginated = await listAgentTaskBatchRuns(projectId, { + q, + model: models, + page, + page_size: pageSize, + }); + batchRuns = paginated.data; + totalCount = paginated.total_count; + totalPages = paginated.total_pages; + modelFacets = paginated.model_facets; } catch (e: unknown) { error = e instanceof Error ? e.message : "Failed to fetch runs"; } + // Project result (started in parallel with the runs list). + const project = await projectPromise; + taskSource = project?.task_source ?? null; + return (
- +
); diff --git a/apps/dashboard/src/app/project/[projectId]/runs/runs-client.tsx b/apps/dashboard/src/app/project/[projectId]/runs/runs-client.tsx index 9f634bde..a6b2a412 100644 --- a/apps/dashboard/src/app/project/[projectId]/runs/runs-client.tsx +++ b/apps/dashboard/src/app/project/[projectId]/runs/runs-client.tsx @@ -1,11 +1,13 @@ "use client"; import Link from "next/link"; -import { useCallback, useMemo, useState } from "react"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ArrowUpRight, CalendarClock, ChevronDown, + ChevronLeft, ChevronRight, Clock, GitBranch, @@ -19,10 +21,11 @@ import { import { type AgentTaskBatchRunSummary, type AgentTaskRunSummary, + type ModelFacetOption, getAgentTaskBatchRun, } from "@/lib/agent-task-api"; import { type ProjectTaskSource } from "@/lib/projects-api"; -import { ProjectTaskSourceEmptyState } from "@/components/project-task-source"; + import { TASK_RUN_STATUS, type TaskRunStatus } from "@/components/task-run-list.utils"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -35,31 +38,17 @@ import { TableRow, } from "@/components/ui/table"; import { cn } from "@/lib/utils"; -import { parseUTC, formatCostMicro } from "@/lib/format"; +import { parseUTC, formatCostMicro, formatTokenTotal } from "@/lib/format"; import { formatBatchExecution, formatRunExecution, formatRunExecutionFull } from "@/lib/run-configuration"; import { useProjectId } from "@/lib/project-router"; import { useClientNow } from "@/hooks/use-client-now"; -import { - conclusionStyle, - deriveConclusion, - type Conclusion, -} from "@/components/run-outcome"; -import { useUrlParam, useUrlParamSet } from "@/hooks/use-url-state"; +import { conclusionStyle } from "@/components/run-outcome"; import { RunsModelFilter, type ModelOption } from "./runs-model-filter"; -type ConclusionFilter = "all" | "running" | "passed" | "failed"; - -/** - * Column widths — fixed, not flexible. Mirrors the traces table's approach: - * with `table-fixed` (baked into our primitive) the browser honors - * these widths as authoritative and truncates overflowing cell content instead - * of growing the table past its container. Only the Run column holds variable - * (long) text; everything else is bounded. - */ const COL = { chevron: 28, - run: "auto", // flexible remainder — the only column without a px cap + run: "auto", source: 150, execution: 180, tasks: 180, @@ -67,13 +56,6 @@ const COL = { created: 150, } as const; -/** Coarse bucket for the filter tabs: "problems" groups failed + errored. */ -function filterBucket(c: Conclusion): ConclusionFilter { - if (c === "running" || c === "queued") return "running"; - if (c === "passed") return "passed"; - return "failed"; -} - function formatDate(value: string | null): string { if (!value) return "\u2014"; const d = parseUTC(value); @@ -112,7 +94,7 @@ function formatDuration(start: string | null, end: string | null): string { function formatTrigger(trigger: { source: string | null; actor: string | null; schedule_name?: string | null } | null): string { if (!trigger) return "Manual"; if (trigger.source === "schedule") { - return trigger.schedule_name ? `Scheduled · ${trigger.schedule_name}` : "Scheduled"; + return trigger.schedule_name ? `Scheduled \u00b7 ${trigger.schedule_name}` : "Scheduled"; } if (trigger.source === "manual") return "Manual"; if (trigger.source === "ci") return "CI / Pipeline"; @@ -120,132 +102,122 @@ function formatTrigger(trigger: { source: string | null; actor: string | null; s return trigger.source ?? "Manual"; } -function getSelectionLabel(batch: AgentTaskBatchRunSummary): string { - const q = batch.selection_query; - if (q && typeof q === "object" && "task_paths" in q) { - const paths = q.task_paths; - if (Array.isArray(paths) && paths.length > 0) { - return paths.length === 1 ? (paths[0] as string) : `${paths.length} tasks`; - } - } - if (batch.selection_type === "all") return "All tasks"; - return batch.selection_type; -} - export function RunsClient({ batchRuns, error: _error, taskSource, + totalCount, + page, + pageSize, + totalPages, + modelFacets, }: { batchRuns: AgentTaskBatchRunSummary[]; error: string | null; taskSource: ProjectTaskSource | null; + totalCount: number; + page: number; + pageSize: number; + totalPages: number; + modelFacets: ModelFacetOption[]; }) { const projectId = useProjectId(); - // Filter state lives in the URL (?q=, ?filter=) so a shared link lands the - // reader on the same filtered view. - const [query, setQuery] = useUrlParam("q"); - const [filterParam, setFilter] = useUrlParam("filter", "all"); - const filter: ConclusionFilter = - filterParam === "running" || filterParam === "passed" || filterParam === "failed" - ? filterParam - : "all"; + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); const clientNow = useClientNow(); - const sourceUnconfigured = taskSource === null && batchRuns.length === 0; + const sourceUnconfigured = taskSource === null && totalCount === 0; + + const updateUrl = useCallback( + (updates: Record) => { + const params = new URLSearchParams(searchParams.toString()); + for (const [key, value] of Object.entries(updates)) { + if (value) params.set(key, value); + else params.delete(key); + } + const qs = params.toString(); + router.replace(`${pathname}${qs ? `?${qs}` : ""}`, { scroll: false }); + }, + [router, pathname, searchParams], + ); + + const urlQ = searchParams.get("q") ?? ""; + const [searchInput, setSearchInput] = useState(urlQ); + useEffect(() => { + setSearchInput(urlQ); + }, [urlQ]); + const searchTimer = useRef | undefined>(undefined); + const handleSearchChange = useCallback( + (value: string) => { + setSearchInput(value); + clearTimeout(searchTimer.current); + searchTimer.current = setTimeout(() => { + updateUrl({ q: value || null, page: null }); + }, 350); + }, + [updateUrl], + ); + + const selectedModels = useMemo(() => { + const raw = searchParams.get("model") ?? ""; + return new Set(raw.split(",").filter(Boolean)); + }, [searchParams]); + + const toggleModel = useCallback( + (model: string) => { + const next = new Set(selectedModels); + if (next.has(model)) next.delete(model); + else next.add(model); + updateUrl({ model: Array.from(next).join(",") || null, page: null }); + }, + [selectedModels, updateUrl], + ); + const clearModels = useCallback(() => { + updateUrl({ model: null, page: null }); + }, [updateUrl]); + + const modelOptions: ModelOption[] = useMemo( + () => + modelFacets + .map((f) => ({ model: f.model, count: f.count })) + .sort((a, b) => a.model.localeCompare(b.model)), + [modelFacets], + ); + + const handlePageChange = useCallback( + (newPage: number) => { + updateUrl({ page: newPage === 0 ? null : String(newPage) }); + }, + [updateUrl], + ); - // Compare selection: at most two batch ids, chosen here (not on the compare - // page) so you can see the runs and their overlap in context before deciding - // what to compare. const [compareIds, setCompareIds] = useState([]); const compareIdSet = useMemo(() => new Set(compareIds), [compareIds]); const toggleCompare = useCallback((id: string) => { setCompareIds((prev) => { if (prev.includes(id)) return prev.filter((x) => x !== id); - // Cap at two; replace the older (second) slot when adding a third. return prev.length >= 2 ? [prev[1], id] : [...prev, id]; }); }, []); const clearCompare = useCallback(() => setCompareIds([]), []); - const selectedBatches = useMemo( - () => compareIds.map((id) => batchRuns.find((b) => b.id === id)).filter((b): b is AgentTaskBatchRunSummary => !!b), + const compareBatches = useMemo( + () => compareIds.map((id) => batchRuns.find((b) => b.id === id) ?? null), [compareIds, batchRuns], ); const overlap = useMemo( - () => (selectedBatches.length === 2 ? computeOverlap(selectedBatches[0], selectedBatches[1]) : null), - [selectedBatches], - ); - - const conclusions = useMemo( () => - batchRuns.map((b) => - deriveConclusion({ - status: b.status, - passed: b.passed_tasks, - failed: b.failed_tasks, - errored: b.errored_tasks, - total: b.total_tasks, - }), - ), - [batchRuns], + compareBatches.length === 2 && compareBatches[0] && compareBatches[1] + ? computeOverlap(compareBatches[0], compareBatches[1]) + : null, + [compareBatches], ); - const counts = useMemo(() => { - const c: Record = { all: batchRuns.length, running: 0, passed: 0, failed: 0 }; - for (const con of conclusions) c[filterBucket(con)]++; - return c; - }, [batchRuns.length, conclusions]); - - // SPEC-148: model facet. Options come from every batch's configuration - // summary so selecting one value never removes the others from the list. - // Selection is URL-backed (?model=a,b) → shareable, back/forward-restored. - const [selectedModels, toggleModel, clearModels] = useUrlParamSet("model"); - const modelOptions: ModelOption[] = useMemo(() => { - const c = new Map(); - for (const b of batchRuns) { - const seen = new Set(); - for (const pair of b.configuration?.configurations ?? []) seen.add(pair.model); - for (const m of seen) c.set(m, (c.get(m) ?? 0) + 1); - } - return Array.from(c.entries()) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([model, count]) => ({ model, count })); - }, [batchRuns]); - - const filtered = useMemo(() => { - const q = query.toLowerCase().trim(); - const modelSelected = selectedModels.size > 0; - return batchRuns.filter((b, i) => { - if (filter !== "all" && filterBucket(conclusions[i]) !== filter) return false; - // SPEC-148: model facet — a batch matches if any of its reported - // configurations uses a selected model (OR within the model dimension). - if (modelSelected) { - const hasModel = (b.configuration?.configurations ?? []).some( - (pair) => selectedModels.has(pair.model), - ); - if (!hasModel) return false; - } - if (!q) return true; - // SPEC-148: free-text also searches the reported model/effort pairs. - const configText = (b.configuration?.configurations ?? []) - .map((c) => `${c.model} ${c.effort ?? ""}`) - .join(" ") - .toLowerCase(); - return ( - b.id.includes(q) || - b.selection_type.toLowerCase().includes(q) || - getSelectionLabel(b).toLowerCase().includes(q) || - (b.trigger?.actor?.toLowerCase().includes(q) ?? false) || - (b.trigger?.source?.toLowerCase().includes(q) ?? false) || - b.environment.toLowerCase().includes(q) || - configText.includes(q) - ); - }); - }, [query, filter, batchRuns, conclusions, selectedModels]); + const showingFrom = totalCount === 0 ? 0 : page * pageSize + 1; + const showingTo = Math.min((page + 1) * pageSize, totalCount); return (
- {/* Toolbar — stays fixed above the scroll region. */}
@@ -259,50 +231,31 @@ export function RunsClient({
setQuery(e.target.value)} - placeholder="Filter by ID, selection, source, actor, or task name..." + value={searchInput} + onChange={(e) => handleSearchChange(e.target.value)} + placeholder="Filter by ID, selection, environment..." className="h-8 border-border bg-card pl-8 text-[13px] placeholder:text-muted-foreground/50 focus-visible:ring-1" />
-
- {(["all", "running", "passed", "failed"] as ConclusionFilter[]).map((k) => ( - - ))} -
- {filtered.length} runs + {totalCount} runs
- {/* Scroll region — the table scrolls here while toolbar + footer stay - fixed. The sticky table header sticks to the top of THIS container, - not the page, which is what makes it behave like the traces table. */}
{sourceUnconfigured ? (
- +
+ Run apo task publish to publish your task catalog. +
- ) : filtered.length === 0 ? ( + ) : batchRuns.length === 0 ? (
- {batchRuns.length === 0 + {totalCount === 0 ? <>No runs yet. Discover and run tasks : "No runs match your filters."}
@@ -324,13 +277,13 @@ export function RunsClient({ /> - Tasks · Pass rate + Tasks {"\u00b7"} Pass rate Duration Created - {filtered.map((b) => ( + {batchRuns.map((b) => (
- Showing {filtered.length} of {batchRuns.length} runs + + {totalCount > 0 && ( + <>Showing {showingFrom}{"\u2013"}{showingTo} of + )} + {totalCount} runs + + {totalPages > 1 && ( +
+ + + Page {page + 1} of {totalPages} + + +
+ )}
- {/* Compare bar — appears when one or two runs are selected for comparison. - Shows the task overlap so you can tell, before navigating, whether the - two runs actually share enough tasks to be worth comparing. */} - {selectedBatches.length > 0 && ( + {compareIds.length > 0 && (
- {selectedBatches.map((b, i) => ( - - {i > 0 && vs} - - {getBatchName(b)} + {compareIds.map((id, i) => { + const batch = compareBatches[i]; + return ( + + {i > 0 && vs} + + {batch ? getBatchName(batch) : id.slice(0, 8)} + - - ))} - {selectedBatches.length === 2 && ( + ); + })} + {compareIds.length === 2 && ( {overlap ? ( overlap.shared === 0 ? ( @@ -376,15 +363,17 @@ export function RunsClient({ <> {overlap.shared} shared {overlap.onlyA > 0 && ( - <> · {overlap.onlyA} only A + <> {"\u00b7"} {overlap.onlyA} only A )} {overlap.onlyB > 0 && ( - <> · {overlap.onlyB} only B + <> {"\u00b7"} {overlap.onlyB} only B )} ) ) : ( - overlap unknown + + {compareBatches[0] && compareBatches[1] ? "overlap unknown" : "select both on one page"} + )} )} @@ -399,9 +388,9 @@ export function RunsClient({ > Clear - {selectedBatches.length === 2 && overlap !== null && overlap.shared === 0 ? ( + {compareIds.length === 2 && overlap !== null && overlap.shared === 0 ? ( Nothing to compare - ) : selectedBatches.length === 2 ? ( + ) : compareIds.length === 2 ? (
s inside the table body: - * 1. The main row (chevron, name, source, tasks, duration, created). - * 2. When expanded, a child with a single
holding the - * lazy-loaded task runs. - * - * Using table-fixed (from ) + explicit column widths means the Run - * column — the only one without a px width — absorbs free space, and its - * `min-w-0` + `truncate` content clips instead of pushing the table wider. - */ function RunsRow({ batch, clientNow, @@ -499,8 +471,6 @@ function RunsRow({ compareSelected: boolean; compareDisabled: boolean; onToggleCompare: () => void; - /** SPEC-148: active model facet. When non-empty, expanded children that don't - match a selected model are hidden (the batch row itself is filtered upstream). */ modelFilter: Set; }) { const [expanded, setExpanded] = useState(false); @@ -515,13 +485,9 @@ function RunsRow({ errored: batch.errored_tasks, total: batch.total_tasks, }); - // Pass rate is check-level (Σ passed_checks / Σ total_checks) — "how well did - // it do". Comparable across batch sizes and matches the child rows. The - // task-level fraction (passed_tasks/total_tasks) is shown beside it as the - // "did every task fully pass" signal. See plan: check-level leads. const checkTotal = Math.max(batch.total_checks, 1); const passRate = Math.round((batch.passed_checks / checkTotal) * 100); - const isRunning = batch.status === "running"; + const isRunning = batch.status === "running" || batch.status === "queued"; const showRate = batch.total_checks > 0 || !isRunning; const triggerLabel = formatTrigger(batch.trigger); const SourceIcon = getSourceIcon(batch.trigger?.source ?? null); @@ -530,8 +496,6 @@ function RunsRow({ const branch = (batch.trigger as Record | null)?.branch as string | null; const commit = (batch.trigger as Record | null)?.commit_sha as string | null; - // Lazy-load child task runs the first time the row is expanded. Cached in - // state so re-collapsing/expanding doesn't re-fetch. const handleToggle = useCallback(async () => { const next = !expanded; setExpanded(next); @@ -552,9 +516,6 @@ function RunsRow({ return ( <> - {/* Expand chevron — only for batches with >1 task. A single-task batch - is just the task run; expanding would echo the same name, so it has - nothing to reveal. The row title links to batch detail instead. */} {batch.total_tasks > 1 ? ( + ); +} diff --git a/apps/dashboard/src/app/project/[projectId]/runs/task/[taskRunId]/group-by-describe.test.ts b/apps/dashboard/src/app/project/[projectId]/runs/task/[taskRunId]/group-by-describe.test.ts new file mode 100644 index 00000000..b6208cd7 --- /dev/null +++ b/apps/dashboard/src/app/project/[projectId]/runs/task/[taskRunId]/group-by-describe.test.ts @@ -0,0 +1,132 @@ +import { describe as suite, it, expect } from "vitest"; + +import { groupChecksByDescribe, groupVerdict, groupCost } from "./group-by-describe"; +import type { CheckResult } from "@/lib/agent-task-api"; + +function check(overrides: Partial & { id: string }): CheckResult { + return { pass: true, reasoning: "", ...overrides }; +} + +function judgedCheck(id: string, cost: number, pass = true): CheckResult { + return check({ + id, + pass, + reasoning: "", + assertions: [ + { id: `${id}-a`, pass, reasoning: "", judge: { cost, model: "gpt-4o" } }, + ], + }); +} + +suite("groupChecksByDescribe", () => { + it("returns no segments for an empty list", () => { + expect(groupChecksByDescribe([])).toEqual([]); + }); + + it("emits a check segment per ungrouped check, in order", () => { + const checks = [check({ id: "a" }), check({ id: "b" })]; + const segments = groupChecksByDescribe(checks); + expect(segments).toHaveLength(2); + expect(segments[0]).toMatchObject({ kind: "check", check: { id: "a" } }); + expect(segments[1]).toMatchObject({ kind: "check", check: { id: "b" } }); + }); + + it("groups all checks under one group segment", () => { + const checks = [ + check({ id: "R-0", group_id: "rules", group_name: "Rules" }), + check({ id: "R-1", group_id: "rules", group_name: "Rules" }), + ]; + const segments = groupChecksByDescribe(checks); + expect(segments).toHaveLength(1); + expect(segments[0]).toMatchObject({ + kind: "group", + groupId: "rules", + groupName: "Rules", + }); + expect(segments[0]!.kind).toBe("group"); + expect((segments[0] as { checks: CheckResult[] }).checks.map((c) => c.id)).toEqual([ + "R-0", + "R-1", + ]); + }); + + it("preserves declaration order across mixed grouped and ungrouped checks", () => { + const checks = [ + check({ id: "bare-1" }), + check({ id: "R-0", group_id: "rules", group_name: "Rules" }), + check({ id: "R-1", group_id: "rules", group_name: "Rules" }), + check({ id: "bare-2" }), + ]; + const segments = groupChecksByDescribe(checks); + expect(segments.map((s) => s.kind)).toEqual(["check", "group", "check"]); + expect(segments[1]!.kind).toBe("group"); + }); + + it("opens sibling groups at their first member's position", () => { + const checks = [ + check({ id: "R-0", group_id: "rules", group_name: "Rules" }), + check({ id: "R-1", group_id: "rules", group_name: "Rules" }), + check({ id: "S-0", group_id: "safety", group_name: "Safety" }), + check({ id: "S-1", group_id: "safety", group_name: "Safety" }), + ]; + const segments = groupChecksByDescribe(checks); + expect(segments).toHaveLength(2); + expect(segments[0]).toMatchObject({ kind: "group", groupId: "rules" }); + expect(segments[1]).toMatchObject({ kind: "group", groupId: "safety" }); + }); + + it("defaults groupName to groupId when group_name is absent", () => { + const checks = [check({ id: "R-0", group_id: "rules" })]; + const segments = groupChecksByDescribe(checks); + expect(segments[0]).toMatchObject({ kind: "group", groupId: "rules", groupName: "rules" }); + }); +}); + +suite("groupVerdict", () => { + it("counts passed/failed/total", () => { + const checks = [ + check({ id: "a", pass: true }), + check({ id: "b", pass: false }), + check({ id: "c", pass: true }), + ]; + expect(groupVerdict(checks)).toEqual({ passed: 2, failed: 1, total: 3 }); + }); + + it("treats a group where every check passes as fully passing", () => { + const checks = [check({ id: "a", pass: true }), check({ id: "b", pass: true })]; + expect(groupVerdict(checks)).toEqual({ passed: 2, failed: 0, total: 2 }); + }); +}); + +suite("groupCost", () => { + it("sums judge costs across all assertions in the group", () => { + const checks = [judgedCheck("a", 0.1), judgedCheck("b", 0.05), check({ id: "c" })]; + expect(groupCost(checks)).toBeCloseTo(0.15, 10); + }); + + it("returns 0 when no checks have judge metadata", () => { + expect(groupCost([check({ id: "a" })])).toBe(0); + }); + + it("does not double-count the top-level check.judge when assertions exist", () => { + const c: CheckResult = { + id: "a", + pass: true, + reasoning: "", + judge: { cost: 0.2, model: "gpt-4o" }, + assertions: [{ id: "a-a", pass: true, reasoning: "", judge: { cost: 0.1, model: "gpt-4o" } }], + }; + // assertions present → only the assertion's cost counts (0.1), not check.judge (0.2). + expect(groupCost([c])).toBeCloseTo(0.1, 10); + }); + + it("falls back to check.judge.cost when assertions are absent", () => { + const c: CheckResult = { + id: "a", + pass: true, + reasoning: "", + judge: { cost: 0.2, model: "gpt-4o" }, + }; + expect(groupCost([c])).toBeCloseTo(0.2, 10); + }); +}); diff --git a/apps/dashboard/src/app/project/[projectId]/runs/task/[taskRunId]/group-by-describe.ts b/apps/dashboard/src/app/project/[projectId]/runs/task/[taskRunId]/group-by-describe.ts new file mode 100644 index 00000000..150f1c73 --- /dev/null +++ b/apps/dashboard/src/app/project/[projectId]/runs/task/[taskRunId]/group-by-describe.ts @@ -0,0 +1,80 @@ +import type { CheckResult } from "@/lib/agent-task-api"; + +/** + * One segment of the checks list after `describe()` grouping. + * + * - ``"check"`` — a bare check, declared outside any describe. + * - ``"group"`` — a collapsible group of checks that shared one describe id. + * + * Segments are emitted in declaration order: a group segment appears at the + * position of its first member check, so a bare check between two groups + * renders between them. This keeps the list faithful to how the author wrote + * it while letting the dashboard collapse a generated family (e.g. 34 + * ``R-00…R-33`` checks) under one roll-up header. + */ +export type CheckSegment = + | { kind: "check"; check: CheckResult } + | { kind: "group"; groupId: string; groupName: string; checks: CheckResult[] }; + +/** + * Partition a flat ``checks_json`` list into ordered segments keyed off each + * check's optional ``group_id``. Pure and stable — does not mutate the input. + */ +export function groupChecksByDescribe(checks: CheckResult[]): CheckSegment[] { + const segments: CheckSegment[] = []; + const groupIndex = new Map(); + + for (const check of checks) { + const groupId = check.group_id; + if (!groupId) { + segments.push({ kind: "check", check }); + continue; + } + const existing = groupIndex.get(groupId); + if (existing !== undefined) { + const group = segments[existing] as Extract; + group.checks.push(check); + } else { + groupIndex.set(groupId, segments.length); + segments.push({ + kind: "group", + groupId, + groupName: check.group_name ?? groupId, + checks: [check], + }); + } + } + return segments; +} + +/** Roll-up pass/failed/total tally for a group's checks. */ +export function groupVerdict(checks: CheckResult[]): { + passed: number; + failed: number; + total: number; +} { + const total = checks.length; + const passed = checks.filter((c) => c.pass === true).length; + return { passed, failed: total - passed, total }; +} + +/** + * Sum judge costs across a group's checks. Sums from ``assertions[].judge.cost`` + * when assertions are present (the per-call truth), falling back to the + * top-level ``check.judge.cost`` only when assertions are absent — so a check + * is never double-counted. + */ +export function groupCost(checks: CheckResult[]): number { + let sum = 0; + for (const check of checks) { + const assertions = check.assertions; + if (assertions && assertions.length > 0) { + for (const a of assertions) { + if (typeof a.judge?.cost === "number") sum += a.judge.cost; + } + } else if (typeof check.judge?.cost === "number") { + sum += check.judge.cost; + } + } + return sum; +} diff --git a/apps/dashboard/src/app/project/[projectId]/runs/task/[taskRunId]/page.tsx b/apps/dashboard/src/app/project/[projectId]/runs/task/[taskRunId]/page.tsx index a7966be8..4f916b8c 100644 --- a/apps/dashboard/src/app/project/[projectId]/runs/task/[taskRunId]/page.tsx +++ b/apps/dashboard/src/app/project/[projectId]/runs/task/[taskRunId]/page.tsx @@ -1,13 +1,8 @@ import { getAgentTaskRun } from "@/lib/agent-task-api"; -import { getTraceDetail } from "@/lib/traces-api"; -import { - deriveConversationFromTrace, - type ChatMessage, -} from "@/lib/conversation-from-trace"; import type { Metadata } from "next"; import { Button } from "@/components/ui/button"; import { TraceHomeLink } from "@/components/trace-detail"; -import { Suspense } from "react"; +import { Suspense, cache } from "react"; import Link from "next/link"; import { ArrowLeft, @@ -24,9 +19,13 @@ import { TaskRunDetailBody } from "./task-run-detail-body"; import { TaskRunAutoRefresh } from "@/components/agent-task-execution/task-run-auto-refresh"; import { OutcomeSummary } from "@/components/run-outcome"; import { formatTokenTotal, formatCostMicro } from "@/lib/format"; +import { getProject } from "@/lib/projects-api"; export const dynamic = "force-dynamic"; +// Per-request memo so generateMetadata and the page body share one fetch. +const getAgentTaskRunCached = cache(getAgentTaskRun); + // Tab title: "Task Run #". Falls back to "Task Run" on fetch failure. export async function generateMetadata({ params, @@ -35,7 +34,7 @@ export async function generateMetadata({ }): Promise { const { taskRunId } = await params; try { - const run = await getAgentTaskRun(taskRunId); + const run = await getAgentTaskRunCached(taskRunId); return { title: `Task Run #${run.task_id.slice(0, 8)}` }; } catch { return { title: "Task Run" }; @@ -82,28 +81,27 @@ export default async function TaskRunDetailPage({ }) { const { projectId, taskRunId } = await params; + // Start the project fetch in parallel — it's independent of the task run. + // A failure here is non-fatal (sourceType falls back to null). + const projectPromise = getProject(projectId).catch(() => null); + let taskRun; let error: string | null = null; try { - taskRun = await getAgentTaskRun(taskRunId); + taskRun = await getAgentTaskRunCached(taskRunId); } catch (e: unknown) { error = e instanceof Error ? e.message : "Failed to fetch task run"; } - // Derive the conversation view from the linked trace when one exists. A fetch - // failure here must not break the page — the empty conversation just renders - // the empty state, and the trace link still points at the full viewer. - const traceRunId = taskRun?.trace_run_id ?? null; - let conversation: { messages: ChatMessage[] } = { messages: [] }; - if (taskRun && traceRunId) { - try { - const trace = await getTraceDetail(traceRunId, projectId); - conversation = deriveConversationFromTrace(trace); - } catch { - // Leave conversation empty; the transcript tab shows its empty state. - } - } + // The transcript is NOT derived here: it requires the full trace detail + // (every call's input/output — megabytes for agent runs), and the default + // tab is "checks". TaskRunDetailBody fetches and derives it client-side + // when the transcript tab first opens. + + // Project result — started in parallel with the task run, likely resolved. + const project = await projectPromise; + const sourceType = project?.task_source?.source_type ?? null; if (error) { return ( @@ -285,13 +283,15 @@ export default async function TaskRunDetailPage({ diff --git a/apps/dashboard/src/app/project/[projectId]/runs/task/[taskRunId]/task-run-detail-body.tsx b/apps/dashboard/src/app/project/[projectId]/runs/task/[taskRunId]/task-run-detail-body.tsx index dd8ee249..5c3f0946 100644 --- a/apps/dashboard/src/app/project/[projectId]/runs/task/[taskRunId]/task-run-detail-body.tsx +++ b/apps/dashboard/src/app/project/[projectId]/runs/task/[taskRunId]/task-run-detail-body.tsx @@ -1,7 +1,7 @@ "use client"; import dynamic from "next/dynamic"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { ChevronDown, ChevronRight, @@ -22,20 +22,28 @@ import { DeliverablesPanel } from "@/components/agent-task-execution/deliverable import { ShikiCodeBlock } from "@/components/shiki-code-block"; import { ExpandableJson } from "@/components/ExpandableJson"; import { Markdown } from "@/components/trace-detail/Markdown"; -import type { ChatMessage } from "@/lib/conversation-from-trace"; +import { deriveConversationFromTrace, type ChatMessage } from "@/lib/conversation-from-trace"; +import { getTraceDetail } from "@/lib/traces-api"; import type { DeliverableSummary } from "@/lib/agent-task-deliverables-api"; -import { readTaskFile, type TaskFileContentResponse } from "@/lib/agent-task-api"; +import { readTaskFile, readTaskDefinitionSource, type TaskFileContentResponse, type TaskDefinitionRevisionSummary } from "@/lib/agent-task-api"; import { extractJudgeReasoning } from "@/lib/judge-reasoning"; import type { CheckAssertionResult, CheckResult, JudgeMetadata } from "@/lib/agent-task-api"; import { buildCheckDiagnostics } from "@/lib/check-diagnostics"; import { extractCheckBlock } from "@/lib/extract-check-block"; import { buildSourceCandidates, + checkAnchorLine, shouldAcceptSource, } from "@/lib/check-source-candidates"; import { locateAssertionsInBlock } from "@/lib/locate-assertion"; import { formatTokenBreakdown } from "@/lib/format"; import { buildAssertionParam, parseOwnAssertionId } from "@/lib/assertion-select"; +import { + groupChecksByDescribe, + groupVerdict, + groupCost, +} from "./group-by-describe"; +import { CheckGroupHeader } from "./check-group-header"; // CodeMirror is heavy — load it only when a code check is expanded. const CodeViewer = dynamic( @@ -394,7 +402,7 @@ function ExpandableCheckItem({ checksSource ? extractCheckBlock(checksSource.content, { id: item.id, - anchorLine: item.location?.line, + anchorLine: checkAnchorLine(item), }) : null; const diagnostics = checkBlock @@ -625,32 +633,177 @@ function DeliverableFile({ name, value }: { name: string; value: unknown }) { ); } +// ── Checks list (grouped by describe) ───────────────────────────────────── + +// Renders the checks panel, nesting checks declared inside a `describe()` +// under a collapsible {@link CheckGroupHeader} with a roll-up verdict. Bare +// checks render at the top level as before. Backward compatible: a run whose +// checks carry no `group_id` produces one "check" segment per check, so the +// layout is identical to the old flat list. +function ChecksList({ + checks, + checksSource, +}: { + checks: CheckResult[]; + checksSource?: TaskFileContentResponse | null; +}) { + const [collapsedGroups, setCollapsedGroups] = useState>( + () => new Set(), + ); + // Groups start expanded (option b from the design discussion); the set + // tracks which the user has collapsed. + const toggleGroup = (groupId: string) => + setCollapsedGroups((prev) => { + const next = new Set(prev); + if (next.has(groupId)) next.delete(groupId); + else next.add(groupId); + return next; + }); + + const segments = groupChecksByDescribe(checks); + // Assign each check a global display index (the "Check N" fallback label). + const indexByGroupId = new Map(); + let counter = 0; + for (const segment of segments) { + const items = segment.kind === "check" ? [segment.check] : segment.checks; + for (const item of items) { + indexByGroupId.set(item.id, counter++); + } + } + + return ( + <> + {segments.map((segment) => { + if (segment.kind === "check") { + const idx = indexByGroupId.get(segment.check.id) ?? 0; + return ( + + ); + } + const { passed, total } = groupVerdict(segment.checks); + const cost = groupCost(segment.checks); + const isOpen = !collapsedGroups.has(segment.groupId); + return ( +
+ toggleGroup(segment.groupId)} + /> + {isOpen && ( +
+ {segment.checks.map((item) => ( + + ))} +
+ )} +
+ ); + })} + + ); +} + // ── Main body ──────────────────────────────────────────────────────────── +type ConversationState = + | { status: "idle" | "loading" } + | { status: "ready"; messages: ChatMessage[] } + | { status: "error"; message: string }; + +/** + * Fetch the linked trace and derive the conversation only once the + * transcript tab is first opened. The trace detail endpoint returns every + * call's full input/output (megabytes for agent runs), so eagerly fetching + * it server-side made every task-run page load pay for a tab that usually + * stays closed. State lives at the body level so switching tabs doesn't + * refetch. + */ +function useLazyConversation( + traceRunId: string | null, + projectId: string | null | undefined, + enabled: boolean, +): ConversationState { + const [state, setState] = useState({ status: "idle" }); + const startedRef = useRef(false); + + useEffect(() => { + if (!enabled || startedRef.current) return; + startedRef.current = true; + if (!traceRunId) { + // No linked trace: ConversationTranscript renders its empty state. + setState({ status: "ready", messages: [] }); + return; + } + setState({ status: "loading" }); + getTraceDetail(traceRunId, projectId ?? undefined) + .then((trace) => { + setState({ + status: "ready", + messages: deriveConversationFromTrace(trace).messages, + }); + }) + .catch((e: unknown) => { + setState({ + status: "error", + message: e instanceof Error ? e.message : "Failed to load transcript", + }); + }); + }, [enabled, traceRunId, projectId]); + + return state; +} + export function TaskRunDetailBody({ checks, - conversation, deliverables, deliverableItems, traceRunId, projectId, commitSha, taskId, + sourceType, + taskDefinition, + taskRunId, }: { checks: CheckResult[]; - conversation: ChatMessage[]; deliverables: Record | null; deliverableItems: DeliverableSummary[]; traceRunId: string | null; projectId?: string | null; commitSha?: string | null; taskId: string; + sourceType?: string | null; + taskDefinition?: TaskDefinitionRevisionSummary | null; + taskRunId?: string | null; }) { // Active tab lives in the URL (?tab=) so a shared link lands the reader on // the same view (checks / transcript / deliverables). const [tabParam, setTabParam] = useUrlParam("tab"); const tab: Tab = tabParam === "transcript" || tabParam === "deliverables" ? tabParam : "checks"; + const conversationState = useLazyConversation( + traceRunId, + projectId, + tab === "transcript", + ); + const recordedSourceFile = checks.find((check) => check.source_file)?.source_file; const checkIds = checks.map((check) => check.id).join("\u0000"); const [sourceState, setSourceState] = useState<{ @@ -675,7 +828,7 @@ export function TaskRunDetailBody({ signal, ); const containsKnownCheck = checks.some((check) => - extractCheckBlock(source.content, { id: check.id }) !== null + extractCheckBlock(source.content, { id: check.id, anchorLine: checkAnchorLine(check) }) !== null ); if ( shouldAcceptSource({ @@ -699,7 +852,24 @@ export function TaskRunDetailBody({ ); useEffect(() => { - if (checks.length === 0 || !projectId) return; + if (checks.length === 0) return; + // SPEC-169: when a Task Definition is pinned, load source through the + // Run-bound endpoint instead of the retired project-source resolver. + if (taskDefinition && taskRunId && taskDefinition.files[0]) { + const controller = new AbortController(); + setSourceState({ data: null, error: null }); + readTaskDefinitionSource(taskRunId, taskDefinition.files[0].path, controller.signal) + .then((source) => setSourceState({ data: source, error: null })) + .catch((err) => { + if (!controller.signal.aborted) { + setSourceState({ data: null, error: err instanceof Error ? err.message : "Failed to load definition source" }); + } + }); + return () => controller.abort(); + } + // No definition: fall back to the legacy project-source path (or skip + // for published catalogs where source is metadata-only). + if (!projectId || sourceType === "published") return; const controller = new AbortController(); setSourceState({ data: null, error: null }); @@ -728,6 +898,7 @@ export function TaskRunDetailBody({ commitSha, projectId, recordedSourceFile, + sourceType, taskId, ]); @@ -821,22 +992,15 @@ export function TaskRunDetailBody({ Expand a code check to see its source with the failing line marked.

)} - {sourceState.error && ( -

- Check source unavailable: {sourceState.error} + {!checksSource && checks.length > 0 && sourceType === "published" && ( +

+ Check source remains on the machine that executed this task — published task catalogs carry metadata only.

)} {checks.length > 0 && ( - {checks.map((item, index) => ( - - ))} + )} @@ -847,10 +1011,20 @@ export function TaskRunDetailBody({ )} {tab === "transcript" && ( - + conversationState.status === "ready" ? ( + + ) : conversationState.status === "error" ? ( +

+ Failed to load transcript: {conversationState.message} +

+ ) : ( +

+ Loading transcript… +

+ ) )} {tab === "deliverables" && ( diff --git a/apps/dashboard/src/app/project/[projectId]/schedules/CreateScheduleDialog.tsx b/apps/dashboard/src/app/project/[projectId]/schedules/CreateScheduleDialog.tsx index 26fb5387..fa470cff 100644 --- a/apps/dashboard/src/app/project/[projectId]/schedules/CreateScheduleDialog.tsx +++ b/apps/dashboard/src/app/project/[projectId]/schedules/CreateScheduleDialog.tsx @@ -18,23 +18,22 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/u import { Input } from "@/components/ui/input"; import { ScheduleBuilder, type ScheduleBuilderValue } from "@/components/schedule/ScheduleBuilder"; import { TaskFolderSelect } from "@/components/task-folder-select"; -import type { ExecutorPoolSummary } from "@/lib/executor-api"; -import { ScheduleExecutionFields } from "./schedule-execution-fields"; interface CreateScheduleDialogProps { tasks: AgentTaskSummary[]; initialTaskIds: string[]; - taskRoot: string | null; - executorPools: ExecutorPoolSummary[]; onClose: () => void; onCreated: (schedule: AgentTaskScheduleSummary) => void; } +/** + * create a source-owned Schedule. The authenticated creator becomes + * the fixed Execution Owner — the dialog never offers a Pool, queue TTL, task + * root, path, or owner selector. It submits exact catalog Task IDs. + */ export default function CreateScheduleDialog({ tasks, initialTaskIds, - taskRoot, - executorPools, onClose, onCreated, }: CreateScheduleDialogProps) { @@ -57,10 +56,6 @@ export default function CreateScheduleDialog({ setScheduleValue((v) => ({ ...v, timezone: browserTz })); } const [selected, setSelected] = useState>(() => new Set(initialTaskIds)); - const [executorPoolId, setExecutorPoolId] = useState( - () => executorPools.find((pool) => pool.is_default && pool.enabled && !pool.archived)?.id ?? "", - ); - const [queueTtlSeconds, setQueueTtlSeconds] = useState(86_400); const [submitState, dispatchSubmit] = useReducer( (s: { submitting: boolean; error: string | null }, a: | { type: "START" } @@ -87,23 +82,14 @@ export default function CreateScheduleDialog({ dispatchSubmit({ type: "ERROR", error: "Schedule name is required" }); return; } - if (!executorPoolId) { - dispatchSubmit({ type: "ERROR", error: "Choose where this schedule should run" }); - setStep("schedule"); - return; - } dispatchSubmit({ type: "START" }); try { - const selectedPaths = tasks.flatMap((t) => - selected.has(t.id) ? [t.task_path] : [], - ); + const taskIds = tasks.flatMap((t) => (selected.has(t.id) ? [t.id] : [])); const created = await createAgentTaskSchedule({ project: projectId, name: name.trim(), - selection_type: selectedPaths.length === 1 ? "task" : "tasks", - task_paths: selectedPaths, - task_root: taskRoot, + selection: { kind: "tasks", task_ids: taskIds }, cadence_type: scheduleValue.cadence_type, timezone: scheduleValue.timezone, hour: scheduleValue.hour, @@ -113,8 +99,6 @@ export default function CreateScheduleDialog({ min_interval_days: scheduleValue.min_interval_days, max_interval_days: scheduleValue.max_interval_days, enabled: true, - executor_pool_id: executorPoolId, - queue_ttl_seconds: queueTtlSeconds, }); onCreated(created); } catch (e: unknown) { @@ -128,7 +112,7 @@ export default function CreateScheduleDialog({ New Schedule

- Configure when your agent tasks run automatically + Runs in your connected environment — you are the fixed Execution Owner

@@ -176,16 +160,7 @@ export default function CreateScheduleDialog({ {step === "schedule" && ( -
- - -
+ )} {step === "tasks" && ( diff --git a/apps/dashboard/src/app/project/[projectId]/schedules/ScheduleCard.tsx b/apps/dashboard/src/app/project/[projectId]/schedules/ScheduleCard.tsx index ab69259f..ad177eba 100644 --- a/apps/dashboard/src/app/project/[projectId]/schedules/ScheduleCard.tsx +++ b/apps/dashboard/src/app/project/[projectId]/schedules/ScheduleCard.tsx @@ -88,7 +88,13 @@ export default function ScheduleCard({ · {summarizeTasks(schedule)} · - {executorPoolName ?? "Pool unavailable"} + + {schedule.execution_kind === "source_owned" + ? (schedule.execution_owner + ? `Runs in ${schedule.execution_owner.name}’s connected environment` + : "Runs in a connected environment") + : (executorPoolName ?? "Pool unavailable")} + diff --git a/apps/dashboard/src/app/project/[projectId]/schedules/[scheduleId]/schedule-detail-client.tsx b/apps/dashboard/src/app/project/[projectId]/schedules/[scheduleId]/schedule-detail-client.tsx index e737811e..37c18e6e 100644 --- a/apps/dashboard/src/app/project/[projectId]/schedules/[scheduleId]/schedule-detail-client.tsx +++ b/apps/dashboard/src/app/project/[projectId]/schedules/[scheduleId]/schedule-detail-client.tsx @@ -18,10 +18,12 @@ import type { AgentTaskSummary, } from "@/lib/agent-task-api"; import { + listScheduleOccurrences, triggerSchedule, updateAgentTaskSchedule, } from "@/lib/agent-task-api"; import { Button } from "@/components/ui/button"; +import { ScheduleOccurrenceList } from "@/app/project/[projectId]/schedules/[scheduleId]/schedule-occurrence-list"; import { ExecutorPoolSelect } from "@/components/executor-pool-select"; import type { ExecutorPoolSummary } from "@/lib/executor-api"; import { cn } from "@/lib/utils"; @@ -233,11 +235,44 @@ export function ScheduleDetailClient({ ) : ( )} + {schedule.execution_kind === "source_owned" && ( + + )} ); } +function OccurrenceHistory({ scheduleId }: { scheduleId: string }) { + const [occurrences, setOccurrences] = useState< + Awaited>["occurrences"] | null + >(null); + + useEffect(() => { + let cancelled = false; + listScheduleOccurrences(scheduleId) + .then((res) => { + if (!cancelled) setOccurrences(res.occurrences); + }) + .catch(() => { + if (!cancelled) setOccurrences([]); + }); + return () => { + cancelled = true; + }; + }, [scheduleId]); + + if (occurrences === null) return null; + return ( +
+

+ Occurrences +

+ +
+ ); +} + function AdaptiveScheduleBody({ projectId, schedule, diff --git a/apps/dashboard/src/app/project/[projectId]/schedules/[scheduleId]/schedule-occurrence-list.test.tsx b/apps/dashboard/src/app/project/[projectId]/schedules/[scheduleId]/schedule-occurrence-list.test.tsx new file mode 100644 index 00000000..02e2432e --- /dev/null +++ b/apps/dashboard/src/app/project/[projectId]/schedules/[scheduleId]/schedule-occurrence-list.test.tsx @@ -0,0 +1,75 @@ +import { describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import type { ScheduleOccurrenceSummary } from "@/lib/agent-task-api"; + +vi.mock("@/lib/project-router", () => ({ + useProjectId: () => "acme", + useIsDemo: () => false, + DEFAULT_PROJECT: "example-service", + DEMO_PROJECT: "demo", +})); + +import { ScheduleOccurrenceList } from "@/app/project/[projectId]/schedules/[scheduleId]/schedule-occurrence-list"; + +function occ( + overrides: Partial, +): ScheduleOccurrenceSummary { + return { + id: "occ-1", + kind: "scheduled", + scheduled_for: "2026-08-01T06:00:00Z", + status: "delivered", + batch_run_id: "batch-1", + missed_reason: null, + resolved_at: null, + ...overrides, + }; +} + +describe("ScheduleOccurrenceList", () => { + it("uses honest states and never labels a miss as a failed Task result", () => { + render( + , + ); + expect(screen.getByText(/Queued — waiting to start/i)).toBeInTheDocument(); + expect(screen.getByText(/Ran in the owner/i)).toBeInTheDocument(); + expect(screen.getAllByText(/Missed/i).length).toBe(2); + expect(screen.getByText(/previous occurrence was still active/i)).toBeInTheDocument(); + expect( + screen.getByText(/No compatible Connected Executor became available within 24 hours/i), + ).toBeInTheDocument(); + expect(screen.getByText(/Paused before any Task started/i)).toBeInTheDocument(); + // Only the pending + delivered Occurrences own a Batch; misses/cancellations do not. + expect(screen.getAllByText("view run").length).toBe(2); + }); + + it("links to the Batch only when one exists", () => { + render( + , + ); + expect(screen.getByText("view run").getAttribute("href")).toBe( + "/project/acme/runs/batch-x", + ); + }); + + it("renders an honest empty state", () => { + render(); + expect(screen.getByText(/No occurrences yet/i)).toBeInTheDocument(); + }); +}); diff --git a/apps/dashboard/src/app/project/[projectId]/schedules/[scheduleId]/schedule-occurrence-list.tsx b/apps/dashboard/src/app/project/[projectId]/schedules/[scheduleId]/schedule-occurrence-list.tsx new file mode 100644 index 00000000..ba43b032 --- /dev/null +++ b/apps/dashboard/src/app/project/[projectId]/schedules/[scheduleId]/schedule-occurrence-list.tsx @@ -0,0 +1,88 @@ +import Link from "next/link"; +import type { + OccurrenceMissedReason, + ScheduleOccurrenceSummary, +} from "@/lib/agent-task-api"; +import { useProjectId } from "@/lib/project-router"; + +interface ScheduleOccurrenceListProps { + occurrences: ScheduleOccurrenceSummary[]; +} + +/** honest Occurrence history. A missed Occurrence is never shown as + * a failed Task result — it is availability/cadence information. Links appear + * only when a Batch exists. */ +export function ScheduleOccurrenceList({ occurrences }: ScheduleOccurrenceListProps) { + const projectId = useProjectId(); + if (occurrences.length === 0) { + return ( +

+ No occurrences yet. +

+ ); + } + return ( +
    + {occurrences.map((occ) => { + const copy = occurrenceCopy(occ); + return ( +
  1. +
    +

    {copy.label}

    + {copy.detail && ( +

    {copy.detail}

    + )} +
    +
    + {formatScheduledFor(occ.scheduled_for)} + {occ.batch_run_id && ( + + view run + + )} +
    +
  2. + ); + })} +
+ ); +} + +function occurrenceCopy( + occ: ScheduleOccurrenceSummary, +): { label: string; detail: string | null } { + if (occ.status === "pending") { + return { label: "Queued — waiting to start", detail: null }; + } + if (occ.status === "delivered") { + return { label: "Delivered", detail: "Ran in the owner’s connected environment." }; + } + if (occ.status === "cancelled") { + return { label: "Cancelled", detail: "Paused before any Task started." }; + } + return { label: "Missed", detail: missedReasonDetail(occ.missed_reason) }; +} + +function missedReasonDetail(reason: OccurrenceMissedReason | null): string { + switch (reason) { + case "previous_occurrence_active": + return "The previous occurrence was still active."; + case "executor_unavailable": + return "No compatible Connected Executor became available within 24 hours."; + case "catalog_changed": + return "Selected Tasks changed; review this Schedule."; + case "selection_empty": + return "This selection currently contains no Tasks."; + default: + return "No Batch was created for this due time."; + } +} + +function formatScheduledFor(value: string): string { + const d = new Date(value); + const pad = (n: number) => String(n).padStart(2, "0"); + return `${pad(d.getUTCMonth() + 1)}/${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`; +} diff --git a/apps/dashboard/src/app/project/[projectId]/schedules/page.tsx b/apps/dashboard/src/app/project/[projectId]/schedules/page.tsx index cf27e61b..e7afbdcb 100644 --- a/apps/dashboard/src/app/project/[projectId]/schedules/page.tsx +++ b/apps/dashboard/src/app/project/[projectId]/schedules/page.tsx @@ -39,9 +39,9 @@ export default async function AgentTaskSchedulesPage({ listExecutorPools(projectId), ]); - // SPEC-118: non-demo projects must NOT inherit example-service tasks + // non-demo projects must NOT inherit example-service tasks // via the legacy DEFAULT_TASK_ROOT fallback. The task list comes - // from the project's configured source (SPEC-119 inventory) or is + // from the project's configured source or is // empty. Demo keeps legacy discovery (its source is seeded from the // bundled workspace). if (projectId === "demo") { @@ -66,7 +66,6 @@ export default async function AgentTaskSchedulesPage({ tasks={tasks} schedules={schedules} initialTaskIds={initialTaskIds} - taskRoot={TASK_ROOT} error={error} taskSource={taskSource} executorPools={executorPools} diff --git a/apps/dashboard/src/app/project/[projectId]/schedules/schedules-client.tsx b/apps/dashboard/src/app/project/[projectId]/schedules/schedules-client.tsx index 7dfc4ca1..03ee8864 100644 --- a/apps/dashboard/src/app/project/[projectId]/schedules/schedules-client.tsx +++ b/apps/dashboard/src/app/project/[projectId]/schedules/schedules-client.tsx @@ -10,7 +10,7 @@ import { } from "lucide-react"; import { type AgentTaskSummary } from "@/lib/agent-task-api"; import { type ProjectTaskSource } from "@/lib/projects-api"; -import { ProjectTaskSourceEmptyState } from "@/components/project-task-source"; + import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { type Filter, useScheduleActions } from "./useScheduleActions"; @@ -25,7 +25,6 @@ interface AgentTaskSchedulesClientProps { tasks: AgentTaskSummary[]; schedules: import("@/lib/agent-task-api").AgentTaskScheduleSummary[]; initialTaskIds: string[]; - taskRoot: string | null; error: string | null; taskSource: ProjectTaskSource | null; executorPools: ExecutorPoolSummary[]; @@ -35,7 +34,6 @@ export function AgentTaskSchedulesClient({ tasks, schedules: initialSchedules, initialTaskIds, - taskRoot, error: serverError, taskSource, executorPools, @@ -70,7 +68,9 @@ export function AgentTaskSchedulesClient({

Schedules

- +
+ Run apo task publish to publish your task catalog before scheduling. +
); } @@ -185,8 +185,6 @@ export function AgentTaskSchedulesClient({ setShowCreate(false)} onCreated={(newSchedule) => { addSchedule(newSchedule); diff --git a/apps/dashboard/src/app/project/[projectId]/tasks/[...taskId]/page.tsx b/apps/dashboard/src/app/project/[projectId]/tasks/[...taskId]/page.tsx index f3ccdff3..5271bca7 100644 --- a/apps/dashboard/src/app/project/[projectId]/tasks/[...taskId]/page.tsx +++ b/apps/dashboard/src/app/project/[projectId]/tasks/[...taskId]/page.tsx @@ -50,7 +50,7 @@ const EMPTY_TASK_RUNS: Awaited> = []; * * 1. **Demo project** → legacy filesystem discovery * (`getAgentTask`). The demo workspace is intentionally read-only and - * not backed by the SPEC-119 inventory sync state machine. + * not backed by the inventory sync state machine. * * 2. **Non-demo project** → the project-scoped inventory endpoint * (`getProjectAgentTask`) is canonical and the *first* thing tried. @@ -160,8 +160,7 @@ export default async function TaskDetailPage({ if (!task) return null; - const fileCount = (task.has_checks ? 1 : 0) + - (task.has_user_simulator ? 1 : 0) + 1; + const fileCount = (task.has_checks ? 1 : 0) + 1; return (
diff --git a/apps/dashboard/src/app/project/[projectId]/tasks/page.tsx b/apps/dashboard/src/app/project/[projectId]/tasks/page.tsx index 42c8cef2..0b16ffc8 100644 --- a/apps/dashboard/src/app/project/[projectId]/tasks/page.tsx +++ b/apps/dashboard/src/app/project/[projectId]/tasks/page.tsx @@ -3,7 +3,6 @@ import { listProjectAgentTasks, } from "@/lib/agent-task-api"; import { getProject } from "@/lib/projects-api"; -import { listExecutorPools } from "@/lib/executor-api"; import { DEMO_PROJECT } from "@/lib/project-router"; import { AgentTasksClient } from "./tasks-client"; @@ -24,28 +23,15 @@ export default async function AgentTasksPage({ let tasks: Awaited> = []; let error: string | null = null; let taskSource = null; - let executorPools: Awaited> = []; - let executorPoolsError: string | null = null; + try { try { - // Fetch the project so we can branch on task source presence. - // Access (403) and existence (404) are enforced by the project layout, - // so this only surfaces transient/network failures as inline errors. - try { - const project = await getProject(projectId); - taskSource = project.task_source; - } catch (e: unknown) { - error = e instanceof Error ? e.message : "Failed to load project"; - } + const project = await getProject(projectId); + taskSource = project.task_source; + } catch (e: unknown) { + error = e instanceof Error ? e.message : "Failed to load project"; + } - // SPEC-118: non-demo projects must NOT inherit example-service tasks - // via the legacy DEFAULT_TASK_ROOT fallback. The task list comes from - // either the project's configured source (SPEC-119 inventory) or is - // empty — which surfaces the setup card on the client. - // - // The demo project keeps the legacy discovery path because its source - // is seeded from the bundled example-service workspace and reading - // from inventory happens automatically once demo is seeded. if (!isDemo && taskSource !== null && !taskSource.inventory_stale) { try { tasks = await listProjectAgentTasks(projectId); @@ -55,15 +41,6 @@ export default async function AgentTasksPage({ } else if (isDemo) { tasks = await listAgentTasks(TASK_ROOT, undefined, projectId); } - - try { - executorPools = await listExecutorPools(projectId); - } catch (e: unknown) { - executorPoolsError = - e instanceof Error ? e.message : "Failed to load executor pools"; - } - // else: non-demo + no source → leave tasks empty so the setup card - // renders instead of leaking example-service tasks. } catch (e: unknown) { error = e instanceof Error ? e.message : "Failed to fetch agent tasks"; } @@ -74,8 +51,6 @@ export default async function AgentTasksPage({ error={error} taskSource={taskSource} isDemo={isDemo} - executorPools={executorPools} - executorPoolsError={executorPoolsError} /> ); } diff --git a/apps/dashboard/src/app/project/[projectId]/tasks/tasks-client.test.tsx b/apps/dashboard/src/app/project/[projectId]/tasks/tasks-client.test.tsx new file mode 100644 index 00000000..df5811fa --- /dev/null +++ b/apps/dashboard/src/app/project/[projectId]/tasks/tasks-client.test.tsx @@ -0,0 +1,119 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import { AgentTasksClient } from "@/app/project/[projectId]/tasks/tasks-client"; +import { + createAgentTaskBatchRun, + type AgentTaskSummary, +} from "@/lib/agent-task-api"; +import type { ProjectTaskSource } from "@/lib/projects-api"; + +vi.mock("@/lib/agent-task-api", async () => { + const actual = await vi.importActual( + "@/lib/agent-task-api", + ); + return { ...actual, createAgentTaskBatchRun: vi.fn() }; +}); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ refresh: vi.fn(), push: vi.fn() }), + useParams: () => ({ projectId: "acme-evals" }), +})); + +vi.mock("@/lib/project-router", () => ({ + useProjectId: () => "acme-evals", + useIsDemo: () => false, + DEFAULT_PROJECT: "example-service", + DEMO_PROJECT: "demo", +})); + +vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); + +const originalLocation = window.location; + +function task(overrides: Partial = {}): AgentTaskSummary { + return { + id: "support/refund", + task_path: "tasks/support/refund", + folder_path: "support", + display_name: "refund", + adapter_name: "claude-code", + has_checks: false, + tags: [], + run_stats: null, + ...overrides, + }; +} + +const taskSource = { + source_type: "published", + inventory_stale: false, +} as unknown as ProjectTaskSource; + +describe("AgentTasksClient — native source-owned Run", () => { + beforeEach(() => { + vi.clearAllMocks(); + // `window.location.href =` assignment is what the client uses to navigate + // after a successful create. jsdom throws on assignment without a setter. + Object.defineProperty(window, "location", { + value: { href: originalLocation.href }, + writable: true, + }); + }); + + it("never disables Run for a non-ready environment status", async () => { + const user = userEvent.setup(); + render( + , + ); + // Select the task first (Run is only gated on selection + permissions, + // never on environment state). + await user.click(screen.getAllByRole("checkbox")[0]); + + const runButtons = screen.getAllByRole("button", { name: /Run/i }); + expect(runButtons.every((b) => !(b as HTMLButtonElement).disabled)).toBe(true); + // No Pool selector copy surfaces. + expect(screen.queryByText(/Choose where this run should execute/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/executor pool/i)).not.toBeInTheDocument(); + }); + + it("sends only the fields the narrowed create API accepts on Run", async () => { + const user = userEvent.setup(); + vi.mocked(createAgentTaskBatchRun).mockResolvedValueOnce({ + id: "batch-1", + } as Awaited>); + const tasks = [task({ id: "support/refund" }), task({ id: "support/cancel", display_name: "cancel" })]; + render( + , + ); + + // Open the folder and select both tasks via their checkboxes. + const checkboxes = screen.getAllByRole("checkbox"); + // First checkbox is the folder select-all; selecting it picks every task. + await user.click(checkboxes[0]); + + const runButtons = screen.getAllByRole("button", { name: /Run/i }); + await user.click(runButtons[0]); + + await waitFor(() => { + expect(createAgentTaskBatchRun).toHaveBeenCalledTimes(1); + }); + const call = vi.mocked(createAgentTaskBatchRun).mock.calls[0][0]; + expect(call.task_ids).toEqual(expect.arrayContaining(["support/refund", "support/cancel"])); + // The backend model forbids extra fields, so any key beyond these 422s the + // whole request — `selection_type` and `execution_target` are derived + // server-side and must not be sent. + expect(Object.keys(call).sort()).toEqual(["project", "run_metadata", "task_ids"]); + }); +}); diff --git a/apps/dashboard/src/app/project/[projectId]/tasks/tasks-client.tsx b/apps/dashboard/src/app/project/[projectId]/tasks/tasks-client.tsx index 734c07b9..c2a6b38c 100644 --- a/apps/dashboard/src/app/project/[projectId]/tasks/tasks-client.tsx +++ b/apps/dashboard/src/app/project/[projectId]/tasks/tasks-client.tsx @@ -34,9 +34,7 @@ import { type ProjectTaskSource, syncProjectTaskSource, } from "@/lib/projects-api"; -import { ProjectTaskSourceSetupCard } from "@/components/project-task-source"; -import { ExecutorPoolSelect } from "@/components/executor-pool-select"; -import type { ExecutorPoolSummary } from "@/lib/executor-api"; + const TASK_ROOT = process.env.NEXT_PUBLIC_AGENT_TASK_ROOT ?? null; function relativePath(path: string): string { @@ -140,8 +138,6 @@ interface AgentTasksClientProps { error: string | null; taskSource: ProjectTaskSource | null; isDemo: boolean; - executorPools: ExecutorPoolSummary[]; - executorPoolsError: string | null; } function TaskCard({ @@ -257,9 +253,6 @@ function TasksToolbar({ onClearSelection, onToggleExpandAll, allExpanded, - executorPools, - selectedPoolId, - onPoolChange, }: { taskSource: ProjectTaskSource | null; isDemoProject: boolean; @@ -275,23 +268,11 @@ function TasksToolbar({ onClearSelection: () => void; onToggleExpandAll: () => void; allExpanded: boolean; - executorPools: ExecutorPoolSummary[]; - selectedPoolId: string; - onPoolChange: (value: string) => void; }) { return (
- {!isDemoProject && ( - - )} {taskSource && !isDemoProject && ( <>
-
- - {poolName ? `on ${poolName}` : "Choose where this run should execute"} - -
- @@ -513,21 +487,14 @@ export function AgentTasksClient({ error, taskSource, isDemo, - executorPools, - executorPoolsError, }: AgentTasksClientProps) { const projectId = useProjectId(); const router = useRouter(); - // Prefer the prop (canonical, server-fetched) and fall back to the - // client hook for any sub-render that might lack it. const clientIsDemo = useIsDemo(); const isDemoProject = isDemo || clientIsDemo; const [selected, setSelected] = useState>(new Set()); const [syncing, setSyncing] = useState(false); const [editingSource, setEditingSource] = useState(false); - const [selectedPoolId, setSelectedPoolId] = useState( - () => executorPools.find((pool) => pool.is_default && pool.enabled && !pool.archived)?.id ?? "", - ); const [expanded, setExpanded] = useState>(() => { const folders = groupByFolder(tasks); return new Set(folders.map((f) => f.id)); @@ -612,20 +579,13 @@ export function AgentTasksClient({ const handleRun = async () => { if (selected.size === 0 || isDemoProject) return; - if (!selectedPoolId) { - dispatchRun({ type: "ERROR", error: "Choose where this run should execute" }); - return; - } dispatchRun({ type: "START" }); try { const selectedTasks = tasks.filter((t) => selected.has(t.id)); - const selectedPaths = selectedTasks.map((t) => t.task_path); + const taskIds = selectedTasks.map((t) => t.id); const result = await createAgentTaskBatchRun({ project: projectId, - selection_type: selectedPaths.length === 1 ? "task" : "tasks", - task_paths: selectedPaths, - task_root: TASK_ROOT, - execution_target: { kind: "pool", pool_id: selectedPoolId }, + task_ids: taskIds, run_metadata: { trigger: { source: "dashboard", @@ -659,14 +619,7 @@ export function AgentTasksClient({
{editingSource && taskSource && !isDemoProject ? (
- setEditingSource(false)} - /> -
+
)} - {!error && !runState.error && executorPoolsError && ( -
- Executor pools could not be loaded: {executorPoolsError} -
- )} - {!isDemoProject && !executorPoolsError && executorPools.filter((pool) => pool.enabled && !pool.archived).length === 0 && ( -
- No executor pool is available. Configure a pool before starting a run. -
- )} {/* Empty state */} {!error && tasks.length === 0 && ( @@ -759,7 +699,6 @@ export function AgentTasksClient({ selectedCount={selected.size} runRunning={runState.running} isDemoProject={isDemoProject} - poolName={executorPools.find((pool) => pool.id === selectedPoolId)?.name ?? null} onClear={() => setSelected(new Set())} onRun={handleRun} /> diff --git a/apps/dashboard/src/app/project/[projectId]/traces/SessionsTablePanel.tsx b/apps/dashboard/src/app/project/[projectId]/traces/SessionsTablePanel.tsx index e8ff08d6..0a0657b1 100644 --- a/apps/dashboard/src/app/project/[projectId]/traces/SessionsTablePanel.tsx +++ b/apps/dashboard/src/app/project/[projectId]/traces/SessionsTablePanel.tsx @@ -37,7 +37,7 @@ import { } from "@tanstack/react-table"; import { DataTablePagination, ColumnResizeHandle, getPinnedColumnStyle, getPinnedColumnAttrs } from "@/components/table"; import { usePersistentTablePreferences } from "@/hooks/use-persistent-table-preferences"; -import { usdFormat, tokenFormat } from "@/lib/format"; +import { formatCostMicro, tokenFormat } from "@/lib/format"; import type { TraceSessionSummary } from "@/lib/traces-api"; interface PaginationData { @@ -125,7 +125,7 @@ function createSessionColumns(): ColumnDef[] { if (!cost || cost <= 0) return —; return ( - {usdFormat(cost)} + {formatCostMicro(cost)} ); }, diff --git a/apps/dashboard/src/app/project/[projectId]/traces/[runId]/page.tsx b/apps/dashboard/src/app/project/[projectId]/traces/[runId]/page.tsx index b60c619a..81223060 100644 --- a/apps/dashboard/src/app/project/[projectId]/traces/[runId]/page.tsx +++ b/apps/dashboard/src/app/project/[projectId]/traces/[runId]/page.tsx @@ -1,4 +1,4 @@ -import { Suspense } from "react"; +import { Suspense, cache } from "react"; import Link from "next/link"; import { ArrowLeft } from "lucide-react"; import type { Metadata } from "next"; @@ -10,6 +10,10 @@ import { export const dynamic = "force-dynamic"; +// Per-request memo so generateMetadata and the page body share one backend +// fetch — the trace detail payload is large (every call's full input/output). +const getTraceDetailCached = cache(getTraceDetail); + // Tab title: "Trace ". Falls back to "Trace" on fetch failure. export async function generateMetadata({ params, @@ -18,7 +22,7 @@ export async function generateMetadata({ }): Promise { const { projectId, runId } = await params; try { - const detail = await getTraceDetail(runId, projectId); + const detail = await getTraceDetailCached(runId, projectId); return { title: `Trace ${detail.run.id.slice(0, 8)}` }; } catch { return { title: "Trace" }; @@ -34,14 +38,17 @@ export default async function TraceDetailPage({ }) { const { projectId, runId } = await params; const { sort_by, sort_order } = await searchParams; - let trace: TraceDetail | null = null; let error: string | null = null; - try { - trace = await getTraceDetail(runId, projectId); - } catch (e: unknown) { - error = e instanceof Error ? e.message : "Failed to fetch trace details"; - } + // Adjacent-trace lookup is independent of the detail fetch (and swallows + // its own errors), so run both in parallel instead of as a waterfall. + const [trace, adjacent] = await Promise.all([ + getTraceDetailCached(runId, projectId).catch((e: unknown): TraceDetail | null => { + error = e instanceof Error ? e.message : "Failed to fetch trace details"; + return null; + }), + getAdjacentTraces(runId, sort_by, sort_order, projectId), + ]); if (error) { return ( @@ -58,8 +65,6 @@ export default async function TraceDetailPage({ return null; } - const adjacent = await getAdjacentTraces(runId, sort_by, sort_order, projectId); - return (
{trace.run.task_run_id && ( diff --git a/apps/dashboard/src/app/project/[projectId]/traces/columns.tsx b/apps/dashboard/src/app/project/[projectId]/traces/columns.tsx index 6c3db3b2..1f75bf24 100644 --- a/apps/dashboard/src/app/project/[projectId]/traces/columns.tsx +++ b/apps/dashboard/src/app/project/[projectId]/traces/columns.tsx @@ -118,7 +118,7 @@ function UsageCell({ metrics, primaryModel, sortedCosts }: { metrics: TraceMetri const prompt = getMetric(metrics, "prompt_tokens"); const completion = getMetric(metrics, "completion_tokens"); const total = getMetric(metrics, "total_tokens") ?? (prompt != null && completion != null ? prompt + completion : null); - // SPEC-136: cost is now a stored micro-USD int (no client-side pricing fetch). + // cost is now a stored micro-USD int (no client-side pricing fetch). const cost = getMetric(metrics, "total_cost"); if (total == null && cost == null) return {"\u2014"}; diff --git a/apps/dashboard/src/app/public/traces/[runId]/page.tsx b/apps/dashboard/src/app/public/traces/[runId]/page.tsx deleted file mode 100644 index 4c333f32..00000000 --- a/apps/dashboard/src/app/public/traces/[runId]/page.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { Suspense } from "react"; -import type { Metadata } from "next"; -import { getPublicTrace, type TraceDetail } from "@/lib/traces-api"; -import { UrlSelectionProvider, TraceWorkspace } from "@/components/trace-detail"; -import { PublicTraceHeader } from "./public-trace-header"; - -export const dynamic = "force-dynamic"; - -// Tab title: "Public trace ". Falls back to "Public trace" on -// fetch failure or when the trace is no longer public. -export async function generateMetadata({ - params, -}: { - params: Promise<{ runId: string }>; -}): Promise { - const { runId } = await params; - try { - const trace = await getPublicTrace(runId); - return { - title: trace ? `Public trace ${trace.run.id.slice(0, 8)}` : "Public trace", - }; - } catch { - return { title: "Public trace" }; - } -} - -export default async function PublicTracePage({ - params, -}: { - params: Promise<{ runId: string }>; -}) { - const { runId } = await params; - let trace: TraceDetail | null = null; - let error: string | null = null; - - try { - trace = await getPublicTrace(runId); - } catch (e: unknown) { - error = e instanceof Error ? e.message : "Failed to fetch trace details"; - } - - if (error) { - return ( - - ); - } - - if (!trace) { - return ( - - ); - } - - return ( -
- -
- - - - - -
-
- ); -} - -function PublicTraceError({ message, hint }: { message: string; hint: string }) { - return ( -
-
-

{message}

-

{hint}

-
-
- ); -} diff --git a/apps/dashboard/src/app/public/traces/[runId]/public-trace-header.tsx b/apps/dashboard/src/app/public/traces/[runId]/public-trace-header.tsx deleted file mode 100644 index e9aa54df..00000000 --- a/apps/dashboard/src/app/public/traces/[runId]/public-trace-header.tsx +++ /dev/null @@ -1,28 +0,0 @@ -"use client"; - -import { BrandMark } from "@/components/brand/brand-mark"; -import { Globe } from "lucide-react"; - -/** - * Minimal top bar for the public (unauthenticated) trace view. - * - * The main app shell (TopNav) assumes a session and would redirect logged-out - * viewers to /login, so the public page renders its own slim header instead. - * Deliberately shows only the brand mark + a "Public trace" indicator — the - * trace name itself is already shown by the workspace's detail header, so - * repeating it here would double it up. - */ -export function PublicTraceHeader() { - return ( -
- - - - Public trace - -
- ); -} diff --git a/apps/dashboard/src/app/settings/executors/__tests__/executor-settings.test.tsx b/apps/dashboard/src/app/settings/executors/__tests__/executor-settings.test.tsx deleted file mode 100644 index b8c055c3..00000000 --- a/apps/dashboard/src/app/settings/executors/__tests__/executor-settings.test.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import type { - EnrollmentTokenResponse, - ExecutorPoolSummary, -} from "@/lib/executor-api"; -import { EnrollmentDialog } from "../enrollment-dialog"; -import { PoolList } from "../pool-list"; - -const POOL: ExecutorPoolSummary = { - id: "pool-private", - name: "Private VPC", - slug: "private-vpc", - kind: "connected", - enabled: true, - archived: false, - is_default: false, - health: "online", - online_executor_count: 1, - available_capacity: 2, - queue_ttl_seconds: 86_400, - required_driver_kind: "subprocess", -}; - -const ENROLLMENT: EnrollmentTokenResponse = { - id: "token-1", - pool_id: "pool-private", - token: "apo_enroll_secret-once", - expires_at: "2026-07-26T12:15:00Z", - container: { - image: "ghcr.io/samikuikka/apo-backend:0.2.0", - command: ["python", "-m", "apo.executor", "connect"], - environment: { - APO_CONTROL_PLANE_URL: "https://apo.example", - APO_EXECUTOR_ENROLLMENT_TOKEN: "apo_enroll_secret-once", - APO_EXECUTOR_NAME: "private-vpc-1", - }, - state_volume: "/var/lib/apo-executor", - }, -}; - -describe("Executor settings", () => { - it("keeps Pool mutations hidden from Project members", () => { - renderPoolList({ canManage: false, canArchive: false }); - expect(screen.getByText("Private VPC")).toBeInTheDocument(); - expect(screen.queryByRole("button", { name: "Edit" })).not.toBeInTheDocument(); - expect(screen.queryByRole("button", { name: "Create token" })).not.toBeInTheDocument(); - expect(screen.queryByRole("button", { name: "Archive" })).not.toBeInTheDocument(); - }); - - it("shows admin controls without exposing owner-only archive", () => { - renderPoolList({ canManage: true, canArchive: false }); - expect(screen.getByRole("button", { name: "Edit" })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Create token" })).toBeInTheDocument(); - expect(screen.queryByRole("button", { name: "Archive" })).not.toBeInTheDocument(); - }); - - it("renders and copies the exact version-pinned enrollment command once", async () => { - const user = userEvent.setup(); - const writeText = vi.fn().mockResolvedValue(undefined); - Object.defineProperty(navigator, "clipboard", { - configurable: true, - value: { writeText }, - }); - const persist = vi.spyOn(Storage.prototype, "setItem"); - - const revoke = vi.fn(); - render( - , - ); - expect(screen.getByText(/apo-backend:0\.2\.0/)).toBeInTheDocument(); - expect(screen.getByText(/apo_enroll_secret-once/)).toBeInTheDocument(); - - await user.click(screen.getByRole("button", { - name: "Copy Executor Docker command", - })); - expect(writeText).toHaveBeenCalledWith(expect.stringContaining( - "ghcr.io/samikuikka/apo-backend:0.2.0", - )); - expect(persist).not.toHaveBeenCalled(); - await user.click(screen.getByRole("button", { - name: "Revoke unused token", - })); - expect(revoke).toHaveBeenCalledOnce(); - persist.mockRestore(); - }); -}); - -function renderPoolList({ - canManage, - canArchive, -}: { - canManage: boolean; - canArchive: boolean; -}) { - return render( - , - ); -} diff --git a/apps/dashboard/src/app/settings/executors/create-pool-dialog.tsx b/apps/dashboard/src/app/settings/executors/create-pool-dialog.tsx deleted file mode 100644 index d354187b..00000000 --- a/apps/dashboard/src/app/settings/executors/create-pool-dialog.tsx +++ /dev/null @@ -1,86 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; - -interface CreatePoolDialogProps { - busy: boolean; - onClose: () => void; - onCreate: (body: { - name: string; - slug: string; - queue_ttl_seconds: number; - }) => void; -} - -export function CreatePoolDialog({ - busy, - onClose, - onCreate, -}: CreatePoolDialogProps) { - const [name, setName] = useState(""); - const [slug, setSlug] = useState(""); - - return ( - { if (!open) onClose(); }}> - - Create Connected Pool -
-
- - { - setName(event.target.value); - setSlug(slugify(event.target.value)); - }} - placeholder="Production VPC" - /> -
-
- - setSlug(event.target.value)} - className="font-mono" - placeholder="production-vpc" - /> -
-
- - - - -
-
- ); -} - -function slugify(value: string): string { - return value - .toLowerCase() - .trim() - .replaceAll(/[^a-z0-9]+/g, "-") - .replaceAll(/^-|-$/g, "") - .slice(0, 63); -} diff --git a/apps/dashboard/src/app/settings/executors/edit-pool-dialog.tsx b/apps/dashboard/src/app/settings/executors/edit-pool-dialog.tsx deleted file mode 100644 index ade03a13..00000000 --- a/apps/dashboard/src/app/settings/executors/edit-pool-dialog.tsx +++ /dev/null @@ -1,81 +0,0 @@ -"use client"; - -import { useState } from "react"; -import type { ExecutorPoolSummary } from "@/lib/executor-api"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; - -export function EditPoolDialog({ - pool, - busy, - onClose, - onSave, -}: { - pool: ExecutorPoolSummary; - busy: boolean; - onClose: () => void; - onSave: (patch: { name: string; queue_ttl_seconds: number }) => void; -}) { - const [name, setName] = useState(pool.name); - const [queueTtlSeconds, setQueueTtlSeconds] = useState(pool.queue_ttl_seconds); - - return ( - { if (!open) onClose(); }}> - - - Edit {pool.name} - -
-
- - setName(event.target.value)} - /> -
-
- - -
-
- - - - -
-
- ); -} diff --git a/apps/dashboard/src/app/settings/executors/enrollment-dialog.tsx b/apps/dashboard/src/app/settings/executors/enrollment-dialog.tsx deleted file mode 100644 index a129113c..00000000 --- a/apps/dashboard/src/app/settings/executors/enrollment-dialog.tsx +++ /dev/null @@ -1,96 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { Check, Copy } from "lucide-react"; -import type { EnrollmentTokenResponse } from "@/lib/executor-api"; -import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; - -export function EnrollmentDialog({ - enrollment, - busy, - onClose, - onRevoke, -}: { - enrollment: EnrollmentTokenResponse; - busy: boolean; - onClose: () => void; - onRevoke: () => void; -}) { - const [copied, setCopied] = useState(false); - const command = buildDockerCommand(enrollment); - - async function copyCommand() { - await navigator.clipboard.writeText(command); - setCopied(true); - window.setTimeout(() => setCopied(false), 2_000); - } - - return ( - { if (!open) onClose(); }}> - - - Connect an Executor - - This enrollment token is shown once and expires{" "} - {new Date(enrollment.expires_at).toLocaleString()}. - - -
-

- Copy the command before closing. Apo cannot recover this raw token. -

-
-            {command}
-          
- -
- - - - -
-
- ); -} - -function buildDockerCommand(enrollment: EnrollmentTokenResponse): string { - const environment = Object.entries(enrollment.container.environment) - .map(([name, value]) => `-e ${name}=${shellQuote(value)}`) - .join(" \\\n "); - const command = enrollment.container.command.map(shellQuote).join(" "); - return [ - "docker run -d --name apo-executor \\", - ` -v apo-executor-state:${enrollment.container.state_volume} \\`, - ` ${environment} \\`, - ` ${shellQuote(enrollment.container.image)} ${command}`, - ].join("\n"); -} - -function shellQuote(value: string): string { - return `'${value.replaceAll("'", "'\"'\"'")}'`; -} diff --git a/apps/dashboard/src/app/settings/executors/executor-list.tsx b/apps/dashboard/src/app/settings/executors/executor-list.tsx deleted file mode 100644 index 20fa6d61..00000000 --- a/apps/dashboard/src/app/settings/executors/executor-list.tsx +++ /dev/null @@ -1,70 +0,0 @@ -"use client"; - -import type { ExecutorSummary } from "@/lib/executor-api"; -import { Button } from "@/components/ui/button"; -import { cn } from "@/lib/utils"; - -export function ExecutorList({ - executors, - poolId, - canManage, - busy, - onRename, - onRevoke, -}: { - executors: ExecutorSummary[]; - poolId: string; - canManage: boolean; - busy: boolean; - onRename: (executor: ExecutorSummary) => void; - onRevoke: (executor: ExecutorSummary) => void; -}) { - const visible = executors.filter((executor) => executor.pool_id === poolId); - - return ( -
-

- Executors -

- {visible.length === 0 ? ( -
- No enrolled Executors in this pool. -
- ) : ( -
- {visible.map((executor) => ( -
-
-
- - {executor.name} - {executor.status} -
-

- v{executor.executor_version} · protocol {executor.protocol_version} · {executor.os}/{executor.architecture} · {executor.active_attempts}/{executor.max_concurrency} active -

-
- {canManage && executor.status !== "disabled" && ( -
- - -
- )} -
- ))} -
- )} -
- ); -} - -function statusDot(status: string): string { - if (status === "online") return "bg-success"; - if (status === "busy") return "bg-warning"; - if (status === "disabled") return "bg-destructive"; - return "bg-muted-foreground"; -} diff --git a/apps/dashboard/src/app/settings/executors/executors-client.tsx b/apps/dashboard/src/app/settings/executors/executors-client.tsx deleted file mode 100644 index 7910a70b..00000000 --- a/apps/dashboard/src/app/settings/executors/executors-client.tsx +++ /dev/null @@ -1,250 +0,0 @@ -"use client"; - -import { useCallback, useEffect, useReducer } from "react"; -import { Plus } from "lucide-react"; -import { - archiveExecutorPool, - createEnrollmentToken, - createExecutorPool, - listExecutorPools, - listExecutors, - patchExecutorPool, - renameExecutor, - revokeEnrollmentToken, - revokeExecutor, - setDefaultExecutorPool, -} from "@/lib/executor-api"; -import { getProject, listProjects } from "@/lib/projects-api"; -import { Button } from "@/components/ui/button"; -import { CreatePoolDialog } from "./create-pool-dialog"; -import { EditPoolDialog } from "./edit-pool-dialog"; -import { EnrollmentDialog } from "./enrollment-dialog"; -import { ExecutorList } from "./executor-list"; -import { PoolList } from "./pool-list"; -import { RenameExecutorDialog } from "./rename-executor-dialog"; -import { INITIAL_EXECUTORS_PAGE_STATE, executorsPageReducer } from "./executors-page-state"; - -export function ExecutorsClient() { - const [state, dispatch] = useReducer(executorsPageReducer, INITIAL_EXECUTORS_PAGE_STATE); - const { - projects, - projectId, - role, - pools, - executors, - selectedPoolId, - loading, - busy, - error, - showCreate, - enrollment, - editingPool, - renamingExecutor, - } = state; - - useEffect(() => { - const controller = new AbortController(); - listProjects(controller.signal) - .then((items) => { - const writable = items.filter((project) => project.id !== "demo"); - dispatch({ type: "projects-loaded", projects: writable }); - }) - .catch((caught: unknown) => { - if (!controller.signal.aborted) { - dispatch({ - type: "failed", - error: caught instanceof Error ? caught.message : "Failed to load projects", - }); - } - }); - return () => controller.abort(); - }, []); - - const loadProject = useCallback(async (signal?: AbortSignal) => { - if (!projectId) { - dispatch({ type: "loading", loading: false }); - return; - } - dispatch({ type: "loading", loading: true }); - try { - const [project, nextPools, nextExecutors] = await Promise.all([ - getProject(projectId, signal), - listExecutorPools(projectId, signal), - listExecutors(projectId, signal), - ]); - if (signal?.aborted) return; - dispatch({ - type: "executor-state-loaded", - role: project.current_user_role, - pools: nextPools, - executors: nextExecutors, - }); - } catch (caught: unknown) { - if (!signal?.aborted) { - dispatch({ - type: "failed", - error: caught instanceof Error ? caught.message : "Failed to load Executors", - }); - } - } finally { - if (!signal?.aborted) dispatch({ type: "loading", loading: false }); - } - }, [projectId]); - - useEffect(() => { - const controller = new AbortController(); - void loadProject(controller.signal); - return () => controller.abort(); - }, [loadProject]); - - const mutate = async (action: () => Promise) => { - dispatch({ type: "busy", busy: true }); - try { - await action(); - await loadProject(); - } catch (caught: unknown) { - dispatch({ - type: "failed", - error: caught instanceof Error ? caught.message : "Executor operation failed", - }); - } finally { - dispatch({ type: "busy", busy: false }); - } - }; - - const canManage = role === "owner" || role === "admin"; - const canArchive = role === "owner"; - - return ( -
-
-
- - -
- {canManage && ( - - )} -
- - {error && ( -
- {error} -
- )} - - {loading ? ( -

Loading Executor state…

- ) : ( - <> - dispatch({ type: "pool-selected", poolId })} - onSetDefault={(pool) => void mutate(() => setDefaultExecutorPool(projectId, pool.id))} - onCreateToken={(pool) => { - dispatch({ type: "busy", busy: true }); - createEnrollmentToken(projectId, pool.id) - .then((value) => dispatch({ type: "enrollment", enrollment: value })) - .catch((caught: unknown) => dispatch({ - type: "failed", - error: caught instanceof Error ? caught.message : "Failed to create token", - })) - .finally(() => dispatch({ type: "busy", busy: false })); - }} - onEdit={(pool) => dispatch({ type: "edit-pool", pool })} - onToggle={(pool) => void mutate(() => patchExecutorPool(projectId, pool.id, { enabled: !pool.enabled }))} - onArchive={(pool) => { - if (window.confirm(`Archive ${pool.name}? Its schedules will be disabled and it cannot accept new work.`)) { - void mutate(() => archiveExecutorPool(projectId, pool.id)); - } - }} - /> - {selectedPoolId && ( - dispatch({ type: "rename-executor", executor })} - onRevoke={(executor) => { - if (window.confirm(`Revoke ${executor.name}? Running work becomes lost and cannot be proven stopped.`)) { - void mutate(() => revokeExecutor(projectId, executor.id)); - } - }} - /> - )} -
- Bundled Executors run as a separate trusted apo process. Connected Executors run in your environment using outbound HTTPS. - The subprocess driver separates Tasks from the API process, but it is not a hostile-code sandbox. -
- - )} - - {showCreate && ( - dispatch({ type: "create-dialog", open: false })} - onCreate={(body) => void mutate(async () => { - const pool = await createExecutorPool(projectId, body); - dispatch({ type: "pool-selected", poolId: pool.id }); - dispatch({ type: "create-dialog", open: false }); - })} - /> - )} - {enrollment && ( - dispatch({ type: "enrollment", enrollment: null })} - onRevoke={() => void mutate(async () => { - await revokeEnrollmentToken(projectId, enrollment.pool_id, enrollment.id); - dispatch({ type: "enrollment", enrollment: null }); - })} - /> - )} - {editingPool && ( - dispatch({ type: "edit-pool", pool: null })} - onSave={(patch) => void mutate(async () => { - await patchExecutorPool(projectId, editingPool.id, patch); - dispatch({ type: "edit-pool", pool: null }); - })} - /> - )} - {renamingExecutor && ( - dispatch({ type: "rename-executor", executor: null })} - onRename={(name) => void mutate(async () => { - await renameExecutor(projectId, renamingExecutor.id, name); - dispatch({ type: "rename-executor", executor: null }); - })} - /> - )} -
- ); -} diff --git a/apps/dashboard/src/app/settings/executors/executors-page-state.ts b/apps/dashboard/src/app/settings/executors/executors-page-state.ts deleted file mode 100644 index 6144813d..00000000 --- a/apps/dashboard/src/app/settings/executors/executors-page-state.ts +++ /dev/null @@ -1,123 +0,0 @@ -import type { - EnrollmentTokenResponse, - ExecutorPoolSummary, - ExecutorSummary, -} from "@/lib/executor-api"; -import type { Project } from "@/lib/projects-api"; - -export interface ExecutorsPageState { - projects: Project[]; - projectId: string; - role: string | null; - pools: ExecutorPoolSummary[]; - executors: ExecutorSummary[]; - selectedPoolId: string; - loading: boolean; - busy: boolean; - error: string | null; - showCreate: boolean; - enrollment: EnrollmentTokenResponse | null; - editingPool: ExecutorPoolSummary | null; - renamingExecutor: ExecutorSummary | null; -} - -export type ExecutorsPageAction = - | { type: "projects-loaded"; projects: Project[] } - | { type: "project-selected"; projectId: string } - | { type: "loading"; loading: boolean } - | { - type: "executor-state-loaded"; - role: string | null; - pools: ExecutorPoolSummary[]; - executors: ExecutorSummary[]; - } - | { type: "failed"; error: string } - | { type: "busy"; busy: boolean } - | { type: "pool-selected"; poolId: string } - | { type: "create-dialog"; open: boolean } - | { type: "enrollment"; enrollment: EnrollmentTokenResponse | null } - | { type: "edit-pool"; pool: ExecutorPoolSummary | null } - | { type: "rename-executor"; executor: ExecutorSummary | null }; - -export const INITIAL_EXECUTORS_PAGE_STATE: ExecutorsPageState = { - projects: [], - projectId: "", - role: null, - pools: [], - executors: [], - selectedPoolId: "", - loading: true, - busy: false, - error: null, - showCreate: false, - enrollment: null, - editingPool: null, - renamingExecutor: null, -}; - -export function executorsPageReducer( - state: ExecutorsPageState, - action: ExecutorsPageAction, -): ExecutorsPageState { - switch (action.type) { - case "projects-loaded": - return { - ...state, - projects: action.projects, - projectId: state.projectId || action.projects[0]?.id || "", - }; - case "project-selected": - return { - ...state, - projectId: action.projectId, - selectedPoolId: "", - role: null, - }; - case "loading": - return { - ...state, - loading: action.loading, - error: action.loading ? null : state.error, - }; - case "executor-state-loaded": - return loadExecutorState(state, action); - case "failed": - return { ...state, error: action.error, loading: false }; - case "busy": - return { - ...state, - busy: action.busy, - error: action.busy ? null : state.error, - }; - case "pool-selected": - return { ...state, selectedPoolId: action.poolId }; - case "create-dialog": - return { ...state, showCreate: action.open }; - case "enrollment": - return { ...state, enrollment: action.enrollment }; - case "edit-pool": - return { ...state, editingPool: action.pool }; - case "rename-executor": - return { ...state, renamingExecutor: action.executor }; - } -} - -function loadExecutorState( - state: ExecutorsPageState, - action: Extract, -): ExecutorsPageState { - const selectedPoolId = action.pools.some( - (pool) => pool.id === state.selectedPoolId, - ) - ? state.selectedPoolId - : action.pools.find((pool) => pool.is_default)?.id - ?? action.pools[0]?.id - ?? ""; - return { - ...state, - role: action.role, - pools: action.pools, - executors: action.executors, - selectedPoolId, - }; -} diff --git a/apps/dashboard/src/app/settings/executors/page.tsx b/apps/dashboard/src/app/settings/executors/page.tsx deleted file mode 100644 index eca8f3f9..00000000 --- a/apps/dashboard/src/app/settings/executors/page.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { ServerCog } from "lucide-react"; -import { SettingsPageHeader } from "@/components/settings/page-header"; -import { ExecutorsClient } from "./executors-client"; - -export const metadata = { title: "Executors" }; - -export default function ExecutorsSettingsPage() { - return ( - <> - - - - ); -} diff --git a/apps/dashboard/src/app/settings/executors/pool-list.tsx b/apps/dashboard/src/app/settings/executors/pool-list.tsx deleted file mode 100644 index b8acfa62..00000000 --- a/apps/dashboard/src/app/settings/executors/pool-list.tsx +++ /dev/null @@ -1,115 +0,0 @@ -"use client"; - -import { Archive, KeyRound, Pencil, Power, Star } from "lucide-react"; -import type { ExecutorPoolSummary } from "@/lib/executor-api"; -import { Button } from "@/components/ui/button"; -import { Badge } from "@/components/ui/badge"; -import { cn } from "@/lib/utils"; - -export function PoolList({ - pools, - selectedPoolId, - canManage, - canArchive, - busy, - onSelect, - onSetDefault, - onCreateToken, - onEdit, - onToggle, - onArchive, -}: { - pools: ExecutorPoolSummary[]; - selectedPoolId: string; - canManage: boolean; - canArchive: boolean; - busy: boolean; - onSelect: (poolId: string) => void; - onSetDefault: (pool: ExecutorPoolSummary) => void; - onCreateToken: (pool: ExecutorPoolSummary) => void; - onEdit: (pool: ExecutorPoolSummary) => void; - onToggle: (pool: ExecutorPoolSummary) => void; - onArchive: (pool: ExecutorPoolSummary) => void; -}) { - if (pools.length === 0) { - return ( -
-

No executor pools

-

- Connect an Executor to run dashboard Tasks and schedules in your environment. - CLI Task runs can still use the caller. -

-
- ); - } - - return ( -
- {pools.map((pool) => ( -
- - {canManage && !pool.archived && ( -
- - {!pool.is_default && pool.enabled && ( - - )} - {pool.kind === "connected" && pool.enabled && ( - - )} - - {canArchive && pool.kind === "connected" && ( - - )} -
- )} -
- ))} -
- ); -} - -function formatTtl(seconds: number): string { - if (seconds % 86_400 === 0) return `${seconds / 86_400}d`; - if (seconds % 3_600 === 0) return `${seconds / 3_600}h`; - return `${seconds}s`; -} - -function healthColor(health: ExecutorPoolSummary["health"]): string { - if (health === "online") return "text-success"; - if (health === "busy" || health === "incompatible") return "text-warning"; - if (health === "disabled") return "text-destructive"; - return "text-muted-foreground"; -} diff --git a/apps/dashboard/src/app/settings/executors/rename-executor-dialog.tsx b/apps/dashboard/src/app/settings/executors/rename-executor-dialog.tsx deleted file mode 100644 index a7d2f623..00000000 --- a/apps/dashboard/src/app/settings/executors/rename-executor-dialog.tsx +++ /dev/null @@ -1,61 +0,0 @@ -"use client"; - -import { useState } from "react"; -import type { ExecutorSummary } from "@/lib/executor-api"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; - -export function RenameExecutorDialog({ - executor, - busy, - onClose, - onRename, -}: { - executor: ExecutorSummary; - busy: boolean; - onClose: () => void; - onRename: (name: string) => void; -}) { - const [name, setName] = useState(executor.name); - const normalizedName = name.trim(); - - return ( - { if (!open) onClose(); }}> - - - Rename Executor - -
- - setName(event.target.value)} - autoFocus - /> -
- - - - -
-
- ); -} diff --git a/apps/dashboard/src/app/settings/nav-config.ts b/apps/dashboard/src/app/settings/nav-config.ts index 2157ab8a..1e35eb91 100644 --- a/apps/dashboard/src/app/settings/nav-config.ts +++ b/apps/dashboard/src/app/settings/nav-config.ts @@ -7,7 +7,7 @@ * `app/settings//page.tsx` to exist — which prevents the * "click Settings → 404" regression this module was introduced to guard. */ -import { KeyRound, LucideIcon, MonitorSmartphone, ServerCog, Settings, User, Users } from "lucide-react"; +import { KeyRound, LucideIcon, MonitorSmartphone, Settings, User, Users } from "lucide-react"; export type SettingsNavItem = { label: string; @@ -22,7 +22,7 @@ export function settingsHref(item: SettingsNavItem): string { return `/settings/${item.segment}`; } -// SPEC-122: instance-maintenance screens stay hidden/dev-only. They are +// instance-maintenance screens stay hidden/dev-only. They are // not part of normal product roles. Project member management lives // alongside API Keys in the project settings group below. export const PERSONAL_ITEMS: SettingsNavItem[] = [ @@ -32,7 +32,6 @@ export const PERSONAL_ITEMS: SettingsNavItem[] = [ export const PROJECT_ITEMS: SettingsNavItem[] = [ { label: "API Keys", segment: "api-keys", icon: KeyRound }, - { label: "Executors", segment: "executors", icon: ServerCog }, { label: "Members", segment: "members", icon: Users }, ]; diff --git a/apps/dashboard/src/components/__tests__/connected-environment-status.test.tsx b/apps/dashboard/src/components/__tests__/connected-environment-status.test.tsx new file mode 100644 index 00000000..c2b861b5 --- /dev/null +++ b/apps/dashboard/src/components/__tests__/connected-environment-status.test.tsx @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { ConnectedEnvironmentStatusView } from "@/components/connected-environment-status"; + +describe("ConnectedEnvironmentStatusView (SPEC-162)", () => { + const cases: Array<{ + state: Parameters[0]["state"]; + label: string; + tooltip: string; + }> = [ + { state: "ready", label: "Connected", tooltip: "Your connected environment is ready" }, + { state: "busy", label: "Busy", tooltip: "Your connected environment is busy" }, + { state: "offline", label: "Offline", tooltip: "Waiting for apo connect" }, + { state: "not_connected", label: "Not connected", tooltip: "Run apo connect" }, + { state: "incompatible", label: "Incompatible", tooltip: "Update the Apo CLI" }, + { state: "catalog_mismatch", label: "Catalog mismatch", tooltip: "apo task publish" }, + ]; + + it.each(cases)("renders compact label '$label' for state $state", ({ state, label }) => { + render(); + expect(screen.getByText(label)).toBeInTheDocument(); + }); + + it.each(cases)("puts full guidance in the tooltip for state $state", ({ state, tooltip, label }) => { + render(); + const el = screen.getByText(label); + expect(el.closest("[title]")?.getAttribute("title")).toContain(tooltip); + }); + + it("renders a status dot", () => { + const { container } = render(); + const dot = container.querySelector(".bg-success"); + expect(dot).toBeTruthy(); + }); +}); diff --git a/apps/dashboard/src/components/admin/__tests__/api-key-create-dialog.test.tsx b/apps/dashboard/src/components/admin/__tests__/api-key-create-dialog.test.tsx new file mode 100644 index 00000000..63b8b85f --- /dev/null +++ b/apps/dashboard/src/components/admin/__tests__/api-key-create-dialog.test.tsx @@ -0,0 +1,137 @@ +/** + * Issue #73: the Create API key dialog must offer the user's accessible + * projects as a selector instead of a free-text Project ID input, and must + * default to the project currently selected in the API Keys section. + * + * Tests cover: default selection, initial fallback to the first project, + * changing selection + the exact Project ID submitted, and the no-project + * empty state. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +vi.mock("@/lib/api-keys-api", () => ({ + createApiKey: vi.fn(), +})); + +vi.mock("sonner", () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})); + +import { createApiKey } from "@/lib/api-keys-api"; +import { ApiKeyCreateDialog } from "@/components/admin/api-key-create-dialog"; +import type { Project } from "@/lib/projects-api"; + +function project(overrides: Partial = {}): Project { + return { + id: "proj-a", + name: "Alpha", + created_by: "u1", + created_at: "2026-07-01", + current_user_role: "admin", + ...overrides, + }; +} + +const reveal = { + id: "k1", + name: "Production", + prefix: "pk-apo-", + project: "proj-a", + created_by: "u1", + scope: "ingest", + created_at: "2026-07-30T00:00:00", + last_used_at: null, + expires_at: null, + publicKey: "pk-apo-abc", + secretKey: "sk-apo-secret", + displaySecretKey: "sk-apo-se••", +}; + +beforeEach(() => { + vi.mocked(createApiKey).mockReset(); + vi.mocked(createApiKey).mockResolvedValue(reveal); +}); + +describe("ApiKeyCreateDialog project selector (issue #73)", () => { + it("selects the parent's current project by default", () => { + const projects = [ + project({ id: "proj-a", name: "Alpha" }), + project({ id: "proj-b", name: "Beta" }), + ]; + render( + , + ); + + const select = screen.getByLabelText("Project") as HTMLSelectElement; + expect(select.value).toBe("proj-b"); + }); + + it("falls back to the first accessible project when none is passed", () => { + const projects = [ + project({ id: "proj-a", name: "Alpha" }), + project({ id: "proj-b", name: "Beta" }), + ]; + render( + , + ); + + const select = screen.getByLabelText("Project") as HTMLSelectElement; + expect(select.value).toBe("proj-a"); + }); + + it("submits the selected Project ID (not the display name)", async () => { + const user = userEvent.setup(); + const projects = [ + project({ id: "proj-a", name: "Alpha" }), + project({ id: "proj-b", name: "Beta" }), + ]; + render( + , + ); + + await user.selectOptions(screen.getByLabelText("Project"), "proj-b"); + await user.click(screen.getByRole("button", { name: "Create key" })); + + expect(createApiKey).toHaveBeenCalledWith( + "Default", + "proj-b", + "ingest", + undefined, + ); + }); + + it("disables creation and shows an empty state when there are no eligible projects", () => { + render( + , + ); + + expect(screen.getByLabelText("Project")).toBeDisabled(); + expect( + screen.getByRole("button", { name: "Create key" }), + ).toBeDisabled(); + }); +}); diff --git a/apps/dashboard/src/components/admin/__tests__/api-keys-section.test.tsx b/apps/dashboard/src/components/admin/__tests__/api-keys-section.test.tsx new file mode 100644 index 00000000..1e83b530 --- /dev/null +++ b/apps/dashboard/src/components/admin/__tests__/api-keys-section.test.tsx @@ -0,0 +1,48 @@ +/** + * Issue #73: on initial load the API Keys section must use the first + * accessible project when the user has not explicitly picked one. The + * previous code used ``selectedProject ?? projects[0]?.id`` — but + * ``selectedProject`` starts as ``""`` (not nullish), so ``??`` never fell + * back and keys never loaded until a project was manually chosen. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, waitFor } from "@testing-library/react"; + +vi.mock("@/lib/api-keys-api", () => ({ + listApiKeys: vi.fn().mockResolvedValue([]), + revokeApiKey: vi.fn(), + rotateApiKey: vi.fn(), +})); + +vi.mock("@/lib/projects-api", () => ({ + listProjects: vi.fn(), +})); + +vi.mock("sonner", () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})); + +import { listApiKeys } from "@/lib/api-keys-api"; +import { listProjects } from "@/lib/projects-api"; +import { ApiKeysSection } from "@/components/admin/api-keys-section"; + +beforeEach(() => { + vi.mocked(listApiKeys).mockReset(); + vi.mocked(listApiKeys).mockResolvedValue([]); + vi.mocked(listProjects).mockReset(); +}); + +describe("ApiKeysSection project fallback (issue #73)", () => { + it("uses the first accessible project on initial load when none is selected", async () => { + vi.mocked(listProjects).mockResolvedValue([ + { id: "proj-a", name: "Alpha", created_by: "u", created_at: null, current_user_role: "admin" }, + { id: "proj-b", name: "Beta", created_by: "u", created_at: null, current_user_role: "admin" }, + ]); + + render(); + + await waitFor(() => { + expect(listApiKeys).toHaveBeenCalledWith("proj-a"); + }); + }); +}); diff --git a/apps/dashboard/src/components/admin/api-key-create-dialog.tsx b/apps/dashboard/src/components/admin/api-key-create-dialog.tsx index 5c7c796b..2ba0c3ec 100644 --- a/apps/dashboard/src/components/admin/api-key-create-dialog.tsx +++ b/apps/dashboard/src/components/admin/api-key-create-dialog.tsx @@ -17,6 +17,7 @@ import { type ApiKeyScope, createApiKey, } from "@/lib/api-keys-api" +import type { Project } from "@/lib/projects-api" import { toast } from "sonner" import type { ApiKeyRevealPayload } from "@/components/api-key-created-dialog" @@ -24,38 +25,51 @@ interface ApiKeyCreateDialogProps { open: boolean onOpenChange: (open: boolean) => void defaultProject?: string + /** Accessible projects to choose from (issue #73). */ + projects?: Project[] onCreated: (payload: ApiKeyRevealPayload) => void } export function ApiKeyCreateDialog({ open, onOpenChange, - defaultProject = "example-service", + defaultProject, + projects = [], onCreated, }: ApiKeyCreateDialogProps) { + // Issue #73: pick a real Project ID from the user's accessible projects + // rather than a free-text value. Fall back to the first project when the + // parent has no current selection, and to "" (disabled) when none exist. + const initialProject = defaultProject ?? projects[0]?.id ?? "" const [name, setName] = useState("") - const [project, setProject] = useState(defaultProject) - const [scope, setScope] = useState("full") + const [project, setProject] = useState(initialProject) + // default to the least-privileged scope. Telemetry producer + // issuance is the common case; full management access is an explicit + // administrative choice. + const [scope, setScope] = useState("ingest") const [expiresAt, setExpiresAt] = useState("") const [creating, setCreating] = useState(false) + const hasProjects = projects.length > 0 + const handleOpenChange = useCallback((next: boolean) => { if (next) { setName("") - setProject(defaultProject) - setScope("full") + setProject(defaultProject ?? projects[0]?.id ?? "") + setScope("ingest") setExpiresAt("") } onOpenChange(next) - }, [defaultProject, onOpenChange]) + }, [defaultProject, projects, onOpenChange]) async function handleSubmit(e: React.FormEvent) { e.preventDefault() + if (!hasProjects || !project) return setCreating(true) try { const result = await createApiKey( name.trim() || "Default", - project.trim() || defaultProject, + project, scope, expiresAt ? new Date(expiresAt).toISOString() : undefined, ) @@ -93,12 +107,19 @@ export function ApiKeyCreateDialog({
- setProject(e.target.value)} - /> + disabled={!hasProjects} + className="h-8 w-full min-w-0 rounded-none border border-input bg-input/30 px-2.5 py-1 text-xs outline-none transition-colors focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50" + > + {!hasProjects && } + {projects.map((p) => ( + + ))} +
@@ -109,8 +130,8 @@ export function ApiKeyCreateDialog({ onChange={(e) => setScope(e.target.value as ApiKeyScope)} className="h-8 w-full min-w-0 rounded-none border border-input bg-input/30 px-2.5 py-1 text-xs outline-none transition-colors focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring/50" > - - + +
@@ -132,7 +153,7 @@ export function ApiKeyCreateDialog({ > Cancel - diff --git a/apps/dashboard/src/components/admin/api-keys-section.tsx b/apps/dashboard/src/components/admin/api-keys-section.tsx index d0a9de66..bceb84df 100644 --- a/apps/dashboard/src/components/admin/api-keys-section.tsx +++ b/apps/dashboard/src/components/admin/api-keys-section.tsx @@ -33,8 +33,9 @@ export function ApiKeysSection() { }, []) // Derive the effective project: fall back to the first project when the - // user hasn't explicitly picked one. - const effectiveProject = selectedProject ?? projects[0]?.id + // user hasn't explicitly picked one. Issue #73: use ``||`` not ``??`` — + // ``selectedProject`` starts as "" (not nullish), so ``??`` never fell back. + const effectiveProject = selectedProject || projects[0]?.id const fetchKeys = useCallback(async () => { if (!effectiveProject) return @@ -144,16 +145,18 @@ export function ApiKeysSection() {

- Public keys (pk-apo-…) are safe to share and grant - ingest-only access. Secret keys (sk-apo-…) grant full - access — store them server-side. + Public keys (pk-apo-…) are stable identifiers — + they do not authorize requests by themselves. Secret keys + (sk-apo-…) are required alongside the public key + and must be stored server-side.

({ + cancelAgentTaskBatchRun: vi.fn(), +})); + +vi.mock("sonner", () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})); + +describe("BatchRunCancelButton", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("sends one cancel request and shows a Cancelling state", async () => { + const user = userEvent.setup(); + vi.mocked(cancelAgentTaskBatchRun).mockResolvedValueOnce({ ok: true, cancelled: 2 }); + render(); + + await user.click(screen.getByRole("button", { name: /Cancel Run/i })); + + expect(cancelAgentTaskBatchRun).toHaveBeenCalledTimes(1); + expect(cancelAgentTaskBatchRun).toHaveBeenCalledWith("batch-1"); + }); + + it("is idempotent: a second click while pending is suppressed", async () => { + const user = userEvent.setup(); + let resolveCancel!: (value: { ok: true; cancelled: number }) => void; + vi.mocked(cancelAgentTaskBatchRun).mockImplementationOnce( + () => new Promise((resolve) => { + resolveCancel = resolve; + }), + ); + render(); + + const button = screen.getByRole("button", { name: /Cancel Run/i }); + await user.click(button); + await user.click(button); // second click while in flight + + expect(cancelAgentTaskBatchRun).toHaveBeenCalledTimes(1); + resolveCancel({ ok: true, cancelled: 1 }); + }); + + it("surfaces an actionable error when the request fails", async () => { + const user = userEvent.setup(); + vi.mocked(cancelAgentTaskBatchRun).mockRejectedValueOnce(new Error("offline")); + render(); + + await user.click(screen.getByRole("button", { name: /Cancel Run/i })); + + const { toast } = await import("sonner"); + expect(toast.error).toHaveBeenCalledWith(expect.stringMatching(/Cancel failed/i)); + }); + + it("renders disabled when the Batch is already terminal", () => { + render(); + expect(screen.getByRole("button")).toBeDisabled(); + }); +}); diff --git a/apps/dashboard/src/components/agent-task-execution/__tests__/execution-attempt-panel.test.tsx b/apps/dashboard/src/components/agent-task-execution/__tests__/execution-attempt-panel.test.tsx index 20245601..0c58976b 100644 --- a/apps/dashboard/src/components/agent-task-execution/__tests__/execution-attempt-panel.test.tsx +++ b/apps/dashboard/src/components/agent-task-execution/__tests__/execution-attempt-panel.test.tsx @@ -11,11 +11,14 @@ function attempt( task_run_id: "task-run-1", status: "queued", phase: null, + assignment_kind: "bundled", executor_id: null, executor_name: null, executor_pool_id: "pool-1", driver_kind: null, queued_at: new Date(Date.now() - 60_000).toISOString(), + queue_expires_at: null, + waiting_reason: null, claimed_at: null, started_at: null, heartbeat_at: null, @@ -27,7 +30,7 @@ function attempt( }; } -describe("ExecutionAttemptPanel", () => { +describe("ExecutionAttemptPanel — legacy Pool Runs", () => { it("names the exact Pool while queued", () => { render(); expect(screen.getByText("Waiting for Private VPC")).toBeInTheDocument(); @@ -81,3 +84,100 @@ describe("ExecutionAttemptPanel", () => { expect(container).toBeEmptyDOMElement(); }); }); + +describe("ExecutionAttemptPanel — source-owned Runs", () => { + const sourceOwnedQueueExpiry = new Date(Date.now() + 20 * 60 * 60 * 1000).toISOString(); + + it.each([ + ["ready", /Your connected environment is ready/i], + ["busy", /Your connected environment is busy/i], + ["offline", /Waiting for apo connect/i], + ["not_connected", /Run apo connect in this Task workspace/i], + ["incompatible", /Update the Apo CLI, then restart apo connect/i], + ["catalog_mismatch", /Run apo task publish from this Task workspace/i], + ] as const)( + "queued source-owned attempt shows actionable copy for %s", + (_state, expected) => { + render( + , + ); + expect(screen.getByText(expected)).toBeInTheDocument(); + }, + ); + + it("never renders Pool, Executor, driver, or machine for source-owned Runs", () => { + render( + , + ); + expect(screen.queryByText("secret-machine")).not.toBeInTheDocument(); + expect(screen.queryByText("source-owned-ts")).not.toBeInTheDocument(); + expect(screen.queryByText("Source-Owned Tasks")).not.toBeInTheDocument(); + expect(screen.getByText(/Running in your connected environment/i)).toBeInTheDocument(); + }); + + it("shows cancellation-pending copy, distinct from a terminal cancelled state", () => { + render( + , + ); + expect(screen.getByText(/Cancelling in your connected environment/i)).toBeInTheDocument(); + }); + + it("explains the 24-hour executor-unavailable terminal state", () => { + render( + , + ); + expect( + screen.getByText(/No compatible Connected Executor became available within 24 hours/i), + ).toBeInTheDocument(); + }); + + it("explains the task-removed-from-catalog terminal state", () => { + render( + , + ); + expect( + screen.getByText(/no longer in the published catalog/i), + ).toBeInTheDocument(); + }); +}); diff --git a/apps/dashboard/src/components/agent-task-execution/batch-run-cancel-button.tsx b/apps/dashboard/src/components/agent-task-execution/batch-run-cancel-button.tsx new file mode 100644 index 00000000..a6a1d5d8 --- /dev/null +++ b/apps/dashboard/src/components/agent-task-execution/batch-run-cancel-button.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { useState } from "react"; +import { X } from "lucide-react"; +import { cancelAgentTaskBatchRun } from "@/lib/agent-task-api"; +import { Button } from "@/components/ui/button"; +import { toast } from "sonner"; + +interface BatchRunCancelButtonProps { + batchRunId: string; + /** Disables the action when the Batch is already terminal. */ + disabled?: boolean; +} + +/** client cancellation action for the server-rendered Batch detail. + * + * Idempotent: the second click while the request is in flight is suppressed + * and the button renders a disabled "Cancelling…" state. The underlying + * route is itself idempotent, so a retry after a transient failure is safe. + */ +export function BatchRunCancelButton({ + batchRunId, + disabled, +}: BatchRunCancelButtonProps) { + const [cancelling, setCancelling] = useState(false); + + const handleCancel = async () => { + if (cancelling) return; + setCancelling(true); + try { + await cancelAgentTaskBatchRun(batchRunId); + toast.success("Run cancelling"); + // The page auto-refreshes while running; the cancelled state appears + // on the next poll once the cancellation propagates. + } catch (e: unknown) { + toast.error( + e instanceof Error + ? `Cancel failed: ${e.message}` + : "Cancel failed — try again", + ); + } finally { + setCancelling(false); + } + }; + + return ( + + ); +} diff --git a/apps/dashboard/src/components/agent-task-execution/deliverables-panel.tsx b/apps/dashboard/src/components/agent-task-execution/deliverables-panel.tsx index 63704927..49e32c0d 100644 --- a/apps/dashboard/src/components/agent-task-execution/deliverables-panel.tsx +++ b/apps/dashboard/src/components/agent-task-execution/deliverables-panel.tsx @@ -1,7 +1,7 @@ "use client"; /** - * SPEC-140 ticket 07: lazy-loading Deliverables panel. + * lazy-loading Deliverables panel. * * Receives only the manifest initially — names, kinds, sizes — so opening the * Deliverables tab issues no body request. Expanding one JSON row fetches diff --git a/apps/dashboard/src/components/agent-task-execution/execution-attempt-panel.tsx b/apps/dashboard/src/components/agent-task-execution/execution-attempt-panel.tsx index 48cbf32e..6f61389e 100644 --- a/apps/dashboard/src/components/agent-task-execution/execution-attempt-panel.tsx +++ b/apps/dashboard/src/components/agent-task-execution/execution-attempt-panel.tsx @@ -1,5 +1,8 @@ import { Clock, Cpu, Server } from "lucide-react"; -import type { ExecutionAttemptSummary } from "@/lib/agent-task-api"; +import type { + AttemptWaitingReason, + ExecutionAttemptSummary, +} from "@/lib/agent-task-api"; interface ExecutionAttemptPanelProps { attempts: ExecutionAttemptSummary[]; @@ -18,10 +21,16 @@ export function ExecutionAttemptPanel({ ); return ( -
+
-

+

Execution

@@ -30,26 +39,43 @@ export function ExecutionAttemptPanel({
{visibleAttempts.map((attempt) => { - const presentation = describeAttempt(attempt, poolName); + const isSourceOwned = attempt.assignment_kind === "source_owned"; + const presentation = isSourceOwned + ? describeSourceOwnedAttempt(attempt) + : describePoolAttempt(attempt, poolName); return ( -
+
- + {presentation.title} {attempt.task_run_id.slice(0, 8)}
-

{presentation.detail}

- {attempt.error_message && ( +

+ {presentation.detail} +

+ {presentation.guidance && ( +

+ {presentation.guidance} +

+ )} + {attempt.error_message && !presentation.guidance && (

{attempt.error_message}

)}
- {attempt.driver_kind && ( + {/* source-owned Runs never expose driver/machine. */} + {!isSourceOwned && attempt.driver_kind && ( {attempt.driver_kind} @@ -68,15 +94,167 @@ export function ExecutionAttemptPanel({ ); } -function describeAttempt( +type Presentation = { + title: string; + detail: string; + guidance: string | null; + dot: string; +}; + +// ============================================================================ +// Source-owned presentation +// ============================================================================ + +/** Stable copy for a queued source-owned Attempt's dynamic waiting reason. */ +function waitingCopy(reason: AttemptWaitingReason): { + detail: string; + guidance: string | null; +} { + switch (reason) { + case "ready": + return { detail: "Your connected environment is ready.", guidance: null }; + case "busy": + return { + detail: "Your connected environment is busy — this run will wait.", + guidance: null, + }; + case "offline": + return { + detail: "Waiting for apo connect.", + guidance: "Start apo connect in this Task workspace.", + }; + case "not_connected": + return { + detail: "Run apo connect in this Task workspace.", + guidance: "Authenticate and connect this Project.", + }; + case "incompatible": + return { + detail: "Update the Apo CLI, then restart apo connect.", + guidance: "Queued work will start when compatible.", + }; + case "catalog_mismatch": + return { + detail: "Run apo task publish from this Task workspace.", + guidance: "Keep apo connect running; it resumes automatically.", + }; + } +} + +function describeSourceOwnedAttempt(attempt: ExecutionAttemptSummary): Presentation { + const queuedDeadline = attempt.queue_expires_at + ? `Queued until ${formatAbsolute(attempt.queue_expires_at)}.` + : null; + + if (attempt.cancel_requested_at) { + return { + title: "Cancelling in your connected environment…", + detail: queuedDeadline ?? "Cancellation requested.", + guidance: null, + dot: "bg-muted-foreground animate-pulse", + }; + } + if (attempt.status === "queued") { + const reason = attempt.waiting_reason ?? "not_connected"; + const copy = waitingCopy(reason); + return { + title: "Queued in your connected environment", + detail: queuedDelay(attempt) ? `${copy.detail} ${queuedDeadline}` : copy.detail, + guidance: copy.guidance, + dot: "bg-muted-foreground animate-pulse", + }; + } + if (attempt.status === "leased") { + return { + title: "Preparing in your connected environment", + detail: `Claimed ${formatAge(attempt.claimed_at)}.`, + guidance: null, + dot: "bg-foreground animate-pulse", + }; + } + if (attempt.status === "running") { + const phase = attempt.phase ? attempt.phase.replaceAll("_", " ") : "running"; + return { + title: "Running in your connected environment", + detail: `Phase: ${phase}. Last heartbeat ${formatAge(attempt.heartbeat_at)}.`, + guidance: null, + dot: "bg-foreground animate-pulse", + }; + } + if (attempt.status === "lost") { + return { + title: "Connection lost after the Task started", + detail: "Outcome is unknown; Apo does not infer a result or retry.", + guidance: null, + dot: "bg-warning", + }; + } + if (attempt.status === "cancelled") { + return { + title: "Run cancelled", + detail: "Apo stopped tracking this attempt as active.", + guidance: null, + dot: "bg-muted-foreground", + }; + } + if (attempt.status === "failed") { + return sourceOwnedFailure(attempt); + } + return { + title: "Run finished", + detail: "Execution attempt completed in your connected environment.", + guidance: null, + dot: attempt.status === "succeeded" ? "bg-success" : "bg-muted-foreground", + }; +} + +function sourceOwnedFailure(attempt: ExecutionAttemptSummary): Presentation { + if (attempt.failure_kind === "executor_unavailable") { + return { + title: "No compatible Connected Executor became available within 24 hours", + detail: "The queue deadline expired before the run could start.", + guidance: "Connect, update, or publish, then run again.", + dot: "bg-destructive", + }; + } + if (attempt.failure_kind === "task_not_in_catalog") { + return { + title: "This Task is no longer in the published catalog", + detail: "The Task was removed before the run could start.", + guidance: "Refresh Tasks, then run again.", + dot: "bg-destructive", + }; + } + return { + title: "Execution failed", + detail: + attempt.error_message ?? + (attempt.failure_kind + ? attempt.failure_kind.replaceAll("_", " ") + : "The connected environment reported a failure."), + guidance: null, + dot: "bg-destructive", + }; +} + +function queuedDelay(attempt: ExecutionAttemptSummary): boolean { + return Boolean(attempt.queue_expires_at); +} + +// ============================================================================ +// Legacy Pool / bundled presentation (historical Runs) +// ============================================================================ + +function describePoolAttempt( attempt: ExecutionAttemptSummary, poolName: string | null, -): { title: string; detail: string; dot: string } { +): Presentation { const pool = poolName ?? "configured pool"; if (attempt.status === "queued") { return { title: `Waiting for ${pool}`, detail: `Queued ${formatAge(attempt.queued_at)}. Apo will not move this run to another pool.`, + guidance: null, dot: "bg-muted-foreground animate-pulse", }; } @@ -84,6 +262,7 @@ function describeAttempt( return { title: "Executor claimed this task", detail: `${attempt.executor_name ?? "Executor"} is preparing the task.`, + guidance: null, dot: "bg-foreground animate-pulse", }; } @@ -92,6 +271,7 @@ function describeAttempt( return { title: `Running on ${attempt.executor_name ?? "executor"}`, detail: `Phase: ${phase}. Last heartbeat ${formatAge(attempt.heartbeat_at)}.`, + guidance: null, dot: "bg-foreground animate-pulse", }; } @@ -99,6 +279,7 @@ function describeAttempt( return { title: "Executor connection lost", detail: "Execution may have continued remotely, so Apo cannot safely infer a result.", + guidance: null, dot: "bg-warning", }; } @@ -106,23 +287,27 @@ function describeAttempt( return { title: "Execution cancelled", detail: "Apo stopped tracking this attempt as active.", + guidance: null, dot: "bg-muted-foreground", }; } if (attempt.status === "failed") { - return failurePresentation(attempt.failure_kind, pool); + return poolFailure(attempt.failure_kind, pool); } return { title: attempt.status.replaceAll("_", " "), - detail: attempt.executor_name ? `Executor: ${attempt.executor_name}` : "Execution attempt finished.", + detail: attempt.executor_name + ? `Executor: ${attempt.executor_name}` + : "Execution attempt finished.", + guidance: null, dot: attempt.status === "succeeded" ? "bg-success" : "bg-muted-foreground", }; } -function failurePresentation( +function poolFailure( failureKind: string | null, poolName: string, -): { title: string; detail: string; dot: string } { +): Presentation { const failures: Record = { executor_unavailable: [ "Executor unavailable", @@ -133,6 +318,10 @@ function failurePresentation( "The executor could not prepare the task dependencies.", ], runtime_error: ["Runtime failed", "The task process exited with a runtime error."], + result_invalid: [ + "Task produced no result", + "The task exited without writing a valid result file — usually a crash before completion. See the run logs for the error.", + ], timeout: ["Execution timed out", "The task exceeded its configured execution limit."], cancelled: ["Execution cancelled", "The attempt was cancelled before completion."], }; @@ -140,9 +329,13 @@ function failurePresentation( "Execution failed", failureKind ? failureKind.replaceAll("_", " ") : "The executor reported a failure.", ]; - return { title, detail, dot: "bg-destructive" }; + return { title, detail, guidance: null, dot: "bg-destructive" }; } +// ============================================================================ +// Shared formatters +// ============================================================================ + function formatAttemptTime(attempt: ExecutionAttemptSummary): string { const value = attempt.completed_at ?? @@ -153,6 +346,10 @@ function formatAttemptTime(attempt: ExecutionAttemptSummary): string { return new Date(value).toISOString().slice(11, 19); } +function formatAbsolute(value: string): string { + return new Date(value).toISOString().slice(0, 16).replace("T", " ") + " UTC"; +} + function formatAge(value: string | null): string { if (!value) return "unknown"; const seconds = Math.max(0, Math.floor((Date.now() - new Date(value).getTime()) / 1000)); diff --git a/apps/dashboard/src/components/api-key-created-dialog.tsx b/apps/dashboard/src/components/api-key-created-dialog.tsx index 810a0f59..da60af2a 100644 --- a/apps/dashboard/src/components/api-key-created-dialog.tsx +++ b/apps/dashboard/src/components/api-key-created-dialog.tsx @@ -64,7 +64,7 @@ export function ApiKeyRevealDialog({
{payload?.publicKey && ( - + )} {secret && ( setShowSecret((s) => !s)} - hint="Server-side only. Grants full access when paired with the public key." + hint="Required with the public key. Copy now; it cannot be shown again." /> )}
diff --git a/apps/dashboard/src/components/auth/auth-shell.tsx b/apps/dashboard/src/components/auth/auth-shell.tsx index 9190a9aa..060ccd45 100644 --- a/apps/dashboard/src/components/auth/auth-shell.tsx +++ b/apps/dashboard/src/components/auth/auth-shell.tsx @@ -13,12 +13,12 @@ export default function AuthShell({ footer, }: AuthShellProps) { return ( -
+
-
@@ -200,7 +206,7 @@ export function TaskRunListHeader({ withCompare = false }: { withCompare?: boole Trigger · Batch Execution Judges - Duration · Cost + Duration · Cost · Tokens {withCompare ? "Started · Compare" : "Started"}
diff --git a/apps/dashboard/src/components/task-runtime-status-panel.tsx b/apps/dashboard/src/components/task-runtime-status-panel.tsx index a6c08c9a..553a724b 100644 --- a/apps/dashboard/src/components/task-runtime-status-panel.tsx +++ b/apps/dashboard/src/components/task-runtime-status-panel.tsx @@ -91,7 +91,7 @@ export function TaskRuntimeStatusPanel({ ) : null}
Agent task runs will fail with an operator-grade error until the - backend image includes the packaged runtime (SPEC-125). + backend image includes the packaged runtime.
diff --git a/apps/dashboard/src/components/trace-detail/CallDetailTabs.tsx b/apps/dashboard/src/components/trace-detail/CallDetailTabs.tsx index d54b7f40..90511076 100644 --- a/apps/dashboard/src/components/trace-detail/CallDetailTabs.tsx +++ b/apps/dashboard/src/components/trace-detail/CallDetailTabs.tsx @@ -36,6 +36,7 @@ export function CallDetailTabs({ const isChatML = detectChatML(data); const traceEventKind = detectTraceEventKind(data); const hasTypedEventPreview = traceEventKind !== "unknown"; + const hasToolCalls = Array.isArray(data?.toolCalls) && data.toolCalls.length > 0; const normalized = normalizeData(data); const readableText = extractReadableText(normalized); const flatEntries = getFlatKeyValueEntries(normalized); @@ -43,16 +44,18 @@ export function CallDetailTabs({ return (
-
+
{title}
{viewMode === "preview" ? ( isChatML ? ( - + ) : hasTypedEventPreview ? ( + ) : hasToolCalls ? ( + ) : readableText ? ( ) : flatEntries ? ( @@ -90,7 +93,7 @@ function FlatKeyValuePreview({ key={key} className="flex items-start justify-between gap-4 border-b border-border/60 px-3 py-2.5 last:border-b-0" > - + {humanizeKey(key)} {String(value)} @@ -217,3 +220,49 @@ function humanizeKey(key: string) { .trim() .replace(/^./, (c) => c.toUpperCase()); } + + +function ToolCallsPreview({ toolCalls }: { toolCalls: Array> }) { + // A generation round that produced tool calls (no text) is rendered with the + // tool names + inputs visible by default. ExpandableJson auto-collapses the + // tool-call objects, which made every tool-call round in an agent loop render + // as the identical ``{finishReason, toolCalls: [{...}]}`` and hid what each + // round actually did. + return ( +
+ {toolCalls.map((tc, i) => { + const name = typeof tc.toolName === "string" ? tc.toolName : "tool"; + const id = typeof tc.toolCallId === "string" ? tc.toolCallId : null; + const rawInput = tc.input; + const inputText = + typeof rawInput === "string" + ? rawInput + : rawInput === undefined || rawInput === null + ? "" + : JSON.stringify(rawInput); + return ( +
+
+ + {name} + + {id ? ( + + {id.slice(0, 12)} + + ) : null} +
+ {inputText && inputText !== "{}" ? ( +
+                {inputText}
+              
+ ) : null} +
+ ); + })} +
+ ); +} diff --git a/apps/dashboard/src/components/trace-detail/ChatMessagePreview.tsx b/apps/dashboard/src/components/trace-detail/ChatMessagePreview.tsx index d41aa06e..c1742907 100644 --- a/apps/dashboard/src/components/trace-detail/ChatMessagePreview.tsx +++ b/apps/dashboard/src/components/trace-detail/ChatMessagePreview.tsx @@ -1,9 +1,7 @@ "use client"; import Image from "next/image"; -import { Badge } from "@/components/ui/badge"; -import { Sparkles, User, Wrench, Cpu } from "lucide-react"; -import { useMemo, useRef, useCallback } from "react"; +import { useMemo, useRef, useCallback, useState } from "react"; import { ToolDefinitionsSection } from "./ToolDefinitionsSection"; import { extractTools, countToolInvocations } from "./tool-utils"; import { ThinkingBlock } from "./ThinkingBlock"; @@ -33,9 +31,18 @@ type ContentPart = interface ChatMessagePreviewProps { data: unknown; + /** + * "history" (default): show the first 3 + last 3 messages with a collapsible + * middle. "last": show ONLY the newest message by default (the delta — e.g. + * the tool result that triggered this generation) with a "Show full prompt" + * toggle that expands the whole accumulated prompt. Used for generation + * inputs so each step shows what's new that round, not the entire repeated + * conversation history. + */ + preview?: "history" | "last"; } -export function ChatMessagePreview({ data }: ChatMessagePreviewProps) { +export function ChatMessagePreview({ data, preview = "history" }: ChatMessagePreviewProps) { const messages = useMemo(() => parseMessages(data), [data]); const tools = useMemo(() => extractTools(data), [data]); const invocationCounts = useMemo( @@ -43,6 +50,7 @@ export function ChatMessagePreview({ data }: ChatMessagePreviewProps) { [messages], ); const toolCallCounter = useRef(0); + const [showFullPrompt, setShowFullPrompt] = useState(false); const getNextToolCallNumber = useCallback(() => { toolCallCounter.current += 1; @@ -57,10 +65,6 @@ export function ChatMessagePreview({ data }: ChatMessagePreviewProps) { ); } - const firstThree = messages.slice(0, 3); - const lastThree = messages.length > 6 ? messages.slice(-3) : []; - const middleMessages = messages.length > 6 ? messages.slice(3, -3) : []; - const renderMessage = (msg: ChatMessage, idx: number) => ( ); + // Delta mode: a generation's input is the whole accumulated prompt, but + // re-displaying it in every node is noise. Show only the newest message + // (the turn that triggered this generation) by default; the full prompt is + // one click away. Falls back to full view when there's only one message. + if (preview === "last" && messages.length > 1 && !showFullPrompt) { + const lastIndex = messages.length - 1; + const last = messages[lastIndex]; + return ( +
+ {tools.length > 0 && ( + + )} + {renderMessage(last, lastIndex)} + +
+ ); + } + + const firstThree = messages.slice(0, 3); + const lastThree = messages.length > 6 ? messages.slice(-3) : []; + const middleMessages = messages.length > 6 ? messages.slice(3, -3) : []; + return (
{tools.length > 0 && ( @@ -77,6 +112,15 @@ export function ChatMessagePreview({ data }: ChatMessagePreviewProps) { invocationCounts={invocationCounts} /> )} + {preview === "last" && showFullPrompt && ( + + )} -
- {roleInfo.icon} +
+
+ {roleInfo.label} + {message.name && ({message.name})}
-
-
- - {roleInfo.label} - {message.name && ({message.name})} - -
- +
{hasContent(message.content) && ( -
+
)} - {!hasContent(message.content) && !message.tool_calls?.length && ( -
- Empty message -
- )} {message.tool_calls && message.tool_calls.length > 0 && (
@@ -142,21 +165,13 @@ function MessageBubble({ return (
-
- - - Tool Call #{callNumber} - - - {call.function?.name || "unknown"} - +
+ {call.function?.name || "unknown"}
{call.function?.arguments && ( -
-                      {call.function.arguments}
-                    
+ )}
); @@ -195,7 +210,7 @@ function MessageContent({ parts }: { parts: ContentPart[] }) { function ImageReference({ url }: { url: string }) { if (!url || url.startsWith("/")) { return ( - + [Image] {url && {url}} @@ -213,7 +228,7 @@ function ImageReference({ url }: { url: string }) { const target = e.currentTarget; target.style.display = "none"; const placeholder = document.createElement("span"); - placeholder.className = "inline-flex items-center gap-1.5 border border-border/60 bg-muted/30 px-2 py-1 text-[11px] text-muted-foreground"; + placeholder.className = "inline-flex items-center gap-1.5 border border-border/60 bg-muted/30 px-2 py-1 text-xs text-muted-foreground"; placeholder.textContent = "[Image: failed to load]"; target.parentNode?.appendChild(placeholder); }} @@ -224,7 +239,7 @@ function ImageReference({ url }: { url: string }) { function AudioReference({ url }: { url: string }) { return ( - + [Audio] {url && {url}} @@ -241,6 +256,78 @@ function parseContentParts(content: string | ContentPart[]): ContentPart[] { return []; } +const TOOL_ARG_COLLAPSE_CHARS = 400; + +/** + * Render a tool call's arguments readably. The raw value is a JSON string whose + * inner text has escaped newlines (e.g. {"text":"import json\\ndef …"}), which + * otherwise renders as a single unbroken wall. Parse it: if the argument is an + * object carrying a text-like field, show that field with real newlines; fall + * back to pretty-printed JSON, then raw text. Long payloads collapse behind an + * expand toggle. + */ +function ToolCallArguments({ arguments: args }: { arguments: string }) { + const [expanded, setExpanded] = useState(false); + + const rendered = useMemo(() => { + let parsed: unknown = args; + try { + parsed = JSON.parse(args); + } catch { + return args; + } + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + const obj = parsed as Record; + const textField = ["text", "content", "source", "code", "input"].find( + (k) => typeof obj[k] === "string", + ); + if (textField) { + return { label: textField, body: obj[textField] as string }; + } + } + try { + return JSON.stringify(parsed, null, 2); + } catch { + return args; + } + }, [args]); + + const label = + typeof rendered === "object" && rendered !== null + ? (rendered as { label?: string }).label + : undefined; + const body = + typeof rendered === "object" && rendered !== null + ? (rendered as { body?: string }).body ?? "" + : (rendered as string); + + const tooLong = body.length > TOOL_ARG_COLLAPSE_CHARS; + const visible = !expanded && tooLong ? body.slice(0, TOOL_ARG_COLLAPSE_CHARS) : body; + + return ( +
+ {label ? ( +
+ {label} +
+ ) : null} +
+        {visible}
+        {tooLong && !expanded ? "…" : ""}
+      
+ {tooLong ? ( + + ) : null} +
+ ); +} + function hasContent(content: string | ContentPart[]): boolean { if (typeof content === "string") return content.length > 0; if (Array.isArray(content)) return content.length > 0; @@ -280,42 +367,29 @@ function parseMessages(data: unknown): ChatMessage[] { } function getRoleInfo(role: string) { - const roleMap: Record< - string, - { label: string; icon: React.ReactNode; bgColor: string; textColor: string } - > = { + const roleMap: Record = { system: { label: "System", - icon: , - bgColor: "bg-violet-900/30 border-violet-700", - textColor: "text-violet-300", + headerClass: "bg-violet-500/10 text-violet-400", }, user: { label: "User", - icon: , - bgColor: "bg-blue-900/30 border-blue-700", - textColor: "text-blue-300", + headerClass: "bg-blue-500/10 text-blue-400", }, assistant: { label: "Assistant", - icon: , - bgColor: "bg-emerald-900/30 border-emerald-700", - textColor: "text-emerald-300", + headerClass: "bg-emerald-500/10 text-emerald-400", }, tool: { label: "Tool", - icon: , - bgColor: "bg-warning/10 border-warning", - textColor: "text-warning", + headerClass: "bg-amber-500/10 text-amber-400", }, }; return ( roleMap[role] || { label: role.charAt(0).toUpperCase() + role.slice(1), - icon: {role[0]}, - bgColor: "bg-muted", - textColor: "text-foreground", + headerClass: "bg-muted text-foreground", } ); } diff --git a/apps/dashboard/src/components/trace-detail/DimensionBreakdownTooltip.tsx b/apps/dashboard/src/components/trace-detail/DimensionBreakdownTooltip.tsx index 952c37e0..5ae8dcd1 100644 --- a/apps/dashboard/src/components/trace-detail/DimensionBreakdownTooltip.tsx +++ b/apps/dashboard/src/components/trace-detail/DimensionBreakdownTooltip.tsx @@ -4,7 +4,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip import { formatCostMicro, usdFormat } from "@/lib/format"; /** - * SPEC-136 ticket 11: dimension cost-breakdown tooltip. + * dimension cost-breakdown tooltip. * * Reads the stored per-call cost breakdown directly (no client-side pricing * fetch). Groups dimensions by family (Input / Output), sorts by descending diff --git a/apps/dashboard/src/components/trace-detail/DimensionMixBar.tsx b/apps/dashboard/src/components/trace-detail/DimensionMixBar.tsx index a7d79dac..4f835aa7 100644 --- a/apps/dashboard/src/components/trace-detail/DimensionMixBar.tsx +++ b/apps/dashboard/src/components/trace-detail/DimensionMixBar.tsx @@ -1,7 +1,7 @@ "use client"; /** - * SPEC-136 ticket 11: a 3px-tall monochrome bar showing a model's cost dimension + * a 3px-tall monochrome bar showing a model's cost dimension * mix. Each canonical dimension is a grayscale shade (design.md: color = state, * not decoration); the relative width of a segment is that dimension's share of * the total breakdown. Amber is reserved for unpriced dimensions (none here — diff --git a/apps/dashboard/src/components/trace-detail/GenerationChatPreview.tsx b/apps/dashboard/src/components/trace-detail/GenerationChatPreview.tsx new file mode 100644 index 00000000..544042dc --- /dev/null +++ b/apps/dashboard/src/components/trace-detail/GenerationChatPreview.tsx @@ -0,0 +1,132 @@ +"use client"; + +import { useMemo } from "react"; +import { ChatMessagePreview } from "./ChatMessagePreview"; + +/** + * Renders a generation's full prompt plus its response as ONE conversation — + * the Langfuse combined-chat model (combineInputOutputMessages), instead of + * two separate Input/Output panels. + * + * The model's input is the accumulated prompt (system + history + new turn); + * its output is appended as a final assistant message. apo's output shapes are + * synthesized into that assistant message: + * - { text } -> assistant message with that text + * - { toolCalls: [...] } -> assistant message with OpenAI-style tool_calls + * - { messages: [...] } -> those messages verbatim (already assistant-shaped) + * + * ChatMessagePreview handles the collapse (first/last window) and tool-call + * rendering. Falls back to null when there's nothing combinable, so the caller + * can render the legacy split panels instead. + */ +export function GenerationChatPreview({ + input, + output, +}: { + input: unknown; + output: unknown; +}) { + const combined = useMemo( + () => combineGenerationMessages(input, output), + [input, output], + ); + + if (combined.length === 0) return null; + // preview="last": show only the model's response (the final message) by + // default — "just the generation" — with the full accumulated prompt behind + // a "Show full prompt (N messages)" toggle. Avoids re-displaying the whole + // conversation history in every generation node. + return ; +} + +type ChatMessage = { + role: string; + content: string | unknown[]; + tool_calls?: Array<{ + id?: string; + type?: string; + function?: { name: string; arguments: string }; + }>; +}; + +export function combineGenerationMessages(input: unknown, output: unknown): ChatMessage[] { + const inputMessages = parseInputMessages(input); + const outputMessages = synthesizeOutputMessages(output); + return [...inputMessages, ...outputMessages]; +} + +export function parseInputMessages(input: unknown): ChatMessage[] { + if (!input || typeof input !== "object") return []; + const obj = input as Record; + const msgs = obj.messages; + if (Array.isArray(msgs)) { + return msgs.filter((m) => m && typeof m === "object") as ChatMessage[]; + } + // Anthropic-style: { prompt: "..." } → single user message + if (typeof obj.prompt === "string") { + return [{ role: "user", content: obj.prompt }]; + } + return []; +} + +export function synthesizeOutputMessages(output: unknown): ChatMessage[] { + if (!output) return []; + + // Anthropic-style: bare array of content blocks (text, reasoning, tool_use) + if (Array.isArray(output)) { + const blocks = output as Array>; + const toolCalls = blocks + .filter((b) => b?.type === "tool_use") + .map((b) => ({ + id: typeof b.id === "string" ? b.id : undefined, + type: "function" as const, + function: { + name: typeof b.name === "string" ? b.name : "tool", + arguments: JSON.stringify(b.input ?? {}), + }, + })); + const contentParts = blocks.filter((b) => b?.type !== "tool_use"); + return [{ + role: "assistant", + content: contentParts.length > 0 ? contentParts : "", + ...(toolCalls.length > 0 ? { tool_calls: toolCalls } : {}), + }]; + } + + if (typeof output !== "object") return []; + const obj = output as Record; + + // Output already carries messages (some SDKs) — pass through. + if (Array.isArray(obj.messages)) { + return obj.messages.filter((m) => m && typeof m === "object") as ChatMessage[]; + } + + // Tool-call round: { finishReason, toolCalls: [{ toolName, input, toolCallId }] } + if (Array.isArray(obj.toolCalls) && obj.toolCalls.length > 0) { + const toolCalls = obj.toolCalls.map((tc) => { + const t = (tc ?? {}) as Record; + const args = + typeof t.input === "string" + ? t.input + : t.input === undefined || t.input === null + ? "{}" + : JSON.stringify(t.input); + return { + id: typeof t.toolCallId === "string" ? t.toolCallId : undefined, + type: "function", + function: { + name: typeof t.toolName === "string" ? t.toolName : "tool", + arguments: args, + }, + }; + }); + return [{ role: "assistant", content: "", tool_calls: toolCalls }]; + } + + // Text round: { text: "..." } + if (typeof obj.text === "string" && obj.text.length > 0) { + return [{ role: "assistant", content: obj.text }]; + } + + return []; +} diff --git a/apps/dashboard/src/components/trace-detail/TraceDetailTabs.tsx b/apps/dashboard/src/components/trace-detail/TraceDetailTabs.tsx index b013cd7f..0226cdfd 100644 --- a/apps/dashboard/src/components/trace-detail/TraceDetailTabs.tsx +++ b/apps/dashboard/src/components/trace-detail/TraceDetailTabs.tsx @@ -121,7 +121,7 @@ export function TraceDetailTabs({ run }: TraceDetailTabsProps) { acc[model].count += 1; acc[model].tokens += c.total_tokens || 0; acc[model].cost += c.cost || 0; - // Aggregate per-dimension breakdown for the mix bar (SPEC-136 ticket 11). + // Aggregate per-dimension breakdown for the mix bar. if (c.cost_breakdown) { for (const [key, val] of Object.entries(c.cost_breakdown as Record)) { acc[model].breakdown[key] = (acc[model].breakdown[key] ?? 0) + val; diff --git a/apps/dashboard/src/components/trace-detail/TraceDetailView.tsx b/apps/dashboard/src/components/trace-detail/TraceDetailView.tsx index d359d772..a88fb99b 100644 --- a/apps/dashboard/src/components/trace-detail/TraceDetailView.tsx +++ b/apps/dashboard/src/components/trace-detail/TraceDetailView.tsx @@ -5,6 +5,7 @@ import Link from "next/link"; import { useTraceData } from "./contexts/TraceDataContext"; import { useSelection } from "./contexts/SelectionContext"; import { CallDetailTabs } from "./CallDetailTabs"; +import { GenerationChatPreview } from "./GenerationChatPreview"; import { TraceDetailTabs } from "./TraceDetailTabs"; import { CopyIdPopover } from "./CopyIdPopover"; import { ScoreInputPanel } from "./ScoreInputPanel"; @@ -164,7 +165,7 @@ function TraceDetailRootView({ {run.run.task_id && ( {run.run.task_id} @@ -260,6 +261,16 @@ function CallDetailView({ call, readOnly = false }: { call: any; readOnly?: bool const canCorrect = outputText !== null; const runId = run?.run?.id ?? ""; + // SPEC: generation I/O as one combined conversation (Langfuse model) when the + // input is a ChatML messages array. Skipped when a correction is being shown + // (DiffView needs the split output panel) and in JSON mode. Non-generation + // observations and non-ChatML inputs keep the legacy split Input/Output panels. + const useCombinedGenerationChat = + (call.observation_type ?? "").toUpperCase() === "GENERATION" + && previewMode === "preview" + && !(correctedOutput && outputText) + && isChatMlInput(effectiveInput); + const handleSaveCorrection = useCallback(async (text: string | null) => { try { const result = await saveCorrection(runId, call.id, text); @@ -289,7 +300,7 @@ function CallDetailView({ call, readOnly = false }: { call: any; readOnly?: bool Back {ancestorPath.length > 1 && ( -
+
{ancestorPath.map((node: any, i: number) => ( {i > 0 && } @@ -351,7 +362,7 @@ function CallDetailView({ call, readOnly = false }: { call: any; readOnly?: bool )} {modelParams && Object.entries(modelParams).map(([key, value]) => ( - + {key}: {formatParamValue(value)} ))} @@ -427,6 +438,10 @@ function CallDetailView({ call, readOnly = false }: { call: any; readOnly?: bool
+ {useCombinedGenerationChat ? ( + + ) : ( + <>
-
+
Output
{canCorrect && previewMode === "preview" && !readOnly && ( @@ -474,6 +489,8 @@ function CallDetailView({ call, readOnly = false }: { call: any; readOnly?: bool /> )}
+ + )}
@@ -492,9 +509,9 @@ function CallDetailView({ call, readOnly = false }: { call: any; readOnly?: bool {formatCostMicro(call.cost)} : "\u2014"} /> {call.end_time ? : null} {call.status_message ? {call.status_message}} /> : null} - {call.environment && call.environment !== "default" ? {call.environment}} /> : null} + {call.environment && call.environment !== "default" ? {call.environment}} /> : null} {call.session_id ? {call.session_id}} /> : null} - {call.tags?.length ? {call.tags.map((tag: string) => {tag})}
} /> : null} + {call.tags?.length ? {call.tags.map((tag: string) => {tag})}
} /> : null} {call.version ? {call.version}} /> : null}
@@ -521,7 +538,7 @@ function HeaderPill({ mono?: boolean; }) { return ( - + {children} ); @@ -530,7 +547,7 @@ function HeaderPill({ function ItemTypeBadge({ type }: { type?: string | null }) { const label = typeof type === "string" && type.length > 0 ? type.toUpperCase() : "SPAN"; return ( - + {label} ); @@ -545,7 +562,7 @@ function MetadataRow({ }) { return (
-
+
{label}
{value}
@@ -590,8 +607,8 @@ function PreviewModeButton({ type="button" onClick={onClick} className={active - ? "border border-border/80 bg-muted/10 px-1.5 py-0.5 text-[11px] text-foreground" - : "px-1.5 py-0.5 text-[11px] text-muted-foreground hover:text-foreground"} + ? "border border-border/80 bg-muted/10 px-1.5 py-0.5 text-xs text-foreground" + : "px-1.5 py-0.5 text-xs text-muted-foreground hover:text-foreground"} > {children} @@ -625,6 +642,15 @@ function extractOutputText(output: any): string | null { } } +function isChatMlInput(input: unknown): boolean { + if (!input || typeof input !== "object") return false; + const obj = input as Record; + if (Array.isArray(obj.messages) && obj.messages.length > 0) return true; + // Anthropic-style: { prompt: "..." } is a single user message + if (typeof obj.prompt === "string") return true; + return false; +} + function formatParamValue(value: unknown): string { if (typeof value === "number") { return Number.isInteger(value) ? String(value) : value.toFixed(2).replace(/\.?0+$/, ""); diff --git a/apps/dashboard/src/components/trace-detail/TraceTree.tsx b/apps/dashboard/src/components/trace-detail/TraceTree.tsx index ff859c4c..713ad5b0 100644 --- a/apps/dashboard/src/components/trace-detail/TraceTree.tsx +++ b/apps/dashboard/src/components/trace-detail/TraceTree.tsx @@ -12,6 +12,8 @@ import { getDisplayName, cleanSpanName } from "./trace-display"; export { getDisplayName, cleanSpanName }; import { ChevronRight, + ChevronsDownUp, + ChevronsUpDown, Wrench, Boxes, BarChart3, @@ -24,7 +26,7 @@ import { useState, useMemo, useRef, useEffect, useCallback } from "react"; import { useVirtualizer } from "@tanstack/react-virtual"; import { cn } from "@/lib/utils"; import type { CumulativeMetrics } from "@/lib/cumulative-metrics"; -import { formatDuration, formatTokenBreakdown, formatTokenTotal } from "@/lib/format"; +import { formatCostMicro, formatDuration, formatTokenBreakdown, formatTokenTotal } from "@/lib/format"; import { CommentCountIcon } from "./CommentCountIcon"; import { getHeatmapColor } from "./trace-heatmap"; @@ -50,7 +52,7 @@ interface MetricPart { title?: string; } -const ROW_HEIGHT = 42; +const ROW_HEIGHT = 37; const OVERSCAN = 5; // Trace type-color tokens (per design.md accent discipline) — hues are @@ -129,7 +131,7 @@ function highlightMatch(value: string, searchQuery: string) { ); } -function getRunSummary(calls: TraceObservation[]) { +export function getRunSummary(calls: TraceObservation[]) { const totalDuration = calls.reduce((sum, call) => sum + (call.latency_ms ?? 0), 0); const totalTokens = calls.reduce((sum, call) => sum + (call.total_tokens ?? 0), 0); const totalCost = calls.reduce((sum, call) => sum + (call.cost ?? 0), 0); @@ -137,7 +139,7 @@ function getRunSummary(calls: TraceObservation[]) { return { duration: totalDuration > 0 ? formatDuration(totalDuration) : null, tokens: totalTokens > 0 ? formatTokenTotal(totalTokens) : null, - cost: totalCost > 0 ? `$${totalCost.toFixed(4)}` : null, + cost: totalCost > 0 ? formatCostMicro(totalCost) : null, }; } @@ -195,7 +197,6 @@ function SpanContent({
- {runLabel}
@@ -238,7 +239,7 @@ function SpanContent({ if (showCostTokens && showMetrics && displayCost > 0) { const prefix = hasDescendants ? "\u2211 " : ""; metricParts.push({ - text: `${prefix}$${displayCost.toFixed(4)}`, + text: `${prefix}${formatCostMicro(displayCost)}`, kind: "cost", title: hasDescendants ? "Aggregated cost of all child observations" : undefined, }); @@ -247,45 +248,35 @@ function SpanContent({ return (
-
- - +
+ {highlightMatch(displayName, searchQuery)} {showComments && commentCount != null && commentCount > 0 && ( )}
-
+
{(metricParts.length > 0 || levelBadge) && ( -
+ <> {levelBadge && ( - + {levelBadge.label} )} - {metricParts.map((part, index) => { + {metricParts.map((part) => { const colorClass = colorCodeMetrics && part.kind === "duration" ? getHeatmapColor(c.latency_ms ?? 0, timingBounds.spanMs) : colorCodeMetrics && part.kind === "cost" ? getHeatmapColor(displayCost, totalCost) : undefined; return ( - - {index > 0 || levelBadge ? · : null} - {part.kind === "model" ? ( - - {highlightMatch(part.text, searchQuery)} - - ) : ( - - {part.text} - - )} + + {part.text} ); })} -
+ )}
@@ -398,13 +389,13 @@ function TreeNode({ ) : null}
-
-
- +
+
+
-
+
= calls.length + 1; + const toggleAll = () => { + if (allExpanded) { + setExpanded(new Set(["root-run"])); + } else { + setExpanded(new Set(["root-run", ...calls.map((c) => c.id)])); + } + }; + const flatTree = useMemo(() => flattenTree(calls, expanded, searchQuery), [calls, expanded, searchQuery]); const timingBounds = useMemo(() => computeTimingBounds(calls), [calls]); const totalCost = useMemo(() => calls.reduce((sum, c) => sum + (c.cost ?? 0), 0), [calls]); @@ -665,6 +665,14 @@ export function TraceTree({ {opt.count} ))} +
diff --git a/apps/dashboard/src/components/trace-detail/TraceWorkspace.tsx b/apps/dashboard/src/components/trace-detail/TraceWorkspace.tsx index bcbb715f..8ed6d1ec 100644 --- a/apps/dashboard/src/components/trace-detail/TraceWorkspace.tsx +++ b/apps/dashboard/src/components/trace-detail/TraceWorkspace.tsx @@ -1,15 +1,15 @@ "use client"; import Link from "next/link"; -import { usePathname, useRouter, useSearchParams } from "next/navigation"; -import { Search, ArrowLeft, Download, Settings2, ChevronLeft, ChevronRight, AlertTriangle, Globe, Lock, LinkIcon, Check, PanelLeft, Radio } from "lucide-react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { Search, ArrowLeft, Download, Settings2, ChevronLeft, ChevronRight, AlertTriangle, PanelLeft, Radio } from "lucide-react"; import { useState, useEffect, useCallback, useRef, useMemo } from "react"; import { useIsMobile } from "@/hooks/use-mobile"; import { useProjectId } from "@/lib/project-router"; import { usePanelRef, type PanelSize } from "react-resizable-panels"; import { toast } from "sonner"; import { getCommentCounts } from "@/lib/comments-api"; -import { toggleVisibility } from "@/lib/traces-api"; +import { setSearchParamShallow } from "@/lib/shallow-search-params"; import { Button } from "@/components/ui/button"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { @@ -164,63 +164,6 @@ function ViewPreferencesDropdown() { ); } -function VisibilityToggle({ - runId, - isPublic, - onToggle, -}: { - runId: string; - isPublic: boolean; - onToggle: (isPublic: boolean) => void; -}) { - const [copied, setCopied] = useState(false); - - const handleToggle = async () => { - try { - const result = await toggleVisibility(runId); - onToggle(result.is_public); - } catch { - toast.error("Failed to toggle visibility"); - } - }; - - const handleCopyLink = async () => { - const url = `${window.location.origin}/public/traces/${runId}`; - await navigator.clipboard.writeText(url); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }; - - return ( -
- - {isPublic && ( - - )} -
- ); -} - function TraceNavToolbar({ run, isLive, @@ -230,8 +173,6 @@ function TraceNavToolbar({ onSearchQueryChange, prevId, nextId, - isPublic, - onVisibilityChange, readOnly = false, }: { run: TraceDetail; @@ -242,8 +183,6 @@ function TraceNavToolbar({ onSearchQueryChange: (value: string) => void; prevId: string | null; nextId: string | null; - isPublic: boolean; - onVisibilityChange: (isPublic: boolean) => void; readOnly?: boolean; }) { const router = useRouter(); @@ -356,13 +295,6 @@ function TraceNavToolbar({ )} - {!readOnly && ( - - )} @@ -467,9 +399,8 @@ function TraceNavigation({ commentCounts, prevId, nextId, - isPublic, - onVisibilityChange, readOnly = false, + onCollapse, }: { run: TraceDetail; isLive: boolean; @@ -482,9 +413,8 @@ function TraceNavigation({ commentCounts: Record; prevId: string | null; nextId: string | null; - isPublic: boolean; - onVisibilityChange: (isPublic: boolean) => void; readOnly?: boolean; + onCollapse?: () => void; }) { return (
@@ -498,12 +428,21 @@ function TraceNavigation({ onSearchQueryChange={onSearchQueryChange} prevId={prevId} nextId={nextId} - isPublic={isPublic} - onVisibilityChange={onVisibilityChange} readOnly={readOnly} /> -
+
+ {onCollapse && ( + + )}
@@ -554,14 +493,11 @@ export function TraceWorkspace({ readOnly = false, }: TraceWorkspaceProps) { const searchParams = useSearchParams(); - const router = useRouter(); - const pathname = usePathname(); const isMobile = useIsMobile(); const [searchQuery, setSearchQuery] = useState(() => mode === "page" ? (searchParams.get("q") ?? "") : "", ); const lastSyncedQueryRef = useRef(searchQuery); - const [isPublic, setIsPublic] = useState(run.run.is_public ?? false); const { view, setView } = useSelection(); // Live streaming: overlay SSE span events onto the server-fetched calls so @@ -620,17 +556,12 @@ export function TraceWorkspace({ if (searchQuery === lastSyncedQueryRef.current) return; const timer = setTimeout(() => { lastSyncedQueryRef.current = searchQuery; - const params = new URLSearchParams(searchParams.toString()); - if (searchQuery) { - params.set("q", searchQuery); - } else { - params.delete("q"); - } - const qs = params.toString(); - router.replace(`${pathname}${qs ? `?${qs}` : ""}`, { scroll: false }); + // Shallow: keeps the URL shareable without re-running the server + // component (which would re-fetch the whole trace) per keystroke. + setSearchParamShallow("q", searchQuery || null); }, 300); return () => clearTimeout(timer); - }, [mode, searchQuery, searchParams, router, pathname]); + }, [mode, searchQuery]); return ( @@ -654,8 +585,6 @@ export function TraceWorkspace({ onSearchQueryChange={setSearchQuery} prevId={prevId} nextId={nextId} - isPublic={isPublic} - onVisibilityChange={setIsPublic} readOnly={readOnly} />
@@ -702,9 +631,8 @@ export function TraceWorkspace({ commentCounts={commentCounts} prevId={prevId} nextId={nextId} - isPublic={isPublic} - onVisibilityChange={setIsPublic} readOnly={readOnly} + onCollapse={toggleCollapse} />
)} diff --git a/apps/dashboard/src/components/trace-detail/__tests__/DimensionBreakdownTooltip.test.tsx b/apps/dashboard/src/components/trace-detail/__tests__/DimensionBreakdownTooltip.test.tsx index d8a66a7f..5fd84fa8 100644 --- a/apps/dashboard/src/components/trace-detail/__tests__/DimensionBreakdownTooltip.test.tsx +++ b/apps/dashboard/src/components/trace-detail/__tests__/DimensionBreakdownTooltip.test.tsx @@ -7,7 +7,7 @@ import { RunCostBreakdownTooltip, } from "../DimensionBreakdownTooltip"; -// Critical regression guard (SPEC-136 ticket 11): the tooltip must NOT fetch +// Critical regression guard: the tooltip must NOT fetch // pricing client-side. If anything imports the deleted model-pricing module, // this test file would fail to load. vi.mock("@/lib/model-pricing", () => { diff --git a/apps/dashboard/src/components/trace-detail/__tests__/TraceTreeRunSummary.test.ts b/apps/dashboard/src/components/trace-detail/__tests__/TraceTreeRunSummary.test.ts new file mode 100644 index 00000000..2ca039ad --- /dev/null +++ b/apps/dashboard/src/components/trace-detail/__tests__/TraceTreeRunSummary.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from "vitest"; +import { getRunSummary } from "../TraceTree"; +import type { TraceObservation } from "../contexts"; + +function makeCall(overrides: Partial & { id: string }): TraceObservation { + return { + step_index: 0, + step_name: null, + model: "unknown", + created_at: "2026-01-01T00:00:00.000Z", + latency_ms: null, + cost: null, + input: null, + output: null, + task_id: null, + parent_call_id: null, + prompt_tokens: null, + completion_tokens: null, + total_tokens: null, + ...overrides, + } as TraceObservation; +} + +describe("getRunSummary", () => { + // Regression: the trace header rendered call.cost (micro-USD) as if it were + // USD, so a $0.44 run showed as "$440892.0000". + it("renders the aggregated cost as USD, not micro-USD", () => { + const calls = [ + makeCall({ id: "1", cost: 208741 }), + makeCall({ id: "2", cost: 19307 }), + makeCall({ id: "3", cost: 212844 }), + ]; + expect(getRunSummary(calls).cost).toBe("$0.4409"); + }); + + it("keeps sub-cent totals readable", () => { + expect(getRunSummary([makeCall({ id: "1", cost: 1_000 })]).cost).toBe("$0.001000"); + }); + + it("omits cost when nothing was billed", () => { + expect(getRunSummary([makeCall({ id: "1", cost: 0 })]).cost).toBeNull(); + expect(getRunSummary([makeCall({ id: "1" })]).cost).toBeNull(); + }); +}); diff --git a/apps/dashboard/src/components/trace-detail/__tests__/UrlSelectionContext.test.tsx b/apps/dashboard/src/components/trace-detail/__tests__/UrlSelectionContext.test.tsx index 01855d5d..0d2836d3 100644 --- a/apps/dashboard/src/components/trace-detail/__tests__/UrlSelectionContext.test.tsx +++ b/apps/dashboard/src/components/trace-detail/__tests__/UrlSelectionContext.test.tsx @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen, act } from "@testing-library/react"; const replaceMock = vi.fn(); @@ -52,19 +52,37 @@ function renderWithProvider( return render({ui}); } -function lastReplaceUrl(): string { - const last = replaceMock.mock.calls.at(-1); - return last ? (last[0] as string) : ""; +let historySpy: ReturnType; + +/** + * Selection updates are shallow (`window.history.replaceState`), so they read + * and write the real jsdom URL. Keep the mocked `useSearchParams` (initial + * render reads) and `window.location` (update reads/writes) in sync. + */ +function setLocation(params = "") { + searchParams = new URLSearchParams(params); + const qs = searchParams.toString(); + window.history.replaceState(null, "", `${pathname}${qs ? `?${qs}` : ""}`); + historySpy?.mockClear(); } -describe("UrlSelectionContext - initial state from URL", () => { - beforeEach(() => { - replaceMock.mockReset(); - pushMock.mockReset(); - searchParams = new URLSearchParams(); - pathname = "/project/test/traces/run-1"; - }); +function currentUrl(): string { + return `${window.location.pathname}${window.location.search}`; +} + +beforeEach(() => { + replaceMock.mockReset(); + pushMock.mockReset(); + pathname = "/project/test/traces/run-1"; + setLocation(); + historySpy = vi.spyOn(window.history, "replaceState"); +}); +afterEach(() => { + historySpy.mockRestore(); +}); + +describe("UrlSelectionContext - initial state from URL", () => { it("defaults to tree view and empty tab when no params", () => { renderWithProvider(); expect(screen.getByTestId("view").textContent).toBe("tree"); @@ -73,19 +91,19 @@ describe("UrlSelectionContext - initial state from URL", () => { }); it("reads observation param from URL on mount", () => { - searchParams = new URLSearchParams("observation=call-abc"); + setLocation("observation=call-abc"); renderWithProvider(); expect(screen.getByTestId("selectedCallId").textContent).toBe("call-abc"); }); it("reads view param from URL on mount", () => { - searchParams = new URLSearchParams("view=timeline"); + setLocation("view=timeline"); renderWithProvider(); expect(screen.getByTestId("view").textContent).toBe("timeline"); }); it("reads tab param from URL on mount", () => { - searchParams = new URLSearchParams("tab=tokens"); + setLocation("tab=tokens"); renderWithProvider(); expect(screen.getByTestId("detailTab").textContent).toBe("tokens"); }); @@ -97,22 +115,15 @@ describe("UrlSelectionContext - initial state from URL", () => { }); describe("UrlSelectionContext - view param validation", () => { - beforeEach(() => { - replaceMock.mockReset(); - pushMock.mockReset(); - searchParams = new URLSearchParams(); - pathname = "/project/test/traces/run-1"; - }); - it("falls back to tree for invalid view param", () => { - searchParams = new URLSearchParams("view=invalid-mode"); + setLocation("view=invalid-mode"); renderWithProvider(); expect(screen.getByTestId("view").textContent).toBe("tree"); }); it("supports all valid view values", () => { for (const v of ["tree", "timeline", "graph"] as const) { - searchParams = new URLSearchParams(`view=${v}`); + setLocation(`view=${v}`); const { unmount } = renderWithProvider(); expect(screen.getByTestId("view").textContent).toBe(v); unmount(); @@ -121,119 +132,100 @@ describe("UrlSelectionContext - view param validation", () => { }); describe("UrlSelectionContext - selectCall syncs URL", () => { - beforeEach(() => { - replaceMock.mockReset(); - pushMock.mockReset(); - searchParams = new URLSearchParams(); - pathname = "/project/test/traces/run-1"; - }); - it("sets observation param when selecting a call", () => { renderWithProvider(); act(() => screen.getByTestId("select-call").click()); - expect(replaceMock).toHaveBeenCalledTimes(1); - expect(lastReplaceUrl()).toContain("observation=call-123"); - expect(pushMock).not.toHaveBeenCalled(); + expect(historySpy).toHaveBeenCalledTimes(1); + expect(currentUrl()).toContain("observation=call-123"); }); it("removes observation param when selecting null", () => { - searchParams = new URLSearchParams("observation=call-old"); + setLocation("observation=call-old"); renderWithProvider(); act(() => screen.getByTestId("select-null").click()); - expect(replaceMock).toHaveBeenCalledTimes(1); - expect(lastReplaceUrl()).not.toContain("observation="); + expect(historySpy).toHaveBeenCalledTimes(1); + expect(currentUrl()).not.toContain("observation="); }); it("clearSelection removes observation param", () => { - searchParams = new URLSearchParams("observation=call-old"); + setLocation("observation=call-old"); renderWithProvider(); act(() => screen.getByTestId("clear").click()); - expect(lastReplaceUrl()).not.toContain("observation="); + expect(currentUrl()).not.toContain("observation="); }); it("preserves other params when updating observation", () => { - searchParams = new URLSearchParams("view=timeline&tab=tokens"); + setLocation("view=timeline&tab=tokens"); renderWithProvider(); act(() => screen.getByTestId("select-call").click()); - const url = lastReplaceUrl(); + const url = currentUrl(); expect(url).toContain("observation=call-123"); expect(url).toContain("view=timeline"); expect(url).toContain("tab=tokens"); }); -}); -describe("UrlSelectionContext - selectRun behavior", () => { - beforeEach(() => { - replaceMock.mockReset(); - pushMock.mockReset(); - searchParams = new URLSearchParams(); - pathname = "/project/test/traces/run-1"; + it("composes rapid successive updates from the live URL", () => { + renderWithProvider(); + // The mocked useSearchParams never updates, so both params surviving + // proves updates read window.location rather than the (stale) hook. + act(() => screen.getByTestId("select-call").click()); + act(() => screen.getByTestId("set-view-timeline").click()); + + const url = currentUrl(); + expect(url).toContain("observation=call-123"); + expect(url).toContain("view=timeline"); }); +}); +describe("UrlSelectionContext - selectRun behavior", () => { it("clears call selection when selecting the current run", () => { - searchParams = new URLSearchParams("observation=call-old"); + setLocation("observation=call-old"); renderWithProvider(, "run-1"); act(() => screen.getByTestId("select-run-same").click()); - expect(lastReplaceUrl()).not.toContain("observation="); + expect(currentUrl()).not.toContain("observation="); }); }); describe("UrlSelectionContext - view sync", () => { - beforeEach(() => { - replaceMock.mockReset(); - pushMock.mockReset(); - searchParams = new URLSearchParams(); - pathname = "/project/test/traces/run-1"; - }); - it("sets view param via setView", () => { renderWithProvider(); act(() => screen.getByTestId("set-view-timeline").click()); - expect(replaceMock).toHaveBeenCalledTimes(1); - expect(lastReplaceUrl()).toContain("view=timeline"); - expect(pushMock).not.toHaveBeenCalled(); + expect(historySpy).toHaveBeenCalledTimes(1); + expect(currentUrl()).toContain("view=timeline"); }); it("preserves observation when updating view", () => { - searchParams = new URLSearchParams("observation=call-x"); + setLocation("observation=call-x"); renderWithProvider(); act(() => screen.getByTestId("set-view-timeline").click()); - const url = lastReplaceUrl(); + const url = currentUrl(); expect(url).toContain("view=timeline"); expect(url).toContain("observation=call-x"); }); }); describe("UrlSelectionContext - detail tab sync", () => { - beforeEach(() => { - replaceMock.mockReset(); - pushMock.mockReset(); - searchParams = new URLSearchParams(); - pathname = "/project/test/traces/run-1"; - }); - it("sets tab param via setDetailTab", () => { renderWithProvider(); act(() => screen.getByTestId("set-tab-tokens").click()); - expect(replaceMock).toHaveBeenCalledTimes(1); - expect(lastReplaceUrl()).toContain("tab=tokens"); - expect(pushMock).not.toHaveBeenCalled(); + expect(historySpy).toHaveBeenCalledTimes(1); + expect(currentUrl()).toContain("tab=tokens"); }); it("preserves observation and view when updating tab", () => { - searchParams = new URLSearchParams("observation=call-x&view=timeline"); + setLocation("observation=call-x&view=timeline"); renderWithProvider(); act(() => screen.getByTestId("set-tab-tokens").click()); - const url = lastReplaceUrl(); + const url = currentUrl(); expect(url).toContain("tab=tokens"); expect(url).toContain("observation=call-x"); expect(url).toContain("view=timeline"); @@ -241,46 +233,36 @@ describe("UrlSelectionContext - detail tab sync", () => { }); describe("UrlSelectionContext - URL construction", () => { - beforeEach(() => { - replaceMock.mockReset(); - pushMock.mockReset(); - searchParams = new URLSearchParams(); - pathname = "/project/test/traces/run-1"; - }); - - it("preserves the current pathname in the replace URL", () => { + it("preserves the current pathname in the updated URL", () => { pathname = "/project/test/traces/run-42"; + setLocation(); renderWithProvider(, "run-42"); act(() => screen.getByTestId("select-call").click()); - expect(lastReplaceUrl()).toContain("/project/test/traces/run-42"); + expect(currentUrl()).toContain("/project/test/traces/run-42"); }); it("omits the query string entirely when no params remain", () => { - searchParams = new URLSearchParams("observation=only-one"); + setLocation("observation=only-one"); renderWithProvider(); act(() => screen.getByTestId("clear").click()); - expect(lastReplaceUrl()).toBe("/project/test/traces/run-1"); + expect(currentUrl()).toBe("/project/test/traces/run-1"); }); - it("passes scroll:false option to router.replace", () => { + it("updates shallowly without a router navigation", () => { renderWithProvider(); act(() => screen.getByTestId("select-call").click()); - const options = replaceMock.mock.calls.at(-1)?.[1]; - expect(options).toEqual({ scroll: false }); + // The whole point of the shallow update: the URL changes but the router + // never navigates, so the server component is not re-rendered. + expect(historySpy).toHaveBeenCalledTimes(1); + expect(replaceMock).not.toHaveBeenCalled(); + expect(pushMock).not.toHaveBeenCalled(); }); }); describe("UrlSelectionContext - throw outside provider", () => { - beforeEach(() => { - replaceMock.mockReset(); - pushMock.mockReset(); - searchParams = new URLSearchParams(); - pathname = "/project/test/traces/run-1"; - }); - it("throws when useSelection is used outside provider", () => { const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); diff --git a/apps/dashboard/src/components/trace-detail/__tests__/generation-chat-preview.test.ts b/apps/dashboard/src/components/trace-detail/__tests__/generation-chat-preview.test.ts new file mode 100644 index 00000000..7f73bdb9 --- /dev/null +++ b/apps/dashboard/src/components/trace-detail/__tests__/generation-chat-preview.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect } from "vitest"; +import { + combineGenerationMessages, + parseInputMessages, + synthesizeOutputMessages, +} from "../GenerationChatPreview"; + +describe("synthesizeOutputMessages", () => { + it("wraps a bare array of Anthropic content blocks as an assistant message", () => { + const blocks = [ + { type: "text", text: "Here is the answer." }, + { type: "tool_use", id: "tu_1", name: "search", input: { q: "foo" } }, + ]; + const result = synthesizeOutputMessages(blocks); + expect(result).toHaveLength(1); + expect(result[0]!.role).toBe("assistant"); + // text blocks stay as content + const content = result[0]!.content; + expect(Array.isArray(content)).toBe(true); + // tool_use blocks extracted to tool_calls (OpenAI shape) + expect(result[0]!.tool_calls).toHaveLength(1); + expect(result[0]!.tool_calls![0]!.function!.name).toBe("search"); + expect(result[0]!.tool_calls![0]!.function!.arguments).toBe('{"q":"foo"}'); + }); + + it("handles Anthropic reasoning + text blocks (no tool_use)", () => { + const blocks = [ + { type: "reasoning", text: "Let me think about this." }, + { type: "text", text: "The answer is 42." }, + ]; + const result = synthesizeOutputMessages(blocks); + expect(result).toHaveLength(1); + expect(result[0]!.role).toBe("assistant"); + expect(Array.isArray(result[0]!.content)).toBe(true); + expect(result[0]!.tool_calls).toBeUndefined(); + }); + + it("still handles {text} shape (issue #63)", () => { + const result = synthesizeOutputMessages({ text: "hello" }); + expect(result).toHaveLength(1); + expect(result[0]!.role).toBe("assistant"); + expect(result[0]!.content).toBe("hello"); + }); + + it("still handles {toolCalls} shape", () => { + const result = synthesizeOutputMessages({ + finishReason: "tool-calls", + toolCalls: [{ toolName: "list_files", input: "{}", toolCallId: "c1" }], + }); + expect(result).toHaveLength(1); + expect(result[0]!.tool_calls).toHaveLength(1); + expect(result[0]!.tool_calls![0]!.function!.name).toBe("list_files"); + }); +}); + +describe("parseInputMessages", () => { + it("accepts {prompt: string} as a single user message (Anthropic-style)", () => { + const result = parseInputMessages({ prompt: "What are the pricing plans?" }); + expect(result).toHaveLength(1); + expect(result[0]!.role).toBe("user"); + expect(result[0]!.content).toBe("What are the pricing plans?"); + }); + + it("still accepts {messages: [...]} (ChatML)", () => { + const result = parseInputMessages({ + messages: [ + { role: "system", content: "You are helpful." }, + { role: "user", content: "Hi" }, + ], + }); + expect(result).toHaveLength(2); + expect(result[0]!.role).toBe("system"); + expect(result[1]!.role).toBe("user"); + }); +}); + +describe("combineGenerationMessages", () => { + it("combines {prompt} input with Anthropic-blocks output", () => { + const combined = combineGenerationMessages( + { prompt: "Review this code." }, + [{ type: "text", text: "Looks good." }], + ); + // 1 user (from prompt) + 1 assistant (from blocks) = 2 + expect(combined).toHaveLength(2); + expect(combined[0]!.role).toBe("user"); + expect(combined[1]!.role).toBe("assistant"); + }); +}); diff --git a/apps/dashboard/src/components/trace-detail/__tests__/trace-gantt-utils.test.ts b/apps/dashboard/src/components/trace-detail/__tests__/trace-gantt-utils.test.ts index 0458f416..4b7348d9 100644 --- a/apps/dashboard/src/components/trace-detail/__tests__/trace-gantt-utils.test.ts +++ b/apps/dashboard/src/components/trace-detail/__tests__/trace-gantt-utils.test.ts @@ -178,22 +178,23 @@ describe("getInlineMetrics", () => { expect(metrics).toContain("2.5k tok"); }); + // call.cost is micro-USD, so 10_000 micro-USD = $0.01. it("includes cost when > 0", () => { - const call = makeCall({ id: "1", latency_ms: 100, cost: 0.01 }); + const call = makeCall({ id: "1", latency_ms: 100, cost: 10_000 }); const metrics = getInlineMetrics(call); expect(metrics).toContain("$0.0100"); }); it("formats small costs with more precision", () => { - const call = makeCall({ id: "1", latency_ms: 100, cost: 0.001 }); + const call = makeCall({ id: "1", latency_ms: 100, cost: 1_000 }); const metrics = getInlineMetrics(call); expect(metrics).toContain("$0.001000"); }); it("uses cumulative metrics for parent nodes", () => { - const call = makeCall({ id: "1", latency_ms: 100, total_tokens: 10, cost: 0.001 }); + const call = makeCall({ id: "1", latency_ms: 100, total_tokens: 10, cost: 1_000 }); const cumulative = { - cost: 0.05, + cost: 50_000, prompt_tokens: 1000, completion_tokens: 500, total_tokens: 1500, diff --git a/apps/dashboard/src/components/trace-detail/contexts/TraceDataContext.tsx b/apps/dashboard/src/components/trace-detail/contexts/TraceDataContext.tsx index dd2e10ea..5eaf7ac9 100644 --- a/apps/dashboard/src/components/trace-detail/contexts/TraceDataContext.tsx +++ b/apps/dashboard/src/components/trace-detail/contexts/TraceDataContext.tsx @@ -33,7 +33,6 @@ export interface Trace { duration_ms?: number; call_count: number; bookmarked?: boolean; - is_public?: boolean; task_run_id?: string | null; } @@ -75,7 +74,7 @@ export interface LoggedCall { prompt_id?: string | null; prompt_version?: number | null; - // Cost (SPEC-136): micro-USD int totals + per-call frozen storage. + // Cost: micro-USD int totals + per-call frozen storage. provided_cost?: number | null; cost_breakdown?: Record | null; raw_usage?: Record | null; diff --git a/apps/dashboard/src/components/trace-detail/contexts/UrlSelectionContext.tsx b/apps/dashboard/src/components/trace-detail/contexts/UrlSelectionContext.tsx index 148ed8bb..947ce96c 100644 --- a/apps/dashboard/src/components/trace-detail/contexts/UrlSelectionContext.tsx +++ b/apps/dashboard/src/components/trace-detail/contexts/UrlSelectionContext.tsx @@ -1,7 +1,8 @@ "use client"; import { createContext, useCallback, useMemo, ReactNode } from "react"; -import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { useSearchParams } from "next/navigation"; +import { setSearchParamShallow } from "@/lib/shallow-search-params"; import { SelectionContext, type NavigationView } from "./SelectionContext"; interface SelectionContextValue { @@ -39,28 +40,17 @@ export function UrlSelectionProvider({ runId: string; projectId?: string | null; }) { - const router = useRouter(); - const pathname = usePathname(); const searchParams = useSearchParams(); const selectedCallId = searchParams.get("observation"); const view = parseView(searchParams.get("view")); const detailTab = searchParams.get("tab") ?? ""; - const updateParam = useCallback( - (key: string, value: string | null) => { - const params = new URLSearchParams(searchParams.toString()); - if (value) { - params.set(key, value); - } else { - params.delete(key); - } - const qs = params.toString(); - router.replace(`${pathname}${qs ? `?${qs}` : ""}`, { - scroll: false, - }); - }, - [router, pathname, searchParams], - ); + // Shallow history update: `useSearchParams` consumers re-render, but the + // server component does NOT re-run — selection stays a pure client concern. + // Reads the live URL (not the hook) so rapid successive updates compose. + const updateParam = useCallback((key: string, value: string | null) => { + setSearchParamShallow(key, value); + }, []); const selectCall = useCallback( (callId: string | null) => { diff --git a/apps/dashboard/src/components/trace-detail/trace-gantt-utils.ts b/apps/dashboard/src/components/trace-detail/trace-gantt-utils.ts index b756f34d..fdc403e6 100644 --- a/apps/dashboard/src/components/trace-detail/trace-gantt-utils.ts +++ b/apps/dashboard/src/components/trace-detail/trace-gantt-utils.ts @@ -1,4 +1,4 @@ -import { formatDuration, formatTokenTotal } from "@/lib/format"; +import { formatCostMicro, formatDuration, formatTokenTotal } from "@/lib/format"; import type { CumulativeMetrics } from "@/lib/cumulative-metrics"; import type { LoggedCall } from "./contexts"; import { getSemanticType } from "./trace-utils"; @@ -228,7 +228,7 @@ export function getInlineMetricsStructured( }); } if (showCostTokens && dCost > 0) { - parts.push({ text: `$${dCost.toFixed(dCost < 0.01 ? 6 : 4)}`, kind: "cost" }); + parts.push({ text: formatCostMicro(dCost), kind: "cost" }); } return parts; } diff --git a/apps/dashboard/src/hooks/__tests__/use-url-state.test.tsx b/apps/dashboard/src/hooks/__tests__/use-url-state.test.tsx index ca58f239..685ee448 100644 --- a/apps/dashboard/src/hooks/__tests__/use-url-state.test.tsx +++ b/apps/dashboard/src/hooks/__tests__/use-url-state.test.tsx @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen, act } from "@testing-library/react"; const replaceMock = vi.fn(); @@ -7,7 +7,6 @@ let searchParams: URLSearchParams; let pathname: string; vi.mock("next/navigation", () => ({ - // Forward all args (url + options) so tests can assert e.g. { scroll: false }. useRouter: () => ({ replace: (...args: unknown[]) => replaceMock(...args), push: (...args: unknown[]) => pushMock(...args), @@ -18,44 +17,41 @@ vi.mock("next/navigation", () => ({ import { useUrlParam, useUrlParamSet } from "../use-url-state"; -function lastReplaceUrl(): string { - const last = replaceMock.mock.calls.at(-1); - return last ? (last[0] as string) : ""; -} - -function lastReplaceOptions() { - return replaceMock.mock.calls.at(-1)?.[1]; -} +let historySpy: ReturnType; /** - * Make router.replace behave like Next.js: after a replace, the next read of - * useSearchParams() should reflect the new query string. The real hook rebuilds - * URLSearchParams from the current search params each render, so a sequence of - * interactions (e.g. toggling two items) accumulates correctly only if the mock - * advances searchParams the way the router would. + * Writes are shallow (`window.history.replaceState`), so they read and write + * the real jsdom URL. Keep the mocked `useSearchParams` (initial render reads) + * and `window.location` (update reads/writes) in sync. */ -function syncSearchParamsOnReplace() { - replaceMock.mockImplementation((url: string) => { - const qIndex = url.indexOf("?"); - searchParams = new URLSearchParams(qIndex >= 0 ? url.slice(qIndex + 1) : ""); - }); +function setLocation(params = "") { + searchParams = new URLSearchParams(params); + const qs = searchParams.toString(); + window.history.replaceState(null, "", `${pathname}${qs ? `?${qs}` : ""}`); + historySpy?.mockClear(); +} + +function currentUrl(): string { + return `${window.location.pathname}${window.location.search}`; } -// Note: the mock advances searchParams after a replace, but jsdom won't -// re-render the component the way Next.js does. So tests that need a sequence -// of interactions should seed the URL with the prior state instead of relying -// on a prior click to persist it. The hook accumulates within a single render -// correctly (it rebuilds the set from the current URL each call), so seeding -// is faithful to real behavior. - -/** Extract the value(s) of one param from the last replaced URL. */ -function paramFromLastUrl(key: string): string { - const url = lastReplaceUrl(); - const qIndex = url.indexOf("?"); - const params = new URLSearchParams(qIndex >= 0 ? url.slice(qIndex + 1) : ""); - return params.get(key) ?? ""; +/** Extract the value of one param from the current URL. */ +function paramFromUrl(key: string): string { + return new URLSearchParams(window.location.search).get(key) ?? ""; } +beforeEach(() => { + replaceMock.mockReset(); + pushMock.mockReset(); + pathname = "/project/test/runs/run-1"; + setLocation(); + historySpy = vi.spyOn(window.history, "replaceState"); +}); + +afterEach(() => { + historySpy.mockRestore(); +}); + // ── useUrlParam ────────────────────────────────────────────────────────── function ParamConsumer({ paramKey, fallback }: { paramKey: string; fallback?: string }) { @@ -70,15 +66,8 @@ function ParamConsumer({ paramKey, fallback }: { paramKey: string; fallback?: st } describe("useUrlParam - initial state from URL", () => { - beforeEach(() => { - replaceMock.mockReset(); - pushMock.mockReset(); - searchParams = new URLSearchParams(); - pathname = "/project/test/runs/run-1"; - }); - it("reads value from URL on mount", () => { - searchParams = new URLSearchParams("q=hello"); + setLocation("q=hello"); render(); expect(screen.getByTestId("value").textContent).toBe("hello"); }); @@ -94,61 +83,49 @@ describe("useUrlParam - initial state from URL", () => { }); }); -describe("useUrlParam - writes sync URL via router.replace", () => { - beforeEach(() => { - replaceMock.mockReset(); - pushMock.mockReset(); - searchParams = new URLSearchParams(); - pathname = "/project/test/runs/run-1"; - syncSearchParamsOnReplace(); - }); - - it("sets the param and uses replace (not push)", () => { +describe("useUrlParam - writes sync URL shallowly", () => { + it("sets the param via a shallow history update, not a router navigation", () => { render(); act(() => screen.getByTestId("set").click()); - expect(replaceMock).toHaveBeenCalledTimes(1); - expect(lastReplaceUrl()).toContain("q=next"); + expect(historySpy).toHaveBeenCalledTimes(1); + expect(currentUrl()).toContain("q=next"); + expect(replaceMock).not.toHaveBeenCalled(); expect(pushMock).not.toHaveBeenCalled(); }); it("removes the param when set to null", () => { - searchParams = new URLSearchParams("q=stale"); + setLocation("q=stale"); render(); act(() => screen.getByTestId("clear").click()); - expect(lastReplaceUrl()).not.toContain("q="); + expect(currentUrl()).not.toContain("q="); }); it("preserves other params when writing", () => { - searchParams = new URLSearchParams("filter=failed&tab=checks"); + setLocation("filter=failed&tab=checks"); render(); act(() => screen.getByTestId("set").click()); - const url = lastReplaceUrl(); + const url = currentUrl(); expect(url).toContain("q=next"); expect(url).toContain("filter=failed"); expect(url).toContain("tab=checks"); }); - it("passes scroll:false to avoid viewport jumps", () => { - render(); - act(() => screen.getByTestId("set").click()); - expect(lastReplaceOptions()).toEqual({ scroll: false }); - }); - - it("keeps pathname in the replace URL", () => { + it("keeps pathname in the updated URL", () => { pathname = "/project/p1/runs/task/abc"; + setLocation(); render(); act(() => screen.getByTestId("set").click()); - expect(lastReplaceUrl()).toContain("/project/p1/runs/task/abc"); + expect(currentUrl()).toContain("/project/p1/runs/task/abc"); }); it("omits query string entirely when no params remain", () => { - searchParams = new URLSearchParams("q=only"); + setLocation("q=only"); render(); act(() => screen.getByTestId("clear").click()); - expect(lastReplaceUrl()).toBe("/project/test/runs/run-1"); + expect(currentUrl()).toBe("/project/test/runs/run-1"); }); }); @@ -169,14 +146,12 @@ function SetConsumer({ paramKey }: { paramKey: string }) { describe("useUrlParamSet - reads comma-separated values from URL", () => { beforeEach(() => { - replaceMock.mockReset(); - pushMock.mockReset(); - searchParams = new URLSearchParams(); pathname = "/project/test/runs/task/x"; + setLocation(); }); it("parses multiple values from URL on mount", () => { - searchParams = new URLSearchParams("check=a,b,c"); + setLocation("check=a,b,c"); render(); expect(screen.getByTestId("set").textContent).toBe("a,b,c"); }); @@ -187,7 +162,7 @@ describe("useUrlParamSet - reads comma-separated values from URL", () => { }); it("trims whitespace and ignores empty entries", () => { - searchParams = new URLSearchParams("check= a ,,b,"); + setLocation("check= a ,,b,"); render(); expect(screen.getByTestId("set").textContent).toBe("a,b"); }); @@ -195,62 +170,59 @@ describe("useUrlParamSet - reads comma-separated values from URL", () => { describe("useUrlParamSet - toggle writes to URL", () => { beforeEach(() => { - replaceMock.mockReset(); - pushMock.mockReset(); - searchParams = new URLSearchParams(); pathname = "/project/test/runs/task/x"; - syncSearchParamsOnReplace(); + setLocation(); }); - it("adds a value via toggle (replace, not push)", () => { + it("adds a value via a shallow history update, not a router navigation", () => { render(); act(() => screen.getByTestId("add-a").click()); - expect(replaceMock).toHaveBeenCalledTimes(1); - expect(lastReplaceUrl()).toContain("check=a"); + expect(historySpy).toHaveBeenCalledTimes(1); + expect(paramFromUrl("check")).toBe("a"); + expect(replaceMock).not.toHaveBeenCalled(); expect(pushMock).not.toHaveBeenCalled(); }); - it("adds multiple values to the same param", () => { - // Seed "a" already in the URL, then add "b". A single render must combine - // the existing URL value with the new toggle (jsdom won't re-render between - // clicks the way Next.js does; seeding mirrors the real re-rendered state). - searchParams = new URLSearchParams("check=a"); + it("accumulates values across successive toggles from the live URL", () => { + // No re-render happens between clicks (the mocked useSearchParams is + // static), so both values surviving proves toggle reads the live URL + // rather than a stale hook snapshot. render(); + act(() => screen.getByTestId("add-a").click()); act(() => screen.getByTestId("add-b").click()); - // Both values land in the single check param, comma-separated. - expect(paramFromLastUrl("check")).toBe("a,b"); + expect(paramFromUrl("check")).toBe("a,b"); }); it("removes a value when toggled off", () => { - searchParams = new URLSearchParams("check=a,b"); + setLocation("check=a,b"); render(); act(() => screen.getByTestId("add-a").click()); - expect(paramFromLastUrl("check")).toBe("b"); + expect(paramFromUrl("check")).toBe("b"); }); it("respects explicit open=false", () => { - searchParams = new URLSearchParams("check=a"); + setLocation("check=a"); render(); act(() => screen.getByTestId("remove-a").click()); - expect(lastReplaceUrl()).not.toContain("check="); + expect(currentUrl()).not.toContain("check="); }); it("respects explicit open=true even if already present", () => { - searchParams = new URLSearchParams("check=a"); + setLocation("check=a"); render(); act(() => screen.getByTestId("force-add-a").click()); - expect(paramFromLastUrl("check")).toBe("a"); + expect(paramFromUrl("check")).toBe("a"); }); it("preserves other params", () => { - searchParams = new URLSearchParams("tab=checks&other=1"); + setLocation("tab=checks&other=1"); render(); act(() => screen.getByTestId("add-a").click()); - const url = lastReplaceUrl(); + const url = currentUrl(); expect(url).toContain("check=a"); expect(url).toContain("tab=checks"); expect(url).toContain("other=1"); diff --git a/apps/dashboard/src/hooks/use-url-state.ts b/apps/dashboard/src/hooks/use-url-state.ts index 8638c590..6439104d 100644 --- a/apps/dashboard/src/hooks/use-url-state.ts +++ b/apps/dashboard/src/hooks/use-url-state.ts @@ -6,8 +6,13 @@ * Lets a component read/write a piece of UI state from the query string so the * page can be shared, bookmarked, or restored via back/forward. Mirrors the * pattern established by `UrlSelectionContext` (used by the trace workspace): - * it writes with `router.replace` + `{ scroll: false }` so selection changes - * don't pollute history or jump the viewport. + * writes are shallow (`window.history.replaceState` via + * `shallow-search-params`), so the URL updates and `useSearchParams` consumers + * re-render without the server component re-running — a `router.replace` on + * these `force-dynamic` pages would re-fetch the page's entire data set per + * click. Only use these hooks for params the server component does NOT read; + * server-driven params (pagination, sorting, filters) must keep using router + * navigation. * * NOTE: `useSearchParams` requires a boundary above any page that * renders these hooks at the top level. The pages that consume them wrap their @@ -15,7 +20,12 @@ */ import { useCallback } from "react"; -import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { useSearchParams } from "next/navigation"; + +import { + setSearchParamShallow, + updateSearchParamsShallow, +} from "@/lib/shallow-search-params"; /** * Read/write a single-valued query param. @@ -24,16 +34,14 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation"; * @param fallback Value used when the param is absent/empty. */ export function useUrlParam(key: string, fallback = ""): [string, (value: string | null) => void] { - const router = useRouter(); - const pathname = usePathname(); const searchParams = useSearchParams(); const value = searchParams.get(key) ?? fallback; const setValue = useCallback( (next: string | null) => { - updateParam(router, pathname, searchParams, key, next); + setSearchParamShallow(key, next); }, - [router, pathname, searchParams, key], + [key], ); return [value, setValue]; @@ -49,30 +57,31 @@ export function useUrlParamSet(key: string): [ (value: string, open?: boolean) => void, () => void, ] { - const router = useRouter(); - const pathname = usePathname(); const searchParams = useSearchParams(); const raw = searchParams.get(key) ?? ""; const values = parseCsvSet(raw); + // Reads the live URL inside the mutation, so rapid toggles compose instead + // of clobbering each other via a stale hook snapshot. const toggle = useCallback( (value: string, open?: boolean) => { - const next = parseCsvSet(raw); - const shouldOpen = open ?? !next.has(value); - if (shouldOpen) next.add(value); - else next.delete(value); - updateParam(router, pathname, searchParams, key, setToParam(next)); + updateSearchParamsShallow((params) => { + const next = parseCsvSet(params.get(key) ?? ""); + const shouldOpen = open ?? !next.has(value); + if (shouldOpen) next.add(value); + else next.delete(value); + const joined = setToParam(next); + if (joined) params.set(key, joined); + else params.delete(key); + }); }, - [router, pathname, searchParams, key, raw], + [key], ); - // Clear every value at once. The toggle closure reads a stale `raw` snapshot, - // so removing items in a loop is unreliable — this writes the empty set - // directly in one router update. const clearAll = useCallback(() => { - updateParam(router, pathname, searchParams, key, null); - }, [router, pathname, searchParams, key]); + setSearchParamShallow(key, null); + }, [key]); return [values, toggle, clearAll]; } @@ -89,24 +98,6 @@ function parseCsvSet(raw: string): Set { ); } -/** Shared writer: rebuild the query string, preserving other params. */ -function updateParam( - router: ReturnType, - pathname: string, - searchParams: ReturnType, - key: string, - value: string | null, -): void { - const params = new URLSearchParams(searchParams.toString()); - if (value) { - params.set(key, value); - } else { - params.delete(key); - } - const qs = params.toString(); - router.replace(`${pathname}${qs ? `?${qs}` : ""}`, { scroll: false }); -} - function setToParam(set: Set): string | null { const joined = Array.from(set).filter(Boolean).join(","); return joined || null; diff --git a/apps/dashboard/src/lib/__tests__/agent-task-api.test.ts b/apps/dashboard/src/lib/__tests__/agent-task-api.test.ts index f6917494..156984bb 100644 --- a/apps/dashboard/src/lib/__tests__/agent-task-api.test.ts +++ b/apps/dashboard/src/lib/__tests__/agent-task-api.test.ts @@ -44,7 +44,6 @@ describe("getProjectAgentTask", () => { display_name: "data-extraction", adapter_name: "claudeAdapter", has_checks: true, - has_user_simulator: false, tags: [], run_stats: null, }, @@ -75,7 +74,6 @@ describe("getProjectAgentTask", () => { display_name: "data-extraction-copy", adapter_name: "claudeAdapter", has_checks: true, - has_user_simulator: false, tags: [], run_stats: null, }, diff --git a/apps/dashboard/src/lib/__tests__/api-keys-api.test.ts b/apps/dashboard/src/lib/__tests__/api-keys-api.test.ts new file mode 100644 index 00000000..29c7b184 --- /dev/null +++ b/apps/dashboard/src/lib/__tests__/api-keys-api.test.ts @@ -0,0 +1,207 @@ +/** + * the dashboard API helper defaults to the + * least-privileged ``ingest`` scope. The previous default of ``full`` made + * every dashboard-minted key a management credential, which is unsafe for + * the common telemetry-producer issuance flow. + * + * Issue #72: the backend serializes API-key responses in snake_case + * (``public_key``, ``secret_key``, ``display_secret_key``). The dashboard + * boundary must normalize them to camelCase so the reveal dialog and list + * rows receive real values instead of ``undefined``. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../config", () => ({ + getBrowserBackendBaseUrl: () => "http://localhost:8000", +})); + +vi.mock("../backend-fetch", () => ({ + backendFetch: (url: string, init: RequestInit) => fetch(url, init), +})); + +import { + createApiKey, + listApiKeys, + rotateApiKey, +} from "../api-keys-api"; +import { ApiError } from "../api-error"; + +const mockFetch = vi.fn(); +globalThis.fetch = mockFetch; + +// Realistic backend fixtures — exactly what FastAPI/SQLModel serializes today +// (snake_case key fields). These MUST drive the dashboard adapters; camelCase +// fixtures would hide the casing bug (issue #72). +const snakeCaseCreateResponse = { + id: "k1", + name: "Production", + prefix: "pk-apo-", + project: "example-service", + created_by: "u1", + scope: "ingest", + created_at: "2026-07-30T00:00:00", + last_used_at: null, + expires_at: null, + public_key: "pk-apo-abc123", + secret_key: "sk-apo-secret456", + display_secret_key: "sk-apo-se••••", +}; + +const snakeCaseListResponse = [ + { + id: "k1", + name: "Production", + prefix: "pk-apo-", + project: "example-service", + created_by: "u1", + scope: "ingest", + created_at: "2026-07-30T00:00:00", + last_used_at: null, + expires_at: null, + public_key: "pk-apo-abc123", + display_secret_key: "sk-apo-se••••", + }, +]; + +const snakeCaseRotateResponse = { + id: "k1", + public_key: "pk-apo-newpub", + secret_key: "sk-apo-newsecret", + message: "Key rotated successfully. The old key is no longer valid.", +}; + +beforeEach(() => { + mockFetch.mockReset(); + mockFetch.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + id: "k1", + name: "n", + prefix: "p", + project: "proj", + created_by: "u", + scope: "ingest", + created_at: "now", + last_used_at: null, + expires_at: null, + }), + }); +}); + +describe("createApiKey default scope", () => { + it("defaults to ingest when scope is omitted", async () => { + await createApiKey("Production", "example-service"); + const [, init] = mockFetch.mock.calls[0]; + expect(init?.body).toEqual( + JSON.stringify({ + name: "Production", + project: "example-service", + scope: "ingest", + expires_at: null, + }), + ); + }); + + it("still passes an explicit full scope through", async () => { + await createApiKey("CLI", "example-service", "full"); + const [, init] = mockFetch.mock.calls[0]; + expect(JSON.parse(String(init?.body)).scope).toBe("full"); + }); +}); + +describe("createApiKey response normalization (issue #72)", () => { + it("maps snake_case key fields to camelCase so the reveal dialog gets values", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(snakeCaseCreateResponse), + }); + + const result = await createApiKey("Production", "example-service", "ingest"); + + expect(result.publicKey).toBe("pk-apo-abc123"); + expect(result.secretKey).toBe("sk-apo-secret456"); + expect(result.displaySecretKey).toBe("sk-apo-se••••"); + }); + + it("preserves the legacy single-key `key` field for bootstrap-created keys", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + ...snakeCaseCreateResponse, + public_key: null, + secret_key: null, + display_secret_key: null, + key: "sk-legacy-key", + }), + }); + + const result = await createApiKey("CLI", "example-service", "full"); + + expect(result.key).toBe("sk-legacy-key"); + expect(result.publicKey).toBeNull(); + expect(result.secretKey).toBeNull(); + }); +}); + +describe("rotateApiKey response normalization (issue #72)", () => { + it("maps snake_case key fields to camelCase so rotation reveals the new pair", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(snakeCaseRotateResponse), + }); + + const result = await rotateApiKey("k1"); + + expect(result.publicKey).toBe("pk-apo-newpub"); + expect(result.secretKey).toBe("sk-apo-newsecret"); + expect(result.id).toBe("k1"); + }); +}); + +describe("listApiKeys response normalization (issue #72)", () => { + it("maps snake_case public_key/display_secret_key to camelCase for list rows", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(snakeCaseListResponse), + }); + + const result = await listApiKeys("example-service"); + + expect(result).toHaveLength(1); + expect(result[0].publicKey).toBe("pk-apo-abc123"); + expect(result[0].displaySecretKey).toBe("sk-apo-se••••"); + }); + + it("never exposes the full secret in list responses", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(snakeCaseListResponse), + }); + + const result = await listApiKeys("example-service"); + + // The normalized row must not carry the secret in either casing. + const leaked = result[0] as unknown as Record; + expect(leaked).not.toHaveProperty("secret_key"); + expect(leaked).not.toHaveProperty("secretKey"); + }); +}); + +describe("API error path (issue #72)", () => { + it("throws an ApiError carrying the status when creation fails", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 422, + statusText: "Unprocessable Entity", + json: () => Promise.resolve({ detail: "Expiry must be in the future" }), + text: () => Promise.resolve('{"detail":"Expiry must be in the future"}'), + }); + + await expect(createApiKey("Bad", "example-service")).rejects.toSatisfy( + (err: unknown) => err instanceof ApiError && err.status === 422, + ); + }); +}); diff --git a/apps/dashboard/src/lib/__tests__/backend-url-split.test.ts b/apps/dashboard/src/lib/__tests__/backend-url-split.test.ts index b20f5633..0d58d383 100644 --- a/apps/dashboard/src/lib/__tests__/backend-url-split.test.ts +++ b/apps/dashboard/src/lib/__tests__/backend-url-split.test.ts @@ -1,5 +1,5 @@ /** - * Tests for SPEC-132 Behavior 3: same-origin browser URL contract. + * Tests for same-origin browser URL contract. * * The dashboard must never let browser code resolve a request to the * Docker-internal backend URL (e.g. `http://backend:8000`). A remote diff --git a/apps/dashboard/src/lib/__tests__/bundle-no-leak.test.ts b/apps/dashboard/src/lib/__tests__/bundle-no-leak.test.ts index ffcb2797..cb4084ba 100644 --- a/apps/dashboard/src/lib/__tests__/bundle-no-leak.test.ts +++ b/apps/dashboard/src/lib/__tests__/bundle-no-leak.test.ts @@ -1,5 +1,5 @@ /** - * SPEC-132 Behavior 3 regression guard: client bundles must never embed + * client bundles must never embed * the Docker-internal backend URL or localhost:8000. * * `NEXT_PUBLIC_*` env vars are inlined into client chunks at build time. @@ -18,7 +18,7 @@ const chunksDir = join(process.cwd(), ".next", "static", "chunks"); const skip = !existsSync(chunksDir); -describe.skipIf(skip)("client bundle URL safety (SPEC-132)", () => { +describe.skipIf(skip)("client bundle URL safety", () => { function readAllChunks(): string { const files = readdirSync(chunksDir).filter((f) => f.endsWith(".js")); return files diff --git a/apps/dashboard/src/lib/__tests__/check-diagnostics.test.ts b/apps/dashboard/src/lib/__tests__/check-diagnostics.test.ts index 2b8bef93..e985abc0 100644 --- a/apps/dashboard/src/lib/__tests__/check-diagnostics.test.ts +++ b/apps/dashboard/src/lib/__tests__/check-diagnostics.test.ts @@ -193,17 +193,16 @@ describe("buildCheckDiagnostics", () => { ]); }); - it("emits green info markers for t.judge passes alongside red errors for failures", () => { - // Marker policy: failures always get red markers, but only Judge - // passes earn green markers. A passing t.check() / t.calledTool() is - // baseline OK and should not clutter the gutter — its line stays plain. + it("emits green info markers for passes alongside red errors for failures", () => { + // Marker policy: failures get red markers and passes get green markers, + // regardless of whether the assertion uses code or an LLM judge. const diagnostics = buildCheckDiagnostics( { id: "mixed-check", pass: false, reasoning: "1 judge + 1 t.check failed", assertions: [ - // Passing t.calledTool — should NOT get any marker. + // Passing t.calledTool — should get a green info marker. { id: "calledTool('read_file')", pass: true, @@ -236,6 +235,17 @@ describe("buildCheckDiagnostics", () => { 10, ); expect(diagnostics).toEqual([ + { + line: 4, + column: undefined, + message: "passed", + severity: "info", + label: "calledTool('read_file')", + expected: undefined, + received: undefined, + reasoning: undefined, + evaluator_type: "code", + }, { line: 5, column: undefined, diff --git a/apps/dashboard/src/lib/__tests__/check-source-candidates.test.ts b/apps/dashboard/src/lib/__tests__/check-source-candidates.test.ts index dc671b84..af2c863b 100644 --- a/apps/dashboard/src/lib/__tests__/check-source-candidates.test.ts +++ b/apps/dashboard/src/lib/__tests__/check-source-candidates.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { buildSourceCandidates, + checkAnchorLine, shouldAcceptSource, } from "../check-source-candidates"; @@ -112,3 +113,45 @@ describe("shouldAcceptSource", () => { ).toBe(false); }); }); + +describe("checkAnchorLine", () => { + it("returns the check-level location line when present", () => { + expect(checkAnchorLine({ location: { line: 42 } })).toBe(42); + }); + + it("falls back to the first assertion location line when check-level is null", () => { + // Issue #126: table-driven evals record the line on assertions[].location, + // not on checks[].location — which is null. + expect( + checkAnchorLine({ + assertions: [ + { location: { line: 78 } }, + { location: { line: 82 } }, + ], + }), + ).toBe(78); + }); + + it("skips assertions with no location and uses the first one that has one", () => { + expect( + checkAnchorLine({ + assertions: [ + { location: null }, + { location: { line: 55 } }, + ], + }), + ).toBe(55); + }); + + it("returns undefined when neither check nor assertions have a location", () => { + expect(checkAnchorLine({ assertions: [{}, { location: null }] })).toBeUndefined(); + }); + + it("returns undefined when assertions is empty", () => { + expect(checkAnchorLine({ assertions: [] })).toBeUndefined(); + }); + + it("returns undefined when assertions is null/undefined", () => { + expect(checkAnchorLine({})).toBeUndefined(); + }); +}); diff --git a/apps/dashboard/src/lib/__tests__/conversation-from-trace.test.ts b/apps/dashboard/src/lib/__tests__/conversation-from-trace.test.ts new file mode 100644 index 00000000..27c1a276 --- /dev/null +++ b/apps/dashboard/src/lib/__tests__/conversation-from-trace.test.ts @@ -0,0 +1,160 @@ +import { describe, it, expect } from "vitest"; + +import { deriveConversationFromTrace } from "../conversation-from-trace"; +import type { LoggedCall, TraceDetail } from "@/components/trace-detail/contexts"; + +function makeCall(overrides: Partial = {}): LoggedCall { + return { + id: "call-1", + step_index: null, + step_name: "generation", + model: "claude-sonnet-4-6", + created_at: "2026-07-27T10:00:00Z", + task_id: "t", + observation_type: "GENERATION", + input: {}, + output: {}, + ...overrides, + }; +} + +function makeTrace(calls: LoggedCall[]): TraceDetail { + return { + run: { + id: "trace-1", + project: "p", + scopeKey: null, + environment: "default", + created_at: "2026-07-27T10:00:00Z", + call_count: calls.length, + }, + metrics: [], + calls, + }; +} + +describe("deriveConversationFromTrace", () => { + describe("native SDK traces (messages arrays)", () => { + it("reads the last generation's input+output messages", () => { + const trace = makeTrace([ + makeCall({ + id: "gen-1", + input: { + messages: [ + { role: "system", content: "You are helpful." }, + { role: "user", content: "What is 2+2?" }, + ], + }, + output: { + messages: [{ role: "assistant", content: "It's 4." }], + }, + }), + ]); + + const result = deriveConversationFromTrace(trace); + expect(result.messages.map((m) => `${m.role}:${m.content}`)).toEqual([ + "system:You are helpful.", + "user:What is 2+2?", + "assistant:It's 4.", + ]); + }); + }); + + describe("imported traces with provider content blocks (issue #47)", () => { + it("reconstructs conversation from content-block outputs when messages are absent", () => { + const trace = makeTrace([ + makeCall({ + id: "gen-1", + step_index: 0, + input: "What plans do you offer?", + output: [{ type: "text", text: "We offer Starter and Business plans." }], + }), + ]); + + const result = deriveConversationFromTrace(trace); + expect(result.messages.length).toBeGreaterThan(0); + const assistant = result.messages.find((m) => m.role === "assistant"); + expect(assistant?.content).toContain("Starter and Business"); + }); + + it("walks multiple generations in order, extracting user input + assistant output", () => { + const trace = makeTrace([ + makeCall({ + id: "gen-1", + step_index: 0, + input: "What is the Starter price?", + output: [{ type: "text", text: "Starter is €108/month." }], + }), + makeCall({ + id: "gen-2", + step_index: 1, + input: { tool_results: [{ tool_use_id: "t1", preview: "Pricing data loaded" }] }, + output: [{ type: "text", text: "Business is €600/month." }], + }), + ]); + + const result = deriveConversationFromTrace(trace); + const contents = result.messages.map((m) => m.content); + + // Both assistant responses should appear + expect(contents).toContain("Starter is €108/month."); + expect(contents).toContain("Business is €600/month."); + // The tool result should surface as context + expect(result.messages.some((m) => m.content.includes("Pricing data loaded"))).toBe(true); + }); + + it("joins multiple text blocks in a single output into one message", () => { + const trace = makeTrace([ + makeCall({ + id: "gen-1", + step_index: 0, + input: "Tell me about your plans", + output: [ + { type: "reasoning", text: "The user wants pricing info." }, + { type: "text", text: "We have three plans available." }, + ], + }), + ]); + + const result = deriveConversationFromTrace(trace); + const assistant = result.messages.find((m) => m.role === "assistant"); + expect(assistant?.content).toContain("three plans"); + }); + }); + + describe("edge cases", () => { + it("returns empty for a null trace", () => { + expect(deriveConversationFromTrace(null).messages).toEqual([]); + }); + + it("returns empty when there are no calls", () => { + expect(deriveConversationFromTrace(makeTrace([])).messages).toEqual([]); + }); + + it("returns empty when no calls have any extractable content", () => { + const trace = makeTrace([ + makeCall({ id: "gen-1", input: {}, output: {} }), + ]); + expect(deriveConversationFromTrace(trace).messages).toEqual([]); + }); + + it("prefers messages arrays over raw content blocks when both exist", () => { + const trace = makeTrace([ + makeCall({ + id: "gen-1", + input: { + messages: [ + { role: "user", content: "From messages array" }, + ], + }, + output: [{ type: "text", text: "From content blocks" }], + }), + ]); + + const result = deriveConversationFromTrace(trace); + // The messages-array path should win + expect(result.messages.some((m) => m.content === "From messages array")).toBe(true); + expect(result.messages.some((m) => m.content === "From content blocks")).toBe(false); + }); + }); +}); diff --git a/apps/dashboard/src/lib/__tests__/extract-check-block.test.ts b/apps/dashboard/src/lib/__tests__/extract-check-block.test.ts index 1ef1697e..e6560dcc 100644 --- a/apps/dashboard/src/lib/__tests__/extract-check-block.test.ts +++ b/apps/dashboard/src/lib/__tests__/extract-check-block.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { extractCheckBlock } from "../extract-check-block"; -const source = `import { test } from "@apo/sdk/agent-task"; +const source = `import { test } from "@apo-ai/sdk/agent-task"; test("quality", async (t, { deliverables }) => { t.calledTool("read_file"); @@ -32,7 +32,7 @@ describe("extractCheckBlock", () => { }); it("extracts a check registered via a typed alias (const check = test)", () => { - const aliasedSource = `import { test } from "@apo/sdk/agent-task"; + const aliasedSource = `import { test } from "@apo-ai/sdk/agent-task"; const check = test; @@ -59,6 +59,28 @@ check("used-read-and-search-tools", async (t) => { expect(extractCheckBlock(aliasedSource, { id: "used-read-and-search-tools" })?.startLine).toBe(10); }); + it("extracts a concise-body arrow check without swallowing the next one", () => { + const conciseSource = `test("E3: spacedout expanded spacing", (_t, { deliverables }) => + expect(has(deliverables, /\\[spacedout\\]\\{[^}]*char-spacing="\\d+"/i)).toBe(true)); +test("E4: shadedrun shaded, not black", (_t, { deliverables }) => { + expect(has(deliverables, /\\[shadedrun\\]/)).toBe(true); +}); +`; + expect(extractCheckBlock(conciseSource, { id: "E3: spacedout expanded spacing" })).toEqual({ + code: `test("E3: spacedout expanded spacing", (_t, { deliverables }) => + expect(has(deliverables, /\\[spacedout\\]\\{[^}]*char-spacing="\\d+"/i)).toBe(true));`, + startLine: 1, + endLine: 2, + }); + expect(extractCheckBlock(conciseSource, { id: "E4: shadedrun shaded, not black" })?.startLine).toBe(3); + }); + + it("extracts a trailing concise-body check (no braced check follows)", () => { + const conciseSource = `test("last", (_t, { deliverables }) => expect(deliverables.a).toBe(1)); +`; + expect(extractCheckBlock(conciseSource, { id: "last" })?.endLine).toBe(1); + }); + it("anchors from a failure line within an aliased check", () => { const aliasedSource = `const check = test; check("quality", async (t) => { diff --git a/apps/dashboard/src/lib/__tests__/load-check-source.test.ts b/apps/dashboard/src/lib/__tests__/load-check-source.test.ts new file mode 100644 index 00000000..3d33e217 --- /dev/null +++ b/apps/dashboard/src/lib/__tests__/load-check-source.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it, vi } from "vitest"; + +import { loadCheckSource, type DefinitionRef, type LoadCheckSourceDeps } from "../load-check-source"; + +/** + * The compare view rendered "Task not found" for every check when recording to + * a hosted backend: it resolved source only through `readTaskFile`, which + * discovers tasks on the *backend's* filesystem, so tasks executing on a + * developer machine could never be found. + * + * The pinned Task Definition (SPEC-169) is the authoritative source and is + * already stored per-run. These tests pin the precedence the run detail view + * established — definition first, the retired filesystem resolver only as a + * legacy fallback — as a pure function, because the vitest JSX transform in + * this repo cannot parse .tsx imports. + */ + +const DEFINITION = { content: 'test("C-001", () => {})', language: "typescript" }; +const FILESYSTEM = { content: 'test("C-001", () => { /* from disk */ })', language: "typescript" }; + +const notFound = (taskId: string) => new Error(`Task not found: ${taskId}`); + +const makeDeps = (overrides: Partial = {}): LoadCheckSourceDeps => ({ + readDefinitionSource: vi.fn(async () => DEFINITION), + readTaskFile: vi.fn(async () => FILESYSTEM), + ...overrides, +}); + +const REFS: DefinitionRef[] = [ + { runId: "run_a", filePath: "cost-inquiry.eval.ts" }, + { runId: "run_b", filePath: "cost-inquiry.eval.ts" }, +]; + +const load = (args: { + deps: LoadCheckSourceDeps; + definitionRefs?: DefinitionRef[]; + commitSha?: string | null; + recordedSourceFile?: string; +}) => + loadCheckSource({ + taskId: "chat/cost-inquiry", + recordedSourceFile: args.recordedSourceFile ?? "cost-inquiry.eval.ts", + commitSha: args.commitSha ?? null, + definitionRefs: args.definitionRefs ?? REFS, + containsKnownCheck: (content) => content.includes('test("C-001"'), + deps: args.deps, + }); + +describe("loadCheckSource", () => { + it("prefers the pinned Task Definition and never touches the filesystem resolver", async () => { + const deps = makeDeps(); + + const source = await load({ deps }); + + expect(source.content).toBe(DEFINITION.content); + expect(deps.readDefinitionSource).toHaveBeenCalledWith("run_a", "cost-inquiry.eval.ts"); + expect(deps.readTaskFile).not.toHaveBeenCalled(); + }); + + it("returns the definition even when the filesystem resolver would 404 — the original bug", async () => { + const deps = makeDeps({ + readTaskFile: vi.fn(async () => { + throw notFound("chat/cost-inquiry"); + }), + }); + + const source = await load({ deps }); + + expect(source.content).toBe(DEFINITION.content); + }); + + it("tries the other run's definition when the first run's read fails", async () => { + const readDefinitionSource = vi.fn(async (runId: string) => { + if (runId === "run_a") throw new Error("definition_source_not_found"); + return DEFINITION; + }); + + const source = await load({ deps: makeDeps({ readDefinitionSource }) }); + + expect(source.content).toBe(DEFINITION.content); + expect(readDefinitionSource).toHaveBeenCalledTimes(2); + }); + + it("falls back to the filesystem resolver when no definition is pinned", async () => { + const deps = makeDeps(); + + const source = await load({ deps, definitionRefs: [] }); + + expect(source.content).toBe(FILESYSTEM.content); + expect(deps.readDefinitionSource).not.toHaveBeenCalled(); + }); + + it("falls back to the filesystem resolver when every definition read fails", async () => { + const deps = makeDeps({ + readDefinitionSource: vi.fn(async () => { + throw new Error("definition_source_not_found"); + }), + }); + + const source = await load({ deps }); + + expect(source.content).toBe(FILESYSTEM.content); + expect(deps.readTaskFile).toHaveBeenCalled(); + }); + + it("retries the filesystem resolver without the commit sha", async () => { + // A run with no published repository has task_source_commit_sha = null; + // a stale/unknown sha must not strand the viewer. + const readTaskFile = vi.fn(async (_taskId: string, _candidate: string, commitSha?: string) => { + if (commitSha) throw new Error("unknown revision"); + return FILESYSTEM; + }); + + const source = await load({ + deps: makeDeps({ readTaskFile }), + definitionRefs: [], + commitSha: "deadbeef", + }); + + expect(source.content).toBe(FILESYSTEM.content); + expect(readTaskFile).toHaveBeenCalledTimes(2); + }); + + it("surfaces the last error when neither source resolves", async () => { + const deps = makeDeps({ + readDefinitionSource: vi.fn(async () => { + throw new Error("definition_source_not_found"); + }), + readTaskFile: vi.fn(async () => { + throw notFound("chat/cost-inquiry"); + }), + }); + + await expect(load({ deps })).rejects.toThrow("Task not found: chat/cost-inquiry"); + }); + + it("skips a definition ref that carries no file path", async () => { + const readDefinitionSource = vi.fn(async () => DEFINITION); + + const source = await load({ + deps: makeDeps({ readDefinitionSource }), + definitionRefs: [{ runId: "run_a", filePath: "" }, REFS[1]], + }); + + expect(source.content).toBe(DEFINITION.content); + expect(readDefinitionSource).toHaveBeenCalledTimes(1); + expect(readDefinitionSource).toHaveBeenCalledWith("run_b", "cost-inquiry.eval.ts"); + }); +}); diff --git a/apps/dashboard/src/lib/__tests__/t-api-highlight.test.ts b/apps/dashboard/src/lib/__tests__/t-api-highlight.test.ts index 1d44525f..1283901e 100644 --- a/apps/dashboard/src/lib/__tests__/t-api-highlight.test.ts +++ b/apps/dashboard/src/lib/__tests__/t-api-highlight.test.ts @@ -5,7 +5,7 @@ import { TEST_METHOD_NAMES as DASHBOARD_METHODS } from "../t-api-methods"; // the browser bundle. The SDK is the single source of truth; the dashboard // keeps a local copy (see t-api-methods.ts) only to avoid bundling the SDK's // server runtime. This assertion is the drift guard. -import { TEST_METHOD_NAMES as SDK_METHODS } from "@apo/sdk/agent-task"; +import { TEST_METHOD_NAMES as SDK_METHODS } from "@apo-ai/sdk/agent-task"; /** * Regression tests for the colorizer drift bug: the dashboard used to keep its diff --git a/apps/dashboard/src/lib/agent-task-api.ts b/apps/dashboard/src/lib/agent-task-api.ts index 1ca8c3ec..e105f431 100644 --- a/apps/dashboard/src/lib/agent-task-api.ts +++ b/apps/dashboard/src/lib/agent-task-api.ts @@ -30,7 +30,6 @@ export interface AgentTaskSummary { display_name: string; adapter_name: string; has_checks: boolean; - has_user_simulator: boolean; tags: string[]; run_stats: AgentTaskRunStats | null; } @@ -63,7 +62,7 @@ export interface FailureBreakdownItem { count: number; } -// SPEC-148: adapter-reported Run Configuration (typed, indexed dimensions). +// adapter-reported Run Configuration (typed, indexed dimensions). export interface AgentTaskRunConfiguration { model: string; effort: string | null; @@ -71,6 +70,20 @@ export interface AgentTaskRunConfiguration { export type BatchRunConfigurationState = "uniform" | "mixed" | "partial" | "unknown"; +export interface ModelFacetOption { + model: string; + count: number; +} + +export interface PaginatedBatchRunSummary { + data: AgentTaskBatchRunSummary[]; + total_count: number; + page: number; + page_size: number; + total_pages: number; + model_facets: ModelFacetOption[]; +} + export interface AgentTaskRunConfigurationCount extends AgentTaskRunConfiguration { task_runs: number; } @@ -98,6 +111,7 @@ export interface AgentTaskRunSummary { task_source_commit_sha: string | null; error_message: string | null; total_cost: number | null; + total_tokens: number | null; total_checks: number; passed_checks: number; failed_checks: number; @@ -105,12 +119,18 @@ export interface AgentTaskRunSummary { trace_persistence_status: TracePersistenceStatus; trace_error_message: string | null; error_category: string | null; - /** SPEC-148: adapter-reported model/effort. Absent when not reported. */ + /** adapter-reported model/effort. Absent when not reported. */ run_configuration: AgentTaskRunConfiguration | null; } export type EvaluatorType = "llm" | "code" | "regex"; +/** typed catalog selection stored on a source-owned Schedule. */ +export type ScheduleSelection = + | { kind: "tasks"; task_ids: string[] } + | { kind: "folder"; folder_id: string } + | { kind: "all" }; + /** Source location of a failed code check — for inline, editor-style display. */ export interface CheckLocation { file: string; @@ -157,15 +177,34 @@ export interface CheckResult { source_file?: string; /** Individual soft assertions recorded inside this check. */ assertions?: CheckAssertionResult[]; + /** id of the describe() group this check was declared inside. */ + group_id?: string; + /** display name of the enclosing describe() group. */ + group_name?: string; +} + +export interface TaskDefinitionFileSummary { + path: string; + language: "typescript"; + size_bytes: number; + lines: number; +} + +export interface TaskDefinitionRevisionSummary { + id: string; + digest: string; + files: TaskDefinitionFileSummary[]; + created_at: string | null; } export interface AgentTaskRunDetail extends AgentTaskRunSummary { - total_tokens?: number | null; checks_json: CheckResult[] | null; transcript_json: Record | null; deliverables_json: Record | null; deliverables?: DeliverableSummary[]; error_category: string | null; + /** SPEC-169: pinned Task Definition for CodeMirror source display. */ + task_definition?: TaskDefinitionRevisionSummary | null; } export interface AgentTaskBatchRunSummary { @@ -189,17 +228,18 @@ export interface AgentTaskBatchRunSummary { trigger: AgentTaskRunTrigger | null; trace_persistence_status: TracePersistenceStatus; trace_error_message: string | null; - /** SPEC-148: derived configuration summary (uniform/mixed/partial/unknown). */ + total_cost: number | null; + total_tokens: number | null; + /** derived configuration summary (uniform/mixed/partial/unknown). */ configuration: AgentTaskBatchRunConfigurationSummary; } export interface AgentTaskBatchRunDetail extends AgentTaskBatchRunSummary { run_metadata: Record | null; - total_cost: number | null; cancelled_tasks: number; task_runs: AgentTaskRunSummary[]; failure_breakdown: FailureBreakdownItem[]; - execution_target: PoolExecutionTarget | null; + execution_target: ExecutionTarget | null; executor_pool_name: string | null; attempts: ExecutionAttemptSummary[]; } @@ -209,16 +249,38 @@ export interface PoolExecutionTarget { pool_id: string; } +/** target for a dashboard Run through the User's Connected Executors. */ +export interface SourceOwnedExecutionTarget { + kind: "source_owned"; +} + +export type ExecutionTarget = PoolExecutionTarget | SourceOwnedExecutionTarget; + +/** aggregate state of one member's Connected Executors. */ +export type ConnectedEnvironmentState = + | "ready" + | "busy" + | "offline" + | "incompatible" + | "catalog_mismatch" + | "not_connected"; + +/** Dynamic waiting reason for a queued source-owned Attempt. */ +export type AttemptWaitingReason = ConnectedEnvironmentState; + export interface ExecutionAttemptSummary { id: string; task_run_id: string; status: string; phase: string | null; + assignment_kind: "caller" | "bundled" | "source_owned"; executor_id: string | null; executor_name: string | null; executor_pool_id: string | null; driver_kind: string | null; queued_at: string; + queue_expires_at: string | null; + waiting_reason: AttemptWaitingReason | null; claimed_at: string | null; started_at: string | null; heartbeat_at: string | null; @@ -228,14 +290,17 @@ export interface ExecutionAttemptSummary { cancel_requested_at: string | null; } +/** + * Mirrors the backend model exactly, which forbids extra fields: a run is + * source-owned by definition, so `selection_type` and `execution_target` are + * derived server-side and rejected on the request. Keep this in sync with + * `CreateAgentTaskBatchRunRequest` in `backend/apo/models/schemas.py`. + */ export interface CreateAgentTaskBatchRunRequest { project: string; - selection_type: string; - task_paths?: string[]; - task_root?: string | null; - grep?: string | null; + /** exact catalog Task IDs. */ + task_ids: string[]; environment?: string; - execution_target: PoolExecutionTarget; run_metadata?: { trigger?: Partial | null; [key: string]: unknown; @@ -305,16 +370,61 @@ export interface AgentTaskScheduleSummary { updated_at: string; last_batch: ScheduleLastBatchSummary | null; consecutive_failures: number; + // source-owned scheduled delivery projection. + execution_kind: "source_owned" | "bundled"; + execution_owner: ScheduleExecutionOwnerSummary | null; + connected_environment_state: ConnectedEnvironmentState | null; + active_batch_run_id: string | null; + latest_occurrence: ScheduleOccurrenceSummary | null; + missed_occurrences: number; } export interface AgentTaskScheduleDetail extends AgentTaskScheduleSummary { run_metadata: Record | null; } +/** the fixed User whose Connected Executors run a source-owned Schedule. */ +export interface ScheduleExecutionOwnerSummary { + id: string; + name: string; +} + +export type ScheduleOccurrenceStatus = + | "pending" + | "delivered" + | "missed" + | "cancelled"; + +export type OccurrenceMissedReason = + | "previous_occurrence_active" + | "executor_unavailable" + | "catalog_changed" + | "selection_empty"; + +export interface ScheduleOccurrenceSummary { + id: string; + kind: "scheduled" | "manual"; + scheduled_for: string; + status: ScheduleOccurrenceStatus; + batch_run_id: string | null; + missed_reason: OccurrenceMissedReason | null; + resolved_at: string | null; +} + +/** the active Batch (existing or newly created). */ +export interface TriggerScheduleResponse { + batch_run_id: string | null; + occurrence_id: string | null; + created: boolean; + schedule: AgentTaskScheduleSummary; +} + export interface CreateAgentTaskScheduleRequest { project: string; name: string; selection_type?: string; + /** typed catalog selection for source-owned schedules. */ + selection?: ScheduleSelection; task_paths?: string[]; task_root?: string | null; grep?: string | null; @@ -328,7 +438,7 @@ export interface CreateAgentTaskScheduleRequest { min_interval_days?: number; max_interval_days?: number; enabled?: boolean; - executor_pool_id: string; + executor_pool_id?: string | null; queue_ttl_seconds?: number | null; run_metadata?: Record | null; } @@ -391,7 +501,7 @@ export const getAgentTask = ( }); /** - * SPEC-119: canonical project-scoped task list backed by persisted + * canonical project-scoped task list backed by persisted * inventory. Use this in place of `listAgentTasks(taskRoot, ..., project)` * whenever the project's task source is configured. Returns an empty * array when the source is configured but has no tasks yet (a valid @@ -444,11 +554,26 @@ export const createAgentTaskBatchRun = ( export const listAgentTaskBatchRuns = ( project?: string, - status?: string, -): Promise => + opts?: { + status?: string; + q?: string; + model?: string[]; + effort?: string[]; + page?: number; + page_size?: number; + }, +): Promise => apiClient("/v1/agent-task-batch-runs", { ...NO_CACHE, - query: { project, status }, + query: { + project, + status: opts?.status, + q: opts?.q, + model: opts?.model?.join(",") || undefined, + effort: opts?.effort?.join(",") || undefined, + page: opts?.page, + page_size: opts?.page_size, + }, }); export const getAgentTaskBatchRun = ( @@ -535,12 +660,22 @@ export const deleteAgentTaskSchedule = ( export const triggerSchedule = ( scheduleId: string, -): Promise<{ batch_run_id: string; schedule: AgentTaskScheduleSummary }> => +): Promise => apiClient( `/v1/agent-task-schedules/${encodeURIComponent(scheduleId)}/trigger`, { method: "POST" }, ); +/** bounded newest-first Occurrence history (membership-scoped). */ +export const listScheduleOccurrences = ( + scheduleId: string, + limit = 20, +): Promise<{ occurrences: ScheduleOccurrenceSummary[] }> => + apiClient( + `/v1/agent-task-schedules/${encodeURIComponent(scheduleId)}/occurrences`, + { ...NO_CACHE, query: { limit } }, + ); + export const getAdaptiveStates = ( scheduleId: string, ): Promise => @@ -548,3 +683,27 @@ export const getAdaptiveStates = ( `/v1/agent-task-schedules/${encodeURIComponent(scheduleId)}/adaptive-states`, NO_CACHE, ); + +/** SPEC-169: read the pinned Task Definition source for CodeMirror display. + * Run-bound: authorization resolves from task_run_id → batch.project. */ +export function readTaskDefinitionSource( + taskRunId: string, + filePath: string, + signal?: AbortSignal, +): Promise { + return apiClient("/v1/task-definition-source", { + ...NO_CACHE, + query: { task_run_id: taskRunId, file_path: filePath }, + signal, + }); +} + +/** idempotently cancel a Batch's Attempts. Reused by source-owned + * and legacy Pool Runs. Returns the number of Attempts touched. */ +export const cancelAgentTaskBatchRun = ( + batchRunId: string, +): Promise<{ ok: true; cancelled: number }> => + apiClient( + `/v1/agent-task-batch-runs/${encodeURIComponent(batchRunId)}/cancel`, + { method: "POST", body: {} }, + ); diff --git a/apps/dashboard/src/lib/agent-task-deliverables-api.ts b/apps/dashboard/src/lib/agent-task-deliverables-api.ts index 07f6f995..7937ff66 100644 --- a/apps/dashboard/src/lib/agent-task-deliverables-api.ts +++ b/apps/dashboard/src/lib/agent-task-deliverables-api.ts @@ -1,5 +1,5 @@ /** - * SPEC-140 ticket 07: dashboard Deliverable API helpers. + * dashboard Deliverable API helpers. * * The Task Run page fetches only the manifest (metadata). JSON bodies load one * at a time on row expansion with an AbortController; artifact downloads use diff --git a/apps/dashboard/src/lib/api-keys-api.ts b/apps/dashboard/src/lib/api-keys-api.ts index 5e9458cf..7f97c1af 100644 --- a/apps/dashboard/src/lib/api-keys-api.ts +++ b/apps/dashboard/src/lib/api-keys-api.ts @@ -49,25 +49,122 @@ export interface ApiKeyRotateResponse { message: string; } +/** + * Raw shape FastAPI/SQLModel serializes over the wire (snake_case). + * + * The dashboard types below read these as camelCase, so the boundary must + * normalize explicitly — see {@link adaptCreateResponse} et al. Defining the + * raw contract here keeps the adapter honest: a field the backend adds shows + * up as a typecheck rather than silently becoming ``undefined`` on the client. + */ +interface RawApiKey { + id: string; + name: string; + prefix: string; + project: string; + created_by: string; + scope: string; + created_at: string; + last_used_at: string | null; + expires_at: string | null; + public_key?: string | null; + secret_key?: string | null; + display_secret_key?: string | null; +} + +interface RawApiKeyCreateResponse extends RawApiKey { + /** Legacy single key (sk-xxx). Absent for two-key model keys. */ + key?: string | null; +} + +interface RawApiKeyRotateResponse { + id: string; + key?: string | null; + public_key?: string | null; + secret_key?: string | null; + message: string; +} + +/** Normalize a two-key field (snake → camel), defaulting missing to ``null``. */ +function adaptCreateResponse( + raw: RawApiKeyCreateResponse, +): ApiKeyCreateResponse { + return { + id: raw.id, + name: raw.name, + prefix: raw.prefix, + project: raw.project, + created_by: raw.created_by, + scope: raw.scope, + created_at: raw.created_at, + last_used_at: raw.last_used_at, + expires_at: raw.expires_at, + key: raw.key ?? null, + publicKey: raw.public_key ?? null, + secretKey: raw.secret_key ?? null, + displaySecretKey: raw.display_secret_key ?? null, + }; +} + +/** Normalize a list row. List responses never carry the secret key. */ +function adaptApiKey(raw: RawApiKey): ApiKey { + return { + id: raw.id, + name: raw.name, + prefix: raw.prefix, + project: raw.project, + created_by: raw.created_by, + scope: raw.scope, + created_at: raw.created_at, + last_used_at: raw.last_used_at, + expires_at: raw.expires_at, + publicKey: raw.public_key ?? null, + displaySecretKey: raw.display_secret_key ?? null, + }; +} + +/** Normalize a rotation response (snake → camel), preserving the legacy key. */ +function adaptRotateResponse( + raw: RawApiKeyRotateResponse, +): ApiKeyRotateResponse { + return { + id: raw.id, + key: raw.key ?? null, + publicKey: raw.public_key ?? null, + secretKey: raw.secret_key ?? null, + message: raw.message, + }; +} + +/** + * Create a new API key pair. + * + * defaults to ``ingest`` (least privilege) — the common + * telemetry-producer use case. Pass ``"full"`` explicitly for CLI and + * management credentials. + */ export const createApiKey = ( name: string, project: string, - scope: ApiKeyScope = "full", + scope: ApiKeyScope = "ingest", expiresAt?: string, ): Promise => - apiClient("/v1/api-keys", { + apiClient("/v1/api-keys", { method: "POST", body: { name, project, scope, expires_at: expiresAt ?? null }, - }); + }).then(adaptCreateResponse); export const listApiKeys = (project?: string): Promise => - apiClient("/v1/api-keys", { + apiClient("/v1/api-keys", { cache: "no-store", query: project ? { project } : undefined, - }); + }).then((rows) => rows.map(adaptApiKey)); export const revokeApiKey = (id: string): Promise => apiClient(`/v1/api-keys/${id}`, { method: "DELETE" }); export const rotateApiKey = (id: string): Promise => - apiClient(`/v1/api-keys/${id}/rotate`, { method: "POST" }); + apiClient( + `/v1/api-keys/${id}/rotate`, + { method: "POST" }, + ).then(adaptRotateResponse); diff --git a/apps/dashboard/src/lib/check-diagnostics.ts b/apps/dashboard/src/lib/check-diagnostics.ts index cc90d0f4..bd248b68 100644 --- a/apps/dashboard/src/lib/check-diagnostics.ts +++ b/apps/dashboard/src/lib/check-diagnostics.ts @@ -56,10 +56,12 @@ function formatAssertionMessage(assertion: CheckAssertionResult): string { /** * Decide whether an assertion earns a gutter marker. * + * Failures always get a red marker. Among passes, only LLM judge verdicts * All assertions get a marker — passes (green) and failures (red). Showing * passes lets users click a green marker to see the actual values (e.g. * "received: 6" for maxToolCalls), the same way they click red markers to - * see why something failed. + * see why something failed. This catches checks that pass for the wrong + * reason — e.g. a substring match that happens to hit the right word. */ function shouldShowMarker(_assertion: CheckAssertionResult): boolean { return true; diff --git a/apps/dashboard/src/lib/check-source-candidates.ts b/apps/dashboard/src/lib/check-source-candidates.ts index 5c0ecd68..6197c1c8 100644 --- a/apps/dashboard/src/lib/check-source-candidates.ts +++ b/apps/dashboard/src/lib/check-source-candidates.ts @@ -74,3 +74,26 @@ export function shouldAcceptSource(input: AcceptSourceInput): boolean { function dedupe(items: string[]): string[] { return [...new Set(items)]; } + +/** + * Structural shape needed to derive an anchor line from a check result. + * Compatible with `CheckResult` without importing it (avoids a cycle). + */ +export interface CheckAnchorSource { + location?: { line?: number } | null; + assertions?: Array<{ location?: { line?: number } | null }> | null; +} + +/** + * The best available source line for a check, for anchoring + * {@link extractCheckBlock} when the check's id doesn't appear literally in + * the source (table-driven evals with generated titles — issue #126). + * + * The check-level `location` is preferred; when it's absent (the common case + * for generated-title checks, where only assertions carry a line), the first + * assertion with a location is used. + */ +export function checkAnchorLine(check: CheckAnchorSource): number | undefined { + if (check.location?.line) return check.location.line; + return check.assertions?.find((a) => a.location?.line)?.location?.line; +} diff --git a/apps/dashboard/src/lib/conversation-from-trace.ts b/apps/dashboard/src/lib/conversation-from-trace.ts index caa54af9..0eded404 100644 --- a/apps/dashboard/src/lib/conversation-from-trace.ts +++ b/apps/dashboard/src/lib/conversation-from-trace.ts @@ -15,6 +15,13 @@ * * Messages are already normalized to OpenAI shape by the backend * (`normalize_genai_message` in `otel_normalization/_shared.py`). + * + * **Imported traces fallback (issue #47):** traces imported via + * `traces import langfuse` carry provider-native content blocks (e.g. + * Anthropic `[{type:"text", text:…}]`) under `input`/`output`, not OpenAI + * `messages` arrays. When the primary path finds no messages, the fallback + * walks the ordered call sequence and reconstructs the conversation by + * extracting text from each call's raw I/O. */ import type { LoggedCall, TraceDetail } from "@/components/trace-detail/contexts"; @@ -45,7 +52,7 @@ export interface ConversationView { * Reconstruct the conversation view from a trace's generation calls. * * Returns `{ messages: [] }` when there is no trace, no generation calls, or - * the last generation carries no messages — callers render an empty state. + * the calls carry no extractable content — callers render an empty state. */ export function deriveConversationFromTrace( trace: TraceDetail | null, @@ -57,13 +64,138 @@ export function deriveConversationFromTrace( .sort(compareCallOrder); if (generations.length === 0) return EMPTY; + // Primary path: the last generation's accumulated messages array (SDK-native + // traces where the backend normalizes gen_ai.*.messages to OpenAI shape). const last = generations[generations.length - 1]; const inputMessages = readMessages(last.input); const outputMessages = readMessages(last.output); const combined = [...inputMessages, ...outputMessages]; - if (combined.length === 0) return EMPTY; + if (combined.length > 0) { + return { messages: dedupe(combined) }; + } + + // Fallback (issue #47): imported traces carry provider-native content blocks, + // not messages arrays. Walk the ordered call sequence and reconstruct the + // conversation from raw I/O. + const reconstructed = reconstructFromRawCalls( + [...trace.calls].sort(compareCallOrder), + ); + if (reconstructed.length === 0) return EMPTY; + return { messages: reconstructed }; +} + +// --------------------------------------------------------------------------- +// Fallback: reconstruct from raw provider-native I/O (issue #47) +// --------------------------------------------------------------------------- + +/** + * Walk the ordered call sequence and build chat messages from raw input/output. + * + * Each GENERATION contributes an assistant message (from its output) and + * optionally a user/tool-context message (from its input). Each TOOL call + * contributes a tool-call + tool-result pair. Provider content blocks like + * `[{type:"text", text:"…"}]` are flattened to text. + */ +function reconstructFromRawCalls(calls: LoggedCall[]): ChatMessage[] { + const messages: ChatMessage[] = []; + for (const call of calls) { + if (isGeneration(call)) { + const inputText = extractText(call.input); + if (inputText) { + messages.push({ role: "user", content: inputText }); + } + const outputText = extractText(call.output); + if (outputText) { + messages.push({ role: "assistant", content: outputText }); + } + } else if (isToolCall(call)) { + if (call.tool_name) { + messages.push({ + role: "assistant", + content: "", + tool_calls: [ + { + type: "function", + function: { + name: call.tool_name, + arguments: JSON.stringify(call.tool_parameters ?? {}), + }, + }, + ], + }); + } + const resultText = extractText(call.tool_result ?? call.output); + if (resultText) { + messages.push({ role: "tool", content: resultText }); + } + } + } + return dedupe(messages); +} + +/** + * Extract a human-readable text string from a raw I/O value. + * + * Handles the shapes imported traces carry: + * - String prompts → returned as-is + * - Provider content-block arrays `[{type:"text", text:"…"}]` → joined text + * - Dicts with `tool_results` → each result's `preview`/`text` joined + * - Already-normalized `{messages: […]}` → skipped (primary path handles these) + * - Other dicts → compact JSON + */ +function extractText(value: unknown): string { + if (value == null) return ""; + if (typeof value === "string") return value.trim(); + if (typeof value === "number" || typeof value === "boolean") return String(value); + if (Array.isArray(value)) { + return value + .map((block) => extractTextFromBlock(block)) + .filter(Boolean) + .join("\n") + .trim(); + } + if (typeof value === "object") { + const obj = value as Record; + // Skip — the primary messages-array path handles this. + if (Array.isArray(obj.messages)) return ""; + // Langfuse/Anthropic tool_results wrapper. + if (Array.isArray(obj.tool_results)) { + return obj.tool_results + .map((r) => extractTextFromBlock(r)) + .filter(Boolean) + .join("\n") + .trim(); + } + // Generic dict → compact JSON so content is never silently dropped. + const json = JSON.stringify(value, null, 2); + return json === "{}" ? "" : json; + } + return ""; +} + +/** Extract text from a single provider content block. */ +function extractTextFromBlock(block: unknown): string { + if (block == null) return ""; + if (typeof block === "string") return block; + if (typeof block === "number" || typeof block === "boolean") return String(block); + if (typeof block === "object" && block !== null) { + const obj = block as Record; + // Anthropic / Bedrock: {type:"text"|"reasoning", text:"…"} + if (typeof obj.text === "string") return obj.text; + // Generic content field + if (typeof obj.content === "string") return obj.content; + // Tool result preview + if (typeof obj.preview === "string") return obj.preview; + // Nested input/output dicts + if (typeof obj.input === "string") return obj.input; + if (typeof obj.output === "string") return obj.output; + } + return ""; +} - return { messages: dedupe(combined) }; +function isToolCall(call: LoggedCall): boolean { + if (call.observation_type === "TOOL") return true; + return getSemanticType(call) === "TOOL"; } function isGeneration(call: LoggedCall): boolean { diff --git a/apps/dashboard/src/lib/executor-api.ts b/apps/dashboard/src/lib/executor-api.ts index 6f1cd453..83aa55c5 100644 --- a/apps/dashboard/src/lib/executor-api.ts +++ b/apps/dashboard/src/lib/executor-api.ts @@ -15,6 +15,31 @@ export interface ExecutorPoolSummary { required_driver_kind: string; } +/** aggregate state of the acting User's Connected Executors. */ +export type ConnectedEnvironmentState = + | "ready" + | "busy" + | "offline" + | "incompatible" + | "catalog_mismatch" + | "not_connected"; + +export interface ConnectedEnvironmentStatus { + state: ConnectedEnvironmentState; +} + +/** read the current User's aggregate Connected Environment state. + * Returns only ``{ state }`` — never Executor IDs, names, or credentials. */ +export async function getConnectedEnvironmentStatus( + projectId: string, + signal?: AbortSignal, +): Promise { + return apiClient( + `/v1/projects/${encodeURIComponent(projectId)}/connected-executor-status`, + { cache: "no-store", signal }, + ); +} + interface ExecutorPoolListResponse { pools: ExecutorPoolSummary[]; } diff --git a/apps/dashboard/src/lib/extract-check-block.ts b/apps/dashboard/src/lib/extract-check-block.ts index daff6535..600f897f 100644 --- a/apps/dashboard/src/lib/extract-check-block.ts +++ b/apps/dashboard/src/lib/extract-check-block.ts @@ -24,6 +24,17 @@ export interface CheckBlock { const BASE_OPENER_NAMES = ["test", "defineCheck"]; +/** + * Code characters after which a `/` opens a regex literal rather than being a + * division operator (`(`, `,`, `=`, `>` as in `=>`, `!`, `&`, `|`, `?`, `:`, + * `{`, `;`, `[`, and the arithmetic operators). After an identifier, digit, + * `)` or `]` — i.e. a value — it is division. + */ +const REGEX_PRECEDERS = new Set([ + "(", ",", "=", ">", "<", "!", "&", "|", "?", ":", "{", "}", ";", "[", "+", + "-", "*", "%", "^", "~", "/", +]); + function escapeRegex(s: string): string { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } @@ -112,10 +123,28 @@ function findOpener( * From the opener line, find the matching close by counting braces, ignoring * braces inside string/template literals and comments. Returns the line index * of the closing `}` (the block usually ends `});` on that line). + * + * A concise-body arrow check — `test("id", (t, { deliverables }) => expr);` — + * never opens a body brace, so it ends where the registration call's own + * parens close. */ function findBlockEnd(lines: string[], startIdx: number): number { - type State = "code" | "sq" | "dq" | "tpl" | "lineComment" | "blockComment"; + type State = + | "code" + | "sq" + | "dq" + | "tpl" + | "lineComment" + | "blockComment" + | "regex"; let state: State = "code"; + // Last non-space code character seen, used to tell a regex literal from a + // division: `/` after an operator/opener starts a regex, after a value + // divides. Checks are regex-heavy (`expect(has(d, /\[x\]\{[^}]*a="b"/i))`), + // and an unrecognized regex hands its braces and quotes to the scanner — + // which then mismatches the block or runs to EOF. + let prev = ""; + let inCharClass = false; // Track parenthesis depth so braces inside the check's argument list — // e.g. the `(t, { deliverables })` destructure — are NOT mistaken for the // body. The body-open `{` is the first `{` at the call's own paren level @@ -152,6 +181,13 @@ function findBlockEnd(lines: string[], startIdx: number): number { else if (c === "`") state = "code"; continue; } + if (state === "regex") { + if (c === "\\") j++; + else if (c === "[") inCharClass = true; + else if (c === "]") inCharClass = false; + else if (c === "/" && !inCharClass) state = "code"; + continue; + } // state === "code" if (c === "/" && next === "/") break; // rest of line is comment if (c === "/" && next === "*") { @@ -159,6 +195,12 @@ function findBlockEnd(lines: string[], startIdx: number): number { j++; continue; } + if (c === "/" && (prev === "" || REGEX_PRECEDERS.has(prev))) { + state = "regex"; + inCharClass = false; + continue; + } + if (c !== " " && c !== "\t") prev = c; if (c === "'" || c === '"' || c === "`") { state = c === "'" ? "sq" : c === '"' ? "dq" : "tpl"; continue; @@ -169,6 +211,10 @@ function findBlockEnd(lines: string[], startIdx: number): number { } if (c === ")") { parenDepth--; + // Concise body: the call closed without ever opening a body brace, so + // this line is the end of the check. Without this the scan runs on and + // swallows the NEXT braced check (or hits EOF and drops the block). + if (parenDepth === 0 && !seenOpen) return i; continue; } if (c === "{") { diff --git a/apps/dashboard/src/lib/format.ts b/apps/dashboard/src/lib/format.ts index 5ebb343e..5f928577 100644 --- a/apps/dashboard/src/lib/format.ts +++ b/apps/dashboard/src/lib/format.ts @@ -44,10 +44,10 @@ export function usdFormat(value: number | null): string { export const formatCost = usdFormat; /** - * Format a micro-USD integer (SPEC-136 cost storage unit) as a USD string. + * Format a micro-USD integer as a USD string. * Costs are stored as integers (micro-USD = USD * 1e6); divide by 1e6 first. * Use this for call.cost / run.total_cost / breakdown values coming from the - * backend post-SPEC-136. + * backend current. */ export function formatCostMicro(microUsd: number | null | undefined): string { if (microUsd == null) return "\u2014"; diff --git a/apps/dashboard/src/lib/github-api.ts b/apps/dashboard/src/lib/github-api.ts deleted file mode 100644 index 0af80822..00000000 --- a/apps/dashboard/src/lib/github-api.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { apiClient } from "./api-client"; -import { isApiError } from "./api-error"; - -export interface GithubAvailability { - enabled: boolean; - client_id: string | null; -} - -export interface GithubRepo { - id: number; - full_name: string; - name: string; - clone_url: string; - default_branch: string; - private: boolean; - pushed_at: string | null; -} - -export interface GithubBranch { - name: string; - protected: boolean; -} - -export interface GithubPathEntry { - name: string; - path: string; - type: "file" | "dir" | "symlink" | "submodule"; -} - -export interface GithubConnection { - project: string; - github_username: string | null; - github_user_id: string; - scopes_granted: string | null; - connected_at: string | null; -} - -const NO_CACHE = { cache: "no-store" } as const; - -export async function getGithubAvailability( - projectId: string, -): Promise { - try { - return await apiClient( - `/v1/projects/${encodeURIComponent(projectId)}/github/availability`, - NO_CACHE, - ); - } catch (error) { - // 503 means the integration is not configured — surface as disabled - // instead of throwing, so the form can render the manual URL fallback. - if (isApiError(error) && error.status === 503) { - return { enabled: false, client_id: null }; - } - throw error; - } -} - -export const getGithubAuthUrl = ( - projectId: string, - next?: string, -): Promise<{ url: string }> => - apiClient( - `/v1/projects/${encodeURIComponent(projectId)}/github/auth-url`, - { ...NO_CACHE, query: next ? { next } : undefined }, - ); - -export async function getGithubConnection( - projectId: string, -): Promise { - try { - return await apiClient( - `/v1/projects/${encodeURIComponent(projectId)}/github/connection`, - NO_CACHE, - ); - } catch (error) { - // 503 = integration not configured → no connection. - if (isApiError(error) && error.status === 503) return null; - throw error; - } -} - -export const listGithubRepos = (projectId: string): Promise => - apiClient( - `/v1/projects/${encodeURIComponent(projectId)}/github/repos`, - NO_CACHE, - ); - -export const listGithubBranches = ( - projectId: string, - owner: string, - repo: string, -): Promise => - apiClient( - `/v1/projects/${encodeURIComponent(projectId)}/github/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/branches`, - NO_CACHE, - ); - -export const listRepoContents = ( - projectId: string, - owner: string, - repo: string, - ref: string, - path: string, -): Promise => - apiClient( - `/v1/projects/${encodeURIComponent(projectId)}/github/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents`, - { ...NO_CACHE, query: { ref, path } }, - ); - -export const disconnectGithub = (projectId: string): Promise => - apiClient( - `/v1/projects/${encodeURIComponent(projectId)}/github/connection`, - { method: "DELETE" }, - ); diff --git a/apps/dashboard/src/lib/load-check-source.ts b/apps/dashboard/src/lib/load-check-source.ts new file mode 100644 index 00000000..0532228b --- /dev/null +++ b/apps/dashboard/src/lib/load-check-source.ts @@ -0,0 +1,99 @@ +/** + * Resolve the source of a check for the code viewers. + * + * Two resolvers exist, and the order matters: + * + * 1. **The pinned Task Definition (SPEC-169)** — the exact `*.eval.ts` text + * captured at run time and stored server-side, addressed per-run. This is + * authoritative: it is the source that produced the stored Check evidence, + * and it works regardless of where the Task executed. + * 2. **The task-files resolver** — retired, kept as a legacy fallback for runs + * with no pinned definition. It discovers Tasks on the *backend's own + * filesystem*, so it can only ever succeed when the backend shares a + * filesystem with the Task tree (a local backend). For a Task executing on a + * developer machine and recording to a hosted backend it always 404s. + * + * The compare view previously used only (2), so every check in a comparison of + * developer-executed runs rendered "Task not found" — while the definition sat + * pinned to both runs. Extracted here as a pure, dependency-injected function + * so the precedence is testable: the vitest JSX transform in this repo cannot + * parse `.tsx` imports, so logic living inside a component is untestable. + */ + +import { buildSourceCandidates, shouldAcceptSource } from "./check-source-candidates"; + +export interface CheckSource { + content: string; + language?: string | null; +} + +/** A run and the definition file path pinned to it. */ +export interface DefinitionRef { + runId: string; + filePath: string; +} + +export interface LoadCheckSourceDeps { + /** SPEC-169 run-bound read of the pinned Task Definition source. */ + readDefinitionSource: (runId: string, filePath: string) => Promise; + /** Legacy filesystem-backed read; `commitSha` pins a published revision. */ + readTaskFile: (taskId: string, candidate: string, commitSha?: string) => Promise; +} + +export interface LoadCheckSourceInput { + taskId: string; + /** The authoritative `source_file` recorded on the check result, if any. */ + recordedSourceFile: string | undefined; + commitSha: string | null; + /** + * Pinned definitions to try, in order. A comparison passes both sides; the + * two runs of one task normally pin the same revision, so either resolves. + */ + definitionRefs: DefinitionRef[]; + /** Whether loaded text contains a check block the caller recognizes. */ + containsKnownCheck: (content: string) => boolean; + deps: LoadCheckSourceDeps; +} + +export async function loadCheckSource(input: LoadCheckSourceInput): Promise { + let lastError: unknown; + + for (const ref of input.definitionRefs) { + if (!ref.runId || !ref.filePath) continue; + try { + return await input.deps.readDefinitionSource(ref.runId, ref.filePath); + } catch (error) { + lastError = error; + } + } + + const candidates = buildSourceCandidates(input.recordedSourceFile, input.taskId); + for (const candidate of candidates) { + try { + let source: CheckSource; + try { + source = await input.deps.readTaskFile(input.taskId, candidate, input.commitSha ?? undefined); + } catch (error) { + // A stale or unpublished revision must not strand the viewer when the + // unpinned read would succeed. + if (!input.commitSha) throw error; + source = await input.deps.readTaskFile(input.taskId, candidate); + } + if ( + shouldAcceptSource({ + candidate, + recordedSourceFile: input.recordedSourceFile, + containsKnownCheck: input.containsKnownCheck(source.content), + isLastCandidate: candidate === candidates[candidates.length - 1], + }) + ) { + return source; + } + } catch (error) { + lastError = error; + } + } + + if (lastError instanceof Error) throw lastError; + throw new Error("Could not load check source — no pinned Task Definition and no readable task file"); +} diff --git a/apps/dashboard/src/lib/projects-api.ts b/apps/dashboard/src/lib/projects-api.ts index 97ed9575..d29334f7 100644 --- a/apps/dashboard/src/lib/projects-api.ts +++ b/apps/dashboard/src/lib/projects-api.ts @@ -18,7 +18,7 @@ export interface Project { current_user_role: ProjectRole | null; } -export type ProjectTaskSourceType = "git" | "filesystem" | "demo"; +export type ProjectTaskSourceType = "git" | "filesystem" | "demo" | "published"; export type ProjectTaskSourceStatus = | "unconfigured" diff --git a/apps/dashboard/src/lib/run-configuration.ts b/apps/dashboard/src/lib/run-configuration.ts index 65ff9176..9aef70f0 100644 --- a/apps/dashboard/src/lib/run-configuration.ts +++ b/apps/dashboard/src/lib/run-configuration.ts @@ -16,7 +16,7 @@ export function shortModel(model: string): string { } /** - * SPEC-148: render a Task Run's adapter-reported configuration as a compact + * render a Task Run's adapter-reported configuration as a compact * `model · effort` string. The model is shown without its provider prefix * (see {@link shortModel}); absent effort renders as `—` (em dash); a run that * reported no configuration renders as a lone `—`. Monochrome data — never a @@ -43,7 +43,7 @@ export function formatRunExecutionFull( } /** - * SPEC-148: render a Batch Run's derived configuration summary. + * render a Batch Run's derived configuration summary. * * - uniform → the single `model · effort` pair; * - mixed → `Mixed · N configs`; diff --git a/apps/dashboard/src/lib/shallow-search-params.ts b/apps/dashboard/src/lib/shallow-search-params.ts new file mode 100644 index 00000000..359ab0fa --- /dev/null +++ b/apps/dashboard/src/lib/shallow-search-params.ts @@ -0,0 +1,47 @@ +/** + * Shallow URL search-param updates — no server round-trip. + * + * Next.js integrates the native History API: calling + * `window.history.replaceState` updates the URL and re-renders + * `useSearchParams()` consumers **without** re-fetching the page's RSC + * payload. `router.replace`, by contrast, re-runs the server component on + * every call — on a `force-dynamic` page like the trace detail view that + * means re-fetching the entire trace from the backend just to move a + * selection highlight. + * + * Use these for ephemeral UI state stored in the URL (selected span, active + * view/tab, search text). Use `router.push`/`router.replace` only when the + * server actually needs to re-render with the new params (pagination, + * sorting, filters on list pages). + * + * Client-only: touches `window`, so callers must be event handlers or + * effects, never render bodies. + */ + +/** Apply an arbitrary mutation to the current search params, shallowly. */ +export function updateSearchParamsShallow( + mutate: (params: URLSearchParams) => void, +): void { + const params = new URLSearchParams(window.location.search); + mutate(params); + const qs = params.toString(); + window.history.replaceState( + null, + "", + `${window.location.pathname}${qs ? `?${qs}` : ""}`, + ); +} + +/** Set (or, with null/empty, remove) a single search param, shallowly. */ +export function setSearchParamShallow( + key: string, + value: string | null, +): void { + updateSearchParamsShallow((params) => { + if (value) { + params.set(key, value); + } else { + params.delete(key); + } + }); +} diff --git a/apps/dashboard/src/lib/t-api-highlight.ts b/apps/dashboard/src/lib/t-api-highlight.ts index 76485183..1ff6f06f 100644 --- a/apps/dashboard/src/lib/t-api-highlight.ts +++ b/apps/dashboard/src/lib/t-api-highlight.ts @@ -6,7 +6,7 @@ * * NOTE on the method list: this is a *copy* of the SDK's * ``TEST_METHOD_NAMES`` (``packages/sdk/src/agent-task/checks/t.ts``), not an - * import — ``@apo/sdk/agent-task`` is a server-runtime entry (runTask, + * import — ``@apo-ai/sdk/agent-task`` is a server-runtime entry (runTask, * callJudge, adapters, …) that must never enter the browser bundle. Keeping * the two lists in sync is enforced by the test in * ``__tests__/t-api-highlight.test.ts``, which compares against the canonical diff --git a/apps/dashboard/src/lib/traces-api.ts b/apps/dashboard/src/lib/traces-api.ts index 2d93d145..0b11c4bc 100644 --- a/apps/dashboard/src/lib/traces-api.ts +++ b/apps/dashboard/src/lib/traces-api.ts @@ -40,7 +40,6 @@ interface TraceSummaryTransport { input_preview: string | null; output_preview: string | null; bookmarked: boolean; - is_public: boolean; } export interface TraceSummary { @@ -65,7 +64,6 @@ export interface TraceSummary { input_preview: string | null; output_preview: string | null; bookmarked: boolean; - is_public: boolean; } export type TraceDetail = SharedTraceDetail; @@ -175,7 +173,6 @@ function normalizeTraceSummary( input_preview: trace.input_preview, output_preview: trace.output_preview, bookmarked: trace.bookmarked, - is_public: trace.is_public, }; } @@ -290,28 +287,6 @@ export async function getTraceDetail( } } -/** - * Fetch a published trace without authentication. Mirrors {@link getTraceDetail} - * but hits the unauthenticated `/public/traces/{id}` endpoint, which the - * backend allows through its auth middleware and Next.js allows through its - * route middleware. Returns `null` on 404 (private / missing) so the page can - * render a friendly not-found state instead of throwing. - */ -export async function getPublicTrace(runId: string): Promise { - try { - const data = await apiClient( - `/public/traces/${runId}`, - NO_CACHE, - ); - return normalizeTraceDetail(data); - } catch (error) { - if (isApiError(error) && error.status === 404) { - return null; - } - throw error; - } -} - export const bulkDeleteTraces = (runIds: string[]): Promise => apiClient("/v1/runs/bulk-delete", { method: "POST", @@ -409,8 +384,3 @@ export const saveCorrection = ( method: "PATCH", body: { corrected_output: correctedOutput }, }); - -export const toggleVisibility = ( - runId: string, -): Promise<{ id: string; is_public: boolean }> => - apiClient(`/v1/runs/${runId}/visibility`, { method: "PATCH" }); diff --git a/apps/docs/Caddyfile b/apps/docs/Caddyfile new file mode 100644 index 00000000..ef137a0a --- /dev/null +++ b/apps/docs/Caddyfile @@ -0,0 +1,14 @@ +# Static file server for the docs container (SPEC-171). +# +# Serves the prebuilt Astro output at /srv. Markdown routes get the correct +# text/markdown content type so coding agents fetch clean content. No TLS, +# no auth, no proxying — the public Caddy in front of this handles ingress. + +:8080 { + root * /srv + encode zstd gzip + @markdown path *.md + header @markdown Content-Type "text/markdown; charset=utf-8" + header @markdown Cache-Control "public, max-age=60" + file_server +} diff --git a/apps/docs/Dockerfile b/apps/docs/Dockerfile new file mode 100644 index 00000000..abb4a739 --- /dev/null +++ b/apps/docs/Dockerfile @@ -0,0 +1,38 @@ +# Multi-stage static docs image (SPEC-171). +# +# Build stage compiles the Astro site + publishes versioned schemas behind the +# publication gate. Runtime stage contains only the built static output and a +# pinned Caddy static-file server — no source, no Node, no package manager, +# no secrets, no repository mount. +# +# Build context is the repo root (see docker-compose.public-docs.yml). + +FROM node:22-slim AS builder + +WORKDIR /app +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY apps/docs/package.json ./apps/docs/package.json +COPY apps/dashboard/package.json ./apps/dashboard/package.json +COPY packages/sdk/package.json ./packages/sdk/package.json +COPY packages/cli/package.json ./packages/cli/package.json +RUN npm install -g pnpm@11.3.0 && pnpm install --frozen-lockfile + +COPY apps/docs/ ./apps/docs/ +COPY contracts/ ./contracts/ + +WORKDIR /app/apps/docs +RUN pnpm build + +# --- runtime: pinned static server, built output only --- +FROM caddy:2.10.2-alpine + +# The official image sets cap_net_bind_service+ep on the binary so it can bind +# ports <1024. We bind :8080 only, and cap_drop:[ALL] empties the bounding set, +# which makes the kernel reject the exec ("operation not permitted"). Strip the +# file capability so the hardened container starts cleanly. +RUN apk add --no-cache libcap && setcap -r /usr/bin/caddy + +COPY --from=builder /app/apps/docs/dist /srv +COPY apps/docs/Caddyfile /etc/caddy/Caddyfile + +EXPOSE 8080 diff --git a/apps/docs/astro.config.mjs b/apps/docs/astro.config.mjs index f6a287ab..2e228ad7 100644 --- a/apps/docs/astro.config.mjs +++ b/apps/docs/astro.config.mjs @@ -9,7 +9,7 @@ import react from '@astrojs/react'; // dark/monochrome/sharp-cornered identity (design.md) is the next step. // https://starlight.astro.build/ export default defineConfig({ - site: 'https://apo.dev', + site: 'https://docs.test-apo.online', integrations: [ starlight({ title: 'apo', @@ -59,7 +59,7 @@ export default defineConfig({ items: [ { label: 'Overview', slug: 'reference/overview' }, { - label: '@apo/sdk/agent-task', + label: '@apo-ai/sdk/agent-task', collapsed: false, items: [ { label: 'Task API', slug: 'reference/task' }, @@ -71,7 +71,7 @@ export default defineConfig({ ], }, { - label: '@apo/sdk', + label: '@apo-ai/sdk', collapsed: false, items: [ { label: 'Tracing', slug: 'reference/tracing' }, @@ -97,11 +97,13 @@ export default defineConfig({ label: 'task', collapsed: false, items: [ + { label: 'task publish', slug: 'cli/task-publish' }, { label: 'task run', slug: 'cli/task-run' }, { label: 'task list', slug: 'cli/task-list' }, { label: 'task show', slug: 'cli/task-show' }, ], }, + { label: 'connect', slug: 'cli/connect' }, { label: 'runs', collapsed: false, diff --git a/apps/docs/package.json b/apps/docs/package.json index a923cdb0..bf9ddc87 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -6,7 +6,8 @@ "scripts": { "dev": "astro dev", "start": "astro dev", - "build": "astro build", + "build": "astro build && node scripts/copy-public-contracts.mjs && node scripts/verify-publication.mjs", + "verify": "node scripts/verify-publication.mjs", "preview": "astro preview", "astro": "astro" }, diff --git a/apps/docs/scripts/copy-public-contracts.mjs b/apps/docs/scripts/copy-public-contracts.mjs new file mode 100644 index 00000000..4ae86506 --- /dev/null +++ b/apps/docs/scripts/copy-public-contracts.mjs @@ -0,0 +1,36 @@ +/** + * copy-public-contracts.mjs — publish canonical versioned schemas into dist. + * + * Copies the Task Revision v1 JSON Schemas from contracts/ into the Astro + * build output so their `$id` URLs resolve on the public docs origin: + * + * contracts/task-revision/v1/manifest.schema.json + * → dist/specs/contracts/task-revision/v1/manifest.schema.json + * + * The source schemas already carry resolvable `$id` values under the public + * docs origin (see astro.config.mjs `site`), so this is a verbatim copy — no + * rewriting, no new dependency. + * + * Run after `astro build`, before verify-publication.mjs. + */ +import { copyFileSync, mkdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const docsRoot = join(here, ".."); +const repoRoot = join(docsRoot, "..", ".."); +const distDir = join(docsRoot, "dist"); + +const SOURCES = [ + ["contracts/task-revision/v1/manifest.schema.json", "specs/contracts/task-revision/v1/manifest.schema.json"], + ["contracts/task-revision/v1/case.schema.json", "specs/contracts/task-revision/v1/case.schema.json"], +]; + +for (const [relSource, relDest] of SOURCES) { + const src = join(repoRoot, relSource); + const dest = join(distDir, relDest); + mkdirSync(dirname(dest), { recursive: true }); + copyFileSync(src, dest); + console.log(`copied ${relSource} → dist/${relDest}`); +} diff --git a/apps/docs/scripts/verify-publication.mjs b/apps/docs/scripts/verify-publication.mjs new file mode 100644 index 00000000..7a5a5fe6 --- /dev/null +++ b/apps/docs/scripts/verify-publication.mjs @@ -0,0 +1,224 @@ +/** + * verify-publication.mjs — fail the build on publication drift. + * + * Runs after `astro build` + schema publication and asserts the five + * publication invariants (SPEC-171 acceptance tests 1-5): + * + * 1. No built artifact references the retired origin (https://apo.dev). + * 2. Every same-origin absolute URL in start.md resolves to a built file. + * 3. The two stale slugs are gone; the canonical slugs are present. + * 4. The landing-page Copy Prompt points at the live origin, not apo.dev + * or localhost. + * 5. The published schema `$id`s map to their exact built path on the + * public docs origin, and the file is byte-for-byte the source schema. + * + * Uses only Node built-ins — no new dependency. + * + * Usage: node apps/docs/scripts/verify-publication.mjs [distDir] + */ +import { readFileSync, readdirSync, statSync, existsSync } from "node:fs"; +import { dirname, join, relative, extname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const docsRoot = join(here, ".."); +const repoRoot = join(docsRoot, "..", ".."); + +const DOCS_ORIGIN = "https://docs.test-apo.online"; +const RETIRED_ORIGIN = "https://apo.dev"; + +const distDir = process.argv[2] + ? join(process.cwd(), process.argv[2]) + : join(docsRoot, "dist"); + +// --------------------------------------------------------------------------- +// 1. Reject the retired origin anywhere in the built output. +// --------------------------------------------------------------------------- +function walkFiles(dir) { + const out = []; + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) { + out.push(...walkFiles(full)); + } else { + out.push(full); + } + } + return out; +} + +const allFiles = existsSync(distDir) ? walkFiles(distDir) : []; +if (allFiles.length === 0) { + fail(`dist is empty or missing at ${distDir} — run the docs build first.`); +} + +for (const file of allFiles) { + // Binary assets (fonts, images) won't contain the origin string and are + // skipped to avoid dumping binary into the error message. + if (isBinaryExt(extname(file))) continue; + const text = readFileSync(file, "utf8"); + if (text.includes(RETIRED_ORIGIN)) { + fail( + `retired origin "${RETIRED_ORIGIN}" found in ${relative(repoRoot, file)} ` + + `— replace with the public docs origin.`, + ); + } +} + +// --------------------------------------------------------------------------- +// 2. Every same-origin absolute URL in start.md resolves to a built file. +// --------------------------------------------------------------------------- +const startMd = readFileSync(join(distDir, "start.md"), "utf8"); +const sameOriginUrls = extractSameOriginPaths(startMd, DOCS_ORIGIN); + +if (sameOriginUrls.length === 0) { + fail(`start.md contains no ${DOCS_ORIGIN} URLs — Copy Prompt is broken.`); +} + +for (const urlPath of sameOriginUrls) { + if (!resolveArtifact(distDir, urlPath)) { + fail( + `start.md links ${DOCS_ORIGIN}${urlPath} but no built artifact ` + + `resolves to it under ${relative(repoRoot, distDir)}.`, + ); + } +} + +// --------------------------------------------------------------------------- +// 3. The two stale slugs are gone; the canonical slugs are present. +// --------------------------------------------------------------------------- +const STALE = ["/reference/adapter-contract.md", "/sdk/tracing-integrations.md"]; +const CANONICAL = ["/reference/adapter.md", "/reference/tracing-integrations.md"]; + +for (const stale of STALE) { + if (startMd.includes(stale)) { + fail(`start.md still references stale slug "${stale}".`); + } +} +for (const good of CANONICAL) { + if (!startMd.includes(good)) { + fail(`start.md is missing canonical slug "${good}".`); + } +} + +// --------------------------------------------------------------------------- +// 4. The Copy Prompt on the landing page points at the live origin. +// --------------------------------------------------------------------------- +const indexHtml = readFileSync(join(distDir, "index.html"), "utf8"); +const copyPromptUrl = `${DOCS_ORIGIN}/start.md`; +if (!indexHtml.includes(copyPromptUrl)) { + fail( + `landing page Copy Prompt does not reference ${copyPromptUrl}.`, + ); +} +if (indexHtml.includes(RETIRED_ORIGIN)) { + fail(`landing page still references retired origin ${RETIRED_ORIGIN}.`); +} +if (indexHtml.includes("http://localhost:3000")) { + fail(`landing page still sends visitors to http://localhost:3000.`); +} + +// --------------------------------------------------------------------------- +// 5. Published schema $id values map to their exact built path. +// --------------------------------------------------------------------------- +const SCHEMA_MAP = [ + [ + "contracts/task-revision/v1/manifest.schema.json", + "specs/contracts/task-revision/v1/manifest.schema.json", + ], + [ + "contracts/task-revision/v1/case.schema.json", + "specs/contracts/task-revision/v1/case.schema.json", + ], +]; + +for (const [relSource, relDest] of SCHEMA_MAP) { + const dest = join(distDir, relDest); + if (!existsSync(dest)) { + fail(`schema artifact missing in dist: ${relDest}`); + } + const source = join(repoRoot, relSource); + const sourceText = readFileSync(source, "utf8"); + const destText = readFileSync(dest, "utf8"); + if (sourceText !== destText) { + fail( + `published schema ${relDest} is not byte-for-byte its source ${relSource}.`, + ); + } + const sourceId = JSON.parse(sourceText).$id; + const expectedId = `${DOCS_ORIGIN}/${relDest}`; + if (sourceId !== expectedId) { + fail( + `schema ${relSource} $id is "${sourceId}" but must be "${expectedId}" ` + + `to resolve on the public docs origin.`, + ); + } +} + +console.log("publication verify: ok"); +process.exit(0); + +// --------------------------------------------------------------------------- +// helpers +// --------------------------------------------------------------------------- +function fail(message) { + console.error(`publication verify: FAIL\n ${message}`); + process.exit(1); +} + +function isBinaryExt(ext) { + return [ + ".png", + ".jpg", + ".jpeg", + ".gif", + ".webp", + ".ico", + ".svg", + ".woff", + ".woff2", + ".ttf", + ".eot", + ".otf", + ].includes(ext.toLowerCase()); +} + +/** + * Extract same-origin absolute URL *paths* from markdown prose. + * + * Matches the docs origin followed by a path, stopping at whitespace, quotes, + * and brackets, then trims trailing prose punctuation (`.,;:!?`) so a URL + * immediately followed by a period/comma/parenthesis is parsed correctly. + * Query strings and fragments are stripped before returning the path. + */ +function extractSameOriginPaths(markdown, origin) { + const escaped = origin.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const re = new RegExp(`${escaped}([^\\s"'<>)\\]]*)`, "g"); + const paths = new Set(); + let match; + while ((match = re.exec(markdown)) !== null) { + let path = match[1].replace(/[?#].*$/, "").replace(/[.,;!?)]+$/, ""); + if (path === "") path = "/"; + paths.add(path); + } + return [...paths]; +} + +/** + * Map a URL path to a built artifact in dist. Returns the resolved file or + * null. Handles: exact files, `.md` routes, directory indexes, and the root. + */ +function resolveArtifact(dist, urlPath) { + const clean = urlPath.replace(/\/+$/, "") || "/"; + // Exact file (e.g. /start.md, /reference/assertions.md, /cli.md). + const exact = join(dist, clean); + if (existsSync(exact) && statSync(exact).isFile()) return exact; + // Directory index (e.g. /overview → /overview/index.html). + const index = join(dist, clean === "/" ? "" : clean, "index.html"); + if (existsSync(index)) return index; + // Root index. + if (clean === "/" && existsSync(join(dist, "index.html"))) { + return join(dist, "index.html"); + } + return null; +} diff --git a/apps/docs/src/components/CodeCard.astro b/apps/docs/src/components/CodeCard.astro index 71967484..2167bcd1 100644 --- a/apps/docs/src/components/CodeCard.astro +++ b/apps/docs/src/components/CodeCard.astro @@ -33,7 +33,7 @@ const { {verdict}
-
import {'{ task, test, turn }'} from '@apo/sdk/agent-task';
+	
import {'{ task, test, turn }'} from '@apo-ai/sdk/agent-task';
 
 export default task(
   'refund',
diff --git a/apps/docs/src/components/CopyPrompt.astro b/apps/docs/src/components/CopyPrompt.astro
index b59c629f..3fd81070 100644
--- a/apps/docs/src/components/CopyPrompt.astro
+++ b/apps/docs/src/components/CopyPrompt.astro
@@ -9,7 +9,7 @@
  *
  * Usage in .mdx:
  *   import CopyPrompt from '../../components/CopyPrompt.astro';
- *   
+ *   
  */
 interface Props {
 	text: string;
diff --git a/apps/docs/src/components/Header.astro b/apps/docs/src/components/Header.astro
index f180566c..ed421cde 100644
--- a/apps/docs/src/components/Header.astro
+++ b/apps/docs/src/components/Header.astro
@@ -32,6 +32,7 @@ const shouldRenderSearch =
 
+ Public Alpha
- Open app @@ -76,6 +76,24 @@ const shouldRenderSearch = margin: -0.25rem; min-width: 0; flex-shrink: 0; + align-items: center; + gap: 0.5rem; + } + + .alpha-badge { + display: inline-flex; + align-items: center; + padding: 0.1rem 0.5rem; + font-size: 0.625rem; + font-weight: 600; + letter-spacing: 0.04em; + color: var(--sl-color-gray-3); + background: var(--sl-color-gray-6); + border: 1px solid var(--sl-color-gray-5); + border-radius: 999px; + text-transform: uppercase; + white-space: nowrap; + flex-shrink: 0; } .section-tabs { diff --git a/apps/docs/src/components/LandingFeatures.astro b/apps/docs/src/components/LandingFeatures.astro index a6d3a43e..71be82d1 100644 --- a/apps/docs/src/components/LandingFeatures.astro +++ b/apps/docs/src/components/LandingFeatures.astro @@ -30,7 +30,7 @@
- - Open app -
+ Public Alpha

Opinionated agent testing framework.

@@ -155,6 +152,22 @@ const COPY_PROMPT_TEXT =